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

View File

@@ -0,0 +1,169 @@
import {
Menu,
Workspaces,
ClientTitle,
Media,
Notifications,
Volume,
Network,
Bluetooth,
BatteryLabel,
Clock,
SysTray,
// Custom Modules
Ram,
Cpu,
CpuTemp,
Storage,
Netstat,
KbInput,
Updates,
Submap,
Weather,
Power,
Hyprsunset,
Hypridle,
} from './exports';
import { WidgetContainer } from './shared/WidgetContainer';
import options from 'src/options';
import { App, Gtk } from 'astal/gtk3/index';
import Astal from 'gi://Astal?version=3.0';
import { bind, Variable } from 'astal';
import { gdkMonitorIdToHyprlandId, getLayoutForMonitor, isLayoutEmpty } from './utils/monitors';
const { layouts } = options.bar;
const { location } = options.theme.bar;
const { location: borderLocation } = options.theme.bar.border;
const widget = {
battery: (): JSX.Element => WidgetContainer(BatteryLabel()),
dashboard: (): JSX.Element => WidgetContainer(Menu()),
workspaces: (monitor: number): JSX.Element => WidgetContainer(Workspaces(monitor)),
windowtitle: (): JSX.Element => WidgetContainer(ClientTitle()),
media: (): JSX.Element => WidgetContainer(Media()),
notifications: (): JSX.Element => WidgetContainer(Notifications()),
volume: (): JSX.Element => WidgetContainer(Volume()),
network: (): JSX.Element => WidgetContainer(Network()),
bluetooth: (): JSX.Element => WidgetContainer(Bluetooth()),
clock: (): JSX.Element => WidgetContainer(Clock()),
systray: (): JSX.Element => WidgetContainer(SysTray()),
ram: (): JSX.Element => WidgetContainer(Ram()),
cpu: (): JSX.Element => WidgetContainer(Cpu()),
cputemp: (): JSX.Element => WidgetContainer(CpuTemp()),
storage: (): JSX.Element => WidgetContainer(Storage()),
netstat: (): JSX.Element => WidgetContainer(Netstat()),
kbinput: (): JSX.Element => WidgetContainer(KbInput()),
updates: (): JSX.Element => WidgetContainer(Updates()),
submap: (): JSX.Element => WidgetContainer(Submap()),
weather: (): JSX.Element => WidgetContainer(Weather()),
power: (): JSX.Element => WidgetContainer(Power()),
hyprsunset: (): JSX.Element => WidgetContainer(Hyprsunset()),
hypridle: (): JSX.Element => WidgetContainer(Hypridle()),
};
export const Bar = (() => {
const usedHyprlandMonitors = new Set<number>();
return (monitor: number): JSX.Element => {
const hyprlandMonitor = gdkMonitorIdToHyprlandId(monitor, usedHyprlandMonitors);
const computeVisibility = bind(layouts).as(() => {
const foundLayout = getLayoutForMonitor(hyprlandMonitor, layouts.get());
return !isLayoutEmpty(foundLayout);
});
const computeAnchor = bind(location).as((loc) => {
if (loc === 'bottom') {
return Astal.WindowAnchor.BOTTOM | Astal.WindowAnchor.LEFT | Astal.WindowAnchor.RIGHT;
}
return Astal.WindowAnchor.TOP | Astal.WindowAnchor.LEFT | Astal.WindowAnchor.RIGHT;
});
const computeLayer = Variable.derive([bind(options.theme.bar.layer), bind(options.tear)], (barLayer, tear) => {
if (tear && barLayer === 'overlay') {
return Astal.Layer.TOP;
}
const layerMap = {
overlay: Astal.Layer.OVERLAY,
top: Astal.Layer.TOP,
bottom: Astal.Layer.BOTTOM,
background: Astal.Layer.BACKGROUND,
};
return layerMap[barLayer];
});
const computeBorderLocation = bind(borderLocation).as((brdrLcn) =>
brdrLcn !== 'none' ? 'bar-panel withBorder' : 'bar-panel',
);
const leftBinding = Variable.derive([bind(layouts)], (currentLayouts) => {
const foundLayout = getLayoutForMonitor(hyprlandMonitor, currentLayouts);
return foundLayout.left
.filter((mod) => Object.keys(widget).includes(mod))
.map((w) => widget[w](hyprlandMonitor));
});
const middleBinding = Variable.derive([bind(layouts)], (currentLayouts) => {
const foundLayout = getLayoutForMonitor(hyprlandMonitor, currentLayouts);
return foundLayout.middle
.filter((mod) => Object.keys(widget).includes(mod))
.map((w) => widget[w](hyprlandMonitor));
});
const rightBinding = Variable.derive([bind(layouts)], (currentLayouts) => {
const foundLayout = getLayoutForMonitor(hyprlandMonitor, currentLayouts);
return foundLayout.right
.filter((mod) => Object.keys(widget).includes(mod))
.map((w) => widget[w](hyprlandMonitor));
});
return (
<window
name={`bar-${hyprlandMonitor}`}
namespace={`bar-${hyprlandMonitor}`}
className={'bar'}
application={App}
monitor={monitor}
visible={computeVisibility}
anchor={computeAnchor}
layer={computeLayer()}
exclusivity={Astal.Exclusivity.EXCLUSIVE}
onDestroy={() => {
computeLayer.drop();
leftBinding.drop();
middleBinding.drop();
rightBinding.drop();
}}
>
<box className={'bar-panel-container'}>
<centerbox
css={'padding: 1px;'}
hexpand
className={computeBorderLocation}
startWidget={
<box className={'box-left'} hexpand>
{leftBinding()}
</box>
}
centerWidget={
<box className={'box-center'} halign={Gtk.Align.CENTER}>
{middleBinding()}
</box>
}
endWidget={
<box className={'box-right'} halign={Gtk.Align.END}>
{rightBinding()}
</box>
}
/>
</box>
</window>
);
};
})();

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;
}

View File

@@ -0,0 +1,286 @@
import { Option } from 'src/components/settings/shared/Option';
import { Header } from 'src/components/settings/shared/Header';
import options from 'src/options';
import { Gtk } from 'astal/gtk3';
export const CustomModuleSettings = (): JSX.Element => {
return (
<scrollable
name={'Custom Modules'}
className="menu-theme-page customModules paged-container"
vscroll={Gtk.PolicyType.AUTOMATIC}
hscroll={Gtk.PolicyType.AUTOMATIC}
>
<box className="menu-theme-page paged-container" vertical>
{/* General Section */}
<Header title="General" />
<Option opt={options.bar.customModules.scrollSpeed} title="Scrolling Speed" type="number" />
{/* RAM Section */}
<Header title="RAM" />
<Option opt={options.theme.bar.buttons.modules.ram.enableBorder} title="Button Border" type="boolean" />
<Option opt={options.bar.customModules.ram.icon} title="Ram Icon" type="string" />
<Option opt={options.bar.customModules.ram.label} title="Show Label" type="boolean" />
<Option opt={options.theme.bar.buttons.modules.ram.spacing} title="Spacing" type="string" />
<Option
opt={options.bar.customModules.ram.labelType}
title="Label Type"
type="enum"
enums={['used/total', 'used', 'free', 'percentage']}
/>
<Option opt={options.bar.customModules.ram.round} title="Round" type="boolean" />
<Option
opt={options.bar.customModules.ram.pollingInterval}
title="Polling Interval"
type="number"
min={100}
max={60 * 24 * 1000}
increment={1000}
/>
<Option opt={options.bar.customModules.ram.leftClick} title="Left Click" type="string" />
<Option opt={options.bar.customModules.ram.rightClick} title="Right Click" type="string" />
<Option opt={options.bar.customModules.ram.middleClick} title="Middle Click" type="string" />
{/* CPU Section */}
<Header title="CPU" />
<Option opt={options.theme.bar.buttons.modules.cpu.enableBorder} title="Button Border" type="boolean" />
<Option opt={options.bar.customModules.cpu.icon} title="Cpu Icon" type="string" />
<Option opt={options.bar.customModules.cpu.label} title="Show Label" type="boolean" />
<Option opt={options.theme.bar.buttons.modules.cpu.spacing} title="Spacing" type="string" />
<Option opt={options.bar.customModules.cpu.round} title="Round" type="boolean" />
<Option
opt={options.bar.customModules.cpu.pollingInterval}
title="Polling Interval"
type="number"
min={100}
max={60 * 24 * 1000}
increment={1000}
/>
<Option opt={options.bar.customModules.cpu.leftClick} title="Left Click" type="string" />
<Option opt={options.bar.customModules.cpu.rightClick} title="Right Click" type="string" />
<Option opt={options.bar.customModules.cpu.middleClick} title="Middle Click" type="string" />
<Option opt={options.bar.customModules.cpu.scrollUp} title="Scroll Up" type="string" />
<Option opt={options.bar.customModules.cpu.scrollDown} title="Scroll Down" type="string" />
{/* CPU Temperature Section */}
<Header title="CPU Temperature" />
<Option
opt={options.theme.bar.buttons.modules.cpuTemp.enableBorder}
title="Button Border"
type="boolean"
/>
<Option
opt={options.bar.customModules.cpuTemp.sensor}
title="CPU Temperature Sensor"
subtitle="Wiki: https://hyprpanel.com/configuration/panel.html#custom-modules"
subtitleLink="https://hyprpanel.com/configuration/panel.html#custom-modules"
type="string"
/>
<Option
opt={options.bar.customModules.cpuTemp.unit}
title="CPU Temperature Unit"
type="enum"
enums={['imperial', 'metric']}
/>
<Option opt={options.bar.customModules.cpuTemp.showUnit} title="Show Unit" type="boolean" />
<Option opt={options.bar.customModules.cpuTemp.icon} title="Cpu Temperature Icon" type="string" />
<Option opt={options.bar.customModules.cpuTemp.label} title="Show Label" type="boolean" />
<Option opt={options.theme.bar.buttons.modules.cpuTemp.spacing} title="Spacing" type="string" />
<Option opt={options.bar.customModules.cpuTemp.round} title="Round" type="boolean" />
<Option
opt={options.bar.customModules.cpuTemp.pollingInterval}
title="Polling Interval"
type="number"
min={100}
max={60 * 24 * 1000}
increment={1000}
/>
<Option opt={options.bar.customModules.cpuTemp.leftClick} title="Left Click" type="string" />
<Option opt={options.bar.customModules.cpuTemp.rightClick} title="Right Click" type="string" />
<Option opt={options.bar.customModules.cpuTemp.middleClick} title="Middle Click" type="string" />
<Option opt={options.bar.customModules.cpuTemp.scrollUp} title="Scroll Up" type="string" />
<Option opt={options.bar.customModules.cpuTemp.scrollDown} title="Scroll Down" type="string" />
{/* Storage Section */}
<Header title="Storage" />
<Option
opt={options.theme.bar.buttons.modules.storage.enableBorder}
title="Button Border"
type="boolean"
/>
<Option opt={options.bar.customModules.storage.icon} title="Storage Icon" type="string" />
<Option opt={options.bar.customModules.storage.label} title="Show Label" type="boolean" />
<Option opt={options.theme.bar.buttons.modules.storage.spacing} title="Spacing" type="string" />
<Option
opt={options.bar.customModules.storage.labelType}
title="Label Type"
type="enum"
enums={['used/total', 'used', 'free', 'percentage']}
/>
<Option opt={options.bar.customModules.storage.round} title="Round" type="boolean" />
<Option
opt={options.bar.customModules.storage.pollingInterval}
title="Polling Interval"
type="number"
min={100}
max={60 * 24 * 1000}
increment={1000}
/>
<Option opt={options.bar.customModules.storage.leftClick} title="Left Click" type="string" />
<Option opt={options.bar.customModules.storage.rightClick} title="Right Click" type="string" />
<Option opt={options.bar.customModules.storage.middleClick} title="Middle Click" type="string" />
{/* Netstat Section */}
<Header title="Netstat" />
<Option
opt={options.theme.bar.buttons.modules.netstat.enableBorder}
title="Button Border"
type="boolean"
/>
<Option
opt={options.bar.customModules.netstat.networkInterface}
title="Network Interface"
subtitle="Wiki: https://hyprpanel.com/configuration/panel.html#custom-modules"
type="string"
/>
<Option
opt={options.bar.customModules.netstat.dynamicIcon}
title="Use Network Icon"
subtitle="If enabled, shows current network icon indicators instead of static icon"
type="boolean"
/>
<Option opt={options.bar.customModules.netstat.icon} title="Netstat Icon" type="string" />
<Option opt={options.bar.customModules.netstat.label} title="Show Label" type="boolean" />
<Option
opt={options.bar.customModules.netstat.rateUnit}
title="Rate Unit"
type="enum"
enums={['GiB', 'MiB', 'KiB', 'auto']}
/>
<Option opt={options.theme.bar.buttons.modules.netstat.spacing} title="Spacing" type="string" />
<Option
opt={options.bar.customModules.netstat.labelType}
title="Label Type"
type="enum"
enums={['full', 'in', 'out']}
/>
<Option opt={options.bar.customModules.netstat.round} title="Round" type="boolean" />
<Option
opt={options.bar.customModules.netstat.pollingInterval}
title="Polling Interval"
type="number"
min={100}
max={60 * 24 * 1000}
increment={1000}
/>
<Option opt={options.bar.customModules.netstat.leftClick} title="Left Click" type="string" />
<Option opt={options.bar.customModules.netstat.rightClick} title="Right Click" type="string" />
<Option opt={options.bar.customModules.netstat.middleClick} title="Middle Click" type="string" />
{/* Keyboard Layout Section */}
<Header title="Keyboard Layout" />
<Option
opt={options.theme.bar.buttons.modules.kbLayout.enableBorder}
title="Button Border"
type="boolean"
/>
<Option opt={options.bar.customModules.kbLayout.icon} title="Keyboard Layout Icon" type="string" />
<Option opt={options.bar.customModules.kbLayout.label} title="Show Label" type="boolean" />
<Option
opt={options.bar.customModules.kbLayout.labelType}
title="Label Type"
type="enum"
enums={['layout', 'code']}
/>
<Option opt={options.theme.bar.buttons.modules.kbLayout.spacing} title="Spacing" type="string" />
<Option opt={options.bar.customModules.kbLayout.leftClick} title="Left Click" type="string" />
<Option opt={options.bar.customModules.kbLayout.rightClick} title="Right Click" type="string" />
<Option opt={options.bar.customModules.kbLayout.middleClick} title="Middle Click" type="string" />
<Option opt={options.bar.customModules.kbLayout.scrollUp} title="Scroll Up" type="string" />
<Option opt={options.bar.customModules.kbLayout.scrollDown} title="Scroll Down" type="string" />
{/* Updates Section */}
<Header title="Updates" />
<Option
opt={options.theme.bar.buttons.modules.updates.enableBorder}
title="Button Border"
type="boolean"
/>
<Option
opt={options.bar.customModules.updates.updateCommand}
title="Check Updates Command"
type="string"
/>
<Option
opt={options.bar.customModules.updates.icon.pending}
title="Updates Available Icon"
type="string"
/>
<Option opt={options.bar.customModules.updates.icon.updated} title="No Updates Icon" type="string" />
<Option opt={options.bar.customModules.updates.label} title="Show Label" type="boolean" />
<Option opt={options.bar.customModules.updates.padZero} title="Pad with 0" type="boolean" />
<Option opt={options.theme.bar.buttons.modules.updates.spacing} title="Spacing" type="string" />
<Option
opt={options.bar.customModules.updates.pollingInterval}
title="Polling Interval"
subtitle="WARNING: Be careful of your package manager's rate limit."
type="number"
min={100}
max={60 * 24 * 1000}
increment={1000}
/>
<Option opt={options.bar.customModules.updates.leftClick} title="Left Click" type="string" />
<Option opt={options.bar.customModules.updates.rightClick} title="Right Click" type="string" />
<Option opt={options.bar.customModules.updates.middleClick} title="Middle Click" type="string" />
<Option opt={options.bar.customModules.updates.scrollUp} title="Scroll Up" type="string" />
<Option opt={options.bar.customModules.updates.scrollDown} title="Scroll Down" type="string" />
{/* Submap Section */}
<Header title="Submap" />
<Option
opt={options.theme.bar.buttons.modules.submap.enableBorder}
title="Button Border"
type="boolean"
/>
<Option
opt={options.bar.customModules.submap.showSubmapName}
title="Show Submap Name"
subtitle="Displays current submap name instead of Enabled/Disabled text."
type="boolean"
/>
<Option opt={options.bar.customModules.submap.enabledIcon} title="Enabled Icon" type="string" />
<Option opt={options.bar.customModules.submap.disabledIcon} title="Disabled Icon" type="string" />
<Option opt={options.bar.customModules.submap.enabledText} title="Enabled Text" type="string" />
<Option opt={options.bar.customModules.submap.disabledText} title="Disabled Text" type="string" />
<Option opt={options.bar.customModules.submap.label} title="Show Label" type="boolean" />
<Option opt={options.theme.bar.buttons.modules.submap.spacing} title="Spacing" type="string" />
<Option opt={options.bar.customModules.submap.leftClick} title="Left Click" type="string" />
<Option opt={options.bar.customModules.submap.rightClick} title="Right Click" type="string" />
<Option opt={options.bar.customModules.submap.middleClick} title="Middle Click" type="string" />
<Option opt={options.bar.customModules.submap.scrollUp} title="Scroll Up" type="string" />
<Option opt={options.bar.customModules.submap.scrollDown} title="Scroll Down" type="string" />
{/* Weather Section */}
<Header title="Weather" />
<Option
opt={options.theme.bar.buttons.modules.weather.enableBorder}
title="Button Border"
type="boolean"
/>
<Option opt={options.bar.customModules.weather.label} title="Show Label" type="boolean" />
<Option
opt={options.bar.customModules.weather.unit}
title="Units"
type="enum"
enums={['imperial', 'metric']}
/>
<Option opt={options.theme.bar.buttons.modules.weather.spacing} title="Spacing" type="string" />
<Option opt={options.bar.customModules.weather.leftClick} title="Left Click" type="string" />
<Option opt={options.bar.customModules.weather.rightClick} title="Right Click" type="string" />
<Option opt={options.bar.customModules.weather.middleClick} title="Middle Click" type="string" />
<Option opt={options.bar.customModules.weather.scrollUp} title="Scroll Up" type="string" />
<Option opt={options.bar.customModules.weather.scrollDown} title="Scroll Down" type="string" />
</box>
</scrollable>
);
};

View File

@@ -0,0 +1,164 @@
import { Option } from 'src/components/settings/shared/Option';
import { Header } from 'src/components/settings/shared/Header';
import options from 'src/options';
import { Gtk } from 'astal/gtk3';
export const CustomModuleTheme = (): JSX.Element => {
return (
<scrollable
name={'Custom Modules'}
className="menu-theme-page customModules paged-container"
vscroll={Gtk.PolicyType.AUTOMATIC}
hscroll={Gtk.PolicyType.AUTOMATIC}
vexpand={false}
>
<box vertical>
{/* RAM Module Section */}
<Header title="RAM" />
<Option opt={options.theme.bar.buttons.modules.ram.text} title="Text" type="color" />
<Option opt={options.theme.bar.buttons.modules.ram.icon} title="Icon" type="color" />
<Option opt={options.theme.bar.buttons.modules.ram.background} title="Label Background" type="color" />
<Option
opt={options.theme.bar.buttons.modules.ram.icon_background}
title="Icon Background"
subtitle="Applies a background color to the icon section of the button.\nRequires 'split' button styling."
type="color"
/>
<Option opt={options.theme.bar.buttons.modules.ram.border} title="Border" type="color" />
{/* CPU Module Section */}
<Header title="CPU" />
<Option opt={options.theme.bar.buttons.modules.cpu.text} title="Text" type="color" />
<Option opt={options.theme.bar.buttons.modules.cpu.icon} title="Icon" type="color" />
<Option opt={options.theme.bar.buttons.modules.cpu.background} title="Label Background" type="color" />
<Option
opt={options.theme.bar.buttons.modules.cpu.icon_background}
title="Icon Background"
subtitle="Applies a background color to the icon section of the button.\nRequires 'split' button styling."
type="color"
/>
<Option opt={options.theme.bar.buttons.modules.cpu.border} title="Border" type="color" />
{/* CPU Temperature Module Section */}
<Header title="CPU Temperature" />
<Option opt={options.theme.bar.buttons.modules.cpuTemp.text} title="Text" type="color" />
<Option opt={options.theme.bar.buttons.modules.cpuTemp.icon} title="Icon" type="color" />
<Option
opt={options.theme.bar.buttons.modules.cpuTemp.background}
title="Label Background"
type="color"
/>
<Option
opt={options.theme.bar.buttons.modules.cpuTemp.icon_background}
title="Icon Background"
subtitle="Applies a background color to the icon section of the button.\nRequires 'split' button styling."
type="color"
/>
<Option opt={options.theme.bar.buttons.modules.cpuTemp.border} title="Border" type="color" />
{/* Storage Module Section */}
<Header title="Storage" />
<Option opt={options.theme.bar.buttons.modules.storage.text} title="Text" type="color" />
<Option opt={options.theme.bar.buttons.modules.storage.icon} title="Icon" type="color" />
<Option
opt={options.theme.bar.buttons.modules.storage.background}
title="Label Background"
type="color"
/>
<Option
opt={options.theme.bar.buttons.modules.storage.icon_background}
title="Icon Background"
subtitle="Applies a background color to the icon section of the button.\nRequires 'split' button styling."
type="color"
/>
<Option opt={options.theme.bar.buttons.modules.storage.border} title="Border" type="color" />
{/* Netstat Module Section */}
<Header title="Netstat" />
<Option opt={options.theme.bar.buttons.modules.netstat.text} title="Text" type="color" />
<Option opt={options.theme.bar.buttons.modules.netstat.icon} title="Icon" type="color" />
<Option
opt={options.theme.bar.buttons.modules.netstat.background}
title="Label Background"
type="color"
/>
<Option
opt={options.theme.bar.buttons.modules.netstat.icon_background}
title="Icon Background"
subtitle="Applies a background color to the icon section of the button.\nRequires 'split' button styling."
type="color"
/>
<Option opt={options.theme.bar.buttons.modules.netstat.border} title="Border" type="color" />
{/* Keyboard Layout Module Section */}
<Header title="Keyboard Layout" />
<Option opt={options.theme.bar.buttons.modules.kbLayout.text} title="Text" type="color" />
<Option opt={options.theme.bar.buttons.modules.kbLayout.icon} title="Icon" type="color" />
<Option
opt={options.theme.bar.buttons.modules.kbLayout.background}
title="Label Background"
type="color"
/>
<Option
opt={options.theme.bar.buttons.modules.kbLayout.icon_background}
title="Icon Background"
subtitle="Applies a background color to the icon section of the button.\nRequires 'split' button styling."
type="color"
/>
<Option opt={options.theme.bar.buttons.modules.kbLayout.border} title="Border" type="color" />
{/* Updates Module Section */}
<Header title="Updates" />
<Option opt={options.theme.bar.buttons.modules.updates.text} title="Text" type="color" />
<Option opt={options.theme.bar.buttons.modules.updates.icon} title="Icon" type="color" />
<Option
opt={options.theme.bar.buttons.modules.updates.background}
title="Label Background"
type="color"
/>
<Option
opt={options.theme.bar.buttons.modules.updates.icon_background}
title="Icon Background"
subtitle="Applies a background color to the icon section of the button.\nRequires 'split' button styling."
type="color"
/>
<Option opt={options.theme.bar.buttons.modules.updates.border} title="Border" type="color" />
{/* Submap Module Section */}
<Header title="Submap" />
<Option opt={options.theme.bar.buttons.modules.submap.text} title="Text" type="color" />
<Option opt={options.theme.bar.buttons.modules.submap.icon} title="Icon" type="color" />
<Option
opt={options.theme.bar.buttons.modules.submap.background}
title="Label Background"
type="color"
/>
<Option
opt={options.theme.bar.buttons.modules.submap.icon_background}
title="Icon Background"
subtitle="Applies a background color to the icon section of the button.\nRequires 'split' button styling."
type="color"
/>
<Option opt={options.theme.bar.buttons.modules.submap.border} title="Border" type="color" />
{/* Weather Module Section */}
<Header title="Weather" />
<Option opt={options.theme.bar.buttons.modules.weather.icon} title="Icon" type="color" />
<Option opt={options.theme.bar.buttons.modules.weather.text} title="Text" type="color" />
<Option
opt={options.theme.bar.buttons.modules.weather.background}
title="Label Background"
type="color"
/>
<Option
opt={options.theme.bar.buttons.modules.weather.icon_background}
title="Icon Background"
subtitle="Applies a background color to the icon section of the button.\nRequires 'split' button styling."
type="color"
/>
<Option opt={options.theme.bar.buttons.modules.weather.border} title="Border" type="color" />
</box>
</scrollable>
);
};

View File

@@ -0,0 +1,93 @@
import { bind, Variable } from 'astal';
import { BarBoxChild, BarModule } from 'src/lib/types/bar';
import { BarButtonStyles } from 'src/lib/types/options';
import options from 'src/options';
const { style } = options.theme.bar.buttons;
const undefinedVar = Variable(undefined);
export const Module = ({
icon,
textIcon,
useTextIcon = bind(Variable(false)),
label,
tooltipText,
boxClass,
props = {},
showLabelBinding = bind(undefinedVar),
showLabel,
labelHook,
hook,
}: BarModule): BarBoxChild => {
const getIconWidget = (useTxtIcn: boolean): JSX.Element | undefined => {
let iconWidget: JSX.Element | undefined;
if (icon !== undefined && !useTxtIcn) {
iconWidget = <icon className={`txt-icon bar-button-icon module-icon ${boxClass}`} icon={icon} />;
} else if (textIcon !== undefined) {
iconWidget = <label className={`txt-icon bar-button-icon module-icon ${boxClass}`} label={textIcon} />;
}
return iconWidget;
};
const componentClass = Variable.derive(
[bind(style), showLabelBinding],
(style: BarButtonStyles, shwLabel: boolean) => {
const shouldShowLabel = shwLabel || showLabel;
const styleMap = {
default: 'style1',
split: 'style2',
wave: 'style3',
wave2: 'style3',
};
return `${boxClass} ${styleMap[style]} ${!shouldShowLabel ? 'no-label' : ''}`;
},
);
const componentChildren = Variable.derive(
[showLabelBinding, useTextIcon],
(showLabel: boolean, forceTextIcon: boolean): JSX.Element[] => {
const childrenArray = [];
const iconWidget = getIconWidget(forceTextIcon);
if (iconWidget !== undefined) {
childrenArray.push(iconWidget);
}
if (showLabel) {
childrenArray.push(
<label
className={`bar-button-label module-label ${boxClass}`}
label={label ?? ''}
setup={labelHook}
/>,
);
}
return childrenArray;
},
);
const component: JSX.Element = (
<box
tooltipText={tooltipText}
className={componentClass()}
setup={hook}
onDestroy={() => {
componentChildren.drop();
componentClass.drop();
}}
>
{componentChildren()}
</box>
);
return {
component,
tooltip_text: tooltipText,
isVisible: true,
boxClass,
props,
};
};

View File

@@ -0,0 +1,40 @@
import { BarBoxChild } from 'src/lib/types/bar';
import { Bind } from '../../../lib/types/variable';
import options from '../../../options';
import { bind } from 'astal';
const computeVisible = (child: BarBoxChild): Bind | boolean => {
if (child.isVis !== undefined) {
return bind(child.isVis);
}
return child.isVisible;
};
export const WidgetContainer = (child: BarBoxChild): JSX.Element => {
const buttonClassName = bind(options.theme.bar.buttons.style).as((style) => {
const styleMap = {
default: 'style1',
split: 'style2',
wave: 'style3',
wave2: 'style4',
};
const boxClassName = Object.hasOwnProperty.call(child, 'boxClass') ? child.boxClass : '';
return `bar_item_box_visible ${styleMap[style]} ${boxClassName}`;
});
if (child.isBox) {
return (
<box className={buttonClassName} visible={computeVisible(child)}>
{child.component}
</box>
);
}
return (
<button className={buttonClassName} visible={computeVisible(child)} {...child.props}>
{child.component}
</button>
);
};

View File

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

View File

@@ -0,0 +1,59 @@
import { App, Gdk } from 'astal/gtk3';
import { GtkWidget } from 'src/lib/types/widget';
import { calculateMenuPosition } from 'src/components/menus/shared/dropdown/locationHandler';
export const closeAllMenus = (): void => {
const menuWindows = App.get_windows()
.filter((w) => {
if (w.name) {
return /.*menu/.test(w.name);
}
return false;
})
.map((window) => window.name);
menuWindows.forEach((window) => {
if (window) {
App.get_window(window)?.set_visible(false);
}
});
};
export const openMenu = async (clicked: GtkWidget, event: Gdk.Event, window: string): Promise<void> => {
/*
* NOTE: We have to make some adjustments so the menu pops up relatively
* to the center of the button clicked. We don't want the menu to spawn
* offcenter depending on which edge of the button you click on.
* -------------
* To fix this, we take the x coordinate of the click within the button's bounds.
* If you click the left edge of a 100 width button, then the x axis will be 0
* and if you click the right edge then the x axis will be 100.
* -------------
* Then we divide the width of the button by 2 to get the center of the button and then get
* the offset by subtracting the clicked x coordinate. Then we can apply that offset
* to the x coordinate of the click relative to the screen to get the center of the
* icon click.
*/
try {
const middleOfButton = Math.floor(clicked.get_allocated_width() / 2);
const xAxisOfButtonClick = clicked.get_pointer()[0];
const middleOffset = middleOfButton - xAxisOfButtonClick;
const clickPos = event.get_root_coords();
const adjustedXCoord = clickPos[1] + middleOffset;
const coords = [adjustedXCoord, clickPos[2]];
await calculateMenuPosition(coords, window);
closeAllMenus();
App.toggle_window(window);
} catch (error) {
if (error instanceof Error) {
console.error(`Error calculating menu position: ${error.stack}`);
} else {
console.error(`Unknown error occurred: ${error}`);
}
}
};

View File

@@ -0,0 +1,169 @@
import { hyprlandService } from 'src/lib/constants/services';
import { Gdk } from 'astal/gtk3';
import { BarLayout, BarLayouts } from 'src/lib/types/options';
type GdkMonitors = {
[key: string]: {
key: string;
model: string;
used: boolean;
};
};
export const getLayoutForMonitor = (monitor: number, layouts: BarLayouts): BarLayout => {
const matchingKey = Object.keys(layouts).find((key) => key === monitor.toString());
const wildcard = Object.keys(layouts).find((key) => key === '*');
if (matchingKey) {
return layouts[matchingKey];
}
if (wildcard) {
return layouts[wildcard];
}
return {
left: ['dashboard', 'workspaces', 'windowtitle'],
middle: ['media'],
right: ['volume', 'network', 'bluetooth', 'battery', 'systray', 'clock', 'notifications'],
};
};
export const isLayoutEmpty = (layout: BarLayout): boolean => {
const isLeftSectionEmpty = !Array.isArray(layout.left) || layout.left.length === 0;
const isRightSectionEmpty = !Array.isArray(layout.right) || layout.right.length === 0;
const isMiddleSectionEmpty = !Array.isArray(layout.middle) || layout.middle.length === 0;
return isLeftSectionEmpty && isRightSectionEmpty && isMiddleSectionEmpty;
};
export function getGdkMonitors(): GdkMonitors {
const display = Gdk.Display.get_default();
if (display === null) {
console.error('Failed to get Gdk display.');
return {};
}
const numGdkMonitors = display.get_n_monitors();
const gdkMonitors: GdkMonitors = {};
for (let i = 0; i < numGdkMonitors; i++) {
const curMonitor = display.get_monitor(i);
if (curMonitor === null) {
console.warn(`Monitor at index ${i} is null.`);
continue;
}
const model = curMonitor.get_model() || '';
const geometry = curMonitor.get_geometry();
const scaleFactor = curMonitor.get_scale_factor();
const key = `${model}_${geometry.width}x${geometry.height}_${scaleFactor}`;
gdkMonitors[i] = { key, model, used: false };
}
return gdkMonitors;
}
/**
* NOTE: Some more funky stuff being done by GDK.
* We render windows/bar based on the monitor ID. So if you have 3 monitors, then your
* monitor IDs will be [0, 1, 2]. Hyprland will NEVER change what ID belongs to what monitor.
*
* So if hyprland determines id 0 = DP-1, even after you unplug, shut off or restart your monitor,
* the id 0 will ALWAYS be DP-1.
*
* However, GDK (the righteous genius that it is) will change the order of ID anytime your monitor
* setup is changed. So if you unplug your monitor and plug it back it, it now becomes the last id.
* So if DP-1 was id 0 and you unplugged it, it will reconfigure to id 2. This sucks because now
* there's a mismtach between what GDK determines the monitor is at id 2 and what Hyprland determines
* is at id 2.
*
* So for that reason, we need to redirect the input `monitor` that the Bar module takes in, to the
* proper Hyprland monitor. So when monitor id 0 comes in, we need to find what the id of that monitor
* is being determined as by Hyprland so the bars show up on the right monitors.
*
* Since GTK3 doesn't contain connection names and only monitor models, we have to make the best guess
* in the case that there are multiple models in the same resolution with the same scale. We find the
* 'right' monitor by checking if the model matches along with the resolution and scale. If monitor at
* ID 0 for GDK is being reported as 'MSI MAG271CQR' we find the same model in the Hyprland monitor list
* and check if the resolution and scaling is the same... if it is then we determine it's a match.
*
* The edge-case that we just can't handle is if you have the same monitors in the same resolution at the same
* scale. So if you've got 2 'MSI MAG271CQR' monitors at 2560x1440 at scale 1, then we just match the first
* monitor in the list as the first match and then the second 'MSI MAG271CQR' as a match in the 2nd iteration.
* You may have the bar showing up on the wrong one in this case because we don't know what the connector id
* is of either of these monitors (DP-1, DP-2) which are unique values - as these are only in GTK4.
*
* Keep in mind though, this is ONLY an issue if you change your monitor setup by plugging in a new one, restarting
* an existing one or shutting it off.
*
* If your monitors aren't changed in the current session you're in then none of this safeguarding is relevant.
*
* Fun stuff really... :facepalm:
*/
export const gdkMonitorIdToHyprlandId = (monitor: number, usedHyprlandMonitors: Set<number>): number => {
const gdkMonitors = getGdkMonitors();
if (Object.keys(gdkMonitors).length === 0) {
return monitor;
}
// Get the GDK monitor for the given monitor index
const gdkMonitor = gdkMonitors[monitor];
// First pass: Strict matching including the monitor index (i.e., hypMon.id === monitor + resolution+scale criteria)
const directMatch = hyprlandService.get_monitors().find((hypMon) => {
const isVertical = hypMon?.transform !== undefined ? hypMon.transform % 2 !== 0 : false;
const width = isVertical ? hypMon.height : hypMon.width;
const height = isVertical ? hypMon.width : hypMon.height;
const hyprlandKey = `${hypMon.model}_${width}x${height}_${hypMon.scale}`;
return gdkMonitor.key.startsWith(hyprlandKey) && !usedHyprlandMonitors.has(hypMon.id) && hypMon.id === monitor;
});
if (directMatch) {
usedHyprlandMonitors.add(directMatch.id);
return directMatch.id;
}
// Second pass: Relaxed matching without considering the monitor index
const hyprlandMonitor = hyprlandService.get_monitors().find((hypMon) => {
const isVertical = hypMon?.transform !== undefined ? hypMon.transform % 2 !== 0 : false;
const width = isVertical ? hypMon.height : hypMon.width;
const height = isVertical ? hypMon.width : hypMon.height;
const hyprlandKey = `${hypMon.model}_${width}x${height}_${hypMon.scale}`;
return gdkMonitor.key.startsWith(hyprlandKey) && !usedHyprlandMonitors.has(hypMon.id);
});
if (hyprlandMonitor) {
usedHyprlandMonitors.add(hyprlandMonitor.id);
return hyprlandMonitor.id;
}
// Fallback: Find the first available monitor ID that hasn't been used
const fallbackMonitor = hyprlandService.get_monitors().find((hypMon) => !usedHyprlandMonitors.has(hypMon.id));
if (fallbackMonitor) {
usedHyprlandMonitors.add(fallbackMonitor.id);
return fallbackMonitor.id;
}
// Ensure we return a valid monitor ID that actually exists
for (let i = 0; i < hyprlandService.get_monitors().length; i++) {
if (!usedHyprlandMonitors.has(i)) {
usedHyprlandMonitors.add(i);
return i;
}
}
// As a last resort, return the original monitor index if no unique monitor can be found
console.warn(`Returning original monitor index as a last resort: ${monitor}`);
return monitor;
};

View File

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

View File

@@ -0,0 +1,40 @@
import { bind } from 'astal';
import { Gtk } from 'astal/gtk3';
import AstalWp from 'gi://AstalWp?version=0.1';
import options from 'src/options';
const { raiseMaximumVolume } = options.menus.volume;
export const Slider = ({ device, type }: SliderProps): JSX.Element => {
return (
<box vertical>
<label
className={`menu-active ${type}`}
halign={Gtk.Align.START}
truncate
expand
wrap
label={bind(device, 'description').as((description) => description ?? `Unknown ${type} Device`)}
/>
<slider
value={bind(device, 'volume')}
className={`menu-active-slider menu-slider ${type}`}
drawValue={false}
hexpand
min={0}
max={type === 'playback' ? bind(raiseMaximumVolume).as((raise) => (raise ? 1.5 : 1)) : 1}
onDragged={({ value, dragging }) => {
if (dragging) {
device.volume = value;
device.mute = false;
}
}}
/>
</box>
);
};
interface SliderProps {
device: AstalWp.Endpoint;
type: 'playback' | 'input';
}

View File

@@ -0,0 +1,39 @@
import { bind, Variable } from 'astal';
import { Gtk } from 'astal/gtk3';
import { isPrimaryClick } from 'src/lib/utils';
import { getIcon } from '../../utils';
import AstalWp from 'gi://AstalWp?version=0.1';
export const SliderIcon = ({ type, device }: SliderIconProps): JSX.Element => {
const iconBinding = Variable.derive([bind(device, 'volume'), bind(device, 'mute')], (volume, isMuted) => {
const iconType = type === 'playback' ? 'spkr' : 'mic';
const effectiveVolume = volume > 0 ? volume : 100;
const mutedState = volume > 0 ? isMuted : true;
return getIcon(effectiveVolume, mutedState)[iconType];
});
return (
<button
className={bind(device, 'mute').as((isMuted) => `menu-active-button ${type} ${isMuted ? 'muted' : ''}`)}
vexpand={false}
valign={Gtk.Align.END}
onClick={(_, event) => {
if (isPrimaryClick(event)) {
device.mute = !device.mute;
}
}}
onDestroy={() => {
iconBinding.drop();
}}
>
<icon className={`menu-active-icon ${type}`} icon={iconBinding()} />
</button>
);
};
interface SliderIconProps {
type: 'playback' | 'input';
device: AstalWp.Endpoint;
}

View File

@@ -0,0 +1,18 @@
import { bind } from 'astal';
import { Gtk } from 'astal/gtk3';
import AstalWp from 'gi://AstalWp?version=0.1';
export const SliderPercentage = ({ type, device }: SliderPercentageProps): JSX.Element => {
return (
<label
className={`menu-active-percentage ${type}`}
valign={Gtk.Align.END}
label={bind(device, 'volume').as((vol) => `${Math.round(vol * 100)}%`)}
/>
);
};
interface SliderPercentageProps {
type: 'playback' | 'input';
device: AstalWp.Endpoint;
}

View File

@@ -0,0 +1,21 @@
import AstalWp from 'gi://AstalWp?version=0.1';
import { SliderIcon } from './SliderIcon';
import { Slider } from './Slider';
import { SliderPercentage } from './SliderPercentage';
export const ActiveDevice = ({ type, device }: ActiveDeviceProps): JSX.Element => {
return (
<box className={`menu-active-container ${type}`} vertical>
<box className={`menu-slider-container ${type}`}>
<SliderIcon type={type} device={device} />
<Slider type={type} device={device} />
<SliderPercentage type={type} device={device} />
</box>
</box>
);
};
interface ActiveDeviceProps {
type: 'playback' | 'input';
device: AstalWp.Endpoint;
}

View File

@@ -0,0 +1,34 @@
import { Gtk } from 'astal/gtk3';
import { ActiveDevice } from './device/index.js';
import { audioService } from 'src/lib/constants/services.js';
import { BindableChild } from 'astal/gtk3/astalify.js';
export const SelectedDevices = (): JSX.Element => {
const Header = (): JSX.Element => (
<box className={'menu-label-container volume selected'} halign={Gtk.Align.FILL}>
<label className={'menu-label audio volume'} halign={Gtk.Align.START} hexpand label={'Volume'} />
</box>
);
const ActiveDeviceContainer = ({ children }: ActiveDeviceContainerProps): JSX.Element => {
return (
<box className={'menu-items-section selected'} vertical>
{children}
</box>
);
};
return (
<box className={'menu-section-container volume'} vertical>
<Header />
<ActiveDeviceContainer>
<ActiveDevice type={'playback'} device={audioService.defaultSpeaker} />
<ActiveDevice type={'input'} device={audioService.defaultMicrophone} />
</ActiveDeviceContainer>
</box>
);
};
interface ActiveDeviceContainerProps {
children?: BindableChild | BindableChild[];
}

View File

@@ -0,0 +1,52 @@
import { Gtk } from 'astal/gtk3';
import AstalWp from 'gi://AstalWp?version=0.1';
import { isPrimaryClick } from 'src/lib/utils';
import { bind } from 'astal';
const DeviceIcon = ({ device, type, icon }: AudioDeviceProps): JSX.Element => {
return (
<label
className={bind(device, 'isDefault').as((isDefault) => {
return `menu-button-icon ${isDefault ? 'active' : ''} ${type} txt-icon`;
})}
label={icon}
/>
);
};
const DeviceName = ({ device, type }: Omit<AudioDeviceProps, 'icon'>): JSX.Element => {
return (
<label
truncate
wrap
className={bind(device, 'description').as((currentDesc) =>
device.description === currentDesc ? `menu-button-name active ${type}` : `menu-button-name ${type}`,
)}
label={device.description}
/>
);
};
export const AudioDevice = ({ device, type, icon }: AudioDeviceProps): JSX.Element => {
return (
<button
className={`menu-button audio ${type} ${device.id}`}
onClick={(_, event) => {
if (isPrimaryClick(event)) {
device.set_is_default(true);
}
}}
>
<box halign={Gtk.Align.START}>
<DeviceIcon device={device} type={type} icon={icon} />
<DeviceName device={device} type={type} />
</box>
</button>
);
};
interface AudioDeviceProps {
device: AstalWp.Endpoint;
type: 'playback' | 'input';
icon: string;
}

View File

@@ -0,0 +1,14 @@
import { Gtk } from 'astal/gtk3';
export const Header = ({ type, label }: HeaderProps): JSX.Element => {
return (
<box className={`menu-label-container ${type}`} halign={Gtk.Align.FILL}>
<label className={`menu-label audio ${type}`} halign={Gtk.Align.START} hexpand label={label} />
</box>
);
};
interface HeaderProps {
type: 'playback' | 'input';
label: string;
}

View File

@@ -0,0 +1,24 @@
import { audioService } from 'src/lib/constants/services.js';
import { bind } from 'astal/binding.js';
import { AudioDevice } from './Device';
import { NotFoundButton } from './NotFoundButton';
export const InputDevices = (): JSX.Element => {
const inputDevices = bind(audioService, 'microphones');
return (
<box className={'menu-items-section input'} vertical>
<box className={'menu-container input'} vertical>
{inputDevices.as((devices) => {
if (!devices || devices.length === 0) {
return <NotFoundButton type={'input'} />;
}
return devices.map((device) => {
return <AudioDevice device={device} type={'input'} icon={''} />;
});
})}
</box>
</box>
);
};

View File

@@ -0,0 +1,13 @@
import { Gtk } from 'astal/gtk3';
export const NotFoundButton = ({ type }: { type: string }): JSX.Element => {
return (
<button className={`menu-unfound-button ${type}`} sensitive={false}>
<box>
<box halign={Gtk.Align.START}>
<label className={`menu-button-name ${type}`} label={`No ${type} devices found...`} />
</box>
</box>
</button>
);
};

View File

@@ -0,0 +1,24 @@
import { audioService } from 'src/lib/constants/services.js';
import { bind } from 'astal/binding.js';
import { AudioDevice } from './Device';
import { NotFoundButton } from './NotFoundButton';
export const PlaybackDevices = (): JSX.Element => {
const playbackDevices = bind(audioService, 'speakers');
return (
<box className={'menu-items-section playback'} vertical>
<box className={'menu-container playback'} vertical>
{playbackDevices.as((devices) => {
if (!devices || devices.length === 0) {
return <NotFoundButton type={'playback'} />;
}
return devices.map((device) => {
return <AudioDevice device={device} type={'playback'} icon={''} />;
});
})}
</box>
</box>
);
};

View File

@@ -0,0 +1,15 @@
import { PlaybackDevices } from './PlaybackDevices.js';
import { InputDevices } from './InputDevices.js';
import { Header } from './Header.js';
export const AvailableDevices = (): JSX.Element => {
return (
<box vertical className={'menu-section-container playback'}>
<Header type={'playback'} label={'Playback Device'} />
<PlaybackDevices />
<Header type={'input'} label={'Input Device'} />
<InputDevices />
</box>
);
};

View File

@@ -0,0 +1,23 @@
import DropdownMenu from '../shared/dropdown/index.js';
import { SelectedDevices } from './active/index.js';
import options from 'src/options.js';
import { bind } from 'astal/binding.js';
import { Gtk } from 'astal/gtk3';
import { AvailableDevices } from './available/index.js';
import { RevealerTransitionMap } from 'src/lib/constants/options.js';
export default (): JSX.Element => {
return (
<DropdownMenu
name="audiomenu"
transition={bind(options.menus.transition).as((transition) => RevealerTransitionMap[transition])}
>
<box className={'menu-items audio'} halign={Gtk.Align.FILL} hexpand>
<box className={'menu-items-container audio'} halign={Gtk.Align.FILL} vertical hexpand>
<SelectedDevices />
<AvailableDevices />
</box>
</box>
</DropdownMenu>
);
};

View File

@@ -0,0 +1,40 @@
const speakerIcons = {
101: 'audio-volume-overamplified-symbolic',
66: 'audio-volume-high-symbolic',
34: 'audio-volume-medium-symbolic',
1: 'audio-volume-low-symbolic',
0: 'audio-volume-muted-symbolic',
} as const;
const inputIcons = {
101: 'microphone-sensitivity-high-symbolic',
66: 'microphone-sensitivity-high-symbolic',
34: 'microphone-sensitivity-medium-symbolic',
1: 'microphone-sensitivity-low-symbolic',
0: 'microphone-disabled-symbolic',
};
type IconVolumes = keyof typeof speakerIcons;
/**
* Retrieves the appropriate speaker and microphone icons based on the audio volume and mute status.
*
* This function determines the correct icons for both the speaker and microphone based on the provided audio volume and mute status.
* It uses predefined thresholds to select the appropriate icons from the `speakerIcons` and `inputIcons` objects.
*
* @param audioVol The current audio volume as a number between 0 and 1.
* @param isMuted A boolean indicating whether the audio is muted.
*
* @returns An object containing the speaker and microphone icons.
*/
const getIcon = (audioVol: number, isMuted: boolean): Record<string, string> => {
const thresholds: IconVolumes[] = [101, 66, 34, 1, 0];
const icon = isMuted ? 0 : thresholds.find((threshold) => threshold <= audioVol * 100) || 0;
return {
spkr: speakerIcons[icon],
mic: inputIcons[icon],
};
};
export { getIcon };

View File

@@ -0,0 +1,9 @@
import { Gtk } from 'astal/gtk3';
export const BluetoothDisabled = (): JSX.Element => {
return (
<box className={'bluetooth-items'} vertical expand valign={Gtk.Align.CENTER} halign={Gtk.Align.CENTER}>
<label className={'bluetooth-disabled dim'} hexpand label={'Bluetooth is disabled'} />
</box>
);
};

View File

@@ -0,0 +1,17 @@
import AstalBluetooth from 'gi://AstalBluetooth?version=0.1';
import { DeviceControls } from './controls';
import { BluetoothDevice } from './device';
export const DeviceListItem = ({ btDevice, connectedDevices }: DeviceListItemProps): JSX.Element => {
return (
<box>
<BluetoothDevice device={btDevice} connectedDevices={connectedDevices} />
<DeviceControls device={btDevice} connectedDevices={connectedDevices} />
</box>
);
};
interface DeviceListItemProps {
btDevice: AstalBluetooth.Device;
connectedDevices: string[];
}

View File

@@ -0,0 +1,10 @@
import { Gtk } from 'astal/gtk3';
export const NoBluetoothDevices = (): JSX.Element => {
return (
<box className={'bluetooth-items'} vertical expand valign={Gtk.Align.CENTER} halign={Gtk.Align.CENTER}>
<label className={'no-bluetooth-devices dim'} hexpand label={'No devices currently found'} />
<label className={'search-bluetooth-label dim'} hexpand label={"Press '󰑐' to search"} />
</box>
);
};

View File

@@ -0,0 +1,20 @@
import { Binding } from 'astal';
import { ButtonProps } from 'astal/gtk3/widget';
export const ActionButton = ({ name = '', tooltipText = '', label = '', ...props }: ActionButtonProps): JSX.Element => {
return (
<button className={`menu-icon-button ${name} bluetooth`} {...props}>
<label
className={`menu-icon-button-label ${name} bluetooth txt-icon`}
tooltipText={tooltipText}
label={label}
/>
</button>
);
};
interface ActionButtonProps extends ButtonProps {
name: string;
tooltipText: string | Binding<string>;
label: string | Binding<string>;
}

View File

@@ -0,0 +1,29 @@
import { bind } from 'astal';
import { ActionButton } from './ActionButton';
import { isPrimaryClick } from 'src/lib/utils';
import AstalBluetooth from 'gi://AstalBluetooth?version=0.1';
export const ConnectButton = ({ device }: ConnectButtonProps): JSX.Element => {
return (
<ActionButton
name={'disconnect'}
tooltipText={bind(device, 'connected').as((connected) => (connected ? 'Disconnect' : 'Connect'))}
label={bind(device, 'connected').as((connected) => (connected ? '󱘖' : ''))}
onClick={(_, self) => {
if (isPrimaryClick(self) && device.connected) {
device.disconnect_device((res) => {
console.info(res);
});
} else {
device.connect_device((res) => {
console.info(res);
});
}
}}
/>
);
};
interface ConnectButtonProps {
device: AstalBluetooth.Device;
}

View File

@@ -0,0 +1,23 @@
import { ActionButton } from './ActionButton';
import { isPrimaryClick } from 'src/lib/utils';
import AstalBluetooth from 'gi://AstalBluetooth?version=0.1';
import { forgetBluetoothDevice } from '../helpers';
export const ForgetButton = ({ device }: ForgetButtonProps): JSX.Element => {
return (
<ActionButton
name={'delete'}
tooltipText={'Forget'}
label={'󰆴'}
onClick={(_, self) => {
if (isPrimaryClick(self)) {
forgetBluetoothDevice(device);
}
}}
/>
);
};
interface ForgetButtonProps {
device: AstalBluetooth.Device;
}

View File

@@ -0,0 +1,29 @@
import { bind } from 'astal';
import { ActionButton } from './ActionButton';
import { isPrimaryClick } from 'src/lib/utils';
import AstalBluetooth from 'gi://AstalBluetooth?version=0.1';
export const PairButton = ({ device }: PairButtonProps): JSX.Element => {
return (
<ActionButton
name={'unpair'}
tooltipText={bind(device, 'paired').as((paired) => (paired ? 'Unpair' : 'Pair'))}
label={bind(device, 'paired').as((paired) => (paired ? '' : ''))}
onClick={(_, self) => {
if (!isPrimaryClick(self)) {
return;
}
if (device.paired) {
device.pair();
} else {
device.cancel_pairing();
}
}}
/>
);
};
interface PairButtonProps {
device: AstalBluetooth.Device;
}

View File

@@ -0,0 +1,23 @@
import { bind } from 'astal';
import { ActionButton } from './ActionButton';
import { isPrimaryClick } from 'src/lib/utils';
import AstalBluetooth from 'gi://AstalBluetooth?version=0.1';
export const TrustButton = ({ device }: TrustButtonProps): JSX.Element => {
return (
<ActionButton
name={'untrust'}
tooltipText={bind(device, 'trusted').as((trusted) => (trusted ? 'Untrust' : 'Trust'))}
label={bind(device, 'trusted').as((trusted) => (trusted ? '' : '󱖡'))}
onClick={(_, self) => {
if (isPrimaryClick(self)) {
device.set_trusted(!device.trusted);
}
}}
/>
);
};
interface TrustButtonProps {
device: AstalBluetooth.Device;
}

View File

@@ -0,0 +1,26 @@
import { Gtk } from 'astal/gtk3';
import AstalBluetooth from 'gi://AstalBluetooth?version=0.1';
import { PairButton } from './PairButton';
import { ConnectButton } from './ConnectButton';
import { TrustButton } from './TrustButton';
import { ForgetButton } from './ForgetButton';
export const DeviceControls = ({ device, connectedDevices }: DeviceControlsProps): JSX.Element => {
if (!connectedDevices.includes(device.address)) {
return <box />;
}
return (
<box valign={Gtk.Align.START} className={'bluetooth-controls'}>
<PairButton device={device} />
<ConnectButton device={device} />
<TrustButton device={device} />
<ForgetButton device={device} />
</box>
);
};
interface DeviceControlsProps {
device: AstalBluetooth.Device;
connectedDevices: string[];
}

View File

@@ -0,0 +1,22 @@
import { bind } from 'astal';
import { Gtk } from 'astal/gtk3';
import AstalBluetooth from 'gi://AstalBluetooth?version=0.1';
import { getBluetoothIcon } from '../../utils';
export const DeviceIcon = ({ device, connectedDevices }: DeviceIconProps): JSX.Element => {
return (
<label
valign={Gtk.Align.START}
className={bind(device, 'address').as(
(address) =>
`menu-button-icon bluetooth ${connectedDevices.includes(address) ? 'active' : ''} txt-icon`,
)}
label={bind(device, 'icon').as((icon) => getBluetoothIcon(`${icon}-symbolic`))}
/>
);
};
interface DeviceIconProps {
device: AstalBluetooth.Device;
connectedDevices: string[];
}

View File

@@ -0,0 +1,20 @@
import { bind } from 'astal';
import { Gtk } from 'astal/gtk3';
import AstalBluetooth from 'gi://AstalBluetooth?version=0.1';
export const DeviceName = ({ device }: DeviceNameProps): JSX.Element => {
return (
<label
valign={Gtk.Align.CENTER}
halign={Gtk.Align.START}
className="menu-button-name bluetooth"
truncate
wrap
label={bind(device, 'alias')}
/>
);
};
interface DeviceNameProps {
device: AstalBluetooth.Device;
}

View File

@@ -0,0 +1,32 @@
import { bind, Variable } from 'astal';
import { Gtk } from 'astal/gtk3';
import AstalBluetooth from 'gi://AstalBluetooth?version=0.1';
export const DeviceStatus = ({ device }: DeviceStatusProps): JSX.Element => {
const revealerBinding = Variable.derive(
[bind(device, 'connected'), bind(device, 'paired')],
(connected, paired) => {
return connected || paired;
},
);
return (
<revealer
halign={Gtk.Align.START}
revealChild={revealerBinding()}
onDestroy={() => {
revealerBinding.drop();
}}
>
<label
halign={Gtk.Align.START}
className={'connection-status dim'}
label={bind(device, 'connected').as((connected) => (connected ? 'Connected' : 'Paired'))}
/>
</revealer>
);
};
interface DeviceStatusProps {
device: AstalBluetooth.Device;
}

View File

@@ -0,0 +1,50 @@
import { Gtk } from 'astal/gtk3';
import AstalBluetooth from 'gi://AstalBluetooth?version=0.1';
import Spinner from 'src/components/shared/Spinner';
import { isPrimaryClick } from 'src/lib/utils';
import { bind } from 'astal';
import { DeviceIcon } from './DeviceIcon';
import { DeviceName } from './DeviceName';
import { DeviceStatus } from './DeviceStatus';
export const BluetoothDevice = ({ device, connectedDevices }: BluetoothDeviceProps): JSX.Element => {
const IsConnectingSpinner = (): JSX.Element => {
return (
<revealer revealChild={bind(device, 'connecting')}>
<Spinner valign={Gtk.Align.START} className="spinner bluetooth" />
</revealer>
);
};
return (
<button
hexpand
className={`bluetooth-element-item ${device}`}
onClick={(_, event) => {
if (!connectedDevices.includes(device.address) && isPrimaryClick(event)) {
device.connect_device((res) => {
console.info(res);
});
}
}}
>
<box>
<box hexpand halign={Gtk.Align.START} className="menu-button-container">
<DeviceIcon device={device} connectedDevices={connectedDevices} />
<box vertical valign={Gtk.Align.CENTER}>
<DeviceName device={device} />
<DeviceStatus device={device} />
</box>
</box>
<box halign={Gtk.Align.END}>
<IsConnectingSpinner />
</box>
</box>
</button>
);
};
interface BluetoothDeviceProps {
device: AstalBluetooth.Device;
connectedDevices: string[];
}

View File

@@ -0,0 +1,60 @@
import { execAsync } from 'astal';
import AstalBluetooth from 'gi://AstalBluetooth?version=0.1';
import { bluetoothService } from 'src/lib/constants/services';
/**
* Retrieves the list of available Bluetooth devices.
*
* This function filters and sorts the list of Bluetooth devices from the `bluetoothService`.
* It excludes devices with a null name and sorts the devices based on their connection and pairing status.
*
* @returns An array of available Bluetooth devices.
*/
export const getAvailableBluetoothDevices = (): AstalBluetooth.Device[] => {
const availableDevices = bluetoothService.devices
.filter((btDev) => btDev.name !== null)
.sort((a, b) => {
if (a.connected || a.paired) {
return -1;
}
if (b.connected || b.paired) {
return 1;
}
return a.name.localeCompare(b.name);
});
return availableDevices;
};
/**
* Retrieves the list of connected Bluetooth devices.
*
* This function filters the list of available Bluetooth devices to include only those that are connected or paired.
* It returns an array of the addresses of the connected devices.
*
* @returns An array of addresses of connected Bluetooth devices.
*/
export const getConnectedBluetoothDevices = (): string[] => {
const availableDevices = getAvailableBluetoothDevices();
const connectedDeviceNames = availableDevices.filter((d) => d.connected || d.paired).map((d) => d.address);
return connectedDeviceNames;
};
/**
* Forgets a Bluetooth device.
*
* This function removes a Bluetooth device using the `bluetoothctl` command.
* It executes the command asynchronously and emits a 'device-removed' event if the command is successful.
*
* @param device The Bluetooth device to forget.
*/
export const forgetBluetoothDevice = (device: AstalBluetooth.Device): void => {
execAsync(['bash', '-c', `bluetoothctl remove ${device.address}`])
.catch((err) => console.error('Bluetooth Remove', err))
.then(() => {
bluetoothService.emit('device-removed', device);
});
};

View File

@@ -0,0 +1,41 @@
import Variable from 'astal/variable.js';
import { bind } from 'astal/binding.js';
import { bluetoothService } from 'src/lib/constants/services.js';
import { getAvailableBluetoothDevices, getConnectedBluetoothDevices } from './helpers.js';
import { NoBluetoothDevices } from './NoBluetoothDevices.js';
import { BluetoothDisabled } from './BluetoothDisabled.js';
import { DeviceListItem } from './DeviceListItem.js';
export const BluetoothDevices = (): JSX.Element => {
const deviceListBinding = Variable.derive(
[bind(bluetoothService, 'devices'), bind(bluetoothService, 'isPowered')],
() => {
const availableDevices = getAvailableBluetoothDevices();
const connectedDevices = getConnectedBluetoothDevices();
if (availableDevices.length === 0) {
return <NoBluetoothDevices />;
}
if (!bluetoothService.adapter?.powered) {
return <BluetoothDisabled />;
}
return availableDevices.map((btDevice) => {
return <DeviceListItem btDevice={btDevice} connectedDevices={connectedDevices} />;
});
},
);
return (
<box
className={'menu-items-section'}
onDestroy={() => {
deviceListBinding.drop();
}}
>
<box className={'menu-content'} vertical>
{deviceListBinding()}
</box>
</box>
);
};

View File

@@ -0,0 +1,35 @@
import { Gtk } from 'astal/gtk3';
import { bluetoothService } from 'src/lib/constants/services';
import { isPrimaryClick } from 'src/lib/utils';
import { bind, timeout } from 'astal';
import { isDiscovering } from './helper';
export const DiscoverButton = (): JSX.Element => (
<button
className="menu-icon-button search"
valign={Gtk.Align.CENTER}
onClick={(_, self) => {
if (!isPrimaryClick(self)) {
return;
}
if (bluetoothService.adapter?.discovering) {
return bluetoothService.adapter.stop_discovery();
}
bluetoothService.adapter?.start_discovery();
const discoveryTimeout = 12000;
timeout(discoveryTimeout, () => {
if (bluetoothService.adapter?.discovering) {
bluetoothService.adapter.stop_discovery();
}
});
}}
>
<icon
className={bind(isDiscovering).as((isDiscovering) => (isDiscovering ? 'spinning-icon' : ''))}
icon="view-refresh-symbolic"
/>
</button>
);

View File

@@ -0,0 +1,23 @@
import { bind, Variable } from 'astal';
import { Gtk } from 'astal/gtk3';
import { bluetoothService } from 'src/lib/constants/services';
const isPowered = Variable(false);
Variable.derive([bind(bluetoothService, 'isPowered')], (isOn) => {
return isPowered.set(isOn);
});
export const ToggleSwitch = (): JSX.Element => (
<switch
className="menu-switch bluetooth"
halign={Gtk.Align.END}
hexpand
active={bluetoothService.isPowered}
setup={(self) => {
self.connect('notify::active', () => {
bluetoothService.adapter?.set_powered(self.active);
});
}}
/>
);

View File

@@ -0,0 +1,20 @@
import { bind, Variable } from 'astal';
import { bluetoothService } from 'src/lib/constants/services';
export const isDiscovering: Variable<boolean> = Variable(false);
let discoveringBinding: Variable<void>;
Variable.derive([bind(bluetoothService, 'adapter')], () => {
if (discoveringBinding) {
discoveringBinding();
discoveringBinding.drop();
}
if (!bluetoothService.adapter) {
return;
}
discoveringBinding = Variable.derive([bind(bluetoothService.adapter, 'discovering')], (discovering) => {
isDiscovering.set(discovering);
});
});

View File

@@ -0,0 +1,14 @@
import { Gtk } from 'astal/gtk3';
import Separator from 'src/components/shared/Separator';
import { ToggleSwitch } from './ToggleSwitch';
import { DiscoverButton } from './DiscoverButton';
export const Controls = (): JSX.Element => {
return (
<box className="controls-container" valign={Gtk.Align.START}>
<ToggleSwitch />
<Separator className="menu-separator bluetooth" />
<DiscoverButton />
</box>
);
};

View File

@@ -0,0 +1,15 @@
import { Gtk } from 'astal/gtk3';
import { Controls } from './Controls';
export const Header = (): JSX.Element => {
const MenuLabel = (): JSX.Element => {
return <label className="menu-label" valign={Gtk.Align.CENTER} halign={Gtk.Align.START} label="Bluetooth" />;
};
return (
<box className="menu-label-container" halign={Gtk.Align.FILL} valign={Gtk.Align.START}>
<MenuLabel />
<Controls />
</box>
);
};

View File

@@ -0,0 +1,25 @@
import DropdownMenu from '../shared/dropdown/index.js';
import { BluetoothDevices } from './devices/index.js';
import { Header } from './header/index.js';
import options from 'src/options.js';
import { bind } from 'astal/binding.js';
import { Gtk } from 'astal/gtk3';
import { RevealerTransitionMap } from 'src/lib/constants/options.js';
export default (): JSX.Element => {
return (
<DropdownMenu
name={'bluetoothmenu'}
transition={bind(options.menus.transition).as((transition) => RevealerTransitionMap[transition])}
>
<box className={'menu-items bluetooth'} halign={Gtk.Align.FILL} hexpand>
<box className={'menu-items-container bluetooth'} halign={Gtk.Align.FILL} vertical hexpand>
<box className={'menu-section-container bluetooth'} vertical>
<Header />
<BluetoothDevices />
</box>
</box>
</box>
</DropdownMenu>
);
};

View File

@@ -0,0 +1,39 @@
/**
* Retrieves the appropriate Bluetooth icon based on the provided icon name.
*
* This function returns a Bluetooth icon based on the given icon name. If no match is found,
* it returns a default Bluetooth icon. It uses a predefined mapping of device icon names to Bluetooth icons.
*
* @param iconName The name of the icon to look up.
*
* @returns The corresponding Bluetooth icon as a string. If no match is found, returns the default Bluetooth icon.
*/
const getBluetoothIcon = (iconName: string): string => {
const deviceIconMap = [
['^audio-card*', '󰎄'],
['^audio-headphones*', '󰋋'],
['^audio-headset*', '󰋎'],
['^audio-input*', '󰍬'],
['^audio-speakers*', '󰓃'],
['^bluetooth*', '󰂯'],
['^camera*', '󰄀'],
['^computer*', '󰟀'],
['^input-gaming*', '󰍬'],
['^input-keyboard*', '󰌌'],
['^input-mouse*', '󰍽'],
['^input-tablet*', '󰓶'],
['^media*', '󱛟'],
['^modem*', '󱂇'],
['^network*', '󱂇'],
['^phone*', '󰄞'],
['^printer*', '󰐪'],
['^scanner*', '󰚫'],
['^video-camera*', '󰕧'],
];
const foundMatch = deviceIconMap.find((icon) => RegExp(icon[0]).test(iconName.toLowerCase()));
return foundMatch ? foundMatch[1] : '󰂯';
};
export { getBluetoothIcon };

View File

@@ -0,0 +1,20 @@
import { Gtk } from 'astal/gtk3';
import Calendar from 'src/components/shared/Calendar';
export const CalendarWidget = (): JSX.Element => {
return (
<box className={'calendar-menu-item-container calendar'} halign={Gtk.Align.FILL} valign={Gtk.Align.FILL} expand>
<box className={'calendar-container-box'}>
<Calendar
className={'calendar-menu-widget'}
halign={Gtk.Align.FILL}
valign={Gtk.Align.FILL}
showDetails={false}
expand
showDayNames
showHeading
/>
</box>
</box>
);
};

View File

@@ -0,0 +1,35 @@
import DropdownMenu from '../shared/dropdown/index.js';
import { TimeWidget } from './time/index';
import { CalendarWidget } from './CalendarWidget.js';
import { WeatherWidget } from './weather/index';
import options from 'src/options';
import { bind } from 'astal';
import { RevealerTransitionMap } from 'src/lib/constants/options.js';
const { transition } = options.menus;
const { enabled: weatherEnabled } = options.menus.clock.weather;
export default (): JSX.Element => {
return (
<DropdownMenu
name={'calendarmenu'}
transition={bind(transition).as((transition) => RevealerTransitionMap[transition])}
>
<box css={'padding: 1px; margin: -1px;'}>
{bind(weatherEnabled).as((isWeatherEnabled) => {
return (
<box className={'calendar-menu-content'} vexpand={false}>
<box className={'calendar-content-container'} vertical>
<box className={'calendar-content-items'} vertical>
<TimeWidget />
<CalendarWidget />
<WeatherWidget isEnabled={isWeatherEnabled} />
</box>
</box>
</box>
);
})}
</box>
</DropdownMenu>
);
};

View File

@@ -0,0 +1,35 @@
import options from 'src/options';
import { bind, Variable } from 'astal';
import { Gtk } from 'astal/gtk3';
import { systemTime } from 'src/globals/time';
const { military, hideSeconds } = options.menus.clock.time;
export const MilitaryTime = (): JSX.Element => {
const timeBinding = Variable.derive([bind(military), bind(hideSeconds)], (is24hr, hideSeconds) => {
if (!is24hr) {
return <box />;
}
return (
<box halign={Gtk.Align.CENTER}>
<label
className={'clock-content-time'}
label={bind(systemTime).as((time) => {
return time?.format(hideSeconds ? '%H:%M' : '%H:%M:%S') || '';
})}
/>
</box>
);
});
return (
<box
onDestroy={() => {
timeBinding.drop();
}}
>
{timeBinding()}
</box>
);
};

View File

@@ -0,0 +1,58 @@
import options from 'src/options';
import { bind, GLib, Variable } from 'astal';
import { Gtk } from 'astal/gtk3';
import { systemTime } from 'src/globals/time';
const { military, hideSeconds } = options.menus.clock.time;
const period = Variable('').poll(1000, (): string => GLib.DateTime.new_now_local().format('%p') || '');
export const StandardTime = (): JSX.Element => {
const CurrentTime = ({ hideSeconds }: CurrentTimeProps): JSX.Element => {
return (
<box halign={Gtk.Align.CENTER}>
<label
className={'clock-content-time'}
label={bind(systemTime).as((time) => {
return time?.format(hideSeconds ? '%I:%M' : '%I:%M:%S') || '';
})}
/>
</box>
);
};
const CurrentPeriod = (): JSX.Element => {
return (
<box halign={Gtk.Align.CENTER}>
<label className={'clock-content-period'} valign={Gtk.Align.END} label={bind(period)} />
</box>
);
};
const timeBinding = Variable.derive([bind(military), bind(hideSeconds)], (is24hr, hideSeconds) => {
if (is24hr) {
return <box />;
}
return (
<box>
<CurrentTime hideSeconds={hideSeconds} />
<CurrentPeriod />
</box>
);
});
return (
<box
onDestroy={() => {
timeBinding.drop();
}}
>
{timeBinding()}
</box>
);
};
interface CurrentTimeProps {
hideSeconds: boolean;
}

View File

@@ -0,0 +1,14 @@
import { Gtk } from 'astal/gtk3';
import { MilitaryTime } from './MilitaryTime';
import { StandardTime } from './StandardTime';
export const TimeWidget = (): JSX.Element => {
return (
<box className={'calendar-menu-item-container clock'} valign={Gtk.Align.CENTER} halign={Gtk.Align.FILL} hexpand>
<box className={'clock-content-items'} valign={Gtk.Align.CENTER} halign={Gtk.Align.CENTER} hexpand>
<StandardTime />
<MilitaryTime />
</box>
</box>
);
};

View File

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

View File

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

View File

@@ -0,0 +1,18 @@
import { HourlyIcon } from './icon/index.js';
import { HourlyTemp } from './temperature/index.js';
import { HourlyTime } from './time/index.js';
import { Gtk } from 'astal/gtk3';
export const HourlyTemperature = (): JSX.Element => {
return (
<box className={'hourly-weather-container'} halign={Gtk.Align.FILL} vertical={false} hexpand>
{[1, 2, 3, 4].map((hoursFromNow) => (
<box className={'hourly-weather-item'} hexpand vertical>
<HourlyTime hoursFromNow={hoursFromNow} />
<HourlyIcon hoursFromNow={hoursFromNow} />
<HourlyTemp hoursFromNow={hoursFromNow} />
</box>
))}
</box>
);
};

View File

@@ -0,0 +1,36 @@
import options from 'src/options';
import { globalWeatherVar } from 'src/globals/weather';
import { getNextEpoch } from '../helpers';
import { bind, Variable } from 'astal';
const { unit } = options.menus.clock.weather;
export const HourlyTemp = ({ hoursFromNow }: HourlyTempProps): JSX.Element => {
const weatherBinding = Variable.derive([bind(globalWeatherVar), bind(unit)], (weather, unitType) => {
if (!Object.keys(weather).length) {
return '-';
}
const nextEpoch = getNextEpoch(weather, hoursFromNow);
const weatherAtEpoch = weather.forecast.forecastday[0].hour.find((h) => h.time_epoch === nextEpoch);
if (unitType === 'imperial') {
return `${weatherAtEpoch ? Math.ceil(weatherAtEpoch.temp_f) : '-'}° F`;
}
return `${weatherAtEpoch ? Math.ceil(weatherAtEpoch.temp_c) : '-'}° C`;
});
return (
<label
className={'hourly-weather-temp'}
label={weatherBinding()}
onDestroy={() => {
weatherBinding.drop();
}}
/>
);
};
interface HourlyTempProps {
hoursFromNow: number;
}

View File

@@ -0,0 +1,30 @@
import { globalWeatherVar } from 'src/globals/weather';
import { getNextEpoch } from '../helpers';
import { bind } from 'astal';
export const HourlyTime = ({ hoursFromNow }: HourlyTimeProps): JSX.Element => {
return (
<label
className={'hourly-weather-time'}
label={bind(globalWeatherVar).as((weather) => {
if (!Object.keys(weather).length) {
return '-';
}
const nextEpoch = getNextEpoch(weather, hoursFromNow);
const dateAtEpoch = new Date(nextEpoch * 1000);
let hours = dateAtEpoch.getHours();
const ampm = hours >= 12 ? 'PM' : 'AM';
hours = hours % 12 || 12;
return `${hours}${ampm}`;
})}
/>
);
};
interface HourlyTimeProps {
hoursFromNow: number;
}

View File

@@ -0,0 +1,18 @@
import { bind } from 'astal';
import { Gtk } from 'astal/gtk3';
import { getWeatherStatusTextIcon } from 'src/globals/weather.js';
export const TodayIcon = (): JSX.Element => {
return (
<box
className={'calendar-menu-weather today icon container'}
halign={Gtk.Align.START}
valign={Gtk.Align.CENTER}
>
<label
className={'calendar-menu-weather today icon txt-icon'}
label={bind(globalWeatherVar).as(getWeatherStatusTextIcon)}
/>
</box>
);
};

View File

@@ -0,0 +1,23 @@
import { TodayIcon } from './icon/index.js';
import { TodayStats } from './stats/index.js';
import { TodayTemperature } from './temperature/index.js';
import { HourlyTemperature } from './hourly/index.js';
import Separator from 'src/components/shared/Separator.js';
export const WeatherWidget = (): JSX.Element => {
return (
<box className={'calendar-menu-item-container weather'}>
<box className={'weather-container-box'}>
<box vertical hexpand>
<box className={'calendar-menu-weather today'} hexpand>
<TodayIcon />
<TodayTemperature />
<TodayStats />
</box>
<Separator className={'menu-separator weather'} />
<HourlyTemperature />
</box>
</box>
</box>
);
};

View File

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

View File

@@ -0,0 +1,49 @@
import options from 'src/options';
import { globalWeatherVar } from 'src/globals/weather';
import { getTemperature, getWeatherIcon } from 'src/globals/weather';
import { Gtk } from 'astal/gtk3';
import { bind, Variable } from 'astal';
const { unit } = options.menus.clock.weather;
export const TodayTemperature = (): JSX.Element => {
const labelBinding = Variable.derive([bind(globalWeatherVar), bind(unit)], getTemperature);
return (
<box
halign={Gtk.Align.CENTER}
valign={Gtk.Align.CENTER}
vertical
onDestroy={() => {
labelBinding.drop();
}}
>
<box
className={'calendar-menu-weather today temp container'}
valign={Gtk.Align.CENTER}
vertical={false}
hexpand
>
<box halign={Gtk.Align.CENTER} hexpand>
<label className={'calendar-menu-weather today temp label'} label={labelBinding()} />
<label
className={bind(globalWeatherVar).as(
(weather) =>
`calendar-menu-weather today temp label icon txt-icon ${getWeatherIcon(Math.ceil(weather.current.temp_f)).color}`,
)}
label={bind(globalWeatherVar).as(
(weather) => getWeatherIcon(Math.ceil(weather.current.temp_f)).icon,
)}
/>
</box>
</box>
<box halign={Gtk.Align.CENTER}>
<label
className={bind(globalWeatherVar).as(
(weather) =>
`calendar-menu-weather today condition label ${getWeatherIcon(Math.ceil(weather.current.temp_f)).color}`,
)}
label={bind(globalWeatherVar).as((weather) => weather.current.condition.text)}
/>
</box>
</box>
);
};

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