diff --git a/ags/HyprPanel/.eslintignore b/ags/HyprPanel/.eslintignore
new file mode 100644
index 0000000..980d7f4
--- /dev/null
+++ b/ags/HyprPanel/.eslintignore
@@ -0,0 +1,2 @@
+types
+node_modules
diff --git a/ags/HyprPanel/.eslintrc.js b/ags/HyprPanel/.eslintrc.js
new file mode 100644
index 0000000..b650cfe
--- /dev/null
+++ b/ags/HyprPanel/.eslintrc.js
@@ -0,0 +1,25 @@
+module.exports = {
+ parser: '@typescript-eslint/parser',
+ parserOptions: {
+ project: 'tsconfig.json',
+ tsconfigRootDir: __dirname,
+ sourceType: 'module',
+ },
+ plugins: ['@typescript-eslint', 'import'],
+ extends: ['plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended'],
+ root: true,
+ ignorePatterns: ['.eslintrc.js', 'types/**/*.ts', 'scripts/**/*.js'],
+ env: {
+ es6: true,
+ browser: true,
+ },
+ rules: {
+ '@typescript-eslint/interface-name-prefix': 'off',
+ '@typescript-eslint/explicit-function-return-type': 'error',
+ '@typescript-eslint/explicit-module-boundary-types': 'error',
+ '@typescript-eslint/no-explicit-any': 'error',
+ 'import/extensions': ['off'],
+ 'import/no-unresolved': 'off',
+ quotes: ['error', 'single', { avoidEscape: true, allowTemplateLiterals: true }],
+ },
+};
diff --git a/ags/HyprPanel/.github/ISSUE_TEMPLATE/bug_report.md b/ags/HyprPanel/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 0000000..e41ef8d
--- /dev/null
+++ b/ags/HyprPanel/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,27 @@
+---
+name: Bug report
+about: Create a report to help HyprPanel improve
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+**Describe the bug**
+A clear and concise description of what the bug is.
+
+**To Reproduce**
+Steps to reproduce the behavior:
+
+**Expected behavior**
+A clear and concise description of what you expected to happen.
+
+**Screenshots**
+If applicable, add screenshots to help explain your problem.
+
+**Desktop (please complete the following information):**
+ - Distribution: [e.g. Arch Linux]
+ - Window Manager/Desktop Environment: [e.g. Hyprland]
+
+**Additional context**
+Add any other context about the problem here.
diff --git a/ags/HyprPanel/.github/ISSUE_TEMPLATE/feature_request.md b/ags/HyprPanel/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 0000000..bbcbbe7
--- /dev/null
+++ b/ags/HyprPanel/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,20 @@
+---
+name: Feature request
+about: Suggest an idea for this project
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+**Is your feature request related to a problem? Please describe.**
+A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
+
+**Describe the solution you'd like**
+A clear and concise description of what you want to happen.
+
+**Describe alternatives you've considered**
+A clear and concise description of any alternative solutions or features you've considered.
+
+**Additional context**
+Add any other context or screenshots about the feature request here.
diff --git a/ags/HyprPanel/.github/workflows/ci.yml b/ags/HyprPanel/.github/workflows/ci.yml
new file mode 100644
index 0000000..27ec51a
--- /dev/null
+++ b/ags/HyprPanel/.github/workflows/ci.yml
@@ -0,0 +1,41 @@
+name: CI
+
+on:
+ pull_request:
+ branches:
+ - master
+
+jobs:
+ code_quality:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout main repository
+ uses: actions/checkout@v3
+
+ - name: Clone ags-types to temp dir
+ uses: actions/checkout@v3
+ with:
+ repository: Jas-SinghFSU/ags-types
+ path: temp-ags-types
+
+ - name: Copy types to types/
+ run: |
+ rm -rf types
+ mkdir -p types
+ cp -R temp-ags-types/types/* types/
+ rm -rf temp-ags-types
+
+ - name: Node Setup
+ uses: actions/setup-node@v3
+ with:
+ node-version: '21'
+
+ - name: Install Dependencies
+ run: npm install
+
+ - name: ESLint
+ run: npm run lint
+
+ - name: Type Check
+ run: npx tsc --noEmit --pretty --extendedDiagnostics
diff --git a/ags/HyprPanel/.gitignore b/ags/HyprPanel/.gitignore
new file mode 100644
index 0000000..9144fba
--- /dev/null
+++ b/ags/HyprPanel/.gitignore
@@ -0,0 +1,2 @@
+.weather.json
+node_modules
diff --git a/ags/HyprPanel/.gitmodules b/ags/HyprPanel/.gitmodules
new file mode 100644
index 0000000..b165aa5
--- /dev/null
+++ b/ags/HyprPanel/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "external/ags-types"]
+ path = external/ags-types
+ url = https://github.com/Jas-SinghFSU/ags-types.git
diff --git a/ags/HyprPanel/.prettierignore b/ags/HyprPanel/.prettierignore
new file mode 100644
index 0000000..97843e6
--- /dev/null
+++ b/ags/HyprPanel/.prettierignore
@@ -0,0 +1,2 @@
+.eslintrc.js
+types/**/*.ts
diff --git a/ags/HyprPanel/.prettierrc b/ags/HyprPanel/.prettierrc
new file mode 100644
index 0000000..6e88f12
--- /dev/null
+++ b/ags/HyprPanel/.prettierrc
@@ -0,0 +1,8 @@
+{
+ "singleQuote": true,
+ "semi": true,
+ "trailingComma": "all",
+ "printWidth": 120,
+ "tabWidth": 4,
+ "useTabs": false
+}
diff --git a/ags/HyprPanel/LICENSE b/ags/HyprPanel/LICENSE
new file mode 100644
index 0000000..75c0349
--- /dev/null
+++ b/ags/HyprPanel/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2024 Jas Singh
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/ags/HyprPanel/README.md b/ags/HyprPanel/README.md
new file mode 100644
index 0000000..bca09ea
--- /dev/null
+++ b/ags/HyprPanel/README.md
@@ -0,0 +1,317 @@
+
+
+
+
+
+
+
+
+
+
+
+
+# HyprPanel 🚀
+
+A panel built for Hyprland with [AGS](https://github.com/Aylur/ags)
+
+
+
+
+## Installation
+
+The [HyprPanel Wiki](https://hyprpanel.com/getting_started/installation.html) contains in depth instructions for installing the panel and all of its dependencies. The instructions below are general instructions for installing the panel.
+
+## Requirements
+
+Bun
+
+```sh
+curl -fsSL https://bun.sh/install | bash && \
+ sudo ln -s $HOME/.bun/bin/bun /usr/local/bin/bun
+```
+
+Additional dependencies:
+
+```sh
+pipewire
+libgtop
+bluez
+bluez-utils
+grimblast
+gpu-screen-recorder
+hyprpicker
+btop
+networkmanager
+matugen
+wl-clipboard
+swww
+dart-sass
+brightnessctl
+gnome-bluetooth-3.0
+```
+
+Optional Dependencies:
+
+```sh
+## Used for Tracking GPU Usage in your Dashboard (NVidia only)
+python
+python-gpustat
+
+## Only if a pywal hook from wallpaper changes applied through settings is desired
+pywal
+
+## To check for pacman updates in the default script used in the updates module
+pacman-contrib
+
+## To switch between power profiles in battery module
+power-profiles-daemon
+```
+
+### Arch
+
+pacman:
+
+```bash
+sudo pacman -S pipewire libgtop bluez bluez-utils btop networkmanager dart-sass wl-clipboard brightnessctl swww python gnome-bluetooth-3.0 pacman-contrib power-profiles-daemon
+```
+
+AUR:
+
+```bash
+yay -S grimblast-git gpu-screen-recorder hyprpicker matugen-bin python-gpustat aylurs-gtk-shell-git
+```
+
+### Fedora
+
+COPR - Add [solopasha/hyprland](https://copr.fedorainfracloud.org/coprs/solopasha/hyprland/) for most hyprland-related dependencies, and [hues-sueh/packages](https://copr.fedorainfracloud.org/coprs/heus-sueh/packages/) for matugen. Both provide the `swww` package, so prioritise the former repo:
+
+```bash
+sudo dnf copr enable solopasha/hyprland
+sudo dnf copr enable heus-sueh/packages
+sudo dnf config-manager --save --setopt=copr:copr.fedorainfracloud.org:heus-sueh:packages.priority=200
+```
+
+DNF:
+
+```bash
+sudo dnf install pipewire libgtop2 bluez bluez-tools grimblast hyprpicker btop NetworkManager wl-clipboard swww brightnessctl gnome-bluetooth aylurs-gtk-shell power-profiles-daemon gvfs
+```
+
+bun:
+
+```bash
+bun install -g sass
+```
+
+flatpak:
+
+```bash
+flatpak install flathub --system com.dec05eba.gpu_screen_recorder
+```
+
+#### Optional Dependencies
+
+pip:
+
+```bash
+sudo dnf install python python3-pip; pip install gpustat pywal
+```
+
+### NixOS
+
+For NixOS/Home-Manager, see [NixOS & Home-Manager instructions](#nixos--home-manager).
+
+## Instructions
+
+### AGS
+
+Once everything is installed you need to put the contents of this repo in `~/.config/ags`.
+If you already have something in `~/.config/ags`, it's recommended that you back it up with:
+
+```bash
+mv $HOME/.config/ags $HOME/.config/ags.bkup
+```
+
+Otherwise you can use this command to install the panel:
+
+```bash
+git clone https://github.com/Jas-SinghFSU/HyprPanel.git && \
+ ln -s $(pwd)/HyprPanel $HOME/.config/ags
+```
+
+### Nerd Fonts
+
+Additionally, you need to ensure that you have a [Nerd Font](https://www.nerdfonts.com/font-downloads) installed for your icons to render properly.
+
+### Launch the panel
+
+Afterwards you can run the panel with the following command in your terminal:
+
+```bash
+ags
+```
+
+Or you can add it to your Hyprland config (hyprland.conf) to auto-start with:
+
+```bash
+exec-once = ags
+```
+
+### 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.
+
+```nix
+# flake.nix
+
+{
+ inputs.hyprpanel.url = "github:Jas-SinghFSU/HyprPanel";
+ # ...
+
+ outputs = { self, nixpkgs, ... }@inputs:
+ let
+ # ...
+ system = "x86_64-linux"; # change to whatever your system should be.
+ pkgs = import nixpkgs {
+ inherit system;
+ # ...
+ overlays = [
+ inputs.hyprpanel.overlay
+ ];
+ };
+ in {
+ # ...
+ }
+}
+```
+
+Once you've set up the overlay, you can reference HyprPanel with `pkgs.hyprpanel` as if it were any other Nix package. This means you can reference it as a NixOS system/user package, a Home-Manager user package, or as a direct reference in your Hyprland configuration (if your configuration is managed by Home-Manager). The first three methods will add it to your `$PATH` (first globally, second two user-only), however the final will not.
+
+```nix
+# configuration.nix
+
+# install it as a system package
+environment.systemPackages = with pkgs; [
+ # ...
+ hyprpanel
+ # ...
+];
+
+# or install it as a user package
+users.users..packages = with pkgs; [
+ # ...
+ hyprpanel
+ # ...
+];
+
+
+# home.nix
+
+# install it as a user package with home-manager
+home.packages = with pkgs; [
+ # ...
+ hyprpanel
+ # ...
+];
+
+# or reference it directly in your Hyprland configuration
+wayland.windowManager.hyprland.settings.exec-once = [
+ "${pkgs.hyprpanel}/bin/hyprpanel"
+];
+
+```
+
+### Notifications
+
+HyprPanel handles notifications through the AGS built-in notification service. If you're already using a notification daemon such as Dunst or Mako, you may have to stop them to prevent conflicts with HyprPanel.
+
+> NOTE: If your system is in a language other than English, the resource monitor in the dashboard may not work properly.
+
+## Configuration
+
+The HyprPanel comes with a configuration menu which is available by opening the Dashboard menu (click the button in the bar with the default - Arch - icon) and then clicking the Gear icon.
+
+### Size
+
+The panel is automatically scaled based on your font size in `Configuration > General`.
+
+### Specifying bar layouts per monitor
+
+To specify layouts for each monitor you can create a JSON object such as:
+
+```JSON
+{
+ "0": {
+ "left": [
+ "dashboard",
+ "workspaces",
+ "windowtitle"
+ ],
+ "middle": [
+ "media"
+ ],
+ "right": [
+ "volume",
+ "clock",
+ "notifications"
+ ]
+ },
+ "1": {
+ "left": [
+ "dashboard",
+ "workspaces",
+ "windowtitle"
+ ],
+ "middle": [
+ "media"
+ ],
+ "right": [
+ "volume",
+ "clock",
+ "notifications"
+ ]
+ },
+ "2": {
+ "left": [
+ "dashboard",
+ "workspaces",
+ "windowtitle"
+ ],
+ "middle": [
+ "media"
+ ],
+ "right": [
+ "volume",
+ "network",
+ "bluetooth",
+ "systray",
+ "clock",
+ "notifications"
+ ]
+ }
+}
+```
+
+Where each monitor is defined by its index (0, 1, 2 in this case) and each section (left, middle, right) contains one or more of the following modules:
+
+```js
+'battery';
+'dashboard';
+'workspaces';
+'windowtitle';
+'media';
+'notifications';
+'volume';
+'network';
+'bluetooth';
+'clock';
+'systray';
+```
+
+Since the text-box in the options dialog isn't sufficient, it is recommended that you create this JSON configuration in a text editor elsewhere and paste it into the layout text-box under Configuration > Bar > "Bar Layouts for Monitors".
+
+### Additional Configuration
+
+#### GPU Tracking
+
+If you have an NVidia GPU, you can track your GPU usage in your Dashboard by going to your `Settings > Configuration > Dashboard Menu > Track GPU` and turning it on.
diff --git a/ags/HyprPanel/assets/21210205.png b/ags/HyprPanel/assets/21210205.png
new file mode 100644
index 0000000..c1f3edc
Binary files /dev/null and b/ags/HyprPanel/assets/21210205.png differ
diff --git a/ags/HyprPanel/assets/HyprPanel.png b/ags/HyprPanel/assets/HyprPanel.png
new file mode 100644
index 0000000..3ea158a
Binary files /dev/null and b/ags/HyprPanel/assets/HyprPanel.png differ
diff --git a/ags/HyprPanel/assets/Mocha_Wolf.png b/ags/HyprPanel/assets/Mocha_Wolf.png
new file mode 100644
index 0000000..219857c
Binary files /dev/null and b/ags/HyprPanel/assets/Mocha_Wolf.png differ
diff --git a/ags/HyprPanel/assets/fonts/JetBrainsMonoNerdFontPropo-Bold.ttf b/ags/HyprPanel/assets/fonts/JetBrainsMonoNerdFontPropo-Bold.ttf
new file mode 100644
index 0000000..3bed27f
Binary files /dev/null and b/ags/HyprPanel/assets/fonts/JetBrainsMonoNerdFontPropo-Bold.ttf differ
diff --git a/ags/HyprPanel/assets/fonts/JetBrainsMonoNerdFontPropo-ExtraBold.ttf b/ags/HyprPanel/assets/fonts/JetBrainsMonoNerdFontPropo-ExtraBold.ttf
new file mode 100644
index 0000000..99cfe5b
Binary files /dev/null and b/ags/HyprPanel/assets/fonts/JetBrainsMonoNerdFontPropo-ExtraBold.ttf differ
diff --git a/ags/HyprPanel/assets/fonts/JetBrainsMonoNerdFontPropo-ExtraLight.ttf b/ags/HyprPanel/assets/fonts/JetBrainsMonoNerdFontPropo-ExtraLight.ttf
new file mode 100644
index 0000000..1e8103b
Binary files /dev/null and b/ags/HyprPanel/assets/fonts/JetBrainsMonoNerdFontPropo-ExtraLight.ttf differ
diff --git a/ags/HyprPanel/assets/fonts/JetBrainsMonoNerdFontPropo-Light.ttf b/ags/HyprPanel/assets/fonts/JetBrainsMonoNerdFontPropo-Light.ttf
new file mode 100644
index 0000000..82dcd0b
Binary files /dev/null and b/ags/HyprPanel/assets/fonts/JetBrainsMonoNerdFontPropo-Light.ttf differ
diff --git a/ags/HyprPanel/assets/fonts/JetBrainsMonoNerdFontPropo-Medium.ttf b/ags/HyprPanel/assets/fonts/JetBrainsMonoNerdFontPropo-Medium.ttf
new file mode 100644
index 0000000..d134c56
Binary files /dev/null and b/ags/HyprPanel/assets/fonts/JetBrainsMonoNerdFontPropo-Medium.ttf differ
diff --git a/ags/HyprPanel/assets/fonts/JetBrainsMonoNerdFontPropo-Regular.ttf b/ags/HyprPanel/assets/fonts/JetBrainsMonoNerdFontPropo-Regular.ttf
new file mode 100644
index 0000000..de31c48
Binary files /dev/null and b/ags/HyprPanel/assets/fonts/JetBrainsMonoNerdFontPropo-Regular.ttf differ
diff --git a/ags/HyprPanel/assets/fonts/JetBrainsMonoNerdFontPropo-SemiBold.ttf b/ags/HyprPanel/assets/fonts/JetBrainsMonoNerdFontPropo-SemiBold.ttf
new file mode 100644
index 0000000..059f6b3
Binary files /dev/null and b/ags/HyprPanel/assets/fonts/JetBrainsMonoNerdFontPropo-SemiBold.ttf differ
diff --git a/ags/HyprPanel/assets/fonts/JetBrainsMonoNerdFontPropo-Thin.ttf b/ags/HyprPanel/assets/fonts/JetBrainsMonoNerdFontPropo-Thin.ttf
new file mode 100644
index 0000000..8aad716
Binary files /dev/null and b/ags/HyprPanel/assets/fonts/JetBrainsMonoNerdFontPropo-Thin.ttf differ
diff --git a/ags/HyprPanel/assets/hp1.png b/ags/HyprPanel/assets/hp1.png
new file mode 100644
index 0000000..e42b363
Binary files /dev/null and b/ags/HyprPanel/assets/hp1.png differ
diff --git a/ags/HyprPanel/assets/hp2.png b/ags/HyprPanel/assets/hp2.png
new file mode 100644
index 0000000..a912e32
Binary files /dev/null and b/ags/HyprPanel/assets/hp2.png differ
diff --git a/ags/HyprPanel/assets/hyprpanel.png b/ags/HyprPanel/assets/hyprpanel.png
new file mode 100644
index 0000000..b4707c0
Binary files /dev/null and b/ags/HyprPanel/assets/hyprpanel.png differ
diff --git a/ags/HyprPanel/assets/solar_system_mocha.png b/ags/HyprPanel/assets/solar_system_mocha.png
new file mode 100644
index 0000000..14ca154
Binary files /dev/null and b/ags/HyprPanel/assets/solar_system_mocha.png differ
diff --git a/ags/HyprPanel/config.js b/ags/HyprPanel/config.js
new file mode 100644
index 0000000..01d6ae8
--- /dev/null
+++ b/ags/HyprPanel/config.js
@@ -0,0 +1,58 @@
+import GLib from 'gi://GLib';
+
+const main = '/tmp/ags/hyprpanel/main.js';
+const entry = `${App.configDir}/main.ts`;
+const bundler = GLib.getenv('AGS_BUNDLER') || 'bun';
+
+const v = {
+ ags: pkg.version?.split('.').map(Number) || [],
+ expect: [1, 8, 1],
+};
+
+try {
+ switch (bundler) {
+ case 'bun':
+ await Utils.execAsync([
+ 'bun',
+ 'build',
+ entry,
+ '--outfile',
+ main,
+ '--external',
+ 'resource://*',
+ '--external',
+ 'gi://*',
+ '--external',
+ 'file://*',
+ ]);
+ break;
+
+ case 'esbuild':
+ await Utils.execAsync([
+ 'esbuild',
+ '--bundle',
+ entry,
+ '--format=esm',
+ `--outfile=${main}`,
+ '--external:resource://*',
+ '--external:gi://*',
+ '--external:file://*',
+ ]);
+ break;
+
+ default:
+ throw `"${bundler}" is not a valid bundler`;
+ }
+
+ if (v.ags[1] < v.expect[1] || v.ags[2] < v.expect[2]) {
+ print(`HyprPanel needs atleast v${v.expect.join('.')} of AGS, yours is v${v.ags.join('.')}`);
+ App.quit();
+ }
+
+ await import(`file://${main}`);
+} catch (error) {
+ console.error(error);
+ App.quit();
+}
+
+export {};
diff --git a/ags/HyprPanel/customModules/PollVar.ts b/ags/HyprPanel/customModules/PollVar.ts
new file mode 100644
index 0000000..2381e89
--- /dev/null
+++ b/ags/HyprPanel/customModules/PollVar.ts
@@ -0,0 +1,77 @@
+import GLib from 'gi://GLib?version=2.0';
+import { GenericFunction } from 'lib/types/customModules/generic';
+import { Bind } from 'lib/types/variable';
+import { Variable as VariableType } from 'types/variable';
+
+/**
+ * @param {VariableType} targetVariable - The Variable to update with the function's result.
+ * @param {Array} trackers - Array of trackers to watch.
+ * @param {Bind} pollingInterval - The polling interval in milliseconds.
+ * @param {GenericFunction} someFunc - The function to execute at each interval, which updates the Variable.
+ * @param {...P} params - Parameters to pass to someFunc.
+ */
+export const pollVariable = >(
+ targetVariable: VariableType,
+ trackers: Array,
+ pollingInterval: Bind,
+ someFunc: F,
+ ...params: P
+): void => {
+ let intervalInstance: number | null = null;
+
+ const intervalFn = (pollIntrvl: number): void => {
+ if (intervalInstance !== null) {
+ GLib.source_remove(intervalInstance);
+ }
+
+ intervalInstance = Utils.interval(pollIntrvl, () => {
+ targetVariable.value = someFunc(...params);
+ });
+ };
+
+ Utils.merge([pollingInterval, ...trackers], (pollIntrvl: number) => {
+ intervalFn(pollIntrvl);
+ });
+};
+
+/**
+ * @param {VariableType} targetVariable - The Variable to update with the result of the command.
+ * @param {Array} trackers - Array of trackers to watch.
+ * @param {Bind} pollingInterval - The polling interval in milliseconds.
+ * @param {string} someCommand - The bash command to execute.
+ * @param {GenericFunction} someFunc - The function to execute after processing the command result;
+ * with the first argument being the result of the command execution.
+ * @param {...P} params - Additional parameters to pass to someFunc.
+ */
+export const pollVariableBash = >(
+ targetVariable: VariableType,
+ trackers: Array,
+ pollingInterval: Bind,
+ someCommand: string,
+ someFunc: F,
+ ...params: P
+): void => {
+ let intervalInstance: number | null = null;
+
+ const intervalFn = (pollIntrvl: number): void => {
+ if (intervalInstance !== null) {
+ GLib.source_remove(intervalInstance);
+ }
+
+ intervalInstance = Utils.interval(pollIntrvl, () => {
+ Utils.execAsync(`bash -c "${someCommand}"`)
+ .then((res: string) => {
+ try {
+ targetVariable.value = someFunc(res, ...params);
+ } catch (error) {
+ console.warn(`An error occurred when running interval bash function: ${error}`);
+ }
+ })
+ .catch((err) => console.error(`Error running command "${someCommand}": ${err}`));
+ });
+ };
+
+ Utils.merge([pollingInterval, ...trackers], (pollIntrvl: number) => {
+ intervalFn(pollIntrvl);
+ });
+};
diff --git a/ags/HyprPanel/customModules/config.ts b/ags/HyprPanel/customModules/config.ts
new file mode 100644
index 0000000..36e2c4c
--- /dev/null
+++ b/ags/HyprPanel/customModules/config.ts
@@ -0,0 +1,595 @@
+import { Option } from 'widget/settings/shared/Option';
+import { Header } from 'widget/settings/shared/Header';
+
+import options from 'options';
+import Scrollable from 'types/widgets/scrollable';
+import { Attribute, GtkWidget } from 'lib/types/widget';
+
+export const CustomModuleSettings = (): Scrollable =>
+ Widget.Scrollable({
+ vscroll: 'automatic',
+ hscroll: 'automatic',
+ class_name: 'menu-theme-page customModules paged-container',
+ child: Widget.Box({
+ class_name: 'menu-theme-page paged-container',
+ vertical: true,
+ children: [
+ /*
+ ************************************
+ * GENERAL *
+ ************************************
+ */
+ Header('General'),
+ Option({
+ opt: options.bar.customModules.scrollSpeed,
+ title: 'Scrolling Speed',
+ type: 'number',
+ }),
+
+ /*
+ ************************************
+ * RAM *
+ ************************************
+ */
+ Header('RAM'),
+ Option({
+ opt: options.theme.bar.buttons.modules.ram.enableBorder,
+ title: 'Button Border',
+ type: 'boolean',
+ }),
+ Option({
+ opt: options.bar.customModules.ram.icon,
+ title: 'Ram Icon',
+ type: 'string',
+ }),
+ Option({
+ opt: options.bar.customModules.ram.label,
+ title: 'Show Label',
+ type: 'boolean',
+ }),
+ Option({
+ opt: options.theme.bar.buttons.modules.ram.spacing,
+ title: 'Spacing',
+ type: 'string',
+ }),
+ Option({
+ opt: options.bar.customModules.ram.labelType,
+ title: 'Label Type',
+ type: 'enum',
+ enums: ['used/total', 'used', 'free', 'percentage'],
+ }),
+ Option({
+ opt: options.bar.customModules.ram.round,
+ title: 'Round',
+ type: 'boolean',
+ }),
+ Option({
+ opt: options.bar.customModules.ram.pollingInterval,
+ title: 'Polling Interval',
+ type: 'number',
+ min: 100,
+ max: 60 * 24 * 1000,
+ increment: 1000,
+ }),
+ Option({
+ opt: options.bar.customModules.ram.leftClick,
+ title: 'Left Click',
+ type: 'string',
+ }),
+ Option({
+ opt: options.bar.customModules.ram.rightClick,
+ title: 'Right Click',
+ type: 'string',
+ }),
+ Option({
+ opt: options.bar.customModules.ram.middleClick,
+ title: 'Middle Click',
+ type: 'string',
+ }),
+
+ /*
+ ************************************
+ * CPU *
+ ************************************
+ */
+ Header('CPU'),
+ Option({
+ opt: options.theme.bar.buttons.modules.cpu.enableBorder,
+ title: 'Button Border',
+ type: 'boolean',
+ }),
+ Option({
+ opt: options.bar.customModules.cpu.icon,
+ title: 'Cpu Icon',
+ type: 'string',
+ }),
+ Option({
+ opt: options.bar.customModules.cpu.label,
+ title: 'Show Label',
+ type: 'boolean',
+ }),
+ Option({
+ opt: options.theme.bar.buttons.modules.cpu.spacing,
+ title: 'Spacing',
+ type: 'string',
+ }),
+ Option({
+ opt: options.bar.customModules.cpu.round,
+ title: 'Round',
+ type: 'boolean',
+ }),
+ Option({
+ opt: options.bar.customModules.cpu.pollingInterval,
+ title: 'Polling Interval',
+ type: 'number',
+ min: 100,
+ max: 60 * 24 * 1000,
+ increment: 1000,
+ }),
+ Option({
+ opt: options.bar.customModules.cpu.leftClick,
+ title: 'Left Click',
+ type: 'string',
+ }),
+ Option({
+ opt: options.bar.customModules.cpu.rightClick,
+ title: 'Right Click',
+ type: 'string',
+ }),
+ Option({
+ opt: options.bar.customModules.cpu.middleClick,
+ title: 'Middle Click',
+ type: 'string',
+ }),
+ Option({
+ opt: options.bar.customModules.cpu.scrollUp,
+ title: 'Scroll Up',
+ type: 'string',
+ }),
+ Option({
+ opt: options.bar.customModules.cpu.scrollDown,
+ title: 'Scroll Down',
+ type: 'string',
+ }),
+
+ /*
+ ************************************
+ * STORAGE *
+ ************************************
+ */
+ Header('Storage'),
+ Option({
+ opt: options.theme.bar.buttons.modules.storage.enableBorder,
+ title: 'Button Border',
+ type: 'boolean',
+ }),
+ Option({
+ opt: options.bar.customModules.storage.icon,
+ title: 'Storage Icon',
+ type: 'string',
+ }),
+ Option({
+ opt: options.bar.customModules.storage.label,
+ title: 'Show Label',
+ type: 'boolean',
+ }),
+ Option({
+ opt: options.theme.bar.buttons.modules.storage.spacing,
+ title: 'Spacing',
+ type: 'string',
+ }),
+ Option({
+ opt: options.bar.customModules.storage.labelType,
+ title: 'Label Type',
+ type: 'enum',
+ enums: ['used/total', 'used', 'free', 'percentage'],
+ }),
+ Option({
+ opt: options.bar.customModules.storage.round,
+ title: 'Round',
+ type: 'boolean',
+ }),
+ Option({
+ opt: options.bar.customModules.storage.pollingInterval,
+ title: 'Polling Interval',
+ type: 'number',
+ min: 100,
+ max: 60 * 24 * 1000,
+ increment: 1000,
+ }),
+ Option({
+ opt: options.bar.customModules.storage.leftClick,
+ title: 'Left Click',
+ type: 'string',
+ }),
+ Option({
+ opt: options.bar.customModules.storage.rightClick,
+ title: 'Right Click',
+ type: 'string',
+ }),
+ Option({
+ opt: options.bar.customModules.storage.middleClick,
+ title: 'Middle Click',
+ type: 'string',
+ }),
+
+ /*
+ ************************************
+ * NETSTAT *
+ ************************************
+ */
+ Header('Netstat'),
+ Option({
+ opt: options.theme.bar.buttons.modules.netstat.enableBorder,
+ title: 'Button Border',
+ type: 'boolean',
+ }),
+ Option({
+ opt: options.bar.customModules.netstat.networkInterface,
+ title: 'Network Interface',
+ subtitle:
+ "Name of the network interface to poll.\nHINT: Get list of interfaces with 'cat /proc/net/dev'",
+ type: 'string',
+ }),
+ Option({
+ opt: options.bar.customModules.netstat.icon,
+ title: 'Netstat Icon',
+ type: 'string',
+ }),
+ Option({
+ opt: options.bar.customModules.netstat.label,
+ title: 'Show Label',
+ type: 'boolean',
+ }),
+ Option({
+ opt: options.bar.customModules.netstat.rateUnit,
+ title: 'Rate Unit',
+ type: 'enum',
+ enums: ['GiB', 'MiB', 'KiB', 'auto'],
+ }),
+ Option({
+ opt: options.theme.bar.buttons.modules.netstat.spacing,
+ title: 'Spacing',
+ type: 'string',
+ }),
+ Option({
+ opt: options.bar.customModules.netstat.labelType,
+ title: 'Label Type',
+ type: 'enum',
+ enums: ['full', 'in', 'out'],
+ }),
+ Option({
+ opt: options.bar.customModules.netstat.round,
+ title: 'Round',
+ type: 'boolean',
+ }),
+ Option({
+ opt: options.bar.customModules.netstat.pollingInterval,
+ title: 'Polling Interval',
+ type: 'number',
+ min: 100,
+ max: 60 * 24 * 1000,
+ increment: 1000,
+ }),
+ Option({
+ opt: options.bar.customModules.netstat.leftClick,
+ title: 'Left Click',
+ type: 'string',
+ }),
+ Option({
+ opt: options.bar.customModules.netstat.rightClick,
+ title: 'Right Click',
+ type: 'string',
+ }),
+ Option({
+ opt: options.bar.customModules.netstat.middleClick,
+ title: 'Middle Click',
+ type: 'string',
+ }),
+
+ /*
+ ************************************
+ * KEYBOARD LAYOUT *
+ ************************************
+ */
+ Header('Keyboard Layout'),
+ Option({
+ opt: options.theme.bar.buttons.modules.kbLayout.enableBorder,
+ title: 'Button Border',
+ type: 'boolean',
+ }),
+ Option({
+ opt: options.bar.customModules.kbLayout.icon,
+ title: 'Keyboard Layout Icon',
+ type: 'string',
+ }),
+ Option({
+ opt: options.bar.customModules.kbLayout.label,
+ title: 'Show Label',
+ type: 'boolean',
+ }),
+ Option({
+ opt: options.bar.customModules.kbLayout.labelType,
+ title: 'Label Type',
+ type: 'enum',
+ enums: ['layout', 'code'],
+ }),
+ Option({
+ opt: options.theme.bar.buttons.modules.kbLayout.spacing,
+ title: 'Spacing',
+ type: 'string',
+ }),
+ Option({
+ opt: options.bar.customModules.kbLayout.leftClick,
+ title: 'Left Click',
+ type: 'string',
+ }),
+ Option({
+ opt: options.bar.customModules.kbLayout.rightClick,
+ title: 'Right Click',
+ type: 'string',
+ }),
+ Option({
+ opt: options.bar.customModules.kbLayout.middleClick,
+ title: 'Middle Click',
+ type: 'string',
+ }),
+ Option({
+ opt: options.bar.customModules.kbLayout.scrollUp,
+ title: 'Scroll Up',
+ type: 'string',
+ }),
+ Option({
+ opt: options.bar.customModules.kbLayout.scrollDown,
+ title: 'Scroll Down',
+ type: 'string',
+ }),
+
+ /*
+ ************************************
+ * UPDATES *
+ ************************************
+ */
+ Header('Updates'),
+ Option({
+ opt: options.theme.bar.buttons.modules.updates.enableBorder,
+ title: 'Button Border',
+ type: 'boolean',
+ }),
+ Option({
+ opt: options.bar.customModules.updates.updateCommand,
+ title: 'Check Updates Command',
+ type: 'string',
+ }),
+ Option({
+ opt: options.bar.customModules.updates.icon,
+ title: 'Updates Icon',
+ type: 'string',
+ }),
+ Option({
+ opt: options.bar.customModules.updates.label,
+ title: 'Show Label',
+ type: 'boolean',
+ }),
+ Option({
+ opt: options.bar.customModules.updates.padZero,
+ title: 'Pad with 0',
+ type: 'boolean',
+ }),
+ Option({
+ opt: options.theme.bar.buttons.modules.updates.spacing,
+ title: 'Spacing',
+ type: 'string',
+ }),
+ Option({
+ opt: options.bar.customModules.updates.pollingInterval,
+ title: 'Polling Interval',
+ type: 'number',
+ subtitle: "WARNING: Be careful of your package manager's rate limit.",
+ min: 100,
+ max: 60 * 24 * 1000,
+ increment: 1000,
+ }),
+ Option({
+ opt: options.bar.customModules.updates.leftClick,
+ title: 'Left Click',
+ type: 'string',
+ }),
+ Option({
+ opt: options.bar.customModules.updates.rightClick,
+ title: 'Right Click',
+ type: 'string',
+ }),
+ Option({
+ opt: options.bar.customModules.updates.middleClick,
+ title: 'Middle Click',
+ type: 'string',
+ }),
+ Option({
+ opt: options.bar.customModules.updates.scrollUp,
+ title: 'Scroll Up',
+ type: 'string',
+ }),
+ Option({
+ opt: options.bar.customModules.updates.scrollDown,
+ title: 'Scroll Down',
+ type: 'string',
+ }),
+
+ /*
+ ************************************
+ * SUBMAP *
+ ************************************
+ */
+ Header('Submap'),
+ Option({
+ opt: options.theme.bar.buttons.modules.submap.enableBorder,
+ title: 'Button Border',
+ type: 'boolean',
+ }),
+ Option({
+ opt: options.bar.customModules.submap.showSubmapName,
+ title: 'Show Submap Name',
+ subtitle:
+ 'When enabled, the name of the current submap will be displayed' +
+ ' instead of the Submap Enabled or Disabled text.',
+ type: 'boolean',
+ }),
+ Option({
+ opt: options.bar.customModules.submap.enabledIcon,
+ title: 'Enabled Icon',
+ type: 'string',
+ }),
+ Option({
+ opt: options.bar.customModules.submap.disabledIcon,
+ title: 'Disabled Icon',
+ type: 'string',
+ }),
+ Option({
+ opt: options.bar.customModules.submap.enabledText,
+ title: 'Enabled Text',
+ type: 'string',
+ }),
+ Option({
+ opt: options.bar.customModules.submap.disabledText,
+ title: 'Disabled Text',
+ type: 'string',
+ }),
+ Option({
+ opt: options.bar.customModules.submap.label,
+ title: 'Show Label',
+ type: 'boolean',
+ }),
+ Option({
+ opt: options.theme.bar.buttons.modules.submap.spacing,
+ title: 'Spacing',
+ type: 'string',
+ }),
+ Option({
+ opt: options.bar.customModules.submap.leftClick,
+ title: 'Left Click',
+ type: 'string',
+ }),
+ Option({
+ opt: options.bar.customModules.submap.rightClick,
+ title: 'Right Click',
+ type: 'string',
+ }),
+ Option({
+ opt: options.bar.customModules.submap.middleClick,
+ title: 'Middle Click',
+ type: 'string',
+ }),
+ Option({
+ opt: options.bar.customModules.submap.scrollUp,
+ title: 'Scroll Up',
+ type: 'string',
+ }),
+ Option({
+ opt: options.bar.customModules.submap.scrollDown,
+ title: 'Scroll Down',
+ type: 'string',
+ }),
+
+ /*
+ ************************************
+ * WEATHER *
+ ************************************
+ */
+ Header('Weather'),
+ Option({
+ opt: options.theme.bar.buttons.modules.weather.enableBorder,
+ title: 'Button Border',
+ type: 'boolean',
+ }),
+ Option({
+ opt: options.bar.customModules.weather.label,
+ title: 'Show Label',
+ type: 'boolean',
+ }),
+ Option({
+ opt: options.bar.customModules.weather.unit,
+ title: 'Units',
+ type: 'enum',
+ enums: ['imperial', 'metric'],
+ }),
+ Option({
+ opt: options.theme.bar.buttons.modules.weather.spacing,
+ title: 'Spacing',
+ type: 'string',
+ }),
+ Option({
+ opt: options.bar.customModules.weather.leftClick,
+ title: 'Left Click',
+ type: 'string',
+ }),
+ Option({
+ opt: options.bar.customModules.weather.rightClick,
+ title: 'Right Click',
+ type: 'string',
+ }),
+ Option({
+ opt: options.bar.customModules.weather.middleClick,
+ title: 'Middle Click',
+ type: 'string',
+ }),
+ Option({
+ opt: options.bar.customModules.weather.scrollUp,
+ title: 'Scroll Up',
+ type: 'string',
+ }),
+ Option({
+ opt: options.bar.customModules.weather.scrollDown,
+ title: 'Scroll Down',
+ type: 'string',
+ }),
+
+ /*
+ ************************************
+ * POWER *
+ ************************************
+ */
+ Header('Power'),
+ Option({
+ opt: options.theme.bar.buttons.modules.power.enableBorder,
+ title: 'Button Border',
+ type: 'boolean',
+ }),
+ Option({
+ opt: options.theme.bar.buttons.modules.power.spacing,
+ title: 'Spacing',
+ type: 'string',
+ }),
+ Option({
+ opt: options.bar.customModules.power.icon,
+ title: 'Power Button Icon',
+ type: 'string',
+ }),
+ Option({
+ opt: options.bar.customModules.power.leftClick,
+ title: 'Left Click',
+ type: 'string',
+ }),
+ Option({
+ opt: options.bar.customModules.power.rightClick,
+ title: 'Right Click',
+ type: 'string',
+ }),
+ Option({
+ opt: options.bar.customModules.power.middleClick,
+ title: 'Middle Click',
+ type: 'string',
+ }),
+ Option({
+ opt: options.bar.customModules.power.scrollUp,
+ title: 'Scroll Up',
+ type: 'string',
+ }),
+ Option({
+ opt: options.bar.customModules.power.scrollDown,
+ title: 'Scroll Down',
+ type: 'string',
+ }),
+ ],
+ }),
+ });
diff --git a/ags/HyprPanel/customModules/cpu/computeCPU.ts b/ags/HyprPanel/customModules/cpu/computeCPU.ts
new file mode 100644
index 0000000..460b1db
--- /dev/null
+++ b/ags/HyprPanel/customModules/cpu/computeCPU.ts
@@ -0,0 +1,20 @@
+// @ts-expect-error: This import is a special directive that tells the compiler to use the GTop library
+import GTop from 'gi://GTop';
+
+let previousCpuData = new GTop.glibtop_cpu();
+GTop.glibtop_get_cpu(previousCpuData);
+
+export const computeCPU = (): number => {
+ const currentCpuData = new GTop.glibtop_cpu();
+ GTop.glibtop_get_cpu(currentCpuData);
+
+ // Calculate the differences from the previous to current data
+ const totalDiff = currentCpuData.total - previousCpuData.total;
+ const idleDiff = currentCpuData.idle - previousCpuData.idle;
+
+ const cpuUsagePercentage = totalDiff > 0 ? ((totalDiff - idleDiff) / totalDiff) * 100 : 0;
+
+ previousCpuData = currentCpuData;
+
+ return cpuUsagePercentage;
+};
diff --git a/ags/HyprPanel/customModules/cpu/index.ts b/ags/HyprPanel/customModules/cpu/index.ts
new file mode 100644
index 0000000..a3848e4
--- /dev/null
+++ b/ags/HyprPanel/customModules/cpu/index.ts
@@ -0,0 +1,69 @@
+import options from 'options';
+
+// Module initializer
+import { module } from '../module';
+
+import Button from 'types/widgets/button';
+
+// Utility Methods
+import { inputHandler } from 'customModules/utils';
+import { computeCPU } from './computeCPU';
+import { pollVariable } from 'customModules/PollVar';
+import { BarBoxChild } from 'lib/types/bar';
+import { Attribute, Child } from 'lib/types/widget';
+
+// All the user configurable options for the cpu module that are needed
+const { label, round, leftClick, rightClick, middleClick, scrollUp, scrollDown, pollingInterval, icon } =
+ options.bar.customModules.cpu;
+
+export const cpuUsage = Variable(0);
+
+pollVariable(
+ // Variable to poll and update with the result of the function passed in
+ cpuUsage,
+ // Variables that should trigger the polling function to update when they change
+ [round.bind('value')],
+ // Interval at which to poll
+ pollingInterval.bind('value'),
+ // Function to execute to get the network data
+ computeCPU,
+);
+
+export const Cpu = (): BarBoxChild => {
+ const renderLabel = (cpuUsg: number, rnd: boolean): string => {
+ return rnd ? `${Math.round(cpuUsg)}%` : `${cpuUsg.toFixed(2)}%`;
+ };
+
+ const cpuModule = module({
+ textIcon: icon.bind('value'),
+ label: Utils.merge([cpuUsage.bind('value'), round.bind('value')], (cpuUsg, rnd) => {
+ return renderLabel(cpuUsg, rnd);
+ }),
+ tooltipText: 'CPU',
+ boxClass: 'cpu',
+ showLabelBinding: label.bind('value'),
+ props: {
+ setup: (self: Button) => {
+ inputHandler(self, {
+ onPrimaryClick: {
+ cmd: leftClick,
+ },
+ onSecondaryClick: {
+ cmd: rightClick,
+ },
+ onMiddleClick: {
+ cmd: middleClick,
+ },
+ onScrollUp: {
+ cmd: scrollUp,
+ },
+ onScrollDown: {
+ cmd: scrollDown,
+ },
+ });
+ },
+ },
+ });
+
+ return cpuModule;
+};
diff --git a/ags/HyprPanel/customModules/kblayout/getLayout.ts b/ags/HyprPanel/customModules/kblayout/getLayout.ts
new file mode 100644
index 0000000..9c42071
--- /dev/null
+++ b/ags/HyprPanel/customModules/kblayout/getLayout.ts
@@ -0,0 +1,28 @@
+import {
+ HyprctlDeviceLayout,
+ HyprctlKeyboard,
+ KbLabelType,
+ LayoutKeys,
+ LayoutValues,
+} from 'lib/types/customModules/kbLayout';
+import { layoutMap } from './layouts';
+
+export const getKeyboardLayout = (obj: string, format: KbLabelType): LayoutKeys | LayoutValues => {
+ const hyprctlDevices: HyprctlDeviceLayout = JSON.parse(obj);
+ const keyboards = hyprctlDevices['keyboards'];
+
+ if (keyboards.length === 0) {
+ return format === 'code' ? 'Unknown' : 'Unknown Layout';
+ }
+
+ let mainKb = keyboards.find((kb: HyprctlKeyboard) => kb.main);
+
+ if (!mainKb) {
+ mainKb = keyboards[keyboards.length - 1];
+ }
+
+ const layout: LayoutKeys = mainKb['active_keymap'] as LayoutKeys;
+ const foundLayout: LayoutValues = layoutMap[layout];
+
+ return format === 'code' ? foundLayout || layout : layout;
+};
diff --git a/ags/HyprPanel/customModules/kblayout/index.ts b/ags/HyprPanel/customModules/kblayout/index.ts
new file mode 100644
index 0000000..8c566e5
--- /dev/null
+++ b/ags/HyprPanel/customModules/kblayout/index.ts
@@ -0,0 +1,73 @@
+const hyprland = await Service.import('hyprland');
+
+import options from 'options';
+import { module } from '../module';
+
+import { inputHandler } from 'customModules/utils';
+import Gtk from 'types/@girs/gtk-3.0/gtk-3.0';
+import Button from 'types/widgets/button';
+import Label from 'types/widgets/label';
+import { getKeyboardLayout } from './getLayout';
+import { BarBoxChild } from 'lib/types/bar';
+import { Attribute, Child } from 'lib/types/widget';
+
+const { label, labelType, icon, leftClick, rightClick, middleClick, scrollUp, scrollDown } =
+ options.bar.customModules.kbLayout;
+
+export const KbInput = (): BarBoxChild => {
+ const keyboardModule = module({
+ textIcon: icon.bind('value'),
+ tooltipText: '',
+ labelHook: (self: Label): void => {
+ self.hook(
+ hyprland,
+ () => {
+ Utils.execAsync('hyprctl devices -j')
+ .then((obj) => {
+ self.label = getKeyboardLayout(obj, labelType.value);
+ })
+ .catch((err) => {
+ console.error(err);
+ });
+ },
+ 'keyboard-layout',
+ );
+
+ self.hook(labelType, () => {
+ Utils.execAsync('hyprctl devices -j')
+ .then((obj) => {
+ self.label = getKeyboardLayout(obj, labelType.value);
+ })
+ .catch((err) => {
+ console.error(err);
+ });
+ });
+ },
+
+ boxClass: 'kblayout',
+ showLabelBinding: label.bind('value'),
+ props: {
+ setup: (self: Button) => {
+ inputHandler(self, {
+ onPrimaryClick: {
+ cmd: leftClick,
+ },
+ onSecondaryClick: {
+ cmd: rightClick,
+ },
+ onMiddleClick: {
+ cmd: middleClick,
+ },
+ onScrollUp: {
+ cmd: scrollUp,
+ },
+ onScrollDown: {
+ cmd: scrollDown,
+ },
+ });
+ },
+ },
+ });
+
+ return keyboardModule;
+};
diff --git a/ags/HyprPanel/customModules/kblayout/layouts.ts b/ags/HyprPanel/customModules/kblayout/layouts.ts
new file mode 100644
index 0000000..ccd37c6
--- /dev/null
+++ b/ags/HyprPanel/customModules/kblayout/layouts.ts
@@ -0,0 +1,585 @@
+export const layoutMap = {
+ 'Abkhazian (Russia)': 'RU (Ab)',
+ Akan: 'GH (Akan)',
+ Albanian: 'AL',
+ 'Albanian (Plisi)': 'AL (Plisi)',
+ 'Albanian (Veqilharxhi)': 'AL (Veqilharxhi)',
+ Amharic: 'ET',
+ Arabic: 'ARA',
+ 'Arabic (Algeria)': 'DZ (Ar)',
+ 'Arabic (AZERTY, Eastern Arabic numerals)': 'ARA (Azerty Digits)',
+ 'Arabic (AZERTY)': 'ARA (Azerty)',
+ 'Arabic (Buckwalter)': 'ARA (Buckwalter)',
+ 'Arabic (Eastern Arabic numerals)': 'ARA (Digits)',
+ 'Arabic (Macintosh)': 'ARA (Mac)',
+ 'Arabic (Morocco)': 'MA',
+ 'Arabic (OLPC)': 'ARA (Olpc)',
+ 'Arabic (Pakistan)': 'PK (Ara)',
+ 'Arabic (QWERTY, Eastern Arabic numerals)': 'ARA (Qwerty Digits)',
+ 'Arabic (QWERTY)': 'ARA (Qwerty)',
+ 'Arabic (Syria)': 'SY',
+ Armenian: 'AM',
+ 'Armenian (alt. eastern)': 'AM (Eastern-Alt)',
+ 'Armenian (alt. phonetic)': 'AM (Phonetic-Alt)',
+ 'Armenian (eastern)': 'AM (Eastern)',
+ 'Armenian (phonetic)': 'AM (Phonetic)',
+ 'Armenian (western)': 'AM (Western)',
+ 'Asturian (Spain, with bottom-dot H and L)': 'ES (Ast)',
+ Avatime: 'GH (Avn)',
+ Azerbaijani: 'AZ',
+ 'Azerbaijani (Cyrillic)': 'AZ (Cyrillic)',
+ 'Azerbaijani (Iran)': 'IR (Azb)',
+ Bambara: 'ML',
+ Bangla: 'BD',
+ 'Bangla (India, Baishakhi InScript)': 'IN (Ben Inscript)',
+ 'Bangla (India, Baishakhi)': 'IN (Ben Baishakhi)',
+ 'Bangla (India, Bornona)': 'IN (Ben Bornona)',
+ 'Bangla (India, Gitanjali)': 'IN (Ben Gitanjali)',
+ 'Bangla (India, Probhat)': 'IN (Ben Probhat)',
+ 'Bangla (India)': 'IN (Ben)',
+ 'Bangla (Probhat)': 'BD (Probhat)',
+ Bashkirian: 'RU (Bak)',
+ Belarusian: 'BY',
+ 'Belarusian (intl.)': 'BY (Intl)',
+ 'Belarusian (Latin)': 'BY (Latin)',
+ 'Belarusian (legacy)': 'BY (Legacy)',
+ 'Belarusian (phonetic)': 'BY (Phonetic)',
+ Belgian: 'BE',
+ 'Belgian (alt.)': 'BE (Oss)',
+ 'Belgian (ISO, alt.)': 'BE (Iso-Alternate)',
+ 'Belgian (Latin-9 only, alt.)': 'BE (Oss Latin9)',
+ 'Belgian (no dead keys)': 'BE (Nodeadkeys)',
+ 'Belgian (Wang 724 AZERTY)': 'BE (Wang)',
+ 'Berber (Algeria, Latin)': 'DZ',
+ 'Berber (Algeria, Tifinagh)': 'DZ (Ber)',
+ 'Berber (Morocco, Tifinagh alt.)': 'MA (Tifinagh-Alt)',
+ 'Berber (Morocco, Tifinagh extended phonetic)': 'MA (Tifinagh-Extended-Phonetic)',
+ 'Berber (Morocco, Tifinagh extended)': 'MA (Tifinagh-Extended)',
+ 'Berber (Morocco, Tifinagh phonetic, alt.)': 'MA (Tifinagh-Alt-Phonetic)',
+ 'Berber (Morocco, Tifinagh phonetic)': 'MA (Tifinagh-Phonetic)',
+ 'Berber (Morocco, Tifinagh)': 'MA (Tifinagh)',
+ Bosnian: 'BA',
+ 'Bosnian (US, with Bosnian digraphs)': 'BA (Unicodeus)',
+ 'Bosnian (US)': 'BA (Us)',
+ 'Bosnian (with Bosnian digraphs)': 'BA (Unicode)',
+ 'Bosnian (with guillemets)': 'BA (Alternatequotes)',
+ Braille: 'BRAI',
+ 'Braille (left-handed inverted thumb)': 'BRAI (Left Hand Invert)',
+ 'Braille (left-handed)': 'BRAI (Left Hand)',
+ 'Braille (right-handed inverted thumb)': 'BRAI (Right Hand Invert)',
+ 'Braille (right-handed)': 'BRAI (Right Hand)',
+ 'Breton (France)': 'FR (Bre)',
+ Bulgarian: 'BG',
+ 'Bulgarian (enhanced)': 'BG (Bekl)',
+ 'Bulgarian (new phonetic)': 'BG (Bas Phonetic)',
+ 'Bulgarian (traditional phonetic)': 'BG (Phonetic)',
+ Burmese: 'MM',
+ 'Burmese Zawgyi': 'MM (Zawgyi)',
+ 'Cameroon (AZERTY, intl.)': 'CM (Azerty)',
+ 'Cameroon (Dvorak, intl.)': 'CM (Dvorak)',
+ 'Cameroon Multilingual (QWERTY, intl.)': 'CM (Qwerty)',
+ 'Canadian (CSA)': 'CA (Multix)',
+ 'Catalan (Spain, with middle-dot L)': 'ES (Cat)',
+ Cherokee: 'US (Chr)',
+ Chinese: 'CN',
+ Chuvash: 'RU (Cv)',
+ 'Chuvash (Latin)': 'RU (Cv Latin)',
+ CloGaelach: 'IE (CloGaelach)',
+ 'Crimean Tatar (Turkish Alt-Q)': 'UA (Crh Alt)',
+ 'Crimean Tatar (Turkish F)': 'UA (Crh F)',
+ 'Crimean Tatar (Turkish Q)': 'UA (Crh)',
+ Croatian: 'HR',
+ 'Croatian (US, with Croatian digraphs)': 'HR (Unicodeus)',
+ 'Croatian (US)': 'HR (Us)',
+ 'Croatian (with Croatian digraphs)': 'HR (Unicode)',
+ 'Croatian (with guillemets)': 'HR (Alternatequotes)',
+ Czech: 'CZ',
+ 'Czech (QWERTY, extended backslash)': 'CZ (Qwerty Bksl)',
+ 'Czech (QWERTY, Macintosh)': 'CZ (Qwerty-Mac)',
+ 'Czech (QWERTY)': 'CZ (Qwerty)',
+ 'Czech (UCW, only accented letters)': 'CZ (Ucw)',
+ 'Czech (US, Dvorak, UCW support)': 'CZ (Dvorak-Ucw)',
+ 'Czech (with <\\|> key)': 'CZ (Bksl)',
+ Danish: 'DK',
+ 'Danish (Dvorak)': 'DK (Dvorak)',
+ 'Danish (Macintosh, no dead keys)': 'DK (Mac Nodeadkeys)',
+ 'Danish (Macintosh)': 'DK (Mac)',
+ 'Danish (no dead keys)': 'DK (Nodeadkeys)',
+ 'Danish (Windows)': 'DK (Winkeys)',
+ Dari: 'AF',
+ 'Dari (Afghanistan, OLPC)': 'AF (Fa-Olpc)',
+ Dhivehi: 'MV',
+ Dutch: 'NL',
+ 'Dutch (Macintosh)': 'NL (Mac)',
+ 'Dutch (standard)': 'NL (Std)',
+ 'Dutch (US)': 'NL (Us)',
+ Dzongkha: 'BT',
+ 'English (Australian)': 'AU',
+ 'English (Cameroon)': 'CM',
+ 'English (Canada)': 'CA (Eng)',
+ 'English (classic Dvorak)': 'US (Dvorak-Classic)',
+ 'English (Colemak-DH ISO)': 'US (Colemak Dh Iso)',
+ 'English (Colemak-DH)': 'US (Colemak Dh)',
+ 'English (Colemak)': 'US (Colemak)',
+ 'English (Dvorak, alt. intl.)': 'US (Dvorak-Alt-Intl)',
+ 'English (Dvorak, intl., with dead keys)': 'US (Dvorak-Intl)',
+ 'English (Dvorak, left-handed)': 'US (Dvorak-L)',
+ 'English (Dvorak, Macintosh)': 'US (Dvorak-Mac)',
+ 'English (Dvorak, right-handed)': 'US (Dvorak-R)',
+ 'English (Dvorak)': 'US (Dvorak)',
+ 'English (Ghana, GILLBT)': 'GH (Gillbt)',
+ 'English (Ghana, multilingual)': 'GH (Generic)',
+ 'English (Ghana)': 'GH',
+ 'English (India, with rupee)': 'IN (Eng)',
+ 'English (intl., with AltGr dead keys)': 'US (Altgr-Intl)',
+ 'English (Macintosh)': 'US (Mac)',
+ 'English (Mali, US, intl.)': 'ML (Us-Intl)',
+ 'English (Mali, US, Macintosh)': 'ML (Us-Mac)',
+ 'English (Nigeria)': 'NG',
+ 'English (Norman)': 'US (Norman)',
+ 'English (programmer Dvorak)': 'US (Dvp)',
+ 'English (South Africa)': 'ZA',
+ 'English (the divide/multiply toggle the layout)': 'US (Olpc2)',
+ 'English (UK, Colemak-DH)': 'GB (Colemak Dh)',
+ 'English (UK, Colemak)': 'GB (Colemak)',
+ 'English (UK, Dvorak, with UK punctuation)': 'GB (Dvorakukp)',
+ 'English (UK, Dvorak)': 'GB (Dvorak)',
+ 'English (UK, extended, Windows)': 'GB (Extd)',
+ 'English (UK, intl., with dead keys)': 'GB (Intl)',
+ 'English (UK, Macintosh, intl.)': 'GB (Mac Intl)',
+ 'English (UK, Macintosh)': 'GB (Mac)',
+ 'English (UK)': 'GB',
+ 'English (US, alt. intl.)': 'US (Alt-Intl)',
+ 'English (US, euro on 5)': 'US (Euro)',
+ 'English (US, intl., with dead keys)': 'US (Intl)',
+ 'English (US, Symbolic)': 'US (Symbolic)',
+ 'English (US)': 'US',
+ 'English (Workman, intl., with dead keys)': 'US (Workman-Intl)',
+ 'English (Workman)': 'US (Workman)',
+ Esperanto: 'EPO',
+ 'Esperanto (Brazil, Nativo)': 'BR (Nativo-Epo)',
+ 'Esperanto (legacy)': 'EPO (Legacy)',
+ 'Esperanto (Portugal, Nativo)': 'PT (Nativo-Epo)',
+ Estonian: 'EE',
+ 'Estonian (Dvorak)': 'EE (Dvorak)',
+ 'Estonian (no dead keys)': 'EE (Nodeadkeys)',
+ 'Estonian (US)': 'EE (Us)',
+ Ewe: 'GH (Ewe)',
+ Faroese: 'FO',
+ 'Faroese (no dead keys)': 'FO (Nodeadkeys)',
+ Filipino: 'PH',
+ 'Filipino (Capewell-Dvorak, Baybayin)': 'PH (Capewell-Dvorak-Bay)',
+ 'Filipino (Capewell-Dvorak, Latin)': 'PH (Capewell-Dvorak)',
+ 'Filipino (Capewell-QWERF 2006, Baybayin)': 'PH (Capewell-Qwerf2k6-Bay)',
+ 'Filipino (Capewell-QWERF 2006, Latin)': 'PH (Capewell-Qwerf2k6)',
+ 'Filipino (Colemak, Baybayin)': 'PH (Colemak-Bay)',
+ 'Filipino (Colemak, Latin)': 'PH (Colemak)',
+ 'Filipino (Dvorak, Baybayin)': 'PH (Dvorak-Bay)',
+ 'Filipino (Dvorak, Latin)': 'PH (Dvorak)',
+ 'Filipino (QWERTY, Baybayin)': 'PH (Qwerty-Bay)',
+ Finnish: 'FI',
+ 'Finnish (classic, no dead keys)': 'FI (Nodeadkeys)',
+ 'Finnish (classic)': 'FI (Classic)',
+ 'Finnish (Macintosh)': 'FI (Mac)',
+ 'Finnish (Windows)': 'FI (Winkeys)',
+ French: 'FR',
+ 'French (alt., Latin-9 only)': 'FR (Oss Latin9)',
+ 'French (alt., no dead keys)': 'FR (Oss Nodeadkeys)',
+ 'French (alt.)': 'FR (Oss)',
+ 'French (AZERTY, AFNOR)': 'FR (Afnor)',
+ 'French (AZERTY)': 'FR (Azerty)',
+ 'French (BEPO, AFNOR)': 'FR (Bepo Afnor)',
+ 'French (BEPO, Latin-9 only)': 'FR (Bepo Latin9)',
+ 'French (BEPO)': 'FR (Bepo)',
+ 'French (Cameroon)': 'CM (French)',
+ 'French (Canada, Dvorak)': 'CA (Fr-Dvorak)',
+ 'French (Canada, legacy)': 'CA (Fr-Legacy)',
+ 'French (Canada)': 'CA',
+ 'French (Democratic Republic of the Congo)': 'CD',
+ 'French (Dvorak)': 'FR (Dvorak)',
+ 'French (legacy, alt., no dead keys)': 'FR (Latin9 Nodeadkeys)',
+ 'French (legacy, alt.)': 'FR (Latin9)',
+ 'French (Macintosh)': 'FR (Mac)',
+ 'French (Mali, alt.)': 'ML (Fr-Oss)',
+ 'French (Morocco)': 'MA (French)',
+ 'French (no dead keys)': 'FR (Nodeadkeys)',
+ 'French (Switzerland, Macintosh)': 'CH (Fr Mac)',
+ 'French (Switzerland, no dead keys)': 'CH (Fr Nodeadkeys)',
+ 'French (Switzerland)': 'CH (Fr)',
+ 'French (Togo)': 'TG',
+ 'French (US)': 'FR (Us)',
+ 'Friulian (Italy)': 'IT (Fur)',
+ Fula: 'GH (Fula)',
+ Ga: 'GH (Ga)',
+ Georgian: 'GE',
+ 'Georgian (ergonomic)': 'GE (Ergonomic)',
+ 'Georgian (France, AZERTY Tskapo)': 'FR (Geo)',
+ 'Georgian (Italy)': 'IT (Geo)',
+ 'Georgian (MESS)': 'GE (Mess)',
+ German: 'DE',
+ 'German (Austria, Macintosh)': 'AT (Mac)',
+ 'German (Austria, no dead keys)': 'AT (Nodeadkeys)',
+ 'German (Austria)': 'AT',
+ 'German (dead acute)': 'DE (Deadacute)',
+ 'German (dead grave acute)': 'DE (Deadgraveacute)',
+ 'German (dead tilde)': 'DE (Deadtilde)',
+ 'German (Dvorak)': 'DE (Dvorak)',
+ 'German (E1)': 'DE (E1)',
+ 'German (E2)': 'DE (E2)',
+ 'German (Macintosh, no dead keys)': 'DE (Mac Nodeadkeys)',
+ 'German (Macintosh)': 'DE (Mac)',
+ 'German (Neo 2)': 'DE (Neo)',
+ 'German (no dead keys)': 'DE (Nodeadkeys)',
+ 'German (QWERTY)': 'DE (Qwerty)',
+ 'German (Switzerland, legacy)': 'CH (Legacy)',
+ 'German (Switzerland, Macintosh)': 'CH (De Mac)',
+ 'German (Switzerland, no dead keys)': 'CH (De Nodeadkeys)',
+ 'German (Switzerland)': 'CH',
+ 'German (T3)': 'DE (T3)',
+ 'German (US)': 'DE (Us)',
+ Greek: 'GR',
+ 'Greek (extended)': 'GR (Extended)',
+ 'Greek (no dead keys)': 'GR (Nodeadkeys)',
+ 'Greek (polytonic)': 'GR (Polytonic)',
+ 'Greek (simple)': 'GR (Simple)',
+ Gujarati: 'IN (Guj)',
+ 'Hanyu Pinyin Letters (with AltGr dead keys)': 'CN (Altgr-Pinyin)',
+ 'Hausa (Ghana)': 'GH (Hausa)',
+ 'Hausa (Nigeria)': 'NG (Hausa)',
+ Hawaiian: 'US (Haw)',
+ Hebrew: 'IL',
+ 'Hebrew (Biblical, Tiro)': 'IL (Biblical)',
+ 'Hebrew (lyx)': 'IL (Lyx)',
+ 'Hebrew (phonetic)': 'IL (Phonetic)',
+ 'Hindi (Bolnagri)': 'IN (Bolnagri)',
+ 'Hindi (KaGaPa, phonetic)': 'IN (Hin-Kagapa)',
+ 'Hindi (Wx)': 'IN (Hin-Wx)',
+ Hungarian: 'HU',
+ 'Hungarian (no dead keys)': 'HU (Nodeadkeys)',
+ 'Hungarian (QWERTY, 101-key, comma, dead keys)': 'HU (101 Qwerty Comma Dead)',
+ 'Hungarian (QWERTY, 101-key, comma, no dead keys)': 'HU (101 Qwerty Comma Nodead)',
+ 'Hungarian (QWERTY, 101-key, dot, dead keys)': 'HU (101 Qwerty Dot Dead)',
+ 'Hungarian (QWERTY, 101-key, dot, no dead keys)': 'HU (101 Qwerty Dot Nodead)',
+ 'Hungarian (QWERTY, 102-key, comma, dead keys)': 'HU (102 Qwerty Comma Dead)',
+ 'Hungarian (QWERTY, 102-key, comma, no dead keys)': 'HU (102 Qwerty Comma Nodead)',
+ 'Hungarian (QWERTY, 102-key, dot, dead keys)': 'HU (102 Qwerty Dot Dead)',
+ 'Hungarian (QWERTY, 102-key, dot, no dead keys)': 'HU (102 Qwerty Dot Nodead)',
+ 'Hungarian (QWERTY)': 'HU (Qwerty)',
+ 'Hungarian (QWERTZ, 101-key, comma, dead keys)': 'HU (101 Qwertz Comma Dead)',
+ 'Hungarian (QWERTZ, 101-key, comma, no dead keys)': 'HU (101 Qwertz Comma Nodead)',
+ 'Hungarian (QWERTZ, 101-key, dot, dead keys)': 'HU (101 Qwertz Dot Dead)',
+ 'Hungarian (QWERTZ, 101-key, dot, no dead keys)': 'HU (101 Qwertz Dot Nodead)',
+ 'Hungarian (QWERTZ, 102-key, comma, dead keys)': 'HU (102 Qwertz Comma Dead)',
+ 'Hungarian (QWERTZ, 102-key, comma, no dead keys)': 'HU (102 Qwertz Comma Nodead)',
+ 'Hungarian (QWERTZ, 102-key, dot, dead keys)': 'HU (102 Qwertz Dot Dead)',
+ 'Hungarian (QWERTZ, 102-key, dot, no dead keys)': 'HU (102 Qwertz Dot Nodead)',
+ 'Hungarian (standard)': 'HU (Standard)',
+ Icelandic: 'IS',
+ 'Icelandic (Dvorak)': 'IS (Dvorak)',
+ 'Icelandic (Macintosh, legacy)': 'IS (Mac Legacy)',
+ 'Icelandic (Macintosh)': 'IS (Mac)',
+ Igbo: 'NG (Igbo)',
+ Indian: 'IN',
+ 'Indic IPA': 'IN (Iipa)',
+ 'Indonesian (Arab Melayu, extended phonetic)': 'ID (Melayu-Phoneticx)',
+ 'Indonesian (Arab Melayu, phonetic)': 'ID (Melayu-Phonetic)',
+ 'Indonesian (Arab Pegon, phonetic)': 'ID (Pegon-Phonetic)',
+ 'Indonesian (Latin)': 'ID',
+ Inuktitut: 'CA (Ike)',
+ Iraqi: 'IQ',
+ Irish: 'IE',
+ 'Irish (UnicodeExpert)': 'IE (UnicodeExpert)',
+ Italian: 'IT',
+ 'Italian (IBM 142)': 'IT (Ibm)',
+ 'Italian (intl., with dead keys)': 'IT (Intl)',
+ 'Italian (Macintosh)': 'IT (Mac)',
+ 'Italian (no dead keys)': 'IT (Nodeadkeys)',
+ 'Italian (US)': 'IT (Us)',
+ 'Italian (Windows)': 'IT (Winkeys)',
+ Japanese: 'JP',
+ 'Japanese (Dvorak)': 'JP (Dvorak)',
+ 'Japanese (Kana 86)': 'JP (Kana86)',
+ 'Japanese (Kana)': 'JP (Kana)',
+ 'Japanese (Macintosh)': 'JP (Mac)',
+ 'Japanese (OADG 109A)': 'JP (OADG109A)',
+ Javanese: 'ID (Javanese)',
+ 'Kabyle (AZERTY, with dead keys)': 'DZ (Azerty-Deadkeys)',
+ 'Kabyle (QWERTY, UK, with dead keys)': 'DZ (Qwerty-Gb-Deadkeys)',
+ 'Kabyle (QWERTY, US, with dead keys)': 'DZ (Qwerty-Us-Deadkeys)',
+ Kalmyk: 'RU (Xal)',
+ Kannada: 'IN (Kan)',
+ 'Kannada (KaGaPa, phonetic)': 'IN (Kan-Kagapa)',
+ Kashubian: 'PL (Csb)',
+ Kazakh: 'KZ',
+ 'Kazakh (extended)': 'KZ (Ext)',
+ 'Kazakh (Latin)': 'KZ (Latin)',
+ 'Kazakh (with Russian)': 'KZ (Kazrus)',
+ 'Khmer (Cambodia)': 'KH',
+ Kikuyu: 'KE (Kik)',
+ Komi: 'RU (Kom)',
+ Korean: 'KR',
+ 'Korean (101/104-key compatible)': 'KR (Kr104)',
+ 'Kurdish (Iran, Arabic-Latin)': 'IR (Ku Ara)',
+ 'Kurdish (Iran, F)': 'IR (Ku F)',
+ 'Kurdish (Iran, Latin Alt-Q)': 'IR (Ku Alt)',
+ 'Kurdish (Iran, Latin Q)': 'IR (Ku)',
+ 'Kurdish (Iraq, Arabic-Latin)': 'IQ (Ku Ara)',
+ 'Kurdish (Iraq, F)': 'IQ (Ku F)',
+ 'Kurdish (Iraq, Latin Alt-Q)': 'IQ (Ku Alt)',
+ 'Kurdish (Iraq, Latin Q)': 'IQ (Ku)',
+ 'Kurdish (Syria, F)': 'SY (Ku F)',
+ 'Kurdish (Syria, Latin Alt-Q)': 'SY (Ku Alt)',
+ 'Kurdish (Syria, Latin Q)': 'SY (Ku)',
+ 'Kurdish (Turkey, F)': 'TR (Ku F)',
+ 'Kurdish (Turkey, Latin Alt-Q)': 'TR (Ku Alt)',
+ 'Kurdish (Turkey, Latin Q)': 'TR (Ku)',
+ Kyrgyz: 'KG',
+ 'Kyrgyz (phonetic)': 'KG (Phonetic)',
+ Lao: 'LA',
+ 'Lao (STEA)': 'LA (Stea)',
+ Latvian: 'LV',
+ 'Latvian (adapted)': 'LV (Adapted)',
+ 'Latvian (apostrophe)': 'LV (Apostrophe)',
+ 'Latvian (ergonomic, ŪGJRMV)': 'LV (Ergonomic)',
+ 'Latvian (F)': 'LV (Fkey)',
+ 'Latvian (modern)': 'LV (Modern)',
+ 'Latvian (tilde)': 'LV (Tilde)',
+ Lithuanian: 'LT',
+ 'Lithuanian (IBM LST 1205-92)': 'LT (Ibm)',
+ 'Lithuanian (LEKP)': 'LT (Lekp)',
+ 'Lithuanian (LEKPa)': 'LT (Lekpa)',
+ 'Lithuanian (Ratise)': 'LT (Ratise)',
+ 'Lithuanian (standard)': 'LT (Std)',
+ 'Lithuanian (US)': 'LT (Us)',
+ 'Lower Sorbian': 'DE (Dsb)',
+ 'Lower Sorbian (QWERTZ)': 'DE (Dsb Qwertz)',
+ Macedonian: 'MK',
+ 'Macedonian (no dead keys)': 'MK (Nodeadkeys)',
+ 'Malay (Jawi, Arabic Keyboard)': 'MY',
+ 'Malay (Jawi, phonetic)': 'MY (Phonetic)',
+ Malayalam: 'IN (Mal)',
+ 'Malayalam (enhanced InScript, with rupee)': 'IN (Mal Enhanced)',
+ 'Malayalam (Lalitha)': 'IN (Mal Lalitha)',
+ Maltese: 'MT',
+ 'Maltese (UK, with AltGr overrides)': 'MT (Alt-Gb)',
+ 'Maltese (US, with AltGr overrides)': 'MT (Alt-Us)',
+ 'Maltese (US)': 'MT (Us)',
+ 'Manipuri (Eeyek)': 'IN (Eeyek)',
+ Maori: 'MAO',
+ 'Marathi (enhanced InScript)': 'IN (Marathi)',
+ 'Marathi (KaGaPa, phonetic)': 'IN (Mar-Kagapa)',
+ Mari: 'RU (Chm)',
+ Mmuock: 'CM (Mmuock)',
+ Moldavian: 'MD',
+ 'Moldavian (Gagauz)': 'MD (Gag)',
+ Mon: 'MM (Mnw)',
+ 'Mon (A1)': 'MM (Mnw-A1)',
+ Mongolian: 'MN',
+ 'Mongolian (Bichig)': 'CN (Mon Trad)',
+ 'Mongolian (Galik)': 'CN (Mon Trad Galik)',
+ 'Mongolian (Manchu Galik)': 'CN (Mon Manchu Galik)',
+ 'Mongolian (Manchu)': 'CN (Mon Trad Manchu)',
+ 'Mongolian (Todo Galik)': 'CN (Mon Todo Galik)',
+ 'Mongolian (Todo)': 'CN (Mon Trad Todo)',
+ 'Mongolian (Xibe)': 'CN (Mon Trad Xibe)',
+ Montenegrin: 'ME',
+ 'Montenegrin (Cyrillic, with guillemets)': 'ME (Cyrillicalternatequotes)',
+ 'Montenegrin (Cyrillic, ZE and ZHE swapped)': 'ME (Cyrillicyz)',
+ 'Montenegrin (Cyrillic)': 'ME (Cyrillic)',
+ 'Montenegrin (Latin, QWERTY)': 'ME (Latinyz)',
+ 'Montenegrin (Latin, Unicode, QWERTY)': 'ME (Latinunicodeyz)',
+ 'Montenegrin (Latin, Unicode)': 'ME (Latinunicode)',
+ 'Montenegrin (Latin, with guillemets)': 'ME (Latinalternatequotes)',
+ "N'Ko (AZERTY)": 'GN',
+ Nepali: 'NP',
+ 'Northern Saami (Finland)': 'FI (Smi)',
+ 'Northern Saami (Norway, no dead keys)': 'NO (Smi Nodeadkeys)',
+ 'Northern Saami (Norway)': 'NO (Smi)',
+ 'Northern Saami (Sweden)': 'SE (Smi)',
+ Norwegian: 'NO',
+ 'Norwegian (Colemak)': 'NO (Colemak)',
+ 'Norwegian (Dvorak)': 'NO (Dvorak)',
+ 'Norwegian (Macintosh, no dead keys)': 'NO (Mac Nodeadkeys)',
+ 'Norwegian (Macintosh)': 'NO (Mac)',
+ 'Norwegian (no dead keys)': 'NO (Nodeadkeys)',
+ 'Norwegian (Windows)': 'NO (Winkeys)',
+ Occitan: 'FR (Oci)',
+ Ogham: 'IE (Ogam)',
+ 'Ogham (IS434)': 'IE (Ogam Is434)',
+ 'Ol Chiki': 'IN (Olck)',
+ 'Old Turkic': 'TR (Otk)',
+ 'Old Turkic (F)': 'TR (Otkf)',
+ Oriya: 'IN (Ori)',
+ 'Oriya (Bolnagri)': 'IN (Ori-Bolnagri)',
+ 'Oriya (Wx)': 'IN (Ori-Wx)',
+ 'Ossetian (Georgia)': 'GE (Os)',
+ 'Ossetian (legacy)': 'RU (Os Legacy)',
+ 'Ossetian (Windows)': 'RU (Os Winkeys)',
+ 'Ottoman (F)': 'TR (Otf)',
+ 'Ottoman (Q)': 'TR (Ot)',
+ 'Pannonian Rusyn': 'RS (Rue)',
+ Pashto: 'AF (Ps)',
+ 'Pashto (Afghanistan, OLPC)': 'AF (Ps-Olpc)',
+ Persian: 'IR',
+ 'Persian (with Persian keypad)': 'IR (Pes Keypad)',
+ Polish: 'PL',
+ 'Polish (British keyboard)': 'GB (Pl)',
+ 'Polish (Dvorak, with Polish quotes on key 1)': 'PL (Dvorak Altquotes)',
+ 'Polish (Dvorak, with Polish quotes on quotemark key)': 'PL (Dvorak Quotes)',
+ 'Polish (Dvorak)': 'PL (Dvorak)',
+ 'Polish (legacy)': 'PL (Legacy)',
+ 'Polish (programmer Dvorak)': 'PL (Dvp)',
+ 'Polish (QWERTZ)': 'PL (Qwertz)',
+ Portuguese: 'PT',
+ 'Portuguese (Brazil, Dvorak)': 'BR (Dvorak)',
+ 'Portuguese (Brazil, IBM/Lenovo ThinkPad)': 'BR (Thinkpad)',
+ 'Portuguese (Brazil, Nativo for US keyboards)': 'BR (Nativo-Us)',
+ 'Portuguese (Brazil, Nativo)': 'BR (Nativo)',
+ 'Portuguese (Brazil, no dead keys)': 'BR (Nodeadkeys)',
+ 'Portuguese (Brazil)': 'BR',
+ 'Portuguese (Macintosh, no dead keys)': 'PT (Mac Nodeadkeys)',
+ 'Portuguese (Macintosh)': 'PT (Mac)',
+ 'Portuguese (Nativo for US keyboards)': 'PT (Nativo-Us)',
+ 'Portuguese (Nativo)': 'PT (Nativo)',
+ 'Portuguese (no dead keys)': 'PT (Nodeadkeys)',
+ 'Punjabi (Gurmukhi Jhelum)': 'IN (Jhelum)',
+ 'Punjabi (Gurmukhi)': 'IN (Guru)',
+ Romanian: 'RO',
+ 'Romanian (Germany, no dead keys)': 'DE (Ro Nodeadkeys)',
+ 'Romanian (Germany)': 'DE (Ro)',
+ 'Romanian (standard)': 'RO (Std)',
+ 'Romanian (Windows)': 'RO (Winkeys)',
+ Russian: 'RU',
+ 'Russian (Belarus)': 'BY (Ru)',
+ 'Russian (Czech, phonetic)': 'CZ (Rus)',
+ 'Russian (DOS)': 'RU (Dos)',
+ 'Russian (engineering, EN)': 'RU (Ruchey En)',
+ 'Russian (engineering, RU)': 'RU (Ruchey Ru)',
+ 'Russian (Georgia)': 'GE (Ru)',
+ 'Russian (Germany, phonetic)': 'DE (Ru)',
+ 'Russian (Kazakhstan, with Kazakh)': 'KZ (Ruskaz)',
+ 'Russian (legacy)': 'RU (Legacy)',
+ 'Russian (Macintosh)': 'RU (Mac)',
+ 'Russian (phonetic, AZERTY)': 'RU (Phonetic Azerty)',
+ 'Russian (phonetic, Dvorak)': 'RU (Phonetic Dvorak)',
+ 'Russian (phonetic, French)': 'RU (Phonetic Fr)',
+ 'Russian (phonetic, Windows)': 'RU (Phonetic Winkeys)',
+ 'Russian (phonetic, YAZHERTY)': 'RU (Phonetic YAZHERTY)',
+ 'Russian (phonetic)': 'RU (Phonetic)',
+ 'Russian (Poland, phonetic Dvorak)': 'PL (Ru Phonetic Dvorak)',
+ 'Russian (Sweden, phonetic, no dead keys)': 'SE (Rus Nodeadkeys)',
+ 'Russian (Sweden, phonetic)': 'SE (Rus)',
+ 'Russian (typewriter, legacy)': 'RU (Typewriter-Legacy)',
+ 'Russian (typewriter)': 'RU (Typewriter)',
+ 'Russian (Ukraine, standard RSTU)': 'UA (Rstu Ru)',
+ 'Russian (US, phonetic)': 'US (Rus)',
+ 'Saisiyat (Taiwan)': 'TW (Saisiyat)',
+ Samogitian: 'LT (Sgs)',
+ 'Sanskrit (KaGaPa, phonetic)': 'IN (San-Kagapa)',
+ 'Scottish Gaelic': 'GB (Gla)',
+ Serbian: 'RS',
+ 'Serbian (Cyrillic, with guillemets)': 'RS (Alternatequotes)',
+ 'Serbian (Cyrillic, ZE and ZHE swapped)': 'RS (Yz)',
+ 'Serbian (Latin, QWERTY)': 'RS (Latinyz)',
+ 'Serbian (Latin, Unicode, QWERTY)': 'RS (Latinunicodeyz)',
+ 'Serbian (Latin, Unicode)': 'RS (Latinunicode)',
+ 'Serbian (Latin, with guillemets)': 'RS (Latinalternatequotes)',
+ 'Serbian (Latin)': 'RS (Latin)',
+ 'Serbian (Russia)': 'RU (Srp)',
+ 'Serbo-Croatian (US)': 'US (Hbs)',
+ Shan: 'MM (Shn)',
+ 'Shan (Zawgyi Tai)': 'MM (Zgt)',
+ Sicilian: 'IT (Scn)',
+ Silesian: 'PL (Szl)',
+ Sindhi: 'PK (Snd)',
+ 'Sinhala (phonetic)': 'LK',
+ 'Sinhala (US)': 'LK (Us)',
+ Slovak: 'SK',
+ 'Slovak (extended backslash)': 'SK (Bksl)',
+ 'Slovak (QWERTY, extended backslash)': 'SK (Qwerty Bksl)',
+ 'Slovak (QWERTY)': 'SK (Qwerty)',
+ Slovenian: 'SI',
+ 'Slovenian (US)': 'SI (Us)',
+ 'Slovenian (with guillemets)': 'SI (Alternatequotes)',
+ Spanish: 'ES',
+ 'Spanish (dead tilde)': 'ES (Deadtilde)',
+ 'Spanish (Dvorak)': 'ES (Dvorak)',
+ 'Spanish (Latin American, Colemak)': 'LATAM (Colemak)',
+ 'Spanish (Latin American, dead tilde)': 'LATAM (Deadtilde)',
+ 'Spanish (Latin American, Dvorak)': 'LATAM (Dvorak)',
+ 'Spanish (Latin American, no dead keys)': 'LATAM (Nodeadkeys)',
+ 'Spanish (Latin American)': 'LATAM',
+ 'Spanish (Macintosh)': 'ES (Mac)',
+ 'Spanish (no dead keys)': 'ES (Nodeadkeys)',
+ 'Spanish (Windows)': 'ES (Winkeys)',
+ 'Swahili (Kenya)': 'KE',
+ 'Swahili (Tanzania)': 'TZ',
+ Swedish: 'SE',
+ 'Swedish (Dvorak, intl.)': 'SE (Us Dvorak)',
+ 'Swedish (Dvorak)': 'SE (Dvorak)',
+ 'Swedish (Macintosh)': 'SE (Mac)',
+ 'Swedish (no dead keys)': 'SE (Nodeadkeys)',
+ 'Swedish (Svdvorak)': 'SE (Svdvorak)',
+ 'Swedish (US)': 'SE (Us)',
+ 'Swedish Sign Language': 'SE (Swl)',
+ Syriac: 'SY (Syc)',
+ 'Syriac (phonetic)': 'SY (Syc Phonetic)',
+ Taiwanese: 'TW',
+ 'Taiwanese (indigenous)': 'TW (Indigenous)',
+ Tajik: 'TJ',
+ 'Tajik (legacy)': 'TJ (Legacy)',
+ 'Tamil (InScript, with Arabic numerals)': 'IN (Tam)',
+ 'Tamil (InScript, with Tamil numerals)': 'IN (Tam Tamilnumbers)',
+ "Tamil (Sri Lanka, TamilNet '99, TAB encoding)": 'LK (Tam TAB)',
+ "Tamil (Sri Lanka, TamilNet '99)": 'LK (Tam Unicode)',
+ "Tamil (TamilNet '99 with Tamil numerals)": 'IN (Tamilnet Tamilnumbers)',
+ "Tamil (TamilNet '99, TAB encoding)": 'IN (Tamilnet TAB)',
+ "Tamil (TamilNet '99, TSCII encoding)": 'IN (Tamilnet TSCII)',
+ "Tamil (TamilNet '99)": 'IN (Tamilnet)',
+ Tarifit: 'MA (Rif)',
+ Tatar: 'RU (Tt)',
+ Telugu: 'IN (Tel)',
+ 'Telugu (KaGaPa, phonetic)': 'IN (Tel-Kagapa)',
+ 'Telugu (Sarala)': 'IN (Tel-Sarala)',
+ Thai: 'TH',
+ 'Thai (Pattachote)': 'TH (Pat)',
+ 'Thai (TIS-820.2538)': 'TH (Tis)',
+ Tibetan: 'CN (Tib)',
+ 'Tibetan (with ASCII numerals)': 'CN (Tib Asciinum)',
+ Tswana: 'BW',
+ Turkish: 'TR',
+ 'Turkish (Alt-Q)': 'TR (Alt)',
+ 'Turkish (E)': 'TR (E)',
+ 'Turkish (F)': 'TR (F)',
+ 'Turkish (Germany)': 'DE (Tr)',
+ 'Turkish (intl., with dead keys)': 'TR (Intl)',
+ Turkmen: 'TM',
+ 'Turkmen (Alt-Q)': 'TM (Alt)',
+ Udmurt: 'RU (Udm)',
+ Ukrainian: 'UA',
+ 'Ukrainian (homophonic)': 'UA (Homophonic)',
+ 'Ukrainian (legacy)': 'UA (Legacy)',
+ 'Ukrainian (macOS)': 'UA (MacOS)',
+ 'Ukrainian (phonetic)': 'UA (Phonetic)',
+ 'Ukrainian (standard RSTU)': 'UA (Rstu)',
+ 'Ukrainian (typewriter)': 'UA (Typewriter)',
+ 'Ukrainian (Windows)': 'UA (Winkeys)',
+ 'Urdu (alt. phonetic)': 'IN (Urd-Phonetic3)',
+ 'Urdu (Pakistan, CRULP)': 'PK (Urd-Crulp)',
+ 'Urdu (Pakistan, NLA)': 'PK (Urd-Nla)',
+ 'Urdu (Pakistan)': 'PK',
+ 'Urdu (phonetic)': 'IN (Urd-Phonetic)',
+ 'Urdu (Windows)': 'IN (Urd-Winkeys)',
+ Uyghur: 'CN (Ug)',
+ Uzbek: 'UZ',
+ 'Uzbek (Afghanistan, OLPC)': 'AF (Uz-Olpc)',
+ 'Uzbek (Afghanistan)': 'AF (Uz)',
+ 'Uzbek (Latin)': 'UZ (Latin)',
+ Vietnamese: 'VN',
+ 'Vietnamese (France)': 'VN (Fr)',
+ 'Vietnamese (US)': 'VN (Us)',
+ Wolof: 'SN',
+ Yakut: 'RU (Sah)',
+ Yoruba: 'NG (Yoruba)',
+ 'Unknown Layout': 'Unknown',
+} as const;
diff --git a/ags/HyprPanel/customModules/module.ts b/ags/HyprPanel/customModules/module.ts
new file mode 100644
index 0000000..5438ade
--- /dev/null
+++ b/ags/HyprPanel/customModules/module.ts
@@ -0,0 +1,84 @@
+import { BarBoxChild, Module } from 'lib/types/bar';
+import { BarButtonStyles } from 'lib/types/options';
+import { Bind } from 'lib/types/variable';
+import { GtkWidget } from 'lib/types/widget';
+import options from 'options';
+import Gtk from 'types/@girs/gtk-3.0/gtk-3.0';
+
+const { style } = options.theme.bar.buttons;
+
+const undefinedVar = Variable(undefined);
+
+export const module = ({
+ icon,
+ textIcon,
+ label,
+ tooltipText,
+ boxClass,
+ props = {},
+ showLabelBinding = undefinedVar.bind('value'),
+ showLabel,
+ labelHook,
+ hook,
+}: Module): BarBoxChild => {
+ const getIconWidget = (): GtkWidget | undefined => {
+ let iconWidget: Gtk.Widget | undefined;
+
+ if (icon !== undefined) {
+ iconWidget = Widget.Icon({
+ class_name: `txt-icon bar-button-icon module-icon ${boxClass}`,
+ icon: icon,
+ }) as unknown as Gtk.Widget;
+ } else if (textIcon !== undefined) {
+ iconWidget = Widget.Label({
+ class_name: `txt-icon bar-button-icon module-icon ${boxClass}`,
+ label: textIcon,
+ }) as unknown as Gtk.Widget;
+ }
+
+ return iconWidget;
+ };
+
+ return {
+ component: Widget.Box({
+ className: Utils.merge(
+ [style.bind('value'), showLabelBinding],
+ (style: BarButtonStyles, shwLabel: boolean) => {
+ const shouldShowLabel = shwLabel || showLabel;
+ const styleMap = {
+ default: 'style1',
+ split: 'style2',
+ wave: 'style3',
+ wave2: 'style3',
+ };
+ return `${boxClass} ${styleMap[style]} ${!shouldShowLabel ? 'no-label' : ''}`;
+ },
+ ),
+ tooltip_text: tooltipText,
+ children: Utils.merge([showLabelBinding], (showLabelBinding): Gtk.Widget[] => {
+ const childrenArray: Gtk.Widget[] = [];
+ const iconWidget = getIconWidget();
+
+ if (iconWidget !== undefined) {
+ childrenArray.push(iconWidget);
+ }
+
+ if (showLabelBinding) {
+ childrenArray.push(
+ Widget.Label({
+ class_name: `bar-button-label module-label ${boxClass}`,
+ label: label,
+ setup: labelHook,
+ }) as unknown as Gtk.Widget,
+ );
+ }
+ return childrenArray;
+ }) as Bind,
+ setup: hook,
+ }),
+ tooltip_text: tooltipText,
+ isVisible: true,
+ boxClass,
+ props,
+ };
+};
diff --git a/ags/HyprPanel/customModules/netstat/computeNetwork.ts b/ags/HyprPanel/customModules/netstat/computeNetwork.ts
new file mode 100644
index 0000000..e1b2dcd
--- /dev/null
+++ b/ags/HyprPanel/customModules/netstat/computeNetwork.ts
@@ -0,0 +1,110 @@
+import GLib from 'gi://GLib';
+import { Variable as VariableType } from 'types/variable';
+import { NetworkResourceData } from 'lib/types/customModules/network';
+import { GET_DEFAULT_NETSTAT_DATA } from 'lib/types/defaults/netstat';
+import { RateUnit } from 'lib/types/bar';
+
+let previousNetUsage = { rx: 0, tx: 0, time: 0 };
+
+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`;
+ }
+};
+
+interface NetworkUsage {
+ name: string;
+ rx: number;
+ tx: number;
+}
+
+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 };
+};
+
+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;
+};
+
+const getNetworkUsage = (interfaceName: string = ''): NetworkUsage => {
+ const [success, data] = GLib.file_get_contents('/proc/net/dev');
+ if (!success) {
+ console.error('Failed to read /proc/net/dev');
+ return { name: '', rx: 0, tx: 0 };
+ }
+
+ const lines = new TextDecoder('utf-8').decode(data).split('\n');
+ for (const line of lines) {
+ const iface = parseInterfaceData(line);
+ if (isValidInterface(iface, interfaceName)) {
+ return iface!;
+ }
+ }
+
+ return { name: '', rx: 0, tx: 0 };
+};
+
+export const computeNetwork = (
+ round: VariableType,
+ interfaceNameVar: VariableType,
+ dataType: VariableType,
+): NetworkResourceData => {
+ const rateUnit = dataType.value;
+ const interfaceName = interfaceNameVar ? interfaceNameVar.value : '';
+
+ const DEFAULT_NETSTAT_DATA = GET_DEFAULT_NETSTAT_DATA(rateUnit);
+ try {
+ const { rx, tx, name } = getNetworkUsage(interfaceName);
+ const currentTime = Date.now();
+
+ if (!name) {
+ return DEFAULT_NETSTAT_DATA;
+ }
+
+ if (previousNetUsage.time === 0) {
+ previousNetUsage = { rx, tx, time: currentTime };
+ return DEFAULT_NETSTAT_DATA;
+ }
+
+ const timeDiff = Math.max((currentTime - previousNetUsage.time) / 1000, 1);
+ const rxRate = (rx - previousNetUsage.rx) / timeDiff;
+ const txRate = (tx - previousNetUsage.tx) / timeDiff;
+
+ previousNetUsage = { rx, tx, time: currentTime };
+
+ return {
+ in: formatRate(rxRate, rateUnit, round.value),
+ out: formatRate(txRate, rateUnit, round.value),
+ };
+ } catch (error) {
+ console.error('Error calculating network usage:', error);
+ return DEFAULT_NETSTAT_DATA;
+ }
+};
diff --git a/ags/HyprPanel/customModules/netstat/index.ts b/ags/HyprPanel/customModules/netstat/index.ts
new file mode 100644
index 0000000..abfe2ca
--- /dev/null
+++ b/ags/HyprPanel/customModules/netstat/index.ts
@@ -0,0 +1,105 @@
+import options from 'options';
+import { module } from '../module';
+import { inputHandler } from 'customModules/utils';
+import { computeNetwork } from './computeNetwork';
+import { BarBoxChild, NetstatLabelType } from 'lib/types/bar';
+import Button from 'types/widgets/button';
+import { NetworkResourceData } from 'lib/types/customModules/network';
+import { NETWORK_LABEL_TYPES } from 'lib/types/defaults/bar';
+import { GET_DEFAULT_NETSTAT_DATA } from 'lib/types/defaults/netstat';
+import { pollVariable } from 'customModules/PollVar';
+import { Attribute, Child } from 'lib/types/widget';
+
+const {
+ label,
+ labelType,
+ networkInterface,
+ rateUnit,
+ icon,
+ round,
+ leftClick,
+ rightClick,
+ middleClick,
+ pollingInterval,
+} = options.bar.customModules.netstat;
+
+export const networkUsage = Variable(GET_DEFAULT_NETSTAT_DATA(rateUnit.value));
+
+pollVariable(
+ // Variable to poll and update with the result of the function passed in
+ networkUsage,
+ // Variables that should trigger the polling function to update when they change
+ [rateUnit.bind('value'), networkInterface.bind('value'), round.bind('value')],
+ // Interval at which to poll
+ pollingInterval.bind('value'),
+ // Function to execute to get the network data
+ computeNetwork,
+ // Optional parameters to pass to the function
+ // round is a boolean that determines whether to round the values
+ round,
+ // Optional parameters to pass to the function
+ // networkInterface is the interface name to filter the data
+ networkInterface,
+ // Optional parameters to pass to the function
+ // rateUnit is the unit to display the data in
+ // e.g. KiB, MiB, GiB, etc.
+ rateUnit,
+);
+
+export const Netstat = (): BarBoxChild => {
+ const renderNetworkLabel = (lblType: NetstatLabelType, network: NetworkResourceData): string => {
+ switch (lblType) {
+ case 'in':
+ return `↓ ${network.in}`;
+ case 'out':
+ return `↑ ${network.out}`;
+ default:
+ return `↓ ${network.in} ↑ ${network.out}`;
+ }
+ };
+
+ const netstatModule = module({
+ textIcon: icon.bind('value'),
+ label: Utils.merge(
+ [networkUsage.bind('value'), labelType.bind('value')],
+ (network: NetworkResourceData, lblTyp: NetstatLabelType) => renderNetworkLabel(lblTyp, network),
+ ),
+ tooltipText: labelType.bind('value').as((lblTyp) => {
+ return lblTyp === 'full' ? 'Ingress / Egress' : lblTyp === 'in' ? 'Ingress' : 'Egress';
+ }),
+ boxClass: 'netstat',
+ showLabelBinding: label.bind('value'),
+ props: {
+ setup: (self: Button) => {
+ inputHandler(self, {
+ onPrimaryClick: {
+ cmd: leftClick,
+ },
+ onSecondaryClick: {
+ cmd: rightClick,
+ },
+ onMiddleClick: {
+ cmd: middleClick,
+ },
+ onScrollUp: {
+ fn: () => {
+ labelType.value = NETWORK_LABEL_TYPES[
+ (NETWORK_LABEL_TYPES.indexOf(labelType.value) + 1) % NETWORK_LABEL_TYPES.length
+ ] as NetstatLabelType;
+ },
+ },
+ onScrollDown: {
+ fn: () => {
+ labelType.value = NETWORK_LABEL_TYPES[
+ (NETWORK_LABEL_TYPES.indexOf(labelType.value) - 1 + NETWORK_LABEL_TYPES.length) %
+ NETWORK_LABEL_TYPES.length
+ ] as NetstatLabelType;
+ },
+ },
+ });
+ },
+ },
+ });
+
+ return netstatModule;
+};
diff --git a/ags/HyprPanel/customModules/power/index.ts b/ags/HyprPanel/customModules/power/index.ts
new file mode 100644
index 0000000..da678ad
--- /dev/null
+++ b/ags/HyprPanel/customModules/power/index.ts
@@ -0,0 +1,40 @@
+import options from 'options';
+import { module } from '../module';
+
+import { inputHandler } from 'customModules/utils';
+import Button from 'types/widgets/button';
+import { Attribute, Child } from 'lib/types/widget';
+import { BarBoxChild } from 'lib/types/bar';
+
+const { icon, leftClick, rightClick, middleClick, scrollUp, scrollDown } = options.bar.customModules.power;
+
+export const Power = (): BarBoxChild => {
+ const powerModule = module({
+ tooltipText: 'Power Menu',
+ textIcon: icon.bind('value'),
+ boxClass: 'powermodule',
+ props: {
+ setup: (self: Button) => {
+ inputHandler(self, {
+ onPrimaryClick: {
+ cmd: leftClick,
+ },
+ onSecondaryClick: {
+ cmd: rightClick,
+ },
+ onMiddleClick: {
+ cmd: middleClick,
+ },
+ onScrollUp: {
+ cmd: scrollUp,
+ },
+ onScrollDown: {
+ cmd: scrollDown,
+ },
+ });
+ },
+ },
+ });
+
+ return powerModule;
+};
diff --git a/ags/HyprPanel/customModules/ram/computeRam.ts b/ags/HyprPanel/customModules/ram/computeRam.ts
new file mode 100644
index 0000000..b8026c2
--- /dev/null
+++ b/ags/HyprPanel/customModules/ram/computeRam.ts
@@ -0,0 +1,40 @@
+const GLib = imports.gi.GLib;
+
+import { divide } from 'customModules/utils';
+import { GenericResourceData } from 'lib/types/customModules/generic';
+import { Variable as VariableType } from 'types/variable';
+
+export const calculateRamUsage = (round: VariableType): GenericResourceData => {
+ try {
+ const [success, meminfoBytes] = GLib.file_get_contents('/proc/meminfo');
+
+ if (!success || !meminfoBytes) {
+ throw new Error('Failed to read /proc/meminfo or file content is null.');
+ }
+
+ const meminfo = new TextDecoder('utf-8').decode(meminfoBytes);
+
+ const totalMatch = meminfo.match(/MemTotal:\s+(\d+)/);
+ const availableMatch = meminfo.match(/MemAvailable:\s+(\d+)/);
+
+ if (!totalMatch || !availableMatch) {
+ throw new Error('Failed to parse /proc/meminfo for memory values.');
+ }
+
+ const totalRamInBytes = parseInt(totalMatch[1], 10) * 1024;
+ const availableRamInBytes = parseInt(availableMatch[1], 10) * 1024;
+
+ let usedRam = totalRamInBytes - availableRamInBytes;
+ usedRam = isNaN(usedRam) || usedRam < 0 ? 0 : usedRam;
+
+ return {
+ percentage: divide([totalRamInBytes, usedRam], round.value),
+ total: totalRamInBytes,
+ used: usedRam,
+ free: availableRamInBytes,
+ };
+ } catch (error) {
+ console.error('Error calculating RAM usage:', error);
+ return { total: 0, used: 0, percentage: 0, free: 0 };
+ }
+};
diff --git a/ags/HyprPanel/customModules/ram/index.ts b/ags/HyprPanel/customModules/ram/index.ts
new file mode 100644
index 0000000..b651aaa
--- /dev/null
+++ b/ags/HyprPanel/customModules/ram/index.ts
@@ -0,0 +1,79 @@
+import options from 'options';
+
+// Module initializer
+import { module } from '../module';
+
+// Types
+import { GenericResourceData } from 'lib/types/customModules/generic';
+import Button from 'types/widgets/button';
+
+// Helper Methods
+import { calculateRamUsage } from './computeRam';
+
+// Utility Methods
+import { formatTooltip, inputHandler, renderResourceLabel } from 'customModules/utils';
+import { BarBoxChild, ResourceLabelType } from 'lib/types/bar';
+
+// Global Constants
+import { LABEL_TYPES } from 'lib/types/defaults/bar';
+import { pollVariable } from 'customModules/PollVar';
+import { Attribute, Child } from 'lib/types/widget';
+
+// All the user configurable options for the ram module that are needed
+const { label, labelType, round, leftClick, rightClick, middleClick, pollingInterval, icon } =
+ options.bar.customModules.ram;
+
+const defaultRamData: GenericResourceData = { total: 0, used: 0, percentage: 0, free: 0 };
+const ramUsage = Variable(defaultRamData);
+
+pollVariable(ramUsage, [round.bind('value')], pollingInterval.bind('value'), calculateRamUsage, round);
+
+export const Ram = (): BarBoxChild => {
+ const ramModule = module({
+ textIcon: icon.bind('value'),
+ label: Utils.merge(
+ [ramUsage.bind('value'), labelType.bind('value'), round.bind('value')],
+ (rmUsg: GenericResourceData, lblTyp: ResourceLabelType, round: boolean) => {
+ const returnValue = renderResourceLabel(lblTyp, rmUsg, round);
+
+ return returnValue;
+ },
+ ),
+ tooltipText: labelType.bind('value').as((lblTyp) => {
+ return formatTooltip('RAM', lblTyp);
+ }),
+ boxClass: 'ram',
+ showLabelBinding: label.bind('value'),
+ props: {
+ setup: (self: Button) => {
+ inputHandler(self, {
+ onPrimaryClick: {
+ cmd: leftClick,
+ },
+ onSecondaryClick: {
+ cmd: rightClick,
+ },
+ onMiddleClick: {
+ cmd: middleClick,
+ },
+ onScrollUp: {
+ fn: () => {
+ labelType.value = LABEL_TYPES[
+ (LABEL_TYPES.indexOf(labelType.value) + 1) % LABEL_TYPES.length
+ ] as ResourceLabelType;
+ },
+ },
+ onScrollDown: {
+ fn: () => {
+ labelType.value = LABEL_TYPES[
+ (LABEL_TYPES.indexOf(labelType.value) - 1 + LABEL_TYPES.length) % LABEL_TYPES.length
+ ] as ResourceLabelType;
+ },
+ },
+ });
+ },
+ },
+ });
+
+ return ramModule;
+};
diff --git a/ags/HyprPanel/customModules/storage/computeStorage.ts b/ags/HyprPanel/customModules/storage/computeStorage.ts
new file mode 100644
index 0000000..49cbc14
--- /dev/null
+++ b/ags/HyprPanel/customModules/storage/computeStorage.ts
@@ -0,0 +1,28 @@
+// @ts-expect-error is a special directive that tells the compiler to use the GTop library
+import GTop from 'gi://GTop';
+
+import { divide } from 'customModules/utils';
+import { Variable as VariableType } from 'types/variable';
+import { GenericResourceData } from 'lib/types/customModules/generic';
+
+export const computeStorage = (round: VariableType): 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.value),
+ };
+ } catch (error) {
+ console.error('Error calculating RAM usage:', error);
+ return { total: 0, used: 0, percentage: 0, free: 0 };
+ }
+};
diff --git a/ags/HyprPanel/customModules/storage/index.ts b/ags/HyprPanel/customModules/storage/index.ts
new file mode 100644
index 0000000..9cfeb85
--- /dev/null
+++ b/ags/HyprPanel/customModules/storage/index.ts
@@ -0,0 +1,68 @@
+import options from 'options';
+import { module } from '../module';
+
+import { formatTooltip, inputHandler, renderResourceLabel } from 'customModules/utils';
+import { computeStorage } from './computeStorage';
+import { BarBoxChild, ResourceLabelType } from 'lib/types/bar';
+import { GenericResourceData } from 'lib/types/customModules/generic';
+import Button from 'types/widgets/button';
+import { LABEL_TYPES } from 'lib/types/defaults/bar';
+import { pollVariable } from 'customModules/PollVar';
+import { Attribute, Child } from 'lib/types/widget';
+
+const { label, labelType, icon, round, leftClick, rightClick, middleClick, pollingInterval } =
+ options.bar.customModules.storage;
+
+const defaultStorageData = { total: 0, used: 0, percentage: 0, free: 0 };
+
+const storageUsage = Variable(defaultStorageData);
+
+pollVariable(storageUsage, [round.bind('value')], pollingInterval.bind('value'), computeStorage, round);
+
+export const Storage = (): BarBoxChild => {
+ const storageModule = module({
+ textIcon: icon.bind('value'),
+ label: Utils.merge(
+ [storageUsage.bind('value'), labelType.bind('value'), round.bind('value')],
+ (storage: GenericResourceData, lblTyp: ResourceLabelType, round: boolean) => {
+ return renderResourceLabel(lblTyp, storage, round);
+ },
+ ),
+ tooltipText: labelType.bind('value').as((lblTyp) => {
+ return formatTooltip('Storage', lblTyp);
+ }),
+ boxClass: 'storage',
+ showLabelBinding: label.bind('value'),
+ props: {
+ setup: (self: Button) => {
+ inputHandler(self, {
+ onPrimaryClick: {
+ cmd: leftClick,
+ },
+ onSecondaryClick: {
+ cmd: rightClick,
+ },
+ onMiddleClick: {
+ cmd: middleClick,
+ },
+ onScrollUp: {
+ fn: () => {
+ labelType.value = LABEL_TYPES[
+ (LABEL_TYPES.indexOf(labelType.value) + 1) % LABEL_TYPES.length
+ ] as ResourceLabelType;
+ },
+ },
+ onScrollDown: {
+ fn: () => {
+ labelType.value = LABEL_TYPES[
+ (LABEL_TYPES.indexOf(labelType.value) - 1 + LABEL_TYPES.length) % LABEL_TYPES.length
+ ] as ResourceLabelType;
+ },
+ },
+ });
+ },
+ },
+ });
+
+ return storageModule;
+};
diff --git a/ags/HyprPanel/customModules/submap/helpers.ts b/ags/HyprPanel/customModules/submap/helpers.ts
new file mode 100644
index 0000000..0c38718
--- /dev/null
+++ b/ags/HyprPanel/customModules/submap/helpers.ts
@@ -0,0 +1,20 @@
+import { Variable } from 'types/variable';
+
+const hyprland = await Service.import('hyprland');
+
+export const isSubmapEnabled = (submap: string, enabled: string, disabled: string): string => {
+ return submap !== 'default' ? enabled : disabled;
+};
+
+export const getInitialSubmap = (submapStatus: Variable): void => {
+ let submap = hyprland.message('submap');
+
+ const newLineCarriage = /\n/g;
+ submap = submap.replace(newLineCarriage, '');
+
+ if (submap === 'unknown request') {
+ submap = 'default';
+ }
+
+ submapStatus.value = submap;
+};
diff --git a/ags/HyprPanel/customModules/submap/index.ts b/ags/HyprPanel/customModules/submap/index.ts
new file mode 100644
index 0000000..c83316e
--- /dev/null
+++ b/ags/HyprPanel/customModules/submap/index.ts
@@ -0,0 +1,101 @@
+const hyprland = await Service.import('hyprland');
+import options from 'options';
+import { module } from '../module';
+
+import { inputHandler } from 'customModules/utils';
+import Button from 'types/widgets/button';
+import { Variable as VariableType } from 'types/variable';
+import { Attribute, Child } from 'lib/types/widget';
+import { BarBoxChild } from 'lib/types/bar';
+import { capitalizeFirstLetter } from 'lib/utils';
+import { getInitialSubmap, isSubmapEnabled } from './helpers';
+
+const {
+ label,
+ showSubmapName,
+ enabledIcon,
+ disabledIcon,
+ enabledText,
+ disabledText,
+ leftClick,
+ rightClick,
+ middleClick,
+ scrollUp,
+ scrollDown,
+} = options.bar.customModules.submap;
+
+const submapStatus: VariableType = Variable('default');
+
+hyprland.connect('submap', (_, currentSubmap) => {
+ if (currentSubmap.length === 0) {
+ submapStatus.value = 'default';
+ } else {
+ submapStatus.value = currentSubmap;
+ }
+});
+
+getInitialSubmap(submapStatus);
+
+export const Submap = (): BarBoxChild => {
+ const submapModule = module({
+ textIcon: Utils.merge(
+ [submapStatus.bind('value'), enabledIcon.bind('value'), disabledIcon.bind('value')],
+ (status, enabled, disabled) => {
+ return isSubmapEnabled(status, enabled, disabled);
+ },
+ ),
+ tooltipText: Utils.merge(
+ [
+ submapStatus.bind('value'),
+ enabledText.bind('value'),
+ disabledText.bind('value'),
+ showSubmapName.bind('value'),
+ ],
+ (status, enabled, disabled, showSmName) => {
+ if (showSmName) {
+ return capitalizeFirstLetter(status);
+ }
+ return isSubmapEnabled(status, enabled, disabled);
+ },
+ ),
+ boxClass: 'submap',
+ label: Utils.merge(
+ [
+ submapStatus.bind('value'),
+ enabledText.bind('value'),
+ disabledText.bind('value'),
+ showSubmapName.bind('value'),
+ ],
+ (status, enabled, disabled, showSmName) => {
+ if (showSmName) {
+ return capitalizeFirstLetter(status);
+ }
+ return isSubmapEnabled(status, enabled, disabled);
+ },
+ ),
+ showLabelBinding: label.bind('value'),
+ props: {
+ setup: (self: Button) => {
+ inputHandler(self, {
+ onPrimaryClick: {
+ cmd: leftClick,
+ },
+ onSecondaryClick: {
+ cmd: rightClick,
+ },
+ onMiddleClick: {
+ cmd: middleClick,
+ },
+ onScrollUp: {
+ cmd: scrollUp,
+ },
+ onScrollDown: {
+ cmd: scrollDown,
+ },
+ });
+ },
+ },
+ });
+
+ return submapModule;
+};
diff --git a/ags/HyprPanel/customModules/theme.ts b/ags/HyprPanel/customModules/theme.ts
new file mode 100644
index 0000000..2ac92ff
--- /dev/null
+++ b/ags/HyprPanel/customModules/theme.ts
@@ -0,0 +1,171 @@
+import { Option } from 'widget/settings/shared/Option';
+import { Header } from 'widget/settings/shared/Header';
+
+import options from 'options';
+import Scrollable from 'types/widgets/scrollable';
+import { Attribute, GtkWidget } from 'lib/types/widget';
+
+export const CustomModuleTheme = (): Scrollable => {
+ return Widget.Scrollable({
+ vscroll: 'automatic',
+ hscroll: 'automatic',
+ class_name: 'menu-theme-page customModules paged-container',
+ child: Widget.Box({
+ class_name: 'bar-theme-page paged-container',
+ vertical: true,
+ children: [
+ Header('RAM'),
+ Option({ opt: options.theme.bar.buttons.modules.ram.text, title: 'Text', type: 'color' }),
+ Option({ opt: options.theme.bar.buttons.modules.ram.icon, title: 'Icon', type: 'color' }),
+ Option({
+ opt: options.theme.bar.buttons.modules.ram.background,
+ title: 'Label Background',
+ type: 'color',
+ }),
+ Option({
+ opt: options.theme.bar.buttons.modules.ram.icon_background,
+ title: 'Icon Background',
+ subtitle:
+ "Applies a background color to the icon section of the button.\nRequires 'split' button styling.",
+ type: 'color',
+ }),
+ Option({ opt: options.theme.bar.buttons.modules.ram.border, title: 'Border', type: 'color' }),
+
+ Header('CPU'),
+ Option({ opt: options.theme.bar.buttons.modules.cpu.text, title: 'Text', type: 'color' }),
+ Option({ opt: options.theme.bar.buttons.modules.cpu.icon, title: 'Icon', type: 'color' }),
+ Option({
+ opt: options.theme.bar.buttons.modules.cpu.background,
+ title: 'Label Background',
+ type: 'color',
+ }),
+ Option({
+ opt: options.theme.bar.buttons.modules.cpu.icon_background,
+ title: 'Icon Background',
+ subtitle:
+ "Applies a background color to the icon section of the button.\nRequires 'split' button styling.",
+ type: 'color',
+ }),
+ Option({ opt: options.theme.bar.buttons.modules.cpu.border, title: 'Border', type: 'color' }),
+
+ Header('Storage'),
+ Option({ opt: options.theme.bar.buttons.modules.storage.text, title: 'Text', type: 'color' }),
+ Option({ opt: options.theme.bar.buttons.modules.storage.icon, title: 'Icon', type: 'color' }),
+ Option({
+ opt: options.theme.bar.buttons.modules.storage.background,
+ title: 'Label Background',
+ type: 'color',
+ }),
+ Option({
+ opt: options.theme.bar.buttons.modules.storage.icon_background,
+ title: 'Icon Background',
+ subtitle:
+ "Applies a background color to the icon section of the button.\nRequires 'split' button styling.",
+ type: 'color',
+ }),
+ Option({ opt: options.theme.bar.buttons.modules.storage.border, title: 'Border', type: 'color' }),
+
+ Header('Netstat'),
+ Option({ opt: options.theme.bar.buttons.modules.netstat.text, title: 'Text', type: 'color' }),
+ Option({ opt: options.theme.bar.buttons.modules.netstat.icon, title: 'Icon', type: 'color' }),
+ Option({
+ opt: options.theme.bar.buttons.modules.netstat.background,
+ title: 'Label Background',
+ type: 'color',
+ }),
+ Option({
+ opt: options.theme.bar.buttons.modules.netstat.icon_background,
+ title: 'Icon Background',
+ subtitle:
+ "Applies a background color to the icon section of the button.\nRequires 'split' button styling.",
+ type: 'color',
+ }),
+ Option({ opt: options.theme.bar.buttons.modules.netstat.border, title: 'Border', type: 'color' }),
+
+ Header('Keyboard Layout'),
+ Option({ opt: options.theme.bar.buttons.modules.kbLayout.text, title: 'Text', type: 'color' }),
+ Option({ opt: options.theme.bar.buttons.modules.kbLayout.icon, title: 'Icon', type: 'color' }),
+ Option({
+ opt: options.theme.bar.buttons.modules.kbLayout.background,
+ title: 'Label Background',
+ type: 'color',
+ }),
+ Option({
+ opt: options.theme.bar.buttons.modules.kbLayout.icon_background,
+ title: 'Icon Background',
+ subtitle:
+ "Applies a background color to the icon section of the button.\nRequires 'split' button styling.",
+ type: 'color',
+ }),
+ Option({ opt: options.theme.bar.buttons.modules.kbLayout.border, title: 'Border', type: 'color' }),
+
+ Header('Updates'),
+ Option({ opt: options.theme.bar.buttons.modules.updates.text, title: 'Text', type: 'color' }),
+ Option({ opt: options.theme.bar.buttons.modules.updates.icon, title: 'Icon', type: 'color' }),
+ Option({
+ opt: options.theme.bar.buttons.modules.updates.background,
+ title: 'Label Background',
+ type: 'color',
+ }),
+ Option({
+ opt: options.theme.bar.buttons.modules.updates.icon_background,
+ title: 'Icon Background',
+ subtitle:
+ "Applies a background color to the icon section of the button.\nRequires 'split' button styling.",
+ type: 'color',
+ }),
+ Option({ opt: options.theme.bar.buttons.modules.updates.border, title: 'Border', type: 'color' }),
+
+ Header('Submap'),
+ Option({ opt: options.theme.bar.buttons.modules.submap.text, title: 'Text', type: 'color' }),
+ Option({ opt: options.theme.bar.buttons.modules.submap.icon, title: 'Icon', type: 'color' }),
+ Option({
+ opt: options.theme.bar.buttons.modules.submap.background,
+ title: 'Label Background',
+ type: 'color',
+ }),
+ Option({
+ opt: options.theme.bar.buttons.modules.submap.icon_background,
+ title: 'Icon Background',
+ subtitle:
+ "Applies a background color to the icon section of the button.\nRequires 'split' button styling.",
+ type: 'color',
+ }),
+ Option({ opt: options.theme.bar.buttons.modules.submap.border, title: 'Border', type: 'color' }),
+
+ Header('Weather'),
+ Option({ opt: options.theme.bar.buttons.modules.weather.icon, title: 'Icon', type: 'color' }),
+ Option({ opt: options.theme.bar.buttons.modules.weather.text, title: 'Text', type: 'color' }),
+ Option({
+ opt: options.theme.bar.buttons.modules.weather.background,
+ title: 'Label Background',
+ type: 'color',
+ }),
+ Option({
+ opt: options.theme.bar.buttons.modules.weather.icon_background,
+ title: 'Icon Background',
+ subtitle:
+ "Applies a background color to the icon section of the button.\nRequires 'split' button styling.",
+ type: 'color',
+ }),
+ Option({ opt: options.theme.bar.buttons.modules.weather.border, title: 'Border', type: 'color' }),
+
+ Header('Power'),
+ Option({ opt: options.theme.bar.buttons.modules.power.icon, title: 'Icon', type: 'color' }),
+ Option({
+ opt: options.theme.bar.buttons.modules.power.background,
+ title: 'Label Background',
+ type: 'color',
+ }),
+ Option({
+ opt: options.theme.bar.buttons.modules.power.icon_background,
+ title: 'Icon Background',
+ subtitle:
+ "Applies a background color to the icon section of the button.\nRequires 'split' button styling.",
+ type: 'color',
+ }),
+ Option({ opt: options.theme.bar.buttons.modules.power.border, title: 'Border', type: 'color' }),
+ ],
+ }),
+ });
+};
diff --git a/ags/HyprPanel/customModules/updates/index.ts b/ags/HyprPanel/customModules/updates/index.ts
new file mode 100644
index 0000000..4be5ee8
--- /dev/null
+++ b/ags/HyprPanel/customModules/updates/index.ts
@@ -0,0 +1,70 @@
+import options from 'options';
+import { module } from '../module';
+
+import { inputHandler } from 'customModules/utils';
+import Button from 'types/widgets/button';
+import { Variable as VariableType } from 'types/variable';
+import { pollVariableBash } from 'customModules/PollVar';
+import { Attribute, Child } from 'lib/types/widget';
+import { BarBoxChild } from 'lib/types/bar';
+
+const {
+ updateCommand,
+ label,
+ padZero,
+ pollingInterval,
+ icon,
+ leftClick,
+ rightClick,
+ middleClick,
+ scrollUp,
+ scrollDown,
+} = options.bar.customModules.updates;
+
+const pendingUpdates: VariableType = Variable(' 0');
+
+const processUpdateCount = (updateCount: string): string => {
+ if (!padZero.value) return updateCount;
+ return `${updateCount.padStart(2, '0')}`;
+};
+
+pollVariableBash(
+ pendingUpdates,
+ [padZero.bind('value')],
+ pollingInterval.bind('value'),
+ updateCommand.value,
+ processUpdateCount,
+);
+
+export const Updates = (): BarBoxChild => {
+ const updatesModule = module({
+ textIcon: icon.bind('value'),
+ tooltipText: pendingUpdates.bind('value').as((v) => `${v} updates available`),
+ boxClass: 'updates',
+ label: pendingUpdates.bind('value'),
+ showLabelBinding: label.bind('value'),
+ props: {
+ setup: (self: Button) => {
+ inputHandler(self, {
+ onPrimaryClick: {
+ cmd: leftClick,
+ },
+ onSecondaryClick: {
+ cmd: rightClick,
+ },
+ onMiddleClick: {
+ cmd: middleClick,
+ },
+ onScrollUp: {
+ cmd: scrollUp,
+ },
+ onScrollDown: {
+ cmd: scrollDown,
+ },
+ });
+ },
+ },
+ });
+
+ return updatesModule;
+};
diff --git a/ags/HyprPanel/customModules/utils.ts b/ags/HyprPanel/customModules/utils.ts
new file mode 100644
index 0000000..b68258e
--- /dev/null
+++ b/ags/HyprPanel/customModules/utils.ts
@@ -0,0 +1,197 @@
+import { ResourceLabelType } from 'lib/types/bar';
+import { GenericResourceData, Postfix } from 'lib/types/customModules/generic';
+import { InputHandlerEvents, RunAsyncCommand } from 'lib/types/customModules/utils';
+import { ThrottleFn, ThrottleFnCallback } from 'lib/types/utils';
+import { Attribute, Child, EventArgs } from 'lib/types/widget';
+import { Binding } from 'lib/utils';
+import { openMenu } from 'modules/bar/utils';
+import options from 'options';
+import Gdk from 'types/@girs/gdk-3.0/gdk-3.0';
+import { Variable as VariableType } from 'types/variable';
+import Button from 'types/widgets/button';
+
+const { scrollSpeed } = options.bar.customModules;
+
+export const runAsyncCommand: RunAsyncCommand = (cmd, events, fn): void => {
+ if (cmd.startsWith('menu:')) {
+ const menuName = cmd.split(':')[1].trim().toLowerCase();
+ openMenu(events.clicked, events.event, `${menuName}menu`);
+
+ return;
+ }
+
+ Utils.execAsync(`bash -c "${cmd}"`)
+ .then((output) => {
+ if (fn !== undefined) {
+ fn(output);
+ }
+ })
+ .catch((err) => console.error(`Error running command "${cmd}": ${err})`));
+};
+
+export function throttle(func: T, limit: number): T {
+ let inThrottle: boolean;
+ return function (this: ThisParameterType, ...args: Parameters) {
+ if (!inThrottle) {
+ func.apply(this, args);
+ inThrottle = true;
+ setTimeout(() => {
+ inThrottle = false;
+ }, limit);
+ }
+ } as T;
+}
+
+export const throttledScrollHandler = (interval: number): ThrottleFn =>
+ throttle((cmd: string, events: EventArgs, fn: ThrottleFnCallback) => {
+ runAsyncCommand(cmd, events, fn);
+ }, 200 / interval);
+
+const dummyVar = Variable('');
+
+export const inputHandler = (
+ self: Button,
+ { onPrimaryClick, onSecondaryClick, onMiddleClick, onScrollUp, onScrollDown }: InputHandlerEvents,
+): void => {
+ const sanitizeInput = (input: VariableType): string => {
+ if (input === undefined) {
+ return '';
+ }
+ return input.value;
+ };
+
+ const updateHandlers = (): void => {
+ const interval = scrollSpeed.value;
+ const throttledHandler = throttledScrollHandler(interval);
+
+ self.on_primary_click = (clicked: Button, event: Gdk.Event): void =>
+ runAsyncCommand(sanitizeInput(onPrimaryClick?.cmd || dummyVar), { clicked, event }, onPrimaryClick.fn);
+
+ self.on_secondary_click = (clicked: Button, event: Gdk.Event): void =>
+ runAsyncCommand(sanitizeInput(onSecondaryClick?.cmd || dummyVar), { clicked, event }, onSecondaryClick.fn);
+
+ self.on_middle_click = (clicked: Button, event: Gdk.Event): void =>
+ runAsyncCommand(sanitizeInput(onMiddleClick?.cmd || dummyVar), { clicked, event }, onMiddleClick.fn);
+
+ self.on_scroll_up = (clicked: Button, event: Gdk.Event): void =>
+ throttledHandler(sanitizeInput(onScrollUp?.cmd || dummyVar), { clicked, event }, onScrollUp.fn);
+
+ self.on_scroll_down = (clicked: Button, event: Gdk.Event): void =>
+ throttledHandler(sanitizeInput(onScrollDown?.cmd || dummyVar), { clicked, event }, onScrollDown.fn);
+ };
+
+ // Initial setup of event handlers
+ updateHandlers();
+
+ const sanitizeVariable = (someVar: VariableType | undefined): Binding => {
+ if (someVar === undefined || typeof someVar.bind !== 'function') {
+ return dummyVar.bind('value');
+ }
+ return someVar.bind('value');
+ };
+
+ // Re-run the update whenever scrollSpeed changes
+ Utils.merge(
+ [
+ scrollSpeed.bind('value'),
+ sanitizeVariable(onPrimaryClick),
+ sanitizeVariable(onSecondaryClick),
+ sanitizeVariable(onMiddleClick),
+ sanitizeVariable(onScrollUp),
+ sanitizeVariable(onScrollDown),
+ ],
+ updateHandlers,
+ );
+};
+
+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;
+};
+
+export const formatSizeInKiB = (sizeInBytes: number, round: boolean): number => {
+ const sizeInGiB = sizeInBytes / 1024 ** 1;
+ return round ? Math.round(sizeInGiB) : parseFloat(sizeInGiB.toFixed(2));
+};
+export const formatSizeInMiB = (sizeInBytes: number, round: boolean): number => {
+ const sizeInGiB = sizeInBytes / 1024 ** 2;
+ return round ? Math.round(sizeInGiB) : parseFloat(sizeInGiB.toFixed(2));
+};
+export const formatSizeInGiB = (sizeInBytes: number, round: boolean): number => {
+ const sizeInGiB = sizeInBytes / 1024 ** 3;
+ return round ? Math.round(sizeInGiB) : parseFloat(sizeInGiB.toFixed(2));
+};
+export const formatSizeInTiB = (sizeInBytes: number, round: boolean): number => {
+ const sizeInGiB = sizeInBytes / 1024 ** 4;
+ return round ? Math.round(sizeInGiB) : parseFloat(sizeInGiB.toFixed(2));
+};
+
+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;
+};
+
+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';
+};
+
+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 them datas 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}%`;
+};
+
+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 '';
+ }
+};
diff --git a/ags/HyprPanel/customModules/weather/index.ts b/ags/HyprPanel/customModules/weather/index.ts
new file mode 100644
index 0000000..b40995c
--- /dev/null
+++ b/ags/HyprPanel/customModules/weather/index.ts
@@ -0,0 +1,52 @@
+import options from 'options';
+import { module } from '../module';
+
+import { inputHandler } from 'customModules/utils';
+import Button from 'types/widgets/button';
+import { getWeatherStatusTextIcon, globalWeatherVar } from 'globals/weather';
+import { Attribute, Child } from 'lib/types/widget';
+import { BarBoxChild } from 'lib/types/bar';
+
+const { label, unit, leftClick, rightClick, middleClick, scrollUp, scrollDown } = options.bar.customModules.weather;
+
+export const Weather = (): BarBoxChild => {
+ const weatherModule = module({
+ textIcon: Utils.merge([globalWeatherVar.bind('value')], (wthr) => {
+ const weatherStatusIcon = getWeatherStatusTextIcon(wthr);
+ return weatherStatusIcon;
+ }),
+ tooltipText: globalWeatherVar.bind('value').as((v) => `Weather Status: ${v.current.condition.text}`),
+ boxClass: 'weather-custom',
+ label: Utils.merge([globalWeatherVar.bind('value'), unit.bind('value')], (wthr, unt) => {
+ if (unt === 'imperial') {
+ return `${Math.ceil(wthr.current.temp_f)}° F`;
+ } else {
+ return `${Math.ceil(wthr.current.temp_c)}° C`;
+ }
+ }),
+ showLabelBinding: label.bind('value'),
+ props: {
+ setup: (self: Button) => {
+ inputHandler(self, {
+ onPrimaryClick: {
+ cmd: leftClick,
+ },
+ onSecondaryClick: {
+ cmd: rightClick,
+ },
+ onMiddleClick: {
+ cmd: middleClick,
+ },
+ onScrollUp: {
+ cmd: scrollUp,
+ },
+ onScrollDown: {
+ cmd: scrollDown,
+ },
+ });
+ },
+ },
+ });
+
+ return weatherModule;
+};
diff --git a/ags/HyprPanel/directoryMonitorService.ts b/ags/HyprPanel/directoryMonitorService.ts
new file mode 100644
index 0000000..2e1a74a
--- /dev/null
+++ b/ags/HyprPanel/directoryMonitorService.ts
@@ -0,0 +1,38 @@
+import Service from 'resource:///com/github/Aylur/ags/service.js';
+import App from 'resource:///com/github/Aylur/ags/app.js';
+import { monitorFile } from 'resource:///com/github/Aylur/ags/utils.js';
+import Gio from 'gi://Gio';
+import { FileInfo } from 'types/@girs/gio-2.0/gio-2.0.cjs';
+
+class DirectoryMonitorService extends Service {
+ static {
+ Service.register(this, {}, {});
+ }
+
+ constructor() {
+ super();
+ this.recursiveDirectoryMonitor(`${App.configDir}/scss`);
+ }
+
+ recursiveDirectoryMonitor(directoryPath: string): void {
+ monitorFile(directoryPath, (_, eventType) => {
+ if (eventType === Gio.FileMonitorEvent.CHANGES_DONE_HINT) {
+ this.emit('changed');
+ }
+ });
+
+ const directory = Gio.File.new_for_path(directoryPath);
+ const enumerator = directory.enumerate_children('standard::*', Gio.FileQueryInfoFlags.NONE, null);
+
+ let fileInfo: FileInfo;
+ while ((fileInfo = enumerator.next_file(null) as FileInfo) !== null) {
+ const childPath = directoryPath + '/' + fileInfo.get_name();
+ if (fileInfo.get_file_type() === Gio.FileType.DIRECTORY) {
+ this.recursiveDirectoryMonitor(childPath);
+ }
+ }
+ }
+}
+
+const service = new DirectoryMonitorService();
+export default service;
diff --git a/ags/HyprPanel/flake.lock b/ags/HyprPanel/flake.lock
new file mode 100644
index 0000000..2417d92
--- /dev/null
+++ b/ags/HyprPanel/flake.lock
@@ -0,0 +1,78 @@
+{
+ "nodes": {
+ "ags": {
+ "inputs": {
+ "nixpkgs": "nixpkgs",
+ "systems": "systems"
+ },
+ "locked": {
+ "lastModified": 1727303518,
+ "narHash": "sha256-lO5JMynTxNZxg/+E4/Y9reeLGIz/M8cEue+Re4cP6qo=",
+ "owner": "Aylur",
+ "repo": "ags",
+ "rev": "cd543afec4d9ed708990e5ba52184716809bf248",
+ "type": "github"
+ },
+ "original": {
+ "owner": "Aylur",
+ "repo": "ags",
+ "type": "github"
+ }
+ },
+ "nixpkgs": {
+ "locked": {
+ "lastModified": 1725634671,
+ "narHash": "sha256-v3rIhsJBOMLR8e/RNWxr828tB+WywYIoajrZKFM+0Gg=",
+ "owner": "NixOS",
+ "repo": "nixpkgs",
+ "rev": "574d1eac1c200690e27b8eb4e24887f8df7ac27c",
+ "type": "github"
+ },
+ "original": {
+ "owner": "NixOS",
+ "ref": "nixos-unstable",
+ "repo": "nixpkgs",
+ "type": "github"
+ }
+ },
+ "nixpkgs_2": {
+ "locked": {
+ "lastModified": 1727634051,
+ "narHash": "sha256-S5kVU7U82LfpEukbn/ihcyNt2+EvG7Z5unsKW9H/yFA=",
+ "owner": "nixos",
+ "repo": "nixpkgs",
+ "rev": "06cf0e1da4208d3766d898b7fdab6513366d45b9",
+ "type": "github"
+ },
+ "original": {
+ "owner": "nixos",
+ "ref": "nixos-unstable",
+ "repo": "nixpkgs",
+ "type": "github"
+ }
+ },
+ "root": {
+ "inputs": {
+ "ags": "ags",
+ "nixpkgs": "nixpkgs_2"
+ }
+ },
+ "systems": {
+ "locked": {
+ "lastModified": 1689347949,
+ "narHash": "sha256-12tWmuL2zgBgZkdoB6qXZsgJEH9LR3oUgpaQq2RbI80=",
+ "owner": "nix-systems",
+ "repo": "default-linux",
+ "rev": "31732fcf5e8fea42e59c2488ad31a0e651500f68",
+ "type": "github"
+ },
+ "original": {
+ "owner": "nix-systems",
+ "repo": "default-linux",
+ "type": "github"
+ }
+ }
+ },
+ "root": "root",
+ "version": 7
+}
diff --git a/ags/HyprPanel/flake.nix b/ags/HyprPanel/flake.nix
new file mode 100644
index 0000000..685fed7
--- /dev/null
+++ b/ags/HyprPanel/flake.nix
@@ -0,0 +1,78 @@
+{
+ description = "A Bar/Panel for Hyprland with extensive customizability.";
+
+ inputs = {
+ nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
+ ags.url = "github:Aylur/ags";
+ };
+
+ outputs = inputs: let
+ systems = [
+ "x86_64-linux"
+ "x86_64-darwin"
+ "aarch64-darwin"
+ "aarch64-linux"
+ ];
+ forEachSystem = inputs.nixpkgs.lib.genAttrs systems;
+ pkgsFor = forEachSystem (
+ system:
+ import inputs.nixpkgs {
+ inherit system;
+ config.allowUnfree = true;
+ }
+ );
+
+ devShellFor = system:
+ inputs.nixpkgs.lib.genAttrs ["default"] (
+ _:
+ inputs.nixpkgs.legacyPackages.${system}.mkShell {
+ buildInputs = [
+ pkgsFor.${system}.esbuild
+ pkgsFor.${system}.fish
+ pkgsFor.${system}.typescript
+ pkgsFor.${system}.bun
+ pkgsFor.${system}.libnotify
+ pkgsFor.${system}.dart-sass
+ pkgsFor.${system}.fd
+ pkgsFor.${system}.btop
+ pkgsFor.${system}.bluez
+ pkgsFor.${system}.libgtop
+ pkgsFor.${system}.gobject-introspection
+ pkgsFor.${system}.glib
+ pkgsFor.${system}.bluez-tools
+ pkgsFor.${system}.grimblast
+ pkgsFor.${system}.gpu-screen-recorder
+ pkgsFor.${system}.brightnessctl
+ pkgsFor.${system}.gnome-bluetooth
+ pkgsFor.${system}.python3
+ pkgsFor.${system}.matugen
+ inputs.ags.packages.${system}.agsWithTypes
+ ];
+ nativeBuildInputs = with pkgsFor.${system}; [
+ nixfmt-rfc-style
+ nil
+ ];
+ shellHook = ''
+ export GDK_BACKEND=wayland
+ export GI_TYPELIB_PATH=${pkgsFor.${system}.libgtop}/lib/girepository-1.0:${pkgsFor.${system}.glib}/lib/girepository-1.0:$GI_TYPELIB_PATH
+ '';
+ }
+ );
+ in {
+ devShells = forEachSystem devShellFor;
+
+ overlay = final: prev: {
+ hyprpanel =
+ if final ? callPackage
+ then (final.callPackage ./nix {inherit inputs;}).desktop.script
+ else inputs.self.packages.${prev.stdenv.system}.default;
+ };
+ packages = forEachSystem (
+ system: let
+ pkgs = pkgsFor.${system};
+ in {
+ default = (pkgs.callPackage ./nix {inherit inputs;}).desktop.script;
+ }
+ );
+ };
+}
diff --git a/ags/HyprPanel/globals.d.ts b/ags/HyprPanel/globals.d.ts
new file mode 100644
index 0000000..8e44a87
--- /dev/null
+++ b/ags/HyprPanel/globals.d.ts
@@ -0,0 +1,14 @@
+// globals.d.ts
+/* eslint-disable no-var */
+
+import { Options, Variable as VariableType } from 'types/variable';
+
+declare global {
+ var useTheme: (filePath: string) => void;
+ var isWindowVisible: (windowName: string) => boolean;
+ var globalWeatherVar: VariableType;
+ var options: Options;
+ var removingNotifications: VariableType;
+}
+
+export {};
diff --git a/ags/HyprPanel/globals/dropdown.ts b/ags/HyprPanel/globals/dropdown.ts
new file mode 100644
index 0000000..b1178bf
--- /dev/null
+++ b/ags/HyprPanel/globals/dropdown.ts
@@ -0,0 +1,6 @@
+import { Variable as VariableType } from 'types/variable';
+
+type GlobalEventBoxes = {
+ [key: string]: unknown;
+};
+export const globalEventBoxes: VariableType = Variable({});
diff --git a/ags/HyprPanel/globals/network.ts b/ags/HyprPanel/globals/network.ts
new file mode 100644
index 0000000..44fbf77
--- /dev/null
+++ b/ags/HyprPanel/globals/network.ts
@@ -0,0 +1,15 @@
+export const WIFI_STATUS_MAP = {
+ unknown: 'Status Unknown',
+ unmanaged: 'Unmanaged',
+ unavailable: 'Unavailable',
+ disconnected: 'Disconnected',
+ prepare: 'Preparing Connecting',
+ config: 'Connecting',
+ need_auth: 'Needs Authentication',
+ ip_config: 'Requesting IP',
+ ip_check: 'Checking Access',
+ secondaries: 'Waiting on Secondaries',
+ activated: 'Connected',
+ deactivating: 'Disconnecting',
+ failed: 'Connection Failed',
+} as const;
diff --git a/ags/HyprPanel/globals/notification.ts b/ags/HyprPanel/globals/notification.ts
new file mode 100644
index 0000000..f382bc4
--- /dev/null
+++ b/ags/HyprPanel/globals/notification.ts
@@ -0,0 +1,37 @@
+import icons from 'modules/icons/index';
+import { Notification } from 'types/service/notifications';
+
+export const removingNotifications = Variable(false);
+
+export const getNotificationIcon = (app_name: string, app_icon: string, app_entry: string): string => {
+ let icon: string = icons.fallback.notification;
+
+ if (Utils.lookUpIcon(app_name) || Utils.lookUpIcon(app_name.toLowerCase() || '')) {
+ icon = Utils.lookUpIcon(app_name)
+ ? app_name
+ : Utils.lookUpIcon(app_name.toLowerCase())
+ ? app_name.toLowerCase()
+ : '';
+ }
+
+ if (Utils.lookUpIcon(app_icon) && icon === '') {
+ icon = app_icon;
+ }
+
+ if (Utils.lookUpIcon(app_entry || '') && icon === '') {
+ icon = app_entry || '';
+ }
+
+ return icon;
+};
+
+export const closeNotifications = async (notifications: Notification[], delay: number): Promise => {
+ removingNotifications.value = true;
+ for (const notif of notifications) {
+ notif.close();
+ await new Promise((resolve) => setTimeout(resolve, delay));
+ }
+ removingNotifications.value = false;
+};
+
+globalThis['removingNotifications'] = removingNotifications;
diff --git a/ags/HyprPanel/globals/useTheme.ts b/ags/HyprPanel/globals/useTheme.ts
new file mode 100644
index 0000000..15c2322
--- /dev/null
+++ b/ags/HyprPanel/globals/useTheme.ts
@@ -0,0 +1,43 @@
+import Gio from 'gi://Gio';
+import { bash, Notify } from 'lib/utils';
+import icons from 'lib/icons';
+import { filterConfigForThemeOnly, loadJsonFile, saveConfigToFile } from 'widget/settings/shared/FileChooser';
+
+export const hexColorPattern = /^#([0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/;
+
+globalThis.useTheme = (filePath: string): void => {
+ const importedConfig = loadJsonFile(filePath);
+
+ if (!importedConfig) {
+ return;
+ }
+
+ Notify({
+ summary: `Importing Theme`,
+ body: `Importing: ${filePath}`,
+ iconName: icons.ui.info,
+ timeout: 7000,
+ });
+
+ const tmpConfigFile = Gio.File.new_for_path(`${TMP}/config.json`);
+ const optionsConfigFile = Gio.File.new_for_path(OPTIONS);
+
+ const [tmpSuccess, tmpContent] = tmpConfigFile.load_contents(null);
+ const [optionsSuccess, optionsContent] = optionsConfigFile.load_contents(null);
+
+ if (!tmpSuccess || !optionsSuccess) {
+ console.error('Failed to read existing configuration files.');
+ return;
+ }
+
+ let tmpConfig = JSON.parse(new TextDecoder('utf-8').decode(tmpContent));
+ let optionsConfig = JSON.parse(new TextDecoder('utf-8').decode(optionsContent));
+
+ const filteredConfig = filterConfigForThemeOnly(importedConfig);
+ tmpConfig = { ...tmpConfig, ...filteredConfig };
+ optionsConfig = { ...optionsConfig, ...filteredConfig };
+
+ saveConfigToFile(tmpConfig, `${TMP}/config.json`);
+ saveConfigToFile(optionsConfig, OPTIONS);
+ bash('pkill ags && ags');
+};
diff --git a/ags/HyprPanel/globals/utilities.ts b/ags/HyprPanel/globals/utilities.ts
new file mode 100644
index 0000000..438c666
--- /dev/null
+++ b/ags/HyprPanel/globals/utilities.ts
@@ -0,0 +1,8 @@
+globalThis.isWindowVisible = (windowName: string): boolean => {
+ const appWindow = App.getWindow(windowName);
+
+ if (appWindow === undefined) {
+ return false;
+ }
+ return appWindow.visible;
+};
diff --git a/ags/HyprPanel/globals/variables.ts b/ags/HyprPanel/globals/variables.ts
new file mode 100644
index 0000000..ea2eb5d
--- /dev/null
+++ b/ags/HyprPanel/globals/variables.ts
@@ -0,0 +1,29 @@
+import { Opt } from 'lib/option';
+import { HexColor, MatugenTheme, RecursiveOptionsObject } from 'lib/types/options';
+
+export const isOpt = (value: unknown): value is Opt =>
+ typeof value === 'object' && value !== null && 'value' in value && value instanceof Opt;
+
+export const isOptString = (value: unknown): value is Opt => {
+ return value instanceof Opt && typeof value.value === 'string';
+};
+
+export const isOptNumber = (value: unknown): value is Opt => {
+ return value instanceof Opt && typeof value.value === 'number';
+};
+
+export const isOptBoolean = (value: unknown): value is Opt => {
+ return value instanceof Opt && typeof value.value === 'boolean';
+};
+
+export const isOptMatugenTheme = (value: unknown): value is Opt => {
+ return value instanceof Opt && typeof value.value === 'object' && 'specificProperty' in value.value; // Replace 'specificProperty' with an actual property of MatugenTheme
+};
+
+export const isRecursiveOptionsObject = (value: unknown): value is RecursiveOptionsObject => {
+ return typeof value === 'object' && value !== null && !(value instanceof Opt);
+};
+
+export const isHexColor = (val: unknown): val is HexColor => {
+ return typeof val === 'string' && /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/.test(val);
+};
diff --git a/ags/HyprPanel/globals/weather.ts b/ags/HyprPanel/globals/weather.ts
new file mode 100644
index 0000000..3da7ba1
--- /dev/null
+++ b/ags/HyprPanel/globals/weather.ts
@@ -0,0 +1,122 @@
+import options from 'options';
+import { UnitType, Weather, WeatherIconTitle, WeatherIcon } from 'lib/types/weather.js';
+import { DEFAULT_WEATHER } from 'lib/types/defaults/weather.js';
+import GLib from 'gi://GLib?version=2.0';
+import { weatherIcons } from 'modules/icons/weather.js';
+
+const { key, interval, location } = options.menus.clock.weather;
+
+export const globalWeatherVar = Variable(DEFAULT_WEATHER);
+
+let weatherIntervalInstance: null | number = null;
+
+const weatherIntervalFn = (weatherInterval: number, loc: string, weatherKey: string): void => {
+ if (weatherIntervalInstance !== null) {
+ GLib.source_remove(weatherIntervalInstance);
+ }
+
+ const formattedLocation = loc.replace(' ', '%20');
+
+ weatherIntervalInstance = Utils.interval(weatherInterval, () => {
+ Utils.execAsync(
+ `curl "https://api.weatherapi.com/v1/forecast.json?key=${weatherKey}&q=${formattedLocation}&days=1&aqi=no&alerts=no"`,
+ )
+ .then((res) => {
+ try {
+ if (typeof res !== 'string') {
+ return (globalWeatherVar.value = DEFAULT_WEATHER);
+ }
+
+ const parsedWeather = JSON.parse(res);
+
+ if (Object.keys(parsedWeather).includes('error')) {
+ return (globalWeatherVar.value = DEFAULT_WEATHER);
+ }
+
+ return (globalWeatherVar.value = parsedWeather);
+ } catch (error) {
+ globalWeatherVar.value = DEFAULT_WEATHER;
+ console.warn(`Failed to parse weather data: ${error}`);
+ }
+ })
+ .catch((err) => {
+ console.error(`Failed to fetch weather: ${err}`);
+ globalWeatherVar.value = DEFAULT_WEATHER;
+ });
+ });
+};
+
+Utils.merge([key.bind('value'), interval.bind('value'), location.bind('value')], (weatherKey, weatherInterval, loc) => {
+ if (!weatherKey) {
+ return (globalWeatherVar.value = DEFAULT_WEATHER);
+ }
+ weatherIntervalFn(weatherInterval, loc, weatherKey);
+});
+
+export const getTemperature = (wthr: Weather, unt: UnitType): string => {
+ if (unt === 'imperial') {
+ return `${Math.ceil(wthr.current.temp_f)}° F`;
+ } else {
+ return `${Math.ceil(wthr.current.temp_c)}° C`;
+ }
+};
+
+export const getWeatherIcon = (fahren: number): Record => {
+ const icons = {
+ 100: '',
+ 75: '',
+ 50: '',
+ 25: '',
+ 0: '',
+ } as const;
+ const colors = {
+ 100: 'weather-color red',
+ 75: 'weather-color orange',
+ 50: 'weather-color lavender',
+ 25: 'weather-color blue',
+ 0: 'weather-color sky',
+ } as const;
+
+ type IconKeys = keyof typeof icons;
+
+ const threshold: IconKeys =
+ fahren < 0 ? 0 : ([100, 75, 50, 25, 0] as IconKeys[]).find((threshold) => threshold <= fahren) || 0;
+
+ const icon = icons[threshold || 50];
+ const color = colors[threshold || 50];
+
+ return {
+ icon,
+ color,
+ };
+};
+
+export const getWindConditions = (wthr: Weather, unt: UnitType): string => {
+ if (unt === 'imperial') {
+ return `${Math.floor(wthr.current.wind_mph)} mph`;
+ }
+ return `${Math.floor(wthr.current.wind_kph)} kph`;
+};
+
+export const getRainChance = (wthr: Weather): string => `${wthr.forecast.forecastday[0].day.daily_chance_of_rain}%`;
+
+export const isValidWeatherIconTitle = (title: string): title is WeatherIconTitle => {
+ return title in weatherIcons;
+};
+
+export const getWeatherStatusTextIcon = (wthr: Weather): WeatherIcon => {
+ let iconQuery = wthr.current.condition.text.trim().toLowerCase().replaceAll(' ', '_');
+
+ if (!wthr.current.is_day && iconQuery === 'partly_cloudy') {
+ iconQuery = 'partly_cloudy_night';
+ }
+
+ if (isValidWeatherIconTitle(iconQuery)) {
+ return weatherIcons[iconQuery];
+ } else {
+ console.warn(`Unknown weather icon title: ${iconQuery}`);
+ return weatherIcons['warning'];
+ }
+};
+
+globalThis['globalWeatherVar'] = globalWeatherVar;
diff --git a/ags/HyprPanel/globals/window.ts b/ags/HyprPanel/globals/window.ts
new file mode 100644
index 0000000..ae823bb
--- /dev/null
+++ b/ags/HyprPanel/globals/window.ts
@@ -0,0 +1,10 @@
+export const WINDOW_LAYOUTS: string[] = [
+ 'center',
+ 'top',
+ 'top-right',
+ 'top-center',
+ 'top-left',
+ 'bottom-left',
+ 'bottom-center',
+ 'bottom-right',
+];
diff --git a/ags/HyprPanel/install_fonts.sh b/ags/HyprPanel/install_fonts.sh
new file mode 100755
index 0000000..c40b022
--- /dev/null
+++ b/ags/HyprPanel/install_fonts.sh
@@ -0,0 +1,28 @@
+#!/bin/bash
+
+SOURCE_DIR="./assets/fonts"
+DEST_DIR="$HOME/.local/share/fonts"
+DEST_PATH="$DEST_DIR/NFP"
+
+if [ ! -d "$SOURCE_DIR" ]; then
+ echo "Source directory '$SOURCE_DIR' does not exist."
+ exit 1
+fi
+
+if [ ! -d "$DEST_PATH" ]; then
+ echo "Destination directory '$DEST_PATH' does not exist. Creating it..."
+ mkdir -p "$DEST_PATH"
+fi
+
+if [ -z "$(ls -A "$SOURCE_DIR")" ]; then
+ echo "Source directory '$SOURCE_DIR' is empty. No files to copy."
+ exit 1
+fi
+
+echo "Copying fonts from '$SOURCE_DIR' to '$DEST_PATH'..."
+cp -r "$SOURCE_DIR"/* "$DEST_PATH"
+
+echo "Updating font cache..."
+fc-cache -fv
+
+echo "Fonts installed successfully."
diff --git a/ags/HyprPanel/lib/constants/colors.ts b/ags/HyprPanel/lib/constants/colors.ts
new file mode 100644
index 0000000..95ac5cf
--- /dev/null
+++ b/ags/HyprPanel/lib/constants/colors.ts
@@ -0,0 +1,142 @@
+export const namedColors = new Set([
+ 'alice blue',
+ 'antique white',
+ 'aqua',
+ 'aquamarine',
+ 'azure',
+ 'beige',
+ 'bisque',
+ 'black',
+ 'blanched almond',
+ 'blue',
+ 'blue violet',
+ 'brown',
+ 'burlywood',
+ 'cadet blue',
+ 'chartreuse',
+ 'chocolate',
+ 'coral',
+ 'cornflower blue',
+ 'cornsilk',
+ 'crimson',
+ 'cyan',
+ 'dark blue',
+ 'dark cyan',
+ 'dark goldenrod',
+ 'dark gray',
+ 'dark green',
+ 'dark khaki',
+ 'dark magenta',
+ 'dark olive green',
+ 'dark orange',
+ 'dark orchid',
+ 'dark red',
+ 'dark salmon',
+ 'dark sea green',
+ 'dark slate blue',
+ 'dark slate gray',
+ 'dark turquoise',
+ 'dark violet',
+ 'deep pink',
+ 'deep sky blue',
+ 'dim gray',
+ 'dodger blue',
+ 'firebrick',
+ 'floral white',
+ 'forest green',
+ 'fuchsia',
+ 'gainsboro',
+ 'ghost white',
+ 'gold',
+ 'goldenrod',
+ 'gray',
+ 'green',
+ 'green yellow',
+ 'honeydew',
+ 'hot pink',
+ 'indian red',
+ 'indigo',
+ 'ivory',
+ 'khaki',
+ 'lavender',
+ 'lavender blush',
+ 'lawn green',
+ 'lemon chiffon',
+ 'light blue',
+ 'light coral',
+ 'light cyan',
+ 'light goldenrod yellow',
+ 'light green',
+ 'light grey',
+ 'light pink',
+ 'light salmon',
+ 'light sea green',
+ 'light sky blue',
+ 'light slate gray',
+ 'light steel blue',
+ 'light yellow',
+ 'lime',
+ 'lime green',
+ 'linen',
+ 'magenta',
+ 'maroon',
+ 'medium aquamarine',
+ 'medium blue',
+ 'medium orchid',
+ 'medium purple',
+ 'medium sea green',
+ 'medium slate blue',
+ 'medium spring green',
+ 'medium turquoise',
+ 'medium violet red',
+ 'midnight blue',
+ 'mint cream',
+ 'misty rose',
+ 'moccasin',
+ 'navajo white',
+ 'navy',
+ 'old lace',
+ 'olive',
+ 'olive drab',
+ 'orange',
+ 'orange red',
+ 'orchid',
+ 'pale goldenrod',
+ 'pale green',
+ 'pale turquoise',
+ 'pale violet red',
+ 'papaya whip',
+ 'peach puff',
+ 'peru',
+ 'pink',
+ 'plum',
+ 'powder blue',
+ 'purple',
+ 'red',
+ 'rosy brown',
+ 'royal blue',
+ 'saddle brown',
+ 'salmon',
+ 'sandy brown',
+ 'sea green',
+ 'seashell',
+ 'sienna',
+ 'silver',
+ 'sky blue',
+ 'slate blue',
+ 'slate gray',
+ 'snow',
+ 'spring green',
+ 'steel blue',
+ 'tan',
+ 'teal',
+ 'thistle',
+ 'tomato',
+ 'turquoise',
+ 'violet',
+ 'wheat',
+ 'white',
+ 'white smoke',
+ 'yellow',
+ 'yellow green',
+]);
diff --git a/ags/HyprPanel/lib/icons.ts b/ags/HyprPanel/lib/icons.ts
new file mode 100644
index 0000000..8813940
--- /dev/null
+++ b/ags/HyprPanel/lib/icons.ts
@@ -0,0 +1,145 @@
+export const substitutes = {
+ 'transmission-gtk': 'transmission',
+ 'blueberry.py': 'blueberry',
+ Caprine: 'facebook-messenger',
+ 'com.raggesilver.BlackBox-symbolic': 'terminal-symbolic',
+ 'org.wezfurlong.wezterm-symbolic': 'terminal-symbolic',
+ 'audio-headset-bluetooth': 'audio-headphones-symbolic',
+ 'audio-card-analog-usb': 'audio-speakers-symbolic',
+ 'audio-card-analog-pci': 'audio-card-symbolic',
+ 'preferences-system': 'emblem-system-symbolic',
+ 'com.github.Aylur.ags-symbolic': 'controls-symbolic',
+ 'com.github.Aylur.ags': 'controls-symbolic',
+} as const;
+
+export default {
+ missing: 'image-missing-symbolic',
+ nix: {
+ nix: 'nix-snowflake-symbolic',
+ },
+ app: {
+ terminal: 'terminal-symbolic',
+ },
+ fallback: {
+ executable: 'application-x-executable',
+ notification: 'dialog-information-symbolic',
+ video: 'video-x-generic-symbolic',
+ audio: 'audio-x-generic-symbolic',
+ },
+ ui: {
+ close: 'window-close-symbolic',
+ colorpicker: 'color-select-symbolic',
+ info: 'info-symbolic',
+ link: 'external-link-symbolic',
+ lock: 'system-lock-screen-symbolic',
+ menu: 'open-menu-symbolic',
+ refresh: 'view-refresh-symbolic',
+ search: 'system-search-symbolic',
+ settings: 'emblem-system-symbolic',
+ themes: 'preferences-desktop-theme-symbolic',
+ tick: 'object-select-symbolic',
+ time: 'hourglass-symbolic',
+ toolbars: 'toolbars-symbolic',
+ warning: 'dialog-warning-symbolic',
+ avatar: 'avatar-default-symbolic',
+ arrow: {
+ right: 'pan-end-symbolic',
+ left: 'pan-start-symbolic',
+ down: 'pan-down-symbolic',
+ up: 'pan-up-symbolic',
+ },
+ },
+ audio: {
+ mic: {
+ muted: 'microphone-disabled-symbolic',
+ low: 'microphone-sensitivity-low-symbolic',
+ medium: 'microphone-sensitivity-medium-symbolic',
+ high: 'microphone-sensitivity-high-symbolic',
+ },
+ volume: {
+ muted: 'audio-volume-muted-symbolic',
+ low: 'audio-volume-low-symbolic',
+ medium: 'audio-volume-medium-symbolic',
+ high: 'audio-volume-high-symbolic',
+ overamplified: 'audio-volume-overamplified-symbolic',
+ },
+ type: {
+ headset: 'audio-headphones-symbolic',
+ speaker: 'audio-speakers-symbolic',
+ card: 'audio-card-symbolic',
+ },
+ mixer: 'mixer-symbolic',
+ },
+ powerprofile: {
+ balanced: 'power-profile-balanced-symbolic',
+ 'power-saver': 'power-profile-power-saver-symbolic',
+ performance: 'power-profile-performance-symbolic',
+ },
+ asusctl: {
+ profile: {
+ Balanced: 'power-profile-balanced-symbolic',
+ Quiet: 'power-profile-power-saver-symbolic',
+ Performance: 'power-profile-performance-symbolic',
+ },
+ mode: {
+ Integrated: 'processor-symbolic',
+ Hybrid: 'controller-symbolic',
+ },
+ },
+ battery: {
+ charging: 'battery-flash-symbolic',
+ warning: 'battery-empty-symbolic',
+ },
+ bluetooth: {
+ enabled: 'bluetooth-active-symbolic',
+ disabled: 'bluetooth-disabled-symbolic',
+ },
+ brightness: {
+ indicator: 'display-brightness-symbolic',
+ keyboard: 'keyboard-brightness-symbolic',
+ screen: 'display-brightness-symbolic',
+ },
+ powermenu: {
+ sleep: 'weather-clear-night-symbolic',
+ reboot: 'system-reboot-symbolic',
+ logout: 'system-log-out-symbolic',
+ shutdown: 'system-shutdown-symbolic',
+ },
+ recorder: {
+ recording: 'media-record-symbolic',
+ },
+ notifications: {
+ noisy: 'org.gnome.Settings-notifications-symbolic',
+ silent: 'notifications-disabled-symbolic',
+ message: 'chat-bubbles-symbolic',
+ },
+ trash: {
+ full: 'user-trash-full-symbolic',
+ empty: 'user-trash-symbolic',
+ },
+ mpris: {
+ shuffle: {
+ enabled: 'media-playlist-shuffle-symbolic',
+ disabled: 'media-playlist-consecutive-symbolic',
+ },
+ loop: {
+ none: 'media-playlist-repeat-symbolic',
+ track: 'media-playlist-repeat-song-symbolic',
+ playlist: 'media-playlist-repeat-symbolic',
+ },
+ playing: 'media-playback-pause-symbolic',
+ paused: 'media-playback-start-symbolic',
+ stopped: 'media-playback-start-symbolic',
+ prev: 'media-skip-backward-symbolic',
+ next: 'media-skip-forward-symbolic',
+ },
+ system: {
+ cpu: 'org.gnome.SystemMonitor-symbolic',
+ ram: 'drive-harddisk-solidstate-symbolic',
+ temp: 'temperature-symbolic',
+ },
+ color: {
+ dark: 'dark-mode-symbolic',
+ light: 'light-mode-symbolic',
+ },
+};
diff --git a/ags/HyprPanel/lib/option.ts b/ags/HyprPanel/lib/option.ts
new file mode 100644
index 0000000..c261657
--- /dev/null
+++ b/ags/HyprPanel/lib/option.ts
@@ -0,0 +1,147 @@
+import { isHexColor } from 'globals/variables';
+import { Variable } from 'resource:///com/github/Aylur/ags/variable.js';
+import { MkOptionsResult } from './types/options';
+
+type OptProps = {
+ persistent?: boolean;
+};
+
+export class Opt extends Variable {
+ static {
+ Service.register(this);
+ }
+
+ constructor(initial: T, { persistent = false }: OptProps = {}) {
+ super(initial);
+ this.initial = initial;
+ this.persistent = persistent;
+ }
+
+ initial: T;
+ id = '';
+ persistent: boolean;
+ toString(): string {
+ return `${this.value}`;
+ }
+ toJSON(): string {
+ return `opt:${this.value}`;
+ }
+
+ getValue = (): T => {
+ return super.getValue();
+ };
+ init(cacheFile: string): void {
+ const cacheV = JSON.parse(Utils.readFile(cacheFile) || '{}')[this.id];
+ if (cacheV !== undefined) this.value = cacheV;
+
+ this.connect('changed', () => {
+ const cache = JSON.parse(Utils.readFile(cacheFile) || '{}');
+ cache[this.id] = this.value;
+ Utils.writeFileSync(JSON.stringify(cache, null, 2), cacheFile);
+ });
+ }
+
+ reset(): string | undefined {
+ if (this.persistent) return;
+
+ if (JSON.stringify(this.value) !== JSON.stringify(this.initial)) {
+ this.value = this.initial;
+ return this.id;
+ }
+ }
+
+ doResetColor(): string | undefined {
+ if (this.persistent) return;
+
+ const isColor = isHexColor(this.value as string);
+ if (JSON.stringify(this.value) !== JSON.stringify(this.initial) && isColor) {
+ this.value = this.initial;
+ return this.id;
+ }
+ return;
+ }
+}
+
+export const opt = (initial: T, opts?: OptProps): Opt => new Opt(initial, opts);
+
+const getOptions = (object: Record, path = ''): Opt[] => {
+ return Object.keys(object).flatMap((key) => {
+ const obj = object[key];
+ const id = path ? path + '.' + key : key;
+
+ if (obj instanceof Variable) {
+ const optValue = obj as Opt;
+ optValue.id = id;
+ return optValue;
+ }
+
+ if (typeof obj === 'object' && obj !== null) {
+ return getOptions(obj as Record, id); // Recursively process nested objects
+ }
+
+ return [];
+ });
+};
+
+export function mkOptions(
+ cacheFile: string,
+ object: T,
+ confFile: string = 'config.json',
+): T & MkOptionsResult {
+ for (const opt of getOptions(object as Record)) opt.init(cacheFile);
+
+ Utils.ensureDirectory(cacheFile.split('/').slice(0, -1).join('/'));
+
+ const configFile = `${TMP}/${confFile}`;
+ const values = getOptions(object as Record).reduce(
+ (obj, { id, value }) => ({ [id]: value, ...obj }),
+ {},
+ );
+ Utils.writeFileSync(JSON.stringify(values, null, 2), configFile);
+ Utils.monitorFile(configFile, () => {
+ const cache = JSON.parse(Utils.readFile(configFile) || '{}');
+ for (const opt of getOptions(object as Record)) {
+ if (JSON.stringify(cache[opt.id]) !== JSON.stringify(opt.value)) opt.value = cache[opt.id];
+ }
+ });
+
+ function sleep(ms = 0): Promise {
+ return new Promise((r) => setTimeout(r, ms));
+ }
+
+ const reset = async (
+ [opt, ...list] = getOptions(object as Record),
+ id = opt?.reset(),
+ ): Promise> => {
+ if (!opt) return sleep().then(() => []);
+
+ return id ? [id, ...(await sleep(50).then(() => reset(list)))] : await sleep().then(() => reset(list));
+ };
+
+ const resetTheme = async (
+ [opt, ...list] = getOptions(object as Record),
+ id = opt?.doResetColor(),
+ ): Promise> => {
+ if (!opt) return sleep().then(() => []);
+
+ return id
+ ? [id, ...(await sleep(50).then(() => resetTheme(list)))]
+ : await sleep().then(() => resetTheme(list));
+ };
+
+ return Object.assign(object, {
+ configFile,
+ array: () => getOptions(object as Record),
+ async reset() {
+ return (await reset()).join('\n');
+ },
+ async resetTheme() {
+ return (await resetTheme()).join('\n');
+ },
+ handler(deps: string[], callback: () => void) {
+ for (const opt of getOptions(object as Record)) {
+ if (deps.some((i) => opt.id.startsWith(i))) opt.connect('changed', callback);
+ }
+ },
+ });
+}
diff --git a/ags/HyprPanel/lib/session.ts b/ags/HyprPanel/lib/session.ts
new file mode 100644
index 0000000..595b366
--- /dev/null
+++ b/ags/HyprPanel/lib/session.ts
@@ -0,0 +1,16 @@
+import GLib from 'gi://GLib?version=2.0';
+
+declare global {
+ const OPTIONS: string;
+ const TMP: string;
+ const USER: string;
+}
+
+Object.assign(globalThis, {
+ OPTIONS: `${GLib.get_user_cache_dir()}/ags/hyprpanel/options.json`,
+ TMP: `${GLib.get_tmp_dir()}/ags/hyprpanel`,
+ USER: GLib.get_user_name(),
+});
+
+Utils.ensureDirectory(TMP);
+App.addIcons(`${App.configDir}/assets`);
diff --git a/ags/HyprPanel/lib/shared/media.ts b/ags/HyprPanel/lib/shared/media.ts
new file mode 100644
index 0000000..23e0c6f
--- /dev/null
+++ b/ags/HyprPanel/lib/shared/media.ts
@@ -0,0 +1,28 @@
+import { MprisPlayer } from 'types/service/mpris';
+const mpris = await Service.import('mpris');
+
+export const getCurrentPlayer = (activePlayer: MprisPlayer = mpris.players[0]): MprisPlayer => {
+ const statusOrder = {
+ Playing: 1,
+ Paused: 2,
+ Stopped: 3,
+ };
+
+ if (mpris.players.length === 0) {
+ return mpris.players[0];
+ }
+
+ const isPlaying = mpris.players.some((p: MprisPlayer) => p.play_back_status === 'Playing');
+
+ const playerStillExists = mpris.players.some((p) => activePlayer.bus_name === p.bus_name);
+
+ const nextPlayerUp = mpris.players.sort(
+ (a: MprisPlayer, b: MprisPlayer) => statusOrder[a.play_back_status] - statusOrder[b.play_back_status],
+ )[0];
+
+ if (isPlaying || !playerStillExists) {
+ return nextPlayerUp;
+ }
+
+ return activePlayer;
+};
diff --git a/ags/HyprPanel/lib/shared/notifications.ts b/ags/HyprPanel/lib/shared/notifications.ts
new file mode 100644
index 0000000..f772c69
--- /dev/null
+++ b/ags/HyprPanel/lib/shared/notifications.ts
@@ -0,0 +1,12 @@
+import { Notification } from 'types/service/notifications';
+
+export const filterNotifications = (notifications: Notification[], filter: string[]): Notification[] => {
+ const notifFilter = new Set(filter.map((name: string) => name.toLowerCase().replace(/\s+/g, '_')));
+
+ const filteredNotifications = notifications.filter((notif: Notification) => {
+ const normalizedAppName = notif.app_name.toLowerCase().replace(/\s+/g, '_');
+ return !notifFilter.has(normalizedAppName);
+ });
+
+ return filteredNotifications;
+};
diff --git a/ags/HyprPanel/lib/types/audio.d.ts b/ags/HyprPanel/lib/types/audio.d.ts
new file mode 100644
index 0000000..9332431
--- /dev/null
+++ b/ags/HyprPanel/lib/types/audio.d.ts
@@ -0,0 +1,5 @@
+export type InputDevices = Button, Attribute>, Attribute>, Attribute>[];
+
+type DummyDevices = Button, Attribute>, Attribute>, Attribute>[];
+type RealPlaybackDevices = Button, Attribute>, Attribute>, Attribute>[];
+export type PlaybackDevices = DummyDevices | RealPlaybackDevices;
diff --git a/ags/HyprPanel/lib/types/bar.d.ts b/ags/HyprPanel/lib/types/bar.d.ts
new file mode 100644
index 0000000..99066d8
--- /dev/null
+++ b/ags/HyprPanel/lib/types/bar.d.ts
@@ -0,0 +1,42 @@
+import { Binding, Connectable } from 'types/service';
+import { Variable } from 'types/variable';
+import Box from 'types/widgets/box';
+import Button, { ButtonProps } from 'types/widgets/button';
+import Label from 'types/widgets/label';
+import { Attribute, Child } from './widget';
+
+export type BarBoxChild = {
+ component: Box;
+ isVisible?: boolean;
+ isVis?: Variable;
+ boxClass: string;
+ tooltip_text?: string | Binding;
+ props: ButtonProps;
+};
+
+export type SelfButton = Button;
+
+export type BoxHook = (self: Box) => void;
+export type LabelHook = (self: Label) => void;
+
+export type Module = {
+ icon?: string | Binding;
+ textIcon?: string | Binding;
+ label?: string | Binding;
+ labelHook?: LabelHook;
+ boundLabel?: string;
+ tooltipText?: string | Binding;
+ boxClass: string;
+ props?: ButtonProps;
+ showLabel?: boolean;
+ showLabelBinding?: Binding;
+ hook?: BoxHook;
+ connection?: Binding;
+};
+
+export type ResourceLabelType = 'used/total' | 'used' | 'percentage' | 'free';
+
+export type NetstatLabelType = 'full' | 'in' | 'out';
+export type RateUnit = 'GiB' | 'MiB' | 'KiB' | 'auto';
+
+
diff --git a/ags/HyprPanel/lib/types/customModules/generic.d.ts b/ags/HyprPanel/lib/types/customModules/generic.d.ts
new file mode 100644
index 0000000..839e04a
--- /dev/null
+++ b/ags/HyprPanel/lib/types/customModules/generic.d.ts
@@ -0,0 +1,13 @@
+export type GenericFunction = (...args: P) => T;
+
+export type GenericResourceMetrics = {
+ total: number;
+ used: number;
+ percentage: number;
+};
+
+type GenericResourceData = ResourceUsage & {
+ free: number;
+};
+
+export type Postfix = 'TiB' | 'GiB' | 'MiB' | 'KiB' | 'B';
diff --git a/ags/HyprPanel/lib/types/customModules/kbLayout.d.ts b/ags/HyprPanel/lib/types/customModules/kbLayout.d.ts
new file mode 100644
index 0000000..b287e06
--- /dev/null
+++ b/ags/HyprPanel/lib/types/customModules/kbLayout.d.ts
@@ -0,0 +1,32 @@
+import { layoutMap } from 'customModules/kblayout/layouts';
+
+export type KbLabelType = 'layout' | 'code';
+
+export type HyprctlKeyboard = {
+ address: string;
+ name: string;
+ rules: string;
+ model: string;
+ layout: string;
+ variant: string;
+ options: string;
+ active_keymap: string;
+ main: boolean;
+};
+
+export type HyprctlMouse = {
+ address: string;
+ name: string;
+ defaultSpeed: number;
+};
+
+export type HyprctlDeviceLayout = {
+ mice: HyprctlMouse[];
+ keyboards: HyprctlKeyboard[];
+ tablets: unknown[];
+ touch: unknown[];
+ switches: unknown[];
+};
+
+export type LayoutKeys = keyof typeof layoutMap;
+export type LayoutValues = (typeof layoutMap)[LayoutKeys];
diff --git a/ags/HyprPanel/lib/types/customModules/network.d.ts b/ags/HyprPanel/lib/types/customModules/network.d.ts
new file mode 100644
index 0000000..dbef13f
--- /dev/null
+++ b/ags/HyprPanel/lib/types/customModules/network.d.ts
@@ -0,0 +1,4 @@
+export type NetworkResourceData = {
+ in: string;
+ out: string;
+};
diff --git a/ags/HyprPanel/lib/types/customModules/utils.d.ts b/ags/HyprPanel/lib/types/customModules/utils.d.ts
new file mode 100644
index 0000000..14ccf90
--- /dev/null
+++ b/ags/HyprPanel/lib/types/customModules/utils.d.ts
@@ -0,0 +1,11 @@
+import { Binding } from 'lib/utils';
+
+export type InputHandlerEvents = {
+ onPrimaryClick?: Binding;
+ onSecondaryClick?: Binding;
+ onMiddleClick?: Binding;
+ onScrollUp?: Binding;
+ onScrollDown?: Binding;
+};
+
+export type RunAsyncCommand = (cmd: string, args: EventArgs, fn?: (output: string) => void) => void;
diff --git a/ags/HyprPanel/lib/types/defaults/bar.ts b/ags/HyprPanel/lib/types/defaults/bar.ts
new file mode 100644
index 0000000..78966c4
--- /dev/null
+++ b/ags/HyprPanel/lib/types/defaults/bar.ts
@@ -0,0 +1,5 @@
+import { NetstatLabelType, ResourceLabelType } from '../bar';
+
+export const LABEL_TYPES: ResourceLabelType[] = ['used/total', 'used', 'free', 'percentage'];
+
+export const NETWORK_LABEL_TYPES: NetstatLabelType[] = ['full', 'in', 'out'];
diff --git a/ags/HyprPanel/lib/types/defaults/netstat.ts b/ags/HyprPanel/lib/types/defaults/netstat.ts
new file mode 100644
index 0000000..083344b
--- /dev/null
+++ b/ags/HyprPanel/lib/types/defaults/netstat.ts
@@ -0,0 +1,10 @@
+import { RateUnit } from '../bar';
+import { NetworkResourceData } from '../customModules/network';
+
+export const GET_DEFAULT_NETSTAT_DATA = (dataType: RateUnit): NetworkResourceData => {
+ if (dataType === 'auto') {
+ return { in: `0 Kib/s`, out: `0 Kib/s` };
+ }
+
+ return { in: `0 ${dataType}/s`, out: `0 ${dataType}/s` };
+};
diff --git a/ags/HyprPanel/lib/types/defaults/options.ts b/ags/HyprPanel/lib/types/defaults/options.ts
new file mode 100644
index 0000000..11b7347
--- /dev/null
+++ b/ags/HyprPanel/lib/types/defaults/options.ts
@@ -0,0 +1,60 @@
+export const defaultColorMap = {
+ rosewater: '#f5e0dc',
+ flamingo: '#f2cdcd',
+ pink: '#f5c2e7',
+ mauve: '#cba6f7',
+ red: '#f38ba8',
+ maroon: '#eba0ac',
+ peach: '#fab387',
+ yellow: '#f9e2af',
+ green: '#a6e3a1',
+ teal: '#94e2d5',
+ sky: '#89dceb',
+ sapphire: '#74c7ec',
+ blue: '#89b4fa',
+ lavender: '#b4befe',
+ text: '#cdd6f4',
+ subtext1: '#bac2de',
+ subtext2: '#a6adc8',
+ overlay2: '#9399b2',
+ overlay1: '#7f849c',
+ overlay0: '#6c7086',
+ surface2: '#585b70',
+ surface1: '#45475a',
+ surface0: '#313244',
+ base2: '#242438',
+ base: '#1e1e2e',
+ mantle: '#181825',
+ crust: '#11111b',
+ surface1_2: '#454759',
+ text2: '#cdd6f3',
+ pink2: '#f5c2e6',
+ red2: '#f38ba7',
+ peach2: '#fab386',
+ mantle2: '#181824',
+ surface0_2: '#313243',
+ surface2_2: '#585b69',
+ overlay1_2: '#7f849b',
+ lavender2: '#b4befd',
+ mauve2: '#cba6f6',
+ green2: '#a6e3a0',
+ sky2: '#89dcea',
+ teal2: '#94e2d4',
+ yellow2: '#f9e2ad',
+ maroon2: '#eba0ab',
+ crust2: '#11111a',
+ pink3: '#f5c2e8',
+ red3: '#f38ba9',
+ mantle3: '#181826',
+ surface0_3: '#313245',
+ surface2_3: '#585b71',
+ overlay1_3: '#7f849d',
+ lavender3: '#b4beff',
+ mauve3: '#cba6f8',
+ green3: '#a6e3a2',
+ sky3: '#89dcec',
+ teal3: '#94e2d6',
+ yellow3: '#f9e2ae',
+ maroon3: '#eba0ad',
+ crust3: '#11111c',
+} as const;
diff --git a/ags/HyprPanel/lib/types/defaults/weather.ts b/ags/HyprPanel/lib/types/defaults/weather.ts
new file mode 100644
index 0000000..cde95e3
--- /dev/null
+++ b/ags/HyprPanel/lib/types/defaults/weather.ts
@@ -0,0 +1,1053 @@
+export const DEFAULT_WEATHER = {
+ location: {
+ name: 'Tahiti',
+ region: 'Somewhere',
+ country: 'United States of America',
+ lat: 0,
+ lon: 0,
+ tz_id: 'Tahiti',
+ localtime_epoch: 1721981457,
+ localtime: '2024-07-26 1:10',
+ },
+ current: {
+ last_updated_epoch: 1721980800,
+ last_updated: '2024-07-26 01:00',
+ temp_c: 0,
+ temp_f: 0,
+ is_day: 0,
+ condition: {
+ text: 'Clear',
+ icon: '//cdn.weatherapi.com/weather/64x64/night/113.png',
+ code: 1000,
+ },
+ wind_mph: 0,
+ wind_kph: 0,
+ wind_degree: 0,
+ wind_dir: 'NW',
+ pressure_mb: 0,
+ pressure_in: 0,
+ precip_mm: 0.0,
+ precip_in: 0.0,
+ humidity: 0,
+ cloud: 0,
+ feelslike_c: 0,
+ feelslike_f: 0,
+ windchill_c: 0,
+ windchill_f: 0,
+ heatindex_c: 0,
+ heatindex_f: 0,
+ dewpoint_c: 0,
+ dewpoint_f: 0,
+ vis_km: 0,
+ vis_miles: 0,
+ uv: 0,
+ gust_mph: 0,
+ gust_kph: 0,
+ },
+ forecast: {
+ forecastday: [
+ {
+ date: '2024-07-26',
+ date_epoch: 1721952000,
+ day: {
+ maxtemp_c: 0,
+ maxtemp_f: 0,
+ mintemp_c: 0,
+ mintemp_f: 0,
+ avgtemp_c: 0,
+ avgtemp_f: 0,
+ maxwind_mph: 0,
+ maxwind_kph: 0,
+ totalprecip_mm: 0,
+ totalprecip_in: 0,
+ totalsnow_cm: 0,
+ avgvis_km: 0,
+ avgvis_miles: 0,
+ avghumidity: 0,
+ daily_will_it_rain: 0,
+ daily_chance_of_rain: 0,
+ daily_will_it_snow: 0,
+ daily_chance_of_snow: 0,
+ condition: {
+ text: 'Sunny',
+ icon: '//cdn.weatherapi.com/weather/64x64/day/113.png',
+ code: 1000,
+ },
+ uv: 0,
+ },
+ astro: {
+ sunrise: '06:01 AM',
+ sunset: '08:10 PM',
+ moonrise: '11:32 PM',
+ moonset: '12:01 PM',
+ moon_phase: 'Waning Gibbous',
+ moon_illumination: 0,
+ is_moon_up: 0,
+ is_sun_up: 0,
+ },
+ hour: [
+ {
+ time_epoch: 1721977200,
+ time: '2024-07-26 00:00',
+ temp_c: 0,
+ temp_f: 0,
+ is_day: 0,
+ condition: {
+ text: 'Clear ',
+ icon: '//cdn.weatherapi.com/weather/64x64/night/113.png',
+ code: 1000,
+ },
+ wind_mph: 0,
+ wind_kph: 0,
+ wind_degree: 0,
+ wind_dir: 'N',
+ pressure_mb: 0,
+ pressure_in: 0,
+ precip_mm: 0,
+ precip_in: 0,
+ snow_cm: 0,
+ humidity: 0,
+ cloud: 0,
+ feelslike_c: 0,
+ feelslike_f: 0,
+ windchill_c: 0,
+ windchill_f: 0,
+ heatindex_c: 0,
+ heatindex_f: 0,
+ dewpoint_c: 0,
+ dewpoint_f: 0,
+ will_it_rain: 0,
+ chance_of_rain: 0,
+ will_it_snow: 0,
+ chance_of_snow: 0,
+ vis_km: 0,
+ vis_miles: 0,
+ gust_mph: 0,
+ gust_kph: 0,
+ uv: 0,
+ },
+ {
+ time_epoch: 1721980800,
+ time: '2024-07-26 01:00',
+ temp_c: 0,
+ temp_f: 0,
+ is_day: 0,
+ condition: {
+ text: 'Clear ',
+ icon: '//cdn.weatherapi.com/weather/64x64/night/113.png',
+ code: 1000,
+ },
+ wind_mph: 0,
+ wind_kph: 0,
+ wind_degree: 0,
+ wind_dir: 'N',
+ pressure_mb: 0,
+ pressure_in: 0,
+ precip_mm: 0,
+ precip_in: 0,
+ snow_cm: 0,
+ humidity: 0,
+ cloud: 0,
+ feelslike_c: 0,
+ feelslike_f: 0,
+ windchill_c: 0,
+ windchill_f: 0,
+ heatindex_c: 0,
+ heatindex_f: 0,
+ dewpoint_c: 0,
+ dewpoint_f: 0,
+ will_it_rain: 0,
+ chance_of_rain: 0,
+ will_it_snow: 0,
+ chance_of_snow: 0,
+ vis_km: 0,
+ vis_miles: 0,
+ gust_mph: 0,
+ gust_kph: 0,
+ uv: 0,
+ },
+ {
+ time_epoch: 1721984400,
+ time: '2024-07-26 02:00',
+ temp_c: 0,
+ temp_f: 0,
+ is_day: 0,
+ condition: {
+ text: 'Clear ',
+ icon: '//cdn.weatherapi.com/weather/64x64/night/113.png',
+ code: 1000,
+ },
+ wind_mph: 0,
+ wind_kph: 0,
+ wind_degree: 0,
+ wind_dir: 'N',
+ pressure_mb: 0,
+ pressure_in: 0,
+ precip_mm: 0,
+ precip_in: 0,
+ snow_cm: 0,
+ humidity: 0,
+ cloud: 0,
+ feelslike_c: 0,
+ feelslike_f: 0,
+ windchill_c: 0,
+ windchill_f: 0,
+ heatindex_c: 0,
+ heatindex_f: 0,
+ dewpoint_c: 0,
+ dewpoint_f: 0,
+ will_it_rain: 0,
+ chance_of_rain: 0,
+ will_it_snow: 0,
+ chance_of_snow: 0,
+ vis_km: 0,
+ vis_miles: 0,
+ gust_mph: 0,
+ gust_kph: 0,
+ uv: 0,
+ },
+ {
+ time_epoch: 1721988000,
+ time: '2024-07-26 03:00',
+ temp_c: 0,
+ temp_f: 0,
+ is_day: 0,
+ condition: {
+ text: 'Clear ',
+ icon: '//cdn.weatherapi.com/weather/64x64/night/113.png',
+ code: 1000,
+ },
+ wind_mph: 0,
+ wind_kph: 0,
+ wind_degree: 0,
+ wind_dir: 'N',
+ pressure_mb: 0,
+ pressure_in: 0,
+ precip_mm: 0,
+ precip_in: 0,
+ snow_cm: 0,
+ humidity: 0,
+ cloud: 0,
+ feelslike_c: 0,
+ feelslike_f: 0,
+ windchill_c: 0,
+ windchill_f: 0,
+ heatindex_c: 0,
+ heatindex_f: 0,
+ dewpoint_c: 0,
+ dewpoint_f: 0,
+ will_it_rain: 0,
+ chance_of_rain: 0,
+ will_it_snow: 0,
+ chance_of_snow: 0,
+ vis_km: 0,
+ vis_miles: 0,
+ gust_mph: 0,
+ gust_kph: 0,
+ uv: 0,
+ },
+ {
+ time_epoch: 1721991600,
+ time: '2024-07-26 04:00',
+ temp_c: 0,
+ temp_f: 0,
+ is_day: 0,
+ condition: {
+ text: 'Clear ',
+ icon: '//cdn.weatherapi.com/weather/64x64/night/113.png',
+ code: 1000,
+ },
+ wind_mph: 0,
+ wind_kph: 0,
+ wind_degree: 0,
+ wind_dir: 'N',
+ pressure_mb: 0,
+ pressure_in: 0,
+ precip_mm: 0,
+ precip_in: 0,
+ snow_cm: 0,
+ humidity: 0,
+ cloud: 0,
+ feelslike_c: 0,
+ feelslike_f: 0,
+ windchill_c: 0,
+ windchill_f: 0,
+ heatindex_c: 0,
+ heatindex_f: 0,
+ dewpoint_c: 0,
+ dewpoint_f: 0,
+ will_it_rain: 0,
+ chance_of_rain: 0,
+ will_it_snow: 0,
+ chance_of_snow: 0,
+ vis_km: 0,
+ vis_miles: 0,
+ gust_mph: 0,
+ gust_kph: 0,
+ uv: 0,
+ },
+ {
+ time_epoch: 1721995200,
+ time: '2024-07-26 05:00',
+ temp_c: 0,
+ temp_f: 0,
+ is_day: 0,
+ condition: {
+ text: 'Clear ',
+ icon: '//cdn.weatherapi.com/weather/64x64/night/113.png',
+ code: 1000,
+ },
+ wind_mph: 0,
+ wind_kph: 0,
+ wind_degree: 0,
+ wind_dir: 'N',
+ pressure_mb: 0,
+ pressure_in: 0,
+ precip_mm: 0,
+ precip_in: 0,
+ snow_cm: 0,
+ humidity: 0,
+ cloud: 0,
+ feelslike_c: 0,
+ feelslike_f: 0,
+ windchill_c: 0,
+ windchill_f: 0,
+ heatindex_c: 0,
+ heatindex_f: 0,
+ dewpoint_c: 0,
+ dewpoint_f: 0,
+ will_it_rain: 0,
+ chance_of_rain: 0,
+ will_it_snow: 0,
+ chance_of_snow: 0,
+ vis_km: 0,
+ vis_miles: 0,
+ gust_mph: 0,
+ gust_kph: 0,
+ uv: 0,
+ },
+ {
+ time_epoch: 1721998800,
+ time: '2024-07-26 06:00',
+ temp_c: 0,
+ temp_f: 0,
+ is_day: 0,
+ condition: {
+ text: 'Clear ',
+ icon: '//cdn.weatherapi.com/weather/64x64/night/113.png',
+ code: 1000,
+ },
+ wind_mph: 0,
+ wind_kph: 0,
+ wind_degree: 0,
+ wind_dir: 'N',
+ pressure_mb: 0,
+ pressure_in: 0,
+ precip_mm: 0,
+ precip_in: 0,
+ snow_cm: 0,
+ humidity: 0,
+ cloud: 0,
+ feelslike_c: 0,
+ feelslike_f: 0,
+ windchill_c: 0,
+ windchill_f: 0,
+ heatindex_c: 0,
+ heatindex_f: 0,
+ dewpoint_c: 0,
+ dewpoint_f: 0,
+ will_it_rain: 0,
+ chance_of_rain: 0,
+ will_it_snow: 0,
+ chance_of_snow: 0,
+ vis_km: 0,
+ vis_miles: 0,
+ gust_mph: 0,
+ gust_kph: 0,
+ uv: 0,
+ },
+ {
+ time_epoch: 1722002400,
+ time: '2024-07-26 07:00',
+ temp_c: 0,
+ temp_f: 0,
+ is_day: 1,
+ condition: {
+ text: 'Sunny',
+ icon: '//cdn.weatherapi.com/weather/64x64/day/113.png',
+ code: 1000,
+ },
+ wind_mph: 0,
+ wind_kph: 0,
+ wind_degree: 0,
+ wind_dir: 'N',
+ pressure_mb: 0,
+ pressure_in: 0,
+ precip_mm: 0,
+ precip_in: 0,
+ snow_cm: 0,
+ humidity: 0,
+ cloud: 0,
+ feelslike_c: 0,
+ feelslike_f: 0,
+ windchill_c: 0,
+ windchill_f: 0,
+ heatindex_c: 0,
+ heatindex_f: 0,
+ dewpoint_c: 0,
+ dewpoint_f: 0,
+ will_it_rain: 0,
+ chance_of_rain: 0,
+ will_it_snow: 0,
+ chance_of_snow: 0,
+ vis_km: 0,
+ vis_miles: 0,
+ gust_mph: 0,
+ gust_kph: 0,
+ uv: 7.0,
+ },
+ {
+ time_epoch: 1722006000,
+ time: '2024-07-26 08:00',
+ temp_c: 0,
+ temp_f: 0,
+ is_day: 1,
+ condition: {
+ text: 'Sunny',
+ icon: '//cdn.weatherapi.com/weather/64x64/day/113.png',
+ code: 1000,
+ },
+ wind_mph: 0,
+ wind_kph: 0,
+ wind_degree: 0,
+ wind_dir: 'N',
+ pressure_mb: 0,
+ pressure_in: 0,
+ precip_mm: 0,
+ precip_in: 0,
+ snow_cm: 0,
+ humidity: 0,
+ cloud: 0,
+ feelslike_c: 0,
+ feelslike_f: 0,
+ windchill_c: 0,
+ windchill_f: 0,
+ heatindex_c: 0,
+ heatindex_f: 0,
+ dewpoint_c: 0,
+ dewpoint_f: 0,
+ will_it_rain: 0,
+ chance_of_rain: 0,
+ will_it_snow: 0,
+ chance_of_snow: 0,
+ vis_km: 0,
+ vis_miles: 0,
+ gust_mph: 0,
+ gust_kph: 0,
+ uv: 7.0,
+ },
+ {
+ time_epoch: 1722009600,
+ time: '2024-07-26 09:00',
+ temp_c: 0,
+ temp_f: 0,
+ is_day: 1,
+ condition: {
+ text: 'Sunny',
+ icon: '//cdn.weatherapi.com/weather/64x64/day/113.png',
+ code: 1000,
+ },
+ wind_mph: 0,
+ wind_kph: 0,
+ wind_degree: 0,
+ wind_dir: 'N',
+ pressure_mb: 0,
+ pressure_in: 0,
+ precip_mm: 0,
+ precip_in: 0,
+ snow_cm: 0,
+ humidity: 0,
+ cloud: 0,
+ feelslike_c: 0,
+ feelslike_f: 0,
+ windchill_c: 0,
+ windchill_f: 0,
+ heatindex_c: 0,
+ heatindex_f: 0,
+ dewpoint_c: 0,
+ dewpoint_f: 0,
+ will_it_rain: 0,
+ chance_of_rain: 0,
+ will_it_snow: 0,
+ chance_of_snow: 0,
+ vis_km: 0,
+ vis_miles: 0,
+ gust_mph: 0,
+ gust_kph: 0,
+ uv: 8.0,
+ },
+ {
+ time_epoch: 1722013200,
+ time: '2024-07-26 10:00',
+ temp_c: 0,
+ temp_f: 0,
+ is_day: 1,
+ condition: {
+ text: 'Sunny',
+ icon: '//cdn.weatherapi.com/weather/64x64/day/113.png',
+ code: 1000,
+ },
+ wind_mph: 0,
+ wind_kph: 0,
+ wind_degree: 0,
+ wind_dir: 'N',
+ pressure_mb: 0,
+ pressure_in: 0,
+ precip_mm: 0,
+ precip_in: 0,
+ snow_cm: 0,
+ humidity: 0,
+ cloud: 0,
+ feelslike_c: 0,
+ feelslike_f: 0,
+ windchill_c: 0,
+ windchill_f: 0,
+ heatindex_c: 0,
+ heatindex_f: 0,
+ dewpoint_c: 0,
+ dewpoint_f: 0,
+ will_it_rain: 0,
+ chance_of_rain: 0,
+ will_it_snow: 0,
+ chance_of_snow: 0,
+ vis_km: 0,
+ vis_miles: 0,
+ gust_mph: 0,
+ gust_kph: 0,
+ uv: 8.0,
+ },
+ {
+ time_epoch: 1722016800,
+ time: '2024-07-26 11:00',
+ temp_c: 0,
+ temp_f: 0,
+ is_day: 1,
+ condition: {
+ text: 'Sunny',
+ icon: '//cdn.weatherapi.com/weather/64x64/day/113.png',
+ code: 1000,
+ },
+ wind_mph: 0,
+ wind_kph: 0,
+ wind_degree: 0,
+ wind_dir: 'N',
+ pressure_mb: 0,
+ pressure_in: 0,
+ precip_mm: 0,
+ precip_in: 0,
+ snow_cm: 0,
+ humidity: 0,
+ cloud: 0,
+ feelslike_c: 0,
+ feelslike_f: 0,
+ windchill_c: 0,
+ windchill_f: 0,
+ heatindex_c: 0,
+ heatindex_f: 0,
+ dewpoint_c: 0,
+ dewpoint_f: 0,
+ will_it_rain: 0,
+ chance_of_rain: 0,
+ will_it_snow: 0,
+ chance_of_snow: 0,
+ vis_km: 0,
+ vis_miles: 0,
+ gust_mph: 0,
+ gust_kph: 0,
+ uv: 9.0,
+ },
+ {
+ time_epoch: 1722020400,
+ time: '2024-07-26 12:00',
+ temp_c: 0,
+ temp_f: 0,
+ is_day: 1,
+ condition: {
+ text: 'Sunny',
+ icon: '//cdn.weatherapi.com/weather/64x64/day/113.png',
+ code: 1000,
+ },
+ wind_mph: 0,
+ wind_kph: 0,
+ wind_degree: 0,
+ wind_dir: 'N',
+ pressure_mb: 0,
+ pressure_in: 0,
+ precip_mm: 0,
+ precip_in: 0,
+ snow_cm: 0,
+ humidity: 0,
+ cloud: 0,
+ feelslike_c: 0,
+ feelslike_f: 0,
+ windchill_c: 0,
+ windchill_f: 0,
+ heatindex_c: 0,
+ heatindex_f: 0,
+ dewpoint_c: 0,
+ dewpoint_f: 0,
+ will_it_rain: 0,
+ chance_of_rain: 0,
+ will_it_snow: 0,
+ chance_of_snow: 0,
+ vis_km: 0,
+ vis_miles: 0,
+ gust_mph: 0,
+ gust_kph: 0,
+ uv: 9.0,
+ },
+ {
+ time_epoch: 1722024000,
+ time: '2024-07-26 13:00',
+ temp_c: 0,
+ temp_f: 0,
+ is_day: 1,
+ condition: {
+ text: 'Sunny',
+ icon: '//cdn.weatherapi.com/weather/64x64/day/113.png',
+ code: 1000,
+ },
+ wind_mph: 0,
+ wind_kph: 0,
+ wind_degree: 0,
+ wind_dir: 'N',
+ pressure_mb: 0,
+ pressure_in: 0,
+ precip_mm: 0,
+ precip_in: 0,
+ snow_cm: 0,
+ humidity: 0,
+ cloud: 0,
+ feelslike_c: 0,
+ feelslike_f: 0,
+ windchill_c: 0,
+ windchill_f: 0,
+ heatindex_c: 0,
+ heatindex_f: 0,
+ dewpoint_c: 0,
+ dewpoint_f: 0,
+ will_it_rain: 0,
+ chance_of_rain: 0,
+ will_it_snow: 0,
+ chance_of_snow: 0,
+ vis_km: 0,
+ vis_miles: 0,
+ gust_mph: 0,
+ gust_kph: 0,
+ uv: 9.0,
+ },
+ {
+ time_epoch: 1722027600,
+ time: '2024-07-26 14:00',
+ temp_c: 0,
+ temp_f: 0,
+ is_day: 1,
+ condition: {
+ text: 'Sunny',
+ icon: '//cdn.weatherapi.com/weather/64x64/day/113.png',
+ code: 1000,
+ },
+ wind_mph: 0,
+ wind_kph: 0,
+ wind_degree: 0,
+ wind_dir: 'N',
+ pressure_mb: 0,
+ pressure_in: 0,
+ precip_mm: 0,
+ precip_in: 0,
+ snow_cm: 0,
+ humidity: 0,
+ cloud: 0,
+ feelslike_c: 0,
+ feelslike_f: 0,
+ windchill_c: 0,
+ windchill_f: 0,
+ heatindex_c: 0,
+ heatindex_f: 0,
+ dewpoint_c: 0,
+ dewpoint_f: 0,
+ will_it_rain: 0,
+ chance_of_rain: 0,
+ will_it_snow: 0,
+ chance_of_snow: 0,
+ vis_km: 0,
+ vis_miles: 0,
+ gust_mph: 0,
+ gust_kph: 0,
+ uv: 10.0,
+ },
+ {
+ time_epoch: 1722031200,
+ time: '2024-07-26 15:00',
+ temp_c: 0,
+ temp_f: 0,
+ is_day: 1,
+ condition: {
+ text: 'Sunny',
+ icon: '//cdn.weatherapi.com/weather/64x64/day/113.png',
+ code: 1000,
+ },
+ wind_mph: 0,
+ wind_kph: 0,
+ wind_degree: 0,
+ wind_dir: 'N',
+ pressure_mb: 0,
+ pressure_in: 0,
+ precip_mm: 0,
+ precip_in: 0,
+ snow_cm: 0,
+ humidity: 0,
+ cloud: 0,
+ feelslike_c: 0,
+ feelslike_f: 0,
+ windchill_c: 0,
+ windchill_f: 0,
+ heatindex_c: 0,
+ heatindex_f: 0,
+ dewpoint_c: 0,
+ dewpoint_f: 0,
+ will_it_rain: 0,
+ chance_of_rain: 0,
+ will_it_snow: 0,
+ chance_of_snow: 0,
+ vis_km: 0,
+ vis_miles: 0,
+ gust_mph: 0,
+ gust_kph: 0,
+ uv: 10.0,
+ },
+ {
+ time_epoch: 1722034800,
+ time: '2024-07-26 16:00',
+ temp_c: 0,
+ temp_f: 0,
+ is_day: 1,
+ condition: {
+ text: 'Sunny',
+ icon: '//cdn.weatherapi.com/weather/64x64/day/113.png',
+ code: 1000,
+ },
+ wind_mph: 0,
+ wind_kph: 0,
+ wind_degree: 0,
+ wind_dir: 'N',
+ pressure_mb: 0,
+ pressure_in: 0,
+ precip_mm: 0,
+ precip_in: 0,
+ snow_cm: 0,
+ humidity: 0,
+ cloud: 0,
+ feelslike_c: 0,
+ feelslike_f: 0,
+ windchill_c: 0,
+ windchill_f: 0,
+ heatindex_c: 0,
+ heatindex_f: 0,
+ dewpoint_c: 0,
+ dewpoint_f: 0,
+ will_it_rain: 0,
+ chance_of_rain: 0,
+ will_it_snow: 0,
+ chance_of_snow: 0,
+ vis_km: 0,
+ vis_miles: 0,
+ gust_mph: 0,
+ gust_kph: 0,
+ uv: 10.0,
+ },
+ {
+ time_epoch: 1722038400,
+ time: '2024-07-26 17:00',
+ temp_c: 0,
+ temp_f: 0,
+ is_day: 1,
+ condition: {
+ text: 'Sunny',
+ icon: '//cdn.weatherapi.com/weather/64x64/day/113.png',
+ code: 1000,
+ },
+ wind_mph: 0,
+ wind_kph: 0,
+ wind_degree: 0,
+ wind_dir: 'N',
+ pressure_mb: 0,
+ pressure_in: 0,
+ precip_mm: 0,
+ precip_in: 0,
+ snow_cm: 0,
+ humidity: 0,
+ cloud: 0,
+ feelslike_c: 0,
+ feelslike_f: 0,
+ windchill_c: 0,
+ windchill_f: 0,
+ heatindex_c: 0,
+ heatindex_f: 0,
+ dewpoint_c: 0,
+ dewpoint_f: 0,
+ will_it_rain: 0,
+ chance_of_rain: 0,
+ will_it_snow: 0,
+ chance_of_snow: 0,
+ vis_km: 0,
+ vis_miles: 0,
+ gust_mph: 0,
+ gust_kph: 0,
+ uv: 10.0,
+ },
+ {
+ time_epoch: 1722042000,
+ time: '2024-07-26 18:00',
+ temp_c: 0,
+ temp_f: 0,
+ is_day: 1,
+ condition: {
+ text: 'Sunny',
+ icon: '//cdn.weatherapi.com/weather/64x64/day/113.png',
+ code: 1000,
+ },
+ wind_mph: 0,
+ wind_kph: 0,
+ wind_degree: 0,
+ wind_dir: 'N',
+ pressure_mb: 0,
+ pressure_in: 0,
+ precip_mm: 0,
+ precip_in: 0,
+ snow_cm: 0,
+ humidity: 0,
+ cloud: 0,
+ feelslike_c: 0,
+ feelslike_f: 0,
+ windchill_c: 0,
+ windchill_f: 0,
+ heatindex_c: 0,
+ heatindex_f: 0,
+ dewpoint_c: 0,
+ dewpoint_f: 0,
+ will_it_rain: 0,
+ chance_of_rain: 0,
+ will_it_snow: 0,
+ chance_of_snow: 0,
+ vis_km: 0,
+ vis_miles: 0,
+ gust_mph: 0,
+ gust_kph: 0,
+ uv: 9.0,
+ },
+ {
+ time_epoch: 1722045600,
+ time: '2024-07-26 19:00',
+ temp_c: 0,
+ temp_f: 0,
+ is_day: 1,
+ condition: {
+ text: 'Sunny',
+ icon: '//cdn.weatherapi.com/weather/64x64/day/113.png',
+ code: 1000,
+ },
+ wind_mph: 0,
+ wind_kph: 0,
+ wind_degree: 0,
+ wind_dir: 'N',
+ pressure_mb: 0,
+ pressure_in: 0,
+ precip_mm: 0,
+ precip_in: 0,
+ snow_cm: 0,
+ humidity: 0,
+ cloud: 0,
+ feelslike_c: 0,
+ feelslike_f: 0,
+ windchill_c: 0,
+ windchill_f: 0,
+ heatindex_c: 0,
+ heatindex_f: 0,
+ dewpoint_c: 0,
+ dewpoint_f: 0,
+ will_it_rain: 0,
+ chance_of_rain: 0,
+ will_it_snow: 0,
+ chance_of_snow: 0,
+ vis_km: 0,
+ vis_miles: 0,
+ gust_mph: 0,
+ gust_kph: 0,
+ uv: 9.0,
+ },
+ {
+ time_epoch: 1722049200,
+ time: '2024-07-26 20:00',
+ temp_c: 0,
+ temp_f: 0,
+ is_day: 1,
+ condition: {
+ text: 'Sunny',
+ icon: '//cdn.weatherapi.com/weather/64x64/day/113.png',
+ code: 1000,
+ },
+ wind_mph: 0,
+ wind_kph: 0,
+ wind_degree: 0,
+ wind_dir: 'N',
+ pressure_mb: 0,
+ pressure_in: 0,
+ precip_mm: 0,
+ precip_in: 0,
+ snow_cm: 0,
+ humidity: 0,
+ cloud: 0,
+ feelslike_c: 0,
+ feelslike_f: 0,
+ windchill_c: 0,
+ windchill_f: 0,
+ heatindex_c: 0,
+ heatindex_f: 0,
+ dewpoint_c: 0,
+ dewpoint_f: 0,
+ will_it_rain: 0,
+ chance_of_rain: 0,
+ will_it_snow: 0,
+ chance_of_snow: 0,
+ vis_km: 0,
+ vis_miles: 0,
+ gust_mph: 0,
+ gust_kph: 0,
+ uv: 8.0,
+ },
+ {
+ time_epoch: 1722052800,
+ time: '2024-07-26 21:00',
+ temp_c: 0,
+ temp_f: 0,
+ is_day: 0,
+ condition: {
+ text: 'Clear ',
+ icon: '//cdn.weatherapi.com/weather/64x64/night/113.png',
+ code: 1000,
+ },
+ wind_mph: 0,
+ wind_kph: 0,
+ wind_degree: 0,
+ wind_dir: 'N',
+ pressure_mb: 0,
+ pressure_in: 0,
+ precip_mm: 0,
+ precip_in: 0,
+ snow_cm: 0,
+ humidity: 0,
+ cloud: 0,
+ feelslike_c: 0,
+ feelslike_f: 0,
+ windchill_c: 0,
+ windchill_f: 0,
+ heatindex_c: 0,
+ heatindex_f: 0,
+ dewpoint_c: 0,
+ dewpoint_f: 0,
+ will_it_rain: 0,
+ chance_of_rain: 0,
+ will_it_snow: 0,
+ chance_of_snow: 0,
+ vis_km: 0,
+ vis_miles: 0,
+ gust_mph: 0,
+ gust_kph: 0,
+ uv: 0,
+ },
+ {
+ time_epoch: 1722056400,
+ time: '2024-07-26 22:00',
+ temp_c: 0,
+ temp_f: 0,
+ is_day: 0,
+ condition: {
+ text: 'Clear ',
+ icon: '//cdn.weatherapi.com/weather/64x64/night/113.png',
+ code: 1000,
+ },
+ wind_mph: 0,
+ wind_kph: 0,
+ wind_degree: 0,
+ wind_dir: 'N',
+ pressure_mb: 0,
+ pressure_in: 0,
+ precip_mm: 0,
+ precip_in: 0,
+ snow_cm: 0,
+ humidity: 0,
+ cloud: 0,
+ feelslike_c: 0,
+ feelslike_f: 0,
+ windchill_c: 0,
+ windchill_f: 0,
+ heatindex_c: 0,
+ heatindex_f: 0,
+ dewpoint_c: 0,
+ dewpoint_f: 0,
+ will_it_rain: 0,
+ chance_of_rain: 0,
+ will_it_snow: 0,
+ chance_of_snow: 0,
+ vis_km: 0,
+ vis_miles: 0,
+ gust_mph: 0,
+ gust_kph: 0,
+ uv: 0,
+ },
+ {
+ time_epoch: 1722060000,
+ time: '2024-07-26 23:00',
+ temp_c: 0,
+ temp_f: 0,
+ is_day: 0,
+ condition: {
+ text: 'Clear ',
+ icon: '//cdn.weatherapi.com/weather/64x64/night/113.png',
+ code: 1000,
+ },
+ wind_mph: 0,
+ wind_kph: 0,
+ wind_degree: 0,
+ wind_dir: 'N',
+ pressure_mb: 0,
+ pressure_in: 0,
+ precip_mm: 0,
+ precip_in: 0,
+ snow_cm: 0,
+ humidity: 0,
+ cloud: 0,
+ feelslike_c: 0,
+ feelslike_f: 0,
+ windchill_c: 0,
+ windchill_f: 0,
+ heatindex_c: 0,
+ heatindex_f: 0,
+ dewpoint_c: 0,
+ dewpoint_f: 0,
+ will_it_rain: 0,
+ chance_of_rain: 0,
+ will_it_snow: 0,
+ chance_of_snow: 0,
+ vis_km: 0,
+ vis_miles: 0,
+ gust_mph: 0,
+ gust_kph: 0,
+ uv: 0,
+ },
+ ],
+ },
+ ],
+ },
+};
diff --git a/ags/HyprPanel/lib/types/dropdownmenu.d.ts b/ags/HyprPanel/lib/types/dropdownmenu.d.ts
new file mode 100644
index 0000000..8d76e5c
--- /dev/null
+++ b/ags/HyprPanel/lib/types/dropdownmenu.d.ts
@@ -0,0 +1,12 @@
+import { WindowProps } from 'types/widgets/window';
+import { GtkWidget, Transition } from './widget';
+import { Binding } from 'types/service';
+
+export type DropdownMenuProps = {
+ name: string;
+ child: GtkWidget;
+ layout?: string;
+ transition?: Transition | Binding;
+ exclusivity?: Exclusivity;
+ fixed?: boolean;
+} & WindowProps;
diff --git a/ags/HyprPanel/lib/types/filechooser.d.ts b/ags/HyprPanel/lib/types/filechooser.d.ts
new file mode 100644
index 0000000..8bb9d8f
--- /dev/null
+++ b/ags/HyprPanel/lib/types/filechooser.d.ts
@@ -0,0 +1,3 @@
+export type Config = {
+ [key: string]: string | number | boolean | object;
+};
diff --git a/ags/HyprPanel/lib/types/globals.d.ts b/ags/HyprPanel/lib/types/globals.d.ts
new file mode 100644
index 0000000..ecdb39f
--- /dev/null
+++ b/ags/HyprPanel/lib/types/globals.d.ts
@@ -0,0 +1,4 @@
+export type MousePos = {
+ source: string;
+ pos: number[];
+};
diff --git a/ags/HyprPanel/lib/types/gpustat.d.ts b/ags/HyprPanel/lib/types/gpustat.d.ts
new file mode 100644
index 0000000..49dc75d
--- /dev/null
+++ b/ags/HyprPanel/lib/types/gpustat.d.ts
@@ -0,0 +1,25 @@
+export type GPU_Stat_Process = {
+ username: string;
+ command: string;
+ full_command: string[];
+ gpu_memory_usage: number;
+ cpu_percent: number;
+ cpu_memory_usage: number;
+ pid: number;
+};
+
+export type GPU_Stat = {
+ index: number;
+ uuid: string;
+ name: string;
+ 'temperature.gpu': number;
+ 'fan.speed': number;
+ 'utilization.gpu': number;
+ 'utilization.enc': number;
+ 'utilization.dec': number;
+ 'power.draw': number;
+ 'enforced.power.limit': number;
+ 'memory.used': number;
+ 'memory.total': number;
+ processes: Process[];
+};
diff --git a/ags/HyprPanel/lib/types/mpris.d.ts b/ags/HyprPanel/lib/types/mpris.d.ts
new file mode 100644
index 0000000..5450e80
--- /dev/null
+++ b/ags/HyprPanel/lib/types/mpris.d.ts
@@ -0,0 +1,2 @@
+export type LoopStatus = 'none' | 'track' | 'playlist';
+export type PlaybackStatus = 'playing' | 'paused' | 'stopped';
diff --git a/ags/HyprPanel/lib/types/network.d.ts b/ags/HyprPanel/lib/types/network.d.ts
new file mode 100644
index 0000000..58dcdec
--- /dev/null
+++ b/ags/HyprPanel/lib/types/network.d.ts
@@ -0,0 +1,16 @@
+import { WIFI_STATUS_MAP } from 'globals/network';
+
+export type AccessPoint = {
+ bssid: string | null;
+ address: string | null;
+ lastSeen: number;
+ ssid: string | null;
+ active: boolean;
+ strength: number;
+ frequency: number;
+ iconName: string | undefined;
+};
+
+export type WifiStatus = keyof typeof WIFI_STATUS_MAP;
+
+export type WifiIcon = '' | '' | '' | '' | '' | '' | '' | '' | '' | '' | '';
diff --git a/ags/HyprPanel/lib/types/notification.d.ts b/ags/HyprPanel/lib/types/notification.d.ts
new file mode 100644
index 0000000..027f195
--- /dev/null
+++ b/ags/HyprPanel/lib/types/notification.d.ts
@@ -0,0 +1,15 @@
+import icons from 'modules/icons/index';
+
+export interface NotificationArgs {
+ appName?: string;
+ body?: string;
+ iconName?: string;
+ id?: number;
+ summary?: string;
+ urgency?: Urgency;
+ category?: string;
+ timeout?: number;
+ transient?: boolean;
+}
+
+export type NotificationIcon = keyof typeof icons.notifications;
diff --git a/ags/HyprPanel/lib/types/options.d.ts b/ags/HyprPanel/lib/types/options.d.ts
new file mode 100644
index 0000000..51e7f76
--- /dev/null
+++ b/ags/HyprPanel/lib/types/options.d.ts
@@ -0,0 +1,217 @@
+import { Opt } from 'lib/option';
+import { Variable } from 'types/variable';
+import { defaultColorMap } from './defaults/options';
+
+export type MkOptionsResult = {
+ configFile: string;
+ array: () => Opt[];
+ reset: () => Promise;
+ resetTheme: () => Promise;
+ handler: (deps: string[], callback: () => void) => void;
+};
+
+export type RecursiveOptionsObject = {
+ [key: string]: RecursiveOptionsObject | Opt | Opt | Opt;
+};
+
+export type BarLocation = 'top' | 'bottom';
+
+export type Unit = 'imperial' | 'metric';
+export type PowerOptions = 'sleep' | 'reboot' | 'logout' | 'shutdown';
+export type NotificationAnchor =
+ | 'top'
+ | 'top right'
+ | 'top left'
+ | 'bottom'
+ | 'bottom right'
+ | 'bottom left'
+ | 'left'
+ | 'right';
+export type OSDAnchor = 'top left' | 'top' | 'top right' | 'right' | 'bottom right' | 'bottom' | 'bottom left' | 'left';
+export type BarButtonStyles = 'default' | 'split' | 'wave' | 'wave2';
+
+export type ThemeExportData = {
+ filePath: string;
+ themeOnly: boolean;
+};
+export type RowProps = {
+ opt: Opt;
+ title: string;
+ note?: string;
+ type?:
+ | 'number'
+ | 'color'
+ | 'float'
+ | 'object'
+ | 'string'
+ | 'enum'
+ | 'boolean'
+ | 'img'
+ | 'wallpaper'
+ | 'export'
+ | 'import'
+ | 'config_import'
+ | 'font';
+ enums?: T[];
+ max?: number;
+ min?: number;
+ disabledBinding?: Variable;
+ exportData?: ThemeExportData;
+ subtitle?: string | VarType | Opt;
+ subtitleLink?: string;
+ dependencies?: string[];
+ increment?: number;
+};
+
+export type OSDOrientation = 'horizontal' | 'vertical';
+
+export type HexColor = `#${string}`;
+
+export type WindowLayer = 'top' | 'bottom' | 'overlay' | 'background';
+
+export type ActiveWsIndicator = 'underline' | 'highlight' | 'color';
+
+export type MatugenColors = {
+ background: HexColor;
+ error: HexColor;
+ error_container: HexColor;
+ inverse_on_surface: HexColor;
+ inverse_primary: HexColor;
+ inverse_surface: HexColor;
+ on_background: HexColor;
+ on_error: HexColor;
+ on_error_container: HexColor;
+ on_primary: HexColor;
+ on_primary_container: HexColor;
+ on_primary_fixed: HexColor;
+ on_primary_fixed_variant: HexColor;
+ on_secondary: HexColor;
+ on_secondary_container: HexColor;
+ on_secondary_fixed: HexColor;
+ on_secondary_fixed_variant: HexColor;
+ on_surface: HexColor;
+ on_surface_variant: HexColor;
+ on_tertiary: HexColor;
+ on_tertiary_container: HexColor;
+ on_tertiary_fixed: HexColor;
+ on_tertiary_fixed_variant: HexColor;
+ outline: HexColor;
+ outline_variant: HexColor;
+ primary: HexColor;
+ primary_container: HexColor;
+ primary_fixed: HexColor;
+ primary_fixed_dim: HexColor;
+ scrim: HexColor;
+ secondary: HexColor;
+ secondary_container: HexColor;
+ secondary_fixed: HexColor;
+ secondary_fixed_dim: HexColor;
+ shadow: HexColor;
+ surface: HexColor;
+ surface_bright: HexColor;
+ surface_container: HexColor;
+ surface_container_high: HexColor;
+ surface_container_highest: HexColor;
+ surface_container_low: HexColor;
+ surface_container_lowest: HexColor;
+ surface_dim: HexColor;
+ surface_variant: HexColor;
+ tertiary: HexColor;
+ tertiary_container: HexColor;
+ tertiary_fixed: HexColor;
+ tertiary_fixed_dim: HexColor;
+};
+
+export type MatugenVariation = {
+ rosewater: HexColor;
+ flamingo: HexColor;
+ pink: HexColor;
+ mauve: HexColor;
+ red: HexColor;
+ maroon: HexColor;
+ peach: HexColor;
+ yellow: HexColor;
+ green: HexColor;
+ teal: HexColor;
+ sky: HexColor;
+ sapphire: HexColor;
+ blue: HexColor;
+ lavender: HexColor;
+ text: HexColor;
+ subtext1: HexColor;
+ subtext2: HexColor;
+ overlay2: HexColor;
+ overlay1: HexColor;
+ overlay0: HexColor;
+ surface2: HexColor;
+ surface1: HexColor;
+ surface0: HexColor;
+ base2: HexColor;
+ base: HexColor;
+ mantle: HexColor;
+ crust: HexColor;
+ notifications_closer: HexColor;
+ notifications_background: HexColor;
+ dashboard_btn_text: HexColor;
+ red2: HexColor;
+ peach2: HexColor;
+ pink2: HexColor;
+ mantle2: HexColor;
+ surface1_2: HexColor;
+ surface0_2: HexColor;
+ overlay1_2: HexColor;
+ text2: HexColor;
+ lavender2: HexColor;
+ crust2: HexColor;
+ maroon2: HexColor;
+ mauve2: HexColor;
+ green2: HexColor;
+ surface2_2: HexColor;
+ sky2: HexColor;
+ teal2: HexColor;
+ yellow2: HexColor;
+ pink3: HexColor;
+ red3: HexColor;
+ mantle3: HexColor;
+ surface0_3: HexColor;
+ surface2_3: HexColor;
+ overlay1_3: HexColor;
+ lavender3: HexColor;
+ mauve3: HexColor;
+ green3: HexColor;
+ sky3: HexColor;
+ teal3: HexColor;
+ yellow3: HexColor;
+ maroon3: HexColor;
+ crust3: HexColor;
+ notifications_closer?: HexColor;
+ notifications_background?: HexColor;
+ dashboard_btn_text?: HexColor;
+};
+export type MatugenScheme =
+ | 'content'
+ | 'expressive'
+ | 'fidelity'
+ | 'fruit-salad'
+ | 'monochrome'
+ | 'neutral'
+ | 'rainbow'
+ | 'tonal-spot';
+
+export type MatugenVariations =
+ | 'standard_1'
+ | 'standard_2'
+ | 'standard_3'
+ | 'monochrome_1'
+ | 'monochrome_2'
+ | 'monochrome_3'
+ | 'vivid_1'
+ | 'vivid_2'
+ | 'vivid_3';
+
+type MatugenTheme = 'light' | 'dark';
+
+export type ColorMapKey = keyof typeof defaultColorMap;
+export type ColorMapValue = (typeof defaultColorMap)[ColorMapKey];
+
+export type ScalingPriority = 'gdk' | 'hyprland' | 'both';
diff --git a/ags/HyprPanel/lib/types/popupwindow.d.ts b/ags/HyprPanel/lib/types/popupwindow.d.ts
new file mode 100644
index 0000000..300baab
--- /dev/null
+++ b/ags/HyprPanel/lib/types/popupwindow.d.ts
@@ -0,0 +1,35 @@
+import { Widget } from 'types/widgets/widget';
+import { WindowProps } from 'types/widgets/window';
+import { Transition } from './widget';
+
+export type PopupWindowProps = {
+ name: string;
+ child: Widget;
+ layout?: Layouts;
+ transition?: Transition | Binding;
+ exclusivity?: Exclusivity;
+} & WindowProps;
+
+export type LayoutFunction = (
+ name: string,
+ child: Widget,
+ transition: Transition,
+) => {
+ center: () => Widget;
+ top: () => Widget;
+ 'top-right': () => Widget;
+ 'top-center': () => Widget;
+ 'top-left': () => Widget;
+ 'bottom-left': () => Widget;
+ 'bottom-center': () => Widget;
+ 'bottom-right': () => Widget;
+};
+export type Layouts =
+ | 'center'
+ | 'top'
+ | 'top-right'
+ | 'top-center'
+ | 'top-left'
+ | 'bottom-left'
+ | 'bottom-center'
+ | 'bottom-right';
diff --git a/ags/HyprPanel/lib/types/power.d.ts b/ags/HyprPanel/lib/types/power.d.ts
new file mode 100644
index 0000000..ef3c536
--- /dev/null
+++ b/ags/HyprPanel/lib/types/power.d.ts
@@ -0,0 +1 @@
+export type Action = 'sleep' | 'reboot' | 'logout' | 'shutdown';
diff --git a/ags/HyprPanel/lib/types/powerprofiles.d.ts b/ags/HyprPanel/lib/types/powerprofiles.d.ts
new file mode 100644
index 0000000..980ac3b
--- /dev/null
+++ b/ags/HyprPanel/lib/types/powerprofiles.d.ts
@@ -0,0 +1,7 @@
+import PowerProfiles from 'types/service/powerprofiles.js';
+
+export type PowerProfiles = InstanceType;
+export type PowerProfile = 'power-saver' | 'balanced' | 'performance';
+export type PowerProfileObject = {
+ [key: string]: string;
+};
diff --git a/ags/HyprPanel/lib/types/systray.d.ts b/ags/HyprPanel/lib/types/systray.d.ts
new file mode 100644
index 0000000..e69de29
diff --git a/ags/HyprPanel/lib/types/utils.d.ts b/ags/HyprPanel/lib/types/utils.d.ts
new file mode 100644
index 0000000..b35af46
--- /dev/null
+++ b/ags/HyprPanel/lib/types/utils.d.ts
@@ -0,0 +1,7 @@
+import { substitutes } from 'lib/icons';
+import { EventArgs } from './widget';
+
+type SubstituteKeys = keyof typeof substitutes;
+
+export type ThrottleFn = (cmd: string, args: EventArgs, fn?: (output: string) => void) => void;
+export type ThrottleFnCallback = ((output: string) => void) | undefined;
diff --git a/ags/HyprPanel/lib/types/variable.d.ts b/ags/HyprPanel/lib/types/variable.d.ts
new file mode 100644
index 0000000..af540dd
--- /dev/null
+++ b/ags/HyprPanel/lib/types/variable.d.ts
@@ -0,0 +1 @@
+export type Bind = OriginalBinding, unknown>;
diff --git a/ags/HyprPanel/lib/types/volume.d.ts b/ags/HyprPanel/lib/types/volume.d.ts
new file mode 100644
index 0000000..5e2cf32
--- /dev/null
+++ b/ags/HyprPanel/lib/types/volume.d.ts
@@ -0,0 +1,3 @@
+export type VolumeIcons = {
+ [index: number]: string;
+};
diff --git a/ags/HyprPanel/lib/types/weather.d.ts b/ags/HyprPanel/lib/types/weather.d.ts
new file mode 100644
index 0000000..daf0e1a
--- /dev/null
+++ b/ags/HyprPanel/lib/types/weather.d.ts
@@ -0,0 +1,118 @@
+import { weatherIcons } from 'modules/icons/weather';
+
+export type UnitType = 'imperial' | 'metric';
+
+export type Weather = {
+ location: Location;
+ current: Current;
+ forecast: Forecast;
+};
+
+export type Current = {
+ last_updated_epoch?: number;
+ last_updated?: string;
+ temp_c: number;
+ temp_f: number;
+ is_day: number;
+ condition: Condition;
+ wind_mph: number;
+ wind_kph: number;
+ wind_degree: number;
+ wind_dir: string;
+ pressure_mb: number;
+ pressure_in: number;
+ precip_mm: number;
+ precip_in: number;
+ humidity: number;
+ cloud: number;
+ feelslike_c: number;
+ feelslike_f: number;
+ windchill_c: number;
+ windchill_f: number;
+ heatindex_c: number;
+ heatindex_f: number;
+ dewpoint_c: number;
+ dewpoint_f: number;
+ vis_km: number;
+ vis_miles: number;
+ uv: number;
+ gust_mph: number;
+ gust_kph: number;
+ time_epoch?: number;
+ time?: string;
+ snow_cm?: number;
+ will_it_rain?: number;
+ chance_of_rain?: number;
+ will_it_snow?: number;
+ chance_of_snow?: number;
+};
+
+export type Condition = {
+ text: string;
+ icon: string;
+ code: number;
+};
+
+export type Forecast = {
+ forecastday: Forecastday[];
+};
+
+export type Forecastday = {
+ date: string;
+ date_epoch: number;
+ day: Day;
+ astro: Astro;
+ hour: Current[];
+};
+
+export type Astro = {
+ sunrise: string;
+ sunset: string;
+ moonrise: string;
+ moonset: string;
+ moon_phase: string;
+ moon_illumination: number;
+ is_moon_up: number;
+ is_sun_up: number;
+};
+
+export type Day = {
+ maxtemp_c: number;
+ maxtemp_f: number;
+ mintemp_c: number;
+ mintemp_f: number;
+ avgtemp_c: number;
+ avgtemp_f: number;
+ maxwind_mph: number;
+ maxwind_kph: number;
+ totalprecip_mm: number;
+ totalprecip_in: number;
+ totalsnow_cm: number;
+ avgvis_km: number;
+ avgvis_miles: number;
+ avghumidity: number;
+ daily_will_it_rain: number;
+ daily_chance_of_rain: number;
+ daily_will_it_snow: number;
+ daily_chance_of_snow: number;
+ condition: Condition;
+ uv: number;
+};
+
+export type Location = {
+ name: string;
+ region: string;
+ country: string;
+ lat: number;
+ lon: number;
+ tz_id: string;
+ localtime_epoch: number;
+ localtime: string;
+};
+
+export type TemperatureIconColorMap = {
+ [key: number]: string;
+};
+
+export type WeatherIconTitle = keyof typeof weatherIcons;
+export type WeatherIcon = (typeof weatherIcons)[WeatherIconTitle];
diff --git a/ags/HyprPanel/lib/types/widget.d.ts b/ags/HyprPanel/lib/types/widget.d.ts
new file mode 100644
index 0000000..ebb09a4
--- /dev/null
+++ b/ags/HyprPanel/lib/types/widget.d.ts
@@ -0,0 +1,29 @@
+import Gtk from 'types/@girs/gtk-3.0/gtk-3.0';
+import Box from 'types/widgets/box';
+
+export type Exclusivity = 'normal' | 'ignore' | 'exclusive';
+export type Anchor = 'left' | 'right' | 'top' | 'down';
+export type Transition = 'none' | 'crossfade' | 'slide_right' | 'slide_left' | 'slide_up' | 'slide_down';
+
+export type Layouts =
+ | 'center'
+ | 'top'
+ | 'top-right'
+ | 'top-center'
+ | 'top-left'
+ | 'bottom-left'
+ | 'bottom-center'
+ | 'bottom-right';
+
+export type Attribute = unknown;
+export type Child = Gtk.Widget;
+export type GtkWidget = Gtk.Widget;
+export type BoxWidget = Box;
+
+export type GButton = Gtk.Button;
+export type GBox = Gtk.Box;
+export type GLabel = Gtk.Label;
+export type GCenterBox = Gtk.Box;
+
+export type EventHandler = (self: Self, event: Gdk.Event) => boolean | unknown;
+export type EventArgs = { clicked: Button; event: Gdk.Event };
diff --git a/ags/HyprPanel/lib/types/workspace.d.ts b/ags/HyprPanel/lib/types/workspace.d.ts
new file mode 100644
index 0000000..9c46c15
--- /dev/null
+++ b/ags/HyprPanel/lib/types/workspace.d.ts
@@ -0,0 +1,25 @@
+export type WorkspaceRule = {
+ workspaceString: string;
+ monitor: string;
+};
+
+export type WorkspaceMap = {
+ [key: string]: number[];
+};
+
+export type MonitorMap = {
+ [key: number]: string;
+};
+
+export type WorkspaceIcons = {
+ [key: string]: string;
+};
+
+export type WorkspaceIconsColored = {
+ [key: string]: {
+ color: string;
+ icon: string;
+ };
+};
+
+export type WorkspaceIconMap = WorkspaceIcons | WorkspaceIconsColored;
diff --git a/ags/HyprPanel/lib/utils.ts b/ags/HyprPanel/lib/utils.ts
new file mode 100644
index 0000000..8b8fd04
--- /dev/null
+++ b/ags/HyprPanel/lib/utils.ts
@@ -0,0 +1,195 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import { type Application } from 'types/service/applications';
+import { NotificationAnchor } from './types/options';
+import { OSDAnchor } from 'lib/types/options';
+import icons, { substitutes } from './icons';
+import Gtk from 'gi://Gtk?version=3.0';
+import Gdk from 'gi://Gdk';
+import GLib from 'gi://GLib?version=2.0';
+import GdkPixbuf from 'gi://GdkPixbuf';
+import { NotificationArgs } from 'types/utils/notify';
+import { SubstituteKeys } from './types/utils';
+import { Window } from 'types/@girs/gtk-3.0/gtk-3.0.cjs';
+import { namedColors } from './constants/colors';
+
+export type Binding = import('types/service').Binding;
+
+/**
+ * @returns substitute icon || name || fallback icon
+ */
+export function icon(name: string | null, fallback = icons.missing): string {
+ const validateSubstitute = (name: string): name is SubstituteKeys => name in substitutes;
+
+ if (!name) return fallback || '';
+
+ if (GLib.file_test(name, GLib.FileTest.EXISTS)) return name;
+
+ let icon: string = name;
+
+ if (validateSubstitute(name)) {
+ icon = substitutes[name];
+ }
+
+ if (Utils.lookUpIcon(icon)) return icon;
+
+ print(`no icon substitute "${icon}" for "${name}", fallback: "${fallback}"`);
+ return fallback;
+}
+
+/**
+ * @returns execAsync(["bash", "-c", cmd])
+ */
+export async function bash(strings: TemplateStringsArray | string, ...values: unknown[]): Promise {
+ const cmd =
+ typeof strings === 'string' ? strings : strings.flatMap((str, i) => str + `${values[i] ?? ''}`).join('');
+
+ return Utils.execAsync(['bash', '-c', cmd]).catch((err) => {
+ console.error(cmd, err);
+ return '';
+ });
+}
+
+/**
+ * @returns execAsync(cmd)
+ */
+export async function sh(cmd: string | string[]): Promise {
+ return Utils.execAsync(cmd).catch((err) => {
+ console.error(typeof cmd === 'string' ? cmd : cmd.join(' '), err);
+ return '';
+ });
+}
+
+export function forMonitors(widget: (monitor: number) => Gtk.Window): Window[] {
+ const n = Gdk.Display.get_default()?.get_n_monitors() || 1;
+ return range(n, 0).flatMap(widget);
+}
+
+/**
+ * @returns [start...length]
+ */
+export function range(length: number, start = 1): number[] {
+ return Array.from({ length }, (_, i) => i + start);
+}
+
+/**
+ * @returns true if all of the `bins` are found
+ */
+export function dependencies(...bins: string[]): boolean {
+ const missing = bins.filter((bin) =>
+ Utils.exec({
+ cmd: `which ${bin}`,
+ out: () => false,
+ err: () => true,
+ }),
+ );
+
+ if (missing.length > 0) {
+ console.warn(Error(`missing dependencies: ${missing.join(', ')}`));
+ Notify({
+ summary: 'Dependencies not found!',
+ body: `The following dependencies are missing: ${missing.join(', ')}`,
+ iconName: icons.ui.warning,
+ timeout: 7000,
+ });
+ }
+
+ return missing.length === 0;
+}
+
+/**
+ * run app detached
+ */
+export function launchApp(app: Application): void {
+ const exe = app.executable
+ .split(/\s+/)
+ .filter((str) => !str.startsWith('%') && !str.startsWith('@'))
+ .join(' ');
+
+ bash(`${exe} &`);
+ app.frequency += 1;
+}
+
+/**
+ * to use with drag and drop
+ */
+export function createSurfaceFromWidget(widget: Gtk.Widget): GdkPixbuf.Pixbuf {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const cairo = imports.gi.cairo as any;
+ const alloc = widget.get_allocation();
+ const surface = new cairo.ImageSurface(cairo.Format.ARGB32, alloc.width, alloc.height);
+ const cr = new cairo.Context(surface);
+ cr.setSourceRGBA(255, 255, 255, 0);
+ cr.rectangle(0, 0, alloc.width, alloc.height);
+ cr.fill();
+ widget.draw(cr);
+ return surface;
+}
+
+/**
+ * Ensure that the provided filepath is a valid image
+ */
+export const isAnImage = (imgFilePath: string): boolean => {
+ try {
+ GdkPixbuf.Pixbuf.new_from_file(imgFilePath);
+ return true;
+ } catch (error) {
+ console.error(error);
+ return false;
+ }
+};
+
+export const Notify = (notifPayload: NotificationArgs): void => {
+ let command = 'notify-send';
+ command += ` "${notifPayload.summary} "`;
+ if (notifPayload.body) command += ` "${notifPayload.body}" `;
+ if (notifPayload.appName) command += ` -a "${notifPayload.appName}"`;
+ if (notifPayload.iconName) command += ` -i "${notifPayload.iconName}"`;
+ if (notifPayload.urgency) command += ` -u "${notifPayload.urgency}"`;
+ if (notifPayload.timeout !== undefined) command += ` -t ${notifPayload.timeout}`;
+ if (notifPayload.category) command += ` -c "${notifPayload.category}"`;
+ if (notifPayload.transient) command += ` -e`;
+ if (notifPayload.id !== undefined) command += ` -r ${notifPayload.id}`;
+
+ Utils.execAsync(command);
+};
+
+export const getPosition = (pos: NotificationAnchor | OSDAnchor): ('top' | 'bottom' | 'left' | 'right')[] => {
+ const positionMap: { [key: string]: ('top' | 'bottom' | 'left' | 'right')[] } = {
+ top: ['top'],
+ 'top right': ['top', 'right'],
+ 'top left': ['top', 'left'],
+ bottom: ['bottom'],
+ 'bottom right': ['bottom', 'right'],
+ 'bottom left': ['bottom', 'left'],
+ right: ['right'],
+ left: ['left'],
+ };
+
+ return positionMap[pos] || ['top'];
+};
+export const isValidGjsColor = (color: string): boolean => {
+ const colorLower = color.toLowerCase().trim();
+
+ if (namedColors.has(colorLower)) {
+ return true;
+ }
+
+ const hexColorRegex = /^#(?:[a-fA-F0-9]{3,4}|[a-fA-F0-9]{6,8})$/;
+
+ const rgbRegex = /^rgb\(\s*(\d{1,3}%?\s*,\s*){2}\d{1,3}%?\s*\)$/;
+ const rgbaRegex = /^rgba\(\s*(\d{1,3}%?\s*,\s*){3}(0|1|0?\.\d+)\s*\)$/;
+
+ if (hexColorRegex.test(color)) {
+ return true;
+ }
+
+ if (rgbRegex.test(colorLower) || rgbaRegex.test(colorLower)) {
+ return true;
+ }
+
+ return false;
+};
+
+export const capitalizeFirstLetter = (str: string): string => {
+ return str.charAt(0).toUpperCase() + str.slice(1);
+};
diff --git a/ags/HyprPanel/lib/variables.ts b/ags/HyprPanel/lib/variables.ts
new file mode 100644
index 0000000..303c121
--- /dev/null
+++ b/ags/HyprPanel/lib/variables.ts
@@ -0,0 +1,15 @@
+import GLib from 'gi://GLib';
+import { DateTime } from 'types/@girs/glib-2.0/glib-2.0.cjs';
+
+export const clock = Variable(GLib.DateTime.new_now_local(), {
+ poll: [1000, (): DateTime => GLib.DateTime.new_now_local()],
+});
+
+export const uptime = Variable(0, {
+ poll: [60_000, 'cat /proc/uptime', (line): number => Number.parseInt(line.split('.')[0]) / 60],
+});
+
+export const distro = {
+ id: GLib.get_os_info('ID'),
+ logo: GLib.get_os_info('LOGO'),
+};
diff --git a/ags/HyprPanel/main.ts b/ags/HyprPanel/main.ts
new file mode 100644
index 0000000..399c1f9
--- /dev/null
+++ b/ags/HyprPanel/main.ts
@@ -0,0 +1,22 @@
+import 'lib/session';
+import 'scss/style';
+import 'globals/useTheme';
+import 'globals/dropdown.js';
+import 'globals/utilities';
+
+import { Bar } from 'modules/bar/Bar';
+import MenuWindows from './modules/menus/main.js';
+import SettingsDialog from 'widget/settings/SettingsDialog';
+import Notifications from './modules/notifications/index.js';
+import { forMonitors } from 'lib/utils';
+import OSD from 'modules/osd/index';
+
+App.config({
+ onConfigParsed: () => Utils.execAsync(`python3 ${App.configDir}/services/bluetooth.py`),
+ windows: [...MenuWindows, Notifications(), SettingsDialog(), ...forMonitors(Bar), OSD()],
+ closeWindowDelay: {
+ sideright: 350,
+ launcher: 350,
+ bar0: 350,
+ },
+});
diff --git a/ags/HyprPanel/modules/bar/Bar.ts b/ags/HyprPanel/modules/bar/Bar.ts
new file mode 100644
index 0000000..b5eff25
--- /dev/null
+++ b/ags/HyprPanel/modules/bar/Bar.ts
@@ -0,0 +1,315 @@
+const hyprland = await Service.import('hyprland');
+
+import {
+ Menu,
+ Workspaces,
+ ClientTitle,
+ Media,
+ Notifications,
+ Volume,
+ Network,
+ Bluetooth,
+ BatteryLabel,
+ Clock,
+ SysTray,
+
+ // Custom Modules
+ Ram,
+ Cpu,
+ Storage,
+ Netstat,
+ KbInput,
+ Updates,
+ Submap,
+ Weather,
+ Power,
+} from './Exports';
+
+import { BarItemBox as WidgetContainer } from '../shared/barItemBox.js';
+import options from 'options';
+import Gdk from 'gi://Gdk?version=3.0';
+import Button from 'types/widgets/button.js';
+import Gtk from 'types/@girs/gtk-3.0/gtk-3.0.js';
+
+import './SideEffects';
+import { WindowLayer } from 'lib/types/options.js';
+import { Attribute, Child } from 'lib/types/widget.js';
+import Window from 'types/widgets/window.js';
+
+const { layouts } = options.bar;
+const { location } = options.theme.bar;
+
+export type BarWidget = keyof typeof widget;
+
+type Section =
+ | 'battery'
+ | 'dashboard'
+ | 'workspaces'
+ | 'windowtitle'
+ | 'media'
+ | 'notifications'
+ | 'volume'
+ | 'network'
+ | 'bluetooth'
+ | 'clock'
+ | 'ram'
+ | 'cpu'
+ | 'storage'
+ | 'netstat'
+ | 'kbinput'
+ | 'updates'
+ | 'submap'
+ | 'weather'
+ | 'power'
+ | 'systray';
+
+type Layout = {
+ left: Section[];
+ middle: Section[];
+ right: Section[];
+};
+
+type BarLayout = {
+ [key: string]: Layout;
+};
+
+const getLayoutForMonitor = (monitor: number, layouts: BarLayout): Layout => {
+ const matchingKey = Object.keys(layouts).find((key) => key === monitor.toString());
+ const wildcard = Object.keys(layouts).find((key) => key === '*');
+
+ if (matchingKey) {
+ return layouts[matchingKey];
+ }
+
+ if (wildcard) {
+ return layouts[wildcard];
+ }
+
+ return {
+ left: ['dashboard', 'workspaces', 'windowtitle'],
+ middle: ['media'],
+ right: ['volume', 'network', 'bluetooth', 'battery', 'systray', 'clock', 'notifications'],
+ };
+};
+
+const widget = {
+ battery: (): Button => WidgetContainer(BatteryLabel()),
+ dashboard: (): Button => WidgetContainer(Menu()),
+ workspaces: (monitor: number): Button => WidgetContainer(Workspaces(monitor)),
+ windowtitle: (): Button => WidgetContainer(ClientTitle()),
+ media: (): Button => WidgetContainer(Media()),
+ notifications: (): Button => WidgetContainer(Notifications()),
+ volume: (): Button => WidgetContainer(Volume()),
+ network: (): Button => WidgetContainer(Network()),
+ bluetooth: (): Button => WidgetContainer(Bluetooth()),
+ clock: (): Button => WidgetContainer(Clock()),
+ systray: (): Button => WidgetContainer(SysTray()),
+ ram: (): Button => WidgetContainer(Ram()),
+ cpu: (): Button => WidgetContainer(Cpu()),
+ storage: (): Button => WidgetContainer(Storage()),
+ netstat: (): Button => WidgetContainer(Netstat()),
+ kbinput: (): Button => WidgetContainer(KbInput()),
+ updates: (): Button => WidgetContainer(Updates()),
+ submap: (): Button => WidgetContainer(Submap()),
+ weather: (): Button => WidgetContainer(Weather()),
+ power: (): Button => WidgetContainer(Power()),
+};
+
+type GdkMonitors = {
+ [key: string]: {
+ key: string;
+ model: string;
+ used: boolean;
+ };
+};
+
+function getGdkMonitors(): GdkMonitors {
+ const display = Gdk.Display.get_default();
+
+ if (display === null) {
+ console.error('Failed to get Gdk display.');
+ return {};
+ }
+
+ const numGdkMonitors = display.get_n_monitors();
+ const gdkMonitors: GdkMonitors = {};
+
+ for (let i = 0; i < numGdkMonitors; i++) {
+ const curMonitor = display.get_monitor(i);
+
+ if (curMonitor === null) {
+ console.warn(`Monitor at index ${i} is null.`);
+ continue;
+ }
+
+ const model = curMonitor.get_model() || '';
+ const geometry = curMonitor.get_geometry();
+ const scaleFactor = curMonitor.get_scale_factor();
+
+ const key = `${model}_${geometry.width}x${geometry.height}_${scaleFactor}`;
+ gdkMonitors[i] = { key, model, used: false };
+ }
+
+ return gdkMonitors;
+}
+
+/**
+ * NOTE: Some more funky stuff being done by GDK.
+ * We render windows/bar based on the monitor ID. So if you have 3 monitors, then your
+ * monitor IDs will be [0, 1, 2]. Hyprland will NEVER change what ID belongs to what monitor.
+ *
+ * So if hyprland determines id 0 = DP-1, even after you unplug, shut off or restart your monitor,
+ * the id 0 will ALWAYS be DP-1.
+ *
+ * However, GDK (the righteous genius that it is) will change the order of ID anytime your monitor
+ * setup is changed. So if you unplug your monitor and plug it back it, it now becomes the last id.
+ * So if DP-1 was id 0 and you unplugged it, it will reconfigure to id 2. This sucks because now
+ * there's a mismtach between what GDK determines the monitor is at id 2 and what Hyprland determines
+ * is at id 2.
+ *
+ * So for that reason, we need to redirect the input `monitor` that the Bar module takes in, to the
+ * proper Hyprland monitor. So when monitor id 0 comes in, we need to find what the id of that monitor
+ * is being determined as by Hyprland so the bars show up on the right monitors.
+ *
+ * Since GTK3 doesn't contain connection names and only monitor models, we have to make the best guess
+ * in the case that there are multiple models in the same resolution with the same scale. We find the
+ * 'right' monitor by checking if the model matches along with the resolution and scale. If monitor at
+ * ID 0 for GDK is being reported as 'MSI MAG271CQR' we find the same model in the Hyprland monitor list
+ * and check if the resolution and scaling is the same... if it is then we determine it's a match.
+ *
+ * The edge-case that we just can't handle is if you have the same monitors in the same resolution at the same
+ * scale. So if you've got 2 'MSI MAG271CQR' monitors at 2560x1440 at scale 1, then we just match the first
+ * monitor in the list as the first match and then the second 'MSI MAG271CQR' as a match in the 2nd iteration.
+ * You may have the bar showing up on the wrong one in this case because we don't know what the connector id
+ * is of either of these monitors (DP-1, DP-2) which are unique values - as these are only in GTK4.
+ *
+ * Keep in mind though, this is ONLY an issue if you change your monitor setup by plugging in a new one, restarting
+ * an existing one or shutting it off.
+ *
+ * If your monitors aren't changed in the current session you're in then none of this safeguarding is relevant.
+ *
+ * Fun stuff really... :facepalm:
+ */
+
+const gdkMonitorIdToHyprlandId = (monitor: number, usedHyprlandMonitors: Set): number => {
+ const gdkMonitors = getGdkMonitors();
+
+ if (Object.keys(gdkMonitors).length === 0) {
+ console.error('No GDK monitors were found.');
+ return monitor;
+ }
+
+ // Get the GDK monitor for the given monitor index
+ const gdkMonitor = gdkMonitors[monitor];
+
+ // First pass: Strict matching including the monitor index (i.e., hypMon.id === monitor + resolution+scale criteria)
+ const directMatch = hyprland.monitors.find((hypMon) => {
+ const hyprlandKey = `${hypMon.model}_${hypMon.width}x${hypMon.height}_${hypMon.scale}`;
+ return gdkMonitor.key.startsWith(hyprlandKey) && !usedHyprlandMonitors.has(hypMon.id) && hypMon.id === monitor;
+ });
+
+ if (directMatch) {
+ usedHyprlandMonitors.add(directMatch.id);
+ return directMatch.id;
+ }
+
+ // Second pass: Relaxed matching without considering the monitor index
+ const hyprlandMonitor = hyprland.monitors.find((hypMon) => {
+ const hyprlandKey = `${hypMon.model}_${hypMon.width}x${hypMon.height}_${hypMon.scale}`;
+ return gdkMonitor.key.startsWith(hyprlandKey) && !usedHyprlandMonitors.has(hypMon.id);
+ });
+
+ if (hyprlandMonitor) {
+ usedHyprlandMonitors.add(hyprlandMonitor.id);
+ return hyprlandMonitor.id;
+ }
+
+ // Fallback: Find the first available monitor ID that hasn't been used
+ const fallbackMonitor = hyprland.monitors.find((hypMon) => !usedHyprlandMonitors.has(hypMon.id));
+
+ if (fallbackMonitor) {
+ usedHyprlandMonitors.add(fallbackMonitor.id);
+ return fallbackMonitor.id;
+ }
+
+ // Ensure we return a valid monitor ID that actually exists
+ for (let i = 0; i < hyprland.monitors.length; i++) {
+ if (!usedHyprlandMonitors.has(i)) {
+ usedHyprlandMonitors.add(i);
+ return i;
+ }
+ }
+
+ // As a last resort, return the original monitor index if no unique monitor can be found
+ console.warn(`Returning original monitor index as a last resort: ${monitor}`);
+ return monitor;
+};
+
+export const Bar = (() => {
+ const usedHyprlandMonitors = new Set();
+
+ return (monitor: number): Window => {
+ const hyprlandMonitor = gdkMonitorIdToHyprlandId(monitor, usedHyprlandMonitors);
+
+ return Widget.Window({
+ name: `bar-${hyprlandMonitor}`,
+ class_name: 'bar',
+ monitor,
+ visible: true,
+ anchor: location.bind('value').as((ln) => [ln, 'left', 'right']),
+ exclusivity: 'exclusive',
+ layer: Utils.merge(
+ [options.theme.bar.layer.bind('value'), options.tear.bind('value')],
+ (barLayer: WindowLayer, tear: boolean) => {
+ if (tear && barLayer === 'overlay') {
+ return 'top';
+ }
+ return barLayer;
+ },
+ ),
+ child: Widget.Box({
+ class_name: 'bar-panel-container',
+ child: Widget.CenterBox({
+ class_name: 'bar-panel',
+ css: 'padding: 1px',
+ startWidget: Widget.Box({
+ class_name: 'box-left',
+ hexpand: true,
+ setup: (self) => {
+ self.hook(layouts, (self) => {
+ const foundLayout = getLayoutForMonitor(hyprlandMonitor, layouts.value as BarLayout);
+ self.children = foundLayout.left
+ .filter((mod) => Object.keys(widget).includes(mod))
+ .map((w) => widget[w](hyprlandMonitor) as Button);
+ });
+ },
+ }),
+ centerWidget: Widget.Box({
+ class_name: 'box-center',
+ hpack: 'center',
+ setup: (self) => {
+ self.hook(layouts, (self) => {
+ const foundLayout = getLayoutForMonitor(hyprlandMonitor, layouts.value as BarLayout);
+ self.children = foundLayout.middle
+ .filter((mod) => Object.keys(widget).includes(mod))
+ .map((w) => widget[w](hyprlandMonitor) as Button);
+ });
+ },
+ }),
+ endWidget: Widget.Box({
+ class_name: 'box-right',
+ hpack: 'end',
+ setup: (self) => {
+ self.hook(layouts, (self) => {
+ const foundLayout = getLayoutForMonitor(hyprlandMonitor, layouts.value as BarLayout);
+ self.children = foundLayout.right
+ .filter((mod) => Object.keys(widget).includes(mod))
+ .map((w) => widget[w](hyprlandMonitor) as Button);
+ });
+ },
+ }),
+ }),
+ }),
+ });
+ };
+})();
diff --git a/ags/HyprPanel/modules/bar/Exports.ts b/ags/HyprPanel/modules/bar/Exports.ts
new file mode 100644
index 0000000..6a17b2f
--- /dev/null
+++ b/ags/HyprPanel/modules/bar/Exports.ts
@@ -0,0 +1,47 @@
+import { Menu } from './menu/index';
+import { Workspaces } from './workspaces/index';
+import { ClientTitle } from './window_title/index';
+import { Media } from './media/index';
+import { Notifications } from './notifications/index';
+import { Volume } from './volume/index';
+import { Network } from './network/index';
+import { Bluetooth } from './bluetooth/index';
+import { BatteryLabel } from './battery/index';
+import { Clock } from './clock/index';
+import { SysTray } from './systray/index';
+
+// Custom Modules
+import { Ram } from '../../customModules/ram/index';
+import { Cpu } from '../../customModules/cpu/index';
+import { Storage } from 'customModules/storage/index';
+import { Netstat } from 'customModules/netstat/index';
+import { KbInput } from 'customModules/kblayout/index';
+import { Updates } from 'customModules/updates/index';
+import { Submap } from 'customModules/submap/index';
+import { Weather } from 'customModules/weather/index';
+import { Power } from 'customModules/power/index';
+
+export {
+ Menu,
+ Workspaces,
+ ClientTitle,
+ Media,
+ Notifications,
+ Volume,
+ Network,
+ Bluetooth,
+ BatteryLabel,
+ Clock,
+ SysTray,
+
+ // Custom Modules
+ Ram,
+ Cpu,
+ Storage,
+ Netstat,
+ KbInput,
+ Updates,
+ Submap,
+ Weather,
+ Power,
+};
diff --git a/ags/HyprPanel/modules/bar/SideEffects.ts b/ags/HyprPanel/modules/bar/SideEffects.ts
new file mode 100644
index 0000000..e4aabec
--- /dev/null
+++ b/ags/HyprPanel/modules/bar/SideEffects.ts
@@ -0,0 +1,29 @@
+import options from 'options';
+
+const { showIcon, showTime } = options.bar.clock;
+
+showIcon.connect('changed', () => {
+ if (!showTime.value && !showIcon.value) {
+ showTime.value = true;
+ }
+});
+
+showTime.connect('changed', () => {
+ if (!showTime.value && !showIcon.value) {
+ showIcon.value = true;
+ }
+});
+
+const { label, icon } = options.bar.windowtitle;
+
+label.connect('changed', () => {
+ if (!label.value && !icon.value) {
+ icon.value = true;
+ }
+});
+
+icon.connect('changed', () => {
+ if (!label.value && !icon.value) {
+ label.value = true;
+ }
+});
diff --git a/ags/HyprPanel/modules/bar/battery/index.ts b/ags/HyprPanel/modules/bar/battery/index.ts
new file mode 100644
index 0000000..8d22ef7
--- /dev/null
+++ b/ags/HyprPanel/modules/bar/battery/index.ts
@@ -0,0 +1,133 @@
+const battery = await Service.import('battery');
+import Gdk from 'gi://Gdk?version=3.0';
+import { openMenu } from '../utils.js';
+import options from 'options';
+import { BarBoxChild } from 'lib/types/bar.js';
+import Button from 'types/widgets/button.js';
+import { Attribute, Child } from 'lib/types/widget.js';
+import { runAsyncCommand, throttledScrollHandler } from 'customModules/utils.js';
+
+const { label: show_label, rightClick, middleClick, scrollUp, scrollDown, hideLabelWhenFull } = options.bar.battery;
+
+const BatteryLabel = (): BarBoxChild => {
+ const isVis = Variable(battery.available);
+
+ const batIcon = Utils.merge(
+ [battery.bind('percent'), battery.bind('charging'), battery.bind('charged')],
+ (batPercent: number, batCharging, batCharged) => {
+ if (batCharged) return `battery-level-100-charged-symbolic`;
+ else return `battery-level-${Math.floor(batPercent / 10) * 10}${batCharging ? '-charging' : ''}-symbolic`;
+ },
+ );
+
+ battery.connect('changed', ({ available }) => {
+ isVis.value = available;
+ });
+
+ const formatTime = (seconds: number): Record => {
+ const hours = Math.floor(seconds / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+ return { hours, minutes };
+ };
+
+ const generateTooltip = (timeSeconds: number, isCharging: boolean, isCharged: boolean): string => {
+ if (isCharged) {
+ return 'Fully Charged!!!';
+ }
+
+ const { hours, minutes } = formatTime(timeSeconds);
+ if (isCharging) {
+ return `${hours} hours ${minutes} minutes until full`;
+ } else {
+ return `${hours} hours ${minutes} minutes left`;
+ }
+ };
+
+ return {
+ component: Widget.Box({
+ className: Utils.merge(
+ [options.theme.bar.buttons.style.bind('value'), show_label.bind('value')],
+ (style, showLabel) => {
+ const styleMap = {
+ default: 'style1',
+ split: 'style2',
+ wave: 'style3',
+ wave2: 'style3',
+ };
+ return `battery-container ${styleMap[style]} ${!showLabel ? 'no-label' : ''}`;
+ },
+ ),
+ visible: battery.bind('available'),
+ tooltip_text: battery.bind('time_remaining').as((t) => t.toString()),
+ children: Utils.merge(
+ [
+ battery.bind('available'),
+ show_label.bind('value'),
+ battery.bind('charged'),
+ hideLabelWhenFull.bind('value'),
+ ],
+ (batAvail, showLabel, isCharged, hideWhenFull) => {
+ if (batAvail && showLabel) {
+ return [
+ Widget.Icon({
+ class_name: 'bar-button-icon battery',
+ icon: batIcon,
+ }),
+ ...(hideWhenFull && isCharged
+ ? []
+ : [
+ Widget.Label({
+ class_name: 'bar-button-label battery',
+ label: battery.bind('percent').as((p) => `${Math.floor(p)}%`),
+ }),
+ ]),
+ ];
+ } else if (batAvail && !showLabel) {
+ return [
+ Widget.Icon({
+ class_name: 'bar-button-icon battery',
+ icon: batIcon,
+ }),
+ ];
+ } else {
+ return [];
+ }
+ },
+ ),
+ setup: (self) => {
+ self.hook(battery, () => {
+ if (battery.available) {
+ self.tooltip_text = generateTooltip(battery.time_remaining, battery.charging, battery.charged);
+ }
+ });
+ },
+ }),
+ isVis,
+ boxClass: 'battery',
+ props: {
+ setup: (self: Button): void => {
+ self.hook(options.bar.scrollSpeed, () => {
+ const throttledHandler = throttledScrollHandler(options.bar.scrollSpeed.value);
+
+ self.on_secondary_click = (clicked: Button, event: Gdk.Event): void => {
+ runAsyncCommand(rightClick.value, { clicked, event });
+ };
+ self.on_middle_click = (clicked: Button, event: Gdk.Event): void => {
+ runAsyncCommand(middleClick.value, { clicked, event });
+ };
+ self.on_scroll_up = (clicked: Button, event: Gdk.Event): void => {
+ throttledHandler(scrollUp.value, { clicked, event });
+ };
+ self.on_scroll_down = (clicked: Button, event: Gdk.Event): void => {
+ throttledHandler(scrollDown.value, { clicked, event });
+ };
+ });
+ },
+ onPrimaryClick: (clicked: Button, event: Gdk.Event): void => {
+ openMenu(clicked, event, 'energymenu');
+ },
+ },
+ };
+};
+
+export { BatteryLabel };
diff --git a/ags/HyprPanel/modules/bar/bluetooth/index.ts b/ags/HyprPanel/modules/bar/bluetooth/index.ts
new file mode 100644
index 0000000..55381a3
--- /dev/null
+++ b/ags/HyprPanel/modules/bar/bluetooth/index.ts
@@ -0,0 +1,74 @@
+const bluetooth = await Service.import('bluetooth');
+import Gdk from 'gi://Gdk?version=3.0';
+import options from 'options';
+import { openMenu } from '../utils.js';
+import { BarBoxChild } from 'lib/types/bar.js';
+import Button from 'types/widgets/button.js';
+import { Attribute, Child } from 'lib/types/widget.js';
+import { runAsyncCommand, throttledScrollHandler } from 'customModules/utils.js';
+
+const { label, rightClick, middleClick, scrollDown, scrollUp } = options.bar.bluetooth;
+
+const Bluetooth = (): BarBoxChild => {
+ const btIcon = Widget.Label({
+ label: bluetooth.bind('enabled').as((v) => (v ? '' : '')),
+ class_name: 'bar-button-icon bluetooth txt-icon bar',
+ });
+
+ const btText = Widget.Label({
+ label: Utils.merge([bluetooth.bind('enabled'), bluetooth.bind('connected_devices')], (btEnabled, btDevices) => {
+ return btEnabled && btDevices.length ? ` Connected (${btDevices.length})` : btEnabled ? 'On' : 'Off';
+ }),
+ class_name: 'bar-button-label bluetooth',
+ });
+
+ return {
+ component: Widget.Box({
+ className: Utils.merge(
+ [options.theme.bar.buttons.style.bind('value'), label.bind('value')],
+ (style, showLabel) => {
+ const styleMap = {
+ default: 'style1',
+ split: 'style2',
+ wave: 'style3',
+ wave2: 'style3',
+ };
+ return `bluetooth-container ${styleMap[style]} ${!showLabel ? 'no-label' : ''}`;
+ },
+ ),
+ children: options.bar.bluetooth.label.bind('value').as((showLabel) => {
+ if (showLabel) {
+ return [btIcon, btText];
+ }
+ return [btIcon];
+ }),
+ }),
+ isVisible: true,
+ boxClass: 'bluetooth',
+ props: {
+ setup: (self: Button): void => {
+ self.hook(options.bar.scrollSpeed, () => {
+ const throttledHandler = throttledScrollHandler(options.bar.scrollSpeed.value);
+
+ self.on_secondary_click = (clicked: Button, event: Gdk.Event): void => {
+ runAsyncCommand(rightClick.value, { clicked, event });
+ };
+ self.on_middle_click = (clicked: Button, event: Gdk.Event): void => {
+ runAsyncCommand(middleClick.value, { clicked, event });
+ };
+ self.on_scroll_up = (clicked: Button, event: Gdk.Event): void => {
+ throttledHandler(scrollUp.value, { clicked, event });
+ };
+ self.on_scroll_down = (clicked: Button, event: Gdk.Event): void => {
+ throttledHandler(scrollDown.value, { clicked, event });
+ };
+ });
+ },
+ on_primary_click: (clicked: Button, event: Gdk.Event): void => {
+ openMenu(clicked, event, 'bluetoothmenu');
+ },
+ },
+ };
+};
+
+export { Bluetooth };
diff --git a/ags/HyprPanel/modules/bar/clock/index.ts b/ags/HyprPanel/modules/bar/clock/index.ts
new file mode 100644
index 0000000..0b2fe21
--- /dev/null
+++ b/ags/HyprPanel/modules/bar/clock/index.ts
@@ -0,0 +1,83 @@
+import Gdk from 'gi://Gdk?version=3.0';
+import GLib from 'gi://GLib';
+import { openMenu } from '../utils.js';
+import options from 'options';
+import { DateTime } from 'types/@girs/glib-2.0/glib-2.0.cjs';
+import { BarBoxChild } from 'lib/types/bar.js';
+import Button from 'types/widgets/button.js';
+import { Attribute, Child } from 'lib/types/widget.js';
+import { runAsyncCommand, throttledScrollHandler } from 'customModules/utils.js';
+
+const { format, icon, showIcon, showTime, rightClick, middleClick, scrollUp, scrollDown } = options.bar.clock;
+const { style } = options.theme.bar.buttons;
+
+const date = Variable(GLib.DateTime.new_now_local(), {
+ poll: [1000, (): DateTime => GLib.DateTime.new_now_local()],
+});
+const time = Utils.derive([date, format], (c, f) => c.format(f) || '');
+
+const Clock = (): BarBoxChild => {
+ const clockTime = Widget.Label({
+ class_name: 'bar-button-label clock bar',
+ label: time.bind(),
+ });
+
+ const clockIcon = Widget.Label({
+ label: icon.bind('value'),
+ class_name: 'bar-button-icon clock txt-icon bar',
+ });
+
+ return {
+ component: Widget.Box({
+ className: Utils.merge(
+ [style.bind('value'), showIcon.bind('value'), showTime.bind('value')],
+ (btnStyle, shwIcn, shwLbl) => {
+ const styleMap = {
+ default: 'style1',
+ split: 'style2',
+ wave: 'style3',
+ wave2: 'style3',
+ };
+
+ return `clock-container ${styleMap[btnStyle]} ${!shwLbl ? 'no-label' : ''} ${!shwIcn ? 'no-icon' : ''}`;
+ },
+ ),
+ children: Utils.merge([showIcon.bind('value'), showTime.bind('value')], (shIcn, shTm) => {
+ if (shIcn && !shTm) {
+ return [clockIcon];
+ } else if (shTm && !shIcn) {
+ return [clockTime];
+ }
+
+ return [clockIcon, clockTime];
+ }),
+ }),
+ isVisible: true,
+ boxClass: 'clock',
+ props: {
+ setup: (self: Button): void => {
+ self.hook(options.bar.scrollSpeed, () => {
+ const throttledHandler = throttledScrollHandler(options.bar.scrollSpeed.value);
+
+ self.on_secondary_click = (clicked: Button, event: Gdk.Event): void => {
+ runAsyncCommand(rightClick.value, { clicked, event });
+ };
+ self.on_middle_click = (clicked: Button, event: Gdk.Event): void => {
+ runAsyncCommand(middleClick.value, { clicked, event });
+ };
+ self.on_scroll_up = (clicked: Button, event: Gdk.Event): void => {
+ throttledHandler(scrollUp.value, { clicked, event });
+ };
+ self.on_scroll_down = (clicked: Button, event: Gdk.Event): void => {
+ throttledHandler(scrollDown.value, { clicked, event });
+ };
+ });
+ },
+ on_primary_click: (clicked: Button, event: Gdk.Event): void => {
+ openMenu(clicked, event, 'calendarmenu');
+ },
+ },
+ };
+};
+
+export { Clock };
diff --git a/ags/HyprPanel/modules/bar/media/index.ts b/ags/HyprPanel/modules/bar/media/index.ts
new file mode 100644
index 0000000..5892d7b
--- /dev/null
+++ b/ags/HyprPanel/modules/bar/media/index.ts
@@ -0,0 +1,113 @@
+import Gdk from 'gi://Gdk?version=3.0';
+const mpris = await Service.import('mpris');
+import { openMenu } from '../utils.js';
+import options from 'options';
+import { getCurrentPlayer } from 'lib/shared/media.js';
+import { BarBoxChild } from 'lib/types/bar.js';
+import Button from 'types/widgets/button.js';
+import { Attribute, Child } from 'lib/types/widget.js';
+import { runAsyncCommand } from 'customModules/utils.js';
+
+const { show_artist, truncation, truncation_size, show_label, show_active_only, rightClick, middleClick } =
+ options.bar.media;
+
+const Media = (): BarBoxChild => {
+ const activePlayer = Variable(mpris.players[0]);
+ const isVis = Variable(!show_active_only.value);
+
+ show_active_only.connect('changed', () => {
+ isVis.value = !show_active_only.value || mpris.players.length > 0;
+ });
+
+ mpris.connect('changed', () => {
+ const curPlayer = getCurrentPlayer(activePlayer.value);
+ activePlayer.value = curPlayer;
+ isVis.value = !show_active_only.value || mpris.players.length > 0;
+ });
+
+ const getIconForPlayer = (playerName: string): string => {
+ const windowTitleMap = [
+ ['Firefox', ''],
+ ['Microsoft Edge', ''],
+ ['Discord', ''],
+ ['Plex', ''],
+ ['Spotify', ''],
+ ['(.*)', ''],
+ ];
+
+ const foundMatch = windowTitleMap.find((wt) => RegExp(wt[0], 'i').test(playerName));
+
+ return foundMatch ? foundMatch[1] : '';
+ };
+
+ const songIcon = Variable('');
+
+ const mediaLabel = Utils.watch('Media', [mpris, show_artist, truncation, truncation_size, show_label], () => {
+ if (activePlayer.value && show_label.value) {
+ const { track_title, identity, track_artists } = activePlayer.value;
+ songIcon.value = getIconForPlayer(identity);
+ const trackArtist = show_artist.value ? ` - ${track_artists.join(', ')}` : ``;
+ const truncatedLabel = truncation.value
+ ? `${track_title + trackArtist}`.substring(0, truncation_size.value)
+ : `${track_title + trackArtist}`;
+
+ return track_title.length === 0
+ ? `No media playing...`
+ : truncatedLabel.length < truncation_size.value || !truncation.value
+ ? `${truncatedLabel}`
+ : `${truncatedLabel.substring(0, truncatedLabel.length - 3)}...`;
+ } else {
+ songIcon.value = getIconForPlayer(activePlayer.value?.identity || '');
+ return `Media`;
+ }
+ });
+
+ return {
+ component: Widget.Box({
+ visible: false,
+ child: Widget.Box({
+ className: Utils.merge(
+ [options.theme.bar.buttons.style.bind('value'), show_label.bind('value')],
+ (style) => {
+ const styleMap = {
+ default: 'style1',
+ split: 'style2',
+ wave: 'style3',
+ wave2: 'style3',
+ };
+ return `media-container ${styleMap[style]}`;
+ },
+ ),
+ child: Widget.Box({
+ children: [
+ Widget.Label({
+ class_name: 'bar-button-icon media txt-icon bar',
+ label: songIcon.bind('value').as((v) => v || ''),
+ }),
+ Widget.Label({
+ class_name: 'bar-button-label media',
+ label: mediaLabel,
+ }),
+ ],
+ }),
+ }),
+ }),
+ isVis,
+ boxClass: 'media',
+ props: {
+ on_scroll_up: () => activePlayer.value?.next(),
+ on_scroll_down: () => activePlayer.value?.previous(),
+ on_primary_click: (clicked: Button, event: Gdk.Event): void => {
+ openMenu(clicked, event, 'mediamenu');
+ },
+ onSecondaryClick: (clicked: Button, event: Gdk.Event): void => {
+ runAsyncCommand(rightClick.value, { clicked, event });
+ },
+ onMiddleClick: (clicked: Button, event: Gdk.Event): void => {
+ runAsyncCommand(middleClick.value, { clicked, event });
+ },
+ },
+ };
+};
+
+export { Media };
diff --git a/ags/HyprPanel/modules/bar/menu/index.ts b/ags/HyprPanel/modules/bar/menu/index.ts
new file mode 100644
index 0000000..5c55bfb
--- /dev/null
+++ b/ags/HyprPanel/modules/bar/menu/index.ts
@@ -0,0 +1,56 @@
+import Gdk from 'gi://Gdk?version=3.0';
+import { openMenu } from '../utils.js';
+import options from 'options';
+import { BarBoxChild } from 'lib/types/bar.js';
+import Button from 'types/widgets/button.js';
+import { Attribute, Child } from 'lib/types/widget.js';
+import { runAsyncCommand, throttledScrollHandler } from 'customModules/utils.js';
+
+const { rightClick, middleClick, scrollUp, scrollDown } = options.bar.launcher;
+
+const Menu = (): BarBoxChild => {
+ return {
+ component: Widget.Box({
+ className: Utils.merge([options.theme.bar.buttons.style.bind('value')], (style) => {
+ const styleMap = {
+ default: 'style1',
+ split: 'style2',
+ wave: 'style3',
+ wave2: 'style3',
+ };
+ return `dashboard ${styleMap[style]}`;
+ }),
+ child: Widget.Label({
+ class_name: 'bar-menu_label bar-button_icon txt-icon bar',
+ label: options.bar.launcher.icon.bind('value'),
+ }),
+ }),
+ isVisible: true,
+ boxClass: 'dashboard',
+ props: {
+ on_primary_click: (clicked: Button, event: Gdk.Event): void => {
+ openMenu(clicked, event, 'dashboardmenu');
+ },
+ setup: (self: Button): void => {
+ self.hook(options.bar.scrollSpeed, () => {
+ const throttledHandler = throttledScrollHandler(options.bar.scrollSpeed.value);
+
+ self.on_secondary_click = (clicked: Button, event: Gdk.Event): void => {
+ runAsyncCommand(rightClick.value, { clicked, event });
+ };
+ self.on_middle_click = (clicked: Button, event: Gdk.Event): void => {
+ runAsyncCommand(middleClick.value, { clicked, event });
+ };
+ self.on_scroll_up = (clicked: Button, event: Gdk.Event): void => {
+ throttledHandler(scrollUp.value, { clicked, event });
+ };
+ self.on_scroll_down = (clicked: Button, event: Gdk.Event): void => {
+ throttledHandler(scrollDown.value, { clicked, event });
+ };
+ });
+ },
+ },
+ };
+};
+
+export { Menu };
diff --git a/ags/HyprPanel/modules/bar/network/index.ts b/ags/HyprPanel/modules/bar/network/index.ts
new file mode 100644
index 0000000..056f455
--- /dev/null
+++ b/ags/HyprPanel/modules/bar/network/index.ts
@@ -0,0 +1,106 @@
+import Gdk from 'gi://Gdk?version=3.0';
+const network = await Service.import('network');
+import options from 'options';
+import { openMenu } from '../utils.js';
+import { BarBoxChild } from 'lib/types/bar.js';
+import Button from 'types/widgets/button.js';
+import { Attribute, Child } from 'lib/types/widget.js';
+import { runAsyncCommand, throttledScrollHandler } from 'customModules/utils.js';
+
+const {
+ label: networkLabel,
+ truncation,
+ truncation_size,
+ rightClick,
+ middleClick,
+ scrollDown,
+ scrollUp,
+} = options.bar.network;
+
+const Network = (): BarBoxChild => {
+ return {
+ component: Widget.Box({
+ vpack: 'fill',
+ vexpand: true,
+ className: Utils.merge(
+ [options.theme.bar.buttons.style.bind('value'), networkLabel.bind('value')],
+ (style, showLabel) => {
+ const styleMap = {
+ default: 'style1',
+ split: 'style2',
+ wave: 'style3',
+ wave2: 'style3',
+ };
+ return `network-container ${styleMap[style]}${!showLabel ? ' no-label' : ''}`;
+ },
+ ),
+ children: [
+ Widget.Icon({
+ class_name: 'bar-button-icon network-icon',
+ icon: Utils.merge(
+ [network.bind('primary'), network.bind('wifi'), network.bind('wired')],
+ (pmry, wfi, wrd) => {
+ if (pmry === 'wired') {
+ return wrd.icon_name;
+ }
+ return wfi.icon_name;
+ },
+ ),
+ }),
+ Widget.Box({
+ child: Utils.merge(
+ [
+ network.bind('primary'),
+ network.bind('wifi'),
+ networkLabel.bind('value'),
+ truncation.bind('value'),
+ truncation_size.bind('value'),
+ ],
+ (pmry, wfi, showLbl, trunc, tSize) => {
+ if (!showLbl) {
+ return Widget.Box();
+ }
+ if (pmry === 'wired') {
+ return Widget.Label({
+ class_name: 'bar-button-label network-label',
+ label: 'Wired'.substring(0, tSize),
+ });
+ }
+ return Widget.Label({
+ class_name: 'bar-button-label network-label',
+ label: wfi.ssid ? `${trunc ? wfi.ssid.substring(0, tSize) : wfi.ssid}` : '--',
+ });
+ },
+ ),
+ }),
+ ],
+ }),
+ isVisible: true,
+ boxClass: 'network',
+ props: {
+ on_primary_click: (clicked: Button, event: Gdk.Event): void => {
+ openMenu(clicked, event, 'networkmenu');
+ },
+ setup: (self: Button): void => {
+ self.hook(options.bar.scrollSpeed, () => {
+ const throttledHandler = throttledScrollHandler(options.bar.scrollSpeed.value);
+
+ self.on_secondary_click = (clicked: Button, event: Gdk.Event): void => {
+ runAsyncCommand(rightClick.value, { clicked, event });
+ };
+ self.on_middle_click = (clicked: Button, event: Gdk.Event): void => {
+ runAsyncCommand(middleClick.value, { clicked, event });
+ };
+ self.on_scroll_up = (clicked: Button, event: Gdk.Event): void => {
+ throttledHandler(scrollUp.value, { clicked, event });
+ };
+ self.on_scroll_down = (clicked: Button, event: Gdk.Event): void => {
+ throttledHandler(scrollDown.value, { clicked, event });
+ };
+ });
+ },
+ },
+ };
+};
+
+export { Network };
diff --git a/ags/HyprPanel/modules/bar/notifications/index.ts b/ags/HyprPanel/modules/bar/notifications/index.ts
new file mode 100644
index 0000000..f0e721d
--- /dev/null
+++ b/ags/HyprPanel/modules/bar/notifications/index.ts
@@ -0,0 +1,94 @@
+import Gdk from 'gi://Gdk?version=3.0';
+import { openMenu } from '../utils.js';
+import options from 'options';
+import { filterNotifications } from 'lib/shared/notifications.js';
+import { BarBoxChild } from 'lib/types/bar.js';
+import Button from 'types/widgets/button.js';
+import { Attribute, Child } from 'lib/types/widget.js';
+import { runAsyncCommand, throttledScrollHandler } from 'customModules/utils.js';
+
+const { show_total, rightClick, middleClick, scrollUp, scrollDown, hideCountWhenZero } = options.bar.notifications;
+const { ignore } = options.notifications;
+
+const notifs = await Service.import('notifications');
+
+export const Notifications = (): BarBoxChild => {
+ return {
+ component: Widget.Box({
+ hpack: 'start',
+ className: Utils.merge(
+ [options.theme.bar.buttons.style.bind('value'), show_total.bind('value')],
+ (style, showTotal) => {
+ const styleMap = {
+ default: 'style1',
+ split: 'style2',
+ wave: 'style3',
+ wave2: 'style3',
+ };
+ return `notifications-container ${styleMap[style]} ${!showTotal ? 'no-label' : ''}`;
+ },
+ ),
+ child: Widget.Box({
+ hpack: 'start',
+ class_name: 'bar-notifications',
+ children: Utils.merge(
+ [
+ notifs.bind('notifications'),
+ notifs.bind('dnd'),
+ show_total.bind('value'),
+ ignore.bind('value'),
+ hideCountWhenZero.bind('value'),
+ ],
+ (notif, dnd, showTotal, ignoredNotifs, hideCountForZero) => {
+ const filteredNotifications = filterNotifications(notif, ignoredNotifs);
+
+ const notifIcon = Widget.Label({
+ hpack: 'center',
+ class_name: 'bar-button-icon notifications txt-icon bar',
+ label: dnd ? '' : filteredNotifications.length > 0 ? '' : '',
+ });
+
+ const notifLabel = Widget.Label({
+ hpack: 'center',
+ class_name: 'bar-button-label notifications',
+ label: filteredNotifications.length.toString(),
+ });
+
+ if (showTotal) {
+ if (hideCountForZero && filteredNotifications.length === 0) {
+ return [notifIcon];
+ }
+ return [notifIcon, notifLabel];
+ }
+ return [notifIcon];
+ },
+ ),
+ }),
+ }),
+ isVisible: true,
+ boxClass: 'notifications',
+ props: {
+ on_primary_click: (clicked: Button, event: Gdk.Event): void => {
+ openMenu(clicked, event, 'notificationsmenu');
+ },
+ setup: (self: Button): void => {
+ self.hook(options.bar.scrollSpeed, () => {
+ const throttledHandler = throttledScrollHandler(options.bar.scrollSpeed.value);
+
+ self.on_secondary_click = (clicked: Button, event: Gdk.Event): void => {
+ runAsyncCommand(rightClick.value, { clicked, event });
+ };
+ self.on_middle_click = (clicked: Button, event: Gdk.Event): void => {
+ runAsyncCommand(middleClick.value, { clicked, event });
+ };
+ self.on_scroll_up = (clicked: Button, event: Gdk.Event): void => {
+ throttledHandler(scrollUp.value, { clicked, event });
+ };
+ self.on_scroll_down = (clicked: Button, event: Gdk.Event): void => {
+ throttledHandler(scrollDown.value, { clicked, event });
+ };
+ });
+ },
+ },
+ };
+};
diff --git a/ags/HyprPanel/modules/bar/systray/index.ts b/ags/HyprPanel/modules/bar/systray/index.ts
new file mode 100644
index 0000000..b6fece3
--- /dev/null
+++ b/ags/HyprPanel/modules/bar/systray/index.ts
@@ -0,0 +1,44 @@
+import Gdk from 'gi://Gdk?version=3.0';
+import { BarBoxChild, SelfButton } from 'lib/types/bar';
+import { Notify } from 'lib/utils';
+const systemtray = await Service.import('systemtray');
+import options from 'options';
+
+const { ignore } = options.bar.systray;
+
+const SysTray = (): BarBoxChild => {
+ const isVis = Variable(false);
+
+ const items = Utils.merge([systemtray.bind('items'), ignore.bind('value')], (items, ignored) => {
+ const filteredTray = items.filter(({ id }) => !ignored.includes(id));
+
+ isVis.value = filteredTray.length > 0;
+
+ return filteredTray.map((item) => {
+ return Widget.Button({
+ cursor: 'pointer',
+ child: Widget.Icon({
+ class_name: 'systray-icon',
+ icon: item.bind('icon'),
+ }),
+ on_primary_click: (_: SelfButton, event: Gdk.Event) => item.activate(event),
+ on_secondary_click: (_, event) => item.openMenu(event),
+ onMiddleClick: () => Notify({ summary: 'App Name', body: item.id }),
+ tooltip_markup: item.bind('tooltip_markup'),
+ });
+ });
+ });
+
+ return {
+ component: Widget.Box({
+ class_name: 'systray-container',
+ children: items,
+ }),
+ isVisible: true,
+ boxClass: 'systray',
+ isVis,
+ props: {},
+ };
+};
+
+export { SysTray };
diff --git a/ags/HyprPanel/modules/bar/utils.ts b/ags/HyprPanel/modules/bar/utils.ts
new file mode 100644
index 0000000..f2f545a
--- /dev/null
+++ b/ags/HyprPanel/modules/bar/utils.ts
@@ -0,0 +1,56 @@
+import Gdk from 'gi://Gdk?version=3.0';
+import { Attribute, Child } from 'lib/types/widget';
+import { calculateMenuPosition } from 'modules/menus/shared/dropdown/locationHandler/index';
+import Button from 'types/widgets/button';
+
+export const closeAllMenus = (): void => {
+ const menuWindows = App.windows
+ .filter((w) => {
+ if (w.name) {
+ return /.*menu/.test(w.name);
+ }
+
+ return false;
+ })
+ .map((w) => w.name);
+
+ menuWindows.forEach((w) => {
+ if (w) {
+ App.closeWindow(w);
+ }
+ });
+};
+
+export const openMenu = async (clicked: Button, event: Gdk.Event, window: string): Promise => {
+ /*
+ * 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 dependending 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.
+ */
+
+ const middleOfButton = Math.floor(clicked.get_allocated_width() / 2);
+ const xAxisOfButtonClick = clicked.get_pointer()[0];
+ const middleOffset = middleOfButton - xAxisOfButtonClick;
+
+ const clickPos = event.get_root_coords();
+ const adjustedXCoord = clickPos[1] + middleOffset;
+ const coords = [adjustedXCoord, clickPos[2]];
+
+ try {
+ await calculateMenuPosition(coords, window);
+ } catch (error) {
+ console.error(`Error calculating menu position: ${error}`);
+ }
+
+ closeAllMenus();
+ App.toggleWindow(window);
+};
diff --git a/ags/HyprPanel/modules/bar/volume/index.ts b/ags/HyprPanel/modules/bar/volume/index.ts
new file mode 100644
index 0000000..e85dc52
--- /dev/null
+++ b/ags/HyprPanel/modules/bar/volume/index.ts
@@ -0,0 +1,106 @@
+import Gdk from 'gi://Gdk?version=3.0';
+const audio = await Service.import('audio');
+import { openMenu } from '../utils.js';
+import options from 'options';
+import { Binding } from 'lib/utils.js';
+import { VolumeIcons } from 'lib/types/volume.js';
+import { BarBoxChild } from 'lib/types/bar.js';
+import { Bind } from 'lib/types/variable.js';
+import Button from 'types/widgets/button.js';
+import { Attribute, Child } from 'lib/types/widget.js';
+import { runAsyncCommand, throttledScrollHandler } from 'customModules/utils.js';
+
+const { rightClick, middleClick, scrollUp, scrollDown } = options.bar.volume;
+
+const Volume = (): BarBoxChild => {
+ const icons: VolumeIcons = {
+ 101: '',
+ 66: '',
+ 34: '',
+ 1: '',
+ 0: '',
+ };
+
+ const getIcon = (): Bind => {
+ const icon: Binding = Utils.merge(
+ [audio.speaker.bind('is_muted'), audio.speaker.bind('volume')],
+ (isMuted, vol) => {
+ if (isMuted) return 0;
+
+ const foundVol = [101, 66, 34, 1, 0].find((threshold) => threshold <= vol * 100);
+
+ if (foundVol !== undefined) {
+ return foundVol;
+ }
+
+ return 101;
+ },
+ );
+
+ return icon.as((i: number) => (i !== undefined ? icons[i] : icons[101]));
+ };
+
+ const volIcn = Widget.Label({
+ hexpand: true,
+ label: getIcon(),
+ class_name: 'bar-button-icon volume txt-icon bar',
+ });
+
+ const volPct = Widget.Label({
+ hexpand: true,
+ label: audio.speaker.bind('volume').as((v) => `${Math.round(v * 100)}%`),
+ class_name: 'bar-button-label volume',
+ });
+
+ return {
+ component: Widget.Box({
+ hexpand: true,
+ vexpand: true,
+ className: Utils.merge(
+ [options.theme.bar.buttons.style.bind('value'), options.bar.volume.label.bind('value')],
+ (style, showLabel) => {
+ const styleMap = {
+ default: 'style1',
+ split: 'style2',
+ wave: 'style3',
+ wave2: 'style3',
+ };
+ return `volume-container ${styleMap[style]} ${!showLabel ? 'no-label' : ''}`;
+ },
+ ),
+ children: options.bar.volume.label.bind('value').as((showLabel) => {
+ if (showLabel) {
+ return [volIcn, volPct];
+ }
+ return [volIcn];
+ }),
+ }),
+ isVisible: true,
+ boxClass: 'volume',
+ props: {
+ onPrimaryClick: (clicked: Button, event: Gdk.Event): void => {
+ openMenu(clicked, event, 'audiomenu');
+ },
+ setup: (self: Button): void => {
+ self.hook(options.bar.scrollSpeed, () => {
+ const throttledHandler = throttledScrollHandler(options.bar.scrollSpeed.value);
+
+ self.on_secondary_click = (clicked: Button, event: Gdk.Event): void => {
+ runAsyncCommand(rightClick.value, { clicked, event });
+ };
+ self.on_middle_click = (clicked: Button, event: Gdk.Event): void => {
+ runAsyncCommand(middleClick.value, { clicked, event });
+ };
+ self.on_scroll_up = (clicked: Button, event: Gdk.Event): void => {
+ throttledHandler(scrollUp.value, { clicked, event });
+ };
+ self.on_scroll_down = (clicked: Button, event: Gdk.Event): void => {
+ throttledHandler(scrollDown.value, { clicked, event });
+ };
+ });
+ },
+ },
+ };
+};
+
+export { Volume };
diff --git a/ags/HyprPanel/modules/bar/window_title/index.ts b/ags/HyprPanel/modules/bar/window_title/index.ts
new file mode 100644
index 0000000..ddfd44b
--- /dev/null
+++ b/ags/HyprPanel/modules/bar/window_title/index.ts
@@ -0,0 +1,241 @@
+const hyprland = await Service.import('hyprland');
+import { BarBoxChild } from 'lib/types/bar';
+import options from 'options';
+import { Attribute, Child } from 'lib/types/widget';
+import { ActiveClient } from 'types/service/hyprland';
+import Label from 'types/widgets/label';
+import { runAsyncCommand, throttledScrollHandler } from 'customModules/utils';
+import Button from 'types/widgets/button';
+import Gdk from 'types/@girs/gdk-3.0/gdk-3.0';
+
+const { leftClick, rightClick, middleClick, scrollDown, scrollUp } = options.bar.windowtitle;
+
+const filterTitle = (windowtitle: ActiveClient): Record => {
+ const windowTitleMap = [
+ // user provided values
+ ...options.bar.windowtitle.title_map.value,
+ // Original Entries
+ ['kitty', '', 'Kitty Terminal'],
+ ['firefox', '', 'Firefox'],
+ ['microsoft-edge', '', 'Edge'],
+ ['discord', '', 'Discord'],
+ ['vesktop', '', 'Vesktop'],
+ ['org.kde.dolphin', '', 'Dolphin'],
+ ['plex', '', 'Plex'],
+ ['steam', '', 'Steam'],
+ ['spotify', '', 'Spotify'],
+ ['ristretto', '', 'Ristretto'],
+ ['obsidian', '', 'Obsidian'],
+
+ // Browsers
+ ['google-chrome', '', 'Google Chrome'],
+ ['brave-browser', '', 'Brave Browser'],
+ ['chromium', '', 'Chromium'],
+ ['opera', '', 'Opera'],
+ ['vivaldi', '', 'Vivaldi'],
+ ['waterfox', '', 'Waterfox'],
+ ['thorium', '', 'Waterfox'],
+ ['tor-browser', '', 'Tor Browser'],
+ ['floorp', '', 'Floorp'],
+
+ // Terminals
+ ['gnome-terminal', '', 'GNOME Terminal'],
+ ['konsole', '', 'Konsole'],
+ ['alacritty', '', 'Alacritty'],
+ ['wezterm', '', 'Wezterm'],
+ ['foot', '', 'Foot Terminal'],
+ ['tilix', '', 'Tilix'],
+ ['xterm', '', 'XTerm'],
+ ['urxvt', '', 'URxvt'],
+ ['st', '', 'st Terminal'],
+
+ // Development Tools
+ ['code', '', 'Visual Studio Code'],
+ ['vscode', '', 'VS Code'],
+ ['sublime-text', '', 'Sublime Text'],
+ ['atom', '', 'Atom'],
+ ['android-studio', '', 'Android Studio'],
+ ['intellij-idea', '', 'IntelliJ IDEA'],
+ ['pycharm', '', 'PyCharm'],
+ ['webstorm', '', 'WebStorm'],
+ ['phpstorm', '', 'PhpStorm'],
+ ['eclipse', '', 'Eclipse'],
+ ['netbeans', '', 'NetBeans'],
+ ['docker', '', 'Docker'],
+ ['vim', '', 'Vim'],
+ ['neovim', '', 'Neovim'],
+ ['neovide', '', 'Neovide'],
+ ['emacs', '', 'Emacs'],
+
+ // Communication Tools
+ ['slack', '', 'Slack'],
+ ['telegram-desktop', '', 'Telegram'],
+ ['org.telegram.desktop', '', 'Telegram'],
+ ['whatsapp', '', 'WhatsApp'],
+ ['teams', '', 'Microsoft Teams'],
+ ['skype', '', 'Skype'],
+ ['thunderbird', '', 'Thunderbird'],
+
+ // File Managers
+ ['nautilus', '', 'Files (Nautilus)'],
+ ['thunar', '', 'Thunar'],
+ ['pcmanfm', '', 'PCManFM'],
+ ['nemo', '', 'Nemo'],
+ ['ranger', '', 'Ranger'],
+ ['doublecmd', '', 'Double Commander'],
+ ['krusader', '', 'Krusader'],
+
+ // Media Players
+ ['vlc', '', 'VLC Media Player'],
+ ['mpv', '', 'MPV'],
+ ['rhythmbox', '', 'Rhythmbox'],
+
+ // Graphics Tools
+ ['gimp', '', 'GIMP'],
+ ['inkscape', '', 'Inkscape'],
+ ['krita', '', 'Krita'],
+ ['blender', '', 'Blender'],
+
+ // Video Editing
+ ['kdenlive', '', 'Kdenlive'],
+
+ // Games and Gaming Platforms
+ ['lutris', '', 'Lutris'],
+ ['heroic', '', 'Heroic Games Launcher'],
+ ['minecraft', '', 'Minecraft'],
+ ['csgo', '', 'CS:GO'],
+ ['dota2', '', 'Dota 2'],
+
+ // Office and Productivity
+ ['evernote', '', 'Evernote'],
+ ['sioyek', '', 'Sioyek'],
+
+ // Cloud Services and Sync
+ ['dropbox', '', 'Dropbox'],
+
+ // Desktop
+ ['^$', '', 'Desktop'],
+
+ // Fallback icon
+ ['(.+)', '', `${windowtitle.class.charAt(0).toUpperCase() + windowtitle.class.slice(1)}`],
+ ];
+
+ const foundMatch = windowTitleMap.find((wt) => RegExp(wt[0]).test(windowtitle.class.toLowerCase()));
+
+ // return the default icon if no match is found or
+ // if the array element matched is not of size 3
+ if (!foundMatch || foundMatch.length !== 3) {
+ return {
+ icon: windowTitleMap[windowTitleMap.length - 1][1],
+ label: windowTitleMap[windowTitleMap.length - 1][2],
+ };
+ }
+
+ return {
+ icon: foundMatch[1],
+ label: foundMatch[2],
+ };
+};
+
+const getTitle = (client: ActiveClient, useCustomTitle: boolean, useClassName: boolean): string => {
+ if (useCustomTitle) return filterTitle(client).label;
+ if (useClassName) return client.class;
+
+ const title = client.title;
+ // If the title is empty or only filled with spaces, fallback to the class name
+ if (title.length === 0 || title.match(/^ *$/)) {
+ return client.class;
+ }
+ return title;
+};
+
+const truncateTitle = (title: string, max_size: number): string => {
+ if (max_size > 0 && title.length > max_size) {
+ return title.substring(0, max_size).trim() + '...';
+ }
+ return title;
+};
+
+const ClientTitle = (): BarBoxChild => {
+ const { custom_title, class_name, label, icon, truncation, truncation_size } = options.bar.windowtitle;
+
+ return {
+ component: Widget.Box({
+ className: Utils.merge(
+ [options.theme.bar.buttons.style.bind('value'), label.bind('value')],
+ (style, showLabel) => {
+ const styleMap = {
+ default: 'style1',
+ split: 'style2',
+ wave: 'style3',
+ wave2: 'style3',
+ };
+ return `windowtitle-container ${styleMap[style]} ${!showLabel ? 'no-label' : ''}`;
+ },
+ ),
+ children: Utils.merge(
+ [
+ hyprland.active.bind('client'),
+ custom_title.bind('value'),
+ class_name.bind('value'),
+ label.bind('value'),
+ icon.bind('value'),
+ truncation.bind('value'),
+ truncation_size.bind('value'),
+ ],
+ (client, useCustomTitle, useClassName, showLabel, showIcon, truncate, truncationSize) => {
+ const children: Label[] = [];
+ if (showIcon) {
+ children.push(
+ Widget.Label({
+ class_name: 'bar-button-icon windowtitle txt-icon bar',
+ label: filterTitle(client).icon,
+ }),
+ );
+ }
+
+ if (showLabel) {
+ children.push(
+ Widget.Label({
+ class_name: `bar-button-label windowtitle ${showIcon ? '' : 'no-icon'}`,
+ label: truncateTitle(
+ getTitle(client, useCustomTitle, useClassName),
+ truncate ? truncationSize : -1,
+ ),
+ }),
+ );
+ }
+
+ return children;
+ },
+ ),
+ }),
+ isVisible: true,
+ boxClass: 'windowtitle',
+ props: {
+ setup: (self: Button): void => {
+ self.hook(options.bar.scrollSpeed, () => {
+ const throttledHandler = throttledScrollHandler(options.bar.scrollSpeed.value);
+
+ self.on_primary_click = (clicked: Button, event: Gdk.Event): void => {
+ runAsyncCommand(leftClick.value, { clicked, event });
+ };
+ self.on_secondary_click = (clicked: Button, event: Gdk.Event): void => {
+ runAsyncCommand(rightClick.value, { clicked, event });
+ };
+ self.on_middle_click = (clicked: Button, event: Gdk.Event): void => {
+ runAsyncCommand(middleClick.value, { clicked, event });
+ };
+ self.on_scroll_up = (clicked: Button, event: Gdk.Event): void => {
+ throttledHandler(scrollUp.value, { clicked, event });
+ };
+ self.on_scroll_down = (clicked: Button, event: Gdk.Event): void => {
+ throttledHandler(scrollDown.value, { clicked, event });
+ };
+ });
+ },
+ },
+ };
+};
+
+export { ClientTitle };
diff --git a/ags/HyprPanel/modules/bar/workspaces/helpers.ts b/ags/HyprPanel/modules/bar/workspaces/helpers.ts
new file mode 100644
index 0000000..daf3cb3
--- /dev/null
+++ b/ags/HyprPanel/modules/bar/workspaces/helpers.ts
@@ -0,0 +1,162 @@
+const hyprland = await Service.import('hyprland');
+
+import { MonitorMap, WorkspaceMap, WorkspaceRule } from 'lib/types/workspace';
+import options from 'options';
+import { Variable } from 'types/variable';
+
+const { workspaces, reverse_scroll, ignored } = options.bar.workspaces;
+
+export const getWorkspacesForMonitor = (curWs: number, wsRules: WorkspaceMap, monitor: number): boolean => {
+ if (!wsRules || !Object.keys(wsRules).length) {
+ return true;
+ }
+
+ const monitorMap: MonitorMap = {};
+ const workspaceMonitorList = hyprland?.workspaces?.map((m) => ({ id: m.monitorID, name: m.monitor }));
+ const monitors = [
+ ...new Map([...workspaceMonitorList, ...hyprland.monitors].map((item) => [item.id, item])).values(),
+ ];
+
+ monitors.forEach((m) => (monitorMap[m.id] = m.name));
+
+ const currentMonitorName = monitorMap[monitor];
+ const monitorWSRules = wsRules[currentMonitorName];
+
+ if (monitorWSRules === undefined) {
+ return true;
+ }
+ return monitorWSRules.includes(curWs);
+};
+
+export const getWorkspaceRules = (): WorkspaceMap => {
+ try {
+ const rules = Utils.exec('hyprctl workspacerules -j');
+
+ const workspaceRules: WorkspaceMap = {};
+
+ JSON.parse(rules).forEach((rule: WorkspaceRule) => {
+ const workspaceNum = parseInt(rule.workspaceString, 10);
+ if (isNaN(workspaceNum)) {
+ return;
+ }
+ if (Object.hasOwnProperty.call(workspaceRules, rule.monitor)) {
+ workspaceRules[rule.monitor].push(workspaceNum);
+ } else {
+ workspaceRules[rule.monitor] = [workspaceNum];
+ }
+ });
+
+ return workspaceRules;
+ } catch (err) {
+ console.error(err);
+ return {};
+ }
+};
+
+export const getCurrentMonitorWorkspaces = (monitor: number): number[] => {
+ if (hyprland.monitors.length === 1) {
+ return Array.from({ length: workspaces.value }, (_, i) => i + 1);
+ }
+
+ const monitorWorkspaces = getWorkspaceRules();
+ const monitorMap: MonitorMap = {};
+ hyprland.monitors.forEach((m) => (monitorMap[m.id] = m.name));
+
+ const currentMonitorName = monitorMap[monitor];
+
+ return monitorWorkspaces[currentMonitorName];
+};
+
+type ThrottledScrollHandlers = {
+ throttledScrollUp: () => void;
+ throttledScrollDown: () => void;
+};
+
+export const isWorkspaceIgnored = (ignoredWorkspaces: Variable, workspaceNumber: number): boolean => {
+ if (ignoredWorkspaces.value === '') return false;
+
+ const ignoredWsRegex = new RegExp(ignoredWorkspaces.value);
+
+ return ignoredWsRegex.test(workspaceNumber.toString());
+};
+
+const navigateWorkspace = (
+ direction: 'next' | 'prev',
+ currentMonitorWorkspaces: Variable,
+ activeWorkspaces: boolean,
+ ignoredWorkspaces: Variable,
+): void => {
+ const workspacesList = activeWorkspaces
+ ? hyprland.workspaces.filter((ws) => hyprland.active.monitor.id === ws.monitorID).map((ws) => ws.id)
+ : currentMonitorWorkspaces.value || Array.from({ length: workspaces.value }, (_, i) => i + 1);
+
+ if (workspacesList.length === 0) return;
+
+ const currentIndex = workspacesList.indexOf(hyprland.active.workspace.id);
+ const step = direction === 'next' ? 1 : -1;
+ let newIndex = (currentIndex + step + workspacesList.length) % workspacesList.length;
+ let attempts = 0;
+
+ while (attempts < workspacesList.length) {
+ const targetWS = workspacesList[newIndex];
+ if (!isWorkspaceIgnored(ignoredWorkspaces, targetWS)) {
+ hyprland.messageAsync(`dispatch workspace ${targetWS}`);
+ return;
+ }
+ newIndex = (newIndex + step + workspacesList.length) % workspacesList.length;
+ attempts++;
+ }
+};
+
+export const goToNextWS = (
+ currentMonitorWorkspaces: Variable,
+ activeWorkspaces: boolean,
+ ignoredWorkspaces: Variable,
+): void => {
+ navigateWorkspace('next', currentMonitorWorkspaces, activeWorkspaces, ignoredWorkspaces);
+};
+
+export const goToPrevWS = (
+ currentMonitorWorkspaces: Variable,
+ activeWorkspaces: boolean,
+ ignoredWorkspaces: Variable,
+): void => {
+ navigateWorkspace('prev', currentMonitorWorkspaces, activeWorkspaces, ignoredWorkspaces);
+};
+
+export function throttle void>(func: T, limit: number): T {
+ let inThrottle: boolean;
+ return function (this: ThisParameterType, ...args: Parameters) {
+ if (!inThrottle) {
+ func.apply(this, args);
+ inThrottle = true;
+ setTimeout(() => {
+ inThrottle = false;
+ }, limit);
+ }
+ } as T;
+}
+
+export const createThrottledScrollHandlers = (
+ scrollSpeed: number,
+ currentMonitorWorkspaces: Variable,
+ activeWorkspaces: boolean = false,
+): ThrottledScrollHandlers => {
+ const throttledScrollUp = throttle(() => {
+ if (reverse_scroll.value) {
+ goToPrevWS(currentMonitorWorkspaces, activeWorkspaces, ignored);
+ } else {
+ goToNextWS(currentMonitorWorkspaces, activeWorkspaces, ignored);
+ }
+ }, 200 / scrollSpeed);
+
+ const throttledScrollDown = throttle(() => {
+ if (reverse_scroll.value) {
+ goToNextWS(currentMonitorWorkspaces, activeWorkspaces, ignored);
+ } else {
+ goToPrevWS(currentMonitorWorkspaces, activeWorkspaces, ignored);
+ }
+ }, 200 / scrollSpeed);
+
+ return { throttledScrollUp, throttledScrollDown };
+};
diff --git a/ags/HyprPanel/modules/bar/workspaces/index.ts b/ags/HyprPanel/modules/bar/workspaces/index.ts
new file mode 100644
index 0000000..c95e212
--- /dev/null
+++ b/ags/HyprPanel/modules/bar/workspaces/index.ts
@@ -0,0 +1,44 @@
+import options from 'options';
+import { createThrottledScrollHandlers, getCurrentMonitorWorkspaces } from './helpers';
+import { BarBoxChild, SelfButton } from 'lib/types/bar';
+import { occupiedWses } from './variants/occupied';
+import { defaultWses } from './variants/default';
+
+const { workspaces, scroll_speed } = options.bar.workspaces;
+
+const Workspaces = (monitor = -1): BarBoxChild => {
+ const currentMonitorWorkspaces = Variable(getCurrentMonitorWorkspaces(monitor));
+
+ workspaces.connect('changed', () => {
+ currentMonitorWorkspaces.value = getCurrentMonitorWorkspaces(monitor);
+ });
+
+ return {
+ component: Widget.Box({
+ class_name: 'workspaces-box-container',
+ child: options.bar.workspaces.hideUnoccupied.bind('value').as((hideUnoccupied) => {
+ return hideUnoccupied ? occupiedWses(monitor) : defaultWses(monitor);
+ }),
+ }),
+ isVisible: true,
+ boxClass: 'workspaces',
+ props: {
+ setup: (self: SelfButton): void => {
+ Utils.merge(
+ [scroll_speed.bind('value'), options.bar.workspaces.hideUnoccupied.bind('value')],
+ (scroll_speed, hideUnoccupied) => {
+ const { throttledScrollUp, throttledScrollDown } = createThrottledScrollHandlers(
+ scroll_speed,
+ currentMonitorWorkspaces,
+ hideUnoccupied,
+ );
+ self.on_scroll_up = throttledScrollUp;
+ self.on_scroll_down = throttledScrollDown;
+ },
+ );
+ },
+ },
+ };
+};
+
+export { Workspaces };
diff --git a/ags/HyprPanel/modules/bar/workspaces/utils.ts b/ags/HyprPanel/modules/bar/workspaces/utils.ts
new file mode 100644
index 0000000..c9f2bc2
--- /dev/null
+++ b/ags/HyprPanel/modules/bar/workspaces/utils.ts
@@ -0,0 +1,130 @@
+import { WorkspaceIconMap } from 'lib/types/workspace';
+import { isValidGjsColor } from 'lib/utils';
+import options from 'options';
+import { Monitor } from 'types/service/hyprland';
+
+const hyprland = await Service.import('hyprland');
+
+const { monochrome, background } = options.theme.bar.buttons;
+const { background: wsBackground, active } = options.theme.bar.buttons.workspaces;
+
+const { showWsIcons, showAllActive, numbered_active_indicator: activeIndicator } = options.bar.workspaces;
+
+const isWorkspaceActiveOnMonitor = (monitor: number, monitors: Monitor[], i: number): boolean => {
+ return showAllActive.value && monitors[monitor]?.activeWorkspace?.id === i;
+};
+
+const getWsIcon = (wsIconMap: WorkspaceIconMap, i: number): string => {
+ const iconEntry = wsIconMap[i];
+
+ if (!iconEntry) {
+ return `${i}`;
+ }
+
+ const hasIcon = typeof iconEntry === 'object' && 'icon' in iconEntry && iconEntry.icon !== '';
+
+ if (typeof iconEntry === 'string' && iconEntry !== '') {
+ return iconEntry;
+ }
+
+ if (hasIcon) {
+ return iconEntry.icon;
+ }
+
+ return `${i}`;
+};
+
+export const getWsColor = (
+ wsIconMap: WorkspaceIconMap,
+ i: number,
+ smartHighlight: boolean,
+ monitor: number,
+ monitors: Monitor[],
+): string => {
+ const iconEntry = wsIconMap[i];
+ const hasColor = typeof iconEntry === 'object' && 'color' in iconEntry && isValidGjsColor(iconEntry.color);
+ if (!iconEntry) {
+ return '';
+ }
+
+ if (
+ showWsIcons.value &&
+ smartHighlight &&
+ activeIndicator.value === 'highlight' &&
+ (hyprland.active.workspace.id === i || isWorkspaceActiveOnMonitor(monitor, monitors, i))
+ ) {
+ const iconColor = monochrome.value ? background : wsBackground;
+ const iconBackground = hasColor && isValidGjsColor(iconEntry.color) ? iconEntry.color : active.value;
+ 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 '';
+};
+
+export const renderClassnames = (
+ showIcons: boolean,
+ showNumbered: boolean,
+ numberedActiveIndicator: string,
+ showWsIcons: boolean,
+ smartHighlight: boolean,
+ monitor: number,
+ monitors: Monitor[],
+ i: number,
+): string => {
+ if (showIcons) {
+ return 'workspace-icon txt-icon bar';
+ }
+
+ if (showNumbered || showWsIcons) {
+ const numActiveInd =
+ hyprland.active.workspace.id === i || isWorkspaceActiveOnMonitor(monitor, monitors, i)
+ ? numberedActiveIndicator
+ : '';
+
+ const wsIconClass = showWsIcons ? 'txt-icon' : '';
+ const smartHighlightClass = smartHighlight ? 'smart-highlight' : '';
+
+ const className = `workspace-number can_${numberedActiveIndicator} ${numActiveInd} ${wsIconClass} ${smartHighlightClass}`;
+
+ return className.trim();
+ }
+
+ return 'default';
+};
+
+export const renderLabel = (
+ showIcons: boolean,
+ available: string,
+ active: string,
+ occupied: string,
+ workspaceMask: boolean,
+ showWsIcons: boolean,
+ wsIconMap: WorkspaceIconMap,
+ i: number,
+ index: number,
+ monitor: number,
+ monitors: Monitor[],
+): string => {
+ if (showIcons) {
+ if (hyprland.active.workspace.id === i || isWorkspaceActiveOnMonitor(monitor, monitors, i)) {
+ return active;
+ }
+ if ((hyprland.getWorkspace(i)?.windows || 0) > 0) {
+ return occupied;
+ }
+ if (monitor !== -1) {
+ return available;
+ }
+ }
+ if (showWsIcons) {
+ return getWsIcon(wsIconMap, i);
+ }
+ return workspaceMask ? `${index + 1}` : `${i}`;
+};
diff --git a/ags/HyprPanel/modules/bar/workspaces/variants/default.ts b/ags/HyprPanel/modules/bar/workspaces/variants/default.ts
new file mode 100644
index 0000000..dddabc9
--- /dev/null
+++ b/ags/HyprPanel/modules/bar/workspaces/variants/default.ts
@@ -0,0 +1,141 @@
+const hyprland = await Service.import('hyprland');
+import options from 'options';
+import { getWorkspaceRules, getWorkspacesForMonitor, isWorkspaceIgnored } from '../helpers';
+import { range } from 'lib/utils';
+import { BoxWidget } from 'lib/types/widget';
+import { getWsColor, renderClassnames, renderLabel } from '../utils';
+import { WorkspaceIconMap } from 'lib/types/workspace';
+import { Monitor } from 'types/service/hyprland';
+
+const { workspaces, monitorSpecific, workspaceMask, spacing, ignored } = options.bar.workspaces;
+export const defaultWses = (monitor: number): BoxWidget => {
+ return Widget.Box({
+ children: Utils.merge(
+ [workspaces.bind('value'), monitorSpecific.bind('value'), ignored.bind('value')],
+ (workspaces: number, monitorSpecific: boolean) => {
+ return range(workspaces || 8)
+ .filter((workspaceNumber) => {
+ if (!monitorSpecific) {
+ return true;
+ }
+ const workspaceRules = getWorkspaceRules();
+ return (
+ getWorkspacesForMonitor(workspaceNumber, workspaceRules, monitor) &&
+ !isWorkspaceIgnored(ignored, workspaceNumber)
+ );
+ })
+ .sort((a, b) => {
+ return a - b;
+ })
+ .map((i, index) => {
+ return Widget.Button({
+ class_name: 'workspace-button',
+ on_primary_click: () => {
+ hyprland.messageAsync(`dispatch workspace ${i}`);
+ },
+ child: Widget.Label({
+ attribute: i,
+ vpack: 'center',
+ css: Utils.merge(
+ [
+ spacing.bind('value'),
+ options.bar.workspaces.showWsIcons.bind('value'),
+ options.bar.workspaces.workspaceIconMap.bind('value'),
+ options.theme.matugen.bind('value'),
+ options.theme.bar.buttons.workspaces.smartHighlight.bind('value'),
+ hyprland.bind('monitors'),
+ ],
+ (
+ sp: number,
+ showWsIcons: boolean,
+ workspaceIconMap: WorkspaceIconMap,
+ matugen: boolean,
+ smartHighlight: boolean,
+ monitors: Monitor[],
+ ) => {
+ return (
+ `margin: 0rem ${0.375 * sp}rem;` +
+ `${showWsIcons && !matugen ? getWsColor(workspaceIconMap, i, smartHighlight, monitor, monitors) : ''}`
+ );
+ },
+ ),
+ class_name: Utils.merge(
+ [
+ options.bar.workspaces.show_icons.bind('value'),
+ options.bar.workspaces.show_numbered.bind('value'),
+ options.bar.workspaces.numbered_active_indicator.bind('value'),
+ options.bar.workspaces.showWsIcons.bind('value'),
+ options.theme.bar.buttons.workspaces.smartHighlight.bind('value'),
+ hyprland.bind('monitors'),
+ options.bar.workspaces.icons.available.bind('value'),
+ options.bar.workspaces.icons.active.bind('value'),
+ ],
+ (
+ showIcons: boolean,
+ showNumbered: boolean,
+ numberedActiveIndicator: string,
+ showWsIcons: boolean,
+ smartHighlight: boolean,
+ monitors: Monitor[],
+ ) => {
+ return renderClassnames(
+ showIcons,
+ showNumbered,
+ numberedActiveIndicator,
+ showWsIcons,
+ smartHighlight,
+ monitor,
+ monitors,
+ i,
+ );
+ },
+ ),
+ label: Utils.merge(
+ [
+ options.bar.workspaces.show_icons.bind('value'),
+ options.bar.workspaces.icons.available.bind('value'),
+ options.bar.workspaces.icons.active.bind('value'),
+ options.bar.workspaces.icons.occupied.bind('value'),
+ options.bar.workspaces.workspaceIconMap.bind('value'),
+ options.bar.workspaces.showWsIcons.bind('value'),
+ workspaceMask.bind('value'),
+ hyprland.bind('monitors'),
+ ],
+ (
+ showIcons: boolean,
+ available: string,
+ active: string,
+ occupied: string,
+ wsIconMap: WorkspaceIconMap,
+ showWsIcons: boolean,
+ workspaceMask: boolean,
+ monitors: Monitor[],
+ ) => {
+ return renderLabel(
+ showIcons,
+ available,
+ active,
+ occupied,
+ workspaceMask,
+ showWsIcons,
+ wsIconMap,
+ i,
+ index,
+ monitor,
+ monitors,
+ );
+ },
+ ),
+ setup: (self) => {
+ self.hook(hyprland, () => {
+ self.toggleClassName('active', hyprland.active.workspace.id === i);
+ self.toggleClassName('occupied', (hyprland.getWorkspace(i)?.windows || 0) > 0);
+ });
+ },
+ }),
+ });
+ });
+ },
+ ),
+ });
+};
diff --git a/ags/HyprPanel/modules/bar/workspaces/variants/occupied.ts b/ags/HyprPanel/modules/bar/workspaces/variants/occupied.ts
new file mode 100644
index 0000000..31f3f14
--- /dev/null
+++ b/ags/HyprPanel/modules/bar/workspaces/variants/occupied.ts
@@ -0,0 +1,145 @@
+const hyprland = await Service.import('hyprland');
+import options from 'options';
+import { getWorkspaceRules, getWorkspacesForMonitor, isWorkspaceIgnored } from '../helpers';
+import { Monitor, Workspace } from 'types/service/hyprland';
+import { getWsColor, renderClassnames, renderLabel } from '../utils';
+import { range } from 'lib/utils';
+import { BoxWidget } from 'lib/types/widget';
+import { WorkspaceIconMap } from 'lib/types/workspace';
+
+const { workspaces, monitorSpecific, workspaceMask, spacing, ignored, showAllActive } = options.bar.workspaces;
+
+export const occupiedWses = (monitor: number): BoxWidget => {
+ return Widget.Box({
+ children: Utils.merge(
+ [
+ monitorSpecific.bind('value'),
+ hyprland.bind('workspaces'),
+ workspaceMask.bind('value'),
+ workspaces.bind('value'),
+ options.bar.workspaces.show_icons.bind('value'),
+ options.bar.workspaces.icons.available.bind('value'),
+ options.bar.workspaces.icons.active.bind('value'),
+ options.bar.workspaces.icons.occupied.bind('value'),
+ options.bar.workspaces.show_numbered.bind('value'),
+ options.bar.workspaces.numbered_active_indicator.bind('value'),
+ spacing.bind('value'),
+ options.bar.workspaces.workspaceIconMap.bind('value'),
+ options.bar.workspaces.showWsIcons.bind('value'),
+ options.theme.matugen.bind('value'),
+ options.theme.bar.buttons.workspaces.smartHighlight.bind('value'),
+ hyprland.bind('monitors'),
+ ignored.bind('value'),
+ showAllActive.bind('value'),
+ ],
+ (
+ monitorSpecific: boolean,
+ wkSpaces: Workspace[],
+ workspaceMask: boolean,
+ totalWkspcs: number,
+ showIcons: boolean,
+ available: string,
+ active: string,
+ occupied: string,
+ showNumbered: boolean,
+ numberedActiveIndicator: string,
+ spacing: number,
+ wsIconMap: WorkspaceIconMap,
+ showWsIcons: boolean,
+ matugen: boolean,
+ smartHighlight: boolean,
+ monitors: Monitor[],
+ ) => {
+ const activeId = hyprland.active.workspace.id;
+ let allWkspcs = range(totalWkspcs || 8);
+
+ const activeWorkspaces = wkSpaces.map((w) => w.id);
+ const workspaceRules = getWorkspaceRules();
+
+ // Sometimes hyprland doesn't have all the monitors in the list
+ // so we complement it with monitors from the workspace list
+ const workspaceMonitorList = hyprland?.workspaces?.map((m) => ({
+ id: m.monitorID,
+ name: m.monitor,
+ }));
+ const curMonitor =
+ hyprland.monitors.find((m) => m.id === monitor) ||
+ workspaceMonitorList.find((m) => m.id === monitor);
+
+ const workspacesWithRules = Object.keys(workspaceRules).reduce((acc: number[], k: string) => {
+ return [...acc, ...workspaceRules[k]];
+ }, [] as number[]);
+
+ const activesForMonitor = activeWorkspaces.filter((w) => {
+ if (
+ curMonitor &&
+ Object.hasOwnProperty.call(workspaceRules, curMonitor.name) &&
+ workspacesWithRules.includes(w)
+ ) {
+ return workspaceRules[curMonitor.name].includes(w);
+ }
+ return true;
+ });
+
+ if (monitorSpecific) {
+ const wrkspcsInRange = range(totalWkspcs).filter((w) => {
+ return getWorkspacesForMonitor(w, workspaceRules, monitor);
+ });
+ allWkspcs = [...new Set([...activesForMonitor, ...wrkspcsInRange])];
+ } else {
+ allWkspcs = [...new Set([...allWkspcs, ...activeWorkspaces])];
+ }
+
+ return allWkspcs
+ .sort((a, b) => {
+ return a - b;
+ })
+ .map((i, index) => {
+ if (isWorkspaceIgnored(ignored, i)) {
+ return Widget.Box();
+ }
+ return Widget.Button({
+ class_name: 'workspace-button',
+ on_primary_click: () => {
+ hyprland.messageAsync(`dispatch workspace ${i}`);
+ },
+ child: Widget.Label({
+ attribute: i,
+ vpack: 'center',
+ css:
+ `margin: 0rem ${0.375 * spacing}rem;` +
+ `${showWsIcons && !matugen ? getWsColor(wsIconMap, i, smartHighlight, monitor, monitors) : ''}`,
+ class_name: renderClassnames(
+ showIcons,
+ showNumbered,
+ numberedActiveIndicator,
+ showWsIcons,
+ smartHighlight,
+ monitor,
+ monitors,
+ i,
+ ),
+ label: renderLabel(
+ showIcons,
+ available,
+ active,
+ occupied,
+ workspaceMask,
+ showWsIcons,
+ wsIconMap,
+ i,
+ index,
+ monitor,
+ monitors,
+ ),
+ setup: (self) => {
+ self.toggleClassName('active', activeId === i);
+ self.toggleClassName('occupied', (hyprland.getWorkspace(i)?.windows || 0) > 0);
+ },
+ }),
+ });
+ });
+ },
+ ),
+ });
+};
diff --git a/ags/HyprPanel/modules/icons/index.ts b/ags/HyprPanel/modules/icons/index.ts
new file mode 100644
index 0000000..14d0a06
--- /dev/null
+++ b/ags/HyprPanel/modules/icons/index.ts
@@ -0,0 +1,199 @@
+export const substitutes = {
+ 'transmission-gtk': 'transmission',
+ 'blueberry.py': 'blueberry',
+ Caprine: 'facebook-messenger',
+ 'com.raggesilver.BlackBox-symbolic': 'terminal-symbolic',
+ 'org.wezfurlong.wezterm-symbolic': 'terminal-symbolic',
+ 'audio-headset-bluetooth': 'audio-headphones-symbolic',
+ 'audio-card-analog-usb': 'audio-speakers-symbolic',
+ 'audio-card-analog-pci': 'audio-card-symbolic',
+ 'preferences-system': 'emblem-system-symbolic',
+ 'com.github.Aylur.ags-symbolic': 'controls-symbolic',
+ 'com.github.Aylur.ags': 'controls-symbolic',
+};
+
+export default {
+ missing: 'image-missing-symbolic',
+ nix: {
+ nix: 'nix-snowflake-symbolic',
+ },
+ app: {
+ terminal: 'terminal-symbolic',
+ },
+ fallback: {
+ executable: 'application-x-executable',
+ notification: 'dialog-information-symbolic',
+ video: 'video-x-generic-symbolic',
+ audio: 'audio-x-generic-symbolic',
+ },
+ ui: {
+ close: 'window-close-symbolic',
+ colorpicker: 'color-select-symbolic',
+ info: 'info-symbolic',
+ link: 'external-link-symbolic',
+ lock: 'system-lock-screen-symbolic',
+ menu: 'open-menu-symbolic',
+ refresh: 'view-refresh-symbolic',
+ search: 'system-search-symbolic',
+ settings: 'emblem-system-symbolic',
+ themes: 'preferences-desktop-theme-symbolic',
+ tick: 'object-select-symbolic',
+ time: 'hourglass-symbolic',
+ toolbars: 'toolbars-symbolic',
+ warning: 'dialog-warning-symbolic',
+ avatar: 'avatar-default-symbolic',
+ arrow: {
+ right: 'pan-end-symbolic',
+ left: 'pan-start-symbolic',
+ down: 'pan-down-symbolic',
+ up: 'pan-up-symbolic',
+ },
+ },
+ audio: {
+ mic: {
+ muted: 'microphone-disabled-symbolic',
+ low: 'microphone-sensitivity-low-symbolic',
+ medium: 'microphone-sensitivity-medium-symbolic',
+ high: 'microphone-sensitivity-high-symbolic',
+ },
+ volume: {
+ muted: 'audio-volume-muted-symbolic',
+ low: 'audio-volume-low-symbolic',
+ medium: 'audio-volume-medium-symbolic',
+ high: 'audio-volume-high-symbolic',
+ overamplified: 'audio-volume-overamplified-symbolic',
+ },
+ type: {
+ headset: 'audio-headphones-symbolic',
+ speaker: 'audio-speakers-symbolic',
+ card: 'audio-card-symbolic',
+ },
+ mixer: 'mixer-symbolic',
+ },
+ powerprofile: {
+ balanced: 'power-profile-balanced-symbolic',
+ 'power-saver': 'power-profile-power-saver-symbolic',
+ performance: 'power-profile-performance-symbolic',
+ },
+ asusctl: {
+ profile: {
+ Balanced: 'power-profile-balanced-symbolic',
+ Quiet: 'power-profile-power-saver-symbolic',
+ Performance: 'power-profile-performance-symbolic',
+ },
+ mode: {
+ Integrated: 'processor-symbolic',
+ Hybrid: 'controller-symbolic',
+ },
+ },
+ battery: {
+ charging: 'battery-flash-symbolic',
+ warning: 'battery-empty-symbolic',
+ },
+ bluetooth: {
+ enabled: 'bluetooth-active-symbolic',
+ disabled: 'bluetooth-disabled-symbolic',
+ },
+ brightness: {
+ indicator: 'display-brightness-symbolic',
+ keyboard: 'keyboard-brightness-symbolic',
+ screen: 'display-brightness-symbolic',
+ },
+ powermenu: {
+ sleep: 'weather-clear-night-symbolic',
+ reboot: 'system-reboot-symbolic',
+ logout: 'system-log-out-symbolic',
+ shutdown: 'system-shutdown-symbolic',
+ },
+ recorder: {
+ recording: 'media-record-symbolic',
+ },
+ notifications: {
+ noisy: 'org.gnome.Settings-notifications-symbolic',
+ silent: 'notifications-disabled-symbolic',
+ message: 'chat-bubbles-symbolic',
+ },
+ trash: {
+ full: 'user-trash-full-symbolic',
+ empty: 'user-trash-symbolic',
+ },
+ mpris: {
+ shuffle: {
+ enabled: 'media-playlist-shuffle-symbolic',
+ disabled: 'media-playlist-consecutive-symbolic',
+ },
+ loop: {
+ none: 'media-playlist-repeat-symbolic',
+ track: 'media-playlist-repeat-song-symbolic',
+ playlist: 'media-playlist-repeat-symbolic',
+ },
+ playing: 'media-playback-pause-symbolic',
+ paused: 'media-playback-start-symbolic',
+ stopped: 'media-playback-start-symbolic',
+ prev: 'media-skip-backward-symbolic',
+ next: 'media-skip-forward-symbolic',
+ },
+ system: {
+ cpu: 'org.gnome.SystemMonitor-symbolic',
+ ram: 'drive-harddisk-solidstate-symbolic',
+ temp: 'temperature-symbolic',
+ },
+ color: {
+ dark: 'dark-mode-symbolic',
+ light: 'light-mode-symbolic',
+ },
+ weather: {
+ warning: 'dialog-warning-symbolic',
+ sunny: 'weather-clear-symbolic',
+ clear: 'weather-clear-night-symbolic',
+ partly_cloudy: 'weather-few-clouds-symbolic',
+ partly_cloudy_night: 'weather-few-clouds-night-symbolic',
+ cloudy: 'weather-overcast-symbolic',
+ overcast: 'weather-overcast-symbolic',
+ mist: 'weather-overcast-symbolic',
+ patchy_rain_nearby: 'weather-showers-scattered-symbolic',
+ patchy_rain_possible: 'weather-showers-scattered-symbolic',
+ patchy_snow_possible: 'weather-snow-symbolic',
+ patchy_sleet_possible: 'weather-snow-symbolic',
+ patchy_freezing_drizzle_possible: 'weather-showers-scattered-symbolic',
+ thundery_outbreaks_possible: 'weather-overcast-symbolic',
+ blowing_snow: 'weather-snow-symbolic',
+ blizzard: 'weather-snow-symbolic',
+ fog: 'weather-fog-symbolic',
+ freezing_fog: 'weather-fog-symbolic',
+ patchy_light_drizzle: 'weather-showers-scattered-symbolic',
+ light_drizzle: 'weather-showers-symbolic',
+ freezing_drizzle: 'weather-showers-symbolic',
+ heavy_freezing_drizzle: 'weather-showers-symbolic',
+ patchy_light_rain: 'weather-showers-scattered-symbolic',
+ light_rain: 'weather-showers-symbolic',
+ moderate_rain_at_times: 'weather-showers-symbolic',
+ moderate_rain: 'weather-showers-symbolic',
+ heavy_rain_at_times: 'weather-showers-symbolic',
+ heavy_rain: 'weather-showers-symbolic',
+ light_freezing_rain: 'weather-showers-symbolic',
+ moderate_or_heavy_freezing_rain: 'weather-showers-symbolic',
+ light_sleet: 'weather-snow-symbolic',
+ moderate_or_heavy_sleet: 'weather-snow-symbolic',
+ patchy_light_snow: 'weather-snow-symbolic',
+ light_snow: 'weather-snow-symbolic',
+ patchy_moderate_snow: 'weather-snow-symbolic',
+ moderate_snow: 'weather-snow-symbolic',
+ patchy_heavy_snow: 'weather-snow-symbolic',
+ heavy_snow: 'weather-snow-symbolic',
+ ice_pellets: 'weather-showers-symbolic',
+ light_rain_shower: 'weather-showers-symbolic',
+ moderate_or_heavy_rain_shower: 'weather-showers-symbolic',
+ torrential_rain_shower: 'weather-showers-symbolic',
+ light_sleet_showers: 'weather-showers-symbolic',
+ moderate_or_heavy_sleet_showers: 'weather-showers-symbolic',
+ light_snow_showers: 'weather-snow-symbolic',
+ moderate_or_heavy_snow_showers: 'weather-snow-symbolic',
+ light_showers_of_ice_pellets: 'weather-showers-symbolic',
+ moderate_or_heavy_showers_of_ice_pellets: 'weather-showers-symbolic',
+ patchy_light_rain_with_thunder: 'weather-showers-scattered-symbolic',
+ moderate_or_heavy_rain_with_thunder: 'weather-showers-symbolic',
+ patchy_light_snow_with_thunder: 'weather-snow-symbolic',
+ moderate_or_heavy_snow_with_thunder: 'weather-snow-symbolic',
+ },
+} as const;
diff --git a/ags/HyprPanel/modules/icons/weather.ts b/ags/HyprPanel/modules/icons/weather.ts
new file mode 100644
index 0000000..90980a0
--- /dev/null
+++ b/ags/HyprPanel/modules/icons/weather.ts
@@ -0,0 +1,54 @@
+export const weatherIcons = {
+ warning: '',
+ sunny: '',
+ clear: '',
+ partly_cloudy: '',
+ partly_cloudy_night: '',
+ cloudy: '',
+ overcast: '',
+ mist: '',
+ patchy_rain_nearby: '',
+ patchy_rain_possible: '',
+ patchy_snow_possible: '',
+ patchy_sleet_possible: '',
+ patchy_freezing_drizzle_possible: '',
+ thundery_outbreaks_possible: '',
+ blowing_snow: '',
+ blizzard: '',
+ fog: '',
+ freezing_fog: '',
+ patchy_light_drizzle: '',
+ light_drizzle: '',
+ freezing_drizzle: '',
+ heavy_freezing_drizzle: '',
+ patchy_light_rain: '',
+ light_rain: '',
+ moderate_rain_at_times: '',
+ moderate_rain: '',
+ heavy_rain_at_times: '',
+ heavy_rain: '',
+ light_freezing_rain: '',
+ moderate_or_heavy_freezing_rain: '',
+ light_sleet: '',
+ moderate_or_heavy_sleet: '',
+ patchy_light_snow: '',
+ light_snow: '',
+ patchy_moderate_snow: '',
+ moderate_snow: '',
+ patchy_heavy_snow: '',
+ heavy_snow: '',
+ ice_pellets: '',
+ light_rain_shower: '',
+ moderate_or_heavy_rain_shower: '',
+ torrential_rain_shower: '',
+ light_sleet_showers: '',
+ moderate_or_heavy_sleet_showers: '',
+ light_snow_showers: '',
+ moderate_or_heavy_snow_showers: '',
+ light_showers_of_ice_pellets: '',
+ moderate_or_heavy_showers_of_ice_pellets: '',
+ patchy_light_rain_with_thunder: '',
+ moderate_or_heavy_rain_with_thunder: '',
+ patchy_light_snow_with_thunder: '',
+ moderate_or_heavy_snow_with_thunder: '',
+} as const;
diff --git a/ags/HyprPanel/modules/menus/audio/active/SelectedInput.ts b/ags/HyprPanel/modules/menus/audio/active/SelectedInput.ts
new file mode 100644
index 0000000..4e3cff3
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/audio/active/SelectedInput.ts
@@ -0,0 +1,75 @@
+const audio = await Service.import('audio');
+import { getIcon } from '../utils.js';
+import Box from 'types/widgets/box.js';
+import { Attribute, Child } from 'lib/types/widget.js';
+
+const renderActiveInput = (): Box[] => {
+ return [
+ Widget.Box({
+ class_name: 'menu-slider-container input',
+ children: [
+ Widget.Button({
+ vexpand: false,
+ vpack: 'end',
+ setup: (self) => {
+ const updateClass = (): void => {
+ const mic = audio.microphone;
+ const className = `menu-active-button input ${mic.is_muted ? 'muted' : ''}`;
+ self.class_name = className;
+ };
+
+ self.hook(audio.microphone, updateClass, 'notify::is-muted');
+ },
+ on_primary_click: () => (audio.microphone.is_muted = !audio.microphone.is_muted),
+ child: Widget.Icon({
+ class_name: 'menu-active-icon input',
+ setup: (self) => {
+ const updateIcon = (): void => {
+ const isMicMuted =
+ audio.microphone.is_muted !== null ? audio.microphone.is_muted : true;
+
+ if (audio.microphone.volume > 0) {
+ self.icon = getIcon(audio.microphone.volume, isMicMuted)['mic'];
+ } else {
+ self.icon = getIcon(100, true)['mic'];
+ }
+ };
+ self.hook(audio.microphone, updateIcon, 'notify::volume');
+ self.hook(audio.microphone, updateIcon, 'notify::is-muted');
+ },
+ }),
+ }),
+ Widget.Box({
+ vertical: true,
+ children: [
+ Widget.Label({
+ class_name: 'menu-active input',
+ hpack: 'start',
+ truncate: 'end',
+ wrap: true,
+ label: audio.bind('microphone').as((v) => {
+ return v.description === null ? 'No input device found...' : v.description;
+ }),
+ }),
+ Widget.Slider({
+ value: audio.microphone.bind('volume').as((v) => v),
+ class_name: 'menu-active-slider menu-slider inputs',
+ draw_value: false,
+ hexpand: true,
+ min: 0,
+ max: 1,
+ onChange: ({ value }) => (audio.microphone.volume = value),
+ }),
+ ],
+ }),
+ Widget.Label({
+ class_name: 'menu-active-percentage input',
+ vpack: 'end',
+ label: audio.microphone.bind('volume').as((v) => `${Math.round(v * 100)}%`),
+ }),
+ ],
+ }),
+ ];
+};
+
+export { renderActiveInput };
diff --git a/ags/HyprPanel/modules/menus/audio/active/SelectedPlayback.ts b/ags/HyprPanel/modules/menus/audio/active/SelectedPlayback.ts
new file mode 100644
index 0000000..29e5afd
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/audio/active/SelectedPlayback.ts
@@ -0,0 +1,68 @@
+const audio = await Service.import('audio');
+import { getIcon } from '../utils.js';
+import Box from 'types/widgets/box.js';
+import { Attribute, Child } from 'lib/types/widget.js';
+
+const renderActivePlayback = (): Box[] => {
+ return [
+ Widget.Box({
+ class_name: 'menu-slider-container playback',
+ children: [
+ Widget.Button({
+ vexpand: false,
+ vpack: 'end',
+ setup: (self) => {
+ const updateClass = (): void => {
+ const spkr = audio.speaker;
+ const className = `menu-active-button playback ${spkr.is_muted ? 'muted' : ''}`;
+ self.class_name = className;
+ };
+
+ self.hook(audio.speaker, updateClass, 'notify::is-muted');
+ },
+ on_primary_click: () => (audio.speaker.is_muted = !audio.speaker.is_muted),
+ child: Widget.Icon({
+ class_name: 'menu-active-icon playback',
+ setup: (self) => {
+ const updateIcon = (): void => {
+ const isSpeakerMuted = audio.speaker.is_muted !== null ? audio.speaker.is_muted : true;
+ self.icon = getIcon(audio.speaker.volume, isSpeakerMuted)['spkr'];
+ };
+ self.hook(audio.speaker, updateIcon, 'notify::volume');
+ self.hook(audio.speaker, updateIcon, 'notify::is-muted');
+ },
+ }),
+ }),
+ Widget.Box({
+ vertical: true,
+ children: [
+ Widget.Label({
+ class_name: 'menu-active playback',
+ hpack: 'start',
+ truncate: 'end',
+ expand: true,
+ wrap: true,
+ label: audio.bind('speaker').as((v) => v.description || ''),
+ }),
+ Widget.Slider({
+ value: audio['speaker'].bind('volume'),
+ class_name: 'menu-active-slider menu-slider playback',
+ draw_value: false,
+ hexpand: true,
+ min: 0,
+ max: 1,
+ onChange: ({ value }) => (audio.speaker.volume = value),
+ }),
+ ],
+ }),
+ Widget.Label({
+ vpack: 'end',
+ class_name: 'menu-active-percentage playback',
+ label: audio.speaker.bind('volume').as((v) => `${Math.round(v * 100)}%`),
+ }),
+ ],
+ }),
+ ];
+};
+
+export { renderActivePlayback };
diff --git a/ags/HyprPanel/modules/menus/audio/active/index.ts b/ags/HyprPanel/modules/menus/audio/active/index.ts
new file mode 100644
index 0000000..9768ddb
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/audio/active/index.ts
@@ -0,0 +1,41 @@
+import { renderActiveInput } from './SelectedInput.js';
+import { renderActivePlayback } from './SelectedPlayback.js';
+import Box from 'types/widgets/box.js';
+import { Attribute, Child } from 'lib/types/widget.js';
+
+const activeDevices = (): Box => {
+ return Widget.Box({
+ class_name: 'menu-section-container volume',
+ vertical: true,
+ children: [
+ Widget.Box({
+ class_name: 'menu-label-container volume selected',
+ hpack: 'fill',
+ child: Widget.Label({
+ class_name: 'menu-label audio volume',
+ hexpand: true,
+ hpack: 'start',
+ label: 'Volume',
+ }),
+ }),
+ Widget.Box({
+ class_name: 'menu-items-section selected',
+ vertical: true,
+ children: [
+ Widget.Box({
+ class_name: 'menu-active-container playback',
+ vertical: true,
+ children: renderActivePlayback(),
+ }),
+ Widget.Box({
+ class_name: 'menu-active-container input',
+ vertical: true,
+ children: renderActiveInput(),
+ }),
+ ],
+ }),
+ ],
+ });
+};
+
+export { activeDevices };
diff --git a/ags/HyprPanel/modules/menus/audio/available/InputDevices.ts b/ags/HyprPanel/modules/menus/audio/available/InputDevices.ts
new file mode 100644
index 0000000..8c75a5a
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/audio/available/InputDevices.ts
@@ -0,0 +1,66 @@
+const audio = await Service.import('audio');
+import { InputDevices } from 'lib/types/audio';
+import { Stream } from 'types/service/audio';
+
+const renderInputDevices = (inputDevices: Stream[]): InputDevices => {
+ if (inputDevices.length === 0) {
+ return [
+ Widget.Button({
+ class_name: `menu-unfound-button input`,
+ child: Widget.Box({
+ children: [
+ Widget.Box({
+ hpack: 'start',
+ children: [
+ Widget.Label({
+ class_name: 'menu-button-name input',
+ label: 'No input devices found...',
+ }),
+ ],
+ }),
+ ],
+ }),
+ }),
+ ];
+ }
+ return inputDevices.map((device) => {
+ return Widget.Button({
+ on_primary_click: () => (audio.microphone = device),
+ class_name: `menu-button audio input ${device}`,
+ child: Widget.Box({
+ children: [
+ Widget.Box({
+ hpack: 'start',
+ children: [
+ Widget.Label({
+ wrap: true,
+ class_name: audio.microphone
+ .bind('description')
+ .as((v) =>
+ device.description === v
+ ? 'menu-button-icon active input txt-icon'
+ : 'menu-button-icon input txt-icon',
+ ),
+ label: '',
+ }),
+ Widget.Label({
+ truncate: 'end',
+ wrap: true,
+ class_name: audio.microphone
+ .bind('description')
+ .as((v) =>
+ device.description === v
+ ? 'menu-button-name active input'
+ : 'menu-button-name input',
+ ),
+ label: device.description,
+ }),
+ ],
+ }),
+ ],
+ }),
+ });
+ });
+};
+
+export { renderInputDevices };
diff --git a/ags/HyprPanel/modules/menus/audio/available/PlaybackDevices.ts b/ags/HyprPanel/modules/menus/audio/available/PlaybackDevices.ts
new file mode 100644
index 0000000..02bc618
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/audio/available/PlaybackDevices.ts
@@ -0,0 +1,60 @@
+const audio = await Service.import('audio');
+import { PlaybackDevices } from 'lib/types/audio';
+import { Stream } from 'types/service/audio';
+
+const renderPlaybacks = (playbackDevices: Stream[]): PlaybackDevices => {
+ return playbackDevices.map((device) => {
+ if (device.description === 'Dummy Output') {
+ return Widget.Box({
+ class_name: 'menu-unfound-button playback',
+ child: Widget.Box({
+ children: [
+ Widget.Label({
+ class_name: 'menu-button-name playback',
+ label: 'No playback devices found...',
+ }),
+ ],
+ }),
+ });
+ }
+ return Widget.Button({
+ class_name: `menu-button audio playback ${device}`,
+ on_primary_click: () => (audio.speaker = device),
+ child: Widget.Box({
+ children: [
+ Widget.Box({
+ hpack: 'start',
+ children: [
+ Widget.Label({
+ truncate: 'end',
+ wrap: true,
+ class_name: audio.speaker
+ .bind('description')
+ .as((v) =>
+ device.description === v
+ ? 'menu-button-icon active playback txt-icon'
+ : 'menu-button-icon playback txt-icon',
+ ),
+ label: '',
+ }),
+ Widget.Label({
+ truncate: 'end',
+ wrap: true,
+ class_name: audio.speaker
+ .bind('description')
+ .as((v) =>
+ device.description === v
+ ? 'menu-button-name active playback'
+ : 'menu-button-name playback',
+ ),
+ label: device.description,
+ }),
+ ],
+ }),
+ ],
+ }),
+ });
+ });
+};
+
+export { renderPlaybacks };
diff --git a/ags/HyprPanel/modules/menus/audio/available/index.ts b/ags/HyprPanel/modules/menus/audio/available/index.ts
new file mode 100644
index 0000000..e9b0d48
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/audio/available/index.ts
@@ -0,0 +1,72 @@
+const audio = await Service.import('audio');
+import { BoxWidget } from 'lib/types/widget.js';
+import { renderInputDevices } from './InputDevices.js';
+import { renderPlaybacks } from './PlaybackDevices.js';
+
+const availableDevices = (): BoxWidget => {
+ return Widget.Box({
+ vertical: true,
+ children: [
+ Widget.Box({
+ class_name: 'menu-section-container playback',
+ vertical: true,
+ children: [
+ Widget.Box({
+ class_name: 'menu-label-container playback',
+ hpack: 'fill',
+ child: Widget.Label({
+ class_name: 'menu-label audio playback',
+ hexpand: true,
+ hpack: 'start',
+ label: 'Playback Devices',
+ }),
+ }),
+ Widget.Box({
+ class_name: 'menu-items-section playback',
+ vertical: true,
+ children: [
+ Widget.Box({
+ class_name: 'menu-container playback',
+ vertical: true,
+ children: [
+ Widget.Box({
+ vertical: true,
+ children: audio.bind('speakers').as((v) => renderPlaybacks(v)),
+ }),
+ ],
+ }),
+ ],
+ }),
+ Widget.Box({
+ class_name: 'menu-label-container input',
+ hpack: 'fill',
+ child: Widget.Label({
+ class_name: 'menu-label audio input',
+ hexpand: true,
+ hpack: 'start',
+ label: 'Input Devices',
+ }),
+ }),
+ Widget.Box({
+ class_name: 'menu-items-section input',
+ vertical: true,
+ children: [
+ Widget.Box({
+ class_name: 'menu-container input',
+ vertical: true,
+ children: [
+ Widget.Box({
+ vertical: true,
+ children: audio.bind('microphones').as((v) => renderInputDevices(v)),
+ }),
+ ],
+ }),
+ ],
+ }),
+ ],
+ }),
+ ],
+ });
+};
+
+export { availableDevices };
diff --git a/ags/HyprPanel/modules/menus/audio/index.ts b/ags/HyprPanel/modules/menus/audio/index.ts
new file mode 100644
index 0000000..6801151
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/audio/index.ts
@@ -0,0 +1,25 @@
+import Window from 'types/widgets/window.js';
+import DropdownMenu from '../shared/dropdown/index.js';
+import { activeDevices } from './active/index.js';
+import { availableDevices } from './available/index.js';
+import { Attribute, Child } from 'lib/types/widget.js';
+import options from 'options.js';
+
+export default (): Window => {
+ return DropdownMenu({
+ name: 'audiomenu',
+ transition: options.menus.transition.bind('value'),
+ child: Widget.Box({
+ class_name: 'menu-items audio',
+ hpack: 'fill',
+ hexpand: true,
+ child: Widget.Box({
+ vertical: true,
+ hpack: 'fill',
+ hexpand: true,
+ class_name: 'menu-items-container audio',
+ children: [activeDevices(), availableDevices()],
+ }),
+ }),
+ });
+};
diff --git a/ags/HyprPanel/modules/menus/audio/utils.ts b/ags/HyprPanel/modules/menus/audio/utils.ts
new file mode 100644
index 0000000..aa1de5c
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/audio/utils.ts
@@ -0,0 +1,29 @@
+const speakerIcons = {
+ 101: 'audio-volume-overamplified-symbolic',
+ 66: 'audio-volume-high-symbolic',
+ 34: 'audio-volume-medium-symbolic',
+ 1: 'audio-volume-low-symbolic',
+ 0: 'audio-volume-muted-symbolic',
+} as const;
+
+const inputIcons = {
+ 101: 'microphone-sensitivity-high-symbolic',
+ 66: 'microphone-sensitivity-high-symbolic',
+ 34: 'microphone-sensitivity-medium-symbolic',
+ 1: 'microphone-sensitivity-low-symbolic',
+ 0: 'microphone-disabled-symbolic',
+};
+
+type IconVolumes = keyof typeof speakerIcons;
+
+const getIcon = (audioVol: number, isMuted: boolean): Record => {
+ const thresholds: IconVolumes[] = [101, 66, 34, 1, 0];
+ const icon = isMuted ? 0 : thresholds.find((threshold) => threshold <= audioVol * 100) || 0;
+
+ return {
+ spkr: speakerIcons[icon],
+ mic: inputIcons[icon],
+ };
+};
+
+export { getIcon };
diff --git a/ags/HyprPanel/modules/menus/bluetooth/devices/connectedControls.ts b/ags/HyprPanel/modules/menus/bluetooth/devices/connectedControls.ts
new file mode 100644
index 0000000..62e8345
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/bluetooth/devices/connectedControls.ts
@@ -0,0 +1,71 @@
+import { BoxWidget } from 'lib/types/widget';
+import { BluetoothDevice } from 'types/service/bluetooth';
+
+const connectedControls = (dev: BluetoothDevice, connectedDevices: BluetoothDevice[]): BoxWidget => {
+ if (!connectedDevices.includes(dev.address)) {
+ return Widget.Box({});
+ }
+
+ return Widget.Box({
+ vpack: 'start',
+ class_name: 'bluetooth-controls',
+ children: [
+ Widget.Button({
+ class_name: 'menu-icon-button unpair bluetooth',
+ child: Widget.Label({
+ tooltip_text: dev.paired ? 'Unpair' : 'Pair',
+ class_name: 'menu-icon-button-label unpair bluetooth txt-icon',
+ label: dev.paired ? '' : '',
+ }),
+ on_primary_click: () =>
+ Utils.execAsync([
+ 'bash',
+ '-c',
+ `bluetoothctl ${dev.paired ? 'unpair' : 'pair'} ${dev.address}`,
+ ]).catch((err) =>
+ console.error(`bluetoothctl ${dev.paired ? 'unpair' : 'pair'} ${dev.address}`, err),
+ ),
+ }),
+ Widget.Button({
+ class_name: 'menu-icon-button disconnect bluetooth',
+ child: Widget.Label({
+ tooltip_text: dev.connected ? 'Disconnect' : 'Connect',
+ class_name: 'menu-icon-button-label disconnect bluetooth txt-icon',
+ label: dev.connected ? '' : '',
+ }),
+ on_primary_click: () => dev.setConnection(!dev.connected),
+ }),
+ Widget.Button({
+ class_name: 'menu-icon-button untrust bluetooth',
+ child: Widget.Label({
+ tooltip_text: dev.trusted ? 'Untrust' : 'Trust',
+ class_name: 'menu-icon-button-label untrust bluetooth txt-icon',
+ label: dev.trusted ? '' : '',
+ }),
+ on_primary_click: () =>
+ Utils.execAsync([
+ 'bash',
+ '-c',
+ `bluetoothctl ${dev.trusted ? 'untrust' : 'trust'} ${dev.address}`,
+ ]).catch((err) =>
+ console.error(`bluetoothctl ${dev.trusted ? 'untrust' : 'trust'} ${dev.address}`, err),
+ ),
+ }),
+ Widget.Button({
+ class_name: 'menu-icon-button delete bluetooth',
+ child: Widget.Label({
+ tooltip_text: 'Forget',
+ class_name: 'menu-icon-button-label delete bluetooth txt-icon',
+ label: '',
+ }),
+ on_primary_click: () => {
+ Utils.execAsync(['bash', '-c', `bluetoothctl remove ${dev.address}`]).catch((err) =>
+ console.error('Bluetooth Remove', err),
+ );
+ },
+ }),
+ ],
+ });
+};
+
+export { connectedControls };
diff --git a/ags/HyprPanel/modules/menus/bluetooth/devices/devicelist.ts b/ags/HyprPanel/modules/menus/bluetooth/devices/devicelist.ts
new file mode 100644
index 0000000..e1fdcc9
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/bluetooth/devices/devicelist.ts
@@ -0,0 +1,136 @@
+import { Bluetooth } from 'types/service/bluetooth.js';
+import Box from 'types/widgets/box.js';
+import { connectedControls } from './connectedControls.js';
+import { getBluetoothIcon } from '../utils.js';
+import Gtk from 'types/@girs/gtk-3.0/gtk-3.0.js';
+import { Attribute, Child } from 'lib/types/widget.js';
+
+const devices = (bluetooth: Bluetooth, self: Box): Box => {
+ return self.hook(bluetooth, () => {
+ if (!bluetooth.enabled) {
+ return (self.child = Widget.Box({
+ class_name: 'bluetooth-items',
+ vertical: true,
+ expand: true,
+ vpack: 'center',
+ hpack: 'center',
+ children: [
+ Widget.Label({
+ class_name: 'bluetooth-disabled dim',
+ hexpand: true,
+ label: 'Bluetooth is disabled',
+ }),
+ ],
+ }));
+ }
+
+ const availableDevices = bluetooth.devices
+ .filter((btDev) => btDev.name !== null)
+ .sort((a, b) => {
+ if (a.connected || a.paired) {
+ return -1;
+ }
+
+ if (b.connected || b.paired) {
+ return 1;
+ }
+
+ return b.name - a.name;
+ });
+
+ const conDevNames = availableDevices.filter((d) => d.connected || d.paired).map((d) => d.address);
+
+ if (!availableDevices.length) {
+ return (self.child = Widget.Box({
+ class_name: 'bluetooth-items',
+ vertical: true,
+ expand: true,
+ vpack: 'center',
+ hpack: 'center',
+ children: [
+ Widget.Label({
+ class_name: 'no-bluetooth-devices dim',
+ hexpand: true,
+ label: 'No devices currently found',
+ }),
+ Widget.Label({
+ class_name: 'search-bluetooth-label dim',
+ hexpand: true,
+ label: "Press '' to search",
+ }),
+ ],
+ }));
+ }
+
+ return (self.child = Widget.Box({
+ vertical: true,
+ children: availableDevices.map((device) => {
+ return Widget.Box({
+ children: [
+ Widget.Button({
+ hexpand: true,
+ class_name: `bluetooth-element-item ${device}`,
+ on_primary_click: () => {
+ if (!conDevNames.includes(device.address)) device.setConnection(true);
+ },
+ child: Widget.Box({
+ hexpand: true,
+ children: [
+ Widget.Box({
+ hexpand: true,
+ hpack: 'start',
+ class_name: 'menu-button-container',
+ children: [
+ Widget.Label({
+ vpack: 'start',
+ class_name: `menu-button-icon bluetooth ${conDevNames.includes(device.address) ? 'active' : ''} txt-icon`,
+ label: getBluetoothIcon(`${device['icon_name']}-symbolic`),
+ }),
+ Widget.Box({
+ vertical: true,
+ vpack: 'center',
+ children: [
+ Widget.Label({
+ vpack: 'center',
+ hpack: 'start',
+ class_name: 'menu-button-name bluetooth',
+ truncate: 'end',
+ wrap: true,
+ label: device.alias,
+ }),
+ Widget.Revealer({
+ hpack: 'start',
+ reveal_child: device.connected || device.paired,
+ child: Widget.Label({
+ hpack: 'start',
+ class_name: 'connection-status dim',
+ label: device.connected ? 'Connected' : 'Paired',
+ }),
+ }),
+ ],
+ }),
+ ],
+ }),
+ Widget.Box({
+ hpack: 'end',
+ children: device.connecting
+ ? [
+ Widget.Spinner({
+ vpack: 'start',
+ class_name: 'spinner bluetooth',
+ }),
+ ]
+ : [],
+ }),
+ ],
+ }),
+ }),
+ connectedControls(device, conDevNames),
+ ],
+ });
+ }),
+ }));
+ });
+};
+
+export { devices };
diff --git a/ags/HyprPanel/modules/menus/bluetooth/devices/index.ts b/ags/HyprPanel/modules/menus/bluetooth/devices/index.ts
new file mode 100644
index 0000000..31315d5
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/bluetooth/devices/index.ts
@@ -0,0 +1,26 @@
+const bluetooth = await Service.import('bluetooth');
+import { label } from './label.js';
+import { devices } from './devicelist.js';
+import { BoxWidget } from 'lib/types/widget.js';
+
+const Devices = (): BoxWidget => {
+ return Widget.Box({
+ class_name: 'menu-section-container',
+ vertical: true,
+ children: [
+ label(bluetooth),
+ Widget.Box({
+ class_name: 'menu-items-section',
+ child: Widget.Box({
+ class_name: 'menu-content',
+ vertical: true,
+ setup: (self) => {
+ devices(bluetooth, self);
+ },
+ }),
+ }),
+ ],
+ });
+};
+
+export { Devices };
diff --git a/ags/HyprPanel/modules/menus/bluetooth/devices/label.ts b/ags/HyprPanel/modules/menus/bluetooth/devices/label.ts
new file mode 100644
index 0000000..45fabf6
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/bluetooth/devices/label.ts
@@ -0,0 +1,65 @@
+import { BoxWidget } from 'lib/types/widget';
+import { Bluetooth } from 'types/service/bluetooth';
+
+const label = (bluetooth: Bluetooth): BoxWidget => {
+ const searchInProgress = Variable(false);
+
+ const startRotation = (): void => {
+ searchInProgress.value = true;
+ setTimeout(() => {
+ searchInProgress.value = false;
+ }, 10 * 1000);
+ };
+
+ return Widget.Box({
+ class_name: 'menu-label-container',
+ hpack: 'fill',
+ vpack: 'start',
+ children: [
+ Widget.Label({
+ class_name: 'menu-label',
+ vpack: 'center',
+ hpack: 'start',
+ label: 'Bluetooth',
+ }),
+ Widget.Box({
+ class_name: 'controls-container',
+ vpack: 'start',
+ children: [
+ Widget.Switch({
+ class_name: 'menu-switch bluetooth',
+ hexpand: true,
+ hpack: 'end',
+ active: bluetooth.bind('enabled'),
+ on_activate: ({ active }) => {
+ searchInProgress.value = false;
+ Utils.execAsync(['bash', '-c', `bluetoothctl power ${active ? 'on' : 'off'}`]).catch(
+ (err) => console.error(`bluetoothctl power ${active ? 'on' : 'off'}`, err),
+ );
+ },
+ }),
+ Widget.Separator({
+ class_name: 'menu-separator bluetooth',
+ }),
+ Widget.Button({
+ vpack: 'center',
+ class_name: 'menu-icon-button search',
+ on_primary_click: () => {
+ startRotation();
+ Utils.execAsync(['bash', '-c', 'bluetoothctl --timeout 120 scan on']).catch((err) => {
+ searchInProgress.value = false;
+ console.error('bluetoothctl --timeout 120 scan on', err);
+ });
+ },
+ child: Widget.Icon({
+ class_name: searchInProgress.bind('value').as((v) => (v ? 'spinning' : '')),
+ icon: 'view-refresh-symbolic',
+ }),
+ }),
+ ],
+ }),
+ ],
+ });
+};
+
+export { label };
diff --git a/ags/HyprPanel/modules/menus/bluetooth/index.ts b/ags/HyprPanel/modules/menus/bluetooth/index.ts
new file mode 100644
index 0000000..54dfcad
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/bluetooth/index.ts
@@ -0,0 +1,24 @@
+import Window from 'types/widgets/window.js';
+import DropdownMenu from '../shared/dropdown/index.js';
+import { Devices } from './devices/index.js';
+import { Attribute, Child } from 'lib/types/widget.js';
+import options from 'options.js';
+
+export default (): Window => {
+ return DropdownMenu({
+ name: 'bluetoothmenu',
+ transition: options.menus.transition.bind('value'),
+ child: Widget.Box({
+ class_name: 'menu-items bluetooth',
+ hpack: 'fill',
+ hexpand: true,
+ child: Widget.Box({
+ vertical: true,
+ hpack: 'fill',
+ hexpand: true,
+ class_name: 'menu-items-container bluetooth',
+ child: Devices(),
+ }),
+ }),
+ });
+};
diff --git a/ags/HyprPanel/modules/menus/bluetooth/utils.ts b/ags/HyprPanel/modules/menus/bluetooth/utils.ts
new file mode 100644
index 0000000..c305538
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/bluetooth/utils.ts
@@ -0,0 +1,29 @@
+const getBluetoothIcon = (iconName: string): string => {
+ const deviceIconMap = [
+ ['^audio-card*', ''],
+ ['^audio-headphones*', ''],
+ ['^audio-headset*', ''],
+ ['^audio-input*', ''],
+ ['^audio-speakers*', ''],
+ ['^bluetooth*', ''],
+ ['^camera*', ''],
+ ['^computer*', ''],
+ ['^input-gaming*', ''],
+ ['^input-keyboard*', ''],
+ ['^input-mouse*', ''],
+ ['^input-tablet*', ''],
+ ['^media*', ''],
+ ['^modem*', ''],
+ ['^network*', ''],
+ ['^phone*', ''],
+ ['^printer*', ''],
+ ['^scanner*', ''],
+ ['^video-camera*', ''],
+ ];
+
+ const foundMatch = deviceIconMap.find((icon) => RegExp(icon[0]).test(iconName.toLowerCase()));
+
+ return foundMatch ? foundMatch[1] : '';
+};
+
+export { getBluetoothIcon };
diff --git a/ags/HyprPanel/modules/menus/calendar/calendar.ts b/ags/HyprPanel/modules/menus/calendar/calendar.ts
new file mode 100644
index 0000000..d2af59e
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/calendar/calendar.ts
@@ -0,0 +1,24 @@
+import { BoxWidget } from 'lib/types/widget';
+
+const CalendarWidget = (): BoxWidget => {
+ return Widget.Box({
+ class_name: 'calendar-menu-item-container calendar',
+ hpack: 'fill',
+ vpack: 'fill',
+ expand: true,
+ child: Widget.Box({
+ class_name: 'calendar-container-box',
+ child: Widget.Calendar({
+ expand: true,
+ hpack: 'fill',
+ vpack: 'fill',
+ class_name: 'calendar-menu-widget',
+ showDayNames: true,
+ showDetails: false,
+ showHeading: true,
+ }),
+ }),
+ });
+};
+
+export { CalendarWidget };
diff --git a/ags/HyprPanel/modules/menus/calendar/index.ts b/ags/HyprPanel/modules/menus/calendar/index.ts
new file mode 100644
index 0000000..d3fecb8
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/calendar/index.ts
@@ -0,0 +1,36 @@
+import DropdownMenu from 'modules/menus/shared/dropdown/index';
+import { TimeWidget } from './time/index';
+import { CalendarWidget } from './calendar';
+import { WeatherWidget } from './weather/index';
+import options from 'options';
+import Window from 'types/widgets/window';
+import { Attribute, Child } from 'lib/types/widget';
+
+const { enabled: weatherEnabled } = options.menus.clock.weather;
+
+export default (): Window => {
+ return DropdownMenu({
+ name: 'calendarmenu',
+ transition: options.menus.transition.bind('value'),
+ child: Widget.Box({
+ class_name: 'calendar-menu-content',
+ css: 'padding: 1px; margin: -1px;',
+ vexpand: false,
+ children: [
+ Widget.Box({
+ class_name: 'calendar-content-container',
+ vertical: true,
+ children: [
+ Widget.Box({
+ class_name: 'calendar-content-items',
+ vertical: true,
+ children: weatherEnabled.bind('value').as((isWeatherEnabled) => {
+ return [TimeWidget(), CalendarWidget(), ...(isWeatherEnabled ? [WeatherWidget()] : [])];
+ }),
+ }),
+ ],
+ }),
+ ],
+ }),
+ });
+};
diff --git a/ags/HyprPanel/modules/menus/calendar/time/index.ts b/ags/HyprPanel/modules/menus/calendar/time/index.ts
new file mode 100644
index 0000000..2cbb279
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/calendar/time/index.ts
@@ -0,0 +1,75 @@
+import { BoxWidget } from 'lib/types/widget';
+import options from 'options';
+
+const { military, hideSeconds } = options.menus.clock.time;
+
+const time = Variable('', {
+ poll: [1000, 'date "+%I:%M:%S"'],
+});
+
+const period = Variable('', {
+ poll: [1000, 'date "+%p"'],
+});
+
+const militaryTime = Variable('', {
+ poll: [1000, 'date "+%H:%M:%S"'],
+});
+
+const TimeWidget = (): BoxWidget => {
+ return Widget.Box({
+ class_name: 'calendar-menu-item-container clock',
+ hexpand: true,
+ vpack: 'center',
+ hpack: 'fill',
+ child: Widget.Box({
+ hexpand: true,
+ vpack: 'center',
+ hpack: 'center',
+ class_name: 'clock-content-items',
+ children: Utils.merge(
+ [military.bind('value'), hideSeconds.bind('value')],
+ (is24hr: boolean, hideSeconds: boolean) => {
+ if (!is24hr) {
+ return [
+ Widget.Box({
+ hpack: 'center',
+ children: [
+ Widget.Label({
+ class_name: 'clock-content-time',
+ label: hideSeconds ? time.bind().as((str) => str.slice(0, -3)) : time.bind(),
+ }),
+ ],
+ }),
+ Widget.Box({
+ hpack: 'center',
+ children: [
+ Widget.Label({
+ vpack: 'end',
+ class_name: 'clock-content-period',
+ label: period.bind(),
+ }),
+ ],
+ }),
+ ];
+ }
+
+ return [
+ Widget.Box({
+ hpack: 'center',
+ children: [
+ Widget.Label({
+ class_name: 'clock-content-time',
+ label: hideSeconds
+ ? militaryTime.bind().as((str) => str.slice(0, -3))
+ : militaryTime.bind(),
+ }),
+ ],
+ }),
+ ];
+ },
+ ),
+ }),
+ });
+};
+
+export { TimeWidget };
diff --git a/ags/HyprPanel/modules/menus/calendar/weather/hourly/icon/index.ts b/ags/HyprPanel/modules/menus/calendar/weather/hourly/icon/index.ts
new file mode 100644
index 0000000..2c636c1
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/calendar/weather/hourly/icon/index.ts
@@ -0,0 +1,43 @@
+import { Weather, WeatherIconTitle } from 'lib/types/weather.js';
+import { Variable } from 'types/variable.js';
+import { weatherIcons } from 'modules/icons/weather.js';
+import { isValidWeatherIconTitle } from 'globals/weather';
+import { BoxWidget } from 'lib/types/widget';
+import { getNextEpoch } from '../utils';
+
+export const HourlyIcon = (theWeather: Variable, hoursFromNow: number): BoxWidget => {
+ const getIconQuery = (wthr: Weather): WeatherIconTitle => {
+ const nextEpoch = getNextEpoch(wthr, hoursFromNow);
+ const weatherAtEpoch = wthr.forecast.forecastday[0].hour.find((h) => h.time_epoch === nextEpoch);
+
+ if (weatherAtEpoch === undefined) {
+ return 'warning';
+ }
+
+ let iconQuery = weatherAtEpoch.condition.text.trim().toLowerCase().replaceAll(' ', '_');
+
+ if (!weatherAtEpoch?.is_day && iconQuery === 'partly_cloudy') {
+ iconQuery = 'partly_cloudy_night';
+ }
+
+ if (isValidWeatherIconTitle(iconQuery)) {
+ return iconQuery;
+ } else {
+ return 'warning';
+ }
+ };
+
+ return Widget.Box({
+ hpack: 'center',
+ child: theWeather.bind('value').as((w) => {
+ const iconQuery = getIconQuery(w);
+ const weatherIcn = weatherIcons[iconQuery] || weatherIcons['warning'];
+
+ return Widget.Label({
+ hpack: 'center',
+ class_name: 'hourly-weather-icon txt-icon',
+ label: weatherIcn,
+ });
+ }),
+ });
+};
diff --git a/ags/HyprPanel/modules/menus/calendar/weather/hourly/index.ts b/ags/HyprPanel/modules/menus/calendar/weather/hourly/index.ts
new file mode 100644
index 0000000..0eb1982
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/calendar/weather/hourly/index.ts
@@ -0,0 +1,27 @@
+import { Weather } from 'lib/types/weather';
+import { Variable } from 'types/variable';
+import { HourlyIcon } from './icon/index.js';
+import { HourlyTemp } from './temperature/index.js';
+import { HourlyTime } from './time/index.js';
+import { BoxWidget } from 'lib/types/widget.js';
+
+export const Hourly = (theWeather: Variable): BoxWidget => {
+ return Widget.Box({
+ vertical: false,
+ hexpand: true,
+ hpack: 'fill',
+ class_name: 'hourly-weather-container',
+ children: [1, 2, 3, 4].map((hoursFromNow) => {
+ return Widget.Box({
+ class_name: 'hourly-weather-item',
+ hexpand: true,
+ vertical: true,
+ children: [
+ HourlyTime(theWeather, hoursFromNow),
+ HourlyIcon(theWeather, hoursFromNow),
+ HourlyTemp(theWeather, hoursFromNow),
+ ],
+ });
+ }),
+ });
+};
diff --git a/ags/HyprPanel/modules/menus/calendar/weather/hourly/temperature/index.ts b/ags/HyprPanel/modules/menus/calendar/weather/hourly/temperature/index.ts
new file mode 100644
index 0000000..440c994
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/calendar/weather/hourly/temperature/index.ts
@@ -0,0 +1,27 @@
+import { Weather } from 'lib/types/weather';
+import { Variable } from 'types/variable';
+import options from 'options';
+import Label from 'types/widgets/label';
+import { Child } from 'lib/types/widget';
+import { getNextEpoch } from '../utils';
+
+const { unit } = options.menus.clock.weather;
+
+export const HourlyTemp = (theWeather: Variable, hoursFromNow: number): Label => {
+ return Widget.Label({
+ class_name: 'hourly-weather-temp',
+ label: Utils.merge([theWeather.bind('value'), unit.bind('value')], (wthr, unt) => {
+ if (!Object.keys(wthr).length) {
+ return '-';
+ }
+
+ const nextEpoch = getNextEpoch(wthr, hoursFromNow);
+ const weatherAtEpoch = wthr.forecast.forecastday[0].hour.find((h) => h.time_epoch === nextEpoch);
+
+ if (unt === 'imperial') {
+ return `${weatherAtEpoch ? Math.ceil(weatherAtEpoch.temp_f) : '-'}° F`;
+ }
+ return `${weatherAtEpoch ? Math.ceil(weatherAtEpoch.temp_c) : '-'}° C`;
+ }),
+ });
+};
diff --git a/ags/HyprPanel/modules/menus/calendar/weather/hourly/time/index.ts b/ags/HyprPanel/modules/menus/calendar/weather/hourly/time/index.ts
new file mode 100644
index 0000000..717eac4
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/calendar/weather/hourly/time/index.ts
@@ -0,0 +1,24 @@
+import { Weather } from 'lib/types/weather';
+import { Child } from 'lib/types/widget';
+import { Variable } from 'types/variable';
+import Label from 'types/widgets/label';
+import { getNextEpoch } from '../utils';
+
+export const HourlyTime = (theWeather: Variable, hoursFromNow: number): Label => {
+ return Widget.Label({
+ class_name: 'hourly-weather-time',
+ label: theWeather.bind('value').as((w) => {
+ if (!Object.keys(w).length) {
+ return '-';
+ }
+
+ const nextEpoch = getNextEpoch(w, hoursFromNow);
+ const dateAtEpoch = new Date(nextEpoch * 1000);
+ let hours = dateAtEpoch.getHours();
+ const ampm = hours >= 12 ? 'PM' : 'AM';
+ hours = hours % 12 || 12;
+
+ return `${hours}${ampm}`;
+ }),
+ });
+};
diff --git a/ags/HyprPanel/modules/menus/calendar/weather/hourly/utils.ts b/ags/HyprPanel/modules/menus/calendar/weather/hourly/utils.ts
new file mode 100644
index 0000000..13f1d53
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/calendar/weather/hourly/utils.ts
@@ -0,0 +1,20 @@
+import { Weather } from 'lib/types/weather';
+
+export const getNextEpoch = (wthr: Weather, hoursFromNow: number): number => {
+ const currentEpoch = wthr.location.localtime_epoch;
+ const epochAtHourStart = currentEpoch - (currentEpoch % 3600);
+ let nextEpoch = 3600 * hoursFromNow + epochAtHourStart;
+
+ const curHour = new Date(currentEpoch * 1000).getHours();
+
+ /*
+ * NOTE: Since the API is only capable of showing the current day; if
+ * the hours left in the day are less than 4 (aka spilling into the next day),
+ * then rewind to contain the prediction within the current day.
+ */
+ if (curHour > 19) {
+ const hoursToRewind = curHour - 19;
+ nextEpoch = 3600 * hoursFromNow + epochAtHourStart - hoursToRewind * 3600;
+ }
+ return nextEpoch;
+};
diff --git a/ags/HyprPanel/modules/menus/calendar/weather/icon/index.ts b/ags/HyprPanel/modules/menus/calendar/weather/icon/index.ts
new file mode 100644
index 0000000..dff981a
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/calendar/weather/icon/index.ts
@@ -0,0 +1,18 @@
+import { Weather } from 'lib/types/weather';
+import { Variable } from 'types/variable';
+import { getWeatherStatusTextIcon } from 'globals/weather.js';
+import { BoxWidget } from 'lib/types/widget';
+
+export const TodayIcon = (theWeather: Variable): BoxWidget => {
+ return Widget.Box({
+ vpack: 'center',
+ hpack: 'start',
+ class_name: 'calendar-menu-weather today icon container',
+ child: Widget.Label({
+ class_name: 'calendar-menu-weather today icon txt-icon',
+ label: theWeather.bind('value').as((w) => {
+ return getWeatherStatusTextIcon(w);
+ }),
+ }),
+ });
+};
diff --git a/ags/HyprPanel/modules/menus/calendar/weather/index.ts b/ags/HyprPanel/modules/menus/calendar/weather/index.ts
new file mode 100644
index 0000000..bf4a6bf
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/calendar/weather/index.ts
@@ -0,0 +1,38 @@
+import { TodayIcon } from './icon/index.js';
+import { TodayStats } from './stats/index.js';
+import { TodayTemperature } from './temperature/index.js';
+import { Hourly } from './hourly/index.js';
+import { globalWeatherVar } from 'globals/weather.js';
+import { BoxWidget } from 'lib/types/widget.js';
+
+const WeatherWidget = (): BoxWidget => {
+ return Widget.Box({
+ class_name: 'calendar-menu-item-container weather',
+ child: Widget.Box({
+ class_name: 'weather-container-box',
+ setup: (self) => {
+ return (self.child = Widget.Box({
+ vertical: true,
+ hexpand: true,
+ children: [
+ Widget.Box({
+ class_name: 'calendar-menu-weather today',
+ hexpand: true,
+ children: [
+ TodayIcon(globalWeatherVar),
+ TodayTemperature(globalWeatherVar),
+ TodayStats(globalWeatherVar),
+ ],
+ }),
+ Widget.Separator({
+ class_name: 'menu-separator weather',
+ }),
+ Hourly(globalWeatherVar),
+ ],
+ }));
+ },
+ }),
+ });
+};
+
+export { WeatherWidget };
diff --git a/ags/HyprPanel/modules/menus/calendar/weather/stats/index.ts b/ags/HyprPanel/modules/menus/calendar/weather/stats/index.ts
new file mode 100644
index 0000000..197032f
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/calendar/weather/stats/index.ts
@@ -0,0 +1,50 @@
+import { Weather } from 'lib/types/weather';
+import { Variable } from 'types/variable';
+import options from 'options';
+import { Unit } from 'lib/types/options';
+import { getRainChance, getWindConditions } from 'globals/weather';
+import { BoxWidget } from 'lib/types/widget';
+
+const { unit } = options.menus.clock.weather;
+
+export const TodayStats = (theWeather: Variable): BoxWidget => {
+ return Widget.Box({
+ class_name: 'calendar-menu-weather today stats container',
+ hpack: 'end',
+ vpack: 'center',
+ vertical: true,
+ children: [
+ Widget.Box({
+ class_name: 'weather wind',
+ children: [
+ Widget.Label({
+ class_name: 'weather wind icon txt-icon',
+ label: '',
+ }),
+ Widget.Label({
+ class_name: 'weather wind label',
+ label: Utils.merge(
+ [theWeather.bind('value'), unit.bind('value')],
+ (wthr: Weather, unt: Unit) => {
+ return getWindConditions(wthr, unt);
+ },
+ ),
+ }),
+ ],
+ }),
+ Widget.Box({
+ class_name: 'weather precip',
+ children: [
+ Widget.Label({
+ class_name: 'weather precip icon txt-icon',
+ label: '',
+ }),
+ Widget.Label({
+ class_name: 'weather precip label',
+ label: theWeather.bind('value').as((v) => getRainChance(v)),
+ }),
+ ],
+ }),
+ ],
+ });
+};
diff --git a/ags/HyprPanel/modules/menus/calendar/weather/temperature/index.ts b/ags/HyprPanel/modules/menus/calendar/weather/temperature/index.ts
new file mode 100644
index 0000000..597a75c
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/calendar/weather/temperature/index.ts
@@ -0,0 +1,62 @@
+import { Weather } from 'lib/types/weather';
+import { Variable } from 'types/variable';
+import options from 'options';
+import { getTemperature, getWeatherIcon } from 'globals/weather';
+import { BoxWidget } from 'lib/types/widget';
+const { unit } = options.menus.clock.weather;
+
+export const TodayTemperature = (theWeather: Variable): BoxWidget => {
+ return Widget.Box({
+ hpack: 'center',
+ vpack: 'center',
+ vertical: true,
+ children: [
+ Widget.Box({
+ hexpand: true,
+ vpack: 'center',
+ class_name: 'calendar-menu-weather today temp container',
+ vertical: false,
+ children: [
+ Widget.Box({
+ hexpand: true,
+ hpack: 'center',
+ children: [
+ Widget.Label({
+ class_name: 'calendar-menu-weather today temp label',
+ label: Utils.merge([theWeather.bind('value'), unit.bind('value')], (wthr, unt) => {
+ return getTemperature(wthr, unt);
+ }),
+ }),
+ Widget.Label({
+ class_name: theWeather
+ .bind('value')
+ .as(
+ (v) =>
+ `calendar-menu-weather today temp label icon txt-icon ${getWeatherIcon(Math.ceil(v.current.temp_f)).color}`,
+ ),
+ label: theWeather
+ .bind('value')
+ .as((v) => getWeatherIcon(Math.ceil(v.current.temp_f)).icon),
+ }),
+ ],
+ }),
+ ],
+ }),
+ Widget.Box({
+ hpack: 'center',
+ child: Widget.Label({
+ max_width_chars: 17,
+ truncate: 'end',
+ lines: 2,
+ class_name: theWeather
+ .bind('value')
+ .as(
+ (v) =>
+ `calendar-menu-weather today condition label ${getWeatherIcon(Math.ceil(v.current.temp_f)).color}`,
+ ),
+ label: theWeather.bind('value').as((v) => v.current.condition.text),
+ }),
+ }),
+ ],
+ });
+};
diff --git a/ags/HyprPanel/modules/menus/dashboard/controls/index.ts b/ags/HyprPanel/modules/menus/dashboard/controls/index.ts
new file mode 100644
index 0000000..e158f96
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/dashboard/controls/index.ts
@@ -0,0 +1,113 @@
+import { BoxWidget } from 'lib/types/widget';
+
+const network = await Service.import('network');
+const bluetooth = await Service.import('bluetooth');
+const notifications = await Service.import('notifications');
+const audio = await Service.import('audio');
+
+const Controls = (): BoxWidget => {
+ return Widget.Box({
+ class_name: 'dashboard-card controls-container',
+ hpack: 'fill',
+ vpack: 'fill',
+ expand: true,
+ children: [
+ Widget.Button({
+ tooltip_text: 'Toggle Wifi',
+ expand: true,
+ setup: (self) => {
+ self.hook(network, () => {
+ return (self.class_name = `dashboard-button wifi ${!network.wifi.enabled ? 'disabled' : ''}`);
+ });
+ },
+ on_primary_click: () => network.toggleWifi(),
+ child: Widget.Label({
+ class_name: 'txt-icon',
+ setup: (self) => {
+ self.hook(network, () => {
+ return (self.label = network.wifi.enabled ? '' : '');
+ });
+ },
+ }),
+ }),
+ Widget.Button({
+ tooltip_text: 'Toggle Bluetooth',
+ expand: true,
+ class_name: bluetooth
+ .bind('enabled')
+ .as((btOn) => `dashboard-button bluetooth ${!btOn ? 'disabled' : ''}`),
+ on_primary_click: () => bluetooth.toggle(),
+ child: Widget.Label({
+ class_name: 'txt-icon',
+ label: bluetooth.bind('enabled').as((btOn) => (btOn ? '' : '')),
+ }),
+ }),
+ Widget.Button({
+ tooltip_text: 'Toggle Notifications',
+ expand: true,
+ class_name: notifications
+ .bind('dnd')
+ .as((dnd) => `dashboard-button notifications ${dnd ? 'disabled' : ''}`),
+ on_primary_click: () => (notifications.dnd = !notifications.dnd),
+ child: Widget.Label({
+ class_name: 'txt-icon',
+ label: notifications.bind('dnd').as((dnd) => (dnd ? '' : '')),
+ }),
+ }),
+ Widget.Button({
+ tooltip_text: 'Toggle Mute (Playback)',
+ expand: true,
+ on_primary_click: () => (audio.speaker.is_muted = !audio.speaker.is_muted),
+ setup: (self) => {
+ self.hook(
+ audio.speaker,
+ () => {
+ return (self.class_name = `dashboard-button playback ${audio.speaker.is_muted ? 'disabled' : ''}`);
+ },
+ 'notify::is-muted',
+ );
+ },
+ child: Widget.Label({
+ class_name: 'txt-icon',
+ setup: (self) => {
+ self.hook(
+ audio.speaker,
+ () => {
+ return (self.label = audio.speaker.is_muted ? '' : '');
+ },
+ 'notify::is-muted',
+ );
+ },
+ }),
+ }),
+ Widget.Button({
+ tooltip_text: 'Toggle Mute (Microphone)',
+ expand: true,
+ on_primary_click: () => (audio.microphone.is_muted = !audio.microphone.is_muted),
+ setup: (self) => {
+ self.hook(
+ audio.microphone,
+ () => {
+ return (self.class_name = `dashboard-button input ${audio.microphone.is_muted ? 'disabled' : ''}`);
+ },
+ 'notify::is-muted',
+ );
+ },
+ child: Widget.Label({
+ class_name: 'txt-icon',
+ setup: (self) => {
+ self.hook(
+ audio.microphone,
+ () => {
+ return (self.label = audio.microphone.is_muted ? '' : '');
+ },
+ 'notify::is-muted',
+ );
+ },
+ }),
+ }),
+ ],
+ });
+};
+
+export { Controls };
diff --git a/ags/HyprPanel/modules/menus/dashboard/directories/index.ts b/ags/HyprPanel/modules/menus/dashboard/directories/index.ts
new file mode 100644
index 0000000..5b6aa11
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/dashboard/directories/index.ts
@@ -0,0 +1,121 @@
+import { BoxWidget } from 'lib/types/widget';
+import options from 'options';
+
+const { left, right } = options.menus.dashboard.directories;
+
+const Directories = (): BoxWidget => {
+ return Widget.Box({
+ class_name: 'dashboard-card directories-container',
+ vpack: 'fill',
+ hpack: 'fill',
+ expand: true,
+ children: [
+ Widget.Box({
+ vertical: true,
+ expand: true,
+ class_name: 'section right',
+ children: [
+ Widget.Button({
+ hpack: 'start',
+ expand: true,
+ class_name: 'directory-link left top',
+ on_primary_click: left.directory1.command.bind('value').as((cmd) => {
+ return () => {
+ App.closeWindow('dashboardmenu');
+ Utils.execAsync(cmd);
+ };
+ }),
+ child: Widget.Label({
+ hpack: 'start',
+ label: left.directory1.label.bind('value'),
+ }),
+ }),
+ Widget.Button({
+ expand: true,
+ hpack: 'start',
+ class_name: 'directory-link left middle',
+ on_primary_click: left.directory2.command.bind('value').as((cmd) => {
+ return () => {
+ App.closeWindow('dashboardmenu');
+ Utils.execAsync(cmd);
+ };
+ }),
+ child: Widget.Label({
+ hpack: 'start',
+ label: left.directory2.label.bind('value'),
+ }),
+ }),
+ Widget.Button({
+ expand: true,
+ hpack: 'start',
+ class_name: 'directory-link left bottom',
+ on_primary_click: left.directory3.command.bind('value').as((cmd) => {
+ return () => {
+ App.closeWindow('dashboardmenu');
+ Utils.execAsync(cmd);
+ };
+ }),
+ child: Widget.Label({
+ hpack: 'start',
+ label: left.directory3.label.bind('value'),
+ }),
+ }),
+ ],
+ }),
+ Widget.Box({
+ vertical: true,
+ expand: true,
+ class_name: 'section left',
+ children: [
+ Widget.Button({
+ hpack: 'start',
+ expand: true,
+ class_name: 'directory-link right top',
+ on_primary_click: right.directory1.command.bind('value').as((cmd) => {
+ return () => {
+ App.closeWindow('dashboardmenu');
+ Utils.execAsync(cmd);
+ };
+ }),
+ child: Widget.Label({
+ hpack: 'start',
+ label: right.directory1.label.bind('value'),
+ }),
+ }),
+ Widget.Button({
+ expand: true,
+ hpack: 'start',
+ class_name: 'directory-link right middle',
+ on_primary_click: right.directory2.command.bind('value').as((cmd) => {
+ return () => {
+ App.closeWindow('dashboardmenu');
+ Utils.execAsync(cmd);
+ };
+ }),
+ child: Widget.Label({
+ hpack: 'start',
+ label: right.directory2.label.bind('value'),
+ }),
+ }),
+ Widget.Button({
+ expand: true,
+ hpack: 'start',
+ class_name: 'directory-link right bottom',
+ on_primary_click: right.directory3.command.bind('value').as((cmd) => {
+ return () => {
+ App.closeWindow('dashboardmenu');
+ Utils.execAsync(cmd);
+ };
+ }),
+ child: Widget.Label({
+ hpack: 'start',
+ label: right.directory3.label.bind('value'),
+ }),
+ }),
+ ],
+ }),
+ ],
+ });
+};
+
+export { Directories };
diff --git a/ags/HyprPanel/modules/menus/dashboard/index.ts b/ags/HyprPanel/modules/menus/dashboard/index.ts
new file mode 100644
index 0000000..9bac607
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/dashboard/index.ts
@@ -0,0 +1,52 @@
+import DropdownMenu from '../shared/dropdown/index.js';
+import { Profile } from './profile/index.js';
+import { Shortcuts } from './shortcuts/index.js';
+import { Controls } from './controls/index.js';
+import { Stats } from './stats/index.js';
+import { Directories } from './directories/index.js';
+import Window from 'types/widgets/window.js';
+import { Attribute, Child } from 'lib/types/widget.js';
+import options from 'options.js';
+
+const { controls, shortcuts, stats, directories } = options.menus.dashboard;
+
+export default (): Window => {
+ return DropdownMenu({
+ name: 'dashboardmenu',
+ transition: options.menus.transition.bind('value'),
+ child: Widget.Box({
+ class_name: 'dashboard-menu-content',
+ css: 'padding: 1px; margin: -1px;',
+ vexpand: false,
+ children: [
+ Widget.Box({
+ class_name: 'dashboard-content-container',
+ vertical: true,
+ children: Utils.merge(
+ [
+ controls.enabled.bind('value'),
+ shortcuts.enabled.bind('value'),
+ stats.enabled.bind('value'),
+ directories.enabled.bind('value'),
+ ],
+ (isControlsEnabled, isShortcutsEnabled, isStatsEnabled, isDirectoriesEnabled) => {
+ return [
+ Widget.Box({
+ class_name: 'dashboard-content-items',
+ vertical: true,
+ children: [
+ Profile(),
+ ...(isShortcutsEnabled ? [Shortcuts()] : []),
+ ...(isControlsEnabled ? [Controls()] : []),
+ ...(isDirectoriesEnabled ? [Directories()] : []),
+ ...(isStatsEnabled ? [Stats()] : []),
+ ],
+ }),
+ ];
+ },
+ ),
+ }),
+ ],
+ }),
+ });
+};
diff --git a/ags/HyprPanel/modules/menus/dashboard/profile/index.ts b/ags/HyprPanel/modules/menus/dashboard/profile/index.ts
new file mode 100644
index 0000000..9d2531a
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/dashboard/profile/index.ts
@@ -0,0 +1,110 @@
+import powermenu from '../../power/helpers/actions.js';
+import { PowerOptions } from 'lib/types/options.js';
+import GdkPixbuf from 'gi://GdkPixbuf';
+
+import options from 'options';
+import { BoxWidget, Child } from 'lib/types/widget.js';
+import Label from 'types/widgets/label.js';
+const { image, name } = options.menus.dashboard.powermenu.avatar;
+const { confirmation, shutdown, logout, sleep, reboot } = options.menus.dashboard.powermenu;
+
+const Profile = (): BoxWidget => {
+ const handleClick = (action: PowerOptions): void => {
+ const actions = {
+ shutdown: shutdown.value,
+ reboot: reboot.value,
+ logout: logout.value,
+ sleep: sleep.value,
+ };
+ App.closeWindow('dashboardmenu');
+
+ if (!confirmation.value) {
+ Utils.execAsync(actions[action]).catch((err) =>
+ console.error(`Failed to execute ${action} command. Error: ${err}`),
+ );
+ } else {
+ powermenu.action(action);
+ }
+ };
+
+ const getIconForButton = (txtIcon: string): Label => {
+ return Widget.Label({
+ className: 'txt-icon',
+ label: txtIcon,
+ });
+ };
+
+ return Widget.Box({
+ class_name: 'profiles-container',
+ hpack: 'fill',
+ hexpand: true,
+ children: [
+ Widget.Box({
+ class_name: 'profile-picture-container dashboard-card',
+ hexpand: true,
+ vertical: true,
+ children: [
+ Widget.Box({
+ hpack: 'center',
+ class_name: 'profile-picture',
+ css: image.bind('value').as((i) => {
+ try {
+ GdkPixbuf.Pixbuf.new_from_file(i);
+ return `background-image: url("${i}")`;
+ } catch {
+ return `background-image: url("${App.configDir}/assets/hyprpanel.png")`;
+ }
+ }),
+ }),
+ Widget.Label({
+ hpack: 'center',
+ class_name: 'profile-name',
+ label: name.bind('value').as((v) => {
+ if (v === 'system') {
+ return Utils.exec('bash -c whoami');
+ }
+ return v;
+ }),
+ }),
+ ],
+ }),
+ Widget.Box({
+ class_name: 'power-menu-container dashboard-card',
+ vertical: true,
+ vexpand: true,
+ children: [
+ Widget.Button({
+ class_name: 'dashboard-button shutdown',
+ on_clicked: () => handleClick('shutdown'),
+ tooltip_text: 'Shut Down',
+ vexpand: true,
+ child: getIconForButton(''),
+ }),
+ Widget.Button({
+ class_name: 'dashboard-button restart',
+ on_clicked: () => handleClick('reboot'),
+ tooltip_text: 'Restart',
+ vexpand: true,
+ child: getIconForButton(''),
+ }),
+ Widget.Button({
+ class_name: 'dashboard-button lock',
+ on_clicked: () => handleClick('logout'),
+ tooltip_text: 'Log Out',
+ vexpand: true,
+ child: getIconForButton(''),
+ }),
+ Widget.Button({
+ class_name: 'dashboard-button sleep',
+ on_clicked: () => handleClick('sleep'),
+ tooltip_text: 'Sleep',
+ vexpand: true,
+ child: getIconForButton(''),
+ }),
+ ],
+ }),
+ ],
+ });
+};
+
+export { Profile };
diff --git a/ags/HyprPanel/modules/menus/dashboard/shortcuts/index.ts b/ags/HyprPanel/modules/menus/dashboard/shortcuts/index.ts
new file mode 100644
index 0000000..5b4f49c
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/dashboard/shortcuts/index.ts
@@ -0,0 +1,317 @@
+const hyprland = await Service.import('hyprland');
+import { Attribute, BoxWidget, Child } from 'lib/types/widget';
+import options from 'options';
+import { Variable as VarType } from 'types/variable';
+import Box from 'types/widgets/box';
+import Button from 'types/widgets/button';
+import Label from 'types/widgets/label';
+
+const { left, right } = options.menus.dashboard.shortcuts;
+
+const Shortcuts = (): BoxWidget => {
+ const isRecording = Variable(false, {
+ poll: [
+ 1000,
+ `${App.configDir}/services/screen_record.sh status`,
+ (out): boolean => {
+ if (out === 'recording') {
+ return true;
+ }
+ return false;
+ },
+ ],
+ });
+ const handleClick = (action: string, tOut: number = 250): void => {
+ App.closeWindow('dashboardmenu');
+
+ setTimeout(() => {
+ Utils.execAsync(action)
+ .then((res) => {
+ return res;
+ })
+ .catch((err) => err);
+ }, tOut);
+ };
+
+ const recordingDropdown = Widget.Menu({
+ class_name: 'dropdown recording',
+ hpack: 'fill',
+ hexpand: true,
+ setup: (self) => {
+ const renderMonitorList = (): void => {
+ const displays = hyprland.monitors.map((mon) => {
+ return Widget.MenuItem({
+ label: `Display ${mon.name}`,
+ on_activate: () => {
+ App.closeWindow('dashboardmenu');
+ Utils.execAsync(`${App.configDir}/services/screen_record.sh start ${mon.name}`).catch(
+ (err) => console.error(err),
+ );
+ },
+ });
+ });
+
+ // NOTE: This is disabled since window recording isn't available on wayland
+ // const apps = hyprland.clients.map((clt) => {
+ // return Widget.MenuItem({
+ // label: `${clt.class.charAt(0).toUpperCase() + clt.class.slice(1)} (Workspace ${clt.workspace.name})`,
+ // on_activate: () => {
+ // App.closeWindow('dashboardmenu');
+ // Utils.execAsync(
+ // `${App.configDir}/services/screen_record.sh start ${clt.focusHistoryID}`,
+ // ).catch((err) => console.error(err));
+ // },
+ // });
+ // });
+
+ self.children = [
+ ...displays,
+ // Disabled since window recording isn't available on wayland
+ // ...apps
+ ];
+ };
+ self.hook(hyprland, renderMonitorList, 'monitor-added');
+ self.hook(hyprland, renderMonitorList, 'monitor-removed');
+ },
+ });
+
+ type ShortcutFixed = {
+ tooltip: string;
+ command: string;
+ icon: string;
+ configurable: false;
+ };
+
+ type ShortcutVariable = {
+ tooltip: VarType;
+ command: VarType;
+ icon: VarType;
+ configurable?: true;
+ };
+
+ type Shortcut = ShortcutFixed | ShortcutVariable;
+
+ const cmdLn = (sCut: ShortcutVariable): boolean => {
+ return sCut.command.value.length > 0;
+ };
+
+ const leftCardHidden = Variable(
+ !(cmdLn(left.shortcut1) || cmdLn(left.shortcut2) || cmdLn(left.shortcut3) || cmdLn(left.shortcut4)),
+ );
+
+ const createButton = (shortcut: Shortcut, className: string): Button, Attribute> => {
+ if (shortcut.configurable !== false) {
+ return Widget.Button({
+ vexpand: true,
+ tooltip_text: shortcut.tooltip.value,
+ class_name: className,
+ on_primary_click: () => handleClick(shortcut.command.value),
+ child: Widget.Label({
+ class_name: 'button-label txt-icon',
+ label: shortcut.icon.value,
+ }),
+ });
+ } else {
+ // handle non-configurable shortcut
+ return Widget.Button({
+ vexpand: true,
+ tooltip_text: shortcut.tooltip,
+ class_name: className,
+ on_primary_click: (_, event) => {
+ if (shortcut.command === 'settings-dialog') {
+ App.closeWindow('dashboardmenu');
+ App.toggleWindow('settings-dialog');
+ } else if (shortcut.command === 'record') {
+ if (isRecording.value === true) {
+ App.closeWindow('dashboardmenu');
+ return Utils.execAsync(`${App.configDir}/services/screen_record.sh stop`).catch((err) =>
+ console.error(err),
+ );
+ } else {
+ recordingDropdown.popup_at_pointer(event);
+ }
+ }
+ },
+ child: Widget.Label({
+ class_name: 'button-label txt-icon',
+ label: shortcut.icon,
+ }),
+ });
+ }
+ };
+
+ const createButtonIfCommandExists = (
+ shortcut: Shortcut,
+ className: string,
+ command: string,
+ ): Button, Attribute> | Box => {
+ if (command.length > 0) {
+ return createButton(shortcut, className);
+ }
+ return Widget.Box();
+ };
+
+ return Widget.Box({
+ class_name: 'shortcuts-container',
+ hpack: 'fill',
+ hexpand: true,
+ children: [
+ Widget.Box({
+ child: Utils.merge(
+ [
+ left.shortcut1.command.bind('value'),
+ left.shortcut2.command.bind('value'),
+ left.shortcut1.tooltip.bind('value'),
+ left.shortcut2.tooltip.bind('value'),
+ left.shortcut1.icon.bind('value'),
+ left.shortcut2.icon.bind('value'),
+ left.shortcut3.command.bind('value'),
+ left.shortcut4.command.bind('value'),
+ left.shortcut3.tooltip.bind('value'),
+ left.shortcut4.tooltip.bind('value'),
+ left.shortcut3.icon.bind('value'),
+ left.shortcut4.icon.bind('value'),
+ ],
+ () => {
+ const isVisibleLeft = cmdLn(left.shortcut1) || cmdLn(left.shortcut2);
+ const isVisibleRight = cmdLn(left.shortcut3) || cmdLn(left.shortcut4);
+
+ if (!isVisibleLeft && !isVisibleRight) {
+ leftCardHidden.value = true;
+ return Widget.Box();
+ }
+
+ leftCardHidden.value = false;
+
+ return Widget.Box({
+ class_name: 'container most-used dashboard-card',
+ children: [
+ Widget.Box({
+ className: `card-button-section-container ${isVisibleRight && isVisibleLeft ? 'visible' : ''}`,
+ child: isVisibleLeft
+ ? Widget.Box({
+ vertical: true,
+ hexpand: true,
+ vexpand: true,
+ children: [
+ createButtonIfCommandExists(
+ left.shortcut1,
+ `dashboard-button top-button ${cmdLn(left.shortcut2) ? 'paired' : ''}`,
+ left.shortcut1.command.value,
+ ),
+ createButtonIfCommandExists(
+ left.shortcut2,
+ 'dashboard-button',
+ left.shortcut2.command.value,
+ ),
+ ],
+ })
+ : Widget.Box({
+ children: [],
+ }),
+ }),
+ Widget.Box({
+ className: 'card-button-section-container',
+ child: isVisibleRight
+ ? Widget.Box({
+ vertical: true,
+ hexpand: true,
+ vexpand: true,
+ children: [
+ createButtonIfCommandExists(
+ left.shortcut3,
+ `dashboard-button top-button ${cmdLn(left.shortcut4) ? 'paired' : ''}`,
+ left.shortcut3.command.value,
+ ),
+ createButtonIfCommandExists(
+ left.shortcut4,
+ 'dashboard-button',
+ left.shortcut4.command.value,
+ ),
+ ],
+ })
+ : Widget.Box({
+ children: [],
+ }),
+ }),
+ ],
+ });
+ },
+ ),
+ }),
+ Widget.Box({
+ child: Utils.merge(
+ [
+ right.shortcut1.command.bind('value'),
+ right.shortcut1.tooltip.bind('value'),
+ right.shortcut1.icon.bind('value'),
+ right.shortcut3.command.bind('value'),
+ right.shortcut3.tooltip.bind('value'),
+ right.shortcut3.icon.bind('value'),
+ leftCardHidden.bind('value'),
+ isRecording.bind('value'),
+ ],
+ () => {
+ return Widget.Box({
+ class_name: `container utilities dashboard-card ${!leftCardHidden.value ? 'paired' : ''}`,
+ children: [
+ Widget.Box({
+ className: `card-button-section-container visible`,
+ child: Widget.Box({
+ vertical: true,
+ hexpand: true,
+ vexpand: true,
+ children: [
+ createButtonIfCommandExists(
+ right.shortcut1,
+ 'dashboard-button top-button paired',
+ right.shortcut1.command.value,
+ ),
+ createButtonIfCommandExists(
+ {
+ tooltip: 'HyprPanel Configuration',
+ command: 'settings-dialog',
+ icon: '',
+ configurable: false,
+ },
+ 'dashboard-button',
+ 'settings-dialog',
+ ),
+ ],
+ }),
+ }),
+ Widget.Box({
+ className: 'card-button-section-container',
+ child: Widget.Box({
+ vertical: true,
+ hexpand: true,
+ vexpand: true,
+ children: [
+ createButtonIfCommandExists(
+ right.shortcut3,
+ 'dashboard-button top-button paired',
+ right.shortcut3.command.value,
+ ),
+ createButtonIfCommandExists(
+ {
+ tooltip: 'Record Screen',
+ command: 'record',
+ icon: '',
+ configurable: false,
+ },
+ `dashboard-button record ${isRecording.value ? 'active' : ''}`,
+ 'record',
+ ),
+ ],
+ }),
+ }),
+ ],
+ });
+ },
+ ),
+ }),
+ ],
+ });
+};
+
+export { Shortcuts };
diff --git a/ags/HyprPanel/modules/menus/dashboard/stats/index.ts b/ags/HyprPanel/modules/menus/dashboard/stats/index.ts
new file mode 100644
index 0000000..43e8457
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/dashboard/stats/index.ts
@@ -0,0 +1,362 @@
+import options from 'options';
+import { GPU_Stat } from 'lib/types/gpustat';
+import { dependencies } from 'lib/utils';
+import { BoxWidget } from 'lib/types/widget';
+import { GenericResourceMetrics } from 'lib/types/customModules/generic';
+
+const { terminal } = options;
+const { enable_gpu } = options.menus.dashboard.stats;
+
+const Stats = (): BoxWidget => {
+ const divide = ([total, free]: number[]): number => free / total;
+
+ const formatSizeInGB = (sizeInKB: number): number => Number((sizeInKB / 1024 ** 2).toFixed(2));
+
+ const cpu = Variable(0, {
+ poll: [
+ 2000,
+ 'top -b -n 1',
+ (out): number => {
+ if (typeof out !== 'string') {
+ return 0;
+ }
+
+ const cpuOut = out.split('\n').find((line) => line.includes('Cpu(s)'));
+
+ if (cpuOut === undefined) {
+ return 0;
+ }
+
+ const freeCpu = parseFloat(cpuOut.split(/\s+/)[1].replace(',', '.'));
+ return divide([100, freeCpu]);
+ },
+ ],
+ });
+
+ const ram = Variable(
+ { total: 0, used: 0, percentage: 0 },
+ {
+ poll: [
+ 2000,
+ 'free',
+ (out): GenericResourceMetrics => {
+ if (typeof out !== 'string') {
+ return { total: 0, used: 0, percentage: 0 };
+ }
+
+ const ramOut = out.split('\n').find((line) => line.includes('Mem:'));
+
+ if (ramOut === undefined) {
+ return { total: 0, used: 0, percentage: 0 };
+ }
+
+ const [totalRam, usedRam] = ramOut.split(/\s+/).splice(1, 2).map(Number);
+
+ return {
+ percentage: divide([totalRam, usedRam]),
+ total: formatSizeInGB(totalRam),
+ used: formatSizeInGB(usedRam),
+ };
+ },
+ ],
+ },
+ );
+
+ const gpu = Variable(0);
+
+ const GPUStat = Widget.Box({
+ child: enable_gpu.bind('value').as((gpStat) => {
+ if (!gpStat || !dependencies('gpustat')) {
+ return Widget.Box();
+ }
+
+ return Widget.Box({
+ vertical: true,
+ children: [
+ Widget.Box({
+ class_name: 'stat gpu',
+ hexpand: true,
+ vpack: 'center',
+ setup: (self) => {
+ const getGpuUsage = (): void => {
+ if (!enable_gpu.value) {
+ gpu.value = 0;
+ return;
+ }
+
+ Utils.execAsync('gpustat --json')
+ .then((out) => {
+ if (typeof out !== 'string') {
+ return 0;
+ }
+ try {
+ const data = JSON.parse(out);
+
+ const totalGpu = 100;
+ const usedGpu =
+ data.gpus.reduce((acc: number, gpu: GPU_Stat) => {
+ return acc + gpu['utilization.gpu'];
+ }, 0) / data.gpus.length;
+
+ gpu.value = divide([totalGpu, usedGpu]);
+ } catch (e) {
+ console.error('Error getting GPU stats:', e);
+ gpu.value = 0;
+ }
+ })
+ .catch((err) => {
+ console.error(`An error occurred while fetching GPU stats: ${err}`);
+ });
+ };
+
+ self.poll(2000, getGpuUsage);
+
+ Utils.merge([gpu.bind('value'), enable_gpu.bind('value')], (gpu, enableGpu) => {
+ if (!enableGpu) {
+ return (self.children = []);
+ }
+
+ return (self.children = [
+ Widget.Button({
+ on_primary_click: terminal.bind('value').as((term) => {
+ return (): void => {
+ App.closeWindow('dashboardmenu');
+ Utils.execAsync(`bash -c "${term} -e btop"`).catch(
+ (err) => `Failed to open btop: ${err}`,
+ );
+ };
+ }),
+ child: Widget.Label({
+ class_name: 'txt-icon',
+ label: '',
+ }),
+ }),
+ Widget.Button({
+ on_primary_click: terminal.bind('value').as((term) => {
+ return (): void => {
+ App.closeWindow('dashboardmenu');
+ Utils.execAsync(`bash -c "${term} -e btop"`).catch(
+ (err) => `Failed to open btop: ${err}`,
+ );
+ };
+ }),
+ child: Widget.LevelBar({
+ class_name: 'stats-bar',
+ hexpand: true,
+ vpack: 'center',
+ value: gpu,
+ }),
+ }),
+ ]);
+ });
+ },
+ }),
+ Widget.Box({
+ hpack: 'end',
+ children: Utils.merge([gpu.bind('value'), enable_gpu.bind('value')], (gpuUsed, enableGpu) => {
+ if (!enableGpu) {
+ return [];
+ }
+ return [
+ Widget.Label({
+ class_name: 'stat-value gpu',
+ label: `${Math.floor(gpuUsed * 100)}%`,
+ }),
+ ];
+ }),
+ }),
+ ],
+ });
+ }),
+ });
+
+ const storage = Variable(
+ { total: 0, used: 0, percentage: 0 },
+ {
+ poll: [
+ 2000,
+ 'df -B1 /',
+ (out): GenericResourceMetrics => {
+ if (typeof out !== 'string') {
+ return { total: 0, used: 0, percentage: 0 };
+ }
+
+ const dfOut = out.split('\n').find((line) => line.startsWith('/'));
+
+ if (dfOut === undefined) {
+ return { total: 0, used: 0, percentage: 0 };
+ }
+
+ const parts = dfOut.split(/\s+/);
+ const size = parseInt(parts[1], 10);
+ const used = parseInt(parts[2], 10);
+
+ const sizeInGB = formatSizeInGB(size);
+ const usedInGB = formatSizeInGB(used);
+
+ return {
+ total: Math.floor(sizeInGB / 1000),
+ used: Math.floor(usedInGB / 1000),
+ percentage: divide([size, used]),
+ };
+ },
+ ],
+ },
+ );
+
+ return Widget.Box({
+ class_name: 'dashboard-card stats-container',
+ vertical: true,
+ vpack: 'fill',
+ hpack: 'fill',
+ expand: true,
+ children: [
+ Widget.Box({
+ vertical: true,
+ children: [
+ Widget.Box({
+ class_name: 'stat cpu',
+ hexpand: true,
+ vpack: 'center',
+ children: [
+ Widget.Button({
+ on_primary_click: terminal.bind('value').as((term) => {
+ return () => {
+ App.closeWindow('dashboardmenu');
+ Utils.execAsync(`bash -c "${term} -e btop"`).catch(
+ (err) => `Failed to open btop: ${err}`,
+ );
+ };
+ }),
+ child: Widget.Label({
+ class_name: 'txt-icon',
+ label: '',
+ }),
+ }),
+ Widget.Button({
+ on_primary_click: terminal.bind('value').as((term) => {
+ return () => {
+ App.closeWindow('dashboardmenu');
+ Utils.execAsync(`bash -c "${term} -e btop"`).catch(
+ (err) => `Failed to open btop: ${err}`,
+ );
+ };
+ }),
+ child: Widget.LevelBar({
+ class_name: 'stats-bar',
+ hexpand: true,
+ vpack: 'center',
+ bar_mode: 'continuous',
+ max_value: 1,
+ value: cpu.bind('value'),
+ }),
+ }),
+ ],
+ }),
+ Widget.Label({
+ hpack: 'end',
+ class_name: 'stat-value cpu',
+ label: cpu.bind('value').as((v) => `${Math.floor(v * 100)}%`),
+ }),
+ ],
+ }),
+ Widget.Box({
+ vertical: true,
+ children: [
+ Widget.Box({
+ class_name: 'stat ram',
+ vpack: 'center',
+ hexpand: true,
+ children: [
+ Widget.Button({
+ on_primary_click: terminal.bind('value').as((term) => {
+ return () => {
+ App.closeWindow('dashboardmenu');
+ Utils.execAsync(`bash -c "${term} -e btop"`).catch(
+ (err) => `Failed to open btop: ${err}`,
+ );
+ };
+ }),
+ child: Widget.Label({
+ class_name: 'txt-icon',
+ label: '',
+ }),
+ }),
+ Widget.Button({
+ on_primary_click: terminal.bind('value').as((term) => {
+ return () => {
+ App.closeWindow('dashboardmenu');
+ Utils.execAsync(`bash -c "${term} -e btop"`).catch(
+ (err) => `Failed to open btop: ${err}`,
+ );
+ };
+ }),
+ child: Widget.LevelBar({
+ class_name: 'stats-bar',
+ hexpand: true,
+ vpack: 'center',
+ value: ram.bind('value').as((v) => v.percentage),
+ }),
+ }),
+ ],
+ }),
+ Widget.Label({
+ hpack: 'end',
+ class_name: 'stat-value ram',
+ label: ram.bind('value').as((v) => `${v.used}/${v.total} GB`),
+ }),
+ ],
+ }),
+ GPUStat,
+ Widget.Box({
+ vertical: true,
+ children: [
+ Widget.Box({
+ class_name: 'stat storage',
+ hexpand: true,
+ vpack: 'center',
+ children: [
+ Widget.Button({
+ on_primary_click: terminal.bind('value').as((term) => {
+ return () => {
+ App.closeWindow('dashboardmenu');
+ Utils.execAsync(`bash -c "${term} -e btop"`).catch(
+ (err) => `Failed to open btop: ${err}`,
+ );
+ };
+ }),
+ child: Widget.Label({
+ class_name: 'txt-icon',
+ label: '',
+ }),
+ }),
+ Widget.Button({
+ on_primary_click: terminal.bind('value').as((term) => {
+ return () => {
+ App.closeWindow('dashboardmenu');
+ Utils.execAsync(`bash -c "${term} -e btop"`).catch(
+ (err) => `Failed to open btop: ${err}`,
+ );
+ };
+ }),
+ child: Widget.LevelBar({
+ class_name: 'stats-bar',
+ hexpand: true,
+ vpack: 'center',
+ value: storage.bind('value').as((v) => v.percentage),
+ }),
+ }),
+ ],
+ }),
+ Widget.Label({
+ hpack: 'end',
+ class_name: 'stat-value storage',
+ label: storage.bind('value').as((v) => `${v.used}/${v.total} GB`),
+ }),
+ ],
+ }),
+ ],
+ });
+};
+
+export { Stats };
diff --git a/ags/HyprPanel/modules/menus/energy/brightness/index.ts b/ags/HyprPanel/modules/menus/energy/brightness/index.ts
new file mode 100644
index 0000000..cdefd1a
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/energy/brightness/index.ts
@@ -0,0 +1,58 @@
+import { BoxWidget } from 'lib/types/widget.js';
+import brightness from '../../../../services/Brightness.js';
+import icons from '../../../icons/index.js';
+
+const Brightness = (): BoxWidget => {
+ return Widget.Box({
+ class_name: 'menu-section-container brightness',
+ vertical: true,
+ children: [
+ Widget.Box({
+ class_name: 'menu-label-container',
+ hpack: 'fill',
+ child: Widget.Label({
+ class_name: 'menu-label',
+ hexpand: true,
+ hpack: 'start',
+ label: 'Brightness',
+ }),
+ }),
+ Widget.Box({
+ class_name: 'menu-items-section',
+ vpack: 'fill',
+ vexpand: true,
+ vertical: true,
+ child: Widget.Box({
+ class_name: 'brightness-container',
+ children: [
+ Widget.Icon({
+ vexpand: true,
+ vpack: 'center',
+ class_name: 'brightness-slider-icon',
+ icon: icons.brightness.screen,
+ }),
+ Widget.Slider({
+ vpack: 'center',
+ vexpand: true,
+ value: brightness.bind('screen'),
+ class_name: 'menu-active-slider menu-slider brightness',
+ draw_value: false,
+ hexpand: true,
+ min: 0,
+ max: 1,
+ onChange: ({ value }) => (brightness.screen = value),
+ }),
+ Widget.Label({
+ vpack: 'center',
+ vexpand: true,
+ class_name: 'brightness-slider-label',
+ label: brightness.bind('screen').as((b) => `${Math.round(b * 100)}%`),
+ }),
+ ],
+ }),
+ }),
+ ],
+ });
+};
+
+export { Brightness };
diff --git a/ags/HyprPanel/modules/menus/energy/index.ts b/ags/HyprPanel/modules/menus/energy/index.ts
new file mode 100644
index 0000000..134d2cc
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/energy/index.ts
@@ -0,0 +1,25 @@
+import DropdownMenu from '../shared/dropdown/index.js';
+import { EnergyProfiles } from './profiles/index.js';
+import { Brightness } from './brightness/index.js';
+import { Attribute, Child } from 'lib/types/widget.js';
+import Window from 'types/widgets/window.js';
+import options from 'options.js';
+
+export default (): Window => {
+ return DropdownMenu({
+ name: 'energymenu',
+ transition: options.menus.transition.bind('value'),
+ child: Widget.Box({
+ class_name: 'menu-items energy',
+ hpack: 'fill',
+ hexpand: true,
+ child: Widget.Box({
+ vertical: true,
+ hpack: 'fill',
+ hexpand: true,
+ class_name: 'menu-items-container energy',
+ children: [Brightness(), EnergyProfiles()],
+ }),
+ }),
+ });
+};
diff --git a/ags/HyprPanel/modules/menus/energy/profiles/index.ts b/ags/HyprPanel/modules/menus/energy/profiles/index.ts
new file mode 100644
index 0000000..72b2824
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/energy/profiles/index.ts
@@ -0,0 +1,85 @@
+const powerProfiles = await Service.import('powerprofiles');
+import { PowerProfile, PowerProfileObject, PowerProfiles } from 'lib/types/powerprofiles.js';
+import { BoxWidget } from 'lib/types/widget.js';
+import icons from '../../../icons/index.js';
+import { uptime } from 'lib/variables.js';
+
+const EnergyProfiles = (): BoxWidget => {
+ const isValidProfile = (profile: string): profile is PowerProfile =>
+ profile === 'power-saver' || profile === 'balanced' || profile === 'performance';
+
+ function renderUptime(curUptime: number): string {
+ const days = Math.floor(curUptime / (60 * 24));
+ const hours = Math.floor((curUptime % (60 * 24)) / 60);
+ const minutes = Math.floor(curUptime % 60);
+ return ` : ${days}d ${hours}h ${minutes}m`;
+ }
+
+ return Widget.Box({
+ class_name: 'menu-section-container energy',
+ vertical: true,
+ children: [
+ Widget.Box({
+ class_name: 'menu-label-container',
+ hpack: 'fill',
+ children: [
+ Widget.Label({
+ class_name: 'menu-label',
+ hexpand: true,
+ hpack: 'start',
+ label: 'Power Profile',
+ }),
+ Widget.Label({
+ class_name: 'menu-label uptime',
+ label: uptime.bind().as(renderUptime),
+ tooltipText: 'Uptime',
+ }),
+ ],
+ }),
+ Widget.Box({
+ class_name: 'menu-items-section',
+ vpack: 'fill',
+ vexpand: true,
+ vertical: true,
+ children: powerProfiles.bind('profiles').as((profiles: PowerProfiles) => {
+ return profiles.map((prof: PowerProfileObject) => {
+ const profileLabels = {
+ 'power-saver': 'Power Saver',
+ balanced: 'Balanced',
+ performance: 'Performance',
+ };
+
+ const profileType = prof.Profile;
+
+ if (!isValidProfile(profileType)) {
+ return profileLabels.balanced;
+ }
+
+ return Widget.Button({
+ on_primary_click: () => {
+ powerProfiles.active_profile = prof.Profile;
+ },
+ class_name: powerProfiles.bind('active_profile').as((active) => {
+ return `power-profile-item ${active === prof.Profile ? 'active' : ''}`;
+ }),
+ child: Widget.Box({
+ children: [
+ Widget.Icon({
+ class_name: 'power-profile-icon',
+ icon: icons.powerprofile[profileType],
+ }),
+ Widget.Label({
+ class_name: 'power-profile-label',
+ label: profileLabels[profileType],
+ }),
+ ],
+ }),
+ });
+ });
+ }),
+ }),
+ ],
+ });
+};
+
+export { EnergyProfiles };
diff --git a/ags/HyprPanel/modules/menus/main.ts b/ags/HyprPanel/modules/menus/main.ts
new file mode 100644
index 0000000..274579f
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/main.ts
@@ -0,0 +1,25 @@
+import PowerMenu from './power/index.js';
+import Verification from './power/verification.js';
+import AudioMenu from './audio/index.js';
+import NetworkMenu from './network/index.js';
+import BluetoothMenu from './bluetooth/index.js';
+import MediaMenu from './media/index.js';
+import NotificationsMenu from './notifications/index.js';
+import CalendarMenu from './calendar/index.js';
+import EnergyMenu from './energy/index.js';
+import DashboardMenu from './dashboard/index.js';
+import PowerDropdown from './powerDropdown/index.js';
+
+export default [
+ PowerMenu(),
+ Verification(),
+ AudioMenu(),
+ NetworkMenu(),
+ BluetoothMenu(),
+ MediaMenu(),
+ NotificationsMenu(),
+ CalendarMenu(),
+ EnergyMenu(),
+ DashboardMenu(),
+ PowerDropdown(),
+];
diff --git a/ags/HyprPanel/modules/menus/media/components/bar.ts b/ags/HyprPanel/modules/menus/media/components/bar.ts
new file mode 100644
index 0000000..81448db
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/media/components/bar.ts
@@ -0,0 +1,71 @@
+import { BoxWidget } from 'lib/types/widget';
+import { Mpris, MprisPlayer } from 'types/service/mpris';
+
+const media = await Service.import('mpris');
+
+const Bar = (getPlayerInfo: (media: Mpris) => MprisPlayer): BoxWidget => {
+ return Widget.Box({
+ class_name: 'media-indicator-current-progress-bar',
+ hexpand: true,
+ children: [
+ Widget.Box({
+ hexpand: true,
+ child: Widget.Slider({
+ hexpand: true,
+ tooltip_text: '--',
+ class_name: 'menu-slider media progress',
+ draw_value: false,
+ on_change: ({ value }) => {
+ const foundPlayer = getPlayerInfo(media);
+ if (foundPlayer === undefined) {
+ return;
+ }
+ return (foundPlayer.position = value * foundPlayer.length);
+ },
+ setup: (self) => {
+ const update = (): void => {
+ const foundPlayer = getPlayerInfo(media);
+ if (foundPlayer !== undefined) {
+ const value = foundPlayer.length ? foundPlayer.position / foundPlayer.length : 0;
+ self.value = value > 0 ? value : 0;
+ } else {
+ self.value = 0;
+ }
+ };
+ self.hook(media, update);
+ self.poll(1000, update);
+
+ const updateTooltip = (): void => {
+ const foundPlayer = getPlayerInfo(media);
+ if (foundPlayer === undefined) {
+ self.tooltip_text = '00:00';
+ return;
+ }
+ const curHour = Math.floor(foundPlayer.position / 3600);
+ const curMin = Math.floor((foundPlayer.position % 3600) / 60);
+ const curSec = Math.floor(foundPlayer.position % 60);
+
+ if (typeof foundPlayer.position === 'number' && foundPlayer.position >= 0) {
+ const formatTime = (time: number): string => {
+ return time.toString().padStart(2, '0');
+ };
+
+ const formatHour = (hour: number): string => {
+ return hour > 0 ? formatTime(hour) + ':' : '';
+ };
+
+ self.tooltip_text = `${formatHour(curHour)}${formatTime(curMin)}:${formatTime(curSec)}`;
+ } else {
+ self.tooltip_text = `00:00`;
+ }
+ };
+ self.poll(1000, updateTooltip);
+ self.hook(media, updateTooltip);
+ },
+ }),
+ }),
+ ],
+ });
+};
+
+export { Bar };
diff --git a/ags/HyprPanel/modules/menus/media/components/controls.ts b/ags/HyprPanel/modules/menus/media/components/controls.ts
new file mode 100644
index 0000000..5cbce21
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/media/components/controls.ts
@@ -0,0 +1,198 @@
+import { MprisPlayer } from 'types/service/mpris.js';
+import icons from '../../../icons/index.js';
+import { LoopStatus, PlaybackStatus } from 'lib/types/mpris.js';
+import { BoxWidget } from 'lib/types/widget.js';
+const media = await Service.import('mpris');
+
+const Controls = (getPlayerInfo: () => MprisPlayer): BoxWidget => {
+ const isValidLoopStatus = (status: string): status is LoopStatus => ['none', 'track', 'playlist'].includes(status);
+
+ const isValidPlaybackStatus = (status: string): status is PlaybackStatus =>
+ ['playing', 'paused', 'stopped'].includes(status);
+
+ const isLoopActive = (player: MprisPlayer): string => {
+ return player['loop_status'] !== null && ['track', 'playlist'].includes(player['loop_status'].toLowerCase())
+ ? 'active'
+ : '';
+ };
+
+ const isShuffleActive = (player: MprisPlayer): string => {
+ return player['shuffle_status'] !== null && player['shuffle_status'] ? 'active' : '';
+ };
+
+ return Widget.Box({
+ class_name: 'media-indicator-current-player-controls',
+ vertical: true,
+ children: [
+ Widget.Box({
+ class_name: 'media-indicator-current-controls',
+ hpack: 'center',
+ children: [
+ Widget.Box({
+ class_name: 'media-indicator-control shuffle',
+ children: [
+ Widget.Button({
+ hpack: 'center',
+ hasTooltip: true,
+ setup: (self) => {
+ self.hook(media, () => {
+ const foundPlayer = getPlayerInfo();
+ if (foundPlayer === undefined) {
+ self.tooltip_text = 'Unavailable';
+ self.class_name = 'media-indicator-control-button shuffle disabled';
+ return;
+ }
+
+ self.tooltip_text =
+ foundPlayer.shuffle_status !== null
+ ? foundPlayer.shuffle_status
+ ? 'Shuffling'
+ : 'Not Shuffling'
+ : null;
+ self.on_primary_click = (): void => {
+ foundPlayer.shuffle();
+ };
+ self.class_name = `media-indicator-control-button shuffle ${isShuffleActive(foundPlayer)} ${foundPlayer.shuffle_status !== null ? 'enabled' : 'disabled'}`;
+ });
+ },
+ child: Widget.Icon(icons.mpris.shuffle['enabled']),
+ }),
+ ],
+ }),
+ Widget.Box({
+ children: [
+ Widget.Button({
+ hpack: 'center',
+ child: Widget.Icon(icons.mpris.prev),
+ setup: (self) => {
+ self.hook(media, () => {
+ const foundPlayer = getPlayerInfo();
+ if (foundPlayer === undefined) {
+ self.class_name = 'media-indicator-control-button prev disabled';
+ return;
+ }
+
+ self.on_primary_click = (): void => {
+ foundPlayer.previous();
+ };
+ self.class_name = `media-indicator-control-button prev ${foundPlayer.can_go_prev !== null && foundPlayer.can_go_prev ? 'enabled' : 'disabled'}`;
+ });
+ },
+ }),
+ ],
+ }),
+ Widget.Box({
+ children: [
+ Widget.Button({
+ hpack: 'center',
+ setup: (self) => {
+ self.hook(media, () => {
+ const foundPlayer = getPlayerInfo();
+ if (foundPlayer === undefined) {
+ self.class_name = 'media-indicator-control-button play disabled';
+ return;
+ }
+
+ self.on_primary_click = (): void => {
+ foundPlayer.playPause();
+ };
+ self.class_name = `media-indicator-control-button play ${foundPlayer.can_play !== null ? 'enabled' : 'disabled'}`;
+ });
+ },
+ child: Widget.Icon({
+ icon: Utils.watch(icons.mpris.paused, media, 'changed', () => {
+ const foundPlayer: MprisPlayer = getPlayerInfo();
+ if (foundPlayer === undefined) {
+ return icons.mpris['paused'];
+ }
+ const playbackStatus = foundPlayer.play_back_status?.toLowerCase();
+
+ if (playbackStatus && isValidPlaybackStatus(playbackStatus)) {
+ return icons.mpris[playbackStatus];
+ } else {
+ return icons.mpris['paused'];
+ }
+ }),
+ }),
+ }),
+ ],
+ }),
+ Widget.Box({
+ class_name: `media-indicator-control next`,
+ children: [
+ Widget.Button({
+ hpack: 'center',
+ child: Widget.Icon(icons.mpris.next),
+ setup: (self) => {
+ self.hook(media, () => {
+ const foundPlayer = getPlayerInfo();
+ if (foundPlayer === undefined) {
+ self.class_name = 'media-indicator-control-button next disabled';
+ return;
+ }
+
+ self.on_primary_click = (): void => {
+ foundPlayer.next();
+ };
+ self.class_name = `media-indicator-control-button next ${foundPlayer.can_go_next !== null && foundPlayer.can_go_next ? 'enabled' : 'disabled'}`;
+ });
+ },
+ }),
+ ],
+ }),
+ Widget.Box({
+ class_name: 'media-indicator-control loop',
+ children: [
+ Widget.Button({
+ hpack: 'center',
+ setup: (self) => {
+ self.hook(media, () => {
+ const foundPlayer = getPlayerInfo();
+ if (foundPlayer === undefined) {
+ self.tooltip_text = 'Unavailable';
+ self.class_name = 'media-indicator-control-button shuffle disabled';
+ return;
+ }
+
+ self.tooltip_text =
+ foundPlayer.loop_status !== null
+ ? foundPlayer.loop_status
+ ? 'Shuffling'
+ : 'Not Shuffling'
+ : null;
+ self.on_primary_click = (): void => {
+ foundPlayer.loop();
+ };
+ self.class_name = `media-indicator-control-button loop ${isLoopActive(foundPlayer)} ${foundPlayer.loop_status !== null ? 'enabled' : 'disabled'}`;
+ });
+ },
+ child: Widget.Icon({
+ setup: (self) => {
+ self.hook(media, () => {
+ const foundPlayer: MprisPlayer = getPlayerInfo();
+
+ if (foundPlayer === undefined) {
+ self.icon = icons.mpris.loop['none'];
+ return;
+ }
+
+ const loopStatus = foundPlayer.loop_status?.toLowerCase();
+
+ if (loopStatus && isValidLoopStatus(loopStatus)) {
+ self.icon = icons.mpris.loop[loopStatus];
+ } else {
+ self.icon = icons.mpris.loop['none'];
+ }
+ });
+ },
+ }),
+ }),
+ ],
+ }),
+ ],
+ }),
+ ],
+ });
+};
+
+export { Controls };
diff --git a/ags/HyprPanel/modules/menus/media/components/mediainfo.ts b/ags/HyprPanel/modules/menus/media/components/mediainfo.ts
new file mode 100644
index 0000000..ea6cb76
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/media/components/mediainfo.ts
@@ -0,0 +1,89 @@
+import { BoxWidget } from 'lib/types/widget';
+import { MprisPlayer } from 'types/service/mpris';
+
+const media = await Service.import('mpris');
+
+const MediaInfo = (getPlayerInfo: () => MprisPlayer): BoxWidget => {
+ return Widget.Box({
+ class_name: 'media-indicator-current-media-info',
+ hpack: 'center',
+ hexpand: true,
+ vertical: true,
+ children: [
+ Widget.Box({
+ class_name: 'media-indicator-current-song-name',
+ hpack: 'center',
+ children: [
+ Widget.Label({
+ truncate: 'end',
+ max_width_chars: 31,
+ wrap: true,
+ class_name: 'media-indicator-current-song-name-label',
+ setup: (self) => {
+ self.hook(media, () => {
+ const curPlayer = getPlayerInfo();
+ return (self.label =
+ curPlayer !== undefined && curPlayer['track_title'].length
+ ? curPlayer['track_title']
+ : 'No Media Currently Playing');
+ });
+ },
+ }),
+ ],
+ }),
+ Widget.Box({
+ class_name: 'media-indicator-current-song-author',
+ hpack: 'center',
+ children: [
+ Widget.Label({
+ truncate: 'end',
+ wrap: true,
+ max_width_chars: 35,
+ class_name: 'media-indicator-current-song-author-label',
+ setup: (self) => {
+ self.hook(media, () => {
+ const curPlayer = getPlayerInfo();
+
+ const makeArtistList = (trackArtists: string[]): string => {
+ if (trackArtists.length === 1 && !trackArtists[0].length) {
+ return '-----';
+ }
+
+ return trackArtists.join(', ');
+ };
+
+ return (self.label =
+ curPlayer !== undefined && curPlayer['track_artists'].length
+ ? makeArtistList(curPlayer['track_artists'])
+ : '-----');
+ });
+ },
+ }),
+ ],
+ }),
+ Widget.Box({
+ class_name: 'media-indicator-current-song-album',
+ hpack: 'center',
+ children: [
+ Widget.Label({
+ truncate: 'end',
+ wrap: true,
+ max_width_chars: 40,
+ class_name: 'media-indicator-current-song-album-label',
+ setup: (self) => {
+ self.hook(media, () => {
+ const curPlayer = getPlayerInfo();
+ return (self.label =
+ curPlayer !== undefined && curPlayer['track_album'].length
+ ? curPlayer['track_album']
+ : '---');
+ });
+ },
+ }),
+ ],
+ }),
+ ],
+ });
+};
+
+export { MediaInfo };
diff --git a/ags/HyprPanel/modules/menus/media/index.ts b/ags/HyprPanel/modules/menus/media/index.ts
new file mode 100644
index 0000000..489034d
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/media/index.ts
@@ -0,0 +1,23 @@
+import Window from 'types/widgets/window.js';
+import DropdownMenu from '../shared/dropdown/index.js';
+import { Media } from './media.js';
+import { Attribute, Child } from 'lib/types/widget.js';
+import options from 'options.js';
+
+export default (): Window => {
+ return DropdownMenu({
+ name: 'mediamenu',
+ transition: options.menus.transition.bind('value'),
+ child: Widget.Box({
+ class_name: 'menu-items media',
+ hpack: 'fill',
+ hexpand: true,
+ child: Widget.Box({
+ class_name: 'menu-items-container media',
+ hpack: 'fill',
+ hexpand: true,
+ child: Media(),
+ }),
+ }),
+ });
+};
diff --git a/ags/HyprPanel/modules/menus/media/media.ts b/ags/HyprPanel/modules/menus/media/media.ts
new file mode 100644
index 0000000..2035516
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/media/media.ts
@@ -0,0 +1,97 @@
+const media = await Service.import('mpris');
+import { MediaInfo } from './components/mediainfo.js';
+import { Controls } from './components/controls.js';
+import { Bar } from './components/bar.js';
+import { MprisPlayer } from 'types/service/mpris.js';
+import options from 'options.js';
+import { BoxWidget } from 'lib/types/widget.js';
+
+const { tint, color } = options.theme.bar.menus.menu.media.card;
+
+const generateAlbumArt = (imageUrl: string): string => {
+ const userTint = tint.value;
+ const userHexColor = color.value;
+
+ const r = parseInt(userHexColor.slice(1, 3), 16);
+ const g = parseInt(userHexColor.slice(3, 5), 16);
+ const b = parseInt(userHexColor.slice(5, 7), 16);
+
+ const alpha = userTint / 100;
+
+ const css = `background-image: linear-gradient(
+ rgba(${r}, ${g}, ${b}, ${alpha}),
+ rgba(${r}, ${g}, ${b}, ${alpha}),
+ ${userHexColor} 65em
+ ), url("${imageUrl}");`;
+
+ return css;
+};
+const Media = (): BoxWidget => {
+ const curPlayer = Variable('');
+
+ media.connect('changed', () => {
+ const statusOrder = {
+ Playing: 1,
+ Paused: 2,
+ Stopped: 3,
+ };
+
+ const isPlaying = media.players.find((p) => p['play_back_status'] === 'Playing');
+
+ const playerStillExists = media.players.some((p) => curPlayer.value === p['bus_name']);
+
+ const nextPlayerUp = media.players.sort(
+ (a, b) => statusOrder[a['play_back_status']] - statusOrder[b['play_back_status']],
+ )[0].bus_name;
+
+ if (isPlaying || !playerStillExists) {
+ curPlayer.value = nextPlayerUp;
+ }
+ });
+
+ const getPlayerInfo = (): MprisPlayer => {
+ return media.players.find((p) => p.bus_name === curPlayer.value) || media.players[0];
+ };
+
+ return Widget.Box({
+ class_name: 'menu-section-container',
+ children: [
+ Widget.Box({
+ class_name: 'menu-items-section',
+ vertical: false,
+ child: Widget.Box({
+ class_name: 'menu-content',
+ children: [
+ Widget.Box({
+ class_name: 'media-content',
+ child: Widget.Box({
+ class_name: 'media-indicator-right-section',
+ hpack: 'fill',
+ hexpand: true,
+ vertical: true,
+ children: [MediaInfo(getPlayerInfo), Controls(getPlayerInfo), Bar(getPlayerInfo)],
+ }),
+ }),
+ ],
+ setup: (self) => {
+ self.hook(media, () => {
+ const curPlayer = getPlayerInfo();
+ if (curPlayer !== undefined) {
+ self.css = generateAlbumArt(curPlayer.track_cover_url);
+ }
+ });
+
+ Utils.merge([color.bind('value'), tint.bind('value')], () => {
+ const curPlayer = getPlayerInfo();
+ if (curPlayer !== undefined) {
+ self.css = generateAlbumArt(curPlayer.track_cover_url);
+ }
+ });
+ },
+ }),
+ }),
+ ],
+ });
+};
+
+export { Media };
diff --git a/ags/HyprPanel/modules/menus/network/ethernet/index.ts b/ags/HyprPanel/modules/menus/network/ethernet/index.ts
new file mode 100644
index 0000000..e1bf0d0
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/network/ethernet/index.ts
@@ -0,0 +1,69 @@
+import { BoxWidget } from 'lib/types/widget';
+
+const network = await Service.import('network');
+
+const Ethernet = (): BoxWidget => {
+ return Widget.Box({
+ class_name: 'menu-section-container ethernet',
+ vertical: true,
+ children: [
+ Widget.Box({
+ class_name: 'menu-label-container',
+ hpack: 'fill',
+ child: Widget.Label({
+ class_name: 'menu-label',
+ hexpand: true,
+ hpack: 'start',
+ label: 'Ethernet',
+ }),
+ }),
+ Widget.Box({
+ class_name: 'menu-items-section',
+ vertical: true,
+ child: Widget.Box({
+ class_name: 'menu-content',
+ vertical: true,
+ setup: (self) => {
+ self.hook(network, () => {
+ return (self.child = Widget.Box({
+ class_name: 'network-element-item',
+ child: Widget.Box({
+ hpack: 'start',
+ children: [
+ Widget.Icon({
+ class_name: `network-icon ethernet ${network.wired.state === 'activated' ? 'active' : ''}`,
+ tooltip_text: network.wired.internet,
+ icon: `${network.wired['icon_name']}`,
+ }),
+ Widget.Box({
+ class_name: 'connection-container',
+ vertical: true,
+ children: [
+ Widget.Label({
+ class_name: 'active-connection',
+ hpack: 'start',
+ truncate: 'end',
+ wrap: true,
+ label: `Ethernet Connection ${network.wired.state !== 'unknown' && typeof network.wired?.speed === 'number' ? `(${network.wired?.speed / 1000} Gbps)` : ''}`,
+ }),
+ Widget.Label({
+ hpack: 'start',
+ class_name: 'connection-status dim',
+ label:
+ network.wired.internet.charAt(0).toUpperCase() +
+ network.wired.internet.slice(1),
+ }),
+ ],
+ }),
+ ],
+ }),
+ }));
+ });
+ },
+ }),
+ }),
+ ],
+ });
+};
+
+export { Ethernet };
diff --git a/ags/HyprPanel/modules/menus/network/index.ts b/ags/HyprPanel/modules/menus/network/index.ts
new file mode 100644
index 0000000..f3e88dc
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/network/index.ts
@@ -0,0 +1,22 @@
+import Window from 'types/widgets/window.js';
+import DropdownMenu from '../shared/dropdown/index.js';
+import { Ethernet } from './ethernet/index.js';
+import { Wifi } from './wifi/index.js';
+import { Attribute, Child } from 'lib/types/widget.js';
+import options from 'options.js';
+
+export default (): Window => {
+ return DropdownMenu({
+ name: 'networkmenu',
+ transition: options.menus.transition.bind('value'),
+ child: Widget.Box({
+ class_name: 'menu-items network',
+ child: Widget.Box({
+ vertical: true,
+ hexpand: true,
+ class_name: 'menu-items-container network',
+ children: [Ethernet(), Wifi()],
+ }),
+ }),
+ });
+};
diff --git a/ags/HyprPanel/modules/menus/network/utils.ts b/ags/HyprPanel/modules/menus/network/utils.ts
new file mode 100644
index 0000000..229189a
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/network/utils.ts
@@ -0,0 +1,23 @@
+import { WifiIcon } from 'lib/types/network';
+
+const getWifiIcon = (iconName: string): WifiIcon => {
+ const deviceIconMap: [string, WifiIcon][] = [
+ ['network-wireless-acquiring', ''],
+ ['network-wireless-connected', ''],
+ ['network-wireless-encrypted', ''],
+ ['network-wireless-hotspot', ''],
+ ['network-wireless-no-route', ''],
+ ['network-wireless-offline', ''],
+ ['network-wireless-signal-excellent', ''],
+ ['network-wireless-signal-good', ''],
+ ['network-wireless-signal-ok', ''],
+ ['network-wireless-signal-weak', ''],
+ ['network-wireless-signal-none', ''],
+ ];
+
+ const foundMatch = deviceIconMap.find((icon) => RegExp(icon[0]).test(iconName.toLowerCase()));
+
+ return foundMatch ? foundMatch[1] : '';
+};
+
+export { getWifiIcon };
diff --git a/ags/HyprPanel/modules/menus/network/wifi/APStaging.ts b/ags/HyprPanel/modules/menus/network/wifi/APStaging.ts
new file mode 100644
index 0000000..29b2798
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/network/wifi/APStaging.ts
@@ -0,0 +1,104 @@
+import { Network } from 'types/service/network';
+import { Variable } from 'types/variable';
+import { AccessPoint } from 'lib/types/network';
+import Box from 'types/widgets/box';
+import { Attribute, Child } from 'lib/types/widget';
+
+const renderWapStaging = (
+ self: Box,
+ network: Network,
+ staging: Variable,
+ connecting: Variable,
+): void => {
+ Utils.merge([network.bind('wifi'), staging.bind('value')], () => {
+ if (!Object.keys(staging.value).length) {
+ return (self.child = Widget.Box());
+ }
+
+ return (self.child = Widget.Box({
+ class_name: 'network-element-item staging',
+ vertical: true,
+ children: [
+ Widget.Box({
+ hpack: 'fill',
+ hexpand: true,
+ children: [
+ Widget.Icon({
+ class_name: `network-icon wifi`,
+ icon: `${staging.value.iconName}`,
+ }),
+ Widget.Box({
+ class_name: 'connection-container',
+ hexpand: true,
+ vertical: true,
+ children: [
+ Widget.Label({
+ class_name: 'active-connection',
+ hpack: 'start',
+ truncate: 'end',
+ wrap: true,
+ label: staging.value.ssid,
+ }),
+ ],
+ }),
+ Widget.Revealer({
+ hpack: 'end',
+ reveal_child: connecting.bind('value').as((c) => staging.value.bssid === c),
+ child: Widget.Spinner({
+ class_name: 'spinner wap',
+ }),
+ }),
+ ],
+ }),
+ Widget.Box({
+ class_name: 'network-password-input-container',
+ hpack: 'fill',
+ hexpand: true,
+ children: [
+ Widget.Entry({
+ hpack: 'start',
+ hexpand: true,
+ visibility: false,
+ class_name: 'network-password-input',
+ placeholder_text: 'enter password',
+ onAccept: (selfInp) => {
+ connecting.value = staging.value.bssid || '';
+ Utils.execAsync(
+ `nmcli dev wifi connect ${staging.value.bssid} password ${selfInp.text}`,
+ )
+ .catch((err) => {
+ connecting.value = '';
+ console.error(`Failed to connect to wifi: ${staging.value.ssid}... ${err}`);
+ Utils.notify({
+ summary: 'Network',
+ body: err,
+ timeout: 5000,
+ });
+ })
+ .then(() => {
+ connecting.value = '';
+ staging.value = {} as AccessPoint;
+ });
+ selfInp.text = '';
+ },
+ }),
+ Widget.Button({
+ hpack: 'end',
+ class_name: 'close-network-password-input-button',
+ on_primary_click: () => {
+ connecting.value = '';
+ staging.value = {} as AccessPoint;
+ },
+ child: Widget.Icon({
+ class_name: 'close-network-password-input-icon',
+ icon: 'window-close-symbolic',
+ }),
+ }),
+ ],
+ }),
+ ],
+ }));
+ });
+};
+
+export { renderWapStaging };
diff --git a/ags/HyprPanel/modules/menus/network/wifi/WirelessAPs.ts b/ags/HyprPanel/modules/menus/network/wifi/WirelessAPs.ts
new file mode 100644
index 0000000..fac891d
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/network/wifi/WirelessAPs.ts
@@ -0,0 +1,219 @@
+import { Network } from 'types/service/network.js';
+import { AccessPoint, WifiStatus } from 'lib/types/network.js';
+import { Variable } from 'types/variable.js';
+import { getWifiIcon } from '../utils.js';
+import { WIFI_STATUS_MAP } from 'globals/network.js';
+import { Attribute, Child } from 'lib/types/widget.js';
+import Box from 'types/widgets/box.js';
+const renderWAPs = (
+ self: Box,
+ network: Network,
+ staging: Variable,
+ connecting: Variable,
+): void => {
+ const getIdBySsid = (ssid: string, nmcliOutput: string): string | undefined => {
+ const lines = nmcliOutput.trim().split('\n');
+ for (const line of lines) {
+ const columns = line.trim().split(/\s{2,}/);
+ if (columns[0].includes(ssid)) {
+ return columns[1];
+ }
+ }
+ };
+
+ const isValidWifiStatus = (status: string): status is WifiStatus => {
+ return status in WIFI_STATUS_MAP;
+ };
+
+ const getWifiStatus = (): string => {
+ const wifiState = network.wifi.state?.toLowerCase();
+
+ if (wifiState && isValidWifiStatus(wifiState)) {
+ return WIFI_STATUS_MAP[wifiState];
+ }
+ return WIFI_STATUS_MAP['unknown'];
+ };
+
+ self.hook(network, () => {
+ Utils.merge([staging.bind('value'), connecting.bind('value')], () => {
+ // NOTE: Sometimes the network service will yield a "this._device is undefined" when
+ // trying to access the "access_points" property. So we must validate that
+ // it's not 'undefined'
+ // --
+ // Also this is an AGS bug that needs to be fixed
+
+ // TODO: Remove @ts-ignore once AGS bug is fixed
+ // @ts-expect-error to fix AGS bug
+ let WAPs = network.wifi._device !== undefined ? network.wifi['access_points'] : [];
+
+ const dedupeWAPs = (): AccessPoint[] => {
+ const dedupMap: Record = {};
+ WAPs.forEach((item: AccessPoint) => {
+ if (item.ssid !== null && !Object.hasOwnProperty.call(dedupMap, item.ssid)) {
+ dedupMap[item.ssid] = item;
+ }
+ });
+
+ return Object.keys(dedupMap).map((itm) => dedupMap[itm]);
+ };
+
+ WAPs = dedupeWAPs();
+
+ const isInStaging = (wap: AccessPoint): boolean => {
+ if (Object.keys(staging.value).length === 0) {
+ return false;
+ }
+
+ return wap.bssid === staging.value.bssid;
+ };
+
+ const isDisconnecting = (wap: AccessPoint): boolean => {
+ if (wap.ssid === network.wifi.ssid) {
+ return network.wifi.state.toLowerCase() === 'deactivating';
+ }
+ return false;
+ };
+
+ const filteredWAPs = WAPs.filter((ap: AccessPoint) => {
+ return ap.ssid !== 'Unknown' && !isInStaging(ap);
+ }).sort((a: AccessPoint, b: AccessPoint) => {
+ if (network.wifi.ssid === a.ssid) {
+ return -1;
+ }
+
+ if (network.wifi.ssid === b.ssid) {
+ return 1;
+ }
+
+ return b.strength - a.strength;
+ });
+
+ if (filteredWAPs.length <= 0 && Object.keys(staging.value).length === 0) {
+ return (self.child = Widget.Label({
+ class_name: 'waps-not-found dim',
+ expand: true,
+ hpack: 'center',
+ vpack: 'center',
+ label: 'No Wi-Fi Networks Found',
+ }));
+ }
+ return (self.children = filteredWAPs.map((ap: AccessPoint) => {
+ return Widget.Box({
+ children: [
+ Widget.Button({
+ on_primary_click: () => {
+ if (ap.bssid === connecting.value || ap.active) {
+ return;
+ }
+
+ connecting.value = ap.bssid || '';
+ Utils.execAsync(`nmcli device wifi connect ${ap.bssid}`)
+ .then(() => {
+ connecting.value = '';
+ staging.value = {} as AccessPoint;
+ })
+ .catch((err) => {
+ if (err.toLowerCase().includes('secrets were required, but not provided')) {
+ staging.value = ap;
+ } else {
+ Utils.notify({
+ summary: 'Network',
+ body: err,
+ timeout: 5000,
+ });
+ }
+ connecting.value = '';
+ });
+ },
+ class_name: 'network-element-item',
+ child: Widget.Box({
+ hexpand: true,
+ children: [
+ Widget.Box({
+ hpack: 'start',
+ hexpand: true,
+ children: [
+ Widget.Label({
+ vpack: 'start',
+ class_name: `network-icon wifi ${ap.ssid === network.wifi.ssid ? 'active' : ''} txt-icon`,
+ label: getWifiIcon(`${ap['iconName']}`),
+ }),
+ Widget.Box({
+ class_name: 'connection-container',
+ vpack: 'center',
+ vertical: true,
+ children: [
+ Widget.Label({
+ vpack: 'center',
+ class_name: 'active-connection',
+ hpack: 'start',
+ truncate: 'end',
+ wrap: true,
+ label: ap.ssid,
+ }),
+ Widget.Revealer({
+ revealChild: ap.ssid === network.wifi.ssid,
+ child: Widget.Label({
+ hpack: 'start',
+ class_name: 'connection-status dim',
+ label: getWifiStatus(),
+ }),
+ }),
+ ],
+ }),
+ ],
+ }),
+ Widget.Revealer({
+ hpack: 'end',
+ vpack: 'start',
+ reveal_child: ap.bssid === connecting.value || isDisconnecting(ap),
+ child: Widget.Spinner({
+ vpack: 'start',
+ class_name: 'spinner wap',
+ }),
+ }),
+ ],
+ }),
+ }),
+ Widget.Revealer({
+ vpack: 'start',
+ reveal_child: ap.bssid !== connecting.value && ap.active,
+ child: Widget.Button({
+ tooltip_text: 'Delete/Forget Network',
+ class_name: 'menu-icon-button network disconnect',
+ on_primary_click: () => {
+ connecting.value = ap.bssid || '';
+ Utils.execAsync('nmcli connection show --active').then(() => {
+ Utils.execAsync('nmcli connection show --active').then((res) => {
+ const connectionId = getIdBySsid(ap.ssid || '', res);
+
+ if (connectionId === undefined) {
+ console.error(
+ `Error while forgetting "${ap.ssid}": Connection ID not found`,
+ );
+ return;
+ }
+
+ Utils.execAsync(`nmcli connection delete ${connectionId} "${ap.ssid}"`)
+ .then(() => (connecting.value = ''))
+ .catch((err) => {
+ connecting.value = '';
+ console.error(`Error while forgetting "${ap.ssid}": ${err}`);
+ });
+ });
+ });
+ },
+ child: Widget.Label({
+ class_name: 'txt-icon delete-network',
+ label: '',
+ }),
+ }),
+ }),
+ ],
+ });
+ }));
+ });
+ });
+};
+
+export { renderWAPs };
diff --git a/ags/HyprPanel/modules/menus/network/wifi/index.ts b/ags/HyprPanel/modules/menus/network/wifi/index.ts
new file mode 100644
index 0000000..57948f6
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/network/wifi/index.ts
@@ -0,0 +1,81 @@
+const network = await Service.import('network');
+import { renderWAPs } from './WirelessAPs.js';
+import { renderWapStaging } from './APStaging.js';
+import { AccessPoint } from 'lib/types/network.js';
+import { BoxWidget } from 'lib/types/widget.js';
+
+const Staging = Variable({} as AccessPoint);
+const Connecting = Variable('');
+
+const searchInProgress = Variable(false);
+
+const startRotation = (): void => {
+ searchInProgress.value = true;
+ setTimeout(() => {
+ searchInProgress.value = false;
+ }, 5 * 1000);
+};
+
+const Wifi = (): BoxWidget => {
+ return Widget.Box({
+ class_name: 'menu-section-container wifi',
+ vertical: true,
+ children: [
+ Widget.Box({
+ class_name: 'menu-label-container',
+ hpack: 'fill',
+ children: [
+ Widget.Label({
+ class_name: 'menu-label',
+ hexpand: true,
+ hpack: 'start',
+ label: 'Wi-Fi',
+ }),
+ Widget.Switch({
+ class_name: 'menu-switch network',
+ vpack: 'center',
+ tooltip_text: 'Toggle Wifi',
+ active: network.wifi.enabled,
+ on_activate: () => {
+ network.toggleWifi();
+ },
+ }),
+ Widget.Button({
+ vpack: 'center',
+ hpack: 'end',
+ class_name: 'menu-icon-button search network',
+ on_primary_click: () => {
+ startRotation();
+ network.wifi.scan();
+ },
+ child: Widget.Icon({
+ class_name: searchInProgress.bind('value').as((v) => (v ? 'spinning' : '')),
+ icon: 'view-refresh-symbolic',
+ }),
+ }),
+ ],
+ }),
+ Widget.Box({
+ class_name: 'menu-items-section',
+ vertical: true,
+ children: [
+ Widget.Box({
+ class_name: 'wap-staging',
+ setup: (self) => {
+ renderWapStaging(self, network, Staging, Connecting);
+ },
+ }),
+ Widget.Box({
+ class_name: 'available-waps',
+ vertical: true,
+ setup: (self) => {
+ renderWAPs(self, network, Staging, Connecting);
+ },
+ }),
+ ],
+ }),
+ ],
+ });
+};
+
+export { Wifi };
diff --git a/ags/HyprPanel/modules/menus/notifications/controls/index.ts b/ags/HyprPanel/modules/menus/notifications/controls/index.ts
new file mode 100644
index 0000000..a2964fd
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/notifications/controls/index.ts
@@ -0,0 +1,76 @@
+import { closeNotifications } from 'globals/notification';
+import { BoxWidget } from 'lib/types/widget';
+import { Notifications } from 'types/service/notifications';
+import options from 'options';
+
+const { clearDelay } = options.notifications;
+
+const Controls = (notifs: Notifications): BoxWidget => {
+ return Widget.Box({
+ class_name: 'notification-menu-controls',
+ expand: false,
+ vertical: false,
+ children: [
+ Widget.Box({
+ class_name: 'menu-label-container notifications',
+ hpack: 'start',
+ vpack: 'center',
+ expand: true,
+ children: [
+ Widget.Label({
+ class_name: 'menu-label notifications',
+ label: 'Notifications',
+ }),
+ ],
+ }),
+ Widget.Box({
+ hpack: 'end',
+ vpack: 'center',
+ expand: false,
+ children: [
+ Widget.Switch({
+ class_name: 'menu-switch notifications',
+ vpack: 'center',
+ active: notifs.bind('dnd').as((dnd: boolean) => !dnd),
+ on_activate: ({ active }) => {
+ notifs.dnd = !active;
+ },
+ }),
+ Widget.Box({
+ children: [
+ Widget.Separator({
+ hpack: 'center',
+ vexpand: true,
+ vertical: true,
+ class_name: 'menu-separator notification-controls',
+ }),
+ Widget.Button({
+ className: 'clear-notifications-button',
+ tooltip_text: 'Clear Notifications',
+ on_primary_click: clearDelay.bind('value').as((delay) => {
+ return () => {
+ if (removingNotifications.value) {
+ return;
+ }
+
+ return closeNotifications(notifs.notifications, delay);
+ };
+ }),
+ child: Widget.Label({
+ class_name: removingNotifications.bind('value').as((removing: boolean) => {
+ return removing
+ ? 'clear-notifications-label txt-icon removing'
+ : 'clear-notifications-label txt-icon';
+ }),
+ label: '',
+ }),
+ }),
+ ],
+ }),
+ ],
+ }),
+ ],
+ });
+};
+
+export { Controls };
diff --git a/ags/HyprPanel/modules/menus/notifications/index.ts b/ags/HyprPanel/modules/menus/notifications/index.ts
new file mode 100644
index 0000000..bbdbce2
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/notifications/index.ts
@@ -0,0 +1,47 @@
+import { Notification } from 'types/service/notifications.js';
+import DropdownMenu from '../shared/dropdown/index.js';
+const notifs = await Service.import('notifications');
+import { Controls } from './controls/index.js';
+import { NotificationCard } from './notification/index.js';
+import { NotificationPager } from './pager/index.js';
+
+import options from 'options';
+import Window from 'types/widgets/window.js';
+import { Attribute, Child } from 'lib/types/widget.js';
+
+const { displayedTotal } = options.notifications;
+
+export default (): Window => {
+ const curPage = Variable(1);
+
+ Utils.merge(
+ [curPage.bind('value'), displayedTotal.bind('value'), notifs.bind('notifications')],
+ (currentPage: number, dispTotal: number, notifications: Notification[]) => {
+ // If the page doesn't have enough notifications to display, go back
+ // to the previous page.
+ if (notifications.length <= (currentPage - 1) * dispTotal) {
+ curPage.value = currentPage <= 1 ? 1 : currentPage - 1;
+ }
+ },
+ );
+
+ return DropdownMenu({
+ name: 'notificationsmenu',
+ transition: options.menus.transition.bind('value'),
+ child: Widget.Box({
+ class_name: 'notification-menu-content',
+ css: 'padding: 1px; margin: -1px;',
+ hexpand: true,
+ vexpand: false,
+ children: [
+ Widget.Box({
+ class_name: 'notification-card-container menu',
+ vertical: true,
+ hexpand: false,
+ vexpand: false,
+ children: [Controls(notifs), NotificationCard(notifs, curPage), NotificationPager(curPage)],
+ }),
+ ],
+ }),
+ });
+};
diff --git a/ags/HyprPanel/modules/menus/notifications/notification/actions/index.ts b/ags/HyprPanel/modules/menus/notifications/notification/actions/index.ts
new file mode 100644
index 0000000..bf68dad
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/notifications/notification/actions/index.ts
@@ -0,0 +1,49 @@
+import { BoxWidget } from 'lib/types/widget';
+import { Notification, Notifications } from 'types/service/notifications';
+const Actions = (notif: Notification, notifs: Notifications): BoxWidget => {
+ if (notif.actions !== undefined && notif.actions.length > 0) {
+ return Widget.Box({
+ class_name: 'notification-card-actions menu',
+ hexpand: true,
+ vpack: 'end',
+ children: notif.actions.map((action) => {
+ return Widget.Button({
+ hexpand: true,
+ class_name: 'notification-action-buttons menu',
+ on_primary_click: () => {
+ if (action.id.includes('scriptAction:-')) {
+ App.closeWindow('notificationsmenu');
+ Utils.execAsync(`${action.id.replace('scriptAction:-', '')}`).catch((err) =>
+ console.error(err),
+ );
+ notifs.CloseNotification(notif.id);
+ } else {
+ App.closeWindow('notificationsmenu');
+ notif.invoke(action.id);
+ }
+ },
+ child: Widget.Box({
+ hpack: 'center',
+ hexpand: true,
+ children: [
+ Widget.Label({
+ class_name: 'notification-action-buttons-label menu',
+ hexpand: true,
+ max_width_chars: 15,
+ truncate: 'end',
+ wrap: true,
+ label: action.label,
+ }),
+ ],
+ }),
+ });
+ }),
+ });
+ }
+
+ return Widget.Box({
+ class_name: 'spacer',
+ });
+};
+
+export { Actions };
diff --git a/ags/HyprPanel/modules/menus/notifications/notification/body/index.ts b/ags/HyprPanel/modules/menus/notifications/notification/body/index.ts
new file mode 100644
index 0000000..62aac7a
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/notifications/notification/body/index.ts
@@ -0,0 +1,25 @@
+import { BoxWidget } from 'lib/types/widget.js';
+import { notifHasImg } from '../../utils.js';
+import { Notification } from 'types/service/notifications';
+
+export const Body = (notif: Notification): BoxWidget => {
+ return Widget.Box({
+ vpack: 'start',
+ hexpand: true,
+ class_name: 'notification-card-body menu',
+ children: [
+ Widget.Label({
+ hexpand: true,
+ use_markup: true,
+ xalign: 0,
+ justification: 'left',
+ truncate: 'end',
+ lines: 2,
+ max_width_chars: !notifHasImg(notif) ? 35 : 28,
+ wrap: true,
+ class_name: 'notification-card-body-label menu',
+ label: notif['body'],
+ }),
+ ],
+ });
+};
diff --git a/ags/HyprPanel/modules/menus/notifications/notification/close/index.ts b/ags/HyprPanel/modules/menus/notifications/notification/close/index.ts
new file mode 100644
index 0000000..261f949
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/notifications/notification/close/index.ts
@@ -0,0 +1,17 @@
+import { Attribute } from 'lib/types/widget';
+import { Notification, Notifications } from 'types/service/notifications';
+import Button from 'types/widgets/button';
+import Label from 'types/widgets/label';
+export const CloseButton = (notif: Notification, notifs: Notifications): Button, Attribute> => {
+ return Widget.Button({
+ class_name: 'close-notification-button menu',
+ on_primary_click: () => {
+ notifs.CloseNotification(notif.id);
+ },
+ child: Widget.Label({
+ class_name: 'txt-icon notif-close',
+ label: '',
+ hpack: 'center',
+ }),
+ });
+};
diff --git a/ags/HyprPanel/modules/menus/notifications/notification/header/icon.ts b/ags/HyprPanel/modules/menus/notifications/notification/header/icon.ts
new file mode 100644
index 0000000..c51bc01
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/notifications/notification/header/icon.ts
@@ -0,0 +1,19 @@
+import { Notification } from 'types/service/notifications.js';
+import { NotificationIcon } from 'lib/types/notification.js';
+import { getNotificationIcon } from 'globals/notification';
+import { BoxWidget } from 'lib/types/widget';
+
+const NotificationIcon = ({ app_entry = '', app_icon = '', app_name = '' }: Partial): BoxWidget => {
+ return Widget.Box({
+ css: `
+ min-width: 2rem;
+ min-height: 2rem;
+ `,
+ child: Widget.Icon({
+ class_name: 'notification-icon menu',
+ icon: getNotificationIcon(app_name, app_icon, app_entry),
+ }),
+ });
+};
+
+export { NotificationIcon };
diff --git a/ags/HyprPanel/modules/menus/notifications/notification/header/index.ts b/ags/HyprPanel/modules/menus/notifications/notification/header/index.ts
new file mode 100644
index 0000000..1373ae3
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/notifications/notification/header/index.ts
@@ -0,0 +1,54 @@
+import GLib from 'gi://GLib';
+import { Notification } from 'types/service/notifications';
+import { NotificationIcon } from './icon.js';
+import { notifHasImg } from '../../utils.js';
+import options from 'options.js';
+import { BoxWidget } from 'lib/types/widget.js';
+
+const { military } = options.menus.clock.time;
+
+export const Header = (notif: Notification): BoxWidget => {
+ const time = (time: number, format = '%I:%M %p'): string => {
+ return GLib.DateTime.new_from_unix_local(time).format(military.value ? '%H:%M' : format) || '--:--';
+ };
+
+ return Widget.Box({
+ vertical: false,
+ hexpand: true,
+ children: [
+ Widget.Box({
+ class_name: 'notification-card-header menu',
+ hpack: 'start',
+ children: [NotificationIcon(notif)],
+ }),
+ Widget.Box({
+ class_name: 'notification-card-header menu',
+ hexpand: true,
+ vpack: 'start',
+ children: [
+ Widget.Label({
+ class_name: 'notification-card-header-label menu',
+ hpack: 'start',
+ hexpand: true,
+ vexpand: true,
+ max_width_chars: !notifHasImg(notif) ? 34 : 22,
+ truncate: 'end',
+ wrap: true,
+ label: notif['summary'],
+ }),
+ ],
+ }),
+ Widget.Box({
+ class_name: 'notification-card-header menu',
+ hpack: 'end',
+ vpack: 'start',
+ hexpand: true,
+ child: Widget.Label({
+ vexpand: true,
+ class_name: 'notification-time',
+ label: time(notif.time),
+ }),
+ }),
+ ],
+ });
+};
diff --git a/ags/HyprPanel/modules/menus/notifications/notification/image/index.ts b/ags/HyprPanel/modules/menus/notifications/notification/image/index.ts
new file mode 100644
index 0000000..6b01768
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/notifications/notification/image/index.ts
@@ -0,0 +1,24 @@
+import { Notification } from 'types/service/notifications';
+import { notifHasImg } from '../../utils.js';
+import { BoxWidget } from 'lib/types/widget.js';
+
+const Image = (notif: Notification): BoxWidget => {
+ if (notifHasImg(notif)) {
+ return Widget.Box({
+ class_name: 'notification-card-image-container menu',
+ hpack: 'center',
+ vpack: 'center',
+ vexpand: false,
+ child: Widget.Box({
+ hpack: 'center',
+ vexpand: false,
+ class_name: 'notification-card-image menu',
+ css: `background-image: url("${notif.image}")`,
+ }),
+ });
+ }
+
+ return Widget.Box();
+};
+
+export { Image };
diff --git a/ags/HyprPanel/modules/menus/notifications/notification/index.ts b/ags/HyprPanel/modules/menus/notifications/notification/index.ts
new file mode 100644
index 0000000..9254327
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/notifications/notification/index.ts
@@ -0,0 +1,78 @@
+import { Notifications, Notification } from 'types/service/notifications';
+import { notifHasImg } from '../utils.js';
+import { Header } from './header/index.js';
+import { Actions } from './actions/index.js';
+import { Image } from './image/index.js';
+import { Placeholder } from './placeholder/index.js';
+import { Body } from './body/index.js';
+import { CloseButton } from './close/index.js';
+import options from 'options.js';
+import { Variable } from 'types/variable.js';
+import { filterNotifications } from 'lib/shared/notifications.js';
+import Scrollable from 'types/widgets/scrollable.js';
+import { Attribute, Child } from 'lib/types/widget.js';
+
+const { displayedTotal, ignore } = options.notifications;
+
+const NotificationCard = (notifs: Notifications, curPage: Variable): Scrollable => {
+ return Widget.Scrollable({
+ vscroll: 'automatic',
+ child: Widget.Box({
+ class_name: 'menu-content-container notifications',
+ hpack: 'center',
+ vexpand: true,
+ spacing: 0,
+ vertical: true,
+ setup: (self) => {
+ Utils.merge(
+ [
+ notifs.bind('notifications'),
+ curPage.bind('value'),
+ displayedTotal.bind('value'),
+ ignore.bind('value'),
+ ],
+ (notifications, currentPage, dispTotal, ignoredNotifs) => {
+ const filteredNotifications = filterNotifications(notifications, ignoredNotifs);
+
+ const sortedNotifications = filteredNotifications.sort((a, b) => b.time - a.time);
+
+ if (filteredNotifications.length <= 0) {
+ return (self.children = [Placeholder(notifs)]);
+ }
+
+ const pageStart = (currentPage - 1) * dispTotal;
+ const pageEnd = currentPage * dispTotal;
+ return (self.children = sortedNotifications
+ .slice(pageStart, pageEnd)
+ .map((notif: Notification) => {
+ return Widget.Box({
+ class_name: 'notification-card-content-container',
+ children: [
+ Widget.Box({
+ class_name: 'notification-card menu',
+ vpack: 'start',
+ hexpand: true,
+ vexpand: false,
+ children: [
+ Image(notif),
+ Widget.Box({
+ vpack: 'center',
+ vertical: true,
+ hexpand: true,
+ class_name: `notification-card-content ${!notifHasImg(notif) ? 'noimg' : ' menu'}`,
+ children: [Header(notif), Body(notif), Actions(notif, notifs)],
+ }),
+ ],
+ }),
+ CloseButton(notif, notifs),
+ ],
+ });
+ }));
+ },
+ );
+ },
+ }),
+ });
+};
+
+export { NotificationCard };
diff --git a/ags/HyprPanel/modules/menus/notifications/notification/placeholder/index.ts b/ags/HyprPanel/modules/menus/notifications/notification/placeholder/index.ts
new file mode 100644
index 0000000..e22df55
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/notifications/notification/placeholder/index.ts
@@ -0,0 +1,30 @@
+import { BoxWidget } from 'lib/types/widget';
+import { Notifications } from 'types/service/notifications';
+
+const Placeholder = (notifs: Notifications): BoxWidget => {
+ return Widget.Box({
+ class_name: 'notification-label-container',
+ vpack: 'fill',
+ hpack: 'center',
+ expand: true,
+ child: Widget.Box({
+ vpack: 'center',
+ vertical: true,
+ expand: true,
+ children: [
+ Widget.Label({
+ vpack: 'center',
+ class_name: 'placeholder-label dim bell txt-icon',
+ label: notifs.bind('dnd').as((dnd) => (dnd ? '' : '')),
+ }),
+ Widget.Label({
+ vpack: 'start',
+ class_name: 'placehold-label dim message',
+ label: "You're all caught up :)",
+ }),
+ ],
+ }),
+ });
+};
+
+export { Placeholder };
diff --git a/ags/HyprPanel/modules/menus/notifications/pager/index.ts b/ags/HyprPanel/modules/menus/notifications/pager/index.ts
new file mode 100644
index 0000000..1a4571b
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/notifications/pager/index.ts
@@ -0,0 +1,88 @@
+const notifs = await Service.import('notifications');
+
+import { BoxWidget } from 'lib/types/widget';
+import options from 'options';
+import { Notification } from 'types/service/notifications';
+import { Variable } from 'types/variable';
+
+const { displayedTotal } = options.notifications;
+const { show: showPager } = options.theme.bar.menus.menu.notifications.pager;
+
+export const NotificationPager = (curPage: Variable): BoxWidget => {
+ return Widget.Box({
+ class_name: 'notification-menu-pager',
+ hexpand: true,
+ vexpand: false,
+ children: Utils.merge(
+ [
+ curPage.bind('value'),
+ displayedTotal.bind('value'),
+ notifs.bind('notifications'),
+ showPager.bind('value'),
+ ],
+ (currentPage: number, dispTotal: number, _: Notification[], showPgr: boolean) => {
+ if (showPgr === false || (currentPage === 1 && notifs.notifications.length <= dispTotal)) {
+ return [];
+ }
+ return [
+ Widget.Button({
+ hexpand: true,
+ hpack: 'start',
+ class_name: `pager-button left ${currentPage <= 1 ? 'disabled' : ''}`,
+ onPrimaryClick: () => {
+ curPage.value = 1;
+ },
+ child: Widget.Label({
+ className: 'pager-button-label',
+ label: '',
+ }),
+ }),
+ Widget.Button({
+ hexpand: true,
+ hpack: 'start',
+ class_name: `pager-button left ${currentPage <= 1 ? 'disabled' : ''}`,
+ onPrimaryClick: () => {
+ curPage.value = currentPage <= 1 ? 1 : currentPage - 1;
+ },
+ child: Widget.Label({
+ className: 'pager-button-label',
+ label: '',
+ }),
+ }),
+ Widget.Label({
+ hexpand: true,
+ hpack: 'center',
+ class_name: 'pager-label',
+ label: `${currentPage} / ${Math.ceil(notifs.notifications.length / dispTotal) || 1}`,
+ }),
+ Widget.Button({
+ hexpand: true,
+ hpack: 'end',
+ class_name: `pager-button right ${currentPage >= Math.ceil(notifs.notifications.length / dispTotal) ? 'disabled' : ''}`,
+ onPrimaryClick: () => {
+ const maxPage = Math.ceil(notifs.notifications.length / displayedTotal.value);
+ curPage.value = currentPage >= maxPage ? currentPage : currentPage + 1;
+ },
+ child: Widget.Label({
+ className: 'pager-button-label',
+ label: '',
+ }),
+ }),
+ Widget.Button({
+ hexpand: true,
+ hpack: 'end',
+ class_name: `pager-button right ${currentPage >= Math.ceil(notifs.notifications.length / dispTotal) ? 'disabled' : ''}`,
+ onPrimaryClick: () => {
+ const maxPage = Math.ceil(notifs.notifications.length / displayedTotal.value);
+ curPage.value = maxPage;
+ },
+ child: Widget.Label({
+ className: 'pager-button-label',
+ label: '',
+ }),
+ }),
+ ];
+ },
+ ),
+ });
+};
diff --git a/ags/HyprPanel/modules/menus/notifications/utils.ts b/ags/HyprPanel/modules/menus/notifications/utils.ts
new file mode 100644
index 0000000..dcf2c7d
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/notifications/utils.ts
@@ -0,0 +1,7 @@
+import { Notification } from 'types/service/notifications';
+
+const notifHasImg = (notif: Notification): boolean => {
+ return notif.image !== undefined && notif.image.length ? true : false;
+};
+
+export { notifHasImg };
diff --git a/ags/HyprPanel/modules/menus/power/helpers/actions.ts b/ags/HyprPanel/modules/menus/power/helpers/actions.ts
new file mode 100644
index 0000000..fee7dfa
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/power/helpers/actions.ts
@@ -0,0 +1,61 @@
+import { Action } from 'lib/types/power';
+import options from 'options';
+const { sleep, reboot, logout, shutdown } = options.menus.dashboard.powermenu;
+
+class PowerMenu extends Service {
+ static {
+ Service.register(
+ this,
+ {},
+ {
+ title: ['string'],
+ cmd: ['string'],
+ },
+ );
+ }
+
+ #title = '';
+ #cmd = '';
+
+ get title(): string {
+ return this.#title;
+ }
+
+ action(action: Action): void {
+ [this.#cmd, this.#title] = {
+ sleep: [sleep.value, 'Sleep'],
+ reboot: [reboot.value, 'Reboot'],
+ logout: [logout.value, 'Log Out'],
+ shutdown: [shutdown.value, 'Shutdown'],
+ }[action];
+
+ this.notify('cmd');
+ this.notify('title');
+ this.emit('changed');
+ App.closeWindow('powermenu');
+ App.openWindow('verification');
+ }
+
+ customAction(action: Action, cmnd: string): void {
+ [this.#cmd, this.#title] = [cmnd, action];
+
+ this.notify('cmd');
+ this.notify('title');
+ this.emit('changed');
+ App.closeWindow('powermenu');
+ App.openWindow('verification');
+ }
+
+ shutdown = (): void => {
+ this.action('shutdown');
+ };
+
+ exec = (): void => {
+ App.closeWindow('verification');
+ Utils.execAsync(this.#cmd);
+ };
+}
+
+const powermenu = new PowerMenu();
+Object.assign(globalThis, { powermenu });
+export default powermenu;
diff --git a/ags/HyprPanel/modules/menus/power/index.ts b/ags/HyprPanel/modules/menus/power/index.ts
new file mode 100644
index 0000000..bb8505d
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/power/index.ts
@@ -0,0 +1,41 @@
+import { Action } from 'lib/types/power.js';
+import PopupWindow from '../shared/popup/index.js';
+import powermenu from './helpers/actions.js';
+import icons from '../../icons/index.js';
+import Window from 'types/widgets/window.js';
+import { Attribute, Child, GButton } from 'lib/types/widget.js';
+import options from 'options.js';
+
+const SysButton = (action: Action, label: string): GButton =>
+ Widget.Button({
+ class_name: `widget-button powermenu-button-${action}`,
+ on_clicked: () => powermenu.action(action),
+ child: Widget.Box({
+ vertical: true,
+ class_name: 'system-button widget-box',
+ children: [
+ Widget.Icon({
+ class_name: `system-button_icon ${action}`,
+ icon: icons.powermenu[action],
+ }),
+ Widget.Label({
+ class_name: `system-button_label ${action}`,
+ label,
+ }),
+ ],
+ }),
+ });
+export default (): Window =>
+ PopupWindow({
+ name: 'powermenu',
+ transition: options.menus.transition.bind('value'),
+ child: Widget.Box({
+ class_name: 'powermenu horizontal',
+ children: [
+ SysButton('shutdown', 'SHUTDOWN'),
+ SysButton('logout', 'LOG OUT'),
+ SysButton('reboot', 'REBOOT'),
+ SysButton('sleep', 'SLEEP'),
+ ],
+ }),
+ });
diff --git a/ags/HyprPanel/modules/menus/power/verification.ts b/ags/HyprPanel/modules/menus/power/verification.ts
new file mode 100644
index 0000000..a71f46b
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/power/verification.ts
@@ -0,0 +1,54 @@
+import Window from 'types/widgets/window.js';
+import PopupWindow from '../shared/popup/index.js';
+import powermenu from './helpers/actions.js';
+import { Attribute, Child } from 'lib/types/widget.js';
+
+export default (): Window =>
+ PopupWindow({
+ name: 'verification',
+ transition: 'crossfade',
+ child: Widget.Box({
+ class_name: 'verification',
+ child: Widget.Box({
+ class_name: 'verification-content',
+ expand: true,
+ vertical: true,
+ children: [
+ Widget.Box({
+ class_name: 'text-box',
+ vertical: true,
+ children: [
+ Widget.Label({
+ class_name: 'title',
+ label: powermenu.bind('title').as((t) => t.toUpperCase()),
+ }),
+ Widget.Label({
+ class_name: 'desc',
+ label: powermenu
+ .bind('title')
+ .as((p) => `Are you sure you want to ${p.toLowerCase()}?`),
+ }),
+ ],
+ }),
+ Widget.Box({
+ class_name: 'buttons horizontal',
+ vexpand: true,
+ vpack: 'end',
+ homogeneous: true,
+ children: [
+ Widget.Button({
+ class_name: 'verification-button bar-verification_yes',
+ child: Widget.Label('Yes'),
+ on_clicked: powermenu.exec,
+ }),
+ Widget.Button({
+ class_name: 'verification-button bar-verification_no',
+ child: Widget.Label('No'),
+ on_clicked: () => App.toggleWindow('verification'),
+ }),
+ ],
+ }),
+ ],
+ }),
+ }),
+ });
diff --git a/ags/HyprPanel/modules/menus/powerDropdown/button.ts b/ags/HyprPanel/modules/menus/powerDropdown/button.ts
new file mode 100644
index 0000000..f2ae20f
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/powerDropdown/button.ts
@@ -0,0 +1,65 @@
+import { PowerOptions } from 'lib/types/options';
+import options from 'options';
+import powermenu from '../power/helpers/actions';
+import { GButton } from 'lib/types/widget';
+
+const { confirmation, shutdown, logout, sleep, reboot, showLabel } = options.menus.power;
+
+export const PowerButton = (action: PowerOptions): GButton => {
+ const handleClick = (action: PowerOptions): void => {
+ const actions = {
+ shutdown: shutdown.value,
+ reboot: reboot.value,
+ logout: logout.value,
+ sleep: sleep.value,
+ };
+ App.closeWindow('powerdropdownmenu');
+
+ if (!confirmation.value) {
+ Utils.execAsync(actions[action]).catch((err) =>
+ console.error(`Failed to execute ${action} command. Error: ${err}`),
+ );
+ } else {
+ powermenu.customAction(action, actions[action]);
+ }
+ };
+
+ const powerIconMap = {
+ shutdown: '',
+ reboot: '',
+ logout: '',
+ sleep: '',
+ };
+
+ return Widget.Button({
+ className: showLabel.bind('value').as((shwLbl) => {
+ return `power-menu-button ${action} ${!shwLbl ? 'no-label' : ''}`;
+ }),
+ on_clicked: () => handleClick(action),
+ child: Widget.Box({
+ vertical: false,
+ children: showLabel.bind('value').as((shwLbl) => {
+ if (shwLbl) {
+ return [
+ Widget.Label({
+ label: powerIconMap[action],
+ className: `power-button-icon ${action}-icon txt-icon`,
+ }),
+ Widget.Label({
+ hpack: 'center',
+ hexpand: true,
+ label: action.charAt(0).toUpperCase() + action.slice(1),
+ className: `power-button-label ${action}-label show-label`,
+ }),
+ ];
+ }
+ return [
+ Widget.Label({
+ label: powerIconMap[action],
+ className: `power-button-icon ${action}-icon no-label txt-icon`,
+ }),
+ ];
+ }),
+ }),
+ });
+};
diff --git a/ags/HyprPanel/modules/menus/powerDropdown/index.ts b/ags/HyprPanel/modules/menus/powerDropdown/index.ts
new file mode 100644
index 0000000..1562d8b
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/powerDropdown/index.ts
@@ -0,0 +1,21 @@
+import Window from 'types/widgets/window.js';
+import DropdownMenu from '../shared/dropdown/index.js';
+import { PowerButton } from './button.js';
+import { Attribute, Child } from 'lib/types/widget.js';
+import options from 'options.js';
+
+export default (): Window => {
+ return DropdownMenu({
+ name: 'powerdropdownmenu',
+ transition: options.menus.transition.bind('value'),
+ child: Widget.Box({
+ class_name: 'menu-items power-dropdown',
+ child: Widget.Box({
+ vertical: true,
+ hexpand: true,
+ class_name: 'menu-items-container power-dropdown',
+ children: [PowerButton('shutdown'), PowerButton('reboot'), PowerButton('logout'), PowerButton('sleep')],
+ }),
+ }),
+ });
+};
diff --git a/ags/HyprPanel/modules/menus/shared/dropdown/eventBoxes/index.ts b/ags/HyprPanel/modules/menus/shared/dropdown/eventBoxes/index.ts
new file mode 100644
index 0000000..727610d
--- /dev/null
+++ b/ags/HyprPanel/modules/menus/shared/dropdown/eventBoxes/index.ts
@@ -0,0 +1,33 @@
+import { Attribute, BoxWidget } from 'lib/types/widget';
+import EventBox from 'types/widgets/eventbox';
+import { BarLocation } from 'lib/types/options';
+
+const createEventBox = (className: string, windowName: string): EventBox => {
+ return Widget.EventBox({
+ class_name: className,
+ hexpand: true,
+ vexpand: false,
+ can_focus: false,
+ child: Widget.Box(),
+ setup: (w) => {
+ w.on('button-press-event', () => App.toggleWindow(windowName));
+ },
+ });
+};
+
+export const barEventMargins = (
+ windowName: string,
+ location: BarLocation = 'top',
+): [EventBox