Merge branch 'master' into patch-2

This commit is contained in:
orangc
2025-05-28 17:37:21 +03:00
committed by GitHub
535 changed files with 13268 additions and 8781 deletions

3
.gitignore vendored
View File

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

View File

@@ -36,6 +36,8 @@ dart-sass
wl-clipboard wl-clipboard
upower upower
gvfs gvfs
gtksourceview3
libsoup3
``` ```
**NOTE: HyprPanel will not run without the required dependencies.** **NOTE: HyprPanel will not run without the required dependencies.**
@@ -83,16 +85,8 @@ swww
### Arch ### Arch
pacman:
```bash ```bash
sudo pacman -S --needed wireplumber libgtop bluez bluez-utils btop networkmanager dart-sass wl-clipboard brightnessctl swww python upower pacman-contrib power-profiles-daemon gvfs yay -S --needed aylurs-gtk-shell-git wireplumber libgtop bluez bluez-utils btop networkmanager dart-sass wl-clipboard brightnessctl swww python upower pacman-contrib power-profiles-daemon gvfs gtksourceview3 libsoup3 grimblast-git wf-recorder-git hyprpicker matugen-bin python-gpustat hyprsunset-git
```
AUR:
```bash
yay -S --needed aylurs-gtk-shell-git grimblast-git wf-recorder-git hyprpicker matugen-bin python-gpustat hyprsunset-git
``` ```
### Fedora ### Fedora
@@ -155,28 +149,40 @@ If you install the fonts after installing HyperPanel, you will need to restart H
### NixOS & Home-Manager ### NixOS & Home-Manager
Alternatively, if you're using NixOS and/or Home-Manager, you can setup AGS using the provided Nix Flake. First, add the repository to your Flake's inputs, and enable the overlay. Alternatively, if you're using NixOS and/or Home-Manager, you can setup AGS using the provided Nix Flake. First, add the repository to your Flake's inputs, and enable the overlay.
You can now also just use wrapper as the package directly and ignore this section almost entirely (expect for adding inputs), it's recommended to avoid overlays.
```nix ```nix
# flake.nix # flake.nix
{ {
inputs.hyprpanel.url = "github:Jas-SinghFSU/HyprPanel"; inputs = {
# ... nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
hyprpanel = {
url = "github:Jas-SinghFSU/HyprPanel";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { self, nixpkgs, ... }@inputs: outputs =
{ self, nixpkgs, ... }@inputs:
let let
# ...
system = "x86_64-linux"; # change to whatever your system should be.
pkgs = import nixpkgs {
inherit system;
# ...
overlays = [ overlays = [
inputs.hyprpanel.overlay inputs.hyprpanel.overlay
]; ];
in
{
nixosConfigurations = {
nixos = nixpkgs.lib.nixosSystem {
specialArgs = {
inherit inputs;
};
modules = [
./configuration.nix
{ nixpkgs.overlays = [ overlays ]; }
];
};
};
}; };
in {
# ...
}
} }
``` ```
@@ -188,6 +194,7 @@ Once you've set up the overlay, you can reference HyprPanel with `pkgs.hyprpanel
# install it as a system package # install it as a system package
environment.systemPackages = with pkgs; [ environment.systemPackages = with pkgs; [
# ... # ...
inputs.hyprpanel.packages.${pkgs.system}.wrapper # this one if you want to avoid overlays/didn't enable them
hyprpanel hyprpanel
# ... # ...
]; ];
@@ -195,6 +202,7 @@ environment.systemPackages = with pkgs; [
# or install it as a user package # or install it as a user package
users.users.<username>.packages = with pkgs; [ users.users.<username>.packages = with pkgs; [
# ... # ...
inputs.hyprpanel.packages.${pkgs.system}.wrapper # this one if you want to avoid overlays/didn't enable them
hyprpanel hyprpanel
# ... # ...
]; ];
@@ -205,6 +213,7 @@ users.users.<username>.packages = with pkgs; [
# install it as a user package with home-manager # install it as a user package with home-manager
home.packages = with pkgs; [ home.packages = with pkgs; [
# ... # ...
inputs.hyprpanel.packages.${pkgs.system}.wrapper # this one if you want to avoid overlays/didn't enable them
hyprpanel hyprpanel
# ... # ...
]; ];
@@ -212,6 +221,7 @@ home.packages = with pkgs; [
# or reference it directly in your Hyprland configuration # or reference it directly in your Hyprland configuration
wayland.windowManager.hyprland.settings.exec-once = [ wayland.windowManager.hyprland.settings.exec-once = [
"${pkgs.hyprpanel}/bin/hyprpanel" "${pkgs.hyprpanel}/bin/hyprpanel"
"${inputs.hyprpanel.packages.${pkgs.system}.wrapper}/bin/hyprpanel" # this one if you want to avoid overlays/didn't enable them
]; ];
``` ```

43
app.ts
View File

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

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

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

View File

@@ -8,17 +8,26 @@
}; };
}; };
outputs = { outputs =
{
self, self,
nixpkgs, nixpkgs,
ags, ags,
}: let }:
systems = ["x86_64-linux" "aarch64-linux"]; let
systems = [
"x86_64-linux"
"aarch64-linux"
];
forEachSystem = nixpkgs.lib.genAttrs systems; forEachSystem = nixpkgs.lib.genAttrs systems;
in { in
packages = forEachSystem (system: let {
packages = forEachSystem (
system:
let
pkgs = nixpkgs.legacyPackages.${system}; pkgs = nixpkgs.legacyPackages.${system};
in { in
{
default = ags.lib.bundle { default = ags.lib.bundle {
inherit pkgs; inherit pkgs;
src = ./.; src = ./.;
@@ -54,12 +63,15 @@
grimblast grimblast
brightnessctl brightnessctl
gnome-bluetooth gnome-bluetooth
(python3.withPackages (ps: gtksourceview3
with ps; [ libsoup_3
(python3.withPackages (
ps: with ps; [
gpustat gpustat
dbus-python dbus-python
pygobject3 pygobject3
])) ]
))
matugen matugen
hyprpicker hyprpicker
hyprsunset hyprsunset
@@ -73,7 +85,16 @@
pywal pywal
]); ]);
}; };
}); # Make a wrapper package to avoid overlay
wrapper = pkgs.writeShellScriptBin "hyprpanel" ''
if [ "$#" -eq 0 ]; then
exec ${self.packages.${pkgs.stdenv.system}.default}/bin/hyprpanel
else
exec ${ags.packages.${pkgs.stdenv.system}.io}/bin/astal -i hyprpanel "$*"
fi
'';
}
);
# Define .overlay to expose the package as pkgs.hyprpanel based on the system # Define .overlay to expose the package as pkgs.hyprpanel based on the system
overlay = final: prev: { overlay = final: prev: {

View File

@@ -12,6 +12,8 @@ datadir = prefix / get_option('datadir') / meson.project_name()
ags = find_program('ags', required: true) ags = find_program('ags', required: true)
find_program('gjs', required: true) find_program('gjs', required: true)
dependency('gtksourceview-3.0', required: true)
custom_target( custom_target(
'hyprpanel_bundle', 'hyprpanel_bundle',
input: files('app.ts'), input: files('app.ts'),
@@ -41,4 +43,4 @@ configure_file(
install_subdir('scripts', install_dir: datadir) install_subdir('scripts', install_dir: datadir)
install_subdir('themes', install_dir: datadir) install_subdir('themes', install_dir: datadir)
install_subdir('assets', install_dir: datadir) install_subdir('assets', install_dir: datadir)
install_subdir('src/scss', install_dir: datadir / 'src') install_subdir('src/style', install_dir: datadir / 'src')

View File

@@ -22,18 +22,7 @@ let
if pkgs ? hyprpanel then if pkgs ? hyprpanel then
pkgs.hyprpanel pkgs.hyprpanel
else else
abort '' self.packages.${pkgs.stdenv.system}.wrapper;
********************************************************************************
* HyprPanel *
*------------------------------------------------------------------------------*
* You didn't add the overlay! *
* *
* Either set 'overlay.enable = true' or manually add it to 'nixpkgs.overlays'. *
* If you use the 'nixosModule' for Home Manager and have 'useGlobalPkgs' set, *
* you will need to add the overlay yourself. *
********************************************************************************
'';
# Shorthand lambda for self-documenting options under settings # Shorthand lambda for self-documenting options under settings
mkStrOption = mkStrOption =

916
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,11 @@
import { CustomBarModule } from '../types'; import { CustomBarModule } from '../types';
import { Module } from '../../shared/Module'; import { Module } from '../../shared/module';
import { Astal } from 'astal/gtk3'; import { Astal } from 'astal/gtk3';
import { bind, Variable } from 'astal'; import { bind, Variable } from 'astal';
import { getIcon } from './helpers/icon'; import { getIcon } from './helpers/icon';
import { getLabel } from './helpers/label'; import { getLabel } from './helpers/label';
import { initActionListener, initCommandPoller, setupModuleInteractions } from './setup'; import { initActionListener, initCommandPoller, setupModuleInteractions } from './setup';
import { BarBoxChild } from 'src/lib/types/bar.types'; import { BarBoxChild } from 'src/components/bar/types';
export const ModuleContainer = (moduleName: string, moduleMetadata: CustomBarModule): BarBoxChild => { export const ModuleContainer = (moduleName: string, moduleMetadata: CustomBarModule): BarBoxChild => {
const { const {

View File

@@ -2,7 +2,9 @@ import { Variable, bind, execAsync } from 'astal';
import { Astal } from 'astal/gtk3'; import { Astal } from 'astal/gtk3';
import { BashPoller } from 'src/lib/poller/BashPoller'; import { BashPoller } from 'src/lib/poller/BashPoller';
import { CustomBarModule } from '../types'; import { CustomBarModule } from '../types';
import { inputHandler } from '../../utils/helpers'; import { InputHandlerService } from '../../utils/input/inputHandler';
const inputHandler = InputHandlerService.getInstance();
export function initCommandPoller( export function initCommandPoller(
commandOutput: Variable<string>, commandOutput: Variable<string>,
@@ -51,7 +53,7 @@ export function setupModuleInteractions(
moduleScrollThreshold: number, moduleScrollThreshold: number,
): void { ): void {
const scrollThreshold = moduleScrollThreshold >= 0 ? moduleScrollThreshold : 1; const scrollThreshold = moduleScrollThreshold >= 0 ? moduleScrollThreshold : 1;
inputHandler( inputHandler.attachHandlers(
element, element,
{ {
onPrimaryClick: { onPrimaryClick: {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,44 +1,108 @@
import { Variable } from 'astal'; import { bind, Binding } from 'astal';
import CpuTempService from 'src/services/system/cputemp';
import { TemperatureConverter } from 'src/lib/units/temperature';
import { CpuTempSensorDiscovery } from 'src/services/system/cputemp/sensorDiscovery';
import options from 'src/configuration';
import GLib from 'gi://GLib?version=2.0'; import GLib from 'gi://GLib?version=2.0';
import { convertCelsiusToFahrenheit } from 'src/shared/weather';
import options from 'src/options'; const { pollingInterval, sensor } = options.bar.customModules.cpuTemp;
import { UnitType } from 'src/lib/types/weather.types';
const { sensor } = options.bar.customModules.cpuTemp;
/** /**
* Retrieves the current CPU temperature. * Creates a tooltip for the CPU temperature module showing sensor details
*
* 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 => { export function getCpuTempTooltip(cpuTempService: CpuTempService): Binding<string> {
return bind(cpuTempService.temperature).as((temp) => {
const currentPath = cpuTempService.currentSensorPath;
const configuredSensor = sensor.get();
const isAuto = configuredSensor === 'auto' || configuredSensor === '';
const tempC = TemperatureConverter.fromCelsius(temp).formatCelsius();
const tempF = TemperatureConverter.fromCelsius(temp).formatFahrenheit();
const lines = [
'CPU Temperature',
'─────────────────────────',
`Current: ${tempC} (${tempF})`,
'',
'Sensor Information',
'─────────────────────────',
];
if (currentPath) {
const sensorType = getSensorType(currentPath);
const sensorName = getSensorName(currentPath);
const chipName = getChipName(currentPath);
lines.push(`Mode: ${isAuto ? 'Auto-discovered' : 'User-configured'}`, `Type: ${sensorType}`);
if (chipName) {
lines.push(`Chip: ${chipName}`);
}
lines.push(`Device: ${sensorName}`, `Path: ${currentPath}`);
} else {
lines.push('Status: No sensor found', 'Try setting a manual sensor path');
}
const interval = pollingInterval.get();
lines.push('', `Update interval: ${interval}ms`);
const allSensors = CpuTempSensorDiscovery.getAllSensors();
if (allSensors.length > 1) {
lines.push('', `Available sensors: ${allSensors.length}`);
}
return lines.join('\n');
});
}
/**
* Determines sensor type from path
*/
function getSensorType(path: string): string {
if (path.includes('/sys/class/hwmon/')) return 'Hardware Monitor';
if (path.includes('/sys/class/thermal/')) return 'Thermal Zone';
return 'Unknown';
}
/**
* Extracts sensor name from path
*/
function getSensorName(path: string): string {
if (path.includes('/sys/class/hwmon/')) {
const match = path.match(/hwmon(\d+)/);
return match ? `hwmon${match[1]}` : 'Unknown';
}
if (path.includes('/sys/class/thermal/')) {
const match = path.match(/thermal_zone(\d+)/);
return match ? `thermal_zone${match[1]}` : 'Unknown';
}
return 'Unknown';
}
/**
* Gets the actual chip name for hwmon sensors
*/
function getChipName(path: string): string | undefined {
if (!path.includes('/sys/class/hwmon/')) return undefined;
try { try {
if (sensor.get().length === 0) { const match = path.match(/\/sys\/class\/hwmon\/hwmon\d+/);
return 0; if (!match) return undefined;
const nameFile = `${match[0]}/name`;
const [success, bytes] = GLib.file_get_contents(nameFile);
if (success && bytes) {
return new TextDecoder('utf-8').decode(bytes).trim();
} }
const [success, tempInfoBytes] = GLib.file_get_contents(sensor.get());
const tempInfo = new TextDecoder('utf-8').decode(tempInfoBytes);
if (!success || tempInfoBytes === null) {
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) { } catch (error) {
console.error('Error calculating CPU Temp:', error); if (error instanceof Error) {
return 0; console.debug(`Failed to get chip name: ${error.message}`);
} }
}; }
return undefined;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,41 +1,24 @@
import { App, Gdk } from 'astal/gtk3'; import { App, Gdk } from 'astal/gtk3';
import { GtkWidget } from 'src/lib/types/widget.types'; import { calculateMenuPosition } from 'src/components/menus/shared/dropdown/helpers/locationHandler';
import { calculateMenuPosition } from 'src/components/menus/shared/dropdown/locationHandler'; import { GtkWidget } from '../../types';
export const closeAllMenus = (): void => { /**
const menuWindows = App.get_windows() * Opens a dropdown menu centered relative to the clicked button
.filter((w) => { *
if (w.name) { * This function handles the positioning logic to ensure menus appear centered
return /.*menu/.test(w.name); * relative to the button that was clicked, regardless of where on the button
} * the click occurred. It calculates the offset needed to center the menu
* based on the click position within the button's bounds.
return false; *
}) * @param clicked - The widget that was clicked to trigger the menu
.map((window) => window.name); * @param event - The click event containing position information
* @param window - The name of the menu window to open
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.
*/ */
export const openDropdownMenu = async (
clicked: GtkWidget,
event: Gdk.Event,
window: string,
): Promise<void> => {
try { try {
const middleOfButton = Math.floor(clicked.get_allocated_width() / 2); const middleOfButton = Math.floor(clicked.get_allocated_width() / 2);
const xAxisOfButtonClick = clicked.get_pointer()[0]; const xAxisOfButtonClick = clicked.get_pointer()[0];
@@ -57,3 +40,28 @@ export const openMenu = async (clicked: GtkWidget, event: Gdk.Event, window: str
} }
} }
}; };
/**
* Closes all currently open menu windows
*
* This function finds all windows whose names contain "menu" and
* hides them. It's used to ensure only one menu is open at a time
* when opening a new dropdown menu.
*/
function closeAllMenus(): void {
const menuWindows = App.get_windows()
.filter((w) => {
if (w.name) {
return /.*menu/.test(w.name);
}
return false;
})
.map((window) => window.name);
menuWindows.forEach((window) => {
if (window) {
App.get_window(window)?.set_visible(false);
}
});
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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