diff --git a/.gitignore b/.gitignore
index f732789b..f9bd47ba 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,18 +3,14 @@
*.pyc
*.pyo
*.pyd
-# config/assets/config.json
-config/assets/dock.json
-config/assets/accounts.json
-config/assets/passwords.json
-config/assets/bookmarks.json
-# config/hypr/modus.conf
+config/config.json
+config/dock.json
+config/accounts.json
+config/passwords.json
+config/bookmarks.json
config/hypr/colors.conf
-fabric/**
-fabric/
-# styles/colors.css
+src/shared/styles/colors.css
.aider*
.venv/*
.venv/
-AGENTS.md
diff --git a/README.md b/README.md
index 3a9df24c..4e54600b 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,5 @@
-
+
@@ -26,17 +26,17 @@
Home Screen:
-
+
Lock Screen:
-
+
-
-## Installation
+## Installation
> [!CAUTION]
+>
> - You need a working installation of hyprland and knowledge of how it works
> - There may not be all packages in your system install them accordingly
@@ -47,23 +47,23 @@ cd ~/.config/Modus
```
> [!TIP]
+>
> ## Post Installation
+>
> - Install recommended [Icon theme](https://github.com/vinceliuice/MacTahoe-icon-theme) , [GTK theme](https://github.com/vinceliuice/MacTahoe-gtk-theme) and [Cursor Theme](https://github.com/vinceliuice/MacTahoe-icon-theme/tree/main/cursors)
> - Check `config/hypr/modus.conf` edit it according to your device and copy it to your hyprland config
-> - For Lock Screen Bind keys to `python lock.py`
+> - For Lock Screen Bind keys to `uv run lock`
Todo
-## Manual Installation (WIP)
+## Manual Installation
```bash
-paru -S glace-git gtk-session-lock python-pyotp python-pillow python-ijson python-setproctitle apple-fonts cinnamon-desktop --needed
+paru -S fabric-cli-git gtk-session-lock uv apple-fonts cinnamon-desktop hyprshot hypridle hyprpicker grim slurp gnome-bluetooth-3.0 cliphist matugen-bin awww swappy wl-clipboard webp-pixbuf-loader acpi wf-recorder brightnessctl power-profile-daemon uwsm libnotify playerctl ffmpeg --needed
git clone https://github.com/S4NKALP/Modus ~/.config/Modus
cd ~/.config/Modus
-python -m venv .venv
-source .venv/bin/activate
-pip install -r requirements.txt
-pip install --no-deps git+https://github.com/Fabric-Development/fabric.git
+uv sync
+uv run start
```
- [x] Launcher
@@ -80,7 +80,8 @@ pip install --no-deps git+https://github.com/Fabric-Development/fabric.git
- [x] Panel Widget
- [x] MacOS like Widget
- [x] Expandable Notification Centre
-- [ ] Installation Script
+- [x] Installation Script
+- [x] Migrate to a `uv` managed Python virtual environment
- [ ] Proper Documentation
- [ ] Pomodoro Timer Widget
- [x] To-do List Widget
diff --git a/assets/default.png b/assets/default.png
deleted file mode 100644
index 774a4e08..00000000
Binary files a/assets/default.png and /dev/null differ
diff --git a/config/assets/config.json b/config/assets/config.json
deleted file mode 100644
index 3750756b..00000000
--- a/config/assets/config.json
+++ /dev/null
@@ -1,16 +0,0 @@
-{
- "wallpapers_dir": "~/Pictures/Wallpapers/",
- "dock_position": "Bottom",
- "terminal_command": "kitty -e",
- "dock_enabled": true,
- "dock_auto_hide": true,
- "dock_always_occluded": false,
- "dock_icon_size": 52,
- "window_switcher_items_per_row": 10,
- "hide_special_workspace": true,
- "dock_hide_special_workspace_apps": true,
- "dock_preview_apps": false,
- "notification_timeout": "5s",
- "notification_ignored_apps": ["Hyprshot"],
- "notification_limited_apps_history": ["Spotify"]
-}
diff --git a/config/assets/emoji.json b/config/assets/emoji.json
deleted file mode 100644
index fa174ffb..00000000
--- a/config/assets/emoji.json
+++ /dev/null
@@ -1,15509 +0,0 @@
-{
- "๐": {
- "name": "grinning face",
- "slug": "grinning_face",
- "group": "Smileys & Emotion",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "grinning face with big eyes",
- "slug": "grinning_face_with_big_eyes",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "grinning face with smiling eyes",
- "slug": "grinning_face_with_smiling_eyes",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "beaming face with smiling eyes",
- "slug": "beaming_face_with_smiling_eyes",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "grinning squinting face",
- "slug": "grinning_squinting_face",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐
": {
- "name": "grinning face with sweat",
- "slug": "grinning_face_with_sweat",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐คฃ": {
- "name": "rolling on the floor laughing",
- "slug": "rolling_on_the_floor_laughing",
- "group": "Smileys & Emotion",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "face with tears of joy",
- "slug": "face_with_tears_of_joy",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "slightly smiling face",
- "slug": "slightly_smiling_face",
- "group": "Smileys & Emotion",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "upside-down face",
- "slug": "upside_down_face",
- "group": "Smileys & Emotion",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ซ ": {
- "name": "melting face",
- "slug": "melting_face",
- "group": "Smileys & Emotion",
- "emoji_version": "14.0",
- "unicode_version": "14.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "winking face",
- "slug": "winking_face",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "smiling face with smiling eyes",
- "slug": "smiling_face_with_smiling_eyes",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "smiling face with halo",
- "slug": "smiling_face_with_halo",
- "group": "Smileys & Emotion",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ฅฐ": {
- "name": "smiling face with hearts",
- "slug": "smiling_face_with_hearts",
- "group": "Smileys & Emotion",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "smiling face with heart-eyes",
- "slug": "smiling_face_with_heart_eyes",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐คฉ": {
- "name": "star-struck",
- "slug": "star_struck",
- "group": "Smileys & Emotion",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "face blowing a kiss",
- "slug": "face_blowing_a_kiss",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "kissing face",
- "slug": "kissing_face",
- "group": "Smileys & Emotion",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "โบ๏ธ": {
- "name": "smiling face",
- "slug": "smiling_face",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "kissing face with closed eyes",
- "slug": "kissing_face_with_closed_eyes",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "kissing face with smiling eyes",
- "slug": "kissing_face_with_smiling_eyes",
- "group": "Smileys & Emotion",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ฅฒ": {
- "name": "smiling face with tear",
- "slug": "smiling_face_with_tear",
- "group": "Smileys & Emotion",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "face savoring food",
- "slug": "face_savoring_food",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "face with tongue",
- "slug": "face_with_tongue",
- "group": "Smileys & Emotion",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "winking face with tongue",
- "slug": "winking_face_with_tongue",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐คช": {
- "name": "zany face",
- "slug": "zany_face",
- "group": "Smileys & Emotion",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "squinting face with tongue",
- "slug": "squinting_face_with_tongue",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ค": {
- "name": "money-mouth face",
- "slug": "money_mouth_face",
- "group": "Smileys & Emotion",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ค": {
- "name": "smiling face with open hands",
- "slug": "smiling_face_with_open_hands",
- "group": "Smileys & Emotion",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐คญ": {
- "name": "face with hand over mouth",
- "slug": "face_with_hand_over_mouth",
- "group": "Smileys & Emotion",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": false
- },
- "๐ซข": {
- "name": "face with open eyes and hand over mouth",
- "slug": "face_with_open_eyes_and_hand_over_mouth",
- "group": "Smileys & Emotion",
- "emoji_version": "14.0",
- "unicode_version": "14.0",
- "skin_tone_support": false
- },
- "๐ซฃ": {
- "name": "face with peeking eye",
- "slug": "face_with_peeking_eye",
- "group": "Smileys & Emotion",
- "emoji_version": "14.0",
- "unicode_version": "14.0",
- "skin_tone_support": false
- },
- "๐คซ": {
- "name": "shushing face",
- "slug": "shushing_face",
- "group": "Smileys & Emotion",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": false
- },
- "๐ค": {
- "name": "thinking face",
- "slug": "thinking_face",
- "group": "Smileys & Emotion",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ซก": {
- "name": "saluting face",
- "slug": "saluting_face",
- "group": "Smileys & Emotion",
- "emoji_version": "14.0",
- "unicode_version": "14.0",
- "skin_tone_support": false
- },
- "๐ค": {
- "name": "zipper-mouth face",
- "slug": "zipper_mouth_face",
- "group": "Smileys & Emotion",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐คจ": {
- "name": "face with raised eyebrow",
- "slug": "face_with_raised_eyebrow",
- "group": "Smileys & Emotion",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "neutral face",
- "slug": "neutral_face",
- "group": "Smileys & Emotion",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐": {
- "name": "expressionless face",
- "slug": "expressionless_face",
- "group": "Smileys & Emotion",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ถ": {
- "name": "face without mouth",
- "slug": "face_without_mouth",
- "group": "Smileys & Emotion",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ซฅ": {
- "name": "dotted line face",
- "slug": "dotted_line_face",
- "group": "Smileys & Emotion",
- "emoji_version": "14.0",
- "unicode_version": "14.0",
- "skin_tone_support": false
- },
- "๐ถโ๐ซ๏ธ": {
- "name": "face in clouds",
- "slug": "face_in_clouds",
- "group": "Smileys & Emotion",
- "emoji_version": "13.1",
- "unicode_version": "13.1",
- "skin_tone_support": false
- },
- "๐": {
- "name": "smirking face",
- "slug": "smirking_face",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "unamused face",
- "slug": "unamused_face",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "face with rolling eyes",
- "slug": "face_with_rolling_eyes",
- "group": "Smileys & Emotion",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ฌ": {
- "name": "grimacing face",
- "slug": "grimacing_face",
- "group": "Smileys & Emotion",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ฎโ๐จ": {
- "name": "face exhaling",
- "slug": "face_exhaling",
- "group": "Smileys & Emotion",
- "emoji_version": "13.1",
- "unicode_version": "13.1",
- "skin_tone_support": false
- },
- "๐คฅ": {
- "name": "lying face",
- "slug": "lying_face",
- "group": "Smileys & Emotion",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": false
- },
- "๐ซจ": {
- "name": "shaking face",
- "slug": "shaking_face",
- "group": "Smileys & Emotion",
- "emoji_version": "15.0",
- "unicode_version": "15.0",
- "skin_tone_support": false
- },
- "๐โโ๏ธ": {
- "name": "head shaking horizontally",
- "slug": "head_shaking_horizontally",
- "group": "Smileys & Emotion",
- "emoji_version": "15.1",
- "unicode_version": "15.1",
- "skin_tone_support": false
- },
- "๐โโ๏ธ": {
- "name": "head shaking vertically",
- "slug": "head_shaking_vertically",
- "group": "Smileys & Emotion",
- "emoji_version": "15.1",
- "unicode_version": "15.1",
- "skin_tone_support": false
- },
- "๐": {
- "name": "relieved face",
- "slug": "relieved_face",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "pensive face",
- "slug": "pensive_face",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ช": {
- "name": "sleepy face",
- "slug": "sleepy_face",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐คค": {
- "name": "drooling face",
- "slug": "drooling_face",
- "group": "Smileys & Emotion",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": false
- },
- "๐ด": {
- "name": "sleeping face",
- "slug": "sleeping_face",
- "group": "Smileys & Emotion",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ท": {
- "name": "face with medical mask",
- "slug": "face_with_medical_mask",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ค": {
- "name": "face with thermometer",
- "slug": "face_with_thermometer",
- "group": "Smileys & Emotion",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ค": {
- "name": "face with head-bandage",
- "slug": "face_with_head_bandage",
- "group": "Smileys & Emotion",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐คข": {
- "name": "nauseated face",
- "slug": "nauseated_face",
- "group": "Smileys & Emotion",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": false
- },
- "๐คฎ": {
- "name": "face vomiting",
- "slug": "face_vomiting",
- "group": "Smileys & Emotion",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": false
- },
- "๐คง": {
- "name": "sneezing face",
- "slug": "sneezing_face",
- "group": "Smileys & Emotion",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": false
- },
- "๐ฅต": {
- "name": "hot face",
- "slug": "hot_face",
- "group": "Smileys & Emotion",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": false
- },
- "๐ฅถ": {
- "name": "cold face",
- "slug": "cold_face",
- "group": "Smileys & Emotion",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": false
- },
- "๐ฅด": {
- "name": "woozy face",
- "slug": "woozy_face",
- "group": "Smileys & Emotion",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": false
- },
- "๐ต": {
- "name": "face with crossed-out eyes",
- "slug": "face_with_crossed_out_eyes",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ตโ๐ซ": {
- "name": "face with spiral eyes",
- "slug": "face_with_spiral_eyes",
- "group": "Smileys & Emotion",
- "emoji_version": "13.1",
- "unicode_version": "13.1",
- "skin_tone_support": false
- },
- "๐คฏ": {
- "name": "exploding head",
- "slug": "exploding_head",
- "group": "Smileys & Emotion",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": false
- },
- "๐ค ": {
- "name": "cowboy hat face",
- "slug": "cowboy_hat_face",
- "group": "Smileys & Emotion",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": false
- },
- "๐ฅณ": {
- "name": "partying face",
- "slug": "partying_face",
- "group": "Smileys & Emotion",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": false
- },
- "๐ฅธ": {
- "name": "disguised face",
- "slug": "disguised_face",
- "group": "Smileys & Emotion",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "smiling face with sunglasses",
- "slug": "smiling_face_with_sunglasses",
- "group": "Smileys & Emotion",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ค": {
- "name": "nerd face",
- "slug": "nerd_face",
- "group": "Smileys & Emotion",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ง": {
- "name": "face with monocle",
- "slug": "face_with_monocle",
- "group": "Smileys & Emotion",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "confused face",
- "slug": "confused_face",
- "group": "Smileys & Emotion",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ซค": {
- "name": "face with diagonal mouth",
- "slug": "face_with_diagonal_mouth",
- "group": "Smileys & Emotion",
- "emoji_version": "14.0",
- "unicode_version": "14.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "worried face",
- "slug": "worried_face",
- "group": "Smileys & Emotion",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "slightly frowning face",
- "slug": "slightly_frowning_face",
- "group": "Smileys & Emotion",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "โน๏ธ": {
- "name": "frowning face",
- "slug": "frowning_face",
- "group": "Smileys & Emotion",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐ฎ": {
- "name": "face with open mouth",
- "slug": "face_with_open_mouth",
- "group": "Smileys & Emotion",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ฏ": {
- "name": "hushed face",
- "slug": "hushed_face",
- "group": "Smileys & Emotion",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ฒ": {
- "name": "astonished face",
- "slug": "astonished_face",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ณ": {
- "name": "flushed face",
- "slug": "flushed_face",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฅบ": {
- "name": "pleading face",
- "slug": "pleading_face",
- "group": "Smileys & Emotion",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": false
- },
- "๐ฅน": {
- "name": "face holding back tears",
- "slug": "face_holding_back_tears",
- "group": "Smileys & Emotion",
- "emoji_version": "14.0",
- "unicode_version": "14.0",
- "skin_tone_support": false
- },
- "๐ฆ": {
- "name": "frowning face with open mouth",
- "slug": "frowning_face_with_open_mouth",
- "group": "Smileys & Emotion",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ง": {
- "name": "anguished face",
- "slug": "anguished_face",
- "group": "Smileys & Emotion",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐จ": {
- "name": "fearful face",
- "slug": "fearful_face",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฐ": {
- "name": "anxious face with sweat",
- "slug": "anxious_face_with_sweat",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฅ": {
- "name": "sad but relieved face",
- "slug": "sad_but_relieved_face",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ข": {
- "name": "crying face",
- "slug": "crying_face",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ญ": {
- "name": "loudly crying face",
- "slug": "loudly_crying_face",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฑ": {
- "name": "face screaming in fear",
- "slug": "face_screaming_in_fear",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "confounded face",
- "slug": "confounded_face",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฃ": {
- "name": "persevering face",
- "slug": "persevering_face",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "disappointed face",
- "slug": "disappointed_face",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "downcast face with sweat",
- "slug": "downcast_face_with_sweat",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฉ": {
- "name": "weary face",
- "slug": "weary_face",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ซ": {
- "name": "tired face",
- "slug": "tired_face",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฅฑ": {
- "name": "yawning face",
- "slug": "yawning_face",
- "group": "Smileys & Emotion",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": false
- },
- "๐ค": {
- "name": "face with steam from nose",
- "slug": "face_with_steam_from_nose",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ก": {
- "name": "enraged face",
- "slug": "enraged_face",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ ": {
- "name": "angry face",
- "slug": "angry_face",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐คฌ": {
- "name": "face with symbols on mouth",
- "slug": "face_with_symbols_on_mouth",
- "group": "Smileys & Emotion",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "smiling face with horns",
- "slug": "smiling_face_with_horns",
- "group": "Smileys & Emotion",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ฟ": {
- "name": "angry face with horns",
- "slug": "angry_face_with_horns",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "skull",
- "slug": "skull",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โ ๏ธ": {
- "name": "skull and crossbones",
- "slug": "skull_and_crossbones",
- "group": "Smileys & Emotion",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ฉ": {
- "name": "pile of poo",
- "slug": "pile_of_poo",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐คก": {
- "name": "clown face",
- "slug": "clown_face",
- "group": "Smileys & Emotion",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": false
- },
- "๐น": {
- "name": "ogre",
- "slug": "ogre",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐บ": {
- "name": "goblin",
- "slug": "goblin",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ป": {
- "name": "ghost",
- "slug": "ghost",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฝ": {
- "name": "alien",
- "slug": "alien",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐พ": {
- "name": "alien monster",
- "slug": "alien_monster",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ค": {
- "name": "robot",
- "slug": "robot",
- "group": "Smileys & Emotion",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐บ": {
- "name": "grinning cat",
- "slug": "grinning_cat",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ธ": {
- "name": "grinning cat with smiling eyes",
- "slug": "grinning_cat_with_smiling_eyes",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐น": {
- "name": "cat with tears of joy",
- "slug": "cat_with_tears_of_joy",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ป": {
- "name": "smiling cat with heart-eyes",
- "slug": "smiling_cat_with_heart_eyes",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ผ": {
- "name": "cat with wry smile",
- "slug": "cat_with_wry_smile",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฝ": {
- "name": "kissing cat",
- "slug": "kissing_cat",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "weary cat",
- "slug": "weary_cat",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฟ": {
- "name": "crying cat",
- "slug": "crying_cat",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐พ": {
- "name": "pouting cat",
- "slug": "pouting_cat",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "see-no-evil monkey",
- "slug": "see_no_evil_monkey",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "hear-no-evil monkey",
- "slug": "hear_no_evil_monkey",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "speak-no-evil monkey",
- "slug": "speak_no_evil_monkey",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "love letter",
- "slug": "love_letter",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "heart with arrow",
- "slug": "heart_with_arrow",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "heart with ribbon",
- "slug": "heart_with_ribbon",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "sparkling heart",
- "slug": "sparkling_heart",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "growing heart",
- "slug": "growing_heart",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "beating heart",
- "slug": "beating_heart",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "revolving hearts",
- "slug": "revolving_hearts",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "two hearts",
- "slug": "two_hearts",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "heart decoration",
- "slug": "heart_decoration",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โฃ๏ธ": {
- "name": "heart exclamation",
- "slug": "heart_exclamation",
- "group": "Smileys & Emotion",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "broken heart",
- "slug": "broken_heart",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โค๏ธโ๐ฅ": {
- "name": "heart on fire",
- "slug": "heart_on_fire",
- "group": "Smileys & Emotion",
- "emoji_version": "13.1",
- "unicode_version": "13.1",
- "skin_tone_support": false
- },
- "โค๏ธโ๐ฉน": {
- "name": "mending heart",
- "slug": "mending_heart",
- "group": "Smileys & Emotion",
- "emoji_version": "13.1",
- "unicode_version": "13.1",
- "skin_tone_support": false
- },
- "โค๏ธ": {
- "name": "red heart",
- "slug": "red_heart",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฉท": {
- "name": "pink heart",
- "slug": "pink_heart",
- "group": "Smileys & Emotion",
- "emoji_version": "15.0",
- "unicode_version": "15.0",
- "skin_tone_support": false
- },
- "๐งก": {
- "name": "orange heart",
- "slug": "orange_heart",
- "group": "Smileys & Emotion",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "yellow heart",
- "slug": "yellow_heart",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "green heart",
- "slug": "green_heart",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "blue heart",
- "slug": "blue_heart",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฉต": {
- "name": "light blue heart",
- "slug": "light_blue_heart",
- "group": "Smileys & Emotion",
- "emoji_version": "15.0",
- "unicode_version": "15.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "purple heart",
- "slug": "purple_heart",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ค": {
- "name": "brown heart",
- "slug": "brown_heart",
- "group": "Smileys & Emotion",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": false
- },
- "๐ค": {
- "name": "black heart",
- "slug": "black_heart",
- "group": "Smileys & Emotion",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": false
- },
- "๐ฉถ": {
- "name": "grey heart",
- "slug": "grey_heart",
- "group": "Smileys & Emotion",
- "emoji_version": "15.0",
- "unicode_version": "15.0",
- "skin_tone_support": false
- },
- "๐ค": {
- "name": "white heart",
- "slug": "white_heart",
- "group": "Smileys & Emotion",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "kiss mark",
- "slug": "kiss_mark",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฏ": {
- "name": "hundred points",
- "slug": "hundred_points",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ข": {
- "name": "anger symbol",
- "slug": "anger_symbol",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฅ": {
- "name": "collision",
- "slug": "collision",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ซ": {
- "name": "dizzy",
- "slug": "dizzy",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฆ": {
- "name": "sweat droplets",
- "slug": "sweat_droplets",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐จ": {
- "name": "dashing away",
- "slug": "dashing_away",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ณ๏ธ": {
- "name": "hole",
- "slug": "hole",
- "group": "Smileys & Emotion",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐ฌ": {
- "name": "speech balloon",
- "slug": "speech_balloon",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐๏ธโ๐จ๏ธ": {
- "name": "eye in speech bubble",
- "slug": "eye_in_speech_bubble",
- "group": "Smileys & Emotion",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐จ๏ธ": {
- "name": "left speech bubble",
- "slug": "left_speech_bubble",
- "group": "Smileys & Emotion",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฏ๏ธ": {
- "name": "right anger bubble",
- "slug": "right_anger_bubble",
- "group": "Smileys & Emotion",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐ญ": {
- "name": "thought balloon",
- "slug": "thought_balloon",
- "group": "Smileys & Emotion",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ค": {
- "name": "ZZZ",
- "slug": "zzz",
- "group": "Smileys & Emotion",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "waving hand",
- "slug": "waving_hand",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "๐ค": {
- "name": "raised back of hand",
- "slug": "raised_back_of_hand",
- "group": "People & Body",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "3.0"
- },
- "๐๏ธ": {
- "name": "hand with fingers splayed",
- "slug": "hand_with_fingers_splayed",
- "group": "People & Body",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "โ": {
- "name": "raised hand",
- "slug": "raised_hand",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "๐": {
- "name": "vulcan salute",
- "slug": "vulcan_salute",
- "group": "People & Body",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "๐ซฑ": {
- "name": "rightwards hand",
- "slug": "rightwards_hand",
- "group": "People & Body",
- "emoji_version": "14.0",
- "unicode_version": "14.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "14.0"
- },
- "๐ซฒ": {
- "name": "leftwards hand",
- "slug": "leftwards_hand",
- "group": "People & Body",
- "emoji_version": "14.0",
- "unicode_version": "14.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "14.0"
- },
- "๐ซณ": {
- "name": "palm down hand",
- "slug": "palm_down_hand",
- "group": "People & Body",
- "emoji_version": "14.0",
- "unicode_version": "14.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "14.0"
- },
- "๐ซด": {
- "name": "palm up hand",
- "slug": "palm_up_hand",
- "group": "People & Body",
- "emoji_version": "14.0",
- "unicode_version": "14.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "14.0"
- },
- "๐ซท": {
- "name": "leftwards pushing hand",
- "slug": "leftwards_pushing_hand",
- "group": "People & Body",
- "emoji_version": "15.0",
- "unicode_version": "15.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "15.0"
- },
- "๐ซธ": {
- "name": "rightwards pushing hand",
- "slug": "rightwards_pushing_hand",
- "group": "People & Body",
- "emoji_version": "15.0",
- "unicode_version": "15.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "15.0"
- },
- "๐": {
- "name": "OK hand",
- "slug": "ok_hand",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "๐ค": {
- "name": "pinched fingers",
- "slug": "pinched_fingers",
- "group": "People & Body",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "13.0"
- },
- "๐ค": {
- "name": "pinching hand",
- "slug": "pinching_hand",
- "group": "People & Body",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "12.0"
- },
- "โ๏ธ": {
- "name": "victory hand",
- "slug": "victory_hand",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "๐ค": {
- "name": "crossed fingers",
- "slug": "crossed_fingers",
- "group": "People & Body",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "3.0"
- },
- "๐ซฐ": {
- "name": "hand with index finger and thumb crossed",
- "slug": "hand_with_index_finger_and_thumb_crossed",
- "group": "People & Body",
- "emoji_version": "14.0",
- "unicode_version": "14.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "14.0"
- },
- "๐ค": {
- "name": "love-you gesture",
- "slug": "love_you_gesture",
- "group": "People & Body",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "5.0"
- },
- "๐ค": {
- "name": "sign of the horns",
- "slug": "sign_of_the_horns",
- "group": "People & Body",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "๐ค": {
- "name": "call me hand",
- "slug": "call_me_hand",
- "group": "People & Body",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "3.0"
- },
- "๐": {
- "name": "backhand index pointing left",
- "slug": "backhand_index_pointing_left",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "๐": {
- "name": "backhand index pointing right",
- "slug": "backhand_index_pointing_right",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "๐": {
- "name": "backhand index pointing up",
- "slug": "backhand_index_pointing_up",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "๐": {
- "name": "middle finger",
- "slug": "middle_finger",
- "group": "People & Body",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "๐": {
- "name": "backhand index pointing down",
- "slug": "backhand_index_pointing_down",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "โ๏ธ": {
- "name": "index pointing up",
- "slug": "index_pointing_up",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "๐ซต": {
- "name": "index pointing at the viewer",
- "slug": "index_pointing_at_the_viewer",
- "group": "People & Body",
- "emoji_version": "14.0",
- "unicode_version": "14.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "14.0"
- },
- "๐": {
- "name": "thumbs up",
- "slug": "thumbs_up",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "๐": {
- "name": "thumbs down",
- "slug": "thumbs_down",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "โ": {
- "name": "raised fist",
- "slug": "raised_fist",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "๐": {
- "name": "oncoming fist",
- "slug": "oncoming_fist",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "๐ค": {
- "name": "left-facing fist",
- "slug": "left_facing_fist",
- "group": "People & Body",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "3.0"
- },
- "๐ค": {
- "name": "right-facing fist",
- "slug": "right_facing_fist",
- "group": "People & Body",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "3.0"
- },
- "๐": {
- "name": "clapping hands",
- "slug": "clapping_hands",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "๐": {
- "name": "raising hands",
- "slug": "raising_hands",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "๐ซถ": {
- "name": "heart hands",
- "slug": "heart_hands",
- "group": "People & Body",
- "emoji_version": "14.0",
- "unicode_version": "14.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "14.0"
- },
- "๐": {
- "name": "open hands",
- "slug": "open_hands",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "๐คฒ": {
- "name": "palms up together",
- "slug": "palms_up_together",
- "group": "People & Body",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "5.0"
- },
- "๐ค": {
- "name": "handshake",
- "slug": "handshake",
- "group": "People & Body",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "14.0"
- },
- "๐": {
- "name": "folded hands",
- "slug": "folded_hands",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "โ๏ธ": {
- "name": "writing hand",
- "slug": "writing_hand",
- "group": "People & Body",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "๐
": {
- "name": "nail polish",
- "slug": "nail_polish",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "๐คณ": {
- "name": "selfie",
- "slug": "selfie",
- "group": "People & Body",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "3.0"
- },
- "๐ช": {
- "name": "flexed biceps",
- "slug": "flexed_biceps",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "๐ฆพ": {
- "name": "mechanical arm",
- "slug": "mechanical_arm",
- "group": "People & Body",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": false
- },
- "๐ฆฟ": {
- "name": "mechanical leg",
- "slug": "mechanical_leg",
- "group": "People & Body",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": false
- },
- "๐ฆต": {
- "name": "leg",
- "slug": "leg",
- "group": "People & Body",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "11.0"
- },
- "๐ฆถ": {
- "name": "foot",
- "slug": "foot",
- "group": "People & Body",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "11.0"
- },
- "๐": {
- "name": "ear",
- "slug": "ear",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "๐ฆป": {
- "name": "ear with hearing aid",
- "slug": "ear_with_hearing_aid",
- "group": "People & Body",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "12.0"
- },
- "๐": {
- "name": "nose",
- "slug": "nose",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "๐ง ": {
- "name": "brain",
- "slug": "brain",
- "group": "People & Body",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": false
- },
- "๐ซ": {
- "name": "anatomical heart",
- "slug": "anatomical_heart",
- "group": "People & Body",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": false
- },
- "๐ซ": {
- "name": "lungs",
- "slug": "lungs",
- "group": "People & Body",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": false
- },
- "๐ฆท": {
- "name": "tooth",
- "slug": "tooth",
- "group": "People & Body",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": false
- },
- "๐ฆด": {
- "name": "bone",
- "slug": "bone",
- "group": "People & Body",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "eyes",
- "slug": "eyes",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐๏ธ": {
- "name": "eye",
- "slug": "eye",
- "group": "People & Body",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐
": {
- "name": "tongue",
- "slug": "tongue",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "mouth",
- "slug": "mouth",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ซฆ": {
- "name": "biting lip",
- "slug": "biting_lip",
- "group": "People & Body",
- "emoji_version": "14.0",
- "unicode_version": "14.0",
- "skin_tone_support": false
- },
- "๐ถ": {
- "name": "baby",
- "slug": "baby",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "๐ง": {
- "name": "child",
- "slug": "child",
- "group": "People & Body",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "5.0"
- },
- "๐ฆ": {
- "name": "boy",
- "slug": "boy",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "๐ง": {
- "name": "girl",
- "slug": "girl",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "๐ง": {
- "name": "person",
- "slug": "person",
- "group": "People & Body",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "5.0"
- },
- "๐ฑ": {
- "name": "person blond hair",
- "slug": "person_blond_hair",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "๐จ": {
- "name": "man",
- "slug": "man",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "๐ง": {
- "name": "person beard",
- "slug": "person_beard",
- "group": "People & Body",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "5.0"
- },
- "๐งโโ๏ธ": {
- "name": "man beard",
- "slug": "man_beard",
- "group": "People & Body",
- "emoji_version": "13.1",
- "unicode_version": "13.1",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "13.1"
- },
- "๐งโโ๏ธ": {
- "name": "woman beard",
- "slug": "woman_beard",
- "group": "People & Body",
- "emoji_version": "13.1",
- "unicode_version": "13.1",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "13.1"
- },
- "๐จโ๐ฆฐ": {
- "name": "man red hair",
- "slug": "man_red_hair",
- "group": "People & Body",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "11.0"
- },
- "๐จโ๐ฆฑ": {
- "name": "man curly hair",
- "slug": "man_curly_hair",
- "group": "People & Body",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "11.0"
- },
- "๐จโ๐ฆณ": {
- "name": "man white hair",
- "slug": "man_white_hair",
- "group": "People & Body",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "11.0"
- },
- "๐จโ๐ฆฒ": {
- "name": "man bald",
- "slug": "man_bald",
- "group": "People & Body",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "11.0"
- },
- "๐ฉ": {
- "name": "woman",
- "slug": "woman",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "๐ฉโ๐ฆฐ": {
- "name": "woman red hair",
- "slug": "woman_red_hair",
- "group": "People & Body",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "11.0"
- },
- "๐งโ๐ฆฐ": {
- "name": "person red hair",
- "slug": "person_red_hair",
- "group": "People & Body",
- "emoji_version": "12.1",
- "unicode_version": "12.1",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "12.1"
- },
- "๐ฉโ๐ฆฑ": {
- "name": "woman curly hair",
- "slug": "woman_curly_hair",
- "group": "People & Body",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "11.0"
- },
- "๐งโ๐ฆฑ": {
- "name": "person curly hair",
- "slug": "person_curly_hair",
- "group": "People & Body",
- "emoji_version": "12.1",
- "unicode_version": "12.1",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "12.1"
- },
- "๐ฉโ๐ฆณ": {
- "name": "woman white hair",
- "slug": "woman_white_hair",
- "group": "People & Body",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "11.0"
- },
- "๐งโ๐ฆณ": {
- "name": "person white hair",
- "slug": "person_white_hair",
- "group": "People & Body",
- "emoji_version": "12.1",
- "unicode_version": "12.1",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "12.1"
- },
- "๐ฉโ๐ฆฒ": {
- "name": "woman bald",
- "slug": "woman_bald",
- "group": "People & Body",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "11.0"
- },
- "๐งโ๐ฆฒ": {
- "name": "person bald",
- "slug": "person_bald",
- "group": "People & Body",
- "emoji_version": "12.1",
- "unicode_version": "12.1",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "12.1"
- },
- "๐ฑโโ๏ธ": {
- "name": "woman blond hair",
- "slug": "woman_blond_hair",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐ฑโโ๏ธ": {
- "name": "man blond hair",
- "slug": "man_blond_hair",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐ง": {
- "name": "older person",
- "slug": "older_person",
- "group": "People & Body",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "5.0"
- },
- "๐ด": {
- "name": "old man",
- "slug": "old_man",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "๐ต": {
- "name": "old woman",
- "slug": "old_woman",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "๐": {
- "name": "person frowning",
- "slug": "person_frowning",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "๐โโ๏ธ": {
- "name": "man frowning",
- "slug": "man_frowning",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐โโ๏ธ": {
- "name": "woman frowning",
- "slug": "woman_frowning",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐": {
- "name": "person pouting",
- "slug": "person_pouting",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "๐โโ๏ธ": {
- "name": "man pouting",
- "slug": "man_pouting",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐โโ๏ธ": {
- "name": "woman pouting",
- "slug": "woman_pouting",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐
": {
- "name": "person gesturing NO",
- "slug": "person_gesturing_no",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "๐
โโ๏ธ": {
- "name": "man gesturing NO",
- "slug": "man_gesturing_no",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐
โโ๏ธ": {
- "name": "woman gesturing NO",
- "slug": "woman_gesturing_no",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐": {
- "name": "person gesturing OK",
- "slug": "person_gesturing_ok",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "๐โโ๏ธ": {
- "name": "man gesturing OK",
- "slug": "man_gesturing_ok",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐โโ๏ธ": {
- "name": "woman gesturing OK",
- "slug": "woman_gesturing_ok",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐": {
- "name": "person tipping hand",
- "slug": "person_tipping_hand",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "๐โโ๏ธ": {
- "name": "man tipping hand",
- "slug": "man_tipping_hand",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐โโ๏ธ": {
- "name": "woman tipping hand",
- "slug": "woman_tipping_hand",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐": {
- "name": "person raising hand",
- "slug": "person_raising_hand",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "๐โโ๏ธ": {
- "name": "man raising hand",
- "slug": "man_raising_hand",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐โโ๏ธ": {
- "name": "woman raising hand",
- "slug": "woman_raising_hand",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐ง": {
- "name": "deaf person",
- "slug": "deaf_person",
- "group": "People & Body",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "12.0"
- },
- "๐งโโ๏ธ": {
- "name": "deaf man",
- "slug": "deaf_man",
- "group": "People & Body",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "12.0"
- },
- "๐งโโ๏ธ": {
- "name": "deaf woman",
- "slug": "deaf_woman",
- "group": "People & Body",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "12.0"
- },
- "๐": {
- "name": "person bowing",
- "slug": "person_bowing",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "๐โโ๏ธ": {
- "name": "man bowing",
- "slug": "man_bowing",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐โโ๏ธ": {
- "name": "woman bowing",
- "slug": "woman_bowing",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐คฆ": {
- "name": "person facepalming",
- "slug": "person_facepalming",
- "group": "People & Body",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "3.0"
- },
- "๐คฆโโ๏ธ": {
- "name": "man facepalming",
- "slug": "man_facepalming",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐คฆโโ๏ธ": {
- "name": "woman facepalming",
- "slug": "woman_facepalming",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐คท": {
- "name": "person shrugging",
- "slug": "person_shrugging",
- "group": "People & Body",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "3.0"
- },
- "๐คทโโ๏ธ": {
- "name": "man shrugging",
- "slug": "man_shrugging",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐คทโโ๏ธ": {
- "name": "woman shrugging",
- "slug": "woman_shrugging",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐งโโ๏ธ": {
- "name": "health worker",
- "slug": "health_worker",
- "group": "People & Body",
- "emoji_version": "12.1",
- "unicode_version": "12.1",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "12.1"
- },
- "๐จโโ๏ธ": {
- "name": "man health worker",
- "slug": "man_health_worker",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐ฉโโ๏ธ": {
- "name": "woman health worker",
- "slug": "woman_health_worker",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐งโ๐": {
- "name": "student",
- "slug": "student",
- "group": "People & Body",
- "emoji_version": "12.1",
- "unicode_version": "12.1",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "12.1"
- },
- "๐จโ๐": {
- "name": "man student",
- "slug": "man_student",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐ฉโ๐": {
- "name": "woman student",
- "slug": "woman_student",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐งโ๐ซ": {
- "name": "teacher",
- "slug": "teacher",
- "group": "People & Body",
- "emoji_version": "12.1",
- "unicode_version": "12.1",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "12.1"
- },
- "๐จโ๐ซ": {
- "name": "man teacher",
- "slug": "man_teacher",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐ฉโ๐ซ": {
- "name": "woman teacher",
- "slug": "woman_teacher",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐งโโ๏ธ": {
- "name": "judge",
- "slug": "judge",
- "group": "People & Body",
- "emoji_version": "12.1",
- "unicode_version": "12.1",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "12.1"
- },
- "๐จโโ๏ธ": {
- "name": "man judge",
- "slug": "man_judge",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐ฉโโ๏ธ": {
- "name": "woman judge",
- "slug": "woman_judge",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐งโ๐พ": {
- "name": "farmer",
- "slug": "farmer",
- "group": "People & Body",
- "emoji_version": "12.1",
- "unicode_version": "12.1",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "12.1"
- },
- "๐จโ๐พ": {
- "name": "man farmer",
- "slug": "man_farmer",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐ฉโ๐พ": {
- "name": "woman farmer",
- "slug": "woman_farmer",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐งโ๐ณ": {
- "name": "cook",
- "slug": "cook",
- "group": "People & Body",
- "emoji_version": "12.1",
- "unicode_version": "12.1",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "12.1"
- },
- "๐จโ๐ณ": {
- "name": "man cook",
- "slug": "man_cook",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐ฉโ๐ณ": {
- "name": "woman cook",
- "slug": "woman_cook",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐งโ๐ง": {
- "name": "mechanic",
- "slug": "mechanic",
- "group": "People & Body",
- "emoji_version": "12.1",
- "unicode_version": "12.1",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "12.1"
- },
- "๐จโ๐ง": {
- "name": "man mechanic",
- "slug": "man_mechanic",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐ฉโ๐ง": {
- "name": "woman mechanic",
- "slug": "woman_mechanic",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐งโ๐ญ": {
- "name": "factory worker",
- "slug": "factory_worker",
- "group": "People & Body",
- "emoji_version": "12.1",
- "unicode_version": "12.1",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "12.1"
- },
- "๐จโ๐ญ": {
- "name": "man factory worker",
- "slug": "man_factory_worker",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐ฉโ๐ญ": {
- "name": "woman factory worker",
- "slug": "woman_factory_worker",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐งโ๐ผ": {
- "name": "office worker",
- "slug": "office_worker",
- "group": "People & Body",
- "emoji_version": "12.1",
- "unicode_version": "12.1",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "12.1"
- },
- "๐จโ๐ผ": {
- "name": "man office worker",
- "slug": "man_office_worker",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐ฉโ๐ผ": {
- "name": "woman office worker",
- "slug": "woman_office_worker",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐งโ๐ฌ": {
- "name": "scientist",
- "slug": "scientist",
- "group": "People & Body",
- "emoji_version": "12.1",
- "unicode_version": "12.1",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "12.1"
- },
- "๐จโ๐ฌ": {
- "name": "man scientist",
- "slug": "man_scientist",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐ฉโ๐ฌ": {
- "name": "woman scientist",
- "slug": "woman_scientist",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐งโ๐ป": {
- "name": "technologist",
- "slug": "technologist",
- "group": "People & Body",
- "emoji_version": "12.1",
- "unicode_version": "12.1",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "12.1"
- },
- "๐จโ๐ป": {
- "name": "man technologist",
- "slug": "man_technologist",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐ฉโ๐ป": {
- "name": "woman technologist",
- "slug": "woman_technologist",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐งโ๐ค": {
- "name": "singer",
- "slug": "singer",
- "group": "People & Body",
- "emoji_version": "12.1",
- "unicode_version": "12.1",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "12.1"
- },
- "๐จโ๐ค": {
- "name": "man singer",
- "slug": "man_singer",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐ฉโ๐ค": {
- "name": "woman singer",
- "slug": "woman_singer",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐งโ๐จ": {
- "name": "artist",
- "slug": "artist",
- "group": "People & Body",
- "emoji_version": "12.1",
- "unicode_version": "12.1",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "12.1"
- },
- "๐จโ๐จ": {
- "name": "man artist",
- "slug": "man_artist",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐ฉโ๐จ": {
- "name": "woman artist",
- "slug": "woman_artist",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐งโโ๏ธ": {
- "name": "pilot",
- "slug": "pilot",
- "group": "People & Body",
- "emoji_version": "12.1",
- "unicode_version": "12.1",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "12.1"
- },
- "๐จโโ๏ธ": {
- "name": "man pilot",
- "slug": "man_pilot",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐ฉโโ๏ธ": {
- "name": "woman pilot",
- "slug": "woman_pilot",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐งโ๐": {
- "name": "astronaut",
- "slug": "astronaut",
- "group": "People & Body",
- "emoji_version": "12.1",
- "unicode_version": "12.1",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "12.1"
- },
- "๐จโ๐": {
- "name": "man astronaut",
- "slug": "man_astronaut",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐ฉโ๐": {
- "name": "woman astronaut",
- "slug": "woman_astronaut",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐งโ๐": {
- "name": "firefighter",
- "slug": "firefighter",
- "group": "People & Body",
- "emoji_version": "12.1",
- "unicode_version": "12.1",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "12.1"
- },
- "๐จโ๐": {
- "name": "man firefighter",
- "slug": "man_firefighter",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐ฉโ๐": {
- "name": "woman firefighter",
- "slug": "woman_firefighter",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐ฎ": {
- "name": "police officer",
- "slug": "police_officer",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "๐ฎโโ๏ธ": {
- "name": "man police officer",
- "slug": "man_police_officer",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐ฎโโ๏ธ": {
- "name": "woman police officer",
- "slug": "woman_police_officer",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐ต๏ธ": {
- "name": "detective",
- "slug": "detective",
- "group": "People & Body",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "2.0"
- },
- "๐ต๏ธโโ๏ธ": {
- "name": "man detective",
- "slug": "man_detective",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐ต๏ธโโ๏ธ": {
- "name": "woman detective",
- "slug": "woman_detective",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐": {
- "name": "guard",
- "slug": "guard",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "๐โโ๏ธ": {
- "name": "man guard",
- "slug": "man_guard",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐โโ๏ธ": {
- "name": "woman guard",
- "slug": "woman_guard",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐ฅท": {
- "name": "ninja",
- "slug": "ninja",
- "group": "People & Body",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "13.0"
- },
- "๐ท": {
- "name": "construction worker",
- "slug": "construction_worker",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "๐ทโโ๏ธ": {
- "name": "man construction worker",
- "slug": "man_construction_worker",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐ทโโ๏ธ": {
- "name": "woman construction worker",
- "slug": "woman_construction_worker",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐ซ
": {
- "name": "person with crown",
- "slug": "person_with_crown",
- "group": "People & Body",
- "emoji_version": "14.0",
- "unicode_version": "14.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "14.0"
- },
- "๐คด": {
- "name": "prince",
- "slug": "prince",
- "group": "People & Body",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "3.0"
- },
- "๐ธ": {
- "name": "princess",
- "slug": "princess",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "๐ณ": {
- "name": "person wearing turban",
- "slug": "person_wearing_turban",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "๐ณโโ๏ธ": {
- "name": "man wearing turban",
- "slug": "man_wearing_turban",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐ณโโ๏ธ": {
- "name": "woman wearing turban",
- "slug": "woman_wearing_turban",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐ฒ": {
- "name": "person with skullcap",
- "slug": "person_with_skullcap",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "๐ง": {
- "name": "woman with headscarf",
- "slug": "woman_with_headscarf",
- "group": "People & Body",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "5.0"
- },
- "๐คต": {
- "name": "person in tuxedo",
- "slug": "person_in_tuxedo",
- "group": "People & Body",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "3.0"
- },
- "๐คตโโ๏ธ": {
- "name": "man in tuxedo",
- "slug": "man_in_tuxedo",
- "group": "People & Body",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "13.0"
- },
- "๐คตโโ๏ธ": {
- "name": "woman in tuxedo",
- "slug": "woman_in_tuxedo",
- "group": "People & Body",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "13.0"
- },
- "๐ฐ": {
- "name": "person with veil",
- "slug": "person_with_veil",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "๐ฐโโ๏ธ": {
- "name": "man with veil",
- "slug": "man_with_veil",
- "group": "People & Body",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "13.0"
- },
- "๐ฐโโ๏ธ": {
- "name": "woman with veil",
- "slug": "woman_with_veil",
- "group": "People & Body",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "13.0"
- },
- "๐คฐ": {
- "name": "pregnant woman",
- "slug": "pregnant_woman",
- "group": "People & Body",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "3.0"
- },
- "๐ซ": {
- "name": "pregnant man",
- "slug": "pregnant_man",
- "group": "People & Body",
- "emoji_version": "14.0",
- "unicode_version": "14.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "14.0"
- },
- "๐ซ": {
- "name": "pregnant person",
- "slug": "pregnant_person",
- "group": "People & Body",
- "emoji_version": "14.0",
- "unicode_version": "14.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "14.0"
- },
- "๐คฑ": {
- "name": "breast-feeding",
- "slug": "breast_feeding",
- "group": "People & Body",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "5.0"
- },
- "๐ฉโ๐ผ": {
- "name": "woman feeding baby",
- "slug": "woman_feeding_baby",
- "group": "People & Body",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "13.0"
- },
- "๐จโ๐ผ": {
- "name": "man feeding baby",
- "slug": "man_feeding_baby",
- "group": "People & Body",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "13.0"
- },
- "๐งโ๐ผ": {
- "name": "person feeding baby",
- "slug": "person_feeding_baby",
- "group": "People & Body",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "13.0"
- },
- "๐ผ": {
- "name": "baby angel",
- "slug": "baby_angel",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "๐
": {
- "name": "Santa Claus",
- "slug": "santa_claus",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "๐คถ": {
- "name": "Mrs. Claus",
- "slug": "mrs_claus",
- "group": "People & Body",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "3.0"
- },
- "๐งโ๐": {
- "name": "mx claus",
- "slug": "mx_claus",
- "group": "People & Body",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "13.0"
- },
- "๐ฆธ": {
- "name": "superhero",
- "slug": "superhero",
- "group": "People & Body",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "11.0"
- },
- "๐ฆธโโ๏ธ": {
- "name": "man superhero",
- "slug": "man_superhero",
- "group": "People & Body",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "11.0"
- },
- "๐ฆธโโ๏ธ": {
- "name": "woman superhero",
- "slug": "woman_superhero",
- "group": "People & Body",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "11.0"
- },
- "๐ฆน": {
- "name": "supervillain",
- "slug": "supervillain",
- "group": "People & Body",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "11.0"
- },
- "๐ฆนโโ๏ธ": {
- "name": "man supervillain",
- "slug": "man_supervillain",
- "group": "People & Body",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "11.0"
- },
- "๐ฆนโโ๏ธ": {
- "name": "woman supervillain",
- "slug": "woman_supervillain",
- "group": "People & Body",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "11.0"
- },
- "๐ง": {
- "name": "mage",
- "slug": "mage",
- "group": "People & Body",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "5.0"
- },
- "๐งโโ๏ธ": {
- "name": "man mage",
- "slug": "man_mage",
- "group": "People & Body",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "5.0"
- },
- "๐งโโ๏ธ": {
- "name": "woman mage",
- "slug": "woman_mage",
- "group": "People & Body",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "5.0"
- },
- "๐ง": {
- "name": "fairy",
- "slug": "fairy",
- "group": "People & Body",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "5.0"
- },
- "๐งโโ๏ธ": {
- "name": "man fairy",
- "slug": "man_fairy",
- "group": "People & Body",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "5.0"
- },
- "๐งโโ๏ธ": {
- "name": "woman fairy",
- "slug": "woman_fairy",
- "group": "People & Body",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "5.0"
- },
- "๐ง": {
- "name": "vampire",
- "slug": "vampire",
- "group": "People & Body",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "5.0"
- },
- "๐งโโ๏ธ": {
- "name": "man vampire",
- "slug": "man_vampire",
- "group": "People & Body",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "5.0"
- },
- "๐งโโ๏ธ": {
- "name": "woman vampire",
- "slug": "woman_vampire",
- "group": "People & Body",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "5.0"
- },
- "๐ง": {
- "name": "merperson",
- "slug": "merperson",
- "group": "People & Body",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "5.0"
- },
- "๐งโโ๏ธ": {
- "name": "merman",
- "slug": "merman",
- "group": "People & Body",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "5.0"
- },
- "๐งโโ๏ธ": {
- "name": "mermaid",
- "slug": "mermaid",
- "group": "People & Body",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "5.0"
- },
- "๐ง": {
- "name": "elf",
- "slug": "elf",
- "group": "People & Body",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "5.0"
- },
- "๐งโโ๏ธ": {
- "name": "man elf",
- "slug": "man_elf",
- "group": "People & Body",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "5.0"
- },
- "๐งโโ๏ธ": {
- "name": "woman elf",
- "slug": "woman_elf",
- "group": "People & Body",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "5.0"
- },
- "๐ง": {
- "name": "genie",
- "slug": "genie",
- "group": "People & Body",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": false
- },
- "๐งโโ๏ธ": {
- "name": "man genie",
- "slug": "man_genie",
- "group": "People & Body",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": false
- },
- "๐งโโ๏ธ": {
- "name": "woman genie",
- "slug": "woman_genie",
- "group": "People & Body",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": false
- },
- "๐ง": {
- "name": "zombie",
- "slug": "zombie",
- "group": "People & Body",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": false
- },
- "๐งโโ๏ธ": {
- "name": "man zombie",
- "slug": "man_zombie",
- "group": "People & Body",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": false
- },
- "๐งโโ๏ธ": {
- "name": "woman zombie",
- "slug": "woman_zombie",
- "group": "People & Body",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": false
- },
- "๐ง": {
- "name": "troll",
- "slug": "troll",
- "group": "People & Body",
- "emoji_version": "14.0",
- "unicode_version": "14.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "person getting massage",
- "slug": "person_getting_massage",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "๐โโ๏ธ": {
- "name": "man getting massage",
- "slug": "man_getting_massage",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐โโ๏ธ": {
- "name": "woman getting massage",
- "slug": "woman_getting_massage",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐": {
- "name": "person getting haircut",
- "slug": "person_getting_haircut",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "๐โโ๏ธ": {
- "name": "man getting haircut",
- "slug": "man_getting_haircut",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐โโ๏ธ": {
- "name": "woman getting haircut",
- "slug": "woman_getting_haircut",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐ถ": {
- "name": "person walking",
- "slug": "person_walking",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "๐ถโโ๏ธ": {
- "name": "man walking",
- "slug": "man_walking",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐ถโโ๏ธ": {
- "name": "woman walking",
- "slug": "woman_walking",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐ถโโก๏ธ": {
- "name": "person walking facing right",
- "slug": "person_walking_facing_right",
- "group": "People & Body",
- "emoji_version": "15.1",
- "unicode_version": "15.1",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "15.1"
- },
- "๐ถโโ๏ธโโก๏ธ": {
- "name": "woman walking facing right",
- "slug": "woman_walking_facing_right",
- "group": "People & Body",
- "emoji_version": "15.1",
- "unicode_version": "15.1",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "15.1"
- },
- "๐ถโโ๏ธโโก๏ธ": {
- "name": "man walking facing right",
- "slug": "man_walking_facing_right",
- "group": "People & Body",
- "emoji_version": "15.1",
- "unicode_version": "15.1",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "15.1"
- },
- "๐ง": {
- "name": "person standing",
- "slug": "person_standing",
- "group": "People & Body",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "12.0"
- },
- "๐งโโ๏ธ": {
- "name": "man standing",
- "slug": "man_standing",
- "group": "People & Body",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "12.0"
- },
- "๐งโโ๏ธ": {
- "name": "woman standing",
- "slug": "woman_standing",
- "group": "People & Body",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "12.0"
- },
- "๐ง": {
- "name": "person kneeling",
- "slug": "person_kneeling",
- "group": "People & Body",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "12.0"
- },
- "๐งโโ๏ธ": {
- "name": "man kneeling",
- "slug": "man_kneeling",
- "group": "People & Body",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "12.0"
- },
- "๐งโโ๏ธ": {
- "name": "woman kneeling",
- "slug": "woman_kneeling",
- "group": "People & Body",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "12.0"
- },
- "๐งโโก๏ธ": {
- "name": "person kneeling facing right",
- "slug": "person_kneeling_facing_right",
- "group": "People & Body",
- "emoji_version": "15.1",
- "unicode_version": "15.1",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "15.1"
- },
- "๐งโโ๏ธโโก๏ธ": {
- "name": "woman kneeling facing right",
- "slug": "woman_kneeling_facing_right",
- "group": "People & Body",
- "emoji_version": "15.1",
- "unicode_version": "15.1",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "15.1"
- },
- "๐งโโ๏ธโโก๏ธ": {
- "name": "man kneeling facing right",
- "slug": "man_kneeling_facing_right",
- "group": "People & Body",
- "emoji_version": "15.1",
- "unicode_version": "15.1",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "15.1"
- },
- "๐งโ๐ฆฏ": {
- "name": "person with white cane",
- "slug": "person_with_white_cane",
- "group": "People & Body",
- "emoji_version": "12.1",
- "unicode_version": "12.1",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "12.1"
- },
- "๐งโ๐ฆฏโโก๏ธ": {
- "name": "person with white cane facing right",
- "slug": "person_with_white_cane_facing_right",
- "group": "People & Body",
- "emoji_version": "15.1",
- "unicode_version": "15.1",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "15.1"
- },
- "๐จโ๐ฆฏ": {
- "name": "man with white cane",
- "slug": "man_with_white_cane",
- "group": "People & Body",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "12.0"
- },
- "๐จโ๐ฆฏโโก๏ธ": {
- "name": "man with white cane facing right",
- "slug": "man_with_white_cane_facing_right",
- "group": "People & Body",
- "emoji_version": "15.1",
- "unicode_version": "15.1",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "15.1"
- },
- "๐ฉโ๐ฆฏ": {
- "name": "woman with white cane",
- "slug": "woman_with_white_cane",
- "group": "People & Body",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "12.0"
- },
- "๐ฉโ๐ฆฏโโก๏ธ": {
- "name": "woman with white cane facing right",
- "slug": "woman_with_white_cane_facing_right",
- "group": "People & Body",
- "emoji_version": "15.1",
- "unicode_version": "15.1",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "15.1"
- },
- "๐งโ๐ฆผ": {
- "name": "person in motorized wheelchair",
- "slug": "person_in_motorized_wheelchair",
- "group": "People & Body",
- "emoji_version": "12.1",
- "unicode_version": "12.1",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "12.1"
- },
- "๐งโ๐ฆผโโก๏ธ": {
- "name": "person in motorized wheelchair facing right",
- "slug": "person_in_motorized_wheelchair_facing_right",
- "group": "People & Body",
- "emoji_version": "15.1",
- "unicode_version": "15.1",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "15.1"
- },
- "๐จโ๐ฆผ": {
- "name": "man in motorized wheelchair",
- "slug": "man_in_motorized_wheelchair",
- "group": "People & Body",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "12.0"
- },
- "๐จโ๐ฆผโโก๏ธ": {
- "name": "man in motorized wheelchair facing right",
- "slug": "man_in_motorized_wheelchair_facing_right",
- "group": "People & Body",
- "emoji_version": "15.1",
- "unicode_version": "15.1",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "15.1"
- },
- "๐ฉโ๐ฆผ": {
- "name": "woman in motorized wheelchair",
- "slug": "woman_in_motorized_wheelchair",
- "group": "People & Body",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "12.0"
- },
- "๐ฉโ๐ฆผโโก๏ธ": {
- "name": "woman in motorized wheelchair facing right",
- "slug": "woman_in_motorized_wheelchair_facing_right",
- "group": "People & Body",
- "emoji_version": "15.1",
- "unicode_version": "15.1",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "15.1"
- },
- "๐งโ๐ฆฝ": {
- "name": "person in manual wheelchair",
- "slug": "person_in_manual_wheelchair",
- "group": "People & Body",
- "emoji_version": "12.1",
- "unicode_version": "12.1",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "12.1"
- },
- "๐งโ๐ฆฝโโก๏ธ": {
- "name": "person in manual wheelchair facing right",
- "slug": "person_in_manual_wheelchair_facing_right",
- "group": "People & Body",
- "emoji_version": "15.1",
- "unicode_version": "15.1",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "15.1"
- },
- "๐จโ๐ฆฝ": {
- "name": "man in manual wheelchair",
- "slug": "man_in_manual_wheelchair",
- "group": "People & Body",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "12.0"
- },
- "๐จโ๐ฆฝโโก๏ธ": {
- "name": "man in manual wheelchair facing right",
- "slug": "man_in_manual_wheelchair_facing_right",
- "group": "People & Body",
- "emoji_version": "15.1",
- "unicode_version": "15.1",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "15.1"
- },
- "๐ฉโ๐ฆฝ": {
- "name": "woman in manual wheelchair",
- "slug": "woman_in_manual_wheelchair",
- "group": "People & Body",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "12.0"
- },
- "๐ฉโ๐ฆฝโโก๏ธ": {
- "name": "woman in manual wheelchair facing right",
- "slug": "woman_in_manual_wheelchair_facing_right",
- "group": "People & Body",
- "emoji_version": "15.1",
- "unicode_version": "15.1",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "15.1"
- },
- "๐": {
- "name": "person running",
- "slug": "person_running",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "๐โโ๏ธ": {
- "name": "man running",
- "slug": "man_running",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐โโ๏ธ": {
- "name": "woman running",
- "slug": "woman_running",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐โโก๏ธ": {
- "name": "person running facing right",
- "slug": "person_running_facing_right",
- "group": "People & Body",
- "emoji_version": "15.1",
- "unicode_version": "15.1",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "15.1"
- },
- "๐โโ๏ธโโก๏ธ": {
- "name": "woman running facing right",
- "slug": "woman_running_facing_right",
- "group": "People & Body",
- "emoji_version": "15.1",
- "unicode_version": "15.1",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "15.1"
- },
- "๐โโ๏ธโโก๏ธ": {
- "name": "man running facing right",
- "slug": "man_running_facing_right",
- "group": "People & Body",
- "emoji_version": "15.1",
- "unicode_version": "15.1",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "15.1"
- },
- "๐": {
- "name": "woman dancing",
- "slug": "woman_dancing",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "๐บ": {
- "name": "man dancing",
- "slug": "man_dancing",
- "group": "People & Body",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "3.0"
- },
- "๐ด๏ธ": {
- "name": "person in suit levitating",
- "slug": "person_in_suit_levitating",
- "group": "People & Body",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐ฏ": {
- "name": "people with bunny ears",
- "slug": "people_with_bunny_ears",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฏโโ๏ธ": {
- "name": "men with bunny ears",
- "slug": "men_with_bunny_ears",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": false
- },
- "๐ฏโโ๏ธ": {
- "name": "women with bunny ears",
- "slug": "women_with_bunny_ears",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": false
- },
- "๐ง": {
- "name": "person in steamy room",
- "slug": "person_in_steamy_room",
- "group": "People & Body",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "5.0"
- },
- "๐งโโ๏ธ": {
- "name": "man in steamy room",
- "slug": "man_in_steamy_room",
- "group": "People & Body",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "5.0"
- },
- "๐งโโ๏ธ": {
- "name": "woman in steamy room",
- "slug": "woman_in_steamy_room",
- "group": "People & Body",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "5.0"
- },
- "๐ง": {
- "name": "person climbing",
- "slug": "person_climbing",
- "group": "People & Body",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "5.0"
- },
- "๐งโโ๏ธ": {
- "name": "man climbing",
- "slug": "man_climbing",
- "group": "People & Body",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "5.0"
- },
- "๐งโโ๏ธ": {
- "name": "woman climbing",
- "slug": "woman_climbing",
- "group": "People & Body",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "5.0"
- },
- "๐คบ": {
- "name": "person fencing",
- "slug": "person_fencing",
- "group": "People & Body",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "horse racing",
- "slug": "horse_racing",
- "group": "People & Body",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "โท๏ธ": {
- "name": "skier",
- "slug": "skier",
- "group": "People & Body",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐": {
- "name": "snowboarder",
- "slug": "snowboarder",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "๐๏ธ": {
- "name": "person golfing",
- "slug": "person_golfing",
- "group": "People & Body",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐๏ธโโ๏ธ": {
- "name": "man golfing",
- "slug": "man_golfing",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐๏ธโโ๏ธ": {
- "name": "woman golfing",
- "slug": "woman_golfing",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐": {
- "name": "person surfing",
- "slug": "person_surfing",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "๐โโ๏ธ": {
- "name": "man surfing",
- "slug": "man_surfing",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐โโ๏ธ": {
- "name": "woman surfing",
- "slug": "woman_surfing",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐ฃ": {
- "name": "person rowing boat",
- "slug": "person_rowing_boat",
- "group": "People & Body",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "๐ฃโโ๏ธ": {
- "name": "man rowing boat",
- "slug": "man_rowing_boat",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐ฃโโ๏ธ": {
- "name": "woman rowing boat",
- "slug": "woman_rowing_boat",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐": {
- "name": "person swimming",
- "slug": "person_swimming",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "๐โโ๏ธ": {
- "name": "man swimming",
- "slug": "man_swimming",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐โโ๏ธ": {
- "name": "woman swimming",
- "slug": "woman_swimming",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "โน๏ธ": {
- "name": "person bouncing ball",
- "slug": "person_bouncing_ball",
- "group": "People & Body",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "2.0"
- },
- "โน๏ธโโ๏ธ": {
- "name": "man bouncing ball",
- "slug": "man_bouncing_ball",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "โน๏ธโโ๏ธ": {
- "name": "woman bouncing ball",
- "slug": "woman_bouncing_ball",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐๏ธ": {
- "name": "person lifting weights",
- "slug": "person_lifting_weights",
- "group": "People & Body",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "2.0"
- },
- "๐๏ธโโ๏ธ": {
- "name": "man lifting weights",
- "slug": "man_lifting_weights",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐๏ธโโ๏ธ": {
- "name": "woman lifting weights",
- "slug": "woman_lifting_weights",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐ด": {
- "name": "person biking",
- "slug": "person_biking",
- "group": "People & Body",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "๐ดโโ๏ธ": {
- "name": "man biking",
- "slug": "man_biking",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐ดโโ๏ธ": {
- "name": "woman biking",
- "slug": "woman_biking",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐ต": {
- "name": "person mountain biking",
- "slug": "person_mountain_biking",
- "group": "People & Body",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "๐ตโโ๏ธ": {
- "name": "man mountain biking",
- "slug": "man_mountain_biking",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐ตโโ๏ธ": {
- "name": "woman mountain biking",
- "slug": "woman_mountain_biking",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐คธ": {
- "name": "person cartwheeling",
- "slug": "person_cartwheeling",
- "group": "People & Body",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "3.0"
- },
- "๐คธโโ๏ธ": {
- "name": "man cartwheeling",
- "slug": "man_cartwheeling",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐คธโโ๏ธ": {
- "name": "woman cartwheeling",
- "slug": "woman_cartwheeling",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐คผ": {
- "name": "people wrestling",
- "slug": "people_wrestling",
- "group": "People & Body",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": false
- },
- "๐คผโโ๏ธ": {
- "name": "men wrestling",
- "slug": "men_wrestling",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": false
- },
- "๐คผโโ๏ธ": {
- "name": "women wrestling",
- "slug": "women_wrestling",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": false
- },
- "๐คฝ": {
- "name": "person playing water polo",
- "slug": "person_playing_water_polo",
- "group": "People & Body",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "3.0"
- },
- "๐คฝโโ๏ธ": {
- "name": "man playing water polo",
- "slug": "man_playing_water_polo",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐คฝโโ๏ธ": {
- "name": "woman playing water polo",
- "slug": "woman_playing_water_polo",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐คพ": {
- "name": "person playing handball",
- "slug": "person_playing_handball",
- "group": "People & Body",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "3.0"
- },
- "๐คพโโ๏ธ": {
- "name": "man playing handball",
- "slug": "man_playing_handball",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐คพโโ๏ธ": {
- "name": "woman playing handball",
- "slug": "woman_playing_handball",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐คน": {
- "name": "person juggling",
- "slug": "person_juggling",
- "group": "People & Body",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "3.0"
- },
- "๐คนโโ๏ธ": {
- "name": "man juggling",
- "slug": "man_juggling",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐คนโโ๏ธ": {
- "name": "woman juggling",
- "slug": "woman_juggling",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐ง": {
- "name": "person in lotus position",
- "slug": "person_in_lotus_position",
- "group": "People & Body",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "5.0"
- },
- "๐งโโ๏ธ": {
- "name": "man in lotus position",
- "slug": "man_in_lotus_position",
- "group": "People & Body",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "5.0"
- },
- "๐งโโ๏ธ": {
- "name": "woman in lotus position",
- "slug": "woman_in_lotus_position",
- "group": "People & Body",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "5.0"
- },
- "๐": {
- "name": "person taking bath",
- "slug": "person_taking_bath",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "1.0"
- },
- "๐": {
- "name": "person in bed",
- "slug": "person_in_bed",
- "group": "People & Body",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "4.0"
- },
- "๐งโ๐คโ๐ง": {
- "name": "people holding hands",
- "slug": "people_holding_hands",
- "group": "People & Body",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "12.0"
- },
- "๐ญ": {
- "name": "women holding hands",
- "slug": "women_holding_hands",
- "group": "People & Body",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "12.0"
- },
- "๐ซ": {
- "name": "woman and man holding hands",
- "slug": "woman_and_man_holding_hands",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "12.0"
- },
- "๐ฌ": {
- "name": "men holding hands",
- "slug": "men_holding_hands",
- "group": "People & Body",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "12.0"
- },
- "๐": {
- "name": "kiss",
- "slug": "kiss",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "13.1"
- },
- "๐ฉโโค๏ธโ๐โ๐จ": {
- "name": "kiss woman, man",
- "slug": "kiss_woman_man",
- "group": "People & Body",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "13.1"
- },
- "๐จโโค๏ธโ๐โ๐จ": {
- "name": "kiss man, man",
- "slug": "kiss_man_man",
- "group": "People & Body",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "13.1"
- },
- "๐ฉโโค๏ธโ๐โ๐ฉ": {
- "name": "kiss woman, woman",
- "slug": "kiss_woman_woman",
- "group": "People & Body",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "13.1"
- },
- "๐": {
- "name": "couple with heart",
- "slug": "couple_with_heart",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "13.1"
- },
- "๐ฉโโค๏ธโ๐จ": {
- "name": "couple with heart woman, man",
- "slug": "couple_with_heart_woman_man",
- "group": "People & Body",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "13.1"
- },
- "๐จโโค๏ธโ๐จ": {
- "name": "couple with heart man, man",
- "slug": "couple_with_heart_man_man",
- "group": "People & Body",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "13.1"
- },
- "๐ฉโโค๏ธโ๐ฉ": {
- "name": "couple with heart woman, woman",
- "slug": "couple_with_heart_woman_woman",
- "group": "People & Body",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": true,
- "skin_tone_support_unicode_version": "13.1"
- },
- "๐จโ๐ฉโ๐ฆ": {
- "name": "family man, woman, boy",
- "slug": "family_man_woman_boy",
- "group": "People & Body",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐จโ๐ฉโ๐ง": {
- "name": "family man, woman, girl",
- "slug": "family_man_woman_girl",
- "group": "People & Body",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐จโ๐ฉโ๐งโ๐ฆ": {
- "name": "family man, woman, girl, boy",
- "slug": "family_man_woman_girl_boy",
- "group": "People & Body",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐จโ๐ฉโ๐ฆโ๐ฆ": {
- "name": "family man, woman, boy, boy",
- "slug": "family_man_woman_boy_boy",
- "group": "People & Body",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐จโ๐ฉโ๐งโ๐ง": {
- "name": "family man, woman, girl, girl",
- "slug": "family_man_woman_girl_girl",
- "group": "People & Body",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐จโ๐จโ๐ฆ": {
- "name": "family man, man, boy",
- "slug": "family_man_man_boy",
- "group": "People & Body",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐จโ๐จโ๐ง": {
- "name": "family man, man, girl",
- "slug": "family_man_man_girl",
- "group": "People & Body",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐จโ๐จโ๐งโ๐ฆ": {
- "name": "family man, man, girl, boy",
- "slug": "family_man_man_girl_boy",
- "group": "People & Body",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐จโ๐จโ๐ฆโ๐ฆ": {
- "name": "family man, man, boy, boy",
- "slug": "family_man_man_boy_boy",
- "group": "People & Body",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐จโ๐จโ๐งโ๐ง": {
- "name": "family man, man, girl, girl",
- "slug": "family_man_man_girl_girl",
- "group": "People & Body",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฉโ๐ฉโ๐ฆ": {
- "name": "family woman, woman, boy",
- "slug": "family_woman_woman_boy",
- "group": "People & Body",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฉโ๐ฉโ๐ง": {
- "name": "family woman, woman, girl",
- "slug": "family_woman_woman_girl",
- "group": "People & Body",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฉโ๐ฉโ๐งโ๐ฆ": {
- "name": "family woman, woman, girl, boy",
- "slug": "family_woman_woman_girl_boy",
- "group": "People & Body",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฉโ๐ฉโ๐ฆโ๐ฆ": {
- "name": "family woman, woman, boy, boy",
- "slug": "family_woman_woman_boy_boy",
- "group": "People & Body",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฉโ๐ฉโ๐งโ๐ง": {
- "name": "family woman, woman, girl, girl",
- "slug": "family_woman_woman_girl_girl",
- "group": "People & Body",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐จโ๐ฆ": {
- "name": "family man, boy",
- "slug": "family_man_boy",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": false
- },
- "๐จโ๐ฆโ๐ฆ": {
- "name": "family man, boy, boy",
- "slug": "family_man_boy_boy",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": false
- },
- "๐จโ๐ง": {
- "name": "family man, girl",
- "slug": "family_man_girl",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": false
- },
- "๐จโ๐งโ๐ฆ": {
- "name": "family man, girl, boy",
- "slug": "family_man_girl_boy",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": false
- },
- "๐จโ๐งโ๐ง": {
- "name": "family man, girl, girl",
- "slug": "family_man_girl_girl",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": false
- },
- "๐ฉโ๐ฆ": {
- "name": "family woman, boy",
- "slug": "family_woman_boy",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": false
- },
- "๐ฉโ๐ฆโ๐ฆ": {
- "name": "family woman, boy, boy",
- "slug": "family_woman_boy_boy",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": false
- },
- "๐ฉโ๐ง": {
- "name": "family woman, girl",
- "slug": "family_woman_girl",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": false
- },
- "๐ฉโ๐งโ๐ฆ": {
- "name": "family woman, girl, boy",
- "slug": "family_woman_girl_boy",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": false
- },
- "๐ฉโ๐งโ๐ง": {
- "name": "family woman, girl, girl",
- "slug": "family_woman_girl_girl",
- "group": "People & Body",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": false
- },
- "๐ฃ๏ธ": {
- "name": "speaking head",
- "slug": "speaking_head",
- "group": "People & Body",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐ค": {
- "name": "bust in silhouette",
- "slug": "bust_in_silhouette",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฅ": {
- "name": "busts in silhouette",
- "slug": "busts_in_silhouette",
- "group": "People & Body",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ซ": {
- "name": "people hugging",
- "slug": "people_hugging",
- "group": "People & Body",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": false
- },
- "๐ช": {
- "name": "family",
- "slug": "family",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐งโ๐งโ๐ง": {
- "name": "family adult, adult, child",
- "slug": "family_adult_adult_child",
- "group": "People & Body",
- "emoji_version": "15.1",
- "unicode_version": "15.1",
- "skin_tone_support": false
- },
- "๐งโ๐งโ๐งโ๐ง": {
- "name": "family adult, adult, child, child",
- "slug": "family_adult_adult_child_child",
- "group": "People & Body",
- "emoji_version": "15.1",
- "unicode_version": "15.1",
- "skin_tone_support": false
- },
- "๐งโ๐ง": {
- "name": "family adult, child",
- "slug": "family_adult_child",
- "group": "People & Body",
- "emoji_version": "15.1",
- "unicode_version": "15.1",
- "skin_tone_support": false
- },
- "๐งโ๐งโ๐ง": {
- "name": "family adult, child, child",
- "slug": "family_adult_child_child",
- "group": "People & Body",
- "emoji_version": "15.1",
- "unicode_version": "15.1",
- "skin_tone_support": false
- },
- "๐ฃ": {
- "name": "footprints",
- "slug": "footprints",
- "group": "People & Body",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ต": {
- "name": "monkey face",
- "slug": "monkey_face",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "monkey",
- "slug": "monkey",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฆ": {
- "name": "gorilla",
- "slug": "gorilla",
- "group": "Animals & Nature",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": false
- },
- "๐ฆง": {
- "name": "orangutan",
- "slug": "orangutan",
- "group": "Animals & Nature",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": false
- },
- "๐ถ": {
- "name": "dog face",
- "slug": "dog_face",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "dog",
- "slug": "dog",
- "group": "Animals & Nature",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐ฆฎ": {
- "name": "guide dog",
- "slug": "guide_dog",
- "group": "Animals & Nature",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": false
- },
- "๐โ๐ฆบ": {
- "name": "service dog",
- "slug": "service_dog",
- "group": "Animals & Nature",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": false
- },
- "๐ฉ": {
- "name": "poodle",
- "slug": "poodle",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐บ": {
- "name": "wolf",
- "slug": "wolf",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฆ": {
- "name": "fox",
- "slug": "fox",
- "group": "Animals & Nature",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": false
- },
- "๐ฆ": {
- "name": "raccoon",
- "slug": "raccoon",
- "group": "Animals & Nature",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": false
- },
- "๐ฑ": {
- "name": "cat face",
- "slug": "cat_face",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "cat",
- "slug": "cat",
- "group": "Animals & Nature",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐โโฌ": {
- "name": "black cat",
- "slug": "black_cat",
- "group": "Animals & Nature",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": false
- },
- "๐ฆ": {
- "name": "lion",
- "slug": "lion",
- "group": "Animals & Nature",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ฏ": {
- "name": "tiger face",
- "slug": "tiger_face",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐
": {
- "name": "tiger",
- "slug": "tiger",
- "group": "Animals & Nature",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "leopard",
- "slug": "leopard",
- "group": "Animals & Nature",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ด": {
- "name": "horse face",
- "slug": "horse_face",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ซ": {
- "name": "moose",
- "slug": "moose",
- "group": "Animals & Nature",
- "emoji_version": "15.0",
- "unicode_version": "15.0",
- "skin_tone_support": false
- },
- "๐ซ": {
- "name": "donkey",
- "slug": "donkey",
- "group": "Animals & Nature",
- "emoji_version": "15.0",
- "unicode_version": "15.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "horse",
- "slug": "horse",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฆ": {
- "name": "unicorn",
- "slug": "unicorn",
- "group": "Animals & Nature",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ฆ": {
- "name": "zebra",
- "slug": "zebra",
- "group": "Animals & Nature",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": false
- },
- "๐ฆ": {
- "name": "deer",
- "slug": "deer",
- "group": "Animals & Nature",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": false
- },
- "๐ฆฌ": {
- "name": "bison",
- "slug": "bison",
- "group": "Animals & Nature",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": false
- },
- "๐ฎ": {
- "name": "cow face",
- "slug": "cow_face",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "ox",
- "slug": "ox",
- "group": "Animals & Nature",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "water buffalo",
- "slug": "water_buffalo",
- "group": "Animals & Nature",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "cow",
- "slug": "cow",
- "group": "Animals & Nature",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ท": {
- "name": "pig face",
- "slug": "pig_face",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "pig",
- "slug": "pig",
- "group": "Animals & Nature",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "boar",
- "slug": "boar",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฝ": {
- "name": "pig nose",
- "slug": "pig_nose",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "ram",
- "slug": "ram",
- "group": "Animals & Nature",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "ewe",
- "slug": "ewe",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "goat",
- "slug": "goat",
- "group": "Animals & Nature",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ช": {
- "name": "camel",
- "slug": "camel",
- "group": "Animals & Nature",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ซ": {
- "name": "two-hump camel",
- "slug": "two_hump_camel",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฆ": {
- "name": "llama",
- "slug": "llama",
- "group": "Animals & Nature",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": false
- },
- "๐ฆ": {
- "name": "giraffe",
- "slug": "giraffe",
- "group": "Animals & Nature",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "elephant",
- "slug": "elephant",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฆฃ": {
- "name": "mammoth",
- "slug": "mammoth",
- "group": "Animals & Nature",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": false
- },
- "๐ฆ": {
- "name": "rhinoceros",
- "slug": "rhinoceros",
- "group": "Animals & Nature",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": false
- },
- "๐ฆ": {
- "name": "hippopotamus",
- "slug": "hippopotamus",
- "group": "Animals & Nature",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": false
- },
- "๐ญ": {
- "name": "mouse face",
- "slug": "mouse_face",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "mouse",
- "slug": "mouse",
- "group": "Animals & Nature",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "rat",
- "slug": "rat",
- "group": "Animals & Nature",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐น": {
- "name": "hamster",
- "slug": "hamster",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฐ": {
- "name": "rabbit face",
- "slug": "rabbit_face",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "rabbit",
- "slug": "rabbit",
- "group": "Animals & Nature",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ฟ๏ธ": {
- "name": "chipmunk",
- "slug": "chipmunk",
- "group": "Animals & Nature",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐ฆซ": {
- "name": "beaver",
- "slug": "beaver",
- "group": "Animals & Nature",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": false
- },
- "๐ฆ": {
- "name": "hedgehog",
- "slug": "hedgehog",
- "group": "Animals & Nature",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": false
- },
- "๐ฆ": {
- "name": "bat",
- "slug": "bat",
- "group": "Animals & Nature",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": false
- },
- "๐ป": {
- "name": "bear",
- "slug": "bear",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ปโโ๏ธ": {
- "name": "polar bear",
- "slug": "polar_bear",
- "group": "Animals & Nature",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": false
- },
- "๐จ": {
- "name": "koala",
- "slug": "koala",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ผ": {
- "name": "panda",
- "slug": "panda",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฆฅ": {
- "name": "sloth",
- "slug": "sloth",
- "group": "Animals & Nature",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": false
- },
- "๐ฆฆ": {
- "name": "otter",
- "slug": "otter",
- "group": "Animals & Nature",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": false
- },
- "๐ฆจ": {
- "name": "skunk",
- "slug": "skunk",
- "group": "Animals & Nature",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": false
- },
- "๐ฆ": {
- "name": "kangaroo",
- "slug": "kangaroo",
- "group": "Animals & Nature",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": false
- },
- "๐ฆก": {
- "name": "badger",
- "slug": "badger",
- "group": "Animals & Nature",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": false
- },
- "๐พ": {
- "name": "paw prints",
- "slug": "paw_prints",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฆ": {
- "name": "turkey",
- "slug": "turkey",
- "group": "Animals & Nature",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "chicken",
- "slug": "chicken",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "rooster",
- "slug": "rooster",
- "group": "Animals & Nature",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ฃ": {
- "name": "hatching chick",
- "slug": "hatching_chick",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ค": {
- "name": "baby chick",
- "slug": "baby_chick",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฅ": {
- "name": "front-facing baby chick",
- "slug": "front_facing_baby_chick",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฆ": {
- "name": "bird",
- "slug": "bird",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ง": {
- "name": "penguin",
- "slug": "penguin",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐๏ธ": {
- "name": "dove",
- "slug": "dove",
- "group": "Animals & Nature",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐ฆ
": {
- "name": "eagle",
- "slug": "eagle",
- "group": "Animals & Nature",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": false
- },
- "๐ฆ": {
- "name": "duck",
- "slug": "duck",
- "group": "Animals & Nature",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": false
- },
- "๐ฆข": {
- "name": "swan",
- "slug": "swan",
- "group": "Animals & Nature",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": false
- },
- "๐ฆ": {
- "name": "owl",
- "slug": "owl",
- "group": "Animals & Nature",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": false
- },
- "๐ฆค": {
- "name": "dodo",
- "slug": "dodo",
- "group": "Animals & Nature",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": false
- },
- "๐ชถ": {
- "name": "feather",
- "slug": "feather",
- "group": "Animals & Nature",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": false
- },
- "๐ฆฉ": {
- "name": "flamingo",
- "slug": "flamingo",
- "group": "Animals & Nature",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": false
- },
- "๐ฆ": {
- "name": "peacock",
- "slug": "peacock",
- "group": "Animals & Nature",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": false
- },
- "๐ฆ": {
- "name": "parrot",
- "slug": "parrot",
- "group": "Animals & Nature",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": false
- },
- "๐ชฝ": {
- "name": "wing",
- "slug": "wing",
- "group": "Animals & Nature",
- "emoji_version": "15.0",
- "unicode_version": "15.0",
- "skin_tone_support": false
- },
- "๐ฆโโฌ": {
- "name": "black bird",
- "slug": "black_bird",
- "group": "Animals & Nature",
- "emoji_version": "15.0",
- "unicode_version": "15.0",
- "skin_tone_support": false
- },
- "๐ชฟ": {
- "name": "goose",
- "slug": "goose",
- "group": "Animals & Nature",
- "emoji_version": "15.0",
- "unicode_version": "15.0",
- "skin_tone_support": false
- },
- "๐ฆโ๐ฅ": {
- "name": "phoenix",
- "slug": "phoenix",
- "group": "Animals & Nature",
- "emoji_version": "15.1",
- "unicode_version": "15.1",
- "skin_tone_support": false
- },
- "๐ธ": {
- "name": "frog",
- "slug": "frog",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "crocodile",
- "slug": "crocodile",
- "group": "Animals & Nature",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ข": {
- "name": "turtle",
- "slug": "turtle",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฆ": {
- "name": "lizard",
- "slug": "lizard",
- "group": "Animals & Nature",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "snake",
- "slug": "snake",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฒ": {
- "name": "dragon face",
- "slug": "dragon_face",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "dragon",
- "slug": "dragon",
- "group": "Animals & Nature",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ฆ": {
- "name": "sauropod",
- "slug": "sauropod",
- "group": "Animals & Nature",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": false
- },
- "๐ฆ": {
- "name": "T-Rex",
- "slug": "t_rex",
- "group": "Animals & Nature",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": false
- },
- "๐ณ": {
- "name": "spouting whale",
- "slug": "spouting_whale",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "whale",
- "slug": "whale",
- "group": "Animals & Nature",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ฌ": {
- "name": "dolphin",
- "slug": "dolphin",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฆญ": {
- "name": "seal",
- "slug": "seal",
- "group": "Animals & Nature",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "fish",
- "slug": "fish",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ ": {
- "name": "tropical fish",
- "slug": "tropical_fish",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ก": {
- "name": "blowfish",
- "slug": "blowfish",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฆ": {
- "name": "shark",
- "slug": "shark",
- "group": "Animals & Nature",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "octopus",
- "slug": "octopus",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "spiral shell",
- "slug": "spiral_shell",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ชธ": {
- "name": "coral",
- "slug": "coral",
- "group": "Animals & Nature",
- "emoji_version": "14.0",
- "unicode_version": "14.0",
- "skin_tone_support": false
- },
- "๐ชผ": {
- "name": "jellyfish",
- "slug": "jellyfish",
- "group": "Animals & Nature",
- "emoji_version": "15.0",
- "unicode_version": "15.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "snail",
- "slug": "snail",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฆ": {
- "name": "butterfly",
- "slug": "butterfly",
- "group": "Animals & Nature",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "bug",
- "slug": "bug",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "ant",
- "slug": "ant",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "honeybee",
- "slug": "honeybee",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ชฒ": {
- "name": "beetle",
- "slug": "beetle",
- "group": "Animals & Nature",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "lady beetle",
- "slug": "lady_beetle",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฆ": {
- "name": "cricket",
- "slug": "cricket",
- "group": "Animals & Nature",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": false
- },
- "๐ชณ": {
- "name": "cockroach",
- "slug": "cockroach",
- "group": "Animals & Nature",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": false
- },
- "๐ท๏ธ": {
- "name": "spider",
- "slug": "spider",
- "group": "Animals & Nature",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐ธ๏ธ": {
- "name": "spider web",
- "slug": "spider_web",
- "group": "Animals & Nature",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐ฆ": {
- "name": "scorpion",
- "slug": "scorpion",
- "group": "Animals & Nature",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ฆ": {
- "name": "mosquito",
- "slug": "mosquito",
- "group": "Animals & Nature",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": false
- },
- "๐ชฐ": {
- "name": "fly",
- "slug": "fly",
- "group": "Animals & Nature",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": false
- },
- "๐ชฑ": {
- "name": "worm",
- "slug": "worm",
- "group": "Animals & Nature",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": false
- },
- "๐ฆ ": {
- "name": "microbe",
- "slug": "microbe",
- "group": "Animals & Nature",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "bouquet",
- "slug": "bouquet",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ธ": {
- "name": "cherry blossom",
- "slug": "cherry_blossom",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฎ": {
- "name": "white flower",
- "slug": "white_flower",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ชท": {
- "name": "lotus",
- "slug": "lotus",
- "group": "Animals & Nature",
- "emoji_version": "14.0",
- "unicode_version": "14.0",
- "skin_tone_support": false
- },
- "๐ต๏ธ": {
- "name": "rosette",
- "slug": "rosette",
- "group": "Animals & Nature",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐น": {
- "name": "rose",
- "slug": "rose",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฅ": {
- "name": "wilted flower",
- "slug": "wilted_flower",
- "group": "Animals & Nature",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": false
- },
- "๐บ": {
- "name": "hibiscus",
- "slug": "hibiscus",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ป": {
- "name": "sunflower",
- "slug": "sunflower",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ผ": {
- "name": "blossom",
- "slug": "blossom",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ท": {
- "name": "tulip",
- "slug": "tulip",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ชป": {
- "name": "hyacinth",
- "slug": "hyacinth",
- "group": "Animals & Nature",
- "emoji_version": "15.0",
- "unicode_version": "15.0",
- "skin_tone_support": false
- },
- "๐ฑ": {
- "name": "seedling",
- "slug": "seedling",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ชด": {
- "name": "potted plant",
- "slug": "potted_plant",
- "group": "Animals & Nature",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": false
- },
- "๐ฒ": {
- "name": "evergreen tree",
- "slug": "evergreen_tree",
- "group": "Animals & Nature",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ณ": {
- "name": "deciduous tree",
- "slug": "deciduous_tree",
- "group": "Animals & Nature",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ด": {
- "name": "palm tree",
- "slug": "palm_tree",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ต": {
- "name": "cactus",
- "slug": "cactus",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐พ": {
- "name": "sheaf of rice",
- "slug": "sheaf_of_rice",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฟ": {
- "name": "herb",
- "slug": "herb",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โ๏ธ": {
- "name": "shamrock",
- "slug": "shamrock",
- "group": "Animals & Nature",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "four leaf clover",
- "slug": "four_leaf_clover",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "maple leaf",
- "slug": "maple_leaf",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "fallen leaf",
- "slug": "fallen_leaf",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "leaf fluttering in wind",
- "slug": "leaf_fluttering_in_wind",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ชน": {
- "name": "empty nest",
- "slug": "empty_nest",
- "group": "Animals & Nature",
- "emoji_version": "14.0",
- "unicode_version": "14.0",
- "skin_tone_support": false
- },
- "๐ชบ": {
- "name": "nest with eggs",
- "slug": "nest_with_eggs",
- "group": "Animals & Nature",
- "emoji_version": "14.0",
- "unicode_version": "14.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "mushroom",
- "slug": "mushroom",
- "group": "Animals & Nature",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "grapes",
- "slug": "grapes",
- "group": "Food & Drink",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "melon",
- "slug": "melon",
- "group": "Food & Drink",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "watermelon",
- "slug": "watermelon",
- "group": "Food & Drink",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "tangerine",
- "slug": "tangerine",
- "group": "Food & Drink",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "lemon",
- "slug": "lemon",
- "group": "Food & Drink",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐โ๐ฉ": {
- "name": "lime",
- "slug": "lime",
- "group": "Food & Drink",
- "emoji_version": "15.1",
- "unicode_version": "15.1",
- "skin_tone_support": false
- },
- "๐": {
- "name": "banana",
- "slug": "banana",
- "group": "Food & Drink",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "pineapple",
- "slug": "pineapple",
- "group": "Food & Drink",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฅญ": {
- "name": "mango",
- "slug": "mango",
- "group": "Food & Drink",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "red apple",
- "slug": "red_apple",
- "group": "Food & Drink",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "green apple",
- "slug": "green_apple",
- "group": "Food & Drink",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "pear",
- "slug": "pear",
- "group": "Food & Drink",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "peach",
- "slug": "peach",
- "group": "Food & Drink",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "cherries",
- "slug": "cherries",
- "group": "Food & Drink",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "strawberry",
- "slug": "strawberry",
- "group": "Food & Drink",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ซ": {
- "name": "blueberries",
- "slug": "blueberries",
- "group": "Food & Drink",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": false
- },
- "๐ฅ": {
- "name": "kiwi fruit",
- "slug": "kiwi_fruit",
- "group": "Food & Drink",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": false
- },
- "๐
": {
- "name": "tomato",
- "slug": "tomato",
- "group": "Food & Drink",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ซ": {
- "name": "olive",
- "slug": "olive",
- "group": "Food & Drink",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": false
- },
- "๐ฅฅ": {
- "name": "coconut",
- "slug": "coconut",
- "group": "Food & Drink",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": false
- },
- "๐ฅ": {
- "name": "avocado",
- "slug": "avocado",
- "group": "Food & Drink",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "eggplant",
- "slug": "eggplant",
- "group": "Food & Drink",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฅ": {
- "name": "potato",
- "slug": "potato",
- "group": "Food & Drink",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": false
- },
- "๐ฅ": {
- "name": "carrot",
- "slug": "carrot",
- "group": "Food & Drink",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": false
- },
- "๐ฝ": {
- "name": "ear of corn",
- "slug": "ear_of_corn",
- "group": "Food & Drink",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ถ๏ธ": {
- "name": "hot pepper",
- "slug": "hot_pepper",
- "group": "Food & Drink",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐ซ": {
- "name": "bell pepper",
- "slug": "bell_pepper",
- "group": "Food & Drink",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": false
- },
- "๐ฅ": {
- "name": "cucumber",
- "slug": "cucumber",
- "group": "Food & Drink",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": false
- },
- "๐ฅฌ": {
- "name": "leafy green",
- "slug": "leafy_green",
- "group": "Food & Drink",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": false
- },
- "๐ฅฆ": {
- "name": "broccoli",
- "slug": "broccoli",
- "group": "Food & Drink",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": false
- },
- "๐ง": {
- "name": "garlic",
- "slug": "garlic",
- "group": "Food & Drink",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": false
- },
- "๐ง
": {
- "name": "onion",
- "slug": "onion",
- "group": "Food & Drink",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": false
- },
- "๐ฅ": {
- "name": "peanuts",
- "slug": "peanuts",
- "group": "Food & Drink",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": false
- },
- "๐ซ": {
- "name": "beans",
- "slug": "beans",
- "group": "Food & Drink",
- "emoji_version": "14.0",
- "unicode_version": "14.0",
- "skin_tone_support": false
- },
- "๐ฐ": {
- "name": "chestnut",
- "slug": "chestnut",
- "group": "Food & Drink",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ซ": {
- "name": "ginger root",
- "slug": "ginger_root",
- "group": "Food & Drink",
- "emoji_version": "15.0",
- "unicode_version": "15.0",
- "skin_tone_support": false
- },
- "๐ซ": {
- "name": "pea pod",
- "slug": "pea_pod",
- "group": "Food & Drink",
- "emoji_version": "15.0",
- "unicode_version": "15.0",
- "skin_tone_support": false
- },
- "๐โ๐ซ": {
- "name": "brown mushroom",
- "slug": "brown_mushroom",
- "group": "Food & Drink",
- "emoji_version": "15.1",
- "unicode_version": "15.1",
- "skin_tone_support": false
- },
- "๐": {
- "name": "bread",
- "slug": "bread",
- "group": "Food & Drink",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฅ": {
- "name": "croissant",
- "slug": "croissant",
- "group": "Food & Drink",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": false
- },
- "๐ฅ": {
- "name": "baguette bread",
- "slug": "baguette_bread",
- "group": "Food & Drink",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": false
- },
- "๐ซ": {
- "name": "flatbread",
- "slug": "flatbread",
- "group": "Food & Drink",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": false
- },
- "๐ฅจ": {
- "name": "pretzel",
- "slug": "pretzel",
- "group": "Food & Drink",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": false
- },
- "๐ฅฏ": {
- "name": "bagel",
- "slug": "bagel",
- "group": "Food & Drink",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": false
- },
- "๐ฅ": {
- "name": "pancakes",
- "slug": "pancakes",
- "group": "Food & Drink",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": false
- },
- "๐ง": {
- "name": "waffle",
- "slug": "waffle",
- "group": "Food & Drink",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": false
- },
- "๐ง": {
- "name": "cheese wedge",
- "slug": "cheese_wedge",
- "group": "Food & Drink",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "meat on bone",
- "slug": "meat_on_bone",
- "group": "Food & Drink",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "poultry leg",
- "slug": "poultry_leg",
- "group": "Food & Drink",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฅฉ": {
- "name": "cut of meat",
- "slug": "cut_of_meat",
- "group": "Food & Drink",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": false
- },
- "๐ฅ": {
- "name": "bacon",
- "slug": "bacon",
- "group": "Food & Drink",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "hamburger",
- "slug": "hamburger",
- "group": "Food & Drink",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "french fries",
- "slug": "french_fries",
- "group": "Food & Drink",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "pizza",
- "slug": "pizza",
- "group": "Food & Drink",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ญ": {
- "name": "hot dog",
- "slug": "hot_dog",
- "group": "Food & Drink",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ฅช": {
- "name": "sandwich",
- "slug": "sandwich",
- "group": "Food & Drink",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": false
- },
- "๐ฎ": {
- "name": "taco",
- "slug": "taco",
- "group": "Food & Drink",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ฏ": {
- "name": "burrito",
- "slug": "burrito",
- "group": "Food & Drink",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ซ": {
- "name": "tamale",
- "slug": "tamale",
- "group": "Food & Drink",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": false
- },
- "๐ฅ": {
- "name": "stuffed flatbread",
- "slug": "stuffed_flatbread",
- "group": "Food & Drink",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": false
- },
- "๐ง": {
- "name": "falafel",
- "slug": "falafel",
- "group": "Food & Drink",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": false
- },
- "๐ฅ": {
- "name": "egg",
- "slug": "egg",
- "group": "Food & Drink",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": false
- },
- "๐ณ": {
- "name": "cooking",
- "slug": "cooking",
- "group": "Food & Drink",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฅ": {
- "name": "shallow pan of food",
- "slug": "shallow_pan_of_food",
- "group": "Food & Drink",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": false
- },
- "๐ฒ": {
- "name": "pot of food",
- "slug": "pot_of_food",
- "group": "Food & Drink",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ซ": {
- "name": "fondue",
- "slug": "fondue",
- "group": "Food & Drink",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": false
- },
- "๐ฅฃ": {
- "name": "bowl with spoon",
- "slug": "bowl_with_spoon",
- "group": "Food & Drink",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": false
- },
- "๐ฅ": {
- "name": "green salad",
- "slug": "green_salad",
- "group": "Food & Drink",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": false
- },
- "๐ฟ": {
- "name": "popcorn",
- "slug": "popcorn",
- "group": "Food & Drink",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ง": {
- "name": "butter",
- "slug": "butter",
- "group": "Food & Drink",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": false
- },
- "๐ง": {
- "name": "salt",
- "slug": "salt",
- "group": "Food & Drink",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": false
- },
- "๐ฅซ": {
- "name": "canned food",
- "slug": "canned_food",
- "group": "Food & Drink",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": false
- },
- "๐ฑ": {
- "name": "bento box",
- "slug": "bento_box",
- "group": "Food & Drink",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "rice cracker",
- "slug": "rice_cracker",
- "group": "Food & Drink",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "rice ball",
- "slug": "rice_ball",
- "group": "Food & Drink",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "cooked rice",
- "slug": "cooked_rice",
- "group": "Food & Drink",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "curry rice",
- "slug": "curry_rice",
- "group": "Food & Drink",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "steaming bowl",
- "slug": "steaming_bowl",
- "group": "Food & Drink",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "spaghetti",
- "slug": "spaghetti",
- "group": "Food & Drink",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ ": {
- "name": "roasted sweet potato",
- "slug": "roasted_sweet_potato",
- "group": "Food & Drink",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ข": {
- "name": "oden",
- "slug": "oden",
- "group": "Food & Drink",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฃ": {
- "name": "sushi",
- "slug": "sushi",
- "group": "Food & Drink",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ค": {
- "name": "fried shrimp",
- "slug": "fried_shrimp",
- "group": "Food & Drink",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฅ": {
- "name": "fish cake with swirl",
- "slug": "fish_cake_with_swirl",
- "group": "Food & Drink",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฅฎ": {
- "name": "moon cake",
- "slug": "moon_cake",
- "group": "Food & Drink",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": false
- },
- "๐ก": {
- "name": "dango",
- "slug": "dango",
- "group": "Food & Drink",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฅ": {
- "name": "dumpling",
- "slug": "dumpling",
- "group": "Food & Drink",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": false
- },
- "๐ฅ ": {
- "name": "fortune cookie",
- "slug": "fortune_cookie",
- "group": "Food & Drink",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": false
- },
- "๐ฅก": {
- "name": "takeout box",
- "slug": "takeout_box",
- "group": "Food & Drink",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": false
- },
- "๐ฆ": {
- "name": "crab",
- "slug": "crab",
- "group": "Food & Drink",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ฆ": {
- "name": "lobster",
- "slug": "lobster",
- "group": "Food & Drink",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": false
- },
- "๐ฆ": {
- "name": "shrimp",
- "slug": "shrimp",
- "group": "Food & Drink",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": false
- },
- "๐ฆ": {
- "name": "squid",
- "slug": "squid",
- "group": "Food & Drink",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": false
- },
- "๐ฆช": {
- "name": "oyster",
- "slug": "oyster",
- "group": "Food & Drink",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": false
- },
- "๐ฆ": {
- "name": "soft ice cream",
- "slug": "soft_ice_cream",
- "group": "Food & Drink",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ง": {
- "name": "shaved ice",
- "slug": "shaved_ice",
- "group": "Food & Drink",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐จ": {
- "name": "ice cream",
- "slug": "ice_cream",
- "group": "Food & Drink",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฉ": {
- "name": "doughnut",
- "slug": "doughnut",
- "group": "Food & Drink",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ช": {
- "name": "cookie",
- "slug": "cookie",
- "group": "Food & Drink",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "birthday cake",
- "slug": "birthday_cake",
- "group": "Food & Drink",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฐ": {
- "name": "shortcake",
- "slug": "shortcake",
- "group": "Food & Drink",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ง": {
- "name": "cupcake",
- "slug": "cupcake",
- "group": "Food & Drink",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": false
- },
- "๐ฅง": {
- "name": "pie",
- "slug": "pie",
- "group": "Food & Drink",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": false
- },
- "๐ซ": {
- "name": "chocolate bar",
- "slug": "chocolate_bar",
- "group": "Food & Drink",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฌ": {
- "name": "candy",
- "slug": "candy",
- "group": "Food & Drink",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ญ": {
- "name": "lollipop",
- "slug": "lollipop",
- "group": "Food & Drink",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฎ": {
- "name": "custard",
- "slug": "custard",
- "group": "Food & Drink",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฏ": {
- "name": "honey pot",
- "slug": "honey_pot",
- "group": "Food & Drink",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ผ": {
- "name": "baby bottle",
- "slug": "baby_bottle",
- "group": "Food & Drink",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ฅ": {
- "name": "glass of milk",
- "slug": "glass_of_milk",
- "group": "Food & Drink",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": false
- },
- "โ": {
- "name": "hot beverage",
- "slug": "hot_beverage",
- "group": "Food & Drink",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ซ": {
- "name": "teapot",
- "slug": "teapot",
- "group": "Food & Drink",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": false
- },
- "๐ต": {
- "name": "teacup without handle",
- "slug": "teacup_without_handle",
- "group": "Food & Drink",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ถ": {
- "name": "sake",
- "slug": "sake",
- "group": "Food & Drink",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐พ": {
- "name": "bottle with popping cork",
- "slug": "bottle_with_popping_cork",
- "group": "Food & Drink",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ท": {
- "name": "wine glass",
- "slug": "wine_glass",
- "group": "Food & Drink",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ธ": {
- "name": "cocktail glass",
- "slug": "cocktail_glass",
- "group": "Food & Drink",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐น": {
- "name": "tropical drink",
- "slug": "tropical_drink",
- "group": "Food & Drink",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐บ": {
- "name": "beer mug",
- "slug": "beer_mug",
- "group": "Food & Drink",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ป": {
- "name": "clinking beer mugs",
- "slug": "clinking_beer_mugs",
- "group": "Food & Drink",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฅ": {
- "name": "clinking glasses",
- "slug": "clinking_glasses",
- "group": "Food & Drink",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": false
- },
- "๐ฅ": {
- "name": "tumbler glass",
- "slug": "tumbler_glass",
- "group": "Food & Drink",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": false
- },
- "๐ซ": {
- "name": "pouring liquid",
- "slug": "pouring_liquid",
- "group": "Food & Drink",
- "emoji_version": "14.0",
- "unicode_version": "14.0",
- "skin_tone_support": false
- },
- "๐ฅค": {
- "name": "cup with straw",
- "slug": "cup_with_straw",
- "group": "Food & Drink",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": false
- },
- "๐ง": {
- "name": "bubble tea",
- "slug": "bubble_tea",
- "group": "Food & Drink",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": false
- },
- "๐ง": {
- "name": "beverage box",
- "slug": "beverage_box",
- "group": "Food & Drink",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": false
- },
- "๐ง": {
- "name": "mate",
- "slug": "mate",
- "group": "Food & Drink",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": false
- },
- "๐ง": {
- "name": "ice",
- "slug": "ice",
- "group": "Food & Drink",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": false
- },
- "๐ฅข": {
- "name": "chopsticks",
- "slug": "chopsticks",
- "group": "Food & Drink",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": false
- },
- "๐ฝ๏ธ": {
- "name": "fork and knife with plate",
- "slug": "fork_and_knife_with_plate",
- "group": "Food & Drink",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐ด": {
- "name": "fork and knife",
- "slug": "fork_and_knife",
- "group": "Food & Drink",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฅ": {
- "name": "spoon",
- "slug": "spoon",
- "group": "Food & Drink",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": false
- },
- "๐ช": {
- "name": "kitchen knife",
- "slug": "kitchen_knife",
- "group": "Food & Drink",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ซ": {
- "name": "jar",
- "slug": "jar",
- "group": "Food & Drink",
- "emoji_version": "14.0",
- "unicode_version": "14.0",
- "skin_tone_support": false
- },
- "๐บ": {
- "name": "amphora",
- "slug": "amphora",
- "group": "Food & Drink",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "globe showing Europe-Africa",
- "slug": "globe_showing_europe_africa",
- "group": "Travel & Places",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐": {
- "name": "globe showing Americas",
- "slug": "globe_showing_americas",
- "group": "Travel & Places",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐": {
- "name": "globe showing Asia-Australia",
- "slug": "globe_showing_asia_australia",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "globe with meridians",
- "slug": "globe_with_meridians",
- "group": "Travel & Places",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐บ๏ธ": {
- "name": "world map",
- "slug": "world_map",
- "group": "Travel & Places",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐พ": {
- "name": "map of Japan",
- "slug": "map_of_japan",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐งญ": {
- "name": "compass",
- "slug": "compass",
- "group": "Travel & Places",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": false
- },
- "๐๏ธ": {
- "name": "snow-capped mountain",
- "slug": "snow_capped_mountain",
- "group": "Travel & Places",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "โฐ๏ธ": {
- "name": "mountain",
- "slug": "mountain",
- "group": "Travel & Places",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐": {
- "name": "volcano",
- "slug": "volcano",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ป": {
- "name": "mount fuji",
- "slug": "mount_fuji",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐๏ธ": {
- "name": "camping",
- "slug": "camping",
- "group": "Travel & Places",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐๏ธ": {
- "name": "beach with umbrella",
- "slug": "beach_with_umbrella",
- "group": "Travel & Places",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐๏ธ": {
- "name": "desert",
- "slug": "desert",
- "group": "Travel & Places",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐๏ธ": {
- "name": "desert island",
- "slug": "desert_island",
- "group": "Travel & Places",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐๏ธ": {
- "name": "national park",
- "slug": "national_park",
- "group": "Travel & Places",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐๏ธ": {
- "name": "stadium",
- "slug": "stadium",
- "group": "Travel & Places",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐๏ธ": {
- "name": "classical building",
- "slug": "classical_building",
- "group": "Travel & Places",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐๏ธ": {
- "name": "building construction",
- "slug": "building_construction",
- "group": "Travel & Places",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐งฑ": {
- "name": "brick",
- "slug": "brick",
- "group": "Travel & Places",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": false
- },
- "๐ชจ": {
- "name": "rock",
- "slug": "rock",
- "group": "Travel & Places",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": false
- },
- "๐ชต": {
- "name": "wood",
- "slug": "wood",
- "group": "Travel & Places",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "hut",
- "slug": "hut",
- "group": "Travel & Places",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": false
- },
- "๐๏ธ": {
- "name": "houses",
- "slug": "houses",
- "group": "Travel & Places",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐๏ธ": {
- "name": "derelict house",
- "slug": "derelict_house",
- "group": "Travel & Places",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐ ": {
- "name": "house",
- "slug": "house",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ก": {
- "name": "house with garden",
- "slug": "house_with_garden",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ข": {
- "name": "office building",
- "slug": "office_building",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฃ": {
- "name": "Japanese post office",
- "slug": "japanese_post_office",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ค": {
- "name": "post office",
- "slug": "post_office",
- "group": "Travel & Places",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ฅ": {
- "name": "hospital",
- "slug": "hospital",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฆ": {
- "name": "bank",
- "slug": "bank",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐จ": {
- "name": "hotel",
- "slug": "hotel",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฉ": {
- "name": "love hotel",
- "slug": "love_hotel",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ช": {
- "name": "convenience store",
- "slug": "convenience_store",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ซ": {
- "name": "school",
- "slug": "school",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฌ": {
- "name": "department store",
- "slug": "department_store",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ญ": {
- "name": "factory",
- "slug": "factory",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฏ": {
- "name": "Japanese castle",
- "slug": "japanese_castle",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฐ": {
- "name": "castle",
- "slug": "castle",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "wedding",
- "slug": "wedding",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ผ": {
- "name": "Tokyo tower",
- "slug": "tokyo_tower",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฝ": {
- "name": "Statue of Liberty",
- "slug": "statue_of_liberty",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โช": {
- "name": "church",
- "slug": "church",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "mosque",
- "slug": "mosque",
- "group": "Travel & Places",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "hindu temple",
- "slug": "hindu_temple",
- "group": "Travel & Places",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "synagogue",
- "slug": "synagogue",
- "group": "Travel & Places",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "โฉ๏ธ": {
- "name": "shinto shrine",
- "slug": "shinto_shrine",
- "group": "Travel & Places",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐": {
- "name": "kaaba",
- "slug": "kaaba",
- "group": "Travel & Places",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "โฒ": {
- "name": "fountain",
- "slug": "fountain",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โบ": {
- "name": "tent",
- "slug": "tent",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "foggy",
- "slug": "foggy",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "night with stars",
- "slug": "night_with_stars",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐๏ธ": {
- "name": "cityscape",
- "slug": "cityscape",
- "group": "Travel & Places",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐": {
- "name": "sunrise over mountains",
- "slug": "sunrise_over_mountains",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐
": {
- "name": "sunrise",
- "slug": "sunrise",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "cityscape at dusk",
- "slug": "cityscape_at_dusk",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "sunset",
- "slug": "sunset",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "bridge at night",
- "slug": "bridge_at_night",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โจ๏ธ": {
- "name": "hot springs",
- "slug": "hot_springs",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ ": {
- "name": "carousel horse",
- "slug": "carousel_horse",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "playground slide",
- "slug": "playground_slide",
- "group": "Travel & Places",
- "emoji_version": "14.0",
- "unicode_version": "14.0",
- "skin_tone_support": false
- },
- "๐ก": {
- "name": "ferris wheel",
- "slug": "ferris_wheel",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ข": {
- "name": "roller coaster",
- "slug": "roller_coaster",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "barber pole",
- "slug": "barber_pole",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ช": {
- "name": "circus tent",
- "slug": "circus_tent",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "locomotive",
- "slug": "locomotive",
- "group": "Travel & Places",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "railway car",
- "slug": "railway_car",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "high-speed train",
- "slug": "high_speed_train",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐
": {
- "name": "bullet train",
- "slug": "bullet_train",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "train",
- "slug": "train",
- "group": "Travel & Places",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "metro",
- "slug": "metro",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "light rail",
- "slug": "light_rail",
- "group": "Travel & Places",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "station",
- "slug": "station",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "tram",
- "slug": "tram",
- "group": "Travel & Places",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "monorail",
- "slug": "monorail",
- "group": "Travel & Places",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "mountain railway",
- "slug": "mountain_railway",
- "group": "Travel & Places",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "tram car",
- "slug": "tram_car",
- "group": "Travel & Places",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "bus",
- "slug": "bus",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "oncoming bus",
- "slug": "oncoming_bus",
- "group": "Travel & Places",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐": {
- "name": "trolleybus",
- "slug": "trolleybus",
- "group": "Travel & Places",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "minibus",
- "slug": "minibus",
- "group": "Travel & Places",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "ambulance",
- "slug": "ambulance",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "fire engine",
- "slug": "fire_engine",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "police car",
- "slug": "police_car",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "oncoming police car",
- "slug": "oncoming_police_car",
- "group": "Travel & Places",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐": {
- "name": "taxi",
- "slug": "taxi",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "oncoming taxi",
- "slug": "oncoming_taxi",
- "group": "Travel & Places",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "automobile",
- "slug": "automobile",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "oncoming automobile",
- "slug": "oncoming_automobile",
- "group": "Travel & Places",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐": {
- "name": "sport utility vehicle",
- "slug": "sport_utility_vehicle",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ป": {
- "name": "pickup truck",
- "slug": "pickup_truck",
- "group": "Travel & Places",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "delivery truck",
- "slug": "delivery_truck",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "articulated lorry",
- "slug": "articulated_lorry",
- "group": "Travel & Places",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "tractor",
- "slug": "tractor",
- "group": "Travel & Places",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐๏ธ": {
- "name": "racing car",
- "slug": "racing_car",
- "group": "Travel & Places",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐๏ธ": {
- "name": "motorcycle",
- "slug": "motorcycle",
- "group": "Travel & Places",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐ต": {
- "name": "motor scooter",
- "slug": "motor_scooter",
- "group": "Travel & Places",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": false
- },
- "๐ฆฝ": {
- "name": "manual wheelchair",
- "slug": "manual_wheelchair",
- "group": "Travel & Places",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": false
- },
- "๐ฆผ": {
- "name": "motorized wheelchair",
- "slug": "motorized_wheelchair",
- "group": "Travel & Places",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": false
- },
- "๐บ": {
- "name": "auto rickshaw",
- "slug": "auto_rickshaw",
- "group": "Travel & Places",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": false
- },
- "๐ฒ": {
- "name": "bicycle",
- "slug": "bicycle",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ด": {
- "name": "kick scooter",
- "slug": "kick_scooter",
- "group": "Travel & Places",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": false
- },
- "๐น": {
- "name": "skateboard",
- "slug": "skateboard",
- "group": "Travel & Places",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": false
- },
- "๐ผ": {
- "name": "roller skate",
- "slug": "roller_skate",
- "group": "Travel & Places",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "bus stop",
- "slug": "bus_stop",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฃ๏ธ": {
- "name": "motorway",
- "slug": "motorway",
- "group": "Travel & Places",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐ค๏ธ": {
- "name": "railway track",
- "slug": "railway_track",
- "group": "Travel & Places",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐ข๏ธ": {
- "name": "oil drum",
- "slug": "oil_drum",
- "group": "Travel & Places",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "โฝ": {
- "name": "fuel pump",
- "slug": "fuel_pump",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "wheel",
- "slug": "wheel",
- "group": "Travel & Places",
- "emoji_version": "14.0",
- "unicode_version": "14.0",
- "skin_tone_support": false
- },
- "๐จ": {
- "name": "police car light",
- "slug": "police_car_light",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฅ": {
- "name": "horizontal traffic light",
- "slug": "horizontal_traffic_light",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฆ": {
- "name": "vertical traffic light",
- "slug": "vertical_traffic_light",
- "group": "Travel & Places",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "stop sign",
- "slug": "stop_sign",
- "group": "Travel & Places",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": false
- },
- "๐ง": {
- "name": "construction",
- "slug": "construction",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โ": {
- "name": "anchor",
- "slug": "anchor",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "ring buoy",
- "slug": "ring_buoy",
- "group": "Travel & Places",
- "emoji_version": "14.0",
- "unicode_version": "14.0",
- "skin_tone_support": false
- },
- "โต": {
- "name": "sailboat",
- "slug": "sailboat",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ถ": {
- "name": "canoe",
- "slug": "canoe",
- "group": "Travel & Places",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": false
- },
- "๐ค": {
- "name": "speedboat",
- "slug": "speedboat",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ณ๏ธ": {
- "name": "passenger ship",
- "slug": "passenger_ship",
- "group": "Travel & Places",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "โด๏ธ": {
- "name": "ferry",
- "slug": "ferry",
- "group": "Travel & Places",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐ฅ๏ธ": {
- "name": "motor boat",
- "slug": "motor_boat",
- "group": "Travel & Places",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐ข": {
- "name": "ship",
- "slug": "ship",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โ๏ธ": {
- "name": "airplane",
- "slug": "airplane",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฉ๏ธ": {
- "name": "small airplane",
- "slug": "small_airplane",
- "group": "Travel & Places",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐ซ": {
- "name": "airplane departure",
- "slug": "airplane_departure",
- "group": "Travel & Places",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ฌ": {
- "name": "airplane arrival",
- "slug": "airplane_arrival",
- "group": "Travel & Places",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ช": {
- "name": "parachute",
- "slug": "parachute",
- "group": "Travel & Places",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": false
- },
- "๐บ": {
- "name": "seat",
- "slug": "seat",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "helicopter",
- "slug": "helicopter",
- "group": "Travel & Places",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "suspension railway",
- "slug": "suspension_railway",
- "group": "Travel & Places",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ ": {
- "name": "mountain cableway",
- "slug": "mountain_cableway",
- "group": "Travel & Places",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ก": {
- "name": "aerial tramway",
- "slug": "aerial_tramway",
- "group": "Travel & Places",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ฐ๏ธ": {
- "name": "satellite",
- "slug": "satellite",
- "group": "Travel & Places",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐": {
- "name": "rocket",
- "slug": "rocket",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ธ": {
- "name": "flying saucer",
- "slug": "flying_saucer",
- "group": "Travel & Places",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": false
- },
- "๐๏ธ": {
- "name": "bellhop bell",
- "slug": "bellhop_bell",
- "group": "Travel & Places",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐งณ": {
- "name": "luggage",
- "slug": "luggage",
- "group": "Travel & Places",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": false
- },
- "โ": {
- "name": "hourglass done",
- "slug": "hourglass_done",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โณ": {
- "name": "hourglass not done",
- "slug": "hourglass_not_done",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โ": {
- "name": "watch",
- "slug": "watch",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โฐ": {
- "name": "alarm clock",
- "slug": "alarm_clock",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โฑ๏ธ": {
- "name": "stopwatch",
- "slug": "stopwatch",
- "group": "Travel & Places",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "โฒ๏ธ": {
- "name": "timer clock",
- "slug": "timer_clock",
- "group": "Travel & Places",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ฐ๏ธ": {
- "name": "mantelpiece clock",
- "slug": "mantelpiece_clock",
- "group": "Travel & Places",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐": {
- "name": "twelve oโclock",
- "slug": "twelve_o_clock",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ง": {
- "name": "twelve-thirty",
- "slug": "twelve_thirty",
- "group": "Travel & Places",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐": {
- "name": "one oโclock",
- "slug": "one_o_clock",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "one-thirty",
- "slug": "one_thirty",
- "group": "Travel & Places",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐": {
- "name": "two oโclock",
- "slug": "two_o_clock",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "two-thirty",
- "slug": "two_thirty",
- "group": "Travel & Places",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐": {
- "name": "three oโclock",
- "slug": "three_o_clock",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "three-thirty",
- "slug": "three_thirty",
- "group": "Travel & Places",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐": {
- "name": "four oโclock",
- "slug": "four_o_clock",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "four-thirty",
- "slug": "four_thirty",
- "group": "Travel & Places",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐": {
- "name": "five oโclock",
- "slug": "five_o_clock",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ ": {
- "name": "five-thirty",
- "slug": "five_thirty",
- "group": "Travel & Places",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐": {
- "name": "six oโclock",
- "slug": "six_o_clock",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ก": {
- "name": "six-thirty",
- "slug": "six_thirty",
- "group": "Travel & Places",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐": {
- "name": "seven oโclock",
- "slug": "seven_o_clock",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ข": {
- "name": "seven-thirty",
- "slug": "seven_thirty",
- "group": "Travel & Places",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐": {
- "name": "eight oโclock",
- "slug": "eight_o_clock",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฃ": {
- "name": "eight-thirty",
- "slug": "eight_thirty",
- "group": "Travel & Places",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐": {
- "name": "nine oโclock",
- "slug": "nine_o_clock",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ค": {
- "name": "nine-thirty",
- "slug": "nine_thirty",
- "group": "Travel & Places",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐": {
- "name": "ten oโclock",
- "slug": "ten_o_clock",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฅ": {
- "name": "ten-thirty",
- "slug": "ten_thirty",
- "group": "Travel & Places",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐": {
- "name": "eleven oโclock",
- "slug": "eleven_o_clock",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฆ": {
- "name": "eleven-thirty",
- "slug": "eleven_thirty",
- "group": "Travel & Places",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐": {
- "name": "new moon",
- "slug": "new_moon",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "waxing crescent moon",
- "slug": "waxing_crescent_moon",
- "group": "Travel & Places",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "first quarter moon",
- "slug": "first_quarter_moon",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "waxing gibbous moon",
- "slug": "waxing_gibbous_moon",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "full moon",
- "slug": "full_moon",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "waning gibbous moon",
- "slug": "waning_gibbous_moon",
- "group": "Travel & Places",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "last quarter moon",
- "slug": "last_quarter_moon",
- "group": "Travel & Places",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "waning crescent moon",
- "slug": "waning_crescent_moon",
- "group": "Travel & Places",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "crescent moon",
- "slug": "crescent_moon",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "new moon face",
- "slug": "new_moon_face",
- "group": "Travel & Places",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "first quarter moon face",
- "slug": "first_quarter_moon_face",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "last quarter moon face",
- "slug": "last_quarter_moon_face",
- "group": "Travel & Places",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐ก๏ธ": {
- "name": "thermometer",
- "slug": "thermometer",
- "group": "Travel & Places",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "โ๏ธ": {
- "name": "sun",
- "slug": "sun",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "full moon face",
- "slug": "full_moon_face",
- "group": "Travel & Places",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "sun with face",
- "slug": "sun_with_face",
- "group": "Travel & Places",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ช": {
- "name": "ringed planet",
- "slug": "ringed_planet",
- "group": "Travel & Places",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": false
- },
- "โญ": {
- "name": "star",
- "slug": "star",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "glowing star",
- "slug": "glowing_star",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ ": {
- "name": "shooting star",
- "slug": "shooting_star",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "milky way",
- "slug": "milky_way",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โ๏ธ": {
- "name": "cloud",
- "slug": "cloud",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โ
": {
- "name": "sun behind cloud",
- "slug": "sun_behind_cloud",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โ๏ธ": {
- "name": "cloud with lightning and rain",
- "slug": "cloud_with_lightning_and_rain",
- "group": "Travel & Places",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐ค๏ธ": {
- "name": "sun behind small cloud",
- "slug": "sun_behind_small_cloud",
- "group": "Travel & Places",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐ฅ๏ธ": {
- "name": "sun behind large cloud",
- "slug": "sun_behind_large_cloud",
- "group": "Travel & Places",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐ฆ๏ธ": {
- "name": "sun behind rain cloud",
- "slug": "sun_behind_rain_cloud",
- "group": "Travel & Places",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐ง๏ธ": {
- "name": "cloud with rain",
- "slug": "cloud_with_rain",
- "group": "Travel & Places",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐จ๏ธ": {
- "name": "cloud with snow",
- "slug": "cloud_with_snow",
- "group": "Travel & Places",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐ฉ๏ธ": {
- "name": "cloud with lightning",
- "slug": "cloud_with_lightning",
- "group": "Travel & Places",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐ช๏ธ": {
- "name": "tornado",
- "slug": "tornado",
- "group": "Travel & Places",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐ซ๏ธ": {
- "name": "fog",
- "slug": "fog",
- "group": "Travel & Places",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐ฌ๏ธ": {
- "name": "wind face",
- "slug": "wind_face",
- "group": "Travel & Places",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐": {
- "name": "cyclone",
- "slug": "cyclone",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "rainbow",
- "slug": "rainbow",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "closed umbrella",
- "slug": "closed_umbrella",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โ๏ธ": {
- "name": "umbrella",
- "slug": "umbrella",
- "group": "Travel & Places",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "โ": {
- "name": "umbrella with rain drops",
- "slug": "umbrella_with_rain_drops",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โฑ๏ธ": {
- "name": "umbrella on ground",
- "slug": "umbrella_on_ground",
- "group": "Travel & Places",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "โก": {
- "name": "high voltage",
- "slug": "high_voltage",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โ๏ธ": {
- "name": "snowflake",
- "slug": "snowflake",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โ๏ธ": {
- "name": "snowman",
- "slug": "snowman",
- "group": "Travel & Places",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "โ": {
- "name": "snowman without snow",
- "slug": "snowman_without_snow",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โ๏ธ": {
- "name": "comet",
- "slug": "comet",
- "group": "Travel & Places",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ฅ": {
- "name": "fire",
- "slug": "fire",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ง": {
- "name": "droplet",
- "slug": "droplet",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "water wave",
- "slug": "water_wave",
- "group": "Travel & Places",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "jack-o-lantern",
- "slug": "jack_o_lantern",
- "group": "Activities",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "Christmas tree",
- "slug": "christmas_tree",
- "group": "Activities",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "fireworks",
- "slug": "fireworks",
- "group": "Activities",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "sparkler",
- "slug": "sparkler",
- "group": "Activities",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐งจ": {
- "name": "firecracker",
- "slug": "firecracker",
- "group": "Activities",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": false
- },
- "โจ": {
- "name": "sparkles",
- "slug": "sparkles",
- "group": "Activities",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "balloon",
- "slug": "balloon",
- "group": "Activities",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "party popper",
- "slug": "party_popper",
- "group": "Activities",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "confetti ball",
- "slug": "confetti_ball",
- "group": "Activities",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "tanabata tree",
- "slug": "tanabata_tree",
- "group": "Activities",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "pine decoration",
- "slug": "pine_decoration",
- "group": "Activities",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "Japanese dolls",
- "slug": "japanese_dolls",
- "group": "Activities",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "carp streamer",
- "slug": "carp_streamer",
- "group": "Activities",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "wind chime",
- "slug": "wind_chime",
- "group": "Activities",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "moon viewing ceremony",
- "slug": "moon_viewing_ceremony",
- "group": "Activities",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐งง": {
- "name": "red envelope",
- "slug": "red_envelope",
- "group": "Activities",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "ribbon",
- "slug": "ribbon",
- "group": "Activities",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "wrapped gift",
- "slug": "wrapped_gift",
- "group": "Activities",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐๏ธ": {
- "name": "reminder ribbon",
- "slug": "reminder_ribbon",
- "group": "Activities",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐๏ธ": {
- "name": "admission tickets",
- "slug": "admission_tickets",
- "group": "Activities",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐ซ": {
- "name": "ticket",
- "slug": "ticket",
- "group": "Activities",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐๏ธ": {
- "name": "military medal",
- "slug": "military_medal",
- "group": "Activities",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐": {
- "name": "trophy",
- "slug": "trophy",
- "group": "Activities",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐
": {
- "name": "sports medal",
- "slug": "sports_medal",
- "group": "Activities",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ฅ": {
- "name": "1st place medal",
- "slug": "1st_place_medal",
- "group": "Activities",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": false
- },
- "๐ฅ": {
- "name": "2nd place medal",
- "slug": "2nd_place_medal",
- "group": "Activities",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": false
- },
- "๐ฅ": {
- "name": "3rd place medal",
- "slug": "3rd_place_medal",
- "group": "Activities",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": false
- },
- "โฝ": {
- "name": "soccer ball",
- "slug": "soccer_ball",
- "group": "Activities",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โพ": {
- "name": "baseball",
- "slug": "baseball",
- "group": "Activities",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฅ": {
- "name": "softball",
- "slug": "softball",
- "group": "Activities",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "basketball",
- "slug": "basketball",
- "group": "Activities",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "volleyball",
- "slug": "volleyball",
- "group": "Activities",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "american football",
- "slug": "american_football",
- "group": "Activities",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "rugby football",
- "slug": "rugby_football",
- "group": "Activities",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐พ": {
- "name": "tennis",
- "slug": "tennis",
- "group": "Activities",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฅ": {
- "name": "flying disc",
- "slug": "flying_disc",
- "group": "Activities",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": false
- },
- "๐ณ": {
- "name": "bowling",
- "slug": "bowling",
- "group": "Activities",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "cricket game",
- "slug": "cricket_game",
- "group": "Activities",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "field hockey",
- "slug": "field_hockey",
- "group": "Activities",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "ice hockey",
- "slug": "ice_hockey",
- "group": "Activities",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ฅ": {
- "name": "lacrosse",
- "slug": "lacrosse",
- "group": "Activities",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "ping pong",
- "slug": "ping_pong",
- "group": "Activities",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ธ": {
- "name": "badminton",
- "slug": "badminton",
- "group": "Activities",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ฅ": {
- "name": "boxing glove",
- "slug": "boxing_glove",
- "group": "Activities",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": false
- },
- "๐ฅ": {
- "name": "martial arts uniform",
- "slug": "martial_arts_uniform",
- "group": "Activities",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": false
- },
- "๐ฅ
": {
- "name": "goal net",
- "slug": "goal_net",
- "group": "Activities",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": false
- },
- "โณ": {
- "name": "flag in hole",
- "slug": "flag_in_hole",
- "group": "Activities",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โธ๏ธ": {
- "name": "ice skate",
- "slug": "ice_skate",
- "group": "Activities",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐ฃ": {
- "name": "fishing pole",
- "slug": "fishing_pole",
- "group": "Activities",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐คฟ": {
- "name": "diving mask",
- "slug": "diving_mask",
- "group": "Activities",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": false
- },
- "๐ฝ": {
- "name": "running shirt",
- "slug": "running_shirt",
- "group": "Activities",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฟ": {
- "name": "skis",
- "slug": "skis",
- "group": "Activities",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ท": {
- "name": "sled",
- "slug": "sled",
- "group": "Activities",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": false
- },
- "๐ฅ": {
- "name": "curling stone",
- "slug": "curling_stone",
- "group": "Activities",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": false
- },
- "๐ฏ": {
- "name": "bullseye",
- "slug": "bullseye",
- "group": "Activities",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ช": {
- "name": "yo-yo",
- "slug": "yo_yo",
- "group": "Activities",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": false
- },
- "๐ช": {
- "name": "kite",
- "slug": "kite",
- "group": "Activities",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": false
- },
- "๐ซ": {
- "name": "water pistol",
- "slug": "water_pistol",
- "group": "Activities",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฑ": {
- "name": "pool 8 ball",
- "slug": "pool_8_ball",
- "group": "Activities",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฎ": {
- "name": "crystal ball",
- "slug": "crystal_ball",
- "group": "Activities",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ช": {
- "name": "magic wand",
- "slug": "magic_wand",
- "group": "Activities",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": false
- },
- "๐ฎ": {
- "name": "video game",
- "slug": "video_game",
- "group": "Activities",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐น๏ธ": {
- "name": "joystick",
- "slug": "joystick",
- "group": "Activities",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐ฐ": {
- "name": "slot machine",
- "slug": "slot_machine",
- "group": "Activities",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฒ": {
- "name": "game die",
- "slug": "game_die",
- "group": "Activities",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐งฉ": {
- "name": "puzzle piece",
- "slug": "puzzle_piece",
- "group": "Activities",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": false
- },
- "๐งธ": {
- "name": "teddy bear",
- "slug": "teddy_bear",
- "group": "Activities",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": false
- },
- "๐ช
": {
- "name": "piรฑata",
- "slug": "pinata",
- "group": "Activities",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": false
- },
- "๐ชฉ": {
- "name": "mirror ball",
- "slug": "mirror_ball",
- "group": "Activities",
- "emoji_version": "14.0",
- "unicode_version": "14.0",
- "skin_tone_support": false
- },
- "๐ช": {
- "name": "nesting dolls",
- "slug": "nesting_dolls",
- "group": "Activities",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": false
- },
- "โ ๏ธ": {
- "name": "spade suit",
- "slug": "spade_suit",
- "group": "Activities",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โฅ๏ธ": {
- "name": "heart suit",
- "slug": "heart_suit",
- "group": "Activities",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โฆ๏ธ": {
- "name": "diamond suit",
- "slug": "diamond_suit",
- "group": "Activities",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โฃ๏ธ": {
- "name": "club suit",
- "slug": "club_suit",
- "group": "Activities",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โ๏ธ": {
- "name": "chess pawn",
- "slug": "chess_pawn",
- "group": "Activities",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "joker",
- "slug": "joker",
- "group": "Activities",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "mahjong red dragon",
- "slug": "mahjong_red_dragon",
- "group": "Activities",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ด": {
- "name": "flower playing cards",
- "slug": "flower_playing_cards",
- "group": "Activities",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ญ": {
- "name": "performing arts",
- "slug": "performing_arts",
- "group": "Activities",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ผ๏ธ": {
- "name": "framed picture",
- "slug": "framed_picture",
- "group": "Activities",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐จ": {
- "name": "artist palette",
- "slug": "artist_palette",
- "group": "Activities",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐งต": {
- "name": "thread",
- "slug": "thread",
- "group": "Activities",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": false
- },
- "๐ชก": {
- "name": "sewing needle",
- "slug": "sewing_needle",
- "group": "Activities",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": false
- },
- "๐งถ": {
- "name": "yarn",
- "slug": "yarn",
- "group": "Activities",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": false
- },
- "๐ชข": {
- "name": "knot",
- "slug": "knot",
- "group": "Activities",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "glasses",
- "slug": "glasses",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ถ๏ธ": {
- "name": "sunglasses",
- "slug": "sunglasses",
- "group": "Objects",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐ฅฝ": {
- "name": "goggles",
- "slug": "goggles",
- "group": "Objects",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": false
- },
- "๐ฅผ": {
- "name": "lab coat",
- "slug": "lab_coat",
- "group": "Objects",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": false
- },
- "๐ฆบ": {
- "name": "safety vest",
- "slug": "safety_vest",
- "group": "Objects",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "necktie",
- "slug": "necktie",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "t-shirt",
- "slug": "t_shirt",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "jeans",
- "slug": "jeans",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐งฃ": {
- "name": "scarf",
- "slug": "scarf",
- "group": "Objects",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": false
- },
- "๐งค": {
- "name": "gloves",
- "slug": "gloves",
- "group": "Objects",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": false
- },
- "๐งฅ": {
- "name": "coat",
- "slug": "coat",
- "group": "Objects",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": false
- },
- "๐งฆ": {
- "name": "socks",
- "slug": "socks",
- "group": "Objects",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "dress",
- "slug": "dress",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "kimono",
- "slug": "kimono",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฅป": {
- "name": "sari",
- "slug": "sari",
- "group": "Objects",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": false
- },
- "๐ฉฑ": {
- "name": "one-piece swimsuit",
- "slug": "one_piece_swimsuit",
- "group": "Objects",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": false
- },
- "๐ฉฒ": {
- "name": "briefs",
- "slug": "briefs",
- "group": "Objects",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": false
- },
- "๐ฉณ": {
- "name": "shorts",
- "slug": "shorts",
- "group": "Objects",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "bikini",
- "slug": "bikini",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "womanโs clothes",
- "slug": "woman_s_clothes",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ชญ": {
- "name": "folding hand fan",
- "slug": "folding_hand_fan",
- "group": "Objects",
- "emoji_version": "15.0",
- "unicode_version": "15.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "purse",
- "slug": "purse",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "handbag",
- "slug": "handbag",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "clutch bag",
- "slug": "clutch_bag",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐๏ธ": {
- "name": "shopping bags",
- "slug": "shopping_bags",
- "group": "Objects",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐": {
- "name": "backpack",
- "slug": "backpack",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฉด": {
- "name": "thong sandal",
- "slug": "thong_sandal",
- "group": "Objects",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "manโs shoe",
- "slug": "man_s_shoe",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "running shoe",
- "slug": "running_shoe",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฅพ": {
- "name": "hiking boot",
- "slug": "hiking_boot",
- "group": "Objects",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": false
- },
- "๐ฅฟ": {
- "name": "flat shoe",
- "slug": "flat_shoe",
- "group": "Objects",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": false
- },
- "๐ ": {
- "name": "high-heeled shoe",
- "slug": "high_heeled_shoe",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ก": {
- "name": "womanโs sandal",
- "slug": "woman_s_sandal",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฉฐ": {
- "name": "ballet shoes",
- "slug": "ballet_shoes",
- "group": "Objects",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": false
- },
- "๐ข": {
- "name": "womanโs boot",
- "slug": "woman_s_boot",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ชฎ": {
- "name": "hair pick",
- "slug": "hair_pick",
- "group": "Objects",
- "emoji_version": "15.0",
- "unicode_version": "15.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "crown",
- "slug": "crown",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "womanโs hat",
- "slug": "woman_s_hat",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฉ": {
- "name": "top hat",
- "slug": "top_hat",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "graduation cap",
- "slug": "graduation_cap",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐งข": {
- "name": "billed cap",
- "slug": "billed_cap",
- "group": "Objects",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": false
- },
- "๐ช": {
- "name": "military helmet",
- "slug": "military_helmet",
- "group": "Objects",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": false
- },
- "โ๏ธ": {
- "name": "rescue workerโs helmet",
- "slug": "rescue_worker_s_helmet",
- "group": "Objects",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐ฟ": {
- "name": "prayer beads",
- "slug": "prayer_beads",
- "group": "Objects",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "lipstick",
- "slug": "lipstick",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "ring",
- "slug": "ring",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "gem stone",
- "slug": "gem_stone",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "muted speaker",
- "slug": "muted_speaker",
- "group": "Objects",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "speaker low volume",
- "slug": "speaker_low_volume",
- "group": "Objects",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐": {
- "name": "speaker medium volume",
- "slug": "speaker_medium_volume",
- "group": "Objects",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "speaker high volume",
- "slug": "speaker_high_volume",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ข": {
- "name": "loudspeaker",
- "slug": "loudspeaker",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฃ": {
- "name": "megaphone",
- "slug": "megaphone",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฏ": {
- "name": "postal horn",
- "slug": "postal_horn",
- "group": "Objects",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "bell",
- "slug": "bell",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "bell with slash",
- "slug": "bell_with_slash",
- "group": "Objects",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ผ": {
- "name": "musical score",
- "slug": "musical_score",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ต": {
- "name": "musical note",
- "slug": "musical_note",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ถ": {
- "name": "musical notes",
- "slug": "musical_notes",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐๏ธ": {
- "name": "studio microphone",
- "slug": "studio_microphone",
- "group": "Objects",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐๏ธ": {
- "name": "level slider",
- "slug": "level_slider",
- "group": "Objects",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐๏ธ": {
- "name": "control knobs",
- "slug": "control_knobs",
- "group": "Objects",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐ค": {
- "name": "microphone",
- "slug": "microphone",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ง": {
- "name": "headphone",
- "slug": "headphone",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ป": {
- "name": "radio",
- "slug": "radio",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ท": {
- "name": "saxophone",
- "slug": "saxophone",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ช": {
- "name": "accordion",
- "slug": "accordion",
- "group": "Objects",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": false
- },
- "๐ธ": {
- "name": "guitar",
- "slug": "guitar",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐น": {
- "name": "musical keyboard",
- "slug": "musical_keyboard",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐บ": {
- "name": "trumpet",
- "slug": "trumpet",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ป": {
- "name": "violin",
- "slug": "violin",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ช": {
- "name": "banjo",
- "slug": "banjo",
- "group": "Objects",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": false
- },
- "๐ฅ": {
- "name": "drum",
- "slug": "drum",
- "group": "Objects",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": false
- },
- "๐ช": {
- "name": "long drum",
- "slug": "long_drum",
- "group": "Objects",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": false
- },
- "๐ช": {
- "name": "maracas",
- "slug": "maracas",
- "group": "Objects",
- "emoji_version": "15.0",
- "unicode_version": "15.0",
- "skin_tone_support": false
- },
- "๐ช": {
- "name": "flute",
- "slug": "flute",
- "group": "Objects",
- "emoji_version": "15.0",
- "unicode_version": "15.0",
- "skin_tone_support": false
- },
- "๐ฑ": {
- "name": "mobile phone",
- "slug": "mobile_phone",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฒ": {
- "name": "mobile phone with arrow",
- "slug": "mobile_phone_with_arrow",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โ๏ธ": {
- "name": "telephone",
- "slug": "telephone",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "telephone receiver",
- "slug": "telephone_receiver",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "pager",
- "slug": "pager",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ ": {
- "name": "fax machine",
- "slug": "fax_machine",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "battery",
- "slug": "battery",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ชซ": {
- "name": "low battery",
- "slug": "low_battery",
- "group": "Objects",
- "emoji_version": "14.0",
- "unicode_version": "14.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "electric plug",
- "slug": "electric_plug",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ป": {
- "name": "laptop",
- "slug": "laptop",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฅ๏ธ": {
- "name": "desktop computer",
- "slug": "desktop_computer",
- "group": "Objects",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐จ๏ธ": {
- "name": "printer",
- "slug": "printer",
- "group": "Objects",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "โจ๏ธ": {
- "name": "keyboard",
- "slug": "keyboard",
- "group": "Objects",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ฑ๏ธ": {
- "name": "computer mouse",
- "slug": "computer_mouse",
- "group": "Objects",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐ฒ๏ธ": {
- "name": "trackball",
- "slug": "trackball",
- "group": "Objects",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐ฝ": {
- "name": "computer disk",
- "slug": "computer_disk",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐พ": {
- "name": "floppy disk",
- "slug": "floppy_disk",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฟ": {
- "name": "optical disk",
- "slug": "optical_disk",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "dvd",
- "slug": "dvd",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐งฎ": {
- "name": "abacus",
- "slug": "abacus",
- "group": "Objects",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": false
- },
- "๐ฅ": {
- "name": "movie camera",
- "slug": "movie_camera",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐๏ธ": {
- "name": "film frames",
- "slug": "film_frames",
- "group": "Objects",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐ฝ๏ธ": {
- "name": "film projector",
- "slug": "film_projector",
- "group": "Objects",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐ฌ": {
- "name": "clapper board",
- "slug": "clapper_board",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐บ": {
- "name": "television",
- "slug": "television",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ท": {
- "name": "camera",
- "slug": "camera",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ธ": {
- "name": "camera with flash",
- "slug": "camera_with_flash",
- "group": "Objects",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐น": {
- "name": "video camera",
- "slug": "video_camera",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ผ": {
- "name": "videocassette",
- "slug": "videocassette",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "magnifying glass tilted left",
- "slug": "magnifying_glass_tilted_left",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "magnifying glass tilted right",
- "slug": "magnifying_glass_tilted_right",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฏ๏ธ": {
- "name": "candle",
- "slug": "candle",
- "group": "Objects",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐ก": {
- "name": "light bulb",
- "slug": "light_bulb",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฆ": {
- "name": "flashlight",
- "slug": "flashlight",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฎ": {
- "name": "red paper lantern",
- "slug": "red_paper_lantern",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ช": {
- "name": "diya lamp",
- "slug": "diya_lamp",
- "group": "Objects",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "notebook with decorative cover",
- "slug": "notebook_with_decorative_cover",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "closed book",
- "slug": "closed_book",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "open book",
- "slug": "open_book",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "green book",
- "slug": "green_book",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "blue book",
- "slug": "blue_book",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "orange book",
- "slug": "orange_book",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "books",
- "slug": "books",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "notebook",
- "slug": "notebook",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "ledger",
- "slug": "ledger",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "page with curl",
- "slug": "page_with_curl",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "scroll",
- "slug": "scroll",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "page facing up",
- "slug": "page_facing_up",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฐ": {
- "name": "newspaper",
- "slug": "newspaper",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐๏ธ": {
- "name": "rolled-up newspaper",
- "slug": "rolled_up_newspaper",
- "group": "Objects",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐": {
- "name": "bookmark tabs",
- "slug": "bookmark_tabs",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "bookmark",
- "slug": "bookmark",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ท๏ธ": {
- "name": "label",
- "slug": "label",
- "group": "Objects",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐ฐ": {
- "name": "money bag",
- "slug": "money_bag",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ช": {
- "name": "coin",
- "slug": "coin",
- "group": "Objects",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": false
- },
- "๐ด": {
- "name": "yen banknote",
- "slug": "yen_banknote",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ต": {
- "name": "dollar banknote",
- "slug": "dollar_banknote",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ถ": {
- "name": "euro banknote",
- "slug": "euro_banknote",
- "group": "Objects",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ท": {
- "name": "pound banknote",
- "slug": "pound_banknote",
- "group": "Objects",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ธ": {
- "name": "money with wings",
- "slug": "money_with_wings",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ณ": {
- "name": "credit card",
- "slug": "credit_card",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐งพ": {
- "name": "receipt",
- "slug": "receipt",
- "group": "Objects",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": false
- },
- "๐น": {
- "name": "chart increasing with yen",
- "slug": "chart_increasing_with_yen",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โ๏ธ": {
- "name": "envelope",
- "slug": "envelope",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ง": {
- "name": "e-mail",
- "slug": "e_mail",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐จ": {
- "name": "incoming envelope",
- "slug": "incoming_envelope",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฉ": {
- "name": "envelope with arrow",
- "slug": "envelope_with_arrow",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ค": {
- "name": "outbox tray",
- "slug": "outbox_tray",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฅ": {
- "name": "inbox tray",
- "slug": "inbox_tray",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฆ": {
- "name": "package",
- "slug": "package",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ซ": {
- "name": "closed mailbox with raised flag",
- "slug": "closed_mailbox_with_raised_flag",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ช": {
- "name": "closed mailbox with lowered flag",
- "slug": "closed_mailbox_with_lowered_flag",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฌ": {
- "name": "open mailbox with raised flag",
- "slug": "open_mailbox_with_raised_flag",
- "group": "Objects",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐ญ": {
- "name": "open mailbox with lowered flag",
- "slug": "open_mailbox_with_lowered_flag",
- "group": "Objects",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐ฎ": {
- "name": "postbox",
- "slug": "postbox",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ณ๏ธ": {
- "name": "ballot box with ballot",
- "slug": "ballot_box_with_ballot",
- "group": "Objects",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "โ๏ธ": {
- "name": "pencil",
- "slug": "pencil",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โ๏ธ": {
- "name": "black nib",
- "slug": "black_nib",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐๏ธ": {
- "name": "fountain pen",
- "slug": "fountain_pen",
- "group": "Objects",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐๏ธ": {
- "name": "pen",
- "slug": "pen",
- "group": "Objects",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐๏ธ": {
- "name": "paintbrush",
- "slug": "paintbrush",
- "group": "Objects",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐๏ธ": {
- "name": "crayon",
- "slug": "crayon",
- "group": "Objects",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐": {
- "name": "memo",
- "slug": "memo",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ผ": {
- "name": "briefcase",
- "slug": "briefcase",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "file folder",
- "slug": "file_folder",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "open file folder",
- "slug": "open_file_folder",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐๏ธ": {
- "name": "card index dividers",
- "slug": "card_index_dividers",
- "group": "Objects",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐
": {
- "name": "calendar",
- "slug": "calendar",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "tear-off calendar",
- "slug": "tear_off_calendar",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐๏ธ": {
- "name": "spiral notepad",
- "slug": "spiral_notepad",
- "group": "Objects",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐๏ธ": {
- "name": "spiral calendar",
- "slug": "spiral_calendar",
- "group": "Objects",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐": {
- "name": "card index",
- "slug": "card_index",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "chart increasing",
- "slug": "chart_increasing",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "chart decreasing",
- "slug": "chart_decreasing",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "bar chart",
- "slug": "bar_chart",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "clipboard",
- "slug": "clipboard",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "pushpin",
- "slug": "pushpin",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "round pushpin",
- "slug": "round_pushpin",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "paperclip",
- "slug": "paperclip",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐๏ธ": {
- "name": "linked paperclips",
- "slug": "linked_paperclips",
- "group": "Objects",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐": {
- "name": "straight ruler",
- "slug": "straight_ruler",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "triangular ruler",
- "slug": "triangular_ruler",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โ๏ธ": {
- "name": "scissors",
- "slug": "scissors",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐๏ธ": {
- "name": "card file box",
- "slug": "card_file_box",
- "group": "Objects",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐๏ธ": {
- "name": "file cabinet",
- "slug": "file_cabinet",
- "group": "Objects",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐๏ธ": {
- "name": "wastebasket",
- "slug": "wastebasket",
- "group": "Objects",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐": {
- "name": "locked",
- "slug": "locked",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "unlocked",
- "slug": "unlocked",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "locked with pen",
- "slug": "locked_with_pen",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "locked with key",
- "slug": "locked_with_key",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "key",
- "slug": "key",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐๏ธ": {
- "name": "old key",
- "slug": "old_key",
- "group": "Objects",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐จ": {
- "name": "hammer",
- "slug": "hammer",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ช": {
- "name": "axe",
- "slug": "axe",
- "group": "Objects",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": false
- },
- "โ๏ธ": {
- "name": "pick",
- "slug": "pick",
- "group": "Objects",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "โ๏ธ": {
- "name": "hammer and pick",
- "slug": "hammer_and_pick",
- "group": "Objects",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ ๏ธ": {
- "name": "hammer and wrench",
- "slug": "hammer_and_wrench",
- "group": "Objects",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐ก๏ธ": {
- "name": "dagger",
- "slug": "dagger",
- "group": "Objects",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "โ๏ธ": {
- "name": "crossed swords",
- "slug": "crossed_swords",
- "group": "Objects",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ฃ": {
- "name": "bomb",
- "slug": "bomb",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ช": {
- "name": "boomerang",
- "slug": "boomerang",
- "group": "Objects",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": false
- },
- "๐น": {
- "name": "bow and arrow",
- "slug": "bow_and_arrow",
- "group": "Objects",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ก๏ธ": {
- "name": "shield",
- "slug": "shield",
- "group": "Objects",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐ช": {
- "name": "carpentry saw",
- "slug": "carpentry_saw",
- "group": "Objects",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": false
- },
- "๐ง": {
- "name": "wrench",
- "slug": "wrench",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ช": {
- "name": "screwdriver",
- "slug": "screwdriver",
- "group": "Objects",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": false
- },
- "๐ฉ": {
- "name": "nut and bolt",
- "slug": "nut_and_bolt",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โ๏ธ": {
- "name": "gear",
- "slug": "gear",
- "group": "Objects",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐๏ธ": {
- "name": "clamp",
- "slug": "clamp",
- "group": "Objects",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "โ๏ธ": {
- "name": "balance scale",
- "slug": "balance_scale",
- "group": "Objects",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ฆฏ": {
- "name": "white cane",
- "slug": "white_cane",
- "group": "Objects",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "link",
- "slug": "link",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โ๏ธโ๐ฅ": {
- "name": "broken chain",
- "slug": "broken_chain",
- "group": "Objects",
- "emoji_version": "15.1",
- "unicode_version": "15.1",
- "skin_tone_support": false
- },
- "โ๏ธ": {
- "name": "chains",
- "slug": "chains",
- "group": "Objects",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐ช": {
- "name": "hook",
- "slug": "hook",
- "group": "Objects",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": false
- },
- "๐งฐ": {
- "name": "toolbox",
- "slug": "toolbox",
- "group": "Objects",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": false
- },
- "๐งฒ": {
- "name": "magnet",
- "slug": "magnet",
- "group": "Objects",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": false
- },
- "๐ช": {
- "name": "ladder",
- "slug": "ladder",
- "group": "Objects",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": false
- },
- "โ๏ธ": {
- "name": "alembic",
- "slug": "alembic",
- "group": "Objects",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐งช": {
- "name": "test tube",
- "slug": "test_tube",
- "group": "Objects",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": false
- },
- "๐งซ": {
- "name": "petri dish",
- "slug": "petri_dish",
- "group": "Objects",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": false
- },
- "๐งฌ": {
- "name": "dna",
- "slug": "dna",
- "group": "Objects",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": false
- },
- "๐ฌ": {
- "name": "microscope",
- "slug": "microscope",
- "group": "Objects",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ญ": {
- "name": "telescope",
- "slug": "telescope",
- "group": "Objects",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ก": {
- "name": "satellite antenna",
- "slug": "satellite_antenna",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "syringe",
- "slug": "syringe",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฉธ": {
- "name": "drop of blood",
- "slug": "drop_of_blood",
- "group": "Objects",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "pill",
- "slug": "pill",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฉน": {
- "name": "adhesive bandage",
- "slug": "adhesive_bandage",
- "group": "Objects",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": false
- },
- "๐ฉผ": {
- "name": "crutch",
- "slug": "crutch",
- "group": "Objects",
- "emoji_version": "14.0",
- "unicode_version": "14.0",
- "skin_tone_support": false
- },
- "๐ฉบ": {
- "name": "stethoscope",
- "slug": "stethoscope",
- "group": "Objects",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": false
- },
- "๐ฉป": {
- "name": "x-ray",
- "slug": "x_ray",
- "group": "Objects",
- "emoji_version": "14.0",
- "unicode_version": "14.0",
- "skin_tone_support": false
- },
- "๐ช": {
- "name": "door",
- "slug": "door",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "elevator",
- "slug": "elevator",
- "group": "Objects",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": false
- },
- "๐ช": {
- "name": "mirror",
- "slug": "mirror",
- "group": "Objects",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": false
- },
- "๐ช": {
- "name": "window",
- "slug": "window",
- "group": "Objects",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": false
- },
- "๐๏ธ": {
- "name": "bed",
- "slug": "bed",
- "group": "Objects",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐๏ธ": {
- "name": "couch and lamp",
- "slug": "couch_and_lamp",
- "group": "Objects",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐ช": {
- "name": "chair",
- "slug": "chair",
- "group": "Objects",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": false
- },
- "๐ฝ": {
- "name": "toilet",
- "slug": "toilet",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ช ": {
- "name": "plunger",
- "slug": "plunger",
- "group": "Objects",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": false
- },
- "๐ฟ": {
- "name": "shower",
- "slug": "shower",
- "group": "Objects",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "bathtub",
- "slug": "bathtub",
- "group": "Objects",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ชค": {
- "name": "mouse trap",
- "slug": "mouse_trap",
- "group": "Objects",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": false
- },
- "๐ช": {
- "name": "razor",
- "slug": "razor",
- "group": "Objects",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": false
- },
- "๐งด": {
- "name": "lotion bottle",
- "slug": "lotion_bottle",
- "group": "Objects",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": false
- },
- "๐งท": {
- "name": "safety pin",
- "slug": "safety_pin",
- "group": "Objects",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": false
- },
- "๐งน": {
- "name": "broom",
- "slug": "broom",
- "group": "Objects",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": false
- },
- "๐งบ": {
- "name": "basket",
- "slug": "basket",
- "group": "Objects",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": false
- },
- "๐งป": {
- "name": "roll of paper",
- "slug": "roll_of_paper",
- "group": "Objects",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": false
- },
- "๐ชฃ": {
- "name": "bucket",
- "slug": "bucket",
- "group": "Objects",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": false
- },
- "๐งผ": {
- "name": "soap",
- "slug": "soap",
- "group": "Objects",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": false
- },
- "๐ซง": {
- "name": "bubbles",
- "slug": "bubbles",
- "group": "Objects",
- "emoji_version": "14.0",
- "unicode_version": "14.0",
- "skin_tone_support": false
- },
- "๐ชฅ": {
- "name": "toothbrush",
- "slug": "toothbrush",
- "group": "Objects",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": false
- },
- "๐งฝ": {
- "name": "sponge",
- "slug": "sponge",
- "group": "Objects",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": false
- },
- "๐งฏ": {
- "name": "fire extinguisher",
- "slug": "fire_extinguisher",
- "group": "Objects",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "shopping cart",
- "slug": "shopping_cart",
- "group": "Objects",
- "emoji_version": "3.0",
- "unicode_version": "3.0",
- "skin_tone_support": false
- },
- "๐ฌ": {
- "name": "cigarette",
- "slug": "cigarette",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โฐ๏ธ": {
- "name": "coffin",
- "slug": "coffin",
- "group": "Objects",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ชฆ": {
- "name": "headstone",
- "slug": "headstone",
- "group": "Objects",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": false
- },
- "โฑ๏ธ": {
- "name": "funeral urn",
- "slug": "funeral_urn",
- "group": "Objects",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐งฟ": {
- "name": "nazar amulet",
- "slug": "nazar_amulet",
- "group": "Objects",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": false
- },
- "๐ชฌ": {
- "name": "hamsa",
- "slug": "hamsa",
- "group": "Objects",
- "emoji_version": "14.0",
- "unicode_version": "14.0",
- "skin_tone_support": false
- },
- "๐ฟ": {
- "name": "moai",
- "slug": "moai",
- "group": "Objects",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ชง": {
- "name": "placard",
- "slug": "placard",
- "group": "Objects",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": false
- },
- "๐ชช": {
- "name": "identification card",
- "slug": "identification_card",
- "group": "Objects",
- "emoji_version": "14.0",
- "unicode_version": "14.0",
- "skin_tone_support": false
- },
- "๐ง": {
- "name": "ATM sign",
- "slug": "atm_sign",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฎ": {
- "name": "litter in bin sign",
- "slug": "litter_in_bin_sign",
- "group": "Symbols",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ฐ": {
- "name": "potable water",
- "slug": "potable_water",
- "group": "Symbols",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "โฟ": {
- "name": "wheelchair symbol",
- "slug": "wheelchair_symbol",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐น": {
- "name": "menโs room",
- "slug": "men_s_room",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐บ": {
- "name": "womenโs room",
- "slug": "women_s_room",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ป": {
- "name": "restroom",
- "slug": "restroom",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ผ": {
- "name": "baby symbol",
- "slug": "baby_symbol",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐พ": {
- "name": "water closet",
- "slug": "water_closet",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "passport control",
- "slug": "passport_control",
- "group": "Symbols",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "customs",
- "slug": "customs",
- "group": "Symbols",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "baggage claim",
- "slug": "baggage_claim",
- "group": "Symbols",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐
": {
- "name": "left luggage",
- "slug": "left_luggage",
- "group": "Symbols",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "โ ๏ธ": {
- "name": "warning",
- "slug": "warning",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ธ": {
- "name": "children crossing",
- "slug": "children_crossing",
- "group": "Symbols",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "โ": {
- "name": "no entry",
- "slug": "no_entry",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ซ": {
- "name": "prohibited",
- "slug": "prohibited",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ณ": {
- "name": "no bicycles",
- "slug": "no_bicycles",
- "group": "Symbols",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ญ": {
- "name": "no smoking",
- "slug": "no_smoking",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฏ": {
- "name": "no littering",
- "slug": "no_littering",
- "group": "Symbols",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ฑ": {
- "name": "non-potable water",
- "slug": "non_potable_water",
- "group": "Symbols",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ท": {
- "name": "no pedestrians",
- "slug": "no_pedestrians",
- "group": "Symbols",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ต": {
- "name": "no mobile phones",
- "slug": "no_mobile_phones",
- "group": "Symbols",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "no one under eighteen",
- "slug": "no_one_under_eighteen",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โข๏ธ": {
- "name": "radioactive",
- "slug": "radioactive",
- "group": "Symbols",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "โฃ๏ธ": {
- "name": "biohazard",
- "slug": "biohazard",
- "group": "Symbols",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "โฌ๏ธ": {
- "name": "up arrow",
- "slug": "up_arrow",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โ๏ธ": {
- "name": "up-right arrow",
- "slug": "up_right_arrow",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โก๏ธ": {
- "name": "right arrow",
- "slug": "right_arrow",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โ๏ธ": {
- "name": "down-right arrow",
- "slug": "down_right_arrow",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โฌ๏ธ": {
- "name": "down arrow",
- "slug": "down_arrow",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โ๏ธ": {
- "name": "down-left arrow",
- "slug": "down_left_arrow",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โฌ
๏ธ": {
- "name": "left arrow",
- "slug": "left_arrow",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โ๏ธ": {
- "name": "up-left arrow",
- "slug": "up_left_arrow",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โ๏ธ": {
- "name": "up-down arrow",
- "slug": "up_down_arrow",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โ๏ธ": {
- "name": "left-right arrow",
- "slug": "left_right_arrow",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โฉ๏ธ": {
- "name": "right arrow curving left",
- "slug": "right_arrow_curving_left",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โช๏ธ": {
- "name": "left arrow curving right",
- "slug": "left_arrow_curving_right",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โคด๏ธ": {
- "name": "right arrow curving up",
- "slug": "right_arrow_curving_up",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โคต๏ธ": {
- "name": "right arrow curving down",
- "slug": "right_arrow_curving_down",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "clockwise vertical arrows",
- "slug": "clockwise_vertical_arrows",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "counterclockwise arrows button",
- "slug": "counterclockwise_arrows_button",
- "group": "Symbols",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "BACK arrow",
- "slug": "back_arrow",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "END arrow",
- "slug": "end_arrow",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "ON! arrow",
- "slug": "on_arrow",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "SOON arrow",
- "slug": "soon_arrow",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "TOP arrow",
- "slug": "top_arrow",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "place of worship",
- "slug": "place_of_worship",
- "group": "Symbols",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "โ๏ธ": {
- "name": "atom symbol",
- "slug": "atom_symbol",
- "group": "Symbols",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐๏ธ": {
- "name": "om",
- "slug": "om",
- "group": "Symbols",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "โก๏ธ": {
- "name": "star of David",
- "slug": "star_of_david",
- "group": "Symbols",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "โธ๏ธ": {
- "name": "wheel of dharma",
- "slug": "wheel_of_dharma",
- "group": "Symbols",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "โฏ๏ธ": {
- "name": "yin yang",
- "slug": "yin_yang",
- "group": "Symbols",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "โ๏ธ": {
- "name": "latin cross",
- "slug": "latin_cross",
- "group": "Symbols",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "โฆ๏ธ": {
- "name": "orthodox cross",
- "slug": "orthodox_cross",
- "group": "Symbols",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "โช๏ธ": {
- "name": "star and crescent",
- "slug": "star_and_crescent",
- "group": "Symbols",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "โฎ๏ธ": {
- "name": "peace symbol",
- "slug": "peace_symbol",
- "group": "Symbols",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "menorah",
- "slug": "menorah",
- "group": "Symbols",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ฏ": {
- "name": "dotted six-pointed star",
- "slug": "dotted_six_pointed_star",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ชฏ": {
- "name": "khanda",
- "slug": "khanda",
- "group": "Symbols",
- "emoji_version": "15.0",
- "unicode_version": "15.0",
- "skin_tone_support": false
- },
- "โ": {
- "name": "Aries",
- "slug": "aries",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โ": {
- "name": "Taurus",
- "slug": "taurus",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โ": {
- "name": "Gemini",
- "slug": "gemini",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โ": {
- "name": "Cancer",
- "slug": "cancer",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โ": {
- "name": "Leo",
- "slug": "leo",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โ": {
- "name": "Virgo",
- "slug": "virgo",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โ": {
- "name": "Libra",
- "slug": "libra",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โ": {
- "name": "Scorpio",
- "slug": "scorpio",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โ": {
- "name": "Sagittarius",
- "slug": "sagittarius",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โ": {
- "name": "Capricorn",
- "slug": "capricorn",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โ": {
- "name": "Aquarius",
- "slug": "aquarius",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โ": {
- "name": "Pisces",
- "slug": "pisces",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โ": {
- "name": "Ophiuchus",
- "slug": "ophiuchus",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "shuffle tracks button",
- "slug": "shuffle_tracks_button",
- "group": "Symbols",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "repeat button",
- "slug": "repeat_button",
- "group": "Symbols",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "repeat single button",
- "slug": "repeat_single_button",
- "group": "Symbols",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "โถ๏ธ": {
- "name": "play button",
- "slug": "play_button",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โฉ": {
- "name": "fast-forward button",
- "slug": "fast_forward_button",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โญ๏ธ": {
- "name": "next track button",
- "slug": "next_track_button",
- "group": "Symbols",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "โฏ๏ธ": {
- "name": "play or pause button",
- "slug": "play_or_pause_button",
- "group": "Symbols",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "โ๏ธ": {
- "name": "reverse button",
- "slug": "reverse_button",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โช": {
- "name": "fast reverse button",
- "slug": "fast_reverse_button",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โฎ๏ธ": {
- "name": "last track button",
- "slug": "last_track_button",
- "group": "Symbols",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐ผ": {
- "name": "upwards button",
- "slug": "upwards_button",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โซ": {
- "name": "fast up button",
- "slug": "fast_up_button",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฝ": {
- "name": "downwards button",
- "slug": "downwards_button",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โฌ": {
- "name": "fast down button",
- "slug": "fast_down_button",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โธ๏ธ": {
- "name": "pause button",
- "slug": "pause_button",
- "group": "Symbols",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "โน๏ธ": {
- "name": "stop button",
- "slug": "stop_button",
- "group": "Symbols",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "โบ๏ธ": {
- "name": "record button",
- "slug": "record_button",
- "group": "Symbols",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "โ๏ธ": {
- "name": "eject button",
- "slug": "eject_button",
- "group": "Symbols",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ฆ": {
- "name": "cinema",
- "slug": "cinema",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐
": {
- "name": "dim button",
- "slug": "dim_button",
- "group": "Symbols",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐": {
- "name": "bright button",
- "slug": "bright_button",
- "group": "Symbols",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ถ": {
- "name": "antenna bars",
- "slug": "antenna_bars",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "wireless",
- "slug": "wireless",
- "group": "Symbols",
- "emoji_version": "15.0",
- "unicode_version": "15.0",
- "skin_tone_support": false
- },
- "๐ณ": {
- "name": "vibration mode",
- "slug": "vibration_mode",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ด": {
- "name": "mobile phone off",
- "slug": "mobile_phone_off",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โ๏ธ": {
- "name": "female sign",
- "slug": "female_sign",
- "group": "Symbols",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": false
- },
- "โ๏ธ": {
- "name": "male sign",
- "slug": "male_sign",
- "group": "Symbols",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": false
- },
- "โง๏ธ": {
- "name": "transgender symbol",
- "slug": "transgender_symbol",
- "group": "Symbols",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": false
- },
- "โ๏ธ": {
- "name": "multiply",
- "slug": "multiply",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โ": {
- "name": "plus",
- "slug": "plus",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โ": {
- "name": "minus",
- "slug": "minus",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โ": {
- "name": "divide",
- "slug": "divide",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฐ": {
- "name": "heavy equals sign",
- "slug": "heavy_equals_sign",
- "group": "Symbols",
- "emoji_version": "14.0",
- "unicode_version": "14.0",
- "skin_tone_support": false
- },
- "โพ๏ธ": {
- "name": "infinity",
- "slug": "infinity",
- "group": "Symbols",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": false
- },
- "โผ๏ธ": {
- "name": "double exclamation mark",
- "slug": "double_exclamation_mark",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โ๏ธ": {
- "name": "exclamation question mark",
- "slug": "exclamation_question_mark",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โ": {
- "name": "red question mark",
- "slug": "red_question_mark",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โ": {
- "name": "white question mark",
- "slug": "white_question_mark",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โ": {
- "name": "white exclamation mark",
- "slug": "white_exclamation_mark",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โ": {
- "name": "red exclamation mark",
- "slug": "red_exclamation_mark",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "ใฐ๏ธ": {
- "name": "wavy dash",
- "slug": "wavy_dash",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฑ": {
- "name": "currency exchange",
- "slug": "currency_exchange",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฒ": {
- "name": "heavy dollar sign",
- "slug": "heavy_dollar_sign",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โ๏ธ": {
- "name": "medical symbol",
- "slug": "medical_symbol",
- "group": "Symbols",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": false
- },
- "โป๏ธ": {
- "name": "recycling symbol",
- "slug": "recycling_symbol",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โ๏ธ": {
- "name": "fleur-de-lis",
- "slug": "fleur_de_lis",
- "group": "Symbols",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ฑ": {
- "name": "trident emblem",
- "slug": "trident_emblem",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "name badge",
- "slug": "name_badge",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฐ": {
- "name": "Japanese symbol for beginner",
- "slug": "japanese_symbol_for_beginner",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โญ": {
- "name": "hollow red circle",
- "slug": "hollow_red_circle",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โ
": {
- "name": "check mark button",
- "slug": "check_mark_button",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โ๏ธ": {
- "name": "check box with check",
- "slug": "check_box_with_check",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โ๏ธ": {
- "name": "check mark",
- "slug": "check_mark",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โ": {
- "name": "cross mark",
- "slug": "cross_mark",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โ": {
- "name": "cross mark button",
- "slug": "cross_mark_button",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โฐ": {
- "name": "curly loop",
- "slug": "curly_loop",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โฟ": {
- "name": "double curly loop",
- "slug": "double_curly_loop",
- "group": "Symbols",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "ใฝ๏ธ": {
- "name": "part alternation mark",
- "slug": "part_alternation_mark",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โณ๏ธ": {
- "name": "eight-spoked asterisk",
- "slug": "eight_spoked_asterisk",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โด๏ธ": {
- "name": "eight-pointed star",
- "slug": "eight_pointed_star",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โ๏ธ": {
- "name": "sparkle",
- "slug": "sparkle",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "ยฉ๏ธ": {
- "name": "copyright",
- "slug": "copyright",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "ยฎ๏ธ": {
- "name": "registered",
- "slug": "registered",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โข๏ธ": {
- "name": "trade mark",
- "slug": "trade_mark",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "#๏ธโฃ": {
- "name": "keycap #",
- "slug": "keycap_number_sign",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "*๏ธโฃ": {
- "name": "keycap *",
- "slug": "keycap_asterisk",
- "group": "Symbols",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "0๏ธโฃ": {
- "name": "keycap 0",
- "slug": "keycap_0",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "1๏ธโฃ": {
- "name": "keycap 1",
- "slug": "keycap_1",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "2๏ธโฃ": {
- "name": "keycap 2",
- "slug": "keycap_2",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "3๏ธโฃ": {
- "name": "keycap 3",
- "slug": "keycap_3",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "4๏ธโฃ": {
- "name": "keycap 4",
- "slug": "keycap_4",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "5๏ธโฃ": {
- "name": "keycap 5",
- "slug": "keycap_5",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "6๏ธโฃ": {
- "name": "keycap 6",
- "slug": "keycap_6",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "7๏ธโฃ": {
- "name": "keycap 7",
- "slug": "keycap_7",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "8๏ธโฃ": {
- "name": "keycap 8",
- "slug": "keycap_8",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "9๏ธโฃ": {
- "name": "keycap 9",
- "slug": "keycap_9",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "keycap 10",
- "slug": "keycap_10",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ ": {
- "name": "input latin uppercase",
- "slug": "input_latin_uppercase",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ก": {
- "name": "input latin lowercase",
- "slug": "input_latin_lowercase",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ข": {
- "name": "input numbers",
- "slug": "input_numbers",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฃ": {
- "name": "input symbols",
- "slug": "input_symbols",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ค": {
- "name": "input latin letters",
- "slug": "input_latin_letters",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐
ฐ๏ธ": {
- "name": "A button (blood type)",
- "slug": "a_button",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "AB button (blood type)",
- "slug": "ab_button",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐
ฑ๏ธ": {
- "name": "B button (blood type)",
- "slug": "b_button",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "CL button",
- "slug": "cl_button",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "COOL button",
- "slug": "cool_button",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "FREE button",
- "slug": "free_button",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โน๏ธ": {
- "name": "information",
- "slug": "information",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "ID button",
- "slug": "id_button",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โ๏ธ": {
- "name": "circled M",
- "slug": "circled_m",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "NEW button",
- "slug": "new_button",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "NG button",
- "slug": "ng_button",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐
พ๏ธ": {
- "name": "O button (blood type)",
- "slug": "o_button",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "OK button",
- "slug": "ok_button",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐
ฟ๏ธ": {
- "name": "P button",
- "slug": "p_button",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "SOS button",
- "slug": "sos_button",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "UP! button",
- "slug": "up_button",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "VS button",
- "slug": "vs_button",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "Japanese โhereโ button",
- "slug": "japanese_here_button",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐๏ธ": {
- "name": "Japanese โservice chargeโ button",
- "slug": "japanese_service_charge_button",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ท๏ธ": {
- "name": "Japanese โmonthly amountโ button",
- "slug": "japanese_monthly_amount_button",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ถ": {
- "name": "Japanese โnot free of chargeโ button",
- "slug": "japanese_not_free_of_charge_button",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฏ": {
- "name": "Japanese โreservedโ button",
- "slug": "japanese_reserved_button",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "Japanese โbargainโ button",
- "slug": "japanese_bargain_button",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐น": {
- "name": "Japanese โdiscountโ button",
- "slug": "japanese_discount_button",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "Japanese โfree of chargeโ button",
- "slug": "japanese_free_of_charge_button",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฒ": {
- "name": "Japanese โprohibitedโ button",
- "slug": "japanese_prohibited_button",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "Japanese โacceptableโ button",
- "slug": "japanese_acceptable_button",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ธ": {
- "name": "Japanese โapplicationโ button",
- "slug": "japanese_application_button",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ด": {
- "name": "Japanese โpassing gradeโ button",
- "slug": "japanese_passing_grade_button",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ณ": {
- "name": "Japanese โvacancyโ button",
- "slug": "japanese_vacancy_button",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "ใ๏ธ": {
- "name": "Japanese โcongratulationsโ button",
- "slug": "japanese_congratulations_button",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "ใ๏ธ": {
- "name": "Japanese โsecretโ button",
- "slug": "japanese_secret_button",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐บ": {
- "name": "Japanese โopen for businessโ button",
- "slug": "japanese_open_for_business_button",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ต": {
- "name": "Japanese โno vacancyโ button",
- "slug": "japanese_no_vacancy_button",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ด": {
- "name": "red circle",
- "slug": "red_circle",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ ": {
- "name": "orange circle",
- "slug": "orange_circle",
- "group": "Symbols",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": false
- },
- "๐ก": {
- "name": "yellow circle",
- "slug": "yellow_circle",
- "group": "Symbols",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": false
- },
- "๐ข": {
- "name": "green circle",
- "slug": "green_circle",
- "group": "Symbols",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": false
- },
- "๐ต": {
- "name": "blue circle",
- "slug": "blue_circle",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฃ": {
- "name": "purple circle",
- "slug": "purple_circle",
- "group": "Symbols",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": false
- },
- "๐ค": {
- "name": "brown circle",
- "slug": "brown_circle",
- "group": "Symbols",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": false
- },
- "โซ": {
- "name": "black circle",
- "slug": "black_circle",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โช": {
- "name": "white circle",
- "slug": "white_circle",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฅ": {
- "name": "red square",
- "slug": "red_square",
- "group": "Symbols",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": false
- },
- "๐ง": {
- "name": "orange square",
- "slug": "orange_square",
- "group": "Symbols",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": false
- },
- "๐จ": {
- "name": "yellow square",
- "slug": "yellow_square",
- "group": "Symbols",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": false
- },
- "๐ฉ": {
- "name": "green square",
- "slug": "green_square",
- "group": "Symbols",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": false
- },
- "๐ฆ": {
- "name": "blue square",
- "slug": "blue_square",
- "group": "Symbols",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": false
- },
- "๐ช": {
- "name": "purple square",
- "slug": "purple_square",
- "group": "Symbols",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": false
- },
- "๐ซ": {
- "name": "brown square",
- "slug": "brown_square",
- "group": "Symbols",
- "emoji_version": "12.0",
- "unicode_version": "12.0",
- "skin_tone_support": false
- },
- "โฌ": {
- "name": "black large square",
- "slug": "black_large_square",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โฌ": {
- "name": "white large square",
- "slug": "white_large_square",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โผ๏ธ": {
- "name": "black medium square",
- "slug": "black_medium_square",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โป๏ธ": {
- "name": "white medium square",
- "slug": "white_medium_square",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โพ": {
- "name": "black medium-small square",
- "slug": "black_medium_small_square",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โฝ": {
- "name": "white medium-small square",
- "slug": "white_medium_small_square",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โช๏ธ": {
- "name": "black small square",
- "slug": "black_small_square",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "โซ๏ธ": {
- "name": "white small square",
- "slug": "white_small_square",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ถ": {
- "name": "large orange diamond",
- "slug": "large_orange_diamond",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ท": {
- "name": "large blue diamond",
- "slug": "large_blue_diamond",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ธ": {
- "name": "small orange diamond",
- "slug": "small_orange_diamond",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐น": {
- "name": "small blue diamond",
- "slug": "small_blue_diamond",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐บ": {
- "name": "red triangle pointed up",
- "slug": "red_triangle_pointed_up",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ป": {
- "name": "red triangle pointed down",
- "slug": "red_triangle_pointed_down",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ ": {
- "name": "diamond with a dot",
- "slug": "diamond_with_a_dot",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "radio button",
- "slug": "radio_button",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ณ": {
- "name": "white square button",
- "slug": "white_square_button",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฒ": {
- "name": "black square button",
- "slug": "black_square_button",
- "group": "Symbols",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "chequered flag",
- "slug": "chequered_flag",
- "group": "Flags",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฉ": {
- "name": "triangular flag",
- "slug": "triangular_flag",
- "group": "Flags",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐": {
- "name": "crossed flags",
- "slug": "crossed_flags",
- "group": "Flags",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ด": {
- "name": "black flag",
- "slug": "black_flag",
- "group": "Flags",
- "emoji_version": "1.0",
- "unicode_version": "1.0",
- "skin_tone_support": false
- },
- "๐ณ๏ธ": {
- "name": "white flag",
- "slug": "white_flag",
- "group": "Flags",
- "emoji_version": "0.7",
- "unicode_version": "0.7",
- "skin_tone_support": false
- },
- "๐ณ๏ธโ๐": {
- "name": "rainbow flag",
- "slug": "rainbow_flag",
- "group": "Flags",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": false
- },
- "๐ณ๏ธโโง๏ธ": {
- "name": "transgender flag",
- "slug": "transgender_flag",
- "group": "Flags",
- "emoji_version": "13.0",
- "unicode_version": "13.0",
- "skin_tone_support": false
- },
- "๐ดโโ ๏ธ": {
- "name": "pirate flag",
- "slug": "pirate_flag",
- "group": "Flags",
- "emoji_version": "11.0",
- "unicode_version": "11.0",
- "skin_tone_support": false
- },
- "๐ฆ๐จ": {
- "name": "flag Ascension Island",
- "slug": "flag_ascension_island",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฆ๐ฉ": {
- "name": "flag Andorra",
- "slug": "flag_andorra",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฆ๐ช": {
- "name": "flag United Arab Emirates",
- "slug": "flag_united_arab_emirates",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฆ๐ซ": {
- "name": "flag Afghanistan",
- "slug": "flag_afghanistan",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฆ๐ฌ": {
- "name": "flag Antigua & Barbuda",
- "slug": "flag_antigua_barbuda",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฆ๐ฎ": {
- "name": "flag Anguilla",
- "slug": "flag_anguilla",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฆ๐ฑ": {
- "name": "flag Albania",
- "slug": "flag_albania",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฆ๐ฒ": {
- "name": "flag Armenia",
- "slug": "flag_armenia",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฆ๐ด": {
- "name": "flag Angola",
- "slug": "flag_angola",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฆ๐ถ": {
- "name": "flag Antarctica",
- "slug": "flag_antarctica",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฆ๐ท": {
- "name": "flag Argentina",
- "slug": "flag_argentina",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฆ๐ธ": {
- "name": "flag American Samoa",
- "slug": "flag_american_samoa",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฆ๐น": {
- "name": "flag Austria",
- "slug": "flag_austria",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฆ๐บ": {
- "name": "flag Australia",
- "slug": "flag_australia",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฆ๐ผ": {
- "name": "flag Aruba",
- "slug": "flag_aruba",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฆ๐ฝ": {
- "name": "flag ร
land Islands",
- "slug": "flag_aland_islands",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฆ๐ฟ": {
- "name": "flag Azerbaijan",
- "slug": "flag_azerbaijan",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ง๐ฆ": {
- "name": "flag Bosnia & Herzegovina",
- "slug": "flag_bosnia_herzegovina",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ง๐ง": {
- "name": "flag Barbados",
- "slug": "flag_barbados",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ง๐ฉ": {
- "name": "flag Bangladesh",
- "slug": "flag_bangladesh",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ง๐ช": {
- "name": "flag Belgium",
- "slug": "flag_belgium",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ง๐ซ": {
- "name": "flag Burkina Faso",
- "slug": "flag_burkina_faso",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ง๐ฌ": {
- "name": "flag Bulgaria",
- "slug": "flag_bulgaria",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ง๐ญ": {
- "name": "flag Bahrain",
- "slug": "flag_bahrain",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ง๐ฎ": {
- "name": "flag Burundi",
- "slug": "flag_burundi",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ง๐ฏ": {
- "name": "flag Benin",
- "slug": "flag_benin",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ง๐ฑ": {
- "name": "flag St. Barthรฉlemy",
- "slug": "flag_st_barthelemy",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ง๐ฒ": {
- "name": "flag Bermuda",
- "slug": "flag_bermuda",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ง๐ณ": {
- "name": "flag Brunei",
- "slug": "flag_brunei",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ง๐ด": {
- "name": "flag Bolivia",
- "slug": "flag_bolivia",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ง๐ถ": {
- "name": "flag Caribbean Netherlands",
- "slug": "flag_caribbean_netherlands",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ง๐ท": {
- "name": "flag Brazil",
- "slug": "flag_brazil",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ง๐ธ": {
- "name": "flag Bahamas",
- "slug": "flag_bahamas",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ง๐น": {
- "name": "flag Bhutan",
- "slug": "flag_bhutan",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ง๐ป": {
- "name": "flag Bouvet Island",
- "slug": "flag_bouvet_island",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ง๐ผ": {
- "name": "flag Botswana",
- "slug": "flag_botswana",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ง๐พ": {
- "name": "flag Belarus",
- "slug": "flag_belarus",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ง๐ฟ": {
- "name": "flag Belize",
- "slug": "flag_belize",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐จ๐ฆ": {
- "name": "flag Canada",
- "slug": "flag_canada",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐จ๐จ": {
- "name": "flag Cocos (Keeling) Islands",
- "slug": "flag_cocos_islands",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐จ๐ฉ": {
- "name": "flag Congo - Kinshasa",
- "slug": "flag_congo_kinshasa",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐จ๐ซ": {
- "name": "flag Central African Republic",
- "slug": "flag_central_african_republic",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐จ๐ฌ": {
- "name": "flag Congo - Brazzaville",
- "slug": "flag_congo_brazzaville",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐จ๐ญ": {
- "name": "flag Switzerland",
- "slug": "flag_switzerland",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐จ๐ฎ": {
- "name": "flag Cรดte dโIvoire",
- "slug": "flag_cote_d_ivoire",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐จ๐ฐ": {
- "name": "flag Cook Islands",
- "slug": "flag_cook_islands",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐จ๐ฑ": {
- "name": "flag Chile",
- "slug": "flag_chile",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐จ๐ฒ": {
- "name": "flag Cameroon",
- "slug": "flag_cameroon",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐จ๐ณ": {
- "name": "flag China",
- "slug": "flag_china",
- "group": "Flags",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐จ๐ด": {
- "name": "flag Colombia",
- "slug": "flag_colombia",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐จ๐ต": {
- "name": "flag Clipperton Island",
- "slug": "flag_clipperton_island",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐จ๐ท": {
- "name": "flag Costa Rica",
- "slug": "flag_costa_rica",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐จ๐บ": {
- "name": "flag Cuba",
- "slug": "flag_cuba",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐จ๐ป": {
- "name": "flag Cape Verde",
- "slug": "flag_cape_verde",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐จ๐ผ": {
- "name": "flag Curaรงao",
- "slug": "flag_curacao",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐จ๐ฝ": {
- "name": "flag Christmas Island",
- "slug": "flag_christmas_island",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐จ๐พ": {
- "name": "flag Cyprus",
- "slug": "flag_cyprus",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐จ๐ฟ": {
- "name": "flag Czechia",
- "slug": "flag_czechia",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฉ๐ช": {
- "name": "flag Germany",
- "slug": "flag_germany",
- "group": "Flags",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฉ๐ฌ": {
- "name": "flag Diego Garcia",
- "slug": "flag_diego_garcia",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฉ๐ฏ": {
- "name": "flag Djibouti",
- "slug": "flag_djibouti",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฉ๐ฐ": {
- "name": "flag Denmark",
- "slug": "flag_denmark",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฉ๐ฒ": {
- "name": "flag Dominica",
- "slug": "flag_dominica",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฉ๐ด": {
- "name": "flag Dominican Republic",
- "slug": "flag_dominican_republic",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฉ๐ฟ": {
- "name": "flag Algeria",
- "slug": "flag_algeria",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ช๐ฆ": {
- "name": "flag Ceuta & Melilla",
- "slug": "flag_ceuta_melilla",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ช๐จ": {
- "name": "flag Ecuador",
- "slug": "flag_ecuador",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ช๐ช": {
- "name": "flag Estonia",
- "slug": "flag_estonia",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ช๐ฌ": {
- "name": "flag Egypt",
- "slug": "flag_egypt",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ช๐ญ": {
- "name": "flag Western Sahara",
- "slug": "flag_western_sahara",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ช๐ท": {
- "name": "flag Eritrea",
- "slug": "flag_eritrea",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ช๐ธ": {
- "name": "flag Spain",
- "slug": "flag_spain",
- "group": "Flags",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ช๐น": {
- "name": "flag Ethiopia",
- "slug": "flag_ethiopia",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ช๐บ": {
- "name": "flag European Union",
- "slug": "flag_european_union",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ซ๐ฎ": {
- "name": "flag Finland",
- "slug": "flag_finland",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ซ๐ฏ": {
- "name": "flag Fiji",
- "slug": "flag_fiji",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ซ๐ฐ": {
- "name": "flag Falkland Islands",
- "slug": "flag_falkland_islands",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ซ๐ฒ": {
- "name": "flag Micronesia",
- "slug": "flag_micronesia",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ซ๐ด": {
- "name": "flag Faroe Islands",
- "slug": "flag_faroe_islands",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ซ๐ท": {
- "name": "flag France",
- "slug": "flag_france",
- "group": "Flags",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฌ๐ฆ": {
- "name": "flag Gabon",
- "slug": "flag_gabon",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฌ๐ง": {
- "name": "flag United Kingdom",
- "slug": "flag_united_kingdom",
- "group": "Flags",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฌ๐ฉ": {
- "name": "flag Grenada",
- "slug": "flag_grenada",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฌ๐ช": {
- "name": "flag Georgia",
- "slug": "flag_georgia",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฌ๐ซ": {
- "name": "flag French Guiana",
- "slug": "flag_french_guiana",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฌ๐ฌ": {
- "name": "flag Guernsey",
- "slug": "flag_guernsey",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฌ๐ญ": {
- "name": "flag Ghana",
- "slug": "flag_ghana",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฌ๐ฎ": {
- "name": "flag Gibraltar",
- "slug": "flag_gibraltar",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฌ๐ฑ": {
- "name": "flag Greenland",
- "slug": "flag_greenland",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฌ๐ฒ": {
- "name": "flag Gambia",
- "slug": "flag_gambia",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฌ๐ณ": {
- "name": "flag Guinea",
- "slug": "flag_guinea",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฌ๐ต": {
- "name": "flag Guadeloupe",
- "slug": "flag_guadeloupe",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฌ๐ถ": {
- "name": "flag Equatorial Guinea",
- "slug": "flag_equatorial_guinea",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฌ๐ท": {
- "name": "flag Greece",
- "slug": "flag_greece",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฌ๐ธ": {
- "name": "flag South Georgia & South Sandwich Islands",
- "slug": "flag_south_georgia_south_sandwich_islands",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฌ๐น": {
- "name": "flag Guatemala",
- "slug": "flag_guatemala",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฌ๐บ": {
- "name": "flag Guam",
- "slug": "flag_guam",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฌ๐ผ": {
- "name": "flag Guinea-Bissau",
- "slug": "flag_guinea_bissau",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฌ๐พ": {
- "name": "flag Guyana",
- "slug": "flag_guyana",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ญ๐ฐ": {
- "name": "flag Hong Kong SAR China",
- "slug": "flag_hong_kong_sar_china",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ญ๐ฒ": {
- "name": "flag Heard & McDonald Islands",
- "slug": "flag_heard_mcdonald_islands",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ญ๐ณ": {
- "name": "flag Honduras",
- "slug": "flag_honduras",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ญ๐ท": {
- "name": "flag Croatia",
- "slug": "flag_croatia",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ญ๐น": {
- "name": "flag Haiti",
- "slug": "flag_haiti",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ญ๐บ": {
- "name": "flag Hungary",
- "slug": "flag_hungary",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฎ๐จ": {
- "name": "flag Canary Islands",
- "slug": "flag_canary_islands",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฎ๐ฉ": {
- "name": "flag Indonesia",
- "slug": "flag_indonesia",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฎ๐ช": {
- "name": "flag Ireland",
- "slug": "flag_ireland",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฎ๐ฑ": {
- "name": "flag Israel",
- "slug": "flag_israel",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฎ๐ฒ": {
- "name": "flag Isle of Man",
- "slug": "flag_isle_of_man",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฎ๐ณ": {
- "name": "flag India",
- "slug": "flag_india",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฎ๐ด": {
- "name": "flag British Indian Ocean Territory",
- "slug": "flag_british_indian_ocean_territory",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฎ๐ถ": {
- "name": "flag Iraq",
- "slug": "flag_iraq",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฎ๐ท": {
- "name": "flag Iran",
- "slug": "flag_iran",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฎ๐ธ": {
- "name": "flag Iceland",
- "slug": "flag_iceland",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฎ๐น": {
- "name": "flag Italy",
- "slug": "flag_italy",
- "group": "Flags",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฏ๐ช": {
- "name": "flag Jersey",
- "slug": "flag_jersey",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฏ๐ฒ": {
- "name": "flag Jamaica",
- "slug": "flag_jamaica",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฏ๐ด": {
- "name": "flag Jordan",
- "slug": "flag_jordan",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฏ๐ต": {
- "name": "flag Japan",
- "slug": "flag_japan",
- "group": "Flags",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฐ๐ช": {
- "name": "flag Kenya",
- "slug": "flag_kenya",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฐ๐ฌ": {
- "name": "flag Kyrgyzstan",
- "slug": "flag_kyrgyzstan",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฐ๐ญ": {
- "name": "flag Cambodia",
- "slug": "flag_cambodia",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฐ๐ฎ": {
- "name": "flag Kiribati",
- "slug": "flag_kiribati",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฐ๐ฒ": {
- "name": "flag Comoros",
- "slug": "flag_comoros",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฐ๐ณ": {
- "name": "flag St. Kitts & Nevis",
- "slug": "flag_st_kitts_nevis",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฐ๐ต": {
- "name": "flag North Korea",
- "slug": "flag_north_korea",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฐ๐ท": {
- "name": "flag South Korea",
- "slug": "flag_south_korea",
- "group": "Flags",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ฐ๐ผ": {
- "name": "flag Kuwait",
- "slug": "flag_kuwait",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฐ๐พ": {
- "name": "flag Cayman Islands",
- "slug": "flag_cayman_islands",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฐ๐ฟ": {
- "name": "flag Kazakhstan",
- "slug": "flag_kazakhstan",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฑ๐ฆ": {
- "name": "flag Laos",
- "slug": "flag_laos",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฑ๐ง": {
- "name": "flag Lebanon",
- "slug": "flag_lebanon",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฑ๐จ": {
- "name": "flag St. Lucia",
- "slug": "flag_st_lucia",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฑ๐ฎ": {
- "name": "flag Liechtenstein",
- "slug": "flag_liechtenstein",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฑ๐ฐ": {
- "name": "flag Sri Lanka",
- "slug": "flag_sri_lanka",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฑ๐ท": {
- "name": "flag Liberia",
- "slug": "flag_liberia",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฑ๐ธ": {
- "name": "flag Lesotho",
- "slug": "flag_lesotho",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฑ๐น": {
- "name": "flag Lithuania",
- "slug": "flag_lithuania",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฑ๐บ": {
- "name": "flag Luxembourg",
- "slug": "flag_luxembourg",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฑ๐ป": {
- "name": "flag Latvia",
- "slug": "flag_latvia",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฑ๐พ": {
- "name": "flag Libya",
- "slug": "flag_libya",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฒ๐ฆ": {
- "name": "flag Morocco",
- "slug": "flag_morocco",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฒ๐จ": {
- "name": "flag Monaco",
- "slug": "flag_monaco",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฒ๐ฉ": {
- "name": "flag Moldova",
- "slug": "flag_moldova",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฒ๐ช": {
- "name": "flag Montenegro",
- "slug": "flag_montenegro",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฒ๐ซ": {
- "name": "flag St. Martin",
- "slug": "flag_st_martin",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฒ๐ฌ": {
- "name": "flag Madagascar",
- "slug": "flag_madagascar",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฒ๐ญ": {
- "name": "flag Marshall Islands",
- "slug": "flag_marshall_islands",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฒ๐ฐ": {
- "name": "flag North Macedonia",
- "slug": "flag_north_macedonia",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฒ๐ฑ": {
- "name": "flag Mali",
- "slug": "flag_mali",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฒ๐ฒ": {
- "name": "flag Myanmar (Burma)",
- "slug": "flag_myanmar",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฒ๐ณ": {
- "name": "flag Mongolia",
- "slug": "flag_mongolia",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฒ๐ด": {
- "name": "flag Macao SAR China",
- "slug": "flag_macao_sar_china",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฒ๐ต": {
- "name": "flag Northern Mariana Islands",
- "slug": "flag_northern_mariana_islands",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฒ๐ถ": {
- "name": "flag Martinique",
- "slug": "flag_martinique",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฒ๐ท": {
- "name": "flag Mauritania",
- "slug": "flag_mauritania",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฒ๐ธ": {
- "name": "flag Montserrat",
- "slug": "flag_montserrat",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฒ๐น": {
- "name": "flag Malta",
- "slug": "flag_malta",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฒ๐บ": {
- "name": "flag Mauritius",
- "slug": "flag_mauritius",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฒ๐ป": {
- "name": "flag Maldives",
- "slug": "flag_maldives",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฒ๐ผ": {
- "name": "flag Malawi",
- "slug": "flag_malawi",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฒ๐ฝ": {
- "name": "flag Mexico",
- "slug": "flag_mexico",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฒ๐พ": {
- "name": "flag Malaysia",
- "slug": "flag_malaysia",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฒ๐ฟ": {
- "name": "flag Mozambique",
- "slug": "flag_mozambique",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ณ๐ฆ": {
- "name": "flag Namibia",
- "slug": "flag_namibia",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ณ๐จ": {
- "name": "flag New Caledonia",
- "slug": "flag_new_caledonia",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ณ๐ช": {
- "name": "flag Niger",
- "slug": "flag_niger",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ณ๐ซ": {
- "name": "flag Norfolk Island",
- "slug": "flag_norfolk_island",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ณ๐ฌ": {
- "name": "flag Nigeria",
- "slug": "flag_nigeria",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ณ๐ฎ": {
- "name": "flag Nicaragua",
- "slug": "flag_nicaragua",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ณ๐ฑ": {
- "name": "flag Netherlands",
- "slug": "flag_netherlands",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ณ๐ด": {
- "name": "flag Norway",
- "slug": "flag_norway",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ณ๐ต": {
- "name": "flag Nepal",
- "slug": "flag_nepal",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ณ๐ท": {
- "name": "flag Nauru",
- "slug": "flag_nauru",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ณ๐บ": {
- "name": "flag Niue",
- "slug": "flag_niue",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ณ๐ฟ": {
- "name": "flag New Zealand",
- "slug": "flag_new_zealand",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ด๐ฒ": {
- "name": "flag Oman",
- "slug": "flag_oman",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ต๐ฆ": {
- "name": "flag Panama",
- "slug": "flag_panama",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ต๐ช": {
- "name": "flag Peru",
- "slug": "flag_peru",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ต๐ซ": {
- "name": "flag French Polynesia",
- "slug": "flag_french_polynesia",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ต๐ฌ": {
- "name": "flag Papua New Guinea",
- "slug": "flag_papua_new_guinea",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ต๐ญ": {
- "name": "flag Philippines",
- "slug": "flag_philippines",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ต๐ฐ": {
- "name": "flag Pakistan",
- "slug": "flag_pakistan",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ต๐ฑ": {
- "name": "flag Poland",
- "slug": "flag_poland",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ต๐ฒ": {
- "name": "flag St. Pierre & Miquelon",
- "slug": "flag_st_pierre_miquelon",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ต๐ณ": {
- "name": "flag Pitcairn Islands",
- "slug": "flag_pitcairn_islands",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ต๐ท": {
- "name": "flag Puerto Rico",
- "slug": "flag_puerto_rico",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ต๐ธ": {
- "name": "flag Palestinian Territories",
- "slug": "flag_palestinian_territories",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ต๐น": {
- "name": "flag Portugal",
- "slug": "flag_portugal",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ต๐ผ": {
- "name": "flag Palau",
- "slug": "flag_palau",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ต๐พ": {
- "name": "flag Paraguay",
- "slug": "flag_paraguay",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ถ๐ฆ": {
- "name": "flag Qatar",
- "slug": "flag_qatar",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ท๐ช": {
- "name": "flag Rรฉunion",
- "slug": "flag_reunion",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ท๐ด": {
- "name": "flag Romania",
- "slug": "flag_romania",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ท๐ธ": {
- "name": "flag Serbia",
- "slug": "flag_serbia",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ท๐บ": {
- "name": "flag Russia",
- "slug": "flag_russia",
- "group": "Flags",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐ท๐ผ": {
- "name": "flag Rwanda",
- "slug": "flag_rwanda",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ธ๐ฆ": {
- "name": "flag Saudi Arabia",
- "slug": "flag_saudi_arabia",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ธ๐ง": {
- "name": "flag Solomon Islands",
- "slug": "flag_solomon_islands",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ธ๐จ": {
- "name": "flag Seychelles",
- "slug": "flag_seychelles",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ธ๐ฉ": {
- "name": "flag Sudan",
- "slug": "flag_sudan",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ธ๐ช": {
- "name": "flag Sweden",
- "slug": "flag_sweden",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ธ๐ฌ": {
- "name": "flag Singapore",
- "slug": "flag_singapore",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ธ๐ญ": {
- "name": "flag St. Helena",
- "slug": "flag_st_helena",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ธ๐ฎ": {
- "name": "flag Slovenia",
- "slug": "flag_slovenia",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ธ๐ฏ": {
- "name": "flag Svalbard & Jan Mayen",
- "slug": "flag_svalbard_jan_mayen",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ธ๐ฐ": {
- "name": "flag Slovakia",
- "slug": "flag_slovakia",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ธ๐ฑ": {
- "name": "flag Sierra Leone",
- "slug": "flag_sierra_leone",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ธ๐ฒ": {
- "name": "flag San Marino",
- "slug": "flag_san_marino",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ธ๐ณ": {
- "name": "flag Senegal",
- "slug": "flag_senegal",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ธ๐ด": {
- "name": "flag Somalia",
- "slug": "flag_somalia",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ธ๐ท": {
- "name": "flag Suriname",
- "slug": "flag_suriname",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ธ๐ธ": {
- "name": "flag South Sudan",
- "slug": "flag_south_sudan",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ธ๐น": {
- "name": "flag Sรฃo Tomรฉ & Prรญncipe",
- "slug": "flag_sao_tome_principe",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ธ๐ป": {
- "name": "flag El Salvador",
- "slug": "flag_el_salvador",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ธ๐ฝ": {
- "name": "flag Sint Maarten",
- "slug": "flag_sint_maarten",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ธ๐พ": {
- "name": "flag Syria",
- "slug": "flag_syria",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ธ๐ฟ": {
- "name": "flag Eswatini",
- "slug": "flag_eswatini",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐น๐ฆ": {
- "name": "flag Tristan da Cunha",
- "slug": "flag_tristan_da_cunha",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐น๐จ": {
- "name": "flag Turks & Caicos Islands",
- "slug": "flag_turks_caicos_islands",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐น๐ฉ": {
- "name": "flag Chad",
- "slug": "flag_chad",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐น๐ซ": {
- "name": "flag French Southern Territories",
- "slug": "flag_french_southern_territories",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐น๐ฌ": {
- "name": "flag Togo",
- "slug": "flag_togo",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐น๐ญ": {
- "name": "flag Thailand",
- "slug": "flag_thailand",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐น๐ฏ": {
- "name": "flag Tajikistan",
- "slug": "flag_tajikistan",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐น๐ฐ": {
- "name": "flag Tokelau",
- "slug": "flag_tokelau",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐น๐ฑ": {
- "name": "flag Timor-Leste",
- "slug": "flag_timor_leste",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐น๐ฒ": {
- "name": "flag Turkmenistan",
- "slug": "flag_turkmenistan",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐น๐ณ": {
- "name": "flag Tunisia",
- "slug": "flag_tunisia",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐น๐ด": {
- "name": "flag Tonga",
- "slug": "flag_tonga",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐น๐ท": {
- "name": "flag Tรผrkiye",
- "slug": "flag_turkiye",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐น๐น": {
- "name": "flag Trinidad & Tobago",
- "slug": "flag_trinidad_tobago",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐น๐ป": {
- "name": "flag Tuvalu",
- "slug": "flag_tuvalu",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐น๐ผ": {
- "name": "flag Taiwan",
- "slug": "flag_taiwan",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐น๐ฟ": {
- "name": "flag Tanzania",
- "slug": "flag_tanzania",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐บ๐ฆ": {
- "name": "flag Ukraine",
- "slug": "flag_ukraine",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐บ๐ฌ": {
- "name": "flag Uganda",
- "slug": "flag_uganda",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐บ๐ฒ": {
- "name": "flag U.S. Outlying Islands",
- "slug": "flag_u_s_outlying_islands",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐บ๐ณ": {
- "name": "flag United Nations",
- "slug": "flag_united_nations",
- "group": "Flags",
- "emoji_version": "4.0",
- "unicode_version": "4.0",
- "skin_tone_support": false
- },
- "๐บ๐ธ": {
- "name": "flag United States",
- "slug": "flag_united_states",
- "group": "Flags",
- "emoji_version": "0.6",
- "unicode_version": "0.6",
- "skin_tone_support": false
- },
- "๐บ๐พ": {
- "name": "flag Uruguay",
- "slug": "flag_uruguay",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐บ๐ฟ": {
- "name": "flag Uzbekistan",
- "slug": "flag_uzbekistan",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ป๐ฆ": {
- "name": "flag Vatican City",
- "slug": "flag_vatican_city",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ป๐จ": {
- "name": "flag St. Vincent & Grenadines",
- "slug": "flag_st_vincent_grenadines",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ป๐ช": {
- "name": "flag Venezuela",
- "slug": "flag_venezuela",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ป๐ฌ": {
- "name": "flag British Virgin Islands",
- "slug": "flag_british_virgin_islands",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ป๐ฎ": {
- "name": "flag U.S. Virgin Islands",
- "slug": "flag_u_s_virgin_islands",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ป๐ณ": {
- "name": "flag Vietnam",
- "slug": "flag_vietnam",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ป๐บ": {
- "name": "flag Vanuatu",
- "slug": "flag_vanuatu",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ผ๐ซ": {
- "name": "flag Wallis & Futuna",
- "slug": "flag_wallis_futuna",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ผ๐ธ": {
- "name": "flag Samoa",
- "slug": "flag_samoa",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฝ๐ฐ": {
- "name": "flag Kosovo",
- "slug": "flag_kosovo",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐พ๐ช": {
- "name": "flag Yemen",
- "slug": "flag_yemen",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐พ๐น": {
- "name": "flag Mayotte",
- "slug": "flag_mayotte",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฟ๐ฆ": {
- "name": "flag South Africa",
- "slug": "flag_south_africa",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฟ๐ฒ": {
- "name": "flag Zambia",
- "slug": "flag_zambia",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ฟ๐ผ": {
- "name": "flag Zimbabwe",
- "slug": "flag_zimbabwe",
- "group": "Flags",
- "emoji_version": "2.0",
- "unicode_version": "2.0",
- "skin_tone_support": false
- },
- "๐ด๓ ง๓ ข๓ ฅ๓ ฎ๓ ง๓ ฟ": {
- "name": "flag England",
- "slug": "flag_england",
- "group": "Flags",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": false
- },
- "๐ด๓ ง๓ ข๓ ณ๓ ฃ๓ ด๓ ฟ": {
- "name": "flag Scotland",
- "slug": "flag_scotland",
- "group": "Flags",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": false
- },
- "๐ด๓ ง๓ ข๓ ท๓ ฌ๓ ณ๓ ฟ": {
- "name": "flag Wales",
- "slug": "flag_wales",
- "group": "Flags",
- "emoji_version": "5.0",
- "unicode_version": "5.0",
- "skin_tone_support": false
- }
-}
diff --git a/config/hypr/modus.conf b/config/hypr/modus.conf
index 11fda2a4..f3a439ea 100644
--- a/config/hypr/modus.conf
+++ b/config/hypr/modus.conf
@@ -1,7 +1,7 @@
# exec-once
-exec-once = uwsm app -- python ~/.config/Modus/main.py
+exec-once = uwsm app -- uv run $HOME/.config/Modus/main.py
exec = pgrep -x "hypridle" > /dev/null || uwsm app -- hypridle
-exec-once = uwsm app -- swww-daemon
+exec-once = uwsm app -- awww-daemon
exec-once = wl-paste --type text --watch cliphist store
exec-once = wl-paste --type image --watch cliphist store
@@ -13,28 +13,27 @@ layerrule = blur on, xray 0, blur_popups on, ignore_alpha 0, animation popin, ma
layerrule = blur on, ignore_alpha 0, xray 0, blur_popups on, match:namespace ^fabric$
layerrule = blur on, xray 0, blur_popups on, ignore_alpha 0, match:namespace ^modus$
layerrule = animation slide right, match:namespace ^notification-center$
-
# KEYBINDS
-$fabricSend = fabric-cli exec modus
-# Reload Modus
-bind = SUPER ALT, B, exec, killall modus; uwsm-app $(python $HOME/.config/Modus/main.py)
-# Message
-bind = SUPER SHIFT, y, exec, $fabricSend 'app.set_css()' # Reload CSS
-# # Application Switcher
-bind = ALT, TAB, exec, $fabricSend 'switcher.show_switcher()'
-# App Launcher
-bind = SUPER, SPACE, exec, $fabricSend "launcher.show_launcher()"
-# Clipboard History
-bind = SUPER, V, exec, $fabricSend "launcher.show_launcher('clip')"
-# Wallpapers
-bind = SUPER, W, exec, $fabricSend "launcher.show_launcher('wall')"
-# Random Wallpaper
-bind = ALT SHIFT, W, exec, $fabricSend "launcher.show_launcher('wall random', external=True)"
-# Emoji Picker
-bind = SUPER, Period, exec, $fabricSend "launcher.show_launcher('em')"
-# Power Menu
-bind = SUPER, ESCAPE, exec, $fabricSend "launcher.show_launcher('power')"
-# Toggle Caffeine
-bind = SUPER SHIFT, M, exec, $fabricSend "launcher.show_launcher('caffeine on', external=True)"
-
+# $fabricSend = fabric-cli exec modus1
+# # Reload Modus
+# bind = SUPER ALT, B, exec, killall modus; uwsm-app $(python $HOME/.config/Modus/main.py)
+# # Message
+# bind = SUPER SHIFT, y, exec, $fabricSend 'app.set_css()' # Reload CSS
+# # # Application Switcher
+# bind = ALT, TAB, exec, $fabricSend 'switcher.show_switcher()'
+# # App Launcher
+# bind = SUPER, SPACE, exec, $fabricSend "launcher.show_launcher()"
+# # Clipboard History
+# bind = SUPER, V, exec, $fabricSend "launcher.show_launcher('clip')"
+# # Wallpapers
+# bind = SUPER, W, exec, $fabricSend "launcher.show_launcher('wall')"
+# # Random Wallpaper
+# bind = ALT SHIFT, W, exec, $fabricSend "launcher.show_launcher('wall random', external=True)"
+# # Emoji Picker
+# bind = SUPER, Period, exec, $fabricSend "launcher.show_launcher('em')"
+# # Power Menu
+# bind = SUPER, ESCAPE, exec, $fabricSend "launcher.show_launcher('power')"
+# # Toggle Caffeine
+# bind = SUPER SHIFT, M, exec, $fabricSend "launcher.show_launcher('caffeine on', external=True)"
+#
diff --git a/config/assets/launcher.json b/config/launcher.json
similarity index 58%
rename from config/assets/launcher.json
rename to config/launcher.json
index 9d411e0d..332e2d2b 100644
--- a/config/assets/launcher.json
+++ b/config/launcher.json
@@ -1,6 +1,9 @@
{
"launcher_config": {
"em": {
+ "examples": [
+ "em updatejson"
+ ],
"icon": "emoji-people-symbolic",
"description": "Emoji - Search and copy emojis"
},
@@ -21,10 +24,6 @@
"icon": "apps",
"description": "Applications - Launch installed applications"
},
- "bin": {
- "icon": "terminal",
- "description": "Bins - Search and run executable binaries"
- },
"power": {
"icon": "shutdown",
"description": "Power - System power management and session control"
@@ -55,38 +54,6 @@
"? google cats",
"? youtube music"
]
- },
- "remind": {
- "icon": "alarm-timer",
- "description": "Reminders - Set time-based reminders with notifications"
- },
- "otp": {
- "icon": "auth-otp-symbolic",
- "description": "Manage TOTP codes and 2FA authentication"
- },
- "pass": {
- "icon": "nextcloud-password-client",
- "description": "Password Manager - Search and manage passwords"
- },
- "bm": {
- "icon": "bookmark-add-symbolic",
- "description": "Bookmarks - Search and manage bookmarks"
- },
- "script": {
- "icon": "terminal",
- "description": "Bash Scripts - Manage and execute bash scripts"
- },
- "bash": {
- "icon": "terminal",
- "description": "Bash Scripts - Manage and execute bash scripts"
- },
- "sh": {
- "icon": "terminal",
- "description": "Bash Scripts - Manage and execute bash scripts"
- },
- "tmux": {
- "icon": "terminal",
- "description": "Tmux Manager - Create, attach, rename, and kill tmux sessions"
}
},
"settings": {
diff --git a/debug_memory.py b/debug_memory.py
deleted file mode 100644
index 2e175e74..00000000
--- a/debug_memory.py
+++ /dev/null
@@ -1,165 +0,0 @@
-#!/usr/bin/env python3
-"""
-Real-time memory monitor for debugging expanded player memory leaks.
-This module provides functions to track memory usage in real-time.
-"""
-
-import psutil
-import os
-import gc
-import threading
-import time
-from loguru import logger
-
-
-class MemoryMonitor:
- """Real-time memory monitoring for debugging memory leaks."""
-
- def __init__(self):
- self.process = psutil.Process(os.getpid())
- self.baseline_memory = None
- self.last_memory = None
- self.monitoring = False
- self.monitor_thread = None
-
- def get_memory_usage(self):
- """Get current memory usage in MB."""
- return self.process.memory_info().rss / 1024 / 1024
-
- def get_memory_details(self):
- """Get detailed memory information."""
- memory_info = self.process.memory_info()
- memory_percent = self.process.memory_percent()
-
- return {
- "rss_mb": memory_info.rss / 1024 / 1024,
- "vms_mb": memory_info.vms / 1024 / 1024,
- "percent": memory_percent,
- "num_threads": self.process.num_threads(),
- }
-
- def set_baseline(self, label="Baseline"):
- """Set the baseline memory usage."""
- self.baseline_memory = self.get_memory_usage()
- logger.info(f"๐ฏ {label} memory: {self.baseline_memory:.1f} MB")
- return self.baseline_memory
-
- def log_memory_change(self, label="Memory Check", force_gc=True):
- """Log current memory usage and change from baseline."""
- if force_gc:
- gc.collect()
-
- current_memory = self.get_memory_usage()
- details = self.get_memory_details()
-
- if self.baseline_memory:
- delta = current_memory - self.baseline_memory
- logger.info(
- f"๐ {label}: {current_memory:.1f} MB (ฮ: {delta:+.1f} MB) | Threads: {
- details['num_threads']
- }"
- )
- else:
- logger.info(
- f"๐ {label}: {current_memory:.1f} MB | Threads: {
- details['num_threads']
- }"
- )
-
- self.last_memory = current_memory
- return current_memory
-
- def log_memory_spike(self, threshold_mb=10):
- """Log if there's a significant memory increase."""
- if self.last_memory:
- current = self.get_memory_usage()
- increase = current - self.last_memory
- if increase > threshold_mb:
- logger.warning(
- f"๐จ MEMORY SPIKE: +{increase:.1f} MB (from {self.last_memory:.1f} to {current:.1f} MB)"
- )
- return True
- return False
-
- def start_continuous_monitoring(self, interval_seconds=2):
- """Start continuous background monitoring."""
- if self.monitoring:
- return
-
- self.monitoring = True
-
- def monitor_loop():
- while self.monitoring:
- try:
- self.log_memory_change("Continuous Monitor", force_gc=False)
- time.sleep(interval_seconds)
- except Exception as e:
- logger.error(f"Memory monitor error: {e}")
- break
-
- self.monitor_thread = threading.Thread(target=monitor_loop, daemon=True)
- self.monitor_thread.start()
- logger.info(
- f"๐ Started continuous memory monitoring (every {interval_seconds}s)"
- )
-
- def stop_continuous_monitoring(self):
- """Stop continuous monitoring."""
- if self.monitoring:
- self.monitoring = False
- logger.info("๐ Stopped continuous memory monitoring")
-
-
-# Global memory monitor instance
-memory_monitor = MemoryMonitor()
-
-# Convenience functions for easy use
-
-
-def set_memory_baseline(label="Baseline"):
- """Set memory baseline."""
- return memory_monitor.set_baseline(label)
-
-
-def log_memory(label="Memory Check"):
- """Log current memory usage."""
- return memory_monitor.log_memory_change(label)
-
-
-def start_memory_monitoring():
- """Start continuous memory monitoring."""
- memory_monitor.start_continuous_monitoring()
-
-
-def stop_memory_monitoring():
- """Stop continuous memory monitoring."""
- memory_monitor.stop_continuous_monitoring()
-
-
-def check_memory_spike():
- """Check for memory spike."""
- return memory_monitor.log_memory_spike()
-
-
-# Test function
-
-
-def test_memory_monitor():
- """Test the memory monitor."""
- print("Testing Memory Monitor...")
- monitor = MemoryMonitor()
-
- monitor.set_baseline("Test Start")
-
- # Simulate some memory usage
- big_list = [i for i in range(100000)]
- monitor.log_memory_change("After creating big list")
-
- del big_list
- monitor.log_memory_change("After deleting big list")
-
- print("Memory monitor test complete!")
-
-
-if __name__ == "__main__":
- test_memory_monitor()
diff --git a/install.sh b/install.sh
index dc66055a..49ac6ab9 100755
--- a/install.sh
+++ b/install.sh
@@ -21,39 +21,22 @@ REPO_URL="https://github.com/S4NKALP/Modus.git"
INSTALL_DIR="$HOME/.config/Modus"
PACKAGES=(
- python-fabric-git
+ uv
fabric-cli-git
- glace-git
cliphist
gnome-bluetooth-3.0
- gobject-introspection
slurp
ffmpeg
hypridle
hyprsunset
hyprpicker
- imagemagick
+ hyprshot
+ grim
libnotify
matugen-bin
playerctl
- python-gobject
- python-pillow
- python-setproctitle
- python-toml
- python-requests
- python-numpy
- python-pywayland
- python-pyxdg
- python-ijson
- python-watchdog
- python-pyotp
- pyzbar
- python-psutil
- python-pydbus
- python-thefuzz
- python-pam
gtk-session-lock
- swww
+ awww
apple-fonts
swappy
wl-clipboard
@@ -302,20 +285,6 @@ else
success "All packages are up-to-date"
fi
-# Configuration
-# progress "Running configuration"
-# if [ -f "$INSTALL_DIR/config/config.py" ]; then
-# step "Initializing Modus configuration..."
-# if python "$INSTALL_DIR/config/config.py" 2>/dev/null; then
-# success "Configuration completed"
-# else
-# warn "Configuration step failed or was skipped"
-# fi
-# else
-# info "No configuration file found, skipping"
-# fi
-
-# Hyprland configuration
progress "Configuring Hyprland"
HYPR_CONFIG="$HOME/.config/hypr/hyprland.conf"
@@ -349,10 +318,10 @@ else
fi
step "Starting Modus..."
-if uwsm app -- python "$INSTALL_DIR/main.py" >/dev/null 2>&1 & then
+if uwsm app -- uv run --project "$INSTALL_DIR" start >/dev/null 2>&1 & then
disown
- sleep 1
- if pgrep -f "python.*main.py" >/dev/null; then
+ sleep 2
+ if pgrep -x "modus" >/dev/null; then
success "Modus is now running"
else
warn "Modus may not have started correctly"
@@ -366,7 +335,7 @@ fi
echo ""
echo -e "${GREEN}${BOLD}โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ${RESET}"
echo -e "${GREEN}${BOLD}โ โ${RESET}"
-echo -e "${GREEN}${BOLD}โ Installation completed! ๐ โ${RESET}"
+echo -e "${GREEN}${BOLD}โ Installation completed! โ${RESET}"
echo -e "${GREEN}${BOLD}โ โ${RESET}"
echo -e "${GREEN}${BOLD}โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ${RESET}"
echo ""
diff --git a/main.css b/main.css
deleted file mode 100644
index 738870f4..00000000
--- a/main.css
+++ /dev/null
@@ -1,34 +0,0 @@
-@import url("./styles/colors.css");
-@import url("./styles/panel.css");
-@import url("./styles/dock.css");
-@import url("./styles/switcher.css");
-@import url("./styles/launcher.css");
-@import url("./styles/osd.css");
-@import url("./styles/controlcenter.css");
-@import url("./styles/notification.css");
-@import url("./styles/dropdown.css");
-@import url("./styles/about.css");
-@import url("./styles/notification-center.css");
-@import url("./styles/player.css");
-@import url("./styles/widgets.css");
-@import url("./styles/tray.css");
-@import url("./styles/battery-widget.css");
-@import url("./styles/lock.css");
-@import url("./styles/todo.css");
-
-* {
- all: unset;
- color: var(--foreground);
- font-size: unset;
- font-family: "SF Pro Rounded";
-}
-
-#corner {
- background-color: var(--shadow);
- border-radius: 0;
-}
-
-#corner-container {
- min-width: 20px;
- min-height: 20px;
-}
diff --git a/main.py b/main.py
deleted file mode 100644
index f0db31a9..00000000
--- a/main.py
+++ /dev/null
@@ -1,67 +0,0 @@
-import setproctitle
-from fabric import Application
-from fabric.utils import get_relative_path, monitor_file
-from loguru import logger
-
-from config.data import APP_NAME
-from modules.dock import Dock
-from modules.launcher.main import Launcher
-from modules.notification.notification import ModusNoti
-from modules.osd import OSD
-from modules.panel.main import Panel
-from modules.switcher import ApplicationSwitcher
-from modules.widget import Deskwidgets
-
-# from modules.corners import Corners
-
-for log in [
- "fabric",
- "services",
- "utils",
- # "modules",
-]:
- logger.disable(log)
-
-
-if __name__ == "__main__":
- setproctitle.setproctitle(APP_NAME)
-
- # Load configuration
- from config.data import load_config
-
- # About().toggle(None)
- config = load_config()
-
- panel = Panel()
- # corners = Corners()
- dock = Dock()
- modusnoti = ModusNoti()
- switcher = ApplicationSwitcher()
- launcher = Launcher()
- panel.launcher = launcher
- osd = OSD()
-
- widgets = Deskwidgets()
- # Set corners visibility based on config
- # corners_visible = config.get("corners_visible", True)
- # corners.set_visible(corners_visible)
-
- # Monitor CSS files for changes
- css_file = monitor_file(get_relative_path("styles"))
- _ = css_file.connect("changed", lambda *_: set_css())
-
- # Make sure corners is added to the app
- app = Application(
- f"{APP_NAME}", panel, dock, switcher, launcher, modusnoti, osd, widgets
- )
-
- def set_css():
- app.set_stylesheet_from_file(
- get_relative_path("main.css"),
- )
-
- app.set_css = set_css
-
- app.set_css()
-
- app.run()
diff --git a/modules/corners.py b/modules/corners.py
deleted file mode 100644
index cf3c79e8..00000000
--- a/modules/corners.py
+++ /dev/null
@@ -1,68 +0,0 @@
-from fabric.widgets.box import Box
-from fabric.widgets.shapes import Corner
-from widgets.wayland import WaylandWindow as Window
-
-
-class MyCorner(Box):
- def __init__(self, corner):
- super().__init__(
- name="corner-container",
- children=Corner(
- name="corner",
- orientation=corner,
- h_expand=False,
- v_expand=False,
- h_align="center",
- v_align="center",
- size=20,
- ),
- )
-
-
-class Corners(Window):
- def __init__(self):
- super().__init__(
- name="corners",
- layer="bottom",
- anchor="top bottom left right",
- exclusivity="normal",
- # pass_through=True,
- visible=False,
- all_visible=False,
- )
-
- self.all_corners = Box(
- name="all-corners",
- orientation="v",
- h_expand=True,
- v_expand=True,
- h_align="fill",
- v_align="fill",
- children=[
- Box(
- name="top-corners",
- orientation="h",
- h_align="fill",
- children=[
- MyCorner("top-left"),
- Box(h_expand=True),
- MyCorner("top-right"),
- ],
- ),
- Box(v_expand=True),
- Box(
- name="bottom-corners",
- orientation="h",
- h_align="fill",
- children=[
- MyCorner("bottom-left"),
- Box(h_expand=True),
- MyCorner("bottom-right"),
- ],
- ),
- ],
- )
-
- self.add(self.all_corners)
-
- self.show_all()
diff --git a/modules/launcher/plugins/bash_scripts.py b/modules/launcher/plugins/bash_scripts.py
deleted file mode 100644
index 44bb2e80..00000000
--- a/modules/launcher/plugins/bash_scripts.py
+++ /dev/null
@@ -1,431 +0,0 @@
-import json
-import os
-import threading
-import time
-from typing import Dict, List
-
-import config.data as data
-from fabric.utils import exec_shell_command_async
-from modules.launcher.plugin_base import PluginBase
-from modules.launcher.result import Result
-
-
-class BashScriptsPlugin(PluginBase):
- """
- Plugin for managing and executing bash scripts.
- """
-
- def __init__(self):
- super().__init__()
- self.display_name = "Bash Scripts"
- self.description = "Manage and execute bash scripts"
-
- # Configuration
- self.scripts_cache_file = os.path.join(data.CACHE_DIR, "bash_scripts.json")
-
- # Default script directory to scan (only Modus scripts)
- self.modus_scripts_dir = os.path.expanduser("~/.config/Modus/scripts")
-
- # Scripts to exclude from discovery
- self.excluded_scripts = {
- "screen-capture.sh" # Exclude screen-capture.sh as it's handled by screencapture plugin
- }
-
- # In-memory cache
- self._scripts_cache: Dict[str, Dict] = {}
- self._last_cache_update = 0
- self._cache_update_interval = 300 # 5 minutes
-
- # Background cache building
- self._cache_building = False
- self._cache_thread = None
-
- def initialize(self):
- """Initialize the bash scripts plugin."""
- self.set_triggers(["sh"])
- self._load_scripts_cache()
- self._start_background_cache_update()
-
- def cleanup(self):
- """Cleanup the bash scripts plugin."""
- self._scripts_cache.clear()
- if self._cache_thread and self._cache_thread.is_alive():
- # Note: We don't join the thread to avoid blocking cleanup
- pass
-
- def _load_scripts_cache(self):
- """Load scripts cache from JSON file."""
- try:
- if os.path.exists(self.scripts_cache_file):
- with open(self.scripts_cache_file, "r", encoding="utf-8") as f:
- cache_data = json.load(f)
- self._scripts_cache = cache_data.get("scripts", {})
- self._last_cache_update = cache_data.get("last_update", 0)
- else:
- print(
- "BashScriptsPlugin: No cache file found, will build cache in background"
- )
- except Exception as e:
- print(f"BashScriptsPlugin: Error loading scripts cache: {e}")
- self._scripts_cache = {}
- self._last_cache_update = 0
-
- def _save_scripts_cache(self):
- """Save scripts cache to JSON file."""
- try:
- os.makedirs(data.CACHE_DIR, exist_ok=True)
- cache_data = {
- "scripts": self._scripts_cache,
- "last_update": self._last_cache_update,
- }
- with open(self.scripts_cache_file, "w", encoding="utf-8") as f:
- json.dump(cache_data, f, indent=2)
- except Exception as e:
- print(f"BashScriptsPlugin: Error saving scripts cache: {e}")
-
- def _start_background_cache_update(self):
- """Start background thread to update scripts cache."""
- current_time = time.time()
-
- # Check if cache needs updating
- if (
- current_time - self._last_cache_update > self._cache_update_interval
- or not self._scripts_cache
- ):
- if not self._cache_building:
- self._cache_building = True
- self._cache_thread = threading.Thread(
- target=self._build_scripts_cache_background, daemon=True
- )
- self._cache_thread.start()
-
- def _build_scripts_cache_background(self):
- """Build scripts cache in background thread."""
- try:
- new_cache = {}
-
- # Scan Modus scripts directory for discovered scripts
- if os.path.exists(self.modus_scripts_dir) and os.path.isdir(
- self.modus_scripts_dir
- ):
- try:
- self._scan_directory_for_scripts(self.modus_scripts_dir, new_cache)
- except (PermissionError, FileNotFoundError, OSError) as e:
- print(
- f"BashScriptsPlugin: Error scanning Modus scripts directory: {
- e
- }"
- )
-
- # Update cache atomically
- self._scripts_cache = new_cache
- self._last_cache_update = time.time()
-
- # Save to disk
- self._save_scripts_cache()
-
- except Exception as e:
- print(f"BashScriptsPlugin: Error building scripts cache: {e}")
- finally:
- self._cache_building = False
-
- def _scan_directory_for_scripts(self, directory: str, cache: Dict):
- """Scan a directory for bash scripts and add them to cache."""
- try:
- with os.scandir(directory) as entries:
- for entry in entries:
- if entry.is_file(follow_symlinks=False):
- script_path = entry.path
- script_name = entry.name
-
- # Skip excluded scripts
- if script_name in self.excluded_scripts:
- continue
-
- # Check if it's a script file
- if self._is_script_file(script_path):
- cache[script_name] = {
- "path": script_path,
- "name": script_name,
- "description": self._get_script_description(
- script_path
- ),
- "type": "discovered",
- "executable": os.access(script_path, os.X_OK),
- "args": [],
- "category": os.path.basename(directory),
- }
-
- except (PermissionError, FileNotFoundError, OSError) as e:
- print(f"BashScriptsPlugin: Error scanning directory {directory}: {e}")
- except Exception as e:
- print(f"BashScriptsPlugin: Unexpected error scanning {directory}: {e}")
-
- def _is_script_file(self, file_path: str) -> bool:
- """Check if a file is a bash script."""
- try:
- # Check file extension first (most common case)
- if file_path.endswith((".sh", ".bash")):
- return True
-
- # For files without extension, check shebang
- try:
- with open(file_path, "rb") as f:
- first_line = f.readline(100).decode("utf-8", errors="ignore")
- if first_line.startswith("#!") and (
- "bash" in first_line or "sh" in first_line
- ):
- return True
- except (PermissionError, FileNotFoundError, UnicodeDecodeError):
- pass
-
- return False
- except Exception:
- return False
-
- def _get_script_description(self, script_path: str) -> str:
- """Extract description from script comments."""
- try:
- with open(script_path, "r", encoding="utf-8", errors="ignore") as f:
- lines = f.readlines()
-
- # Look for description in first few comment lines
- for line in lines[:10]:
- line = line.strip()
- if line.startswith("#") and not line.startswith("#!"):
- # Remove leading # and whitespace
- desc = line[1:].strip()
- if desc and len(desc) > 5: # Meaningful description
- return desc
-
- return f"Script: {os.path.basename(script_path)}"
- except (PermissionError, FileNotFoundError, UnicodeDecodeError):
- return f"Script: {os.path.basename(script_path)}"
-
- def query(self, query_string: str) -> List[Result]:
- """Search for bash scripts matching the query."""
- query = query_string.strip()
-
- # Start background update if needed (non-blocking)
- if not self._scripts_cache or (
- time.time() - self._last_cache_update > self._cache_update_interval
- ):
- self._start_background_cache_update()
-
- results = []
-
- # Handle special commands
- if not query:
- # Show all scripts when no query
- results.extend(self._list_all_scripts())
- else:
- # Search for scripts
- results.extend(self._search_scripts(query))
-
- return results
-
- def _list_all_scripts(self) -> List[Result]:
- """List all available scripts."""
- results = []
- max_results = 20
-
- # Sort scripts by name for consistent ordering
- sorted_scripts = sorted(
- self._scripts_cache.items(), key=lambda x: x[1].get("name", "")
- )
-
- # Add scripts (limit to max_results)
- for script_name, script_info in sorted_scripts:
- script_results = self._create_script_results_with_args(
- script_name, script_info, 0.8
- )
- for script_result in script_results:
- if len(results) < max_results:
- results.append(script_result)
- else:
- break
- if len(results) >= max_results:
- break
-
- return results
-
- def _search_scripts(self, query: str) -> List[Result]:
- """Search for scripts matching the query."""
- results = []
- query_lower = query.lower()
- max_results = 15
-
- # Categorize matches for better sorting
- exact_matches = []
- prefix_matches = []
- partial_matches = []
- description_matches = []
-
- for script_name, script_info in self._scripts_cache.items():
- script_name_lower = script_name.lower()
- description_lower = script_info.get("description", "").lower()
-
- # Skip if no match at all
- if (
- query_lower not in script_name_lower
- and query_lower not in description_lower
- ):
- continue
-
- # Categorize matches
- if script_name_lower == query_lower:
- exact_matches.append((script_name, script_info, 1.0))
- elif script_name_lower.startswith(query_lower):
- prefix_matches.append((script_name, script_info, 0.9))
- elif query_lower in script_name_lower:
- partial_matches.append((script_name, script_info, 0.7))
- elif query_lower in description_lower:
- description_matches.append((script_name, script_info, 0.5))
-
- # Combine results in priority order
- all_matches = (
- exact_matches + prefix_matches + partial_matches + description_matches
- )
-
- # Convert to Result objects
- for script_name, script_info, relevance in all_matches:
- script_results = self._create_script_results_with_args(
- script_name, script_info, relevance
- )
- for script_result in script_results:
- if len(results) < max_results:
- results.append(script_result)
- else:
- break
- if len(results) >= max_results:
- break
-
- return results
-
- def _create_script_result(
- self, script_name: str, script_info: Dict, relevance: float
- ) -> Result:
- """Create a Result object for a script."""
- script_path = script_info.get("path", "")
- description = script_info.get("description", "")
- script_type = script_info.get("type", "discovered")
- executable = script_info.get("executable", False)
- category = script_info.get("category", "")
-
- # Create subtitle with additional info
- subtitle_parts = []
- if description:
- subtitle_parts.append(description)
- if category:
- subtitle_parts.append(f"[{category}]")
- if not executable:
- subtitle_parts.append("(not executable)")
-
- subtitle = (
- " | ".join(subtitle_parts) if subtitle_parts else f"Execute: {script_name}"
- )
-
- # Choose icon based on script type and status
- if not executable:
- icon_name = "gtk-file"
- elif script_type == "custom":
- icon_name = "folder-script-symbolic"
- else:
- icon_name = "terminalc"
-
- return Result(
- title=script_name,
- subtitle=subtitle,
- icon_name=icon_name,
- action=self._create_script_action(script_name, script_info),
- relevance=relevance,
- plugin_name=self.display_name,
- data={
- "script_name": script_name,
- "script_path": script_path,
- "type": script_type,
- },
- )
-
- def _create_script_results_with_args(
- self, script_name: str, script_info: Dict, relevance: float
- ) -> List[Result]:
- """Create multiple Result objects for scripts that support arguments."""
- results = []
-
- # Check for special scripts that need argument variants
- if script_name == "hyprpicker.sh":
- # For hyprpicker, only show the argument variants (skip basic version)
- variants = [
- ("-rgb", "Pick RGB color"),
- ("-hex", "Pick HEX color"),
- ("-hsv", "Pick HSV color"),
- ]
-
- for arg, desc in variants:
- variant_result = Result(
- title=f"{script_name} {arg}",
- subtitle=f"{desc} | [scripts]",
- icon_name="terminal-symbolic",
- action=self._create_script_action_with_args(
- script_name, script_info, [arg]
- ),
- relevance=relevance
- + 0.1, # Slightly higher relevance for specific variants
- plugin_name=self.display_name,
- data={
- "script_name": script_name,
- "script_path": script_info.get("path", ""),
- "type": script_info.get("type", "discovered"),
- "args": [arg],
- },
- )
- results.append(variant_result)
- else:
- # For other scripts, create the basic result
- basic_result = self._create_script_result(
- script_name, script_info, relevance
- )
- results.append(basic_result)
-
- return results
-
- def _create_script_action(self, script_name: str, script_info: Dict):
- """Create an action function for executing a script."""
-
- def action():
- self._execute_script(script_name, script_info)
-
- return action
-
- def _create_script_action_with_args(
- self, script_name: str, script_info: Dict, args: List[str]
- ):
- """Create an action function for executing a script with specific arguments."""
-
- def action():
- # Create a modified script_info with the specific arguments
- modified_script_info = script_info.copy()
- modified_script_info["args"] = args
- self._execute_script(script_name, modified_script_info)
-
- return action
-
- def _execute_script(self, script_name: str, script_info: Dict):
- """Execute a bash script."""
- try:
- script_path = script_info.get("path", "")
- script_args = script_info.get("args", [])
-
- if not os.path.exists(script_path):
- return
-
- if not script_info.get("executable", False):
- return
-
- # Build command
- command = [script_path] + script_args
- exec_shell_command_async(command)
-
- except Exception as e:
- print(f"BashScriptsPlugin: Error executing script '{script_name}': {e}")
diff --git a/modules/launcher/plugins/bookmarks.py b/modules/launcher/plugins/bookmarks.py
deleted file mode 100644
index ee551398..00000000
--- a/modules/launcher/plugins/bookmarks.py
+++ /dev/null
@@ -1,786 +0,0 @@
-import json
-import subprocess
-import threading
-import time
-from pathlib import Path
-from typing import Dict, List, Optional
-from urllib.parse import urlparse
-
-from thefuzz import fuzz
-
-from fabric.utils.helpers import get_relative_path
-from modules.launcher.plugin_base import PluginBase
-from modules.launcher.result import Result
-
-
-class BookmarkManager:
- """Manages user's custom bookmarks."""
-
- def __init__(self, storage_file: Path):
- self.storage_file = storage_file
- self.bookmarks: List[Dict] = []
- self.cache_lock = threading.Lock()
- self.last_loaded = 0
- self.cache_ttl = 30 # Cache for 30 seconds
- self._load_bookmarks()
-
- def _get_favicon_url(self, url: str) -> str:
- """Generate favicon URL for a given website URL."""
- try:
- parsed = urlparse(url)
- return f"{parsed.scheme}://{parsed.netloc}/favicon.ico"
- except:
- return ""
-
- def _extract_domain(self, url: str) -> str:
- """Extract domain from URL."""
- try:
- parsed = urlparse(url)
- domain = parsed.netloc
- # Remove www. prefix
- if domain.startswith("www."):
- domain = domain[4:]
- return domain
- except:
- return url
-
- def _normalize_url(self, url: str) -> str:
- """Normalize URL by adding protocol if missing."""
- url = url.strip()
- if not url.startswith(("http://", "https://")):
- if url.startswith("www."):
- url = "https://" + url
- else:
- url = "https://" + url
- return url
-
- def _load_bookmarks(self):
- """Load bookmarks from JSON file with caching."""
- with self.cache_lock:
- current_time = time.time()
-
- # Check if cache is still valid
- if (
- current_time - self.last_loaded
- ) < self.cache_ttl and self.last_loaded > 0:
- return
-
- try:
- if self.storage_file.exists():
- with open(self.storage_file, "r", encoding="utf-8") as f:
- data = json.load(f)
- self.bookmarks = data.get("bookmarks", [])
- else:
- # File doesn't exist, start with empty list but don't save yet
- self.bookmarks = []
-
- self.last_loaded = current_time
- except Exception as e:
- print(f"Error loading bookmarks: {e}")
- self.bookmarks = []
-
- def get_bookmarks(self) -> List[Dict]:
- """Get bookmarks, loading from file if needed."""
- self._load_bookmarks()
- return self.bookmarks
-
- def _save_bookmarks_unlocked(self):
- """Save bookmarks to JSON file without acquiring lock."""
- try:
- self.storage_file.parent.mkdir(parents=True, exist_ok=True)
- data = {
- "bookmarks": self.bookmarks,
- "last_updated": time.time(),
- }
- with open(self.storage_file, "w", encoding="utf-8") as f:
- json.dump(data, f, indent=2, ensure_ascii=False)
-
- # Update cache timestamp
- self.last_loaded = time.time()
- except Exception as e:
- print(f"Error saving bookmarks: {e}")
-
- def _save_bookmarks(self):
- """Save bookmarks to JSON file."""
- with self.cache_lock:
- self._save_bookmarks_unlocked()
-
- def add_bookmark(
- self, title: str, url: str, description: str = "", tags: List[str] = None
- ) -> bool:
- """Add a new bookmark."""
- try:
- url = self._normalize_url(url)
-
- # Check if bookmark already exists
- for bookmark in self.bookmarks:
- if bookmark["url"] == url:
- return False # Already exists
-
- new_bookmark = {
- "title": title.strip(),
- "url": url,
- "description": description.strip(),
- "tags": tags or [],
- "created": time.time(),
- "accessed": 0,
- }
-
- self.bookmarks.append(new_bookmark)
- self._save_bookmarks()
-
- # Clear cache to force reload
- self.last_loaded = 0
-
- return True
-
- except Exception as e:
- print(f"Error adding bookmark: {e}")
- return False
-
- def remove_bookmark(self, identifier: str) -> bool:
- """Remove a bookmark by title or URL."""
- try:
- identifier = identifier.lower().strip()
-
- for i, bookmark in enumerate(self.bookmarks):
- if (
- bookmark["title"].lower() == identifier
- or bookmark["url"].lower() == identifier
- or self._extract_domain(bookmark["url"]).lower() == identifier
- ):
- self.bookmarks.pop(i)
- self._save_bookmarks()
-
- # Clear cache to force reload
- self.last_loaded = 0
-
- return True
-
- return False
-
- except Exception as e:
- print(f"Error removing bookmark: {e}")
- return False
-
- def update_access_time(self, url: str):
- """Update the last accessed time for a bookmark."""
- try:
- for bookmark in self.bookmarks:
- if bookmark["url"] == url:
- bookmark["accessed"] = time.time()
- self._save_bookmarks()
- break
- except Exception as e:
- print(f"Error updating access time: {e}")
-
- def get_bookmark_count(self) -> int:
- """Get total number of bookmarks."""
- return len(self.get_bookmarks())
-
-
-class BookmarksPlugin(PluginBase):
- """
- User bookmarks plugin for the launcher.
- Allows users to add, remove, and search their own bookmarks.
- """
-
- def __init__(self):
- super().__init__()
- self.display_name = "Bookmarks"
- self.description = "Manage and search your personal bookmarks"
-
- # Initialize bookmark manager with storage file
- self.bookmark_file = Path(
- get_relative_path("../../../config/assets/bookmarks.json")
- )
- self.bookmark_manager = BookmarkManager(self.bookmark_file)
- self.max_results = 15
-
- # Cache for results
- self._results_cache = {}
- self._cache_timestamps = {}
- self._cache_ttl = 30 # 30 seconds
-
- # Launcher instance for refreshing
- self._launcher_instance = None
- self._original_close_launcher = None
-
- def initialize(self):
- """Initialize the bookmarks plugin."""
- self.set_triggers(["bm"])
- self._setup_launcher_hooks()
-
- def cleanup(self):
- """Cleanup the bookmarks plugin."""
- self._results_cache.clear()
- self._cache_timestamps.clear()
- self._cleanup_launcher_hooks()
-
- def query(self, query_string: str) -> List[Result]:
- """Process bookmark queries with caching."""
- query_key = query_string.strip()
- current_time = time.time()
-
- # Check cache first (except for add/remove commands which should always execute)
- if (
- not query_key.startswith(("add ", "remove ", "delete ", "rm "))
- and query_key in self._results_cache
- and (current_time - self._cache_timestamps.get(query_key, 0))
- < self._cache_ttl
- ):
- return self._results_cache[query_key]
-
- query = query_key.lower()
- results = []
-
- if not query:
- # Show recent/popular bookmarks when no query
- results = self._get_recent_bookmarks()
- elif query.startswith("add "):
- # Add new bookmark (don't cache)
- results = self._handle_add_command(query[4:].strip())
- elif query.startswith(("remove ", "delete ", "rm ")):
- # Remove bookmark (don't cache)
- command_parts = query_key.split(" ", 1)
- if len(command_parts) > 1:
- results = self._handle_remove_command(command_parts[1].strip())
- else:
- results = self._show_remove_help()
- else:
- # Search bookmarks
- results = self._search_bookmarks(query)
-
- # Cache results (except for add/remove commands)
- if not query.startswith(("add ", "remove ", "delete ", "rm ")):
- self._results_cache[query_key] = results
- self._cache_timestamps[query_key] = current_time
-
- return results
-
- def _search_bookmarks(self, query: str) -> List[Result]:
- """Search through bookmarks."""
- bookmarks = self.bookmark_manager.get_bookmarks()
-
- if not bookmarks:
- return [
- Result(
- title="No Bookmarks Found",
- subtitle="Use 'add ' to add first bookmark",
- icon_name="info",
- action=lambda: None,
- relevance=1.0,
- plugin_name=self.display_name,
- data={"type": "info", "keep_launcher_open": True},
- )
- ]
-
- # Search bookmarks
- results = []
- for bookmark in bookmarks:
- relevance = self._calculate_relevance(bookmark, query)
- if relevance > 0.3: # Only show relevant results
- result = self._create_bookmark_result(bookmark, relevance)
- if result:
- results.append(result)
-
- # Sort by relevance and limit results
- results.sort(key=lambda r: r.relevance, reverse=True)
- return results[: self.max_results]
-
- def _get_recent_bookmarks(self) -> List[Result]:
- """Get recent/popular bookmarks when no query is provided."""
- bookmarks = self.bookmark_manager.get_bookmarks()
-
- if not bookmarks:
- return [
- Result(
- title="No Bookmarks Yet",
- subtitle="Use 'add ' to add first bookmark",
- icon_name="info",
- action=lambda: None,
- relevance=1.0,
- plugin_name=self.display_name,
- data={"type": "help", "keep_launcher_open": True},
- ),
- Result(
- title="Example: Add Google",
- subtitle="add Google https://google.com",
- icon_name="info",
- action=lambda: None,
- relevance=0.9,
- plugin_name=self.display_name,
- data={"type": "example", "keep_launcher_open": True},
- ),
- ]
-
- # Sort by access time (most recent first) and show top 10
- sorted_bookmarks = sorted(
- bookmarks, key=lambda b: b.get("accessed", 0), reverse=True
- )
- results = []
- for bookmark in sorted_bookmarks[:10]:
- result = self._create_bookmark_result(bookmark, 0.8)
- if result:
- results.append(result)
-
- return results
-
- def _handle_add_command(self, args: str) -> List[Result]:
- """Handle add bookmark command."""
- if not args:
- return [
- Result(
- title="Add Bookmark",
- subtitle="Usage: add [description]",
- icon_name="info",
- action=lambda: None,
- relevance=1.0,
- plugin_name=self.display_name,
- data={"type": "help", "keep_launcher_open": True},
- ),
- Result(
- title="Example",
- subtitle="add Google https://google.com Search engine",
- icon_name="info",
- action=lambda: None,
- relevance=0.9,
- plugin_name=self.display_name,
- data={"type": "example", "keep_launcher_open": True},
- ),
- ]
-
- # Parse arguments: title url [description]
- parts = args.split()
- if len(parts) < 2:
- return [
- Result(
- title="Invalid Format",
- subtitle="Usage: add [description]",
- icon_name="alert",
- action=lambda: None,
- relevance=1.0,
- plugin_name=self.display_name,
- data={"type": "error", "keep_launcher_open": True},
- )
- ]
-
- title = parts[0]
- url = parts[1]
- description = " ".join(parts[2:]) if len(parts) > 2 else ""
-
- # Check if bookmark already exists
- normalized_url = self.bookmark_manager._normalize_url(url)
- existing_bookmarks = self.bookmark_manager.get_bookmarks()
- already_exists = any(
- bookmark["url"] == normalized_url for bookmark in existing_bookmarks
- )
-
- if already_exists:
- # Truncate URL for display to prevent launcher resize
- display_url = normalized_url
- if len(display_url) > 35:
- display_url = display_url[:32] + "..."
-
- return [
- Result(
- title="Bookmark Already Exists",
- subtitle=f"URL '{display_url}' already exists",
- icon_name="alert",
- action=lambda: None,
- relevance=1.0,
- plugin_name=self.display_name,
- data={"type": "error", "keep_launcher_open": True},
- )
- ]
-
- # Show add action - will execute on Enter
- domain = self.bookmark_manager._extract_domain(normalized_url)
-
- # Truncate domain if too long
- if len(domain) > 25:
- domain = domain[:22] + "..."
-
- subtitle = f"Click to add: {domain}"
- if description:
- # Truncate description to prevent launcher resize
- max_desc_len = 35 - len(domain) # Account for domain + separator
- if len(description) > max_desc_len:
- description = description[: max_desc_len - 3] + "..."
- subtitle += f" โข {description}"
-
- # Truncate title for display
- display_title = title
- if len(display_title) > 25:
- display_title = display_title[:22] + "..."
-
- return [
- Result(
- title=f"Add bookmark '{display_title}'",
- subtitle=subtitle,
- icon_name="plus",
- action=lambda: self._add_bookmark_action(title, url, description),
- relevance=1.0,
- plugin_name=self.display_name,
- data={"type": "add", "name": title, "keep_launcher_open": True},
- )
- ]
-
- def _handle_remove_command(self, identifier: str) -> List[Result]:
- """Handle remove bookmark command."""
- if not identifier:
- return self._show_remove_help()
-
- # Find matching bookmarks
- bookmarks = self.bookmark_manager.get_bookmarks()
- identifier_lower = identifier.lower().strip()
-
- matching_bookmarks = []
- for bookmark in bookmarks:
- if (
- bookmark["title"].lower() == identifier_lower
- or bookmark["url"].lower() == identifier_lower
- or self.bookmark_manager._extract_domain(bookmark["url"]).lower()
- == identifier_lower
- ):
- matching_bookmarks.append(bookmark)
-
- if not matching_bookmarks:
- return [
- Result(
- title="Bookmark Not Found",
- subtitle=f"No bookmark found matching '{identifier}'",
- icon_name="alert",
- action=lambda: None,
- relevance=1.0,
- plugin_name=self.display_name,
- data={"type": "error", "keep_launcher_open": True},
- )
- ]
-
- # Show remove action - will execute on Enter
- bookmark = matching_bookmarks[0] # Take first match
- title = bookmark.get("title", "Untitled")
- domain = self.bookmark_manager._extract_domain(bookmark.get("url", ""))
-
- # Truncate title and domain for display
- display_title = title
- if len(display_title) > 25:
- display_title = display_title[:22] + "..."
-
- display_domain = domain
- if len(display_domain) > 30:
- display_domain = display_domain[:27] + "..."
-
- return [
- Result(
- title=f"Remove '{display_title}'?",
- subtitle=f"Click to confirm: {display_domain}",
- icon_name="trash",
- action=lambda: self._remove_bookmark_action(identifier),
- relevance=1.0,
- plugin_name=self.display_name,
- data={"type": "remove", "name": title, "keep_launcher_open": True},
- )
- ]
-
- def _show_remove_help(self) -> List[Result]:
- """Show help for remove command."""
- bookmarks = self.bookmark_manager.get_bookmarks()
- results = [
- Result(
- title="Remove Bookmark",
- subtitle="Usage: remove ",
- icon_name="info",
- action=lambda: None,
- relevance=1.0,
- plugin_name=self.display_name,
- data={"type": "help", "keep_launcher_open": True},
- )
- ]
-
- # Show available bookmarks to remove
- if bookmarks:
- results.append(
- Result(
- title="Available Bookmarks:",
- subtitle=f"{len(bookmarks)} bookmarks available to remove",
- icon_name="bookmarks-organize",
- action=lambda: None,
- relevance=0.9,
- plugin_name=self.display_name,
- data={"type": "info", "keep_launcher_open": True},
- )
- )
-
- # Show first few bookmarks as examples
- for bookmark in bookmarks[:3]:
- title = bookmark.get("title", "Untitled")
- domain = self._extract_domain(bookmark.get("url", ""))
-
- # Truncate title and domain for consistent display
- display_title = title
- if len(display_title) > 20:
- display_title = display_title[:17] + "..."
-
- display_domain = domain
- if len(display_domain) > 20:
- display_domain = display_domain[:17] + "..."
-
- results.append(
- Result(
- title=f"remove {display_title}",
- subtitle=f"Click to remove: {display_title} ({display_domain})",
- icon_name="trash",
- action=lambda t=title: self._remove_bookmark_action(t),
- relevance=0.8,
- plugin_name=self.display_name,
- data={
- "type": "remove_option",
- "bookmark": bookmark,
- "keep_launcher_open": True,
- },
- )
- )
-
- return results
-
- def _add_bookmark_action(self, title: str, url: str, description: str = ""):
- """Execute the add bookmark action."""
- success = self.bookmark_manager.add_bookmark(title, url, description)
- if success:
- print(f"โ Added bookmark '{title}' - {url}")
- # Clear cache to force refresh
- self._results_cache.clear()
- self._cache_timestamps.clear()
- # Reset to trigger word and refresh
- self._reset_to_trigger()
- else:
- print(f"โ Failed to add bookmark '{title}' - already exists")
-
- def _remove_bookmark_action(self, identifier: str):
- """Execute the remove bookmark action."""
- success = self.bookmark_manager.remove_bookmark(identifier)
- if success:
- print(f"โ Removed bookmark '{identifier}'")
- # Clear cache to force refresh
- self._results_cache.clear()
- self._cache_timestamps.clear()
- # Reset to trigger word and refresh
- self._reset_to_trigger()
- else:
- print(f"โ Failed to remove bookmark '{identifier}' - not found")
-
- def _remove_bookmark_with_reset(self, identifier: str):
- """Execute the remove bookmark action via alt_action (Shift+Enter) and reset to trigger."""
- success = self.bookmark_manager.remove_bookmark(identifier)
- if success:
- print(f"โ Removed bookmark '{identifier}'")
- # Clear cache to force refresh
- self._results_cache.clear()
- self._cache_timestamps.clear()
- # Reset to trigger word and refresh
- self._reset_to_trigger()
- else:
- print(f"โ Failed to remove bookmark '{identifier}' - not found")
-
- def _calculate_relevance(self, bookmark: Dict, query: str) -> float:
- """Calculate relevance score for a bookmark."""
- title = bookmark.get("title", "").lower()
- url = bookmark.get("url", "").lower()
- description = bookmark.get("description", "").lower()
-
- # Exact title match
- if query == title:
- return 1.0
-
- # Title starts with query
- if title.startswith(query):
- return 0.95
-
- # Query in title
- if query in title:
- position = title.index(query)
- position_score = 1.0 - (position / len(title))
- return 0.8 + (position_score * 0.1)
-
- # Query in URL
- if query in url:
- return 0.7
-
- # Query in description
- if query in description:
- return 0.6
-
- # Fuzzy matching for title
- if len(query) >= 3:
- fuzzy_score = fuzz.partial_ratio(query, title) / 100.0
- if fuzzy_score >= 0.7:
- return fuzzy_score * 0.6
-
- return 0.0
-
- def _create_bookmark_result(
- self, bookmark: Dict, relevance: float
- ) -> Optional[Result]:
- """Create a Result object for a bookmark."""
- try:
- title = bookmark.get("title", "Untitled")
- url = bookmark.get("url", "")
- description = bookmark.get("description", "")
-
- # Truncate long titles to prevent launcher resize
- if len(title) > 45:
- title = title[:42] + "..."
-
- # Create subtitle with domain and description
- domain = self._extract_domain(url)
-
- # Truncate domain if too long
- if len(domain) > 30:
- domain = domain[:27] + "..."
-
- if description:
- # Truncate description to prevent launcher resize
- # Account for domain + separator
- max_desc_len = 50 - len(domain)
- if len(description) > max_desc_len:
- description = description[: max_desc_len - 3] + "..."
- subtitle = f"{domain} โข {description}"
- else:
- subtitle = domain
-
- # Final subtitle length check to ensure consistent launcher size
- if len(subtitle) > 60:
- subtitle = subtitle[:57] + "..."
-
- return Result(
- title=title,
- subtitle=subtitle,
- icon_name="bookmark-organize",
- action=lambda u=url: self._open_bookmark(u),
- relevance=relevance,
- plugin_name=self.display_name,
- data={
- "type": "bookmark",
- "url": url,
- "domain": domain,
- "description": description,
- "keep_launcher_open": False,
- "alt_action": lambda t=title: self._remove_bookmark_with_reset(t),
- },
- )
- except Exception as e:
- print(f"Error creating bookmark result: {e}")
- return None
-
- def _extract_domain(self, url: str) -> str:
- """Extract domain from URL."""
- try:
- parsed = urlparse(url)
- domain = parsed.netloc
- # Remove www. prefix
- if domain.startswith("www."):
- domain = domain[4:]
- return domain
- except:
- return url
-
- def _open_bookmark(self, url: str):
- """Open bookmark URL in default browser and update access time."""
- try:
- # Update access time
- self.bookmark_manager.update_access_time(url)
-
- # Open URL
- subprocess.Popen(
- ["xdg-open", url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
- )
- except Exception as e:
- print(f"Failed to open bookmark: {e}")
-
- def _setup_launcher_hooks(self):
- """Setup hooks to monitor launcher state."""
- try:
- # Try to find the launcher instance
- import gc
-
- for obj in gc.get_objects():
- if (
- hasattr(obj, "__class__")
- and obj.__class__.__name__ == "Launcher"
- and hasattr(obj, "close_launcher")
- ):
- self._launcher_instance = obj
- break
- except Exception as e:
- print(f"Warning: Could not setup launcher hooks: {e}")
-
- def _cleanup_launcher_hooks(self):
- """Cleanup launcher hooks."""
- try:
- self._launcher_instance = None
- except Exception as e:
- print(f"Warning: Could not cleanup launcher hooks: {e}")
-
- def _reset_to_trigger(self):
- """Reset launcher to trigger word and refresh."""
- try:
- if self._launcher_instance and hasattr(
- self._launcher_instance, "search_entry"
- ):
- # Get the current trigger (bookmark or bm)
- current_text = self._launcher_instance.search_entry.get_text()
- trigger = "bookmark "
-
- # Determine which trigger was used
- if current_text.lower().startswith("bm "):
- trigger = "bm "
-
- # Reset to trigger word with space
- try:
- from gi.repository import GLib
-
- def reset_and_refresh():
- # Set text to trigger word
- self._launcher_instance.search_entry.set_text(trigger)
- # Position cursor at end
- self._launcher_instance.search_entry.set_position(-1)
- # Trigger search to show default bookmarks
- self._launcher_instance._perform_search(trigger)
- return False
-
- GLib.timeout_add(50, reset_and_refresh)
- except ImportError:
- # Fallback: direct call if GLib not available
- self._launcher_instance.search_entry.set_text(trigger)
- self._launcher_instance.search_entry.set_position(-1)
- self._launcher_instance._perform_search(trigger)
- except Exception as e:
- print(f"Could not reset to trigger: {e}")
-
- def _force_launcher_refresh(self):
- """Force the launcher to refresh and show updated results."""
- try:
- if self._launcher_instance and hasattr(
- self._launcher_instance, "_perform_search"
- ):
- # Get current search text
- current_text = ""
- if hasattr(self._launcher_instance, "search_entry"):
- current_text = self._launcher_instance.search_entry.get_text()
-
- # Trigger a search to refresh results
- try:
- from gi.repository import GLib
-
- def refresh():
- self._launcher_instance._perform_search(current_text)
- return False
-
- GLib.timeout_add(50, refresh)
- except ImportError:
- # Fallback: direct call if GLib not available
- self._launcher_instance._perform_search(current_text)
- except Exception as e:
- print(f"Could not force launcher refresh: {e}")
diff --git a/modules/launcher/plugins/emoji.py b/modules/launcher/plugins/emoji.py
deleted file mode 100644
index 8842e804..00000000
--- a/modules/launcher/plugins/emoji.py
+++ /dev/null
@@ -1,187 +0,0 @@
-import json
-import os
-import subprocess
-import time
-from collections import OrderedDict
-from typing import Dict, List
-
-import config.data as data
-from fabric.utils import get_relative_path
-from modules.launcher.plugin_base import PluginBase
-from modules.launcher.result import Result
-
-
-class EmojiPlugin(PluginBase):
- """
- Plugin for searching and copying emojis.
- """
-
- def __init__(self):
- super().__init__()
- self.name = "emoji"
- self.display_name = "Emoji"
- self.description = "Search and copy emojis"
- self.emoji_data = {}
- self.emoji_path = get_relative_path("../../../config/assets/emoji.json")
-
- # Use cache directory for recent emojis (save directly in cache dir)
- self.recent_emoji_path = os.path.join(data.CACHE_DIR, "recent_emoji.json")
- self.recent_emojis = OrderedDict()
- self.max_recent_emojis = 20 # Maximum number of recent emojis to track
-
- def initialize(self):
- """Initialize the emoji plugin."""
- self.set_triggers(["em"])
- self._load_emoji_data()
- self._load_recent_emojis()
-
- def cleanup(self):
- """Cleanup the emoji plugin."""
- pass
-
- def _load_emoji_data(self):
- """Load emoji data from JSON file."""
- try:
- if os.path.exists(self.emoji_path):
- with open(self.emoji_path, "r", encoding="utf-8") as f:
- self.emoji_data = json.load(f)
- else:
- print(f"Emoji file not found: {self.emoji_path}")
- except Exception as e:
- print(f"Error loading emoji data: {e}")
-
- def _load_recent_emojis(self):
- """Load recently used emojis from JSON file."""
- try:
- if os.path.exists(self.recent_emoji_path):
- with open(self.recent_emoji_path, "r", encoding="utf-8") as f:
- recent_data = json.load(f)
- # Convert to OrderedDict to maintain order
- self.recent_emojis = OrderedDict(recent_data)
- else:
- # Create empty recent emojis file
- self.recent_emojis = OrderedDict()
- self._save_recent_emojis()
- except Exception as e:
- print(f"Error loading recent emoji data: {e}")
- self.recent_emojis = OrderedDict()
-
- def _save_recent_emojis(self):
- """Save recently used emojis to JSON file."""
- try:
- # Ensure the cache directory exists
- os.makedirs(data.CACHE_DIR, exist_ok=True)
-
- with open(self.recent_emoji_path, "w", encoding="utf-8") as f:
- json.dump(dict(self.recent_emojis), f, ensure_ascii=False, indent=2)
- except Exception as e:
- print(f"Error saving recent emoji data: {e}")
-
- def _add_to_recent(self, emoji: str):
- """Add an emoji to the recent list."""
- # Remove if already exists (to move it to front)
- if emoji in self.recent_emojis:
- del self.recent_emojis[emoji]
-
- # Add to front with current timestamp
- self.recent_emojis[emoji] = time.time()
-
- # Keep only the most recent emojis
- while len(self.recent_emojis) > self.max_recent_emojis:
- # Remove the oldest item
- self.recent_emojis.popitem(last=False)
-
- # Save to file
- self._save_recent_emojis()
-
- def _copy_to_clipboard(self, emoji: str):
- """Copy emoji to clipboard and track usage."""
- try:
- # Try Wayland first
- try:
- subprocess.run(["wl-copy"], input=emoji.encode(), check=True)
- except subprocess.SubprocessError:
- # Fall back to X11
- subprocess.run(
- ["xclip", "-selection", "clipboard"],
- input=emoji.encode(),
- check=True,
- )
-
- # Track this emoji as recently used
- self._add_to_recent(emoji)
-
- except Exception as e:
- print(f"Failed to copy to clipboard: {e}")
-
- def query(self, query_string: str) -> List[Result]:
- """Search emojis based on query."""
- results = []
- query = query_string.lower().strip()
-
- # If no query, show recently used emojis
- if not query:
- if self.recent_emojis:
- # Show recent emojis in reverse order (most recent first)
- for emoji in reversed(list(self.recent_emojis.keys())):
- if emoji in self.emoji_data:
- emoji_info = self.emoji_data[emoji]
- results.append(
- self._create_emoji_result(emoji, emoji_info, 1.0)
- )
- else:
- # If no recent emojis, show some popular ones as fallback
- popular_emojis = ["๐", "๐", "โค๏ธ", "๐", "๐ฅ", "โจ", "๐", "๐"]
- for emoji in popular_emojis:
- if emoji in self.emoji_data:
- emoji_info = self.emoji_data[emoji]
- results.append(
- self._create_emoji_result(emoji, emoji_info, 1.0)
- )
- return results
-
- # Search by name, group, or the emoji itself
- for emoji, info in self.emoji_data.items():
- relevance = 0
- name = info.get("name", "").lower()
- group = info.get("group", "").lower()
- slug = info.get("slug", "").lower()
-
- # Exact match with emoji
- if query == emoji:
- relevance = 1.0
- # Name contains query
- elif query in name:
- relevance = 0.9
- # Slug contains query
- elif query in slug:
- relevance = 0.8
- # Group contains query
- elif query in group:
- relevance = 0.7
-
- if relevance > 0:
- results.append(self._create_emoji_result(emoji, info, relevance))
-
- # Sort by relevance
- results.sort(key=lambda x: x.relevance, reverse=True)
- return results[:20] # Limit to 20 results
-
- def _create_emoji_result(self, emoji: str, info: Dict, relevance: float) -> Result:
- """Create a Result object for an emoji."""
- name = info.get("name", "")
- group = info.get("group", "")
-
- # Check if this is a recently used emoji
- is_recent = emoji in self.recent_emojis
- subtitle = f"{group}" + (" โข Recently used" if is_recent else "")
-
- return Result(
- title=name, # Show only the name, not the emoji
- subtitle=subtitle,
- icon_markup=emoji, # Use the emoji itself as the icon
- action=lambda e=emoji: self._copy_to_clipboard(e),
- relevance=relevance,
- plugin_name=self.display_name,
- data={"emoji": emoji, "name": name, "group": group, "recent": is_recent},
- )
diff --git a/modules/launcher/plugins/otp.py b/modules/launcher/plugins/otp.py
deleted file mode 100644
index 7ab0b6de..00000000
--- a/modules/launcher/plugins/otp.py
+++ /dev/null
@@ -1,850 +0,0 @@
-import json
-import subprocess
-import threading
-import time
-from pathlib import Path
-from typing import Dict, List, Optional
-
-from fabric.utils import get_relative_path
-from modules.launcher.plugin_base import PluginBase
-from modules.launcher.result import Result
-from services.auth import (
- generate_totp,
- get_time_remaining_with_blink,
- parse_otpauth_uri,
- scan_qr_and_add_account,
- validate_base32_secret,
-)
-
-
-class OTPPlugin(PluginBase):
- """Plugin for managing TOTP (Time-based One-Time Password) codes."""
-
- def __init__(self):
- super().__init__()
- self.display_name = "OTP Manager"
- self.description = "Manage TOTP codes and 2FA authentication"
-
- self.secrets_file = Path(
- get_relative_path("../../../config/assets/accounts.json")
- )
- self.secrets: Dict[str, Dict] = {}
- self.last_update = 0
-
- # Threading for auto-refresh
- self.refresh_thread = None
- self.stop_refresh = threading.Event()
-
- def initialize(self):
- """Initialize the OTP plugin."""
- self.set_triggers(["otp"])
- self._load_secrets()
- self._ensure_config_file()
- self._start_refresh_thread()
-
- def cleanup(self):
- """Cleanup the OTP plugin."""
- if self.refresh_thread and self.refresh_thread.is_alive():
- self.stop_refresh.set()
- self.refresh_thread.join(timeout=1)
-
- def _load_secrets(self):
- """Load secrets from JSON file."""
- try:
- if self.secrets_file.exists():
- with open(self.secrets_file, "r", encoding="utf-8") as f:
- self.secrets = json.load(f)
- else:
- self.secrets = {}
- except Exception as e:
- print(f"Error loading OTP secrets: {e}")
- self.secrets = {}
-
- def _save_secrets(self):
- """Save secrets to JSON file."""
- try:
- self.secrets_file.parent.mkdir(parents=True, exist_ok=True)
- with open(self.secrets_file, "w", encoding="utf-8") as f:
- json.dump(self.secrets, f, indent=2)
- except Exception as e:
- print(f"Error saving OTP secrets: {e}")
-
- def _ensure_config_file(self):
- """Ensure the config file exists."""
- if not self.secrets_file.exists():
- self.secrets_file.parent.mkdir(parents=True, exist_ok=True)
- with open(self.secrets_file, "w", encoding="utf-8") as f:
- json.dump({}, f, indent=2)
-
- def _start_refresh_thread(self):
- """Start background thread for auto-refreshing tokens."""
-
- def refresh_loop():
- while not self.stop_refresh.wait(5):
- current_time = time.time()
- if current_time - self.last_update >= 5:
- self.last_update = current_time
- # Only refresh if we have secrets and launcher is likely active
- if self.secrets:
- try:
- self._selective_force_refresh()
- except Exception:
- pass
-
- self.refresh_thread = threading.Thread(target=refresh_loop, daemon=True)
- self.refresh_thread.start()
-
- def _selective_force_refresh(self):
- """Update time display in existing OTP result items."""
- try:
- import gc
-
- from gi.repository import GLib
-
- def do_update():
- try:
- for obj in gc.get_objects():
- if (
- hasattr(obj, "__class__")
- and obj.__class__.__name__ == "Launcher"
- and hasattr(obj, "results_box")
- and hasattr(obj, "visible")
- and obj.visible
- and hasattr(obj, "results")
- and obj.results
- ):
- has_otp_results = any(
- result.data and result.data.get("type") == "totp"
- for result in obj.results
- if hasattr(result, "data") and result.data
- )
-
- if has_otp_results:
- self._update_existing_result_labels(obj.results_box)
- return False
- except Exception:
- pass
- return False
-
- GLib.idle_add(do_update)
- except Exception:
- pass
-
- def _update_existing_result_labels(self, results_box):
- """Update subtitle labels in existing ResultItem widgets."""
- try:
- time_display = self._get_time_remaining_with_blink()
- for child in results_box.get_children():
- if (
- hasattr(child, "__class__")
- and child.__class__.__name__ == "ResultItem"
- and hasattr(child, "result")
- and hasattr(child.result, "data")
- and child.result.data
- and child.result.data.get("type") == "totp"
- ):
- self._update_result_item_content(child, time_display)
- except Exception as e:
- print(f"Error updating result labels: {e}")
-
- def _update_result_item_content(self, result_item, time_display):
- """Update both the title (OTP code) and subtitle (time display) of a specific ResultItem."""
- try:
- account_name = result_item.result.data.get("account", "")
- if not account_name or account_name not in self.secrets:
- return
-
- account_data = self.secrets[account_name]
- secret = account_data.get("secret", "")
- issuer = account_data.get("issuer", "")
- display_name = f"{issuer} - {account_name}" if issuer else account_name
-
- current_totp_code = self._generate_totp(secret)
- if not current_totp_code:
- return
-
- old_code = result_item.result.data.get("code", "")
- if current_totp_code != old_code:
- result_item.result.data["code"] = current_totp_code
- self._find_and_update_title_label(result_item, current_totp_code)
- result_item.result.action = (
- lambda code=current_totp_code: self._copy_to_clipboard(code)
- )
-
- new_subtitle_markup = f"{display_name} โข {time_display} remaining"
- self._find_and_update_subtitle_label(result_item, new_subtitle_markup)
- except Exception as e:
- print(f"Error updating result item: {e}")
-
- def _find_and_update_title_label(self, result_item, new_title):
- """Find the title label widget and update its text."""
-
- def find_title_label(widget):
- if hasattr(widget, "get_name") and widget.get_name() == "result-item-title":
- return widget
- if hasattr(widget, "get_children"):
- for child in widget.get_children():
- found = find_title_label(child)
- if found:
- return found
- return None
-
- title_label = find_title_label(result_item)
- if title_label and hasattr(title_label, "set_label"):
- title_label.set_label(new_title)
-
- def _find_and_update_subtitle_label(self, result_item, new_markup):
- """Find the subtitle label widget and update its markup."""
-
- def find_subtitle_label(widget):
- if (
- hasattr(widget, "get_name")
- and widget.get_name() == "result-item-subtitle"
- ):
- return widget
- if hasattr(widget, "get_children"):
- for child in widget.get_children():
- found = find_subtitle_label(child)
- if found:
- return found
- return None
-
- subtitle_label = find_subtitle_label(result_item)
- if subtitle_label and hasattr(subtitle_label, "set_markup"):
- subtitle_label.set_markup(new_markup)
-
- def _copy_to_clipboard(self, text: str):
- """Copy text to clipboard."""
- try:
- try:
- subprocess.run(["wl-copy"], input=text.encode(), check=True)
- except subprocess.SubprocessError:
- subprocess.run(
- ["xclip", "-selection", "clipboard"],
- input=text.encode(),
- check=True,
- )
- except Exception as e:
- print(f"Failed to copy to clipboard: {e}")
-
- def _trigger_refresh(self):
- """Trigger launcher refresh to return to default OTP view."""
- try:
- from gi.repository import GLib
-
- # Use a small delay to ensure the action completes first
- def trigger_refresh():
- try:
- # Try to access the launcher through the fabric Application
- from fabric import Application
-
- app = Application.get_default()
-
- if app and hasattr(app, "launcher"):
- launcher = app.launcher
- if launcher and hasattr(launcher, "search_entry"):
- # Clear the search entry and set it to just "otp "
- launcher.search_entry.set_text("otp ")
- # Position cursor at the end
- launcher.search_entry.set_position(-1)
- # Trigger the search to show default OTP view
- if hasattr(launcher, "_perform_search"):
- launcher._perform_search("otp ")
- return False
-
- # Fallback: try to find launcher instance through other means
- import gc
-
- for obj in gc.get_objects():
- if (
- hasattr(obj, "__class__")
- and obj.__class__.__name__ == "Launcher"
- ):
- if hasattr(obj, "search_entry") and hasattr(
- obj, "_perform_search"
- ):
- obj.search_entry.set_text("otp ")
- obj.search_entry.set_position(-1)
- obj._perform_search("otp ")
- return False
-
- except Exception as e:
- print(f"Error forcing launcher refresh: {e}")
-
- return False # Don't repeat
-
- # Use a small delay to ensure the action completes first
- GLib.timeout_add(50, trigger_refresh)
-
- except Exception as e:
- print(f"Could not trigger refresh: {e}")
-
- def _remove_account_and_refresh(self, account_name: str):
- """Remove an account and trigger refresh to return to default OTP view."""
- try:
- if account_name in self.secrets:
- # Remove the account
- del self.secrets[account_name]
- self._save_secrets()
-
- # Trigger refresh to return to default OTP view
- self._trigger_refresh()
- except Exception as e:
- print(f"Error removing account {account_name}: {e}")
-
- def _generate_totp(self, secret: str) -> Optional[str]:
- """Generate TOTP code from secret."""
- return generate_totp(secret)
-
- def _get_time_remaining_with_blink(self) -> str:
- """Get time remaining with blinking effect."""
- return get_time_remaining_with_blink()
-
- def query(self, query_string: str) -> List[Result]:
- """Process OTP queries."""
- query = query_string.strip()
-
- if not query:
- return self._list_otp_codes()
-
- query_lower = query.lower()
- if query_lower.startswith("add "):
- add_content = query[4:].strip()
-
- # Handle both formats: with ``` and without ```
- if "```" in add_content:
- # Old format: add account```secret```
- parts = add_content.split("```", 1)
- if len(parts) == 2:
- account_name = parts[0].strip()
- secret_or_uri = parts[1].strip()
- return self._handle_direct_add(account_name, secret_or_uri)
- elif " " in add_content:
- # New format: add account secret
- parts = add_content.split(" ", 1)
- if len(parts) == 2:
- account_name = parts[0].strip()
- secret_or_uri = parts[1].strip()
- return self._handle_direct_add(account_name, secret_or_uri)
-
- return self._handle_add_command(add_content)
- elif query_lower == "remove" or query_lower.startswith("remove "):
- # Handle both "remove" and "remove accountname"
- if query_lower == "remove":
- remove_content = ""
- else:
- remove_content = query[7:].strip()
- return self._handle_remove_command(remove_content)
- elif query_lower == "qr" or query_lower.startswith("qr "):
- # Handle QR scanning command
- if query_lower == "qr":
- qr_content = ""
- else:
- qr_content = query[3:].strip()
- return self._handle_qr_command(qr_content)
- else:
- return self._search_accounts(query)
-
- def _handle_direct_add(self, account_name: str, secret_or_uri: str) -> List[Result]:
- """Handle direct addition of OTP account."""
- if not account_name or not secret_or_uri:
- return [
- Result(
- title="Invalid format",
- subtitle="Usage: add or add ``````",
- icon_name="info",
- action=lambda: None,
- relevance=1.0,
- plugin_name=self.display_name,
- data={"type": "help", "keep_launcher_open": True},
- )
- ]
-
- try:
- if secret_or_uri.startswith("otpauth://"):
- return self._handle_otpauth_uri(account_name, secret_or_uri)
- else:
- return self._handle_base32_secret(account_name, secret_or_uri)
- except Exception as e:
- print(f"OTP Debug: Error in _handle_direct_add: {e}")
- return [
- Result(
- title="Error adding account",
- subtitle=f"Debug: {str(e)}",
- icon_name="cancel",
- action=lambda: None,
- relevance=0.5,
- plugin_name=self.display_name,
- data={"type": "error", "keep_launcher_open": True},
- )
- ]
-
- def _list_otp_codes(self) -> List[Result]:
- """List all OTP codes with current tokens."""
- results = []
-
- if not self.secrets:
- results.append(
- Result(
- title="No OTP accounts configured",
- subtitle="Use 'add ' to add your first account",
- icon_name="gtk-authentication-symbolic",
- action=lambda: None,
- relevance=1.0,
- plugin_name=self.display_name,
- data={"type": "empty", "keep_launcher_open": True},
- )
- )
- results.append(
- Result(
- title="Available commands:",
- subtitle="add | remove | qr ",
- icon_name="info",
- action=lambda: None,
- relevance=0.9,
- plugin_name=self.display_name,
- data={"type": "help", "keep_launcher_open": True},
- )
- )
- return results
-
- time_display = self._get_time_remaining_with_blink()
-
- for account_name, account_data in self.secrets.items():
- secret = account_data.get("secret", "")
- issuer = account_data.get("issuer", "")
- totp_code = self._generate_totp(secret)
-
- if totp_code:
- display_name = f"{issuer} - {account_name}" if issuer else account_name
- results.append(
- Result(
- title=f"{totp_code}",
- subtitle_markup=f"{display_name} โข {
- time_display
- } remaining โข Shift+Enter: remove",
- icon_name="gtk-authentication-symbolic",
- action=lambda code=totp_code: self._copy_to_clipboard(code),
- relevance=1.0,
- plugin_name=self.display_name,
- data={
- "type": "totp",
- "account": account_name,
- "code": totp_code,
- "alt_action": lambda acc=account_name: self._remove_account_and_refresh(
- acc
- ),
- },
- )
- )
- else:
- results.append(
- Result(
- title=f"Error: {account_name}",
- subtitle="Invalid secret or configuration",
- icon_name="dialog-cancel-symbolic",
- action=lambda: None,
- relevance=0.5,
- plugin_name=self.display_name,
- data={
- "type": "error",
- "account": account_name,
- "keep_launcher_open": True,
- },
- )
- )
-
- return results
-
- def _search_accounts(self, query: str) -> List[Result]:
- """Search accounts by name or issuer."""
- results = []
- query_lower = query.lower()
-
- for account_name, account_data in self.secrets.items():
- issuer = account_data.get("issuer", "").lower()
- account_lower = account_name.lower()
-
- if query_lower in account_lower or query_lower in issuer:
- secret = account_data.get("secret", "")
- totp_code = self._generate_totp(secret)
-
- if totp_code:
- display_name = (
- f"{account_data.get('issuer', '')} - {account_name}"
- if account_data.get("issuer")
- else account_name
- )
- time_display = self._get_time_remaining_with_blink()
-
- results.append(
- Result(
- title=f"{totp_code}",
- subtitle_markup=f"{display_name} โข {
- time_display
- } remaining โข Shift+Enter: remove",
- icon_name="gtk-authentication-symbolic",
- action=lambda code=totp_code: self._copy_to_clipboard(code),
- relevance=1.0,
- plugin_name=self.display_name,
- data={
- "type": "totp",
- "account": account_name,
- "code": totp_code,
- "alt_action": lambda acc=account_name: self._remove_account_and_refresh(
- acc
- ),
- },
- )
- )
-
- if not results:
- results.append(
- Result(
- title=f"No accounts found for '{query}'",
- subtitle="Use 'add ' to create new account",
- icon_name="edit-find-symbolic",
- action=lambda: None,
- relevance=0.5,
- plugin_name=self.display_name,
- data={"type": "no_results", "keep_launcher_open": True},
- )
- )
- results.append(
- Result(
- title="Available commands:",
- subtitle="add | remove | qr ",
- icon_name="info",
- action=lambda: None,
- relevance=0.4,
- plugin_name=self.display_name,
- data={"type": "help", "keep_launcher_open": True},
- )
- )
-
- return results
-
- def _handle_add_command(self, account_name: str) -> List[Result]:
- """Handle manual addition of OTP secret."""
- if not account_name:
- return [
- Result(
- title="Enter account name",
- subtitle="Usage: add ",
- icon_name="dialog-question-symbolic",
- action=lambda: None,
- relevance=1.0,
- plugin_name=self.display_name,
- data={"type": "help", "keep_launcher_open": True},
- )
- ]
-
- return [
- Result(
- title=f"To add '{account_name}':",
- subtitle=f"Type: add {account_name} ",
- icon_name="info",
- action=lambda: None,
- relevance=1.0,
- plugin_name=self.display_name,
- data={
- "type": "instruction",
- "account": account_name,
- "keep_launcher_open": True,
- },
- ),
- Result(
- title="Base32 Secret Format:",
- subtitle="Example: add gmail JBSWY3DPEHPK3PXP",
- icon_name="info",
- action=lambda: None,
- relevance=0.9,
- plugin_name=self.display_name,
- data={"type": "help", "keep_launcher_open": True},
- ),
- Result(
- title="otpauth URI Format:",
- subtitle="Example: add github otpauth://totp/GitHub:user?secret=JBSWY3DPEHPK3PXP",
- icon_name="info",
- action=lambda: None,
- relevance=0.9,
- plugin_name=self.display_name,
- data={"type": "help", "keep_launcher_open": True},
- ),
- Result(
- title="Remove Account:",
- subtitle="Example: remove gmail",
- icon_name="trash",
- action=lambda: None,
- relevance=0.8,
- plugin_name=self.display_name,
- data={"type": "help", "keep_launcher_open": True},
- ),
- ]
-
- def _handle_qr_command(self, account_name: str) -> List[Result]:
- """Handle QR scanning command."""
- if not account_name:
- return [
- Result(
- title="Scan QR Code",
- subtitle="Click to scan QR code from screen",
- icon_name="view-barcode-qr-symbolic",
- action=lambda: self._scan_qr_and_add_account(""),
- relevance=1.0,
- plugin_name=self.display_name,
- data={"type": "qr_scan"},
- ),
- Result(
- title="QR Scan Instructions:",
- subtitle="Use 'qr ' to specify account name",
- icon_name="info",
- action=lambda: None,
- relevance=0.9,
- plugin_name=self.display_name,
- data={"type": "help", "keep_launcher_open": True},
- ),
- ]
- else:
- return [
- Result(
- title=f"Scan QR Code for '{account_name}'",
- subtitle="Click to scan QR code from screen",
- icon_name="view-barcode-qr-symbolic",
- action=lambda name=account_name: self._scan_qr_and_add_account(
- name
- ),
- relevance=1.0,
- plugin_name=self.display_name,
- data={"type": "qr_scan"},
- ),
- ]
-
- def _scan_qr_and_add_account(self, account_name: str):
- """Scan QR code and add OTP account."""
- print(f"QR scan action called for account: '{account_name}'")
- print("Starting QR scan process asynchronously...")
-
- # Run QR scanning asynchronously so launcher closes immediately
- import threading
-
- thread = threading.Thread(target=self._scan_qr_async, args=(account_name,))
- thread.daemon = True
- thread.start()
-
- def _scan_qr_async(self, account_name: str):
- """Async QR scanning process."""
- result = scan_qr_and_add_account(account_name, str(self.secrets_file))
-
- if result["success"]:
- print(result["message"])
- # Reload secrets from file
- self._load_secrets()
- # Trigger refresh to show the new account
- self._trigger_refresh()
- else:
- print(f"QR scan failed: {result['error']}")
-
- def _handle_remove_command(self, account_name: str) -> List[Result]:
- """Handle removal of OTP account."""
- if not account_name:
- # Show all available accounts for removal
- results = []
-
- if not self.secrets:
- results.append(
- Result(
- title="No OTP accounts to remove",
- subtitle="Use 'add ' to add accounts first",
- icon_name="info",
- action=lambda: None,
- relevance=1.0,
- plugin_name=self.display_name,
- data={"type": "empty", "keep_launcher_open": True},
- )
- )
- else:
- results.append(
- Result(
- title="Select account to remove:",
- subtitle="Type: remove to remove an account",
- icon_name="user-trash-symbolic",
- action=lambda: None,
- relevance=1.0,
- plugin_name=self.display_name,
- data={"type": "help", "keep_launcher_open": True},
- )
- )
-
- # Get time display for consistency with main OTP view
- time_display = self._get_time_remaining_with_blink()
-
- # Show all accounts with their current OTP codes and remove actions
- for acc_name, account_data in self.secrets.items():
- secret = account_data.get("secret", "")
- issuer = account_data.get("issuer", "")
- display_name = f"{issuer} - {acc_name}" if issuer else acc_name
-
- # Generate current TOTP code
- totp_code = self._generate_totp(secret)
-
- if totp_code:
- results.append(
- Result(
- title=f"{totp_code}",
- subtitle_markup=f"Press Enter to remove โข {
- time_display
- } remaining",
- icon_name="user-trash-symbolic",
- action=lambda acc=acc_name: self._remove_account_and_refresh(
- acc
- ),
- relevance=0.9,
- plugin_name=self.display_name,
- data={
- "type": "remove_instruction",
- "account": acc_name,
- "code": totp_code,
- "keep_launcher_open": True,
- },
- )
- )
- else:
- results.append(
- Result(
- title=f"Error: {acc_name}",
- subtitle="Press Enter to remove (Invalid secret)",
- icon_name="user-trash-symbolic",
- action=lambda acc=acc_name: self._remove_account_and_refresh(
- acc
- ),
- relevance=0.8,
- plugin_name=self.display_name,
- data={
- "type": "remove_instruction",
- "account": acc_name,
- "keep_launcher_open": True,
- },
- )
- )
-
- return results
-
- # Check if account exists
- if account_name not in self.secrets:
- return [
- Result(
- title=f"Account '{account_name}' not found",
- subtitle="Use 'remove' to see all available accounts",
- icon_name="dialog-cancel-symbolic",
- action=lambda: None,
- relevance=0.5,
- plugin_name=self.display_name,
- data={"type": "error", "keep_launcher_open": True},
- )
- ]
-
- # Get account info for confirmation
- account_data = self.secrets[account_name]
- issuer = account_data.get("issuer", "")
- display_name = f"{issuer} - {account_name}" if issuer else account_name
-
- # Show confirmation for removal
- return [
- Result(
- title=f"Remove '{display_name}'?",
- subtitle="Press Enter to confirm removal",
- icon_name="user-trash-symbolic",
- action=lambda acc=account_name: self._remove_account_and_refresh(acc),
- relevance=1.0,
- plugin_name=self.display_name,
- data={
- "type": "remove_confirm",
- "account": account_name,
- "keep_launcher_open": True,
- },
- )
- ]
-
- def _handle_base32_secret(self, account_name: str, secret: str) -> List[Result]:
- """Handle raw Base32 secret."""
- result = validate_base32_secret(secret)
-
- if not result["success"]:
- return [
- Result(
- title="Invalid Base32 secret",
- subtitle=result["error"],
- icon_name="dialog-cancel-symbolic",
- action=lambda: None,
- relevance=0.5,
- plugin_name=self.display_name,
- data={"type": "error", "keep_launcher_open": True},
- )
- ]
-
- self.secrets[account_name] = {
- "secret": result["secret"],
- "issuer": "",
- "algorithm": "SHA1",
- "digits": 6,
- "period": 30,
- }
- self._save_secrets()
-
- return [
- Result(
- title=f"โ Added '{account_name}'",
- subtitle=f"OTP account added successfully (secret: {
- result['secret'][:4]
- }...)",
- icon_name="emblem-ok-symbolic",
- action=lambda: self._trigger_refresh(),
- relevance=1.0,
- plugin_name=self.display_name,
- data={"type": "success", "keep_launcher_open": True},
- )
- ]
-
- def _handle_otpauth_uri(self, account_name: str, uri: str) -> List[Result]:
- """Handle otpauth:// URI."""
- result = parse_otpauth_uri(uri, account_name)
-
- if not result["success"]:
- return [
- Result(
- title="Error parsing otpauth URI",
- subtitle=result["error"],
- icon_name="dialog-cancel-symbolic",
- action=lambda: None,
- relevance=0.5,
- plugin_name=self.display_name,
- data={"type": "error", "keep_launcher_open": True},
- )
- ]
-
- self.secrets[result["account_name"]] = {
- "secret": result["secret"],
- "issuer": result["issuer"],
- "algorithm": result["algorithm"],
- "digits": result["digits"],
- "period": result["period"],
- }
- self._save_secrets()
-
- display_name = (
- f"{result['issuer']} - {result['account_name']}"
- if result["issuer"]
- else result["account_name"]
- )
- return [
- Result(
- title=f"โ Added '{display_name}'",
- subtitle="OTP account added from URI",
- icon_name="emblem-ok-symbolic",
- action=lambda: self._trigger_refresh(),
- relevance=1.0,
- plugin_name=self.display_name,
- data={"type": "success", "keep_launcher_open": True},
- )
- ]
diff --git a/modules/launcher/plugins/password.py b/modules/launcher/plugins/password.py
deleted file mode 100644
index 1551b200..00000000
--- a/modules/launcher/plugins/password.py
+++ /dev/null
@@ -1,690 +0,0 @@
-import base64
-import json
-import subprocess
-import threading
-import time
-from datetime import datetime
-from pathlib import Path
-from typing import Dict, List, Optional
-
-from fabric.utils import get_relative_path
-from modules.launcher.plugin_base import PluginBase
-from modules.launcher.result import Result
-
-
-class PasswordManager:
- """Simple password manager with basic encryption and caching."""
-
- def __init__(self, storage_file: Path):
- self.storage_file = storage_file
- self.passwords: Dict[str, Dict] = {}
- self._cache_lock = threading.Lock()
- self._last_loaded = 0
- self._cache_ttl = 30 # Cache for 30 seconds
- self._load_passwords()
-
- def _simple_encrypt(self, text: str, key: str = "modus_pass") -> str:
- """Simple encryption using XOR with base64 encoding."""
- key_bytes = key.encode("utf-8")
- text_bytes = text.encode("utf-8")
-
- # XOR encryption
- encrypted = bytearray()
- for i, byte in enumerate(text_bytes):
- encrypted.append(byte ^ key_bytes[i % len(key_bytes)])
-
- # Base64 encode
- return base64.b64encode(encrypted).decode("utf-8")
-
- def _simple_decrypt(self, encrypted_text: str, key: str = "modus_pass") -> str:
- """Simple decryption using XOR with base64 decoding."""
- try:
- key_bytes = key.encode("utf-8")
-
- # Base64 decode
- encrypted_bytes = base64.b64decode(encrypted_text.encode("utf-8"))
-
- # XOR decryption
- decrypted = bytearray()
- for i, byte in enumerate(encrypted_bytes):
- decrypted.append(byte ^ key_bytes[i % len(key_bytes)])
-
- return decrypted.decode("utf-8")
- except Exception:
- return encrypted_text # Return as-is if decryption fails
-
- def _load_passwords(self):
- """Load passwords from JSON file with caching."""
- with self._cache_lock:
- current_time = time.time()
-
- # Check if cache is still valid
- if (current_time - self._last_loaded) < self._cache_ttl and self.passwords:
- return
-
- try:
- if self.storage_file.exists():
- with open(self.storage_file, "r", encoding="utf-8") as f:
- data = json.load(f)
- self.passwords = data.get("passwords", {})
- else:
- self.passwords = {}
-
- self._last_loaded = current_time
- except Exception as e:
- print(f"Error loading passwords: {e}")
- self.passwords = {}
-
- def _save_passwords(self):
- """Save passwords to JSON file."""
- with self._cache_lock:
- try:
- self.storage_file.parent.mkdir(parents=True, exist_ok=True)
- data = {
- "passwords": self.passwords,
- "last_modified": datetime.now().isoformat(),
- }
- with open(self.storage_file, "w", encoding="utf-8") as f:
- json.dump(data, f, indent=2)
-
- # Update cache timestamp
- self._last_loaded = time.time()
- except Exception as e:
- print(f"Error saving passwords: {e}")
-
- def add_password(self, name: str, password: str, description: str = "") -> bool:
- """Add a new password entry."""
- try:
- encrypted_password = self._simple_encrypt(password)
- self.passwords[name] = {
- "password": encrypted_password,
- "description": description,
- "created": datetime.now().isoformat(),
- "last_accessed": None,
- }
- self._save_passwords()
- return True
- except Exception as e:
- print(f"Error adding password: {e}")
- return False
-
- def get_password(self, name: str, update_access_time: bool = True) -> Optional[str]:
- """Get decrypted password by name."""
- # Ensure we have fresh data
- self._load_passwords()
-
- if name in self.passwords:
- try:
- encrypted = self.passwords[name]["password"]
- decrypted = self._simple_decrypt(encrypted)
-
- # Update last accessed time only if requested (to avoid frequent saves)
- if update_access_time:
- self.passwords[name]["last_accessed"] = datetime.now().isoformat()
- # Don't save immediately - batch saves for better performance
-
- return decrypted
- except Exception as e:
- print(f"Error decrypting password: {e}")
- return None
- return None
-
- def remove_password(self, name: str) -> bool:
- """Remove a password entry."""
- if name in self.passwords:
- del self.passwords[name]
- self._save_passwords()
- return True
- return False
-
- def list_passwords(self) -> List[str]:
- """Get list of all password names."""
- # Ensure we have fresh data
- self._load_passwords()
- return list(self.passwords.keys())
-
- def get_password_info(self, name: str) -> Optional[Dict]:
- """Get password metadata without decrypting."""
- if name in self.passwords:
- info = self.passwords[name].copy()
- info.pop("password", None) # Remove encrypted password
- return info
- return None
-
-
-class PasswordPlugin(PluginBase):
- """
- Password manager plugin for the launcher.
- Stores passwords securely and allows easy access.
- """
-
- def __init__(self):
- super().__init__()
- self.display_name = "Password Manager"
- self.description = "Secure password storage and management"
-
- # Initialize password manager
- self.password_file = Path(
- get_relative_path("../../../config/assets/passwords.json")
- )
- self.password_manager = PasswordManager(self.password_file)
-
- # State for password visibility
- self.revealed_passwords: Dict[str, str] = {}
-
- # Cache for results to avoid repeated queries
- self._results_cache: Dict[str, List[Result]] = {}
- self._cache_timestamps: Dict[str, float] = {}
- self._cache_ttl = 5 # Cache results for 5 seconds
-
- # Track launcher state for auto-hiding passwords
- self._launcher_instance = None
-
- def initialize(self):
- """Initialize the password plugin."""
- self.set_triggers(["pass"])
- self._setup_launcher_hooks()
-
- def cleanup(self):
- """Cleanup the password plugin."""
- self.revealed_passwords.clear()
- self._results_cache.clear()
- self._cache_timestamps.clear()
- self._cleanup_launcher_hooks()
-
- def query(self, query_string: str) -> List[Result]:
- """Process password manager queries with caching."""
- query_key = query_string.strip()
- current_time = time.time()
-
- # Check cache first (except for add/remove commands which should always execute)
- if (
- not query_key.startswith(("add ", "remove ", "delete "))
- and query_key in self._results_cache
- and (current_time - self._cache_timestamps.get(query_key, 0))
- < self._cache_ttl
- ):
- return self._results_cache[query_key]
-
- results = []
- query = query_key.lower()
-
- # Handle different commands
- if not query:
- # Show all passwords
- results.extend(self._list_all_passwords())
- elif query.startswith("add "):
- # Add new password (don't cache)
- results.extend(self._handle_add_command(query_string))
- elif query.startswith("remove ") or query.startswith("delete "):
- # Remove password (don't cache)
- results.extend(self._handle_remove_command(query_string))
- else:
- # Search for specific password
- results.extend(self._search_passwords(query))
-
- # Cache results (except for add/remove commands)
- if not query.startswith(("add ", "remove ", "delete ")):
- self._results_cache[query_key] = results
- self._cache_timestamps[query_key] = current_time
-
- return results
-
- def _list_all_passwords(self) -> List[Result]:
- """List all stored passwords."""
- results = []
- password_names = self.password_manager.list_passwords()
-
- if not password_names:
- results.append(
- Result(
- title="No passwords stored",
- subtitle="Use 'pass add ' to add your first password",
- icon_name="password-symbolic",
- action=lambda: None,
- relevance=1.0,
- plugin_name=self.display_name,
- data={"type": "empty", "keep_launcher_open": True},
- )
- )
- results.append(
- Result(
- title="Available commands:",
- subtitle="add | remove | (to search)",
- icon_name="info",
- action=lambda: None,
- relevance=0.9,
- plugin_name=self.display_name,
- data={"type": "help", "keep_launcher_open": True},
- )
- )
- return results
-
- # Sort passwords alphabetically
- password_names.sort()
-
- for name in password_names:
- info = self.password_manager.get_password_info(name)
- description = info.get("description", "") if info else ""
-
- # Check if password is revealed
- if name in self.revealed_passwords:
- title = f"{name}: {self.revealed_passwords[name]}"
- subtitle = "Password revealed - Enter: copy | Shift+Enter: hide"
- else:
- title = f"{name}: {'*' * 8}"
- subtitle = "Enter: copy | Shift+Enter: reveal password"
-
- if description:
- subtitle += f" | {description}"
-
- results.append(
- Result(
- title=title,
- subtitle=subtitle,
- icon_name="key",
- action=lambda n=name: self._copy_password_to_clipboard(n),
- relevance=1.0,
- plugin_name=self.display_name,
- data={
- "type": "password",
- "name": name,
- "keep_launcher_open": False,
- "alt_action": lambda n=name: self._toggle_password_visibility(
- n
- ),
- },
- )
- )
-
- return results
-
- def _handle_add_command(self, query_string: str) -> List[Result]:
- """Handle add password command."""
- results = []
- parts = query_string.strip().split(" ", 3)
-
- if len(parts) < 3:
- results.append(
- Result(
- title="Add Password - Invalid format",
- subtitle="Usage: add [description]",
- icon_name="cancel",
- action=lambda: None,
- relevance=1.0,
- plugin_name=self.display_name,
- data={"type": "error", "keep_launcher_open": True},
- )
- )
- return results
-
- name = parts[1]
- password = parts[2]
- description = parts[3] if len(parts) > 3 else ""
-
- # Check if password already exists
- if name in self.password_manager.list_passwords():
- results.append(
- Result(
- title=f"Update password for '{name}'?",
- subtitle="Password already exists. Click to update it.",
- icon_name="key",
- action=lambda: self._add_password_action(
- name, password, description, update=True
- ),
- relevance=1.0,
- plugin_name=self.display_name,
- data={"type": "update", "name": name, "keep_launcher_open": False},
- )
- )
- else:
- results.append(
- Result(
- title=f"Add password for '{name}'",
- subtitle="Click to save password"
- + (f" | {description}" if description else ""),
- icon_name="plus",
- action=lambda: self._add_password_action(
- name, password, description
- ),
- relevance=1.0,
- plugin_name=self.display_name,
- data={"type": "add", "name": name, "keep_launcher_open": False},
- )
- )
-
- return results
-
- def _handle_remove_command(self, query_string: str) -> List[Result]:
- """Handle remove password command."""
- results = []
- parts = query_string.strip().split(" ", 1)
-
- if len(parts) < 2:
- results.append(
- Result(
- title="Remove Password - Invalid format",
- subtitle="Usage: remove ",
- icon_name="cancel",
- action=lambda: None,
- relevance=1.0,
- plugin_name=self.display_name,
- data={"type": "error", "keep_launcher_open": True},
- )
- )
- return results
-
- name = parts[1]
-
- if name not in self.password_manager.list_passwords():
- results.append(
- Result(
- title=f"Password '{name}' not found",
- subtitle="Check the name and try again",
- icon_name="cancel",
- action=lambda: None,
- relevance=1.0,
- plugin_name=self.display_name,
- data={"type": "error", "keep_launcher_open": True},
- )
- )
- else:
- results.append(
- Result(
- title=f"Remove password '{name}'?",
- subtitle="Click to confirm deletion (this cannot be undone)",
- icon_name="trash",
- action=lambda: self._remove_password_action(name),
- relevance=1.0,
- plugin_name=self.display_name,
- data={"type": "remove", "name": name, "keep_launcher_open": False},
- )
- )
-
- return results
-
- def _search_passwords(self, query: str) -> List[Result]:
- """Search for passwords by name."""
- results = []
- password_names = self.password_manager.list_passwords()
-
- # Filter passwords that match the query
- matching_passwords = [
- name for name in password_names if query.lower() in name.lower()
- ]
-
- if not matching_passwords:
- results.append(
- Result(
- title=f"No passwords found matching '{query}'",
- subtitle="Try a different search term or use 'pass' to see all passwords",
- icon_name="magnifier",
- action=lambda: None,
- relevance=1.0,
- plugin_name=self.display_name,
- data={"type": "no_results", "keep_launcher_open": True},
- )
- )
- return results
-
- # Sort by relevance (exact match first, then starts with, then contains)
- def get_relevance(name: str) -> float:
- name_lower = name.lower()
- query_lower = query.lower()
-
- if name_lower == query_lower:
- return 1.0
- elif name_lower.startswith(query_lower):
- return 0.9
- else:
- return 0.7
-
- matching_passwords.sort(key=get_relevance, reverse=True)
-
- for name in matching_passwords:
- info = self.password_manager.get_password_info(name)
- description = info.get("description", "") if info else ""
-
- # Check if password is revealed
- if name in self.revealed_passwords:
- title = f"{name}: {self.revealed_passwords[name]}"
- subtitle = "Password revealed - Enter: copy | Shift+Enter: hide"
- else:
- title = f"{name}: {'*' * 8}"
- subtitle = "Enter: copy | Shift+Enter: reveal password"
-
- if description:
- subtitle += f" | {description}"
-
- results.append(
- Result(
- title=title,
- subtitle=subtitle,
- icon_name="key",
- action=lambda n=name: self._copy_password_to_clipboard(n),
- relevance=get_relevance(name),
- plugin_name=self.display_name,
- data={
- "type": "password",
- "name": name,
- "keep_launcher_open": False,
- "alt_action": lambda n=name: self._toggle_password_visibility(
- n
- ),
- },
- )
- )
-
- return results
-
- def _add_password_action(
- self, name: str, password: str, description: str = "", update: bool = False
- ):
- """Action to add/update a password."""
- try:
- success = self.password_manager.add_password(name, password, description)
- if success:
- action_word = "updated" if update else "added"
- # Clear cache to force refresh
- self._results_cache.clear()
-
- # Send notification if available (non-blocking)
- try:
- subprocess.Popen(
- [
- "notify-send",
- "Password Manager",
- f"Password '{name}' {action_word} successfully",
- ]
- )
- except:
- pass
- else:
- print(f"Failed to add password '{name}'")
- except Exception as e:
- print(f"Error adding password: {e}")
-
- def _remove_password_action(self, name: str):
- """Action to remove a password."""
- try:
- success = self.password_manager.remove_password(name)
- if success:
- # Remove from revealed passwords if present
- self.revealed_passwords.pop(name, None)
-
- # Clear cache to force refresh
- self._results_cache.clear()
-
- # Send notification if available (non-blocking)
- try:
- subprocess.Popen(
- [
- "notify-send",
- "Password Manager",
- f"Password '{name}' removed successfully",
- ]
- )
- except:
- pass
- else:
- print(f"Failed to remove password '{name}'")
- except Exception as e:
- print(f"Error removing password: {e}")
-
- def _copy_password_to_clipboard(self, name: str):
- """Copy password to clipboard and reveal it temporarily."""
- try:
- password = self.password_manager.get_password(
- name, update_access_time=False
- )
- if password:
- # Copy to clipboard (use timeout to avoid hanging)
- try:
- subprocess.run(
- ["wl-copy"], input=password.encode(), check=True, timeout=2
- )
- except subprocess.SubprocessError:
- # Fall back to X11
- subprocess.run(
- ["xclip", "-selection", "clipboard"],
- input=password.encode(),
- check=True,
- timeout=2,
- )
-
- # Reveal password temporarily
- self.revealed_passwords[name] = password
-
- # Send notification if available (non-blocking)
- try:
- subprocess.Popen(
- [
- "notify-send",
- "Password Manager",
- f"Password for '{name}' copied to clipboard",
- ]
- )
- except:
- pass
-
- # Clear cache to force refresh
- self._results_cache.clear()
-
- else:
- print(f"Failed to retrieve password for '{name}'")
- except Exception as e:
- print(f"Error copying password: {e}")
-
- def _reveal_password(self, name: str):
- """Reveal password without copying to clipboard."""
- try:
- password = self.password_manager.get_password(
- name, update_access_time=False
- )
- if password:
- self.revealed_passwords[name] = password
- # Clear cache to force refresh with revealed password
- self._results_cache.clear()
- else:
- print(f"Failed to retrieve password for '{name}'")
- except Exception as e:
- print(f"Error revealing password: {e}")
-
- def _hide_password(self, name: str):
- """Hide revealed password."""
- self.revealed_passwords.pop(name, None)
-
- def _hide_all_passwords(self):
- """Hide all revealed passwords."""
- if self.revealed_passwords:
- self.revealed_passwords.clear()
- # Clear cache to force refresh with hidden passwords
- self._results_cache.clear()
-
- def _setup_launcher_hooks(self):
- """Setup hooks to monitor launcher state."""
- try:
- # Try to find the launcher instance
- import gc
-
- for obj in gc.get_objects():
- if (
- hasattr(obj, "__class__")
- and obj.__class__.__name__ == "Launcher"
- and hasattr(obj, "close_launcher")
- ):
- self._launcher_instance = obj
- # Store original close_launcher method
- self._original_close_launcher = obj.close_launcher
- # Replace with our wrapper
- obj.close_launcher = self._wrapped_close_launcher
- break
- except Exception as e:
- print(f"Warning: Could not setup launcher hooks: {e}")
-
- def _cleanup_launcher_hooks(self):
- """Cleanup launcher hooks."""
- try:
- if self._launcher_instance and hasattr(self, "_original_close_launcher"):
- # Restore original close_launcher method
- self._launcher_instance.close_launcher = self._original_close_launcher
- self._launcher_instance = None
- except Exception as e:
- print(f"Warning: Could not cleanup launcher hooks: {e}")
-
- def _wrapped_close_launcher(self):
- """Wrapper for launcher close that hides passwords."""
- # Hide all passwords when launcher closes
- self._hide_all_passwords()
- # Call original close_launcher method
- if hasattr(self, "_original_close_launcher"):
- self._original_close_launcher()
-
- def _toggle_password_visibility(self, name: str):
- """Toggle password visibility when Shift+Enter is pressed."""
- if name in self.revealed_passwords:
- # Hide password
- self.revealed_passwords.pop(name, None)
- self._results_cache.clear()
- else:
- # Reveal password
- try:
- password = self.password_manager.get_password(
- name, update_access_time=False
- )
- if password:
- self.revealed_passwords[name] = password
- self._results_cache.clear()
- else:
- print(f"Failed to retrieve password for '{name}'")
- except Exception as e:
- print(f"Error revealing password: {e}")
-
- # Force refresh of the launcher to show updated state
- self._force_launcher_refresh()
-
- def _force_launcher_refresh(self):
- """Force the launcher to refresh and show updated results."""
- try:
- if self._launcher_instance and hasattr(
- self._launcher_instance, "_perform_search"
- ):
- # Get current search text
- current_text = ""
- if hasattr(self._launcher_instance, "search_entry"):
- current_text = self._launcher_instance.search_entry.get_text()
-
- # Trigger a search to refresh results
- try:
- from gi.repository import GLib
-
- def refresh():
- self._launcher_instance._perform_search(current_text)
- return False
-
- GLib.timeout_add(50, refresh)
- except ImportError:
- # Fallback: direct call if GLib not available
- self._launcher_instance._perform_search(current_text)
- except Exception as e:
- print(f"Could not force launcher refresh: {e}")
diff --git a/modules/launcher/plugins/reminders.py b/modules/launcher/plugins/reminders.py
deleted file mode 100644
index ded8c20b..00000000
--- a/modules/launcher/plugins/reminders.py
+++ /dev/null
@@ -1,448 +0,0 @@
-import re
-import subprocess
-import threading
-from datetime import datetime, timedelta
-from typing import Dict, List, Optional
-
-from modules.launcher.plugin_base import PluginBase
-from modules.launcher.result import Result
-
-
-class Reminder:
- """
- Represents a single reminder with its timer and metadata.
- """
-
- def __init__(
- self,
- reminder_id: int,
- message: str,
- target_time: datetime,
- timer: threading.Timer,
- ):
- self.id = reminder_id
- self.message = message
- self.target_time = target_time
- self.timer = timer
- self.created_time = datetime.now()
-
- def cancel(self):
- """Cancel this reminder."""
- if self.timer:
- self.timer.cancel()
-
- def get_time_remaining(self) -> str:
- """Get formatted time remaining until reminder."""
- now = datetime.now()
- if self.target_time <= now:
- return "Overdue"
-
- delta = self.target_time - now
- total_seconds = int(delta.total_seconds())
-
- if total_seconds < 60:
- return f"{total_seconds}s"
- elif total_seconds < 3600:
- minutes = total_seconds // 60
- seconds = total_seconds % 60
- return f"{minutes}m {seconds}s" if seconds > 0 else f"{minutes}m"
- else:
- hours = total_seconds // 3600
- minutes = (total_seconds % 3600) // 60
- return f"{hours}h {minutes}m" if minutes > 0 else f"{hours}h"
-
- def get_target_time_str(self) -> str:
- """Get formatted target time."""
- return self.target_time.strftime("%H:%M")
-
-
-class RemindersPlugin(PluginBase):
- """
- Time-based reminders plugin for the launcher.
- """
-
- def __init__(self):
- super().__init__()
- self.display_name = "Reminders"
- self.description = "Set time-based reminders with notifications"
- self.reminders: Dict[int, Reminder] = {}
- self.next_id = 1
-
- # Regex patterns for time parsing
- self.time_patterns = {
- "relative_time": re.compile(r"^(\d+)([smhd])$"), # 5m, 30s, 2h, 1d
- "absolute_time": re.compile(r"^(\d{1,2}):(\d{2})$"), # 14:30, 9:15
- "relative_with_unit": re.compile(
- r"^(\d+)\s*(min|mins|minute|minutes|hour|hours|sec|seconds|day|days)$",
- re.IGNORECASE,
- ),
- }
-
- def initialize(self):
- """Initialize the reminders plugin."""
- self.set_triggers(["remind"])
-
- def cleanup(self):
- """Cleanup the reminders plugin."""
- # Cancel all active reminders
- for reminder in self.reminders.values():
- reminder.cancel()
- self.reminders.clear()
-
- def _send_notification(self, title: str, message: str):
- """Send a desktop notification using notify-send."""
- try:
- subprocess.run(
- ["notify-send", "-a", "Reminders", "-i", "alarm-clock", title, message],
- check=False,
- )
- except Exception as e:
- print(f"Failed to send notification: {e}")
-
- def _parse_time_input(self, time_str: str) -> Optional[datetime]:
- """
- Parse various time input formats and return target datetime.
-
- Supported formats:
- - 5m, 30s, 2h, 1d (relative time)
- - 14:30, 9:15 (absolute time today)
- - 5 minutes, 2 hours (relative with full unit names)
- """
- time_str = time_str.strip().lower()
-
- # Try relative time format (5m, 30s, 2h, 1d)
- match = self.time_patterns["relative_time"].match(time_str)
- if match:
- value, unit = match.groups()
- value = int(value)
-
- if unit == "s":
- delta = timedelta(seconds=value)
- elif unit == "m":
- delta = timedelta(minutes=value)
- elif unit == "h":
- delta = timedelta(hours=value)
- elif unit == "d":
- delta = timedelta(days=value)
- else:
- return None
-
- return datetime.now() + delta
-
- # Try absolute time format (14:30, 9:15)
- match = self.time_patterns["absolute_time"].match(time_str)
- if match:
- hour, minute = map(int, match.groups())
- if 0 <= hour <= 23 and 0 <= minute <= 59:
- target = datetime.now().replace(
- hour=hour, minute=minute, second=0, microsecond=0
- )
- # If the time has already passed today, schedule for tomorrow
- if target <= datetime.now():
- target += timedelta(days=1)
- return target
-
- # Try relative time with full unit names
- match = self.time_patterns["relative_with_unit"].match(time_str)
- if match:
- value, unit = match.groups()
- value = int(value)
- unit = unit.lower()
-
- if unit in ["sec", "seconds"]:
- delta = timedelta(seconds=value)
- elif unit in ["min", "mins", "minute", "minutes"]:
- delta = timedelta(minutes=value)
- elif unit in ["hour", "hours"]:
- delta = timedelta(hours=value)
- elif unit in ["day", "days"]:
- delta = timedelta(days=value)
- else:
- return None
-
- return datetime.now() + delta
-
- return None
-
- def _create_reminder(self, time_str: str, message: str) -> Optional[Reminder]:
- """Create a new reminder with the given time and message."""
- target_time = self._parse_time_input(time_str)
- if not target_time:
- return None
-
- # Calculate delay in seconds
- delay = (target_time - datetime.now()).total_seconds()
- if delay <= 0:
- return None
-
- # Create timer that will trigger the notification
- timer = threading.Timer(delay, self._trigger_reminder, [self.next_id, message])
-
- # Create reminder object
- reminder = Reminder(self.next_id, message, target_time, timer)
-
- # Store reminder and start timer
- self.reminders[self.next_id] = reminder
- timer.start()
-
- # Increment ID for next reminder
- self.next_id += 1
-
- return reminder
-
- def _trigger_reminder(self, reminder_id: int, message: str):
- """Trigger a reminder notification and remove it from active reminders."""
- # Send notification
- self._send_notification("โฐ Reminder", message)
-
- # Remove from active reminders
- if reminder_id in self.reminders:
- del self.reminders[reminder_id]
-
- def _cancel_reminder(self, reminder_id: Optional[int] = None) -> int:
- """Cancel a specific reminder or all reminders. Returns number of cancelled reminders."""
- if reminder_id is not None:
- if reminder_id in self.reminders:
- self.reminders[reminder_id].cancel()
- del self.reminders[reminder_id]
- return 1
- return 0
- else:
- # Cancel all reminders
- count = len(self.reminders)
- for reminder in self.reminders.values():
- reminder.cancel()
- self.reminders.clear()
- return count
-
- def _format_time_remaining(self, total_seconds: float) -> str:
- """Format time remaining in a human-readable way."""
- total_seconds = int(total_seconds)
-
- if total_seconds < 60:
- return f"{total_seconds}s"
- elif total_seconds < 3600:
- minutes = total_seconds // 60
- seconds = total_seconds % 60
- return f"{minutes}m {seconds}s" if seconds > 0 else f"{minutes}m"
- else:
- hours = total_seconds // 3600
- minutes = (total_seconds % 3600) // 60
- return f"{hours}h {minutes}m" if minutes > 0 else f"{hours}h"
-
- def _create_and_confirm_reminder(self, time_str: str, message: str):
- """Actually create the reminder when the user presses Enter."""
- reminder = self._create_reminder(time_str, message)
- if reminder:
- time_remaining = reminder.get_time_remaining()
- self._send_notification(
- "โ
Reminder Created", f"Reminder set for {time_remaining}: {message}"
- )
- else:
- self._send_notification(
- "โ Failed to Create Reminder", "The specified time may be in the past"
- )
-
- def query(self, query_string: str) -> List[Result]:
- """Process reminder queries."""
- results = []
- query = query_string.strip()
-
- if not query:
- # Show help and active reminders count
- active_count = len(self.reminders)
- results.append(
- Result(
- title="Reminders Help",
- subtitle=f"Active reminders: {
- active_count
- } | Usage: remind 5m Take a break",
- icon_name="alarm-timer",
- action=lambda: None,
- relevance=1.0,
- plugin_name=self.display_name,
- data={"type": "help"},
- )
- )
-
- # Show quick examples
- examples = [
- ("remind 5m Take a break", "Set 5 minute reminder"),
- ("remind 14:30 Meeting", "Set reminder for 2:30 PM"),
- ("remind list", "List active reminders"),
- ("remind cancel", "Cancel all reminders"),
- ]
-
- for example, desc in examples:
- results.append(
- Result(
- title=example,
- subtitle=desc,
- icon_name="alarm-timer",
- action=lambda: None,
- relevance=0.8,
- plugin_name=self.display_name,
- data={"type": "example"},
- )
- )
-
- return results
-
- # Handle list command
- if query.lower() in ["list", "ls", "show"]:
- if not self.reminders:
- results.append(
- Result(
- title="No Active Reminders",
- subtitle="Use 'remind 5m message' to set a reminder",
- icon_name="timer-off",
- action=lambda: None,
- relevance=1.0,
- plugin_name=self.display_name,
- data={"type": "empty_list"},
- )
- )
- else:
- for reminder in sorted(
- self.reminders.values(), key=lambda r: r.target_time
- ):
- time_remaining = reminder.get_time_remaining()
- target_time = reminder.get_target_time_str()
-
- results.append(
- Result(
- title=f"#{reminder.id}: {reminder.message}",
- subtitle=f"In {time_remaining} (at {target_time})",
- icon_name="alarm-timer",
- action=lambda rid=reminder.id: self._cancel_reminder(rid),
- relevance=1.0,
- plugin_name=self.display_name,
- data={"type": "active_reminder", "id": reminder.id},
- )
- )
-
- return results
-
- # Handle cancel command
- if query.lower().startswith("cancel") or query.lower().startswith("stop"):
- parts = query.split()
- if len(parts) == 1:
- # Cancel all reminders
- count = self._cancel_reminder()
- results.append(
- Result(
- title=f"Cancelled {count} Reminders",
- subtitle="All active reminders have been cancelled",
- icon_name="timer-off",
- action=lambda: None,
- relevance=1.0,
- plugin_name=self.display_name,
- data={"type": "cancel_all"},
- )
- )
- else:
- # Try to cancel specific reminder by ID
- try:
- reminder_id = int(parts[1])
- count = self._cancel_reminder(reminder_id)
- if count > 0:
- results.append(
- Result(
- title=f"Cancelled Reminder #{reminder_id}",
- subtitle="Reminder has been cancelled",
- icon_name="timer-off",
- action=lambda: None,
- relevance=1.0,
- plugin_name=self.display_name,
- data={"type": "cancel_specific"},
- )
- )
- else:
- results.append(
- Result(
- title="Reminder Not Found",
- subtitle=f"No reminder with ID #{reminder_id}",
- icon_name="alert",
- action=lambda: None,
- relevance=0.5,
- plugin_name=self.display_name,
- data={"type": "error"},
- )
- )
- except ValueError:
- results.append(
- Result(
- title="Invalid Reminder ID",
- subtitle="Please provide a valid reminder ID number",
- icon_name="alert",
- action=lambda: None,
- relevance=0.5,
- plugin_name=self.display_name,
- data={"type": "error"},
- )
- )
-
- return results
-
- # Handle setting new reminders
- parts = query.split(None, 1)
- if len(parts) >= 1:
- time_str = parts[0]
- message = parts[1] if len(parts) > 1 else "Reminder"
-
- # Try to parse the time (but don't create the reminder yet!)
- target_time = self._parse_time_input(time_str)
- if target_time:
- # Calculate delay and check if it's valid
- delay = (target_time - datetime.now()).total_seconds()
- if delay > 0:
- # Show what would happen, but don't create the reminder yet
- time_remaining = self._format_time_remaining(delay)
- target_time_str = target_time.strftime("%H:%M")
-
- results.append(
- Result(
- title=f"Set Reminder: {message}",
- subtitle=f"Will remind in {time_remaining} (at {
- target_time_str
- })",
- icon_name="alarm-timer",
- action=lambda ts=time_str, msg=message: self._create_and_confirm_reminder(
- ts, msg
- ),
- relevance=1.0,
- plugin_name=self.display_name,
- data={
- "type": "preview",
- "time_str": time_str,
- "message": message,
- },
- )
- )
- else:
- results.append(
- Result(
- title="Time is in the Past",
- subtitle="Please specify a future time",
- icon_name="alert",
- action=lambda: None,
- relevance=0.5,
- plugin_name=self.display_name,
- data={"type": "error"},
- )
- )
- else:
- # Invalid time format
- results.append(
- Result(
- title="Invalid Time Format",
- subtitle="Use formats like: 5m, 30s, 2h, 14:30, or '5 minutes'",
- icon_name="alert",
- action=lambda: None,
- relevance=0.5,
- plugin_name=self.display_name,
- data={"type": "error"},
- )
- )
-
- return results
diff --git a/modules/launcher/plugins/screencapture.py b/modules/launcher/plugins/screencapture.py
deleted file mode 100644
index 05253b6b..00000000
--- a/modules/launcher/plugins/screencapture.py
+++ /dev/null
@@ -1,687 +0,0 @@
-import subprocess
-from typing import List
-
-from fabric.utils import get_relative_path
-from modules.launcher.plugin_base import PluginBase
-from modules.launcher.result import Result
-
-
-class ScreencapturePlugin(PluginBase):
- """
- Plugin for taking screenshots and screen recordings using screen-capture.sh script.
- """
-
- def __init__(self):
- super().__init__()
- self.display_name = "Screencapture"
- self.description = "Take screenshots and screen recordings"
- self.script_path = get_relative_path("../../../scripts/screen-capture.sh")
-
- def initialize(self):
- """Initialize the screencapture plugin."""
- self.set_triggers(["sc"])
-
- def cleanup(self):
- """Cleanup the screencapture plugin."""
- pass
-
- def get_commands(self):
- """Return available commands for this plugin."""
- return {
- # Screenshot commands
- "screenshot": "Take a screenshot of the main display",
- "ss": "Take a screenshot of the main display",
- "screenshot-region": "Take a screenshot of selected region",
- "ss-region": "Take a screenshot of selected region",
- "screenshot-both": "Take a screenshot of both displays",
- "ss-both": "Take a screenshot of both displays",
- "screenshot-hdmi": "Take a screenshot of HDMI display",
- "ss-hdmi": "Take a screenshot of HDMI display",
- # Recording commands (with audio)
- "record": "Start recording main display with audio",
- "rec": "Start recording main display with audio",
- "record-region": "Start recording selected region with audio",
- "rec-region": "Start recording selected region with audio",
- "record-hdmi": "Start recording HDMI display with audio",
- "rec-hdmi": "Start recording HDMI display with audio",
- # Recording commands (no audio)
- "record-noaudio": "Start recording main display without audio",
- "rec-noaudio": "Start recording main display without audio",
- "record-noaudio-region": "Start recording selected region without audio",
- "rec-noaudio-region": "Start recording selected region without audio",
- "record-noaudio-hdmi": "Start recording HDMI display without audio",
- "rec-noaudio-hdmi": "Start recording HDMI display without audio",
- # High-quality recording commands
- "record-hq": "Start high-quality recording of main display",
- "rec-hq": "Start high-quality recording of main display",
- "record-hq-region": "Start high-quality recording of selected region",
- "rec-hq-region": "Start high-quality recording of selected region",
- "record-hq-hdmi": "Start high-quality recording of HDMI display",
- "rec-hq-hdmi": "Start high-quality recording of HDMI display",
- # GIF recording commands
- "record-gif": "Start GIF recording of main display",
- "rec-gif": "Start GIF recording of main display",
- "record-gif-region": "Start GIF recording of selected region",
- "rec-gif-region": "Start GIF recording of selected region",
- # Control commands
- "stop": "Stop current recording",
- # Conversion commands
- "convert-webm": "Convert latest MKV recording to WebM format",
- "conv-webm": "Convert latest MKV recording to WebM format",
- "convert-iphone": "Convert latest MKV recording for iPhone compatibility",
- "conv-iphone": "Convert latest MKV recording for iPhone compatibility",
- "convert-youtube": "Convert latest recording for YouTube upload",
- "conv-youtube": "Convert latest recording for YouTube upload",
- "convert-gif": "Convert latest recording to GIF format",
- "conv-gif": "Convert latest recording to GIF format",
- # Conversion commands with file input
- "convert-webm-file": "Convert specific MKV file to WebM format",
- "conv-webm-file": "Convert specific MKV file to WebM format",
- "convert-iphone-file": "Convert specific MKV file for iPhone compatibility",
- "conv-iphone-file": "Convert specific MKV file for iPhone compatibility",
- "convert-youtube-file": "Convert specific video file for YouTube upload",
- "conv-youtube-file": "Convert specific video file for YouTube upload",
- "convert-gif-file": "Convert specific video file to GIF format",
- "conv-gif-file": "Convert specific video file to GIF format",
- }
-
- def _run_script(self, *args):
- """Execute the screen-capture script with given arguments."""
- try:
- subprocess.Popen([self.script_path] + list(args))
- except Exception as e:
- print(f"Error running screen-capture script: {e}")
-
- def _run_script_with_file(self, format_type: str, file_path: str):
- """Execute the screen-capture script with file parameter."""
- try:
- subprocess.Popen([self.script_path, "convert", format_type, file_path])
- except Exception as e:
- print(f"Error running screen-capture script with file: {e}")
-
- def _is_recording(self):
- """Check if recording is currently active."""
- try:
- result = subprocess.run(
- [self.script_path, "status"], capture_output=True, text=True
- )
- return result.stdout.strip() == "true"
- except Exception:
- return False
-
- def _get_command_result(self, command: str) -> Result:
- """Get a Result object for a specific command."""
- # Import here to avoid circular imports
-
- command_info = {
- # Screenshot commands
- "screenshot": (
- "Take Screenshot (eDP-1)",
- "Capture the main display",
- "camera-photo-symbolic",
- lambda: self._run_script("screenshot", "eDP-1"),
- ),
- "ss": (
- "Take Screenshot (eDP-1)",
- "Capture the main display",
- "camera-photo-symbolic",
- lambda: self._run_script("screenshot", "eDP-1"),
- ),
- "screenshot-region": (
- "Take Region Screenshot",
- "Capture a selected region",
- "camera-photo-symbolic",
- lambda: self._run_script("screenshot", "selection"),
- ),
- "ss-region": (
- "Take Region Screenshot",
- "Capture a selected region",
- "camera-photo-symbolic",
- lambda: self._run_script("screenshot", "selection"),
- ),
- "screenshot-both": (
- "Take Screenshot (Both Displays)",
- "Capture both displays combined",
- "video-joined-displays-symbolic",
- lambda: self._run_script("screenshot", "both"),
- ),
- "ss-both": (
- "Take Screenshot (Both Displays)",
- "Capture both displays combined",
- "video-joined-displays-symbolic",
- lambda: self._run_script("screenshot", "both"),
- ),
- "screenshot-hdmi": (
- "Take Screenshot (HDMI-A-1)",
- "Capture HDMI display",
- "video-display-symbolic",
- lambda: self._run_script("screenshot", "HDMI-A-1"),
- ),
- "ss-hdmi": (
- "Take Screenshot (HDMI-A-1)",
- "Capture HDMI display",
- "video-display-symbolic",
- lambda: self._run_script("screenshot", "HDMI-A-1"),
- ),
- # Recording commands (with audio)
- "record": (
- "Start Recording (eDP-1)",
- "Record the main display with audio",
- "media-record-symbolic",
- lambda: self._run_script("record", "eDP-1"),
- ),
- "rec": (
- "Start Recording (eDP-1)",
- "Record the main display with audio",
- "media-record-symbolic",
- lambda: self._run_script("record", "eDP-1"),
- ),
- "record-region": (
- "Start Region Recording",
- "Record a selected region with audio",
- "media-record-symbolic",
- lambda: self._run_script("record", "selection"),
- ),
- "rec-region": (
- "Start Region Recording",
- "Record a selected region with audio",
- "media-record-symbolic",
- lambda: self._run_script("record", "selection"),
- ),
- "record-hdmi": (
- "Start Recording (HDMI-A-1)",
- "Record HDMI display with audio",
- "media-record-symbolic",
- lambda: self._run_script("record", "HDMI-A-1"),
- ),
- "rec-hdmi": (
- "Start Recording (HDMI-A-1)",
- "Record HDMI display with audio",
- "media-record-symbolic",
- lambda: self._run_script("record", "HDMI-A-1"),
- ),
- # Recording commands (no audio)
- "record-noaudio": (
- "Start Recording No Audio (eDP-1)",
- "Record the main display without audio",
- "media-record-symbolic",
- lambda: self._run_script("record-noaudio", "eDP-1"),
- ),
- "rec-noaudio": (
- "Start Recording No Audio (eDP-1)",
- "Record the main display without audio",
- "media-record-symbolic",
- lambda: self._run_script("record-noaudio", "eDP-1"),
- ),
- "record-noaudio-region": (
- "Start Region Recording No Audio",
- "Record a selected region without audio",
- "media-record-symbolic",
- lambda: self._run_script("record-noaudio", "selection"),
- ),
- "rec-noaudio-region": (
- "Start Region Recording No Audio",
- "Record a selected region without audio",
- "media-record-symbolic",
- lambda: self._run_script("record-noaudio", "selection"),
- ),
- "record-noaudio-hdmi": (
- "Start Recording No Audio (HDMI-A-1)",
- "Record HDMI display without audio",
- "media-record-symbolic",
- lambda: self._run_script("record-noaudio", "HDMI-A-1"),
- ),
- "rec-noaudio-hdmi": (
- "Start Recording No Audio (HDMI-A-1)",
- "Record HDMI display without audio",
- "media-record-symbolic",
- lambda: self._run_script("record-noaudio", "HDMI-A-1"),
- ),
- # High-quality recording commands
- "record-hq": (
- "Start HQ Recording (eDP-1)",
- "High-quality recording for YouTube",
- "media-record-symbolic",
- lambda: self._run_script("record-hq", "eDP-1"),
- ),
- "rec-hq": (
- "Start HQ Recording (eDP-1)",
- "High-quality recording for YouTube",
- "media-record-symbolic",
- lambda: self._run_script("record-hq", "eDP-1"),
- ),
- "record-hq-region": (
- "Start HQ Region Recording",
- "High-quality region recording",
- "media-record-symbolic",
- lambda: self._run_script("record-hq", "selection"),
- ),
- "rec-hq-region": (
- "Start HQ Region Recording",
- "High-quality region recording",
- "media-record-symbolic",
- lambda: self._run_script("record-hq", "selection"),
- ),
- "record-hq-hdmi": (
- "Start HQ Recording (HDMI-A-1)",
- "High-quality HDMI recording",
- "media-record-symbolic",
- lambda: self._run_script("record-hq", "HDMI-A-1"),
- ),
- "rec-hq-hdmi": (
- "Start HQ Recording (HDMI-A-1)",
- "High-quality HDMI recording",
- "media-record-symbolic",
- lambda: self._run_script("record-hq", "HDMI-A-1"),
- ),
- # GIF recording commands
- "record-gif": (
- "Start GIF Recording (eDP-1)",
- "Record as optimized GIF",
- "media-record-symbolic",
- lambda: self._run_script("record-gif", "eDP-1"),
- ),
- "rec-gif": (
- "Start GIF Recording (eDP-1)",
- "Record as optimized GIF",
- "media-record-symbolic",
- lambda: self._run_script("record-gif", "eDP-1"),
- ),
- "record-gif-region": (
- "Start GIF Region Recording",
- "Record selected region as GIF",
- "media-record-symbolic",
- lambda: self._run_script("record-gif", "selection"),
- ),
- "rec-gif-region": (
- "Start GIF Region Recording",
- "Record selected region as GIF",
- "media-record-symbolic",
- lambda: self._run_script("record-gif", "selection"),
- ),
- # Control commands
- "stop": (
- "Stop Recording",
- "Stop the current screen recording",
- "media-playback-stop-symbolic",
- lambda: self._run_script("record", "stop"),
- ),
- # Conversion commands
- "convert-webm": (
- "Convert Latest to WebM",
- "Convert latest MKV recording to WebM format",
- "video-x-generic-symbolic",
- lambda: self._run_script("convert", "webm"),
- ),
- "conv-webm": (
- "Convert Latest to WebM",
- "Convert latest MKV recording to WebM format",
- "video-x-generic-symbolic",
- lambda: self._run_script("convert", "webm"),
- ),
- "convert-iphone": (
- "Convert Latest for iPhone",
- "Convert latest MKV recording for iPhone compatibility",
- "video-x-generic-symbolic",
- lambda: self._run_script("convert", "iphone"),
- ),
- "conv-iphone": (
- "Convert Latest for iPhone",
- "Convert latest MKV recording for iPhone compatibility",
- "video-x-generic-symbolic",
- lambda: self._run_script("convert", "iphone"),
- ),
- "convert-youtube": (
- "Convert Latest for YouTube",
- "Convert latest recording for YouTube upload",
- "video-x-generic-symbolic",
- lambda: self._run_script("convert", "youtube"),
- ),
- "conv-youtube": (
- "Convert Latest for YouTube",
- "Convert latest recording for YouTube upload",
- "video-x-generic-symbolic",
- lambda: self._run_script("convert", "youtube"),
- ),
- "convert-gif": (
- "Convert Latest to GIF",
- "Convert latest recording to GIF format",
- "image-x-generic-symbolic",
- lambda: self._run_script("convert", "gif"),
- ),
- "conv-gif": (
- "Convert Latest to GIF",
- "Convert latest recording to GIF format",
- "image-x-generic-symbolic",
- lambda: self._run_script("convert", "gif"),
- ),
- # File-based conversion commands (these will be handled specially)
- "convert-webm-file": (
- "Convert File to WebM",
- "Type filename after command (e.g., convert-webm-file video.mkv)",
- "video-x-generic-symbolic",
- None, # Will be handled in query method
- ),
- "conv-webm-file": (
- "Convert File to WebM",
- "Type filename after command (e.g., conv-webm-file video.mkv)",
- "video-x-generic-symbolic",
- None, # Will be handled in query method
- ),
- "convert-iphone-file": (
- "Convert File for iPhone",
- "Type filename after command (e.g., convert-iphone-file video.mkv)",
- "video-x-generic-symbolic",
- None, # Will be handled in query method
- ),
- "conv-iphone-file": (
- "Convert File for iPhone",
- "Type filename after command (e.g., conv-iphone-file video.mkv)",
- "video-x-generic-symbolic",
- None, # Will be handled in query method
- ),
- "convert-youtube-file": (
- "Convert File for YouTube",
- "Type filename after command (e.g., convert-youtube-file video.mkv)",
- "video-x-generic-symbolic",
- None, # Will be handled in query method
- ),
- "conv-youtube-file": (
- "Convert File for YouTube",
- "Type filename after command (e.g., conv-youtube-file video.mkv)",
- "video-x-generic-symbolic",
- None, # Will be handled in query method
- ),
- "convert-gif-file": (
- "Convert File to GIF",
- "Type filename after command (e.g., convert-gif-file video.mkv)",
- "image-x-generic-symbolic",
- None, # Will be handled in query method
- ),
- "conv-gif-file": (
- "Convert File to GIF",
- "Type filename after command (e.g., conv-gif-file video.mkv)",
- "image-x-generic-symbolic",
- None, # Will be handled in query method
- ),
- }
-
- if command in command_info:
- title, subtitle, icon, action = command_info[command]
- if action is not None: # Regular command
- return Result(
- title=title,
- subtitle=subtitle,
- icon_name=icon,
- action=action,
- relevance=1.0,
- plugin_name=self.display_name,
- )
- else: # File-based command, show instruction
- return Result(
- title=title,
- subtitle=subtitle,
- icon_name=icon,
- action=lambda: None, # No action for instruction
- relevance=1.0,
- plugin_name=self.display_name,
- )
-
- return None
-
- def query(self, query_string: str) -> List[Result]:
- """Search for screencapture actions based on query."""
- # Import here to avoid circular imports
-
- # Clean the query string
- query = query_string.strip().lower()
-
- results = []
-
- # Check for file-based conversion commands with parameters
- file_conversion_commands = {
- "convert-webm-file": "webm",
- "conv-webm-file": "webm",
- "convert-iphone-file": "iphone",
- "conv-iphone-file": "iphone",
- "convert-youtube-file": "youtube",
- "conv-youtube-file": "youtube",
- "convert-gif-file": "gif",
- "conv-gif-file": "gif",
- }
-
- # Parse query for file-based commands
- query_parts = query.split()
- if len(query_parts) >= 2:
- command = query_parts[0]
- file_param = " ".join(query_parts[1:])
-
- if command in file_conversion_commands:
- format_type = file_conversion_commands[command]
- return [
- Result(
- title=f"Convert {file_param} to {format_type.upper()}",
- subtitle=f"Convert specified file to {format_type} format",
- icon_name=(
- "video-x-generic-symbolic"
- if format_type != "gif"
- else "image-x-generic-symbolic"
- ),
- action=lambda fp=file_param, ft=format_type: self._run_script_with_file(
- ft, fp
- ),
- relevance=1.0,
- plugin_name=self.display_name,
- )
- ]
-
- # Check if query matches a command and return it as a result
- command_result = self._get_command_result(query)
- if command_result:
- return [command_result]
-
- # Check recording status
- is_recording = self._is_recording()
-
- # If recording is active, show stop button first with highest relevance
- if is_recording:
- results.append(
- Result(
- title="Stop Recording",
- subtitle="Stop the current screen recording",
- icon_name="media-playback-stop-symbolic",
- action=lambda: self._run_script("record", "stop"),
- relevance=2.0, # Highest relevance to appear at top
- plugin_name=self.display_name,
- )
- )
-
- # Screenshot actions
- results.extend(
- [
- Result(
- title="Take Screenshot",
- subtitle="Capture the entire screen (eDP-1)",
- icon_name="camera-photo-symbolic",
- action=lambda: self._run_script("screenshot", "eDP-1"),
- relevance=1.0,
- plugin_name=self.display_name,
- ),
- Result(
- title="Take Region Screenshot",
- subtitle="Capture a selected region",
- icon_name="camera-photo-symbolic",
- action=lambda: self._run_script("screenshot", "selection"),
- relevance=0.9,
- plugin_name=self.display_name,
- ),
- Result(
- title="Take Screenshot (Both Displays)",
- subtitle="Capture both displays combined",
- icon_name="video-joined-displays-symbolic",
- action=lambda: self._run_script("screenshot", "both"),
- relevance=0.8,
- plugin_name=self.display_name,
- ),
- Result(
- title="Take Screenshot (HDMI-A-1)",
- subtitle="Capture HDMI display",
- icon_name="video-display-symbolic",
- action=lambda: self._run_script("screenshot", "HDMI-A-1"),
- relevance=0.7,
- plugin_name=self.display_name,
- ),
- ]
- )
-
- # Standard recording actions
- results.extend(
- [
- Result(
- title="Start Recording (eDP-1)",
- subtitle="Record the main display with audio",
- icon_name="media-record-symbolic",
- action=lambda: self._run_script("record", "eDP-1"),
- relevance=0.7,
- plugin_name=self.display_name,
- ),
- Result(
- title="Start Region Recording",
- subtitle="Record a selected region",
- icon_name="media-record-symbolic",
- action=lambda: self._run_script("record", "selection"),
- relevance=0.6,
- plugin_name=self.display_name,
- ),
- Result(
- title="Start Recording (HDMI-A-1)",
- subtitle="Record HDMI display with audio",
- icon_name="media-record-symbolic",
- action=lambda: self._run_script("record", "HDMI-A-1"),
- relevance=0.5,
- plugin_name=self.display_name,
- ),
- ]
- )
-
- # No-audio recording actions
- results.extend(
- [
- Result(
- title="Start Recording No Audio (eDP-1)",
- subtitle="Record the main display without audio",
- icon_name="media-record-symbolic",
- action=lambda: self._run_script("record-noaudio", "eDP-1"),
- relevance=0.65,
- plugin_name=self.display_name,
- ),
- Result(
- title="Start Region Recording No Audio",
- subtitle="Record a selected region without audio",
- icon_name="media-record-symbolic",
- action=lambda: self._run_script("record-noaudio", "selection"),
- relevance=0.55,
- plugin_name=self.display_name,
- ),
- Result(
- title="Start Recording No Audio (HDMI-A-1)",
- subtitle="Record HDMI display without audio",
- icon_name="media-record-symbolic",
- action=lambda: self._run_script("record-noaudio", "HDMI-A-1"),
- relevance=0.45,
- plugin_name=self.display_name,
- ),
- ]
- )
-
- # High-quality recording actions
- results.extend(
- [
- Result(
- title="Start HQ Recording (eDP-1)",
- subtitle="High-quality recording for YouTube",
- icon_name="media-record-symbolic",
- action=lambda: self._run_script("record-hq", "eDP-1"),
- relevance=0.4,
- plugin_name=self.display_name,
- ),
- Result(
- title="Start HQ Region Recording",
- subtitle="High-quality region recording",
- icon_name="media-record-symbolic",
- action=lambda: self._run_script("record-hq", "selection"),
- relevance=0.3,
- plugin_name=self.display_name,
- ),
- Result(
- title="Start HQ Recording (HDMI-A-1)",
- subtitle="High-quality HDMI recording",
- icon_name="media-record-symbolic",
- action=lambda: self._run_script("record-hq", "HDMI-A-1"),
- relevance=0.2,
- plugin_name=self.display_name,
- ),
- ]
- )
-
- # GIF recording actions
- results.extend(
- [
- Result(
- title="Start GIF Recording (eDP-1)",
- subtitle="Record as optimized GIF",
- icon_name="media-record-symbolic",
- action=lambda: self._run_script("record-gif", "eDP-1"),
- relevance=0.1,
- plugin_name=self.display_name,
- ),
- Result(
- title="Start GIF Region Recording",
- subtitle="Record selected region as GIF",
- icon_name="media-record-symbolic",
- action=lambda: self._run_script("record-gif", "selection"),
- relevance=0.05,
- plugin_name=self.display_name,
- ),
- ]
- )
-
- # Conversion actions
- results.extend(
- [
- Result(
- title="Convert Latest to WebM",
- subtitle="Convert latest MKV recording to WebM format",
- icon_name="video-x-generic-symbolic",
- action=lambda: self._run_script("convert", "webm"),
- relevance=0.01,
- plugin_name=self.display_name,
- ),
- Result(
- title="Convert Latest for iPhone",
- subtitle="Convert latest MKV recording for iPhone compatibility",
- icon_name="video-x-generic-symbolic",
- action=lambda: self._run_script("convert", "iphone"),
- relevance=0.01,
- plugin_name=self.display_name,
- ),
- Result(
- title="Convert Latest for YouTube",
- subtitle="Convert latest recording for YouTube upload",
- icon_name="video-x-generic-symbolic",
- action=lambda: self._run_script("convert", "youtube"),
- relevance=0.01,
- plugin_name=self.display_name,
- ),
- Result(
- title="Convert Latest to GIF",
- subtitle="Convert latest recording to GIF format",
- icon_name="image-x-generic-symbolic",
- action=lambda: self._run_script("convert", "gif"),
- relevance=0.01,
- plugin_name=self.display_name,
- ),
- ]
- )
-
- return results
diff --git a/modules/launcher/plugins/system.py b/modules/launcher/plugins/system.py
deleted file mode 100644
index bc7dba09..00000000
--- a/modules/launcher/plugins/system.py
+++ /dev/null
@@ -1,242 +0,0 @@
-import json
-import os
-import shlex
-import threading
-import time
-from typing import List, Set, Union
-
-import config.data as data
-from fabric.utils import exec_shell_command_async
-from modules.launcher.plugin_base import PluginBase
-from modules.launcher.result import Result
-
-
-class SystemPlugin(PluginBase):
- """
- Plugin for system commands and actions.
- """
-
- def __init__(self):
- super().__init__()
- self.display_name = "System"
- self.description = "System commands and actions"
-
- # JSON cache file for system binaries
- self.bin_cache_file = os.path.join(data.CACHE_DIR, "system_binaries.json")
-
- # In-memory cache for system binaries
- self._bin_cache: Set[str] = set()
- self._last_bin_update = 0
- self._bin_update_interval = 300 # 5 minutes
-
- # Background cache building
- self._cache_building = False
- self._cache_thread = None
-
- def initialize(self):
- """Initialize the system plugin."""
- self.set_triggers(["bin"])
- self._load_bin_cache()
- self._start_background_cache_update()
-
- def cleanup(self):
- """Cleanup the system plugin."""
- self._bin_cache.clear()
- if self._cache_thread and self._cache_thread.is_alive():
- # Note: We don't join the thread to avoid blocking cleanup
- pass
-
- def _load_bin_cache(self):
- """Load binary cache from JSON file."""
- try:
- if os.path.exists(self.bin_cache_file):
- with open(self.bin_cache_file, "r", encoding="utf-8") as f:
- cache_data = json.load(f)
- self._bin_cache = set(cache_data.get("binaries", []))
- self._last_bin_update = cache_data.get("last_update", 0)
- else:
- print(
- "SystemPlugin: No cache file found, will build cache in background"
- )
- except Exception as e:
- print(f"SystemPlugin: Error loading binary cache: {e}")
- self._bin_cache = set()
- self._last_bin_update = 0
-
- def _save_bin_cache(self):
- """Save binary cache to JSON file."""
- try:
- # Ensure the cache directory exists
- os.makedirs(data.CACHE_DIR, exist_ok=True)
-
- cache_data = {
- "binaries": sorted(list(self._bin_cache)),
- "last_update": self._last_bin_update,
- "cache_version": "1.0",
- }
-
- with open(self.bin_cache_file, "w", encoding="utf-8") as f:
- json.dump(cache_data, f, indent=2)
- except Exception as e:
- print(f"SystemPlugin: Error saving binary cache: {e}")
-
- def _start_background_cache_update(self):
- """Start background thread to update binary cache."""
- current_time = time.time()
-
- # Check if cache needs updating
- if (
- current_time - self._last_bin_update > self._bin_update_interval
- or not self._bin_cache
- ):
- if not self._cache_building:
- self._cache_building = True
- self._cache_thread = threading.Thread(
- target=self._build_bin_cache_background, daemon=True
- )
- self._cache_thread.start()
-
- def _build_bin_cache_background(self):
- """Build binary cache in background thread."""
- try:
- new_cache = set()
- processed_paths = set() # Avoid duplicate paths
-
- for path in os.environ["PATH"].split(":"):
- # Skip empty paths and duplicates
- if not path or path in processed_paths:
- continue
- processed_paths.add(path)
-
- if os.path.exists(path) and os.path.isdir(path):
- try:
- # Use os.scandir for better performance than os.listdir
- with os.scandir(path) as entries:
- for entry in entries:
- if entry.is_file(follow_symlinks=False) and os.access(
- entry.path, os.X_OK
- ):
- new_cache.add(entry.name)
- except (PermissionError, FileNotFoundError, OSError):
- continue
-
- # Update cache atomically
- self._bin_cache = new_cache
- self._last_bin_update = time.time()
-
- # Save to disk
- self._save_bin_cache()
-
- except Exception as e:
- print(f"SystemPlugin: Error building binary cache: {e}")
- finally:
- self._cache_building = False
-
- def query(self, query_string: str) -> List[Result]:
- """Search for system commands matching the query."""
- query = query_string.strip()
-
- if not query:
- return []
-
- results = []
-
- # Parse the query to extract binary name and arguments
- query_parts = query.split()
- if not query_parts:
- return []
-
- binary_query = query_parts[0].lower()
- full_command = query # Keep the original case and spacing
-
- # Check system binaries
- # Start background update if needed (non-blocking) - but only if cache is empty or very old
- if not self._bin_cache or (
- time.time() - self._last_bin_update > self._bin_update_interval
- ):
- self._start_background_cache_update()
-
- # Optimize search with early termination and result limiting
- exact_matches = []
- prefix_matches = []
- partial_matches = []
- max_results = 20 # Limit total results for performance
-
- for binary in self._bin_cache:
- # Pre-compute lowercase once
- binary_lower = binary.lower()
-
- # Skip if no match at all
- if binary_query not in binary_lower:
- continue
-
- # Categorize matches for better sorting
- if binary_lower == binary_query:
- # Exact match - highest priority
- display_command = full_command
- command_to_execute = full_command
- relevance = 1.0
- exact_matches.append(
- (binary, display_command, command_to_execute, relevance)
- )
- elif binary_lower.startswith(binary_query):
- # Prefix match - high priority
- display_command = binary
- command_to_execute = binary
- relevance = 0.9
- prefix_matches.append(
- (binary, display_command, command_to_execute, relevance)
- )
- else:
- # Partial match - lower priority
- display_command = binary
- command_to_execute = binary
- relevance = 0.7
- partial_matches.append(
- (binary, display_command, command_to_execute, relevance)
- )
-
- # Early termination if we have enough good matches
- if len(exact_matches) + len(prefix_matches) >= max_results:
- break
-
- # Combine results in priority order
- all_matches = exact_matches + prefix_matches + partial_matches
-
- # Convert to Result objects (limit to max_results)
- for binary, display_command, command_to_execute, relevance in all_matches[
- :max_results
- ]:
- result = Result(
- title=display_command,
- subtitle=f"Execute: {display_command}",
- icon_name="terminal",
- action=self._create_action(command_to_execute),
- relevance=relevance,
- plugin_name=self.display_name,
- data={"command": command_to_execute, "id": binary},
- )
- results.append(result)
-
- return results # Already sorted by priority
-
- def _create_action(self, command: Union[str, List[str]]):
- """Create an action function for the given command."""
-
- def action():
- self._execute_command(command)
-
- return action
-
- def _execute_command(self, command: Union[str, List[str]]):
- """Execute a system command."""
- try:
- if isinstance(command, str):
- # Handle string commands with arguments - split into list for proper execution
- command_list = shlex.split(command)
- exec_shell_command_async(command_list)
- else:
- # Handle list commands (backward compatibility)
- exec_shell_command_async(command)
- except Exception as e:
- print(f"SystemPlugin: Error executing command '{command}': {e}")
diff --git a/modules/launcher/plugins/tmux.py b/modules/launcher/plugins/tmux.py
deleted file mode 100644
index 5c93fadb..00000000
--- a/modules/launcher/plugins/tmux.py
+++ /dev/null
@@ -1,334 +0,0 @@
-import subprocess
-import threading
-import time
-from typing import List
-
-import config.data as data
-from fabric.utils import exec_shell_command_async
-from modules.launcher.plugin_base import PluginBase
-from modules.launcher.result import Result
-
-
-class TmuxPlugin(PluginBase):
- """
- Plugin for managing tmux sessions through the launcher.
- """
-
- def __init__(self):
- super().__init__()
- self.display_name = "Tmux Manager"
- self.description = "Manage tmux sessions - create, attach, rename, and kill"
-
- # Cache for sessions to avoid repeated subprocess calls
- self._sessions_cache = []
- self._last_cache_update = 0
- # Cache sessions for 10 seconds (increased from 2)
- self._cache_ttl = 10
-
- # Threading for auto-refresh - only when actively used
- self.refresh_thread = None
- self.stop_refresh = threading.Event()
- self._last_query_time = 0
- # Stop refreshing after 30 seconds of inactivity
- self._active_refresh_timeout = 30
-
- def initialize(self):
- """Initialize the tmux plugin."""
- self.set_triggers(["tmux"])
- # Don't start refresh thread immediately - start it when first used
-
- def cleanup(self):
- """Cleanup the tmux plugin."""
- self.stop_refresh.set()
- if self.refresh_thread:
- self.refresh_thread.join(timeout=1)
- self._sessions_cache.clear()
-
- def _start_refresh_thread(self):
- """Start background thread to refresh session cache."""
- if not self.refresh_thread or not self.refresh_thread.is_alive():
- self.refresh_thread = threading.Thread(
- target=self._refresh_sessions_background, daemon=True
- )
- self.refresh_thread.start()
-
- def _refresh_sessions_background(self):
- """Background thread to refresh sessions cache only when actively used."""
- while not self.stop_refresh.is_set():
- try:
- current_time = time.time()
-
- # Stop refreshing if plugin hasn't been used recently
- if current_time - self._last_query_time > self._active_refresh_timeout:
- print("TmuxPlugin: Stopping background refresh due to inactivity")
- break
-
- if current_time - self._last_cache_update > self._cache_ttl:
- self._sessions_cache = self._get_tmux_sessions()
- self._last_cache_update = current_time
-
- self.stop_refresh.wait(5)
- except Exception as e:
- print(f"TmuxPlugin: Error in refresh thread: {e}")
- self.stop_refresh.wait(10) # Wait longer on error
-
- def _get_tmux_sessions(self):
- """Get list of tmux sessions."""
- try:
- result = subprocess.run(
- ["tmux", "list-sessions", "-F", "#{session_name}"],
- capture_output=True,
- text=True,
- timeout=5,
- )
- if result.returncode == 0:
- return [
- s.strip() for s in result.stdout.strip().split("\n") if s.strip()
- ]
- return []
- except (
- subprocess.TimeoutExpired,
- subprocess.CalledProcessError,
- FileNotFoundError,
- ) as e:
- print(f"TmuxPlugin: Error getting tmux sessions: {e}")
- return []
-
- def query(self, query_string: str) -> List[Result]:
- """Process tmux queries."""
- query = query_string.strip().lower()
- results = []
-
- # Track usage and start refresh thread if needed
- current_time = time.time()
- self._last_query_time = current_time
-
- # Start refresh thread if not running and plugin is being used
- if not self.refresh_thread or not self.refresh_thread.is_alive():
- self._start_refresh_thread()
-
- # Get current sessions (use cache if recent)
- if current_time - self._last_cache_update > self._cache_ttl:
- self._sessions_cache = self._get_tmux_sessions()
- self._last_cache_update = current_time
-
- sessions = self._sessions_cache
-
- # Handle specific commands
- if query.startswith("new ") or query.startswith("create "):
- session_name = query.split(" ", 1)[1].strip() if " " in query else ""
- results.append(self._create_new_session_result(session_name))
-
- elif query.startswith("kill ") or query.startswith("delete "):
- session_name = query.split(" ", 1)[1].strip() if " " in query else ""
- if session_name:
- matching_sessions = [
- s for s in sessions if session_name.lower() in s.lower()
- ]
- for session in matching_sessions:
- results.append(self._create_kill_session_result(session))
-
- elif query.startswith("rename "):
- parts = query.split(" ", 2)
- if len(parts) >= 3:
- old_name, new_name = parts[1], parts[2]
- if old_name in sessions:
- results.append(
- self._create_rename_session_result(old_name, new_name)
- )
-
- else:
- # Show existing sessions for attachment
- if sessions:
- # Filter sessions based on query
- if query:
- filtered_sessions = [s for s in sessions if query in s.lower()]
- else:
- filtered_sessions = sessions
-
- for session in filtered_sessions:
- results.append(self._create_attach_session_result(session))
-
- # Always show option to create new session
- if not query or "new" in query or "create" in query:
- results.append(
- self._create_new_session_result(
- query
- if query and not any(cmd in query for cmd in ["new", "create"])
- else ""
- )
- )
-
- return results
-
- def _create_attach_session_result(self, session_name: str) -> Result:
- """Create result for attaching to a session."""
- return Result(
- title=f"Attach to '{session_name}'",
- subtitle=f"Connect to tmux session: {session_name}",
- icon_name="terminal",
- action=lambda: self._attach_to_session(session_name),
- relevance=0.9,
- data={"type": "attach", "session": session_name},
- )
-
- def _create_new_session_result(self, session_name: str = "") -> Result:
- """Create result for creating a new session."""
- display_name = session_name if session_name else "new session"
- return Result(
- title=f"Create '{display_name}'",
- subtitle=f"Create new tmux session{
- f': {session_name}' if session_name else ''
- }",
- icon_name="plus",
- action=lambda: self._create_session(session_name),
- relevance=0.8,
- data={"type": "create", "session": session_name},
- )
-
- def _create_kill_session_result(self, session_name: str) -> Result:
- """Create result for killing a session."""
- return Result(
- title=f"Kill '{session_name}'",
- subtitle=f"Terminate tmux session: {session_name}",
- icon_name="trash",
- action=lambda: self._kill_session(session_name),
- relevance=0.7,
- data={"type": "kill", "session": session_name},
- )
-
- def _create_rename_session_result(self, old_name: str, new_name: str) -> Result:
- """Create result for renaming a session."""
- return Result(
- title=f"Rename '{old_name}' to '{new_name}'",
- subtitle=f"Rename tmux session from {old_name} to {new_name}",
- icon_name="config",
- action=lambda: self._rename_session(old_name, new_name),
- relevance=0.6,
- data={"type": "rename", "old_session": old_name, "new_session": new_name},
- )
-
- def _attach_to_session(self, session_name: str):
- """Attach to an existing tmux session."""
- try:
- terminal_cmd = self._get_terminal_command(
- f"tmux attach-session -t '{session_name}'"
- )
- exec_shell_command_async(terminal_cmd)
- print(f"TmuxPlugin: Attaching to session '{session_name}'")
- except Exception as e:
- print(f"TmuxPlugin: Error attaching to session '{session_name}': {e}")
-
- def _create_session(self, session_name: str = ""):
- """Create a new tmux session."""
- try:
- if not session_name:
- # Generate a default name
- existing_sessions = self._get_tmux_sessions()
- counter = 0
- while str(counter) in existing_sessions:
- counter += 1
- session_name = str(counter)
-
- # Clean the session name
- clean_name = session_name.strip().replace(" ", "_")
-
- # Create session
- subprocess.run(
- ["tmux", "new-session", "-d", "-s", clean_name], check=True, timeout=10
- )
-
- # Launch terminal and attach
- terminal_cmd = self._get_terminal_command(
- f"tmux attach-session -t '{clean_name}'"
- )
- exec_shell_command_async(terminal_cmd)
-
- # Refresh cache
- self._sessions_cache = self._get_tmux_sessions()
- self._last_cache_update = time.time()
-
- print(f"TmuxPlugin: Created and attached to session '{clean_name}'")
- except Exception as e:
- print(f"TmuxPlugin: Error creating session '{session_name}': {e}")
-
- def _kill_session(self, session_name: str):
- """Kill a tmux session."""
- try:
- subprocess.run(
- ["tmux", "kill-session", "-t", session_name], check=True, timeout=10
- )
-
- # Refresh cache
- self._sessions_cache = self._get_tmux_sessions()
- self._last_cache_update = time.time()
-
- print(f"TmuxPlugin: Killed session '{session_name}'")
- except Exception as e:
- print(f"TmuxPlugin: Error killing session '{session_name}': {e}")
-
- def _rename_session(self, old_name: str, new_name: str):
- """Rename a tmux session."""
- try:
- clean_name = new_name.strip().replace(" ", "_")
- subprocess.run(
- ["tmux", "rename-session", "-t", old_name, clean_name],
- check=True,
- timeout=10,
- )
-
- # Refresh cache
- self._sessions_cache = self._get_tmux_sessions()
- self._last_cache_update = time.time()
-
- print(f"TmuxPlugin: Renamed session '{old_name}' to '{clean_name}'")
- except Exception as e:
- print(
- f"TmuxPlugin: Error renaming session '{old_name}' to '{new_name}': {e}"
- )
-
- def _get_terminal_command(self, cmd: str) -> str:
- """Get terminal command based on configured terminal or available terminals."""
- # First try to use the configured terminal command
- if hasattr(data, "TERMINAL_COMMAND") and data.TERMINAL_COMMAND:
- parts = data.TERMINAL_COMMAND.split()
- terminal = parts[0]
-
- try:
- # Check if the configured terminal is available
- subprocess.run(
- ["which", terminal],
- check=True,
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- )
- return f"{data.TERMINAL_COMMAND} {cmd}"
- except subprocess.CalledProcessError:
- # If configured terminal is not available, fall back to defaults
- pass
-
- # Fallback to checking available terminals
- terminals = [
- ("kitty", f"kitty -e {cmd}"),
- ("alacritty", f"alacritty -e {cmd}"),
- ("foot", f"foot {cmd}"),
- ("gnome-terminal", f"gnome-terminal -- {cmd}"),
- ("konsole", f"konsole -e {cmd}"),
- ("xfce4-terminal", f"xfce4-terminal -e '{cmd}'"),
- ]
-
- for term, term_cmd in terminals:
- try:
- # Check if terminal is available
- subprocess.run(
- ["which", term],
- check=True,
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- )
- return term_cmd
- except subprocess.CalledProcessError:
- continue
-
- # Default fallback
- return f"kitty -e {cmd}"
diff --git a/modules/osd.py b/modules/osd.py
deleted file mode 100644
index 6b27aaa7..00000000
--- a/modules/osd.py
+++ /dev/null
@@ -1,467 +0,0 @@
-import math
-import time
-from typing import ClassVar, Literal
-
-from gi.repository import GLib, GObject
-
-from fabric.audio import Audio
-from fabric.utils.helpers import get_relative_path
-from fabric.widgets.box import Box
-from fabric.widgets.revealer import Revealer
-from fabric.widgets.scale import Scale, ScaleMark
-from fabric.widgets.svg import Svg
-from services.brightness import Brightness
-from utils.animator import Animator
-from widgets.wayland import WaylandWindow as Window
-
-
-class AnimatedScale(Scale):
- def __init__(self, **kwargs):
- super().__init__(**kwargs)
- self.animator = None
-
- def animate_value(self, value: float):
- if not self.animator:
- self.animator = Animator(
- bezier_curve=(0.34, 1.56, 0.64, 1.0),
- duration=0.8,
- min_value=self.min_value,
- max_value=self.value,
- tick_widget=self,
- notify_value=lambda p, *_: self.set_value(p.value),
- )
- self.animator.pause()
- self.animator.min_value = self.value
- self.animator.max_value = value
- self.animator.play()
-
-
-class BrightnessOSDContainer(Box):
- def __init__(self, **kwargs):
- super().__init__(**kwargs, orientation="v", spacing=3, name="osd")
- self.brightness_service = Brightness.get_initial()
- self.scale = AnimatedScale(
- marks=(ScaleMark(value=i) for i in range(0, 101, 10)),
- value=70,
- min_value=0,
- max_value=100,
- increments=(1, 1),
- orientation="h",
- )
- self.osd_window_image = Svg(
- get_relative_path("../config/assets/icons/brightness/brightness.svg"),
- size=(100, 150),
- name="osd-image",
- h_align="center",
- v_align="center",
- h_expand=True,
- v_expand=True,
- )
-
- self.add(self.osd_window_image)
- self.add(self.scale)
- self.update_brightness()
-
- self.scale.connect("value-changed", lambda *_: self.update_brightness())
- self.brightness_service.connect("screen", self.on_brightness_changed)
-
- def update_brightness(self) -> None:
- current_brightness = self.brightness_service.screen_brightness
- normalized_brightness = self._normalize_brightness(current_brightness)
- if current_brightness != 0:
- self.scale.animate_value(normalized_brightness)
-
- def get_svg(self, value):
- b_level = 0 if value == 0 else min(int(math.ceil(value / 33)), 3)
- return b_level
-
- def on_brightness_changed(self, _sender: any, value: float, *_args) -> None:
- normalized_brightness = self._normalize_brightness(value)
- self.osd_window_image.set_from_file(
- get_relative_path(
- f"../config/assets/icons/brightness/brightness-{
- self.get_svg(normalized_brightness)
- }.svg"
- )
- )
- self.scale.animate_value(normalized_brightness)
-
- def _normalize_brightness(self, brightness: float) -> float:
- return (brightness / self.brightness_service.max_screen) * 100
-
-
-class AudioOSDContainer(Box):
- __gsignals__: ClassVar[dict] = {
- "volume-changed": (GObject.SIGNAL_RUN_FIRST, GObject.TYPE_NONE, ()),
- }
-
- def __init__(self, **kwargs):
- super().__init__(
- **kwargs,
- orientation="v",
- name="osd",
- )
- self.audio = Audio()
- self.scale = AnimatedScale(
- value=70,
- marks=(ScaleMark(value=i) for i in range(1, 100, 10)),
- min_value=0,
- max_value=100,
- increments=(1, 1),
- orientation="h",
- )
- self.osd_window_image = Svg(
- get_relative_path("../config/assets/icons/volume/audio-volume.svg"),
- size=(150, 150),
- name="osd-image",
- h_align="center",
- v_align="center",
- h_expand=True,
- v_expand=True,
- )
-
- self.previous_volume = None
- self.previous_muted = None
-
- self.add(self.osd_window_image)
- self.add(self.scale)
- self.sync_with_audio()
-
- self.scale.connect("value-changed", self.on_volume_changed)
- self.audio.connect("notify::speaker", self.on_audio_speaker_changed)
- self.audio.connect("speaker-changed", self.on_speaker_changed)
-
- # Connect to current speaker if available
- self._connect_speaker_signals()
-
- def _connect_speaker_signals(self):
- """Connect to speaker's changed signal which should fire on both volume and mute changes"""
- if self.audio.speaker:
- # Connect to the main 'changed' signal from AudioStream
- self.audio.speaker.connect("changed", self.on_speaker_stream_changed)
-
- def on_speaker_stream_changed(self, *_):
- """This should be called whenever the speaker stream changes (volume OR mute)"""
- if self.audio.speaker:
- current_volume = (
- round(self.audio.speaker.volume)
- if hasattr(self.audio.speaker, "volume")
- else 0
- )
- current_muted = (
- self.audio.speaker.muted
- if hasattr(self.audio.speaker, "muted")
- else False
- )
-
- # Check if either volume or mute state changed
- if (
- self.previous_volume != current_volume
- or self.previous_muted != current_muted
- ):
- self.previous_volume = current_volume
- self.previous_muted = current_muted
- self.update_volume()
- self.emit("volume-changed")
-
- def get_svg(self, value):
- audio_level = 0 if value == 0 else min(int(math.ceil(value / 33)), 3)
- return audio_level
-
- def sync_with_audio(self):
- if self.audio.speaker:
- volume = (
- round(self.audio.speaker.volume)
- if hasattr(self.audio.speaker, "volume")
- else 0
- )
- self.scale.set_value(volume)
- self.previous_volume = volume
- self.previous_muted = (
- self.audio.speaker.muted
- if hasattr(self.audio.speaker, "muted")
- else False
- )
-
- def on_volume_changed(self, *_):
- if self.audio.speaker:
- volume = self.scale.value
- if 0 <= volume <= 100:
- self.audio.speaker.set_volume(volume)
- self.update_volume_display(volume)
- self.emit("volume-changed")
-
- def update_volume_display(self, volume=None):
- """Update the visual display based on current volume/mute state"""
- if not self.audio.speaker:
- return
-
- if volume is None:
- volume = (
- round(self.audio.speaker.volume)
- if hasattr(self.audio.speaker, "volume")
- else 0
- )
-
- is_muted = (
- self.audio.speaker.muted if hasattr(self.audio.speaker, "muted") else False
- )
-
- if volume == 0 or is_muted:
- self.scale.add_style_class("muted")
- display_volume = 0
- else:
- self.scale.remove_style_class("muted")
- display_volume = volume
-
- self.osd_window_image.set_from_file(
- get_relative_path(
- f"../config/assets/icons/volume/audio-volume-{
- self.get_svg(display_volume)
- }.svg"
- )
- )
-
- def on_audio_speaker_changed(self, *_):
- self._connect_speaker_signals()
- self.update_volume()
-
- def on_speaker_changed(self, *_):
- self._connect_speaker_signals()
- self.update_volume()
-
- def update_volume(self, *_):
- if self.audio.speaker and not self.is_hovered():
- volume = (
- round(self.audio.speaker.volume)
- if hasattr(self.audio.speaker, "volume")
- else 0
- )
- self.scale.set_value(volume)
- self.update_volume_display(volume)
-
-
-class MicrophoneOSDContainer(Box):
- __gsignals__: ClassVar[dict] = {
- "mic-changed": (GObject.SIGNAL_RUN_FIRST, GObject.TYPE_NONE, ()),
- }
-
- def __init__(self, **kwargs):
- super().__init__(**kwargs, orientation="v", spacing=13, name="osd")
- self.audio = Audio()
- self.scale = AnimatedScale(
- marks=(ScaleMark(value=i) for i in range(1, 100, 10)),
- value=70,
- min_value=0,
- max_value=100,
- increments=(1, 1),
- orientation="h",
- )
-
- self.osd_window_image = Svg(
- get_relative_path("../config/assets/icons/mic/microphone.svg"),
- name="osd-image",
- size=(100, 150),
- h_align="center",
- v_align="center",
- h_expand=True,
- v_expand=True,
- )
- self.previous_volume = None
- self.previous_muted = None
-
- self.add(self.osd_window_image)
- self.add(self.scale)
- self.sync_with_audio()
-
- self.scale.connect("value-changed", self.on_volume_changed)
- self.audio.connect("notify::microphone", self.on_audio_microphone_changed)
- self.audio.connect("microphone-changed", self.on_microphone_changed)
-
- # Connect to current microphone if available
- self._connect_microphone_signals()
-
- def _connect_microphone_signals(self):
- """Connect to microphone's changed signal which should fire on both volume and mute changes"""
- if self.audio.microphone:
- # Connect to the main 'changed' signal from AudioStream
- self.audio.microphone.connect("changed", self.on_microphone_stream_changed)
-
- def on_microphone_stream_changed(self, *_):
- """This should be called whenever the microphone stream changes (volume OR mute)"""
- if self.audio.microphone:
- current_volume = (
- round(self.audio.microphone.volume)
- if hasattr(self.audio.microphone, "volume")
- else 0
- )
- current_muted = (
- self.audio.microphone.muted
- if hasattr(self.audio.microphone, "muted")
- else False
- )
-
- # Check if either volume or mute state changed
- if (
- self.previous_volume != current_volume
- or self.previous_muted != current_muted
- ):
- self.previous_volume = current_volume
- self.previous_muted = current_muted
- self.update_volume()
- self.emit("mic-changed")
-
- def get_svg(self, value):
- audio_level = 0 if value == 0 else min(int(math.ceil(value / 33)), 3)
- return audio_level
-
- def sync_with_audio(self):
- if self.audio.microphone:
- volume = (
- round(self.audio.microphone.volume)
- if hasattr(self.audio.microphone, "volume")
- else 0
- )
- self.scale.set_value(volume)
- self.previous_volume = volume
- self.previous_muted = (
- self.audio.microphone.muted
- if hasattr(self.audio.microphone, "muted")
- else False
- )
-
- def on_volume_changed(self, *_):
- if self.audio.microphone:
- volume = self.scale.value
- if 0 <= volume <= 100:
- self.audio.microphone.set_volume(volume)
- self.update_volume_display(volume)
- self.emit("mic-changed")
-
- def update_volume_display(self, volume=None):
- """Update the visual display based on current volume/mute state"""
- if not self.audio.microphone:
- return
-
- if volume is None:
- volume = (
- round(self.audio.microphone.volume)
- if hasattr(self.audio.microphone, "volume")
- else 0
- )
-
- is_muted = (
- self.audio.microphone.muted
- if hasattr(self.audio.microphone, "muted")
- else False
- )
-
- if volume == 0 or is_muted:
- self.scale.add_style_class("muted")
- display_volume = 0
- else:
- self.scale.remove_style_class("muted")
- display_volume = volume
-
- self.osd_window_image.set_from_file(
- get_relative_path(
- f"../config/assets/icons/mic/microphone-{
- self.get_svg(display_volume)
- }.svg"
- )
- )
-
- def on_audio_microphone_changed(self, *_):
- self._connect_microphone_signals()
- self.update_volume()
-
- def on_microphone_changed(self, *_):
- self._connect_microphone_signals()
- self.update_volume()
-
- def update_volume(self, *_):
- if self.audio.microphone and not self.is_hovered():
- volume = (
- round(self.audio.microphone.volume)
- if hasattr(self.audio.microphone, "volume")
- else 0
- )
- self.scale.set_value(volume)
- self.update_volume_display(volume)
-
-
-class OSD(Window):
- def __init__(self, **kwargs):
- self.audio_container = AudioOSDContainer()
- self.brightness_container = BrightnessOSDContainer()
- self.microphone_container = MicrophoneOSDContainer()
-
- self.timeout = 1000
-
- self.revealer = Revealer(
- transition_type="slide-up",
- transition_duration=100,
- child_revealed=False,
- )
-
- self.main_box = Box(
- orientation="v",
- h_expand=True,
- children=[self.revealer],
- )
-
- super().__init__(
- layer="overlay",
- anchor="bottom",
- title="modus",
- child=self.main_box,
- visible=False,
- pass_through=True,
- keyboard_mode="on-demand",
- **kwargs,
- )
-
- self.last_activity_time = time.time()
-
- # Connect to the containers' signals
- self.audio_container.connect("volume-changed", self.show_audio)
- self.brightness_container.brightness_service.connect(
- "screen", self.show_brightness
- )
- self.microphone_container.connect("mic-changed", self.show_microphone)
-
- GLib.timeout_add(100, self.check_inactivity)
-
- def show_audio(self, *_):
- self.show_box(box_to_show="audio")
- self.reset_inactivity_timer()
-
- def show_brightness(self, *_):
- self.show_box(box_to_show="brightness")
- self.reset_inactivity_timer()
-
- def show_microphone(self, *_):
- self.show_box(box_to_show="microphone")
- self.reset_inactivity_timer()
-
- def show_box(self, box_to_show: Literal["audio", "brightness", "microphone"]):
- self.set_visible(True)
- if box_to_show == "audio":
- self.revealer.children = self.audio_container
- elif box_to_show == "brightness":
- self.revealer.children = self.brightness_container
- elif box_to_show == "microphone":
- self.revealer.children = self.microphone_container
- self.revealer.set_reveal_child(True)
- self.reset_inactivity_timer()
-
- def start_hide_timer(self):
- self.set_visible(False)
-
- def reset_inactivity_timer(self):
- self.last_activity_time = time.time()
-
- def check_inactivity(self):
- if time.time() - self.last_activity_time >= (self.timeout / 1000):
- self.start_hide_timer()
- return True
diff --git a/modules/panel/components/enhanced_system_tray.py b/modules/panel/components/enhanced_system_tray.py
deleted file mode 100644
index ac7e63b1..00000000
--- a/modules/panel/components/enhanced_system_tray.py
+++ /dev/null
@@ -1,165 +0,0 @@
-"""
-Enhanced System Tray Icon Handling
-
-This module provides enhanced icon loading capabilities for system tray items,
-including fallback mechanisms for file paths and common icon locations.
-"""
-
-import os
-
-from gi.repository import GdkPixbuf, Gtk
-
-from fabric.system_tray.widgets import SystemTrayItem
-
-# FIX: the tooltip should show application names instead of unknown
-
-
-def patched_do_update_properties(self, *_):
- # Try default GTK theme first
- icon_name = self._item.icon_name
- attention_icon_name = self._item.attention_icon_name
-
- if self._item.status == "NeedsAttention" and attention_icon_name:
- preferred_icon_name = attention_icon_name
- else:
- preferred_icon_name = icon_name
-
- # Try to load from default GTK theme
- if preferred_icon_name:
- try:
- default_theme = Gtk.IconTheme.get_default()
- if default_theme.has_icon(preferred_icon_name):
- pixbuf = default_theme.load_icon(
- preferred_icon_name, self._icon_size, Gtk.IconLookupFlags.FORCE_SIZE
- )
- if pixbuf:
- self._image.set_from_pixbuf(pixbuf)
- # Set tooltip
- tooltip = self._item.tooltip
- self.set_tooltip_markup(
- tooltip.description or tooltip.title or self._item.title.title()
- if self._item.title
- else "Unknown"
- )
- return
- except:
- pass
-
- # Enhanced fallback handling for file paths
- if preferred_icon_name and self._try_load_icon_from_path(preferred_icon_name):
- return
-
- # Fallback to original implementation
- original_do_update_properties(self, *_)
-
-
-def _try_load_icon_from_path(self, icon_path):
- try:
- # Check if it's a file path and handle it directly
- if os.path.isabs(icon_path) or "/" in icon_path:
- # Try to load as SVG from the original path if it exists
- if os.path.exists(icon_path):
- if icon_path.lower().endswith(".svg"):
- # Load SVG directly
- pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size(
- icon_path, self._icon_size, self._icon_size
- )
- if pixbuf:
- self._image.set_from_pixbuf(pixbuf)
- self._set_tooltip()
- return True
- else:
- # Load other image formats
- pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size(
- icon_path, self._icon_size, self._icon_size
- )
- if pixbuf:
- self._image.set_from_pixbuf(pixbuf)
- self._set_tooltip()
- return True
-
- # If it's a file path, try to extract just the filename for theme lookup
- filename = os.path.basename(icon_path)
- if filename:
- # Remove extension for theme lookup
- name_without_ext = os.path.splitext(filename)[0]
- default_theme = Gtk.IconTheme.get_default()
-
- # Try filename without extension
- if default_theme.has_icon(name_without_ext):
- pixbuf = default_theme.load_icon(
- name_without_ext,
- self._icon_size,
- Gtk.IconLookupFlags.FORCE_SIZE,
- )
- if pixbuf:
- self._image.set_from_pixbuf(pixbuf)
- self._set_tooltip()
- return True
-
- # Try full filename
- if default_theme.has_icon(filename):
- pixbuf = default_theme.load_icon(
- filename, self._icon_size, Gtk.IconLookupFlags.FORCE_SIZE
- )
- if pixbuf:
- self._image.set_from_pixbuf(pixbuf)
- self._set_tooltip()
- return True
-
- # If it looks like a file path but doesn't exist, try common icon locations
- if os.path.isabs(icon_path):
- common_icon_dirs = [
- "/usr/share/icons",
- "/usr/share/pixmaps",
- "/usr/local/share/icons",
- "/usr/local/share/pixmaps",
- os.path.expanduser("~/.local/share/icons"),
- os.path.expanduser("~/.icons"),
- ]
-
- filename = os.path.basename(icon_path)
- for icon_dir in common_icon_dirs:
- potential_path = os.path.join(icon_dir, filename)
- if os.path.exists(potential_path):
- try:
- pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size(
- potential_path, self._icon_size, self._icon_size
- )
- if pixbuf:
- self._image.set_from_pixbuf(pixbuf)
- self._set_tooltip()
- return True
- except:
- continue
-
- except Exception:
- pass
-
- return False
-
-
-def _set_tooltip(self):
- tooltip = self._item.tooltip
- self.set_tooltip_markup(
- tooltip.description or tooltip.title or self._item.title.title()
- if self._item.title
- else "Unknown"
- )
-
-
-def apply_enhanced_system_tray():
- # Store original method
- global original_do_update_properties
- original_do_update_properties = SystemTrayItem.do_update_properties
-
- # Attach helper methods to SystemTrayItem class
- SystemTrayItem._try_load_icon_from_path = _try_load_icon_from_path
- SystemTrayItem._set_tooltip = _set_tooltip
-
- # Replace the do_update_properties method
- SystemTrayItem.do_update_properties = patched_do_update_properties
-
-
-# Store reference to original method
-original_do_update_properties = None
diff --git a/modules/panel/components/menubar.py b/modules/panel/components/menubar.py
deleted file mode 100644
index 61e77351..00000000
--- a/modules/panel/components/menubar.py
+++ /dev/null
@@ -1,411 +0,0 @@
-import json
-import os
-import subprocess
-
-from fabric.hyprland.widgets import HyprlandActiveWindow as ActiveWindow
-from fabric.utils import FormattedString
-from fabric.widgets.box import Box
-from fabric.widgets.button import Button
-from fabric.widgets.centerbox import CenterBox
-from fabric.widgets.label import Label
-
-from modules.about import About, AboutApp
-from utils.roam import modus_service
-from widgets.dropdown import ModusDropdown, dropdown_divider
-from widgets.mousecapture import DropDownMouseCapture
-from utils.app_name_resolver import format_window
-
-
-def show_about_app():
- """Show about dialog for current active application"""
- try:
- # Use modus_service's Hyprland connection to get current window info
- wmclass = ""
- title = ""
-
- if (
- hasattr(modus_service, "_hyprland_connection")
- and modus_service._hyprland_connection
- ):
- window_data = modus_service._hyprland_connection.send_command(
- "j/activewindow"
- ).reply
- if window_data:
- window_info = json.loads(window_data.decode("utf-8"))
- wmclass = window_info.get("class", "")
- title = window_info.get("title", "")
-
- # Don't show about dialog if there's no active window (Finder state)
- if not wmclass and not title:
- return
-
- app_name = modus_service.current_active_app_name or "Finder"
- # Don't show about dialog for Finder
- if app_name == "Finder":
- return
-
- about_window = AboutApp(app_name=app_name, wmclass=wmclass)
- about_window.toggle(None)
- except Exception:
- # Fallback: only show if we have a real app name
- app_name = modus_service.current_active_app_name or ""
- if app_name and app_name != "Finder":
- about_window = AboutApp(app_name=app_name, wmclass="")
- about_window.toggle(None)
-
-
-def dropdown_option(
- label: str = "",
- keybind: str = "",
- on_click='echo "ModusPanelDropdown Action"',
- on_clicked=None,
-):
- def on_click_subthread(button):
- # Execute the action first
- if on_clicked:
- on_clicked(button)
- else:
- subprocess.Popen(
- f"nohup {on_click} &",
- shell=True,
- stdout=subprocess.DEVNULL,
- stderr=subprocess.DEVNULL,
- )
-
- # Hide dropdown by finding the current visible dropdown and calling its hide method
- from widgets.dropdown import dropdowns
-
- for dropdown in dropdowns:
- if dropdown.is_visible() and hasattr(dropdown, "hide_via_mousecapture"):
- dropdown.hide_via_mousecapture()
- break
-
- return Button(
- child=CenterBox(
- start_children=[
- Label(label=label, h_align="start", name="dropdown-option-label"),
- ],
- end_children=[
- Label(label=keybind, h_align="end", name="dropdown-option-keybind")
- ],
- orientation="horizontal",
- h_align="fill",
- h_expand=True,
- v_expand=True,
- ),
- name="dropdown-option",
- h_align="fill",
- on_clicked=on_click_subthread,
- h_expand=True,
- v_expand=True,
- )
-
-
-class SystemDropdown(ModusDropdown):
- def __init__(self, parent, **kwargs):
- super().__init__(
- dropdown_id="os-menu",
- parent=parent,
- dropdown_children=[
- dropdown_option(
- "About this PC", on_clicked=lambda _: About().toggle(_)
- ),
- dropdown_divider("---------------------"),
- dropdown_option(
- "System Settings...",
- # TODO: Open Modus own setting
- # on_click="xdg-open settings",
- ),
- dropdown_divider("---------------------"),
- dropdown_option("Force Quit", "", "hyprctl kill"),
- dropdown_divider("---------------------"),
- dropdown_option("Sleep", "", "systemctl suspend"),
- dropdown_option("Restart...", "", "systemctl reboot"),
- dropdown_option("Shut Down...", "", "shutdown now"),
- dropdown_divider("---------------------"),
- dropdown_option("Lock Screen", "๓ฐณ L", "hyprlock"),
- ],
- **kwargs,
- )
-
-
-class MenuBarDropdowns:
- def __init__(self, parent):
- self.parent = parent
-
- # System dropdown
- self.system_dropdown = SystemDropdown(parent=parent)
- self.menu_button_dropdown = DropDownMouseCapture(
- layer="bottom", child_window=self.system_dropdown
- )
- self.menu_button = Button(
- label="Modus",
- name="menu-button",
- style_classes="button",
- on_clicked=lambda _: self.menu_button_dropdown.toggle_mousecapture(),
- )
- self.menu_button_dropdown.child_window.set_pointing_to(self.menu_button)
-
- # Global menu dropdowns
- self.global_title_menu_about = dropdown_option(
- f"About {modus_service.current_active_app_name}",
- on_clicked=lambda _: show_about_app(),
- )
- self.global_menu_title = DropDownMouseCapture(
- layer="bottom",
- child_window=ModusDropdown(
- dropdown_id="global-menu-title",
- parent=parent,
- dropdown_children=[self.global_title_menu_about],
- ),
- )
-
- self.global_menu_file = None
- self.global_menu_edit = None
- self.global_menu_view = DropDownMouseCapture(
- layer="bottom",
- child_window=ModusDropdown(
- dropdown_id="global-menu-view",
- parent=parent,
- dropdown_children=[
- dropdown_option(
- "Enter Full Screen",
- on_click="hyprctl dispatch fullscreen",
- ),
- ],
- ),
- )
- self.global_menu_go = None
- self.global_menu_window = DropDownMouseCapture(
- layer="bottom",
- child_window=ModusDropdown(
- dropdown_id="global-menu-window",
- parent=parent,
- dropdown_children=[
- dropdown_option(
- "Zoom In",
- "๓ฐ +",
- on_click="hyprctl -q keyword cursor:zoom_factor $(hyprctl getoption cursor:zoom_factor -j | jq '.float * 1.1')",
- ),
- dropdown_option(
- "Zoom Out",
- "๓ฐ -",
- on_click="hyprctl -q keyword cursor:zoom_factor $(hyprctl getoption cursor:zoom_factor -j | jq '(.float * 0.9) | if . < 1 then 1 else . end')",
- ),
- dropdown_divider("---------------------"),
- dropdown_option(
- "Move Window to Left",
- on_click="hyprctl dispatch movewindow l",
- ),
- dropdown_option(
- "Move Window to Right",
- on_click="hyprctl dispatch movewindow r",
- ),
- dropdown_option(
- "Cycle Through Windows",
- on_click="hyprctl dispatch cyclenext",
- ),
- dropdown_divider("---------------------"),
- dropdown_option(
- "Float", on_click="hyprctl dispatch togglefloating"
- ),
- dropdown_option("Quit", on_click="hyprctl dispatch killactive"),
- dropdown_option("Pseudo", on_click="hyprctl dispatch pseudo"),
- dropdown_option(
- "Toggle Split", on_click="hyprctl dispatch togglesplit"
- ),
- dropdown_option("Center", on_click="hyprctl dispatch centerwindow"),
- dropdown_option("Group", on_click="hyprctl dispatch togglegroup"),
- dropdown_option(
- "Pin",
- on_clicked=lambda _: subprocess.run(
- "bash ~/.config/scripts/winpin.sh", shell=True
- ),
- ),
- ],
- ),
- )
-
- self.global_menu_help = DropDownMouseCapture(
- layer="bottom",
- child_window=ModusDropdown(
- dropdown_id="global-menu-help",
- parent=parent,
- dropdown_children=[
- dropdown_option(
- "Modus",
- on_click="xdg-open https://github.com/S4NKALP/Modus/issues",
- ),
- dropdown_divider("---------------------"),
- dropdown_option(
- "Hyprland Wiki", on_click="xdg-open https://wiki.hyprland.org/"
- ),
- ],
- ),
- )
-
- # Create menu buttons
- modus_service.connect(
- "current-active-app-name-changed",
- lambda _, value: self.global_title_menu_about.set_property(
- "label", f"About {value}"
- ),
- )
-
- # Connect to active app name changes to update the title button
- modus_service.connect("current-active-app-name-changed", self._on_active_app_changed)
-
- self.global_menu_button_title = Button(
- child=ActiveWindow(
- formatter=FormattedString(
- "{ format_window(win_title, win_class) }",
- format_window=format_window,
- )
- ),
- name="global-title-button",
- style_classes="button",
- on_clicked=self._on_title_button_clicked,
- )
-
- self.global_menu_title.child_window.set_pointing_to(
- self.global_menu_button_title
- )
-
- self.global_menu_button_file = Button(
- label="File", name="global-menu-button-file", style_classes="button"
- )
- self.global_menu_button_edit = Button(
- label="Edit", name="global-menu-button-edit", style_classes="button"
- )
- self.global_menu_button_view = Button(
- label="View",
- name="global-menu-button-view",
- style_classes="button",
- on_clicked=lambda _: self.global_menu_view.toggle_mousecapture(),
- )
- self.global_menu_view.child_window.set_pointing_to(self.global_menu_button_view)
- self.global_menu_button_go = Button(
- label="Go", name="global-menu-button-go", style_classes="button"
- )
- self.global_menu_button_window = Button(
- label="Window",
- name="global-menu-button-window",
- style_classes="button",
- on_clicked=lambda _: self.global_menu_window.toggle_mousecapture(),
- )
- self.global_menu_window.child_window.set_pointing_to(
- self.global_menu_button_window
- )
- self.global_menu_button_help = Button(
- label="Help",
- name="global-menu-button-help",
- style_classes="button",
- on_clicked=lambda _: self.global_menu_help.toggle_mousecapture(),
- )
- self.global_menu_help.child_window.set_pointing_to(self.global_menu_button_help)
-
- modus_service.connect("current-dropdown-changed", self.changed_dropdown)
- modus_service.connect("dropdowns-hide-changed", self.hide_dropdowns)
-
- def _on_title_button_clicked(self, _):
- """Handle title button click - only show dropdown if there's an active window"""
- try:
- # Use modus_service's Hyprland connection to get current window info
- if (
- hasattr(modus_service, "_hyprland_connection")
- and modus_service._hyprland_connection
- ):
- window_data = modus_service._hyprland_connection.send_command(
- "j/activewindow"
- ).reply
- if window_data:
- window_info = json.loads(window_data.decode("utf-8"))
- wmclass = window_info.get("class", "")
- title = window_info.get("title", "")
-
- # Only show dropdown if there's an active window (not Finder)
- if wmclass or title:
- self.global_menu_title.toggle_mousecapture()
- return
-
- # Fallback: check if current_active_app_name is not "Finder"
- if (
- modus_service.current_active_app_name
- and modus_service.current_active_app_name != "Finder"
- ):
- self.global_menu_title.toggle_mousecapture()
- except Exception:
- # If we can't get window info, don't show dropdown
- pass
-
- def _on_active_app_changed(self, _, value):
- """Handle active app name changes"""
- # Update the "About" menu item label
- self.global_title_menu_about.set_property("label", f"About {value}")
-
- def hide_dropdowns(self, *_):
- self.menu_button.remove_style_class("active")
- self.global_menu_button_edit.remove_style_class("active")
- self.global_menu_button_file.remove_style_class("active")
- self.global_menu_button_go.remove_style_class("active")
- self.global_menu_button_help.remove_style_class("active")
- self.global_menu_button_title.remove_style_class("active")
- self.global_menu_button_view.remove_style_class("active")
- self.global_menu_button_window.remove_style_class("active")
-
- def changed_dropdown(self, _, dropdown_id):
- self.hide_dropdowns(_, True)
- match dropdown_id:
- case "os-menu":
- self.menu_button.add_style_class("active")
- case "global-menu-edit":
- self.global_menu_button_edit.add_style_class("active")
- case "global-menu-file":
- self.global_menu_button_file.add_style_class("active")
- case "global-menu-go":
- self.global_menu_button_go.add_style_class("active")
- case "global-menu-help":
- self.global_menu_button_help.add_style_class("active")
- case "global-menu-title":
- self.global_menu_button_title.add_style_class("active")
- case "global-menu-view":
- self.global_menu_button_view.add_style_class("active")
- case "global-menu-window":
- self.global_menu_button_window.add_style_class("active")
- case _:
- pass
-
-
-class MenuBar(Box):
- """Main MenuBar widget that contains all menu buttons"""
-
- def __init__(self, parent_window=None, **kwargs):
- # Extract parent_window from kwargs if not provided as parameter
- if parent_window is None:
- parent_window = kwargs.pop("parent_window", None)
-
- super().__init__(name="menubar", orientation="horizontal", spacing=0, **kwargs)
-
- # Create the dropdown system
- self.dropdown_system = MenuBarDropdowns(parent=parent_window)
-
- # Add all the menu buttons to the menubar
- self.children = [
- self.dropdown_system.global_menu_button_title,
- self.dropdown_system.global_menu_button_file,
- self.dropdown_system.global_menu_button_edit,
- self.dropdown_system.global_menu_button_view,
- self.dropdown_system.global_menu_button_go,
- self.dropdown_system.global_menu_button_window,
- self.dropdown_system.global_menu_button_help,
- ]
-
- def show_system_dropdown(self, imac_button):
- self.dropdown_system.menu_button_dropdown.child_window.set_pointing_to(
- imac_button
- )
- mouse_capture = self.dropdown_system.menu_button_dropdown
- if mouse_capture.is_visible():
- mouse_capture.set_child_window_visible(False)
- else:
- mouse_capture.set_child_window_visible(True)
diff --git a/modules/panel/main.py b/modules/panel/main.py
deleted file mode 100644
index 9008264e..00000000
--- a/modules/panel/main.py
+++ /dev/null
@@ -1,250 +0,0 @@
-from fabric.hyprland.widgets import HyprlandWorkspaces, WorkspaceButton
-from fabric.system_tray.widgets import SystemTray
-from fabric.utils import get_relative_path
-from fabric.widgets.box import Box
-from fabric.widgets.button import Button
-from fabric.widgets.centerbox import CenterBox
-from fabric.widgets.datetime import DateTime
-from fabric.widgets.revealer import Revealer
-from fabric.widgets.svg import Svg
-
-import config.data as data
-from modules.controlcenter.main import ModusControlCenter
-from modules.notification.notification_center import NotificationCenter
-from modules.panel.components.enhanced_system_tray import apply_enhanced_system_tray
-from modules.panel.components.indicators import (
- BatteryIndicator,
- BluetoothIndicator,
- NetworkIndicator,
-)
-from modules.panel.components.menubar import MenuBar
-from modules.panel.components.recording_indicator import RecordingIndicator
-from modules.todo.todo_widget import TodoListCapture
-from services.modus import notification_service
-from utils.functions import is_special_workspace_id
-from utils.roam import modus_service
-from widgets.mousecapture import MouseCapture
-from widgets.wayland import WaylandWindow as Window
-
-# Apply enhanced system tray icon handling
-apply_enhanced_system_tray()
-
-
-class Panel(Window):
- def __init__(self, **kwargs):
- super().__init__(
- name="bar",
- title="modus",
- layer="top",
- anchor="left top right",
- exclusivity="auto",
- visible=False,
- )
-
- self.launcher = kwargs.get("launcher", None)
- self.menubar = MenuBar(parent_window=self)
-
- self.workspace_indicator = HyprlandWorkspaces(
- name="workspaces",
- spacing=4,
- buttons_factory=lambda ws_id: (
- None
- if data.HIDE_SPECIAL_WORKSPACE and is_special_workspace_id(ws_id)
- else WorkspaceButton(id=ws_id, label=str(ws_id))
- ),
- )
-
- self.imac = Button(
- name="panel-button",
- child=Svg(
- size=16,
- svg_file=get_relative_path("../../config/assets/icons/misc/logo.svg"),
- ),
- on_clicked=lambda *_: self.menubar.show_system_dropdown(self.imac),
- )
-
- self.tray = SystemTray(name="system-tray", spacing=4, icon_size=20)
- self.tray_revealer = Revealer(
- name="tray-revealer",
- child=self.tray,
- child_revealed=False,
- transition_type="slide-left",
- transition_duration=300,
- )
-
- self.chevron_button = Button(
- name="panel-button",
- child=Svg(
- size=16,
- svg_file=get_relative_path(
- "../../config/assets/icons/misc/chevron-right.svg"
- ),
- ),
- on_clicked=self.toggle_tray,
- )
-
- self.indicators = Box(
- name="indicators",
- orientation="h",
- spacing=4,
- children=[
- BatteryIndicator(),
- NetworkIndicator(),
- BluetoothIndicator(),
- ],
- )
-
- self.search = Button(
- name="panel-button",
- on_clicked=lambda *_: self.search_apps(),
- child=Svg(
- size=22,
- svg_file=get_relative_path("../../config/assets/icons/misc/search.svg"),
- ),
- )
-
- self.control_center = MouseCapture(
- layer="top", child_window=ModusControlCenter()
- )
-
- self.control_center_button = Button(
- name="control-center-button",
- style_classes="button",
- on_clicked=self.control_center.toggle_mousecapture,
- child=Svg(
- size=22,
- svg_file=get_relative_path(
- "../../config/assets/icons/misc/control-center.svg"
- ),
- ),
- )
-
- # Notification Center with MouseCapture
- self.notification_center = MouseCapture(
- layer="overlay", child_window=NotificationCenter()
- )
-
- # Todo List with MouseCapture
- self.todo_list = TodoListCapture()
-
- # Notification Center Icon
- self.notification_icon = Svg(
- size=22,
- svg_file=get_relative_path(
- "../../config/assets/icons/notifications/notification-inactive.svg"
- ),
- )
-
- self.notification_center_icon_button = Button(
- name="notification-center-icon-button",
- child=self.notification_icon,
- on_clicked=self.on_notification_icon_clicked,
- )
-
- # Clickable DateTime for todo list
- self.datetime_button = Button(
- name="datetime-button",
- child=DateTime(name="date-time", formatters=["%a %-d %b %I:%M %P"]),
- on_clicked=self.on_datetime_clicked,
- )
-
- self.recording_indicator = RecordingIndicator()
-
- self.children = CenterBox(
- name="panel",
- start_children=Box(
- name="modules-left",
- children=[
- self.imac,
- self.menubar,
- ],
- ),
- center_children=Box(
- name="modules-center",
- children=self.recording_indicator,
- ),
- end_children=Box(
- name="modules-right",
- spacing=4,
- orientation="h",
- children=[
- self.workspace_indicator,
- self.tray_revealer,
- self.chevron_button,
- self.indicators,
- self.search,
- self.control_center_button,
- self.datetime_button,
- self.notification_center_icon_button,
- ],
- ),
- )
-
- # Connect to DND state changes for notification icon
- modus_service.connect("dont-disturb-changed", self.on_dnd_changed)
-
- # Connect to notification service for icon state updates
- notification_service.connect(
- "notify::count", self.on_notification_count_changed
- )
-
- # Set initial notification icon state
- self.update_notification_icon()
-
- return self.show_all()
-
- def search_apps(self):
- self.launcher.show_launcher()
-
- def toggle_tray(self, *_):
- current_state = self.tray_revealer.child_revealed
- self.tray_revealer.child_revealed = not current_state
-
- if self.tray_revealer.child_revealed:
- self.chevron_button.get_child().set_from_file(
- get_relative_path("../../config/assets/icons/misc/chevron-left.svg")
- )
- else:
- self.chevron_button.get_child().set_from_file(
- get_relative_path("../../config/assets/icons/misc/chevron-right.svg")
- )
-
- def on_dnd_changed(self, _, dnd_state):
- """Handle DND state changes from the service."""
- self.update_notification_icon() # Update notification icon when DND changes
-
- def on_notification_count_changed(self, service, *args):
- """Handle notification count changes from the service."""
- self.update_notification_icon()
-
- def on_notification_icon_clicked(self, *args):
- """Handle notification icon clicks - only open center if there are notifications."""
- count = notification_service.count
- if count > 0:
- # Only open notification center if there are notifications
- self.notification_center.toggle_mousecapture()
- # Do nothing if no notifications
-
- def on_datetime_clicked(self, *args):
- """Handle datetime button clicks - open todo list."""
- self.todo_list.toggle_mousecapture()
-
- def update_notification_icon(self):
- """Update the notification icon based on count and DND state."""
- count = notification_service.count
- dnd_enabled = modus_service.dont_disturb
-
- if dnd_enabled:
- # DND is enabled - show disabled icon
- icon_file = "notification-disabled.svg"
- elif count > 0:
- # Has notifications - show active icon
- icon_file = "notification-active.svg"
- else:
- # No notifications - show inactive icon
- icon_file = "notification-inactive.svg"
-
- icon_path = get_relative_path(
- f"../../config/assets/icons/notifications/{icon_file}"
- )
- self.notification_icon.set_from_file(icon_path)
diff --git a/modules/todo/__init__.py b/modules/todo/__init__.py
deleted file mode 100644
index 30afe866..00000000
--- a/modules/todo/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-# Todo module
\ No newline at end of file
diff --git a/modules/widget.py b/modules/widget.py
deleted file mode 100644
index 21ce2876..00000000
--- a/modules/widget.py
+++ /dev/null
@@ -1,887 +0,0 @@
-# Standard library imports
-import psutil
-import requests
-import urllib.parse
-import datetime
-import time
-import subprocess
-import calendar
-from concurrent.futures import ThreadPoolExecutor
-from typing import Optional, Tuple, List, Dict, Any
-
-# Fabric imports
-from fabric.widgets.box import Box
-from fabric.widgets.label import Label
-from fabric.widgets.overlay import Overlay
-from fabric.widgets.datetime import DateTime
-from fabric.widgets.circularprogressbar import CircularProgressBar
-from widgets.wayland import WaylandWindow as Window
-from fabric.utils import invoke_repeater
-from gi.repository import GLib
-
-# Local imports
-from config.data import load_config
-
-# Module-level constants
-WEATHER_UPDATE_INTERVAL = 600 # 10 minutes
-WEATHER_CACHE_TIMEOUT = 1800 # 30 minutes
-SYSTEM_UPDATE_INTERVAL = 1000 # 1 second
-CALENDAR_UPDATE_INTERVAL = int(
- (
- (
- datetime.datetime.combine(
- datetime.date.today() + datetime.timedelta(days=1), datetime.time.min
- )
- - datetime.datetime.now()
- ).total_seconds()
- )
- * 1000
-) # Calculate time till midnight
-LOCATION_CACHE_TIMEOUT = 604800 # 7 days (extended from 24h)
-
-# Thread pool for async operations
-executor = ThreadPoolExecutor(max_workers=4)
-
-# Weather condition to CSS class mapping (iOS-style gradients)
-WEATHER_GRADIENT_MAP = {
- # Clear/Sunny conditions - bright blue to lighter blue
- 0: "weather-clear", # Clear sky
- 1: "weather-mostly-clear", # Mainly clear
- # Cloudy conditions - grey gradients
- 2: "weather-partly-cloudy", # Partly cloudy
- 3: "weather-overcast", # Overcast
- # Fog conditions - muted grey/blue
- 45: "weather-fog", # Fog
- 48: "weather-fog", # Depositing rime fog
- # Light rain/drizzle - blue-grey gradients
- 51: "weather-light-rain", # Light drizzle
- 53: "weather-rain", # Moderate drizzle
- 55: "weather-rain", # Dense drizzle
- 61: "weather-light-rain", # Slight rain
- 80: "weather-light-rain", # Slight rain showers
- # Heavy rain - darker blue-grey
- 63: "weather-heavy-rain", # Moderate rain
- 65: "weather-heavy-rain", # Heavy rain
- 81: "weather-heavy-rain", # Moderate rain showers
- 82: "weather-storm", # Violent rain showers
- # Snow conditions - blue-white gradients
- 56: "weather-snow", # Light freezing drizzle
- 57: "weather-snow", # Dense freezing drizzle
- 66: "weather-snow", # Light freezing rain
- 67: "weather-snow", # Heavy freezing rain
- 71: "weather-snow", # Slight snow fall
- 73: "weather-heavy-snow", # Moderate snow fall
- 75: "weather-heavy-snow", # Heavy snow fall
- 77: "weather-snow", # Snow grains
- 85: "weather-snow", # Slight snow showers
- 86: "weather-heavy-snow", # Heavy snow showers
- # Storm conditions - dark dramatic gradients
- 95: "weather-storm", # Thunderstorm
- 96: "weather-storm", # Thunderstorm with slight hail
- 99: "weather-storm", # Thunderstorm with heavy hail
-}
-
-# Weather condition to emoji mapping
-WEATHER_EMOJI_MAP = {
- 0: "โ๏ธ", # Clear sky
- 1: "๐ค๏ธ", # Mainly clear
- 2: "โ
", # Partly cloudy
- 3: "โ๏ธ", # Overcast
- 45: "๐ซ๏ธ", # Fog
- 48: "๐ซ๏ธ", # Depositing rime fog
- 51: "๐ฆ๏ธ", # Light drizzle
- 53: "๐ง๏ธ", # Moderate drizzle
- 55: "๐ง๏ธ", # Dense drizzle
- 56: "๐จ๏ธ", # Light freezing drizzle
- 57: "๐จ๏ธ", # Dense freezing drizzle
- 61: "๐ฆ๏ธ", # Slight rain
- 63: "๐ง๏ธ", # Moderate rain
- 65: "๐ง๏ธ", # Heavy rain
- 66: "๐จ๏ธ", # Light freezing rain
- 67: "๐จ๏ธ", # Heavy freezing rain
- 71: "๐จ๏ธ", # Slight snow fall
- 73: "โ๏ธ", # Moderate snow fall
- 75: "โ๏ธ", # Heavy snow fall
- 77: "๐จ๏ธ", # Snow grains
- 80: "๐ฆ๏ธ", # Slight rain showers
- 81: "๐ง๏ธ", # Moderate rain showers
- 82: "โ๏ธ", # Violent rain showers
- 85: "๐จ๏ธ", # Slight snow showers
- 86: "โ๏ธ", # Heavy snow showers
- 95: "โ๏ธ", # Thunderstorm
- 96: "โ๏ธ", # Thunderstorm with slight hail
- 99: "โ๏ธ", # Thunderstorm with heavy hail
-}
-
-# Weather condition descriptions
-WEATHER_DESC_MAP = {
- 0: "Clear sky",
- 1: "Mainly clear",
- 2: "Partly cloudy",
- 3: "Overcast",
- 45: "Fog",
- 48: "Depositing rime fog",
- 51: "Light drizzle",
- 53: "Moderate drizzle",
- 55: "Dense drizzle",
- 56: "Light freezing drizzle",
- 57: "Dense freezing drizzle",
- 61: "Slight rain",
- 63: "Moderate rain",
- 65: "Heavy rain",
- 66: "Light freezing rain",
- 67: "Heavy freezing rain",
- 71: "Slight snow",
- 73: "Moderate snow",
- 75: "Heavy snow",
- 77: "Snow grains",
- 80: "Light rain showers",
- 81: "Moderate rain showers",
- 82: "Violent rain showers",
- 85: "Slight snow showers",
- 86: "Heavy snow showers",
- 95: "Thunderstorm",
- 96: "Thunderstorm with hail",
- 99: "Thunderstorm with heavy hail",
-}
-
-# Location APIs in order of preference (fastest first)
-LOCATION_APIS = [
- "https://ipapi.co/json/", # Fastest, 200ms average
- "http://ip-api.com/json/", # Fast fallback, 150ms average
- "https://ipinfo.io/json", # Original fallback
-]
-
-# Global cache for weather data
-_weather_cache: Dict[str, Tuple[Any, float]] = {}
-_location_cache: Dict[str, Tuple[float, float, float]] = {}
-
-
-def get_location() -> str:
- """Get current location using multiple IP geolocation APIs with fallback."""
- for api_url in LOCATION_APIS:
- try:
- response = requests.get(api_url, timeout=2)
- if response.status_code == 200:
- data = response.json()
- # Handle different API response formats
- city = data.get("city", "")
- if city:
- return city.replace(" ", "")
- except requests.RequestException as e:
- print(f"Location API {api_url} failed: {e}")
- continue
-
- print("All location APIs failed")
- return ""
-
-
-def get_coordinates(city: str) -> Optional[Tuple[float, float]]:
- """Get coordinates for a city using Nominatim geocoding API."""
- cache_key = city.lower()
- current_time = time.time()
-
- # Check cache first (cache for 7 days)
- if cache_key in _location_cache:
- lat, lon, timestamp = _location_cache[cache_key]
- if current_time - timestamp < LOCATION_CACHE_TIMEOUT:
- return lat, lon
-
- try:
- encoded_city = urllib.parse.quote(city)
- url = f"https://nominatim.openstreetmap.org/search?q={encoded_city}&format=json&limit=1"
- response = requests.get(
- url, timeout=3, headers={"User-Agent": "Modus-Desktop/1.0"}
- )
-
- if response.status_code == 200:
- data = response.json()
- if data:
- lat = float(data[0]["lat"])
- lon = float(data[0]["lon"])
- _location_cache[cache_key] = (lat, lon, current_time)
- return lat, lon
- except (requests.RequestException, ValueError, KeyError) as e:
- print(f"Error geocoding {city}: {e}")
-
- return None
-
-
-def get_weather_data(lat: float, lon: float) -> Optional[Dict[str, Any]]:
- """Fetch weather data from Open-Meteo API."""
- try:
- url = (
- f"https://api.open-meteo.com/v1/forecast?"
- f"latitude={lat}&longitude={lon}"
- f"¤t_weather=true"
- f"&daily=temperature_2m_max,temperature_2m_min"
- f"&timezone=auto"
- f"&forecast_days=1"
- )
-
- response = requests.get(url, timeout=3)
- if response.status_code == 200:
- return response.json()
- except requests.RequestException as e:
- print(f"Error fetching weather data: {e}")
-
- return None
-
-
-def format_weather_data(weather_data: Dict[str, Any], city: str) -> List[str]:
- """Format weather data into the expected format."""
- try:
- current = weather_data["current_weather"]
- daily = weather_data["daily"]
-
- # Get weather code and map to emoji and description
- weather_code = current["weathercode"]
- emoji = WEATHER_EMOJI_MAP.get(weather_code, "๐ค๏ธ")
- condition = WEATHER_DESC_MAP.get(weather_code, "Unknown")
- gradient_class = WEATHER_GRADIENT_MAP.get(weather_code, "weather-clear")
-
- # Temperature
- temp = f"{round(current['temperature'])}ยฐ"
-
- # Daily min/max temperatures
- max_temp = f"{round(daily['temperature_2m_max'][0])}ยฐ"
- min_temp = f"{round(daily['temperature_2m_min'][0])}ยฐ"
-
- return [emoji, temp, condition, city, max_temp, min_temp, gradient_class]
-
- except (KeyError, IndexError, TypeError) as e:
- print(f"Error formatting weather data: {e}")
- return None
-
-
-def get_weather(callback):
- """Fetch weather data asynchronously using Open-Meteo API."""
-
- def fetch_weather():
- # Get location
- location = get_location()
- if not location:
- return GLib.idle_add(callback, None)
-
- # Check cache first
- cache_key = location.lower()
- current_time = time.time()
-
- if cache_key in _weather_cache:
- cached_data, timestamp = _weather_cache[cache_key]
- if current_time - timestamp < WEATHER_CACHE_TIMEOUT:
- return GLib.idle_add(callback, cached_data)
-
- # Get coordinates for the location
- coords = get_coordinates(location)
- if not coords:
- return GLib.idle_add(callback, None)
-
- lat, lon = coords
-
- # Fetch weather data
- weather_data = get_weather_data(lat, lon)
- if not weather_data:
- return GLib.idle_add(callback, None)
-
- # Format data
- formatted_data = format_weather_data(weather_data, location)
- if formatted_data:
- # Cache the result
- _weather_cache[cache_key] = (formatted_data, current_time)
- GLib.idle_add(callback, formatted_data)
- else:
- GLib.idle_add(callback, None)
-
- executor.submit(fetch_weather)
-
-
-def update_weather(widget):
- """Update weather widget with new data."""
-
- def fetch_and_update():
- get_weather(lambda weather_info: update_widget(widget, weather_info))
- return True
-
- GLib.timeout_add_seconds(WEATHER_UPDATE_INTERVAL, fetch_and_update)
- fetch_and_update()
-
-
-def update_widget(widget, weather_info):
- """Update widget labels with weather information."""
- if weather_info:
- widget.weatherinfo = weather_info
- widget.update_labels(weather_info)
-
-
-class Weather(Box):
- """Weather widget displaying current conditions and forecast."""
-
- def __init__(self, parent, **kwargs):
- super().__init__(
- name="weather-widget",
- h_expand=True,
- v_expand=True,
- justification="right",
- orientation="v",
- all_visible=False,
- **kwargs,
- )
-
- self.parent = parent
- self.weatherinfo = None
-
- # Create labels with better organization
- self._create_labels()
- self._layout_labels()
-
- # Start weather updates
- update_weather(self)
-
- def _create_labels(self):
- """Create all weather labels."""
- self.city = Label(
- name="city",
- label="Loading...",
- justification="right",
- h_align="start",
- max_chars_width=12,
- ellipsization="end",
- )
- self.temperature = Label(name="temperature", label="--ยฐ", h_align="start")
- self.condition_em = Label(name="condition-emoji", label="๐ค๏ธ", h_align="start")
- self.condition = Label(
- name="condition",
- label="Loading...",
- max_chars_width=18,
- ellipsization="end",
- h_align="start",
- )
- self.feels_like = Label(name="feels-like", label="H:-- L:--", h_align="start")
-
- def _layout_labels(self):
- """Add labels to the widget in proper order."""
- labels = [
- self.city,
- self.temperature,
- self.condition_em,
- self.condition,
- self.feels_like,
- ]
- for label in labels:
- self.add(label)
-
- def update_labels(self, weather_info: List[str]):
- """Update weather labels with new data."""
- if not weather_info or len(weather_info) != 7:
- return
-
- emoji, temp, condition, location, maxtemp, mintemp, gradient_class = (
- weather_info
- )
- maxmin = f"H:{maxtemp} L:{mintemp}"
-
- # Batch update labels for better performance
- label_updates = [
- (self.city, location),
- (self.temperature, temp),
- (self.condition_em, emoji),
- (self.condition, condition),
- (self.feels_like, maxmin),
- ]
-
- for label, text in label_updates:
- label.set_label(text)
-
- # Apply gradient background based on weather condition
- self.parent.set_visible(True)
-
-
-class WeatherContainer(Box):
- """Container for weather widget."""
-
- def __init__(self, **kwargs):
- super().__init__(
- orientation="v",
- name="weather-container",
- v_expand=True,
- v_align="center",
- size=(170, 170),
- visible=True,
- h_align="center",
- children=[Weather(self)],
- **kwargs,
- )
-
-
-class Date(Box):
- """Date widget displaying day, month, and date."""
-
- def __init__(self, **kwargs):
- super().__init__(
- name="date-widget",
- h_expand=True,
- v_expand=True,
- justification="center",
- h_align="center",
- v_align="start",
- orientation="v",
- **kwargs,
- )
-
- # Create date components
- self.top = Box(orientation="h", name="date-top", h_expand=True)
-
- # Use consistent interval for all date components
- date_interval = 10000 # 10 seconds
- self.dateone = DateTime(formatters=["%a"], interval=date_interval, name="day")
- self.datetwo = DateTime(formatters=["%b"], interval=date_interval, name="month")
- self.datethree = DateTime(
- formatters=["%-d"], interval=date_interval, name="date"
- )
-
- # Layout components
- self.top.add(self.dateone)
- self.top.add(self.datetwo)
- self.add(self.top)
- self.add(self.datethree)
-
-
-class DateContainer(Box):
- """Container for date widget."""
-
- def __init__(self, **kwargs):
- super().__init__(
- orientation="v",
- name="date-container",
- v_expand=True,
- size=(170, 170),
- v_align="center",
- h_align="center",
- children=[Date()],
- **kwargs,
- )
-
-
-class Calendar(Box):
- """Calendar widget displaying current month."""
-
- def __init__(self, **kwargs):
- # Set Sunday as first day of week
- calendar.setfirstweekday(6) # 6 = Sunday
- super().__init__(
- name="calendar-widget",
- h_expand=True,
- v_expand=True,
- orientation="v",
- **kwargs,
- )
-
- # Cache current date for efficiency
- self._update_current_date()
-
- # Create calendar components
- self._create_header()
- self._create_days_header()
- self._create_calendar_grid()
-
- # Layout components
- self.add(self.month_label)
- self.add(self.days_header)
- self.add(self.calendar_grid)
-
- # Schedule updates
- invoke_repeater(CALENDAR_UPDATE_INTERVAL, self.update_calendar_if_needed)
-
- def _update_current_date(self):
- """Update cached current date values."""
- now = datetime.datetime.now()
- self.current_month = now.month
- self.current_year = now.year
- self.current_day = now.day
-
- def _create_header(self):
- """Create month header label."""
- self.month_label = Label(
- name="calendar-month",
- label=calendar.month_name[self.current_month],
- h_align="start",
- justification="left",
- )
-
- def _create_days_header(self):
- """Create day abbreviations header."""
- self.days_header = Box(
- name="calendar-days-header", orientation="h", h_expand=True, spacing=2
- )
-
- day_names = ["S", "M", "T", "W", "T", "F", "S"]
- for i, day_name in enumerate(day_names):
- is_weekend = i in (0, 6) # Sunday or Saturday
- day_label = Label(
- name=(
- "calendar-day-header-weekend"
- if is_weekend
- else "calendar-day-header"
- ),
- label=day_name,
- h_align="center",
- h_expand=True,
- )
- self.days_header.add(day_label)
-
- def _create_calendar_grid(self):
- """Create calendar grid container."""
- self.calendar_grid = Box(name="calendar-grid", orientation="v", spacing=1)
- self.update_calendar()
-
- def update_calendar_if_needed(self) -> bool:
- """Check if date changed and update calendar if needed."""
- now = datetime.datetime.now()
- if (
- now.month != self.current_month
- or now.year != self.current_year
- or now.day != self.current_day
- ):
-
- self._update_current_date()
- self.update_calendar()
- return True
-
- def update_calendar(self):
- """Update the calendar grid with current month."""
- # Clear existing calendar efficiently
- children = self.calendar_grid.get_children()
- for child in children:
- self.calendar_grid.remove(child)
-
- # Update month label
- self.month_label.set_label(calendar.month_name[self.current_month])
-
- # Generate calendar
- cal = calendar.monthcalendar(self.current_year, self.current_month)
- current_date = datetime.datetime.now()
-
- for week in cal:
- week_box = Box(orientation="h", spacing=2, h_expand=True)
-
- for day_index, day in enumerate(week):
- if day == 0:
- # Empty day slot
- day_label = Label(
- name="calendar-day-empty",
- label="",
- h_align="center",
- h_expand=True,
- )
- else:
- # Regular day
- is_today = (
- day == self.current_day
- and self.current_month == current_date.month
- and self.current_year == current_date.year
- )
- is_weekend = day_index in (0, 6) # Sunday or Saturday
-
- if is_today:
- name = "calendar-day-today"
- elif is_weekend:
- name = "calendar-day-weekend"
- else:
- name = "calendar-day"
-
- day_label = Label(
- name=name, label=str(day), h_align="center", h_expand=True
- )
-
- week_box.add(day_label)
- self.calendar_grid.add(week_box)
-
-
-class CalendarContainer(Box):
- """Container for calendar widget."""
-
- def __init__(self, **kwargs):
- super().__init__(
- orientation="v",
- name="calendar-box-widget",
- v_expand=True,
- size=(170, 170),
- v_align="center",
- h_align="center",
- children=[Calendar()],
- **kwargs,
- )
-
-
-class SystemInfoBase(Box):
- """Base class for system information widgets."""
-
- @staticmethod
- def create_progress_bar(name: str = "progress-bar", size: int = 80, **kwargs):
- """Create a standardized circular progress bar."""
- return CircularProgressBar(
- name=name,
- start_angle=270,
- end_angle=630,
- min_value=0,
- max_value=100,
- size=size,
- **kwargs,
- )
-
- def __init__(self, name: str, **kwargs):
- super().__init__(
- layer="bottom",
- title="sysinfo",
- name=name,
- visible=True,
- size=(170, 170),
- h_expand=True,
- v_expand=True,
- all_visible=True,
- **kwargs,
- )
-
- # Create progress bar and labels
- self.progress = self.create_progress_bar(name="progress")
- self.main_label = Label(
- label="0%\nLoading", justification="center", name="progress-label"
- )
-
- # Create info container
- self.info_container = Box(
- name="info-container",
- orientation="v",
- spacing=2,
- h_align="center",
- )
-
- # Create main layout
- self.progress_container = Box(
- name="progress-bar-container",
- h_expand=True,
- v_expand=True,
- orientation="v",
- spacing=12,
- h_align="center",
- v_align="center",
- children=[
- Box(
- children=[
- Overlay(
- child=self.progress,
- tooltip_text="",
- overlays=self.main_label,
- )
- ]
- ),
- Box(
- h_align="center",
- justification="centre",
- orientation="v",
- children=[self.info_container],
- ),
- ],
- )
-
- self.add(self.progress_container)
-
- # Don't start updates here - let subclasses call start_updates() when ready
-
- def start_updates(self):
- """Start the update timer - call this after subclass initialization is complete."""
- invoke_repeater(SYSTEM_UPDATE_INTERVAL, self.update)
-
- def create_info_line(
- self, indicator_name: str, info_text: str, value_text: str
- ) -> Box:
- """Create an information line with indicator, label, and value."""
- indicator = Label(label="โ ", name=indicator_name)
- info_label = Label(label=info_text, name="info-text")
- value_label = Label(label=value_text, name="info-value")
-
- line = Box(
- orientation="h",
- spacing=4,
- h_align="start",
- children=[indicator, info_label, value_label],
- )
-
- # Store references for easy updates
- line.indicator = indicator
- line.info_label = info_label
- line.value_label = value_label
-
- return line
-
- def update(self) -> bool:
- """Override in subclasses."""
- raise NotImplementedError
-
-
-class RamInfo(SystemInfoBase):
- """RAM usage information widget."""
-
- def __init__(self, **kwargs):
- super().__init__("info-box-widget", **kwargs)
-
- # Create info lines and store references
- self.used_line = self.create_info_line("used-color-indicator", "Used", "0.0GB")
- self.free_line = self.create_info_line("free-color-indicator", "Free", "0.0GB")
-
- # Add to info container
- self.info_container.add(self.used_line)
- self.info_container.add(self.free_line)
-
- # Now that everything is set up, start updates
- self.start_updates()
-
- def update(self) -> bool:
- """Update RAM information."""
- try:
- mem = psutil.virtual_memory()
-
- # Update main label
- self.main_label.set_label(f" {round(mem.percent):<2} %\nRAM")
-
- # Calculate values
- used_gb = mem.used / (1024**3)
- free_gb = mem.available / (1024**3)
-
- # Update info labels using stored references
- self.used_line.value_label.set_label(f"{round(used_gb, 1)}GB")
- self.free_line.value_label.set_label(f"{round(free_gb, 1)}GB")
-
- # Update progress bar (use GLib.idle_add for thread safety)
- GLib.idle_add(self.progress.set_value, mem.percent)
-
- except Exception as e:
- print(f"Error updating RAM info: {e}")
-
- return True
-
-
-class CpuInfo(SystemInfoBase):
- """CPU usage and temperature information widget."""
-
- def __init__(self, **kwargs):
- super().__init__("info-box-widget", **kwargs)
-
- # Create temperature info components
- self.temp_info = Label(label="Temp", name="info-text")
- self.temp_value = Label(label="0ยฐC", name="info-value")
-
- # Create temperature info line (no indicator)
- self.temp_line = Box(
- orientation="h",
- spacing=4,
- h_align="start",
- children=[self.temp_info, self.temp_value],
- )
-
- # Add to info container
- self.info_container.add(self.temp_line)
-
- # Now that everything is set up, start updates
- self.start_updates()
-
- def get_cpu_temp(self) -> Optional[float]:
- """Get CPU temperature from system sensors."""
- try:
- temps = psutil.sensors_temperatures()
- if not temps:
- return None
-
- # Search for CPU temperature sensors
- cpu_sensor_names = ["coretemp", "k10temp", "cpu"]
- cpu_label_patterns = ["package id 0", "core 0", ""]
-
- for name, entries in temps.items():
- if any(sensor in name.lower() for sensor in cpu_sensor_names):
- for entry in entries:
- entry_label = (entry.label or "").lower()
- if any(
- pattern in entry_label for pattern in cpu_label_patterns
- ):
- return round(entry.current, 1)
- except Exception as e:
- print(f"Error reading CPU temperature: {e}")
-
- return None
-
- def update(self) -> bool:
- """Update CPU information."""
- try:
- # Get CPU usage
- cpu = psutil.cpu_percent()
-
- # Update main label
- self.main_label.set_label(f" {round(cpu):<2} %\nCPU")
-
- # Update temperature using stored reference
- temp = self.get_cpu_temp()
- temp_text = f"{temp}ยฐC" if temp is not None else "N/A"
- self.temp_value.set_label(temp_text)
-
- # Update progress bar (use GLib.idle_add for thread safety)
- GLib.idle_add(self.progress.set_value, cpu)
-
- except Exception as e:
- print(f"Error updating CPU info: {e}")
-
- return True
-
-
-class Deskwidgets(Window):
- """Desktop widgets manager - handles all desktop widgets."""
-
- config = load_config()
-
- def __init__(self, **kwargs):
- # Create the main invisible window that manages the widgets
- super().__init__(
- name="desktop-widget-manager",
- layer="bottom",
- title="modus-desktop-widget-manager",
- visible=False, # This window is invisible - just manages the others
- size=(1, 1), # Minimal size
- anchor="top left",
- **kwargs,
- )
-
- # Create separate independent windows as attributes
- self.top_left = Window(
- anchor="top left",
- title="modus-widgets-topleft",
- orientation="h",
- layer="bottom",
- visible=False, # Start hidden until content ready
- child=Box(
- name="desktop-widgets-container",
- children=[
- DateContainer(),
- WeatherContainer(),
- CalendarContainer(),
- ],
- ),
- )
-
- self.bottom_left = Window(
- anchor="bottom right",
- title="modus-widgets-bottomright",
- orientation="h",
- layer="bottom",
- visible=False, # Start hidden until content ready
- child=Box(
- name="desktop-widgets-container",
- children=[
- CpuInfo(),
- RamInfo(),
- ],
- ),
- )
-
- # Show widgets after initialization is complete
- self.top_left.set_visible(True)
- self.bottom_left.set_visible(True)
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 00000000..04f81399
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,37 @@
+[project]
+name = "modus"
+version = "0.2.0"
+description = "Add your description here"
+readme = "README.md"
+requires-python = ">=3.13"
+dependencies = [
+ "fabric",
+ "httpx>=0.28.1",
+ "pam>=0.2.0",
+ "psutil>=7.2.2",
+ "pywayland>=0.4.18",
+ "six>=1.17.0",
+]
+
+[dependency-groups]
+dev = [
+ "pyinstrument>=5.1.2",
+ "ruff>=0.15.2",
+]
+
+[tool.uv.sources]
+fabric = { git = "https://github.com/Fabric-Development/fabric.git" }
+
+[project.scripts]
+start = "main:main"
+lock = "lock:main"
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build.targets.wheel]
+packages = ["src/shared", "src/utils", "src/window", "src/services", "src/main.py"]
+
+[tool.hatch.build.targets.wheel.sources]
+"src" = ""
diff --git a/requirements.txt b/requirements.txt
index 4da8a516..a40480aa 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,17 +1,6 @@
-certifi==2025.8.3
-charset-normalizer==3.4.2
-click==8.2.1
-idna==3.10
-loguru==0.7.3
-pillow==12.2.0
-psutil==7.0.0
-pycairo==1.28.0
-pydbus==0.6.0
-PyGObject==3.52.3
-pyotp==2.9.0
-pyzbar==0.1.9
-RapidFuzz==3.13.0
-requests==2.33.0
-setproctitle==1.3.6
-thefuzz==0.22.1
-urllib3==2.6.3
+fabric @ git+https://github.com/Fabric-Development/fabric.git@fd2aabbd7e1859aa7c11c626a6c36a937aca736a
+pam>=0.2.0
+psutil>=7.2.2
+pywayland==0.4.18
+six>=1.17.0
+httpx>=0.28.1
\ No newline at end of file
diff --git a/scripts/gamemode.sh b/scripts/gamemode.sh
deleted file mode 100755
index 3652ccdc..00000000
--- a/scripts/gamemode.sh
+++ /dev/null
@@ -1,40 +0,0 @@
-#!/usr/bin/env sh
-
-# Check if animations are disabled (game mode is active)
-check_gamemode() {
- HYPRGAMEMODE=$(hyprctl getoption animations:enabled | awk 'NR==1{print $2}')
- if [ "$HYPRGAMEMODE" = 0 ]; then
- echo "t"
- return 0
- else
- echo "f"
- return 1
- fi
-}
-
-# Toggle game mode state
-toggle_gamemode() {
- HYPRGAMEMODE=$(hyprctl getoption animations:enabled | awk 'NR==1{print $2}')
- if [ "$HYPRGAMEMODE" = 1 ]; then
- hyprctl --batch "\
- keyword animations:enabled 0;\
- keyword decoration:shadow:enabled 0;\
- keyword decoration:blur:enabled 0;\
- keyword general:gaps_in 0;\
- keyword general:gaps_out 0;\
- keyword general:border_size 1;\
- keyword decoration:rounding 0"
- exit
- fi
- hyprctl reload
-}
-
-# Main script logic
-case "$1" in
-check)
- check_gamemode
- ;;
-*)
- toggle_gamemode
- ;;
-esac
diff --git a/scripts/hyprpicker.sh b/scripts/hyprpicker.sh
deleted file mode 100755
index 703c7df7..00000000
--- a/scripts/hyprpicker.sh
+++ /dev/null
@@ -1,75 +0,0 @@
-#!/bin/bash
-
-pick_rgb() {
-
- # Execute hyprpicker with RGB format and save the output to a variable
- hyprpicker -a -n -f rgb && sleep 0.1
-
- # Create a temporal 64x64 PNG file with the color in /tmp using convert
- magick -size 64x64 xc:"rgb($(wl-paste))" /tmp/color.png
-
- # Send a notification using the file as an icon
- notify-send "RGB color picked" "rgb($(wl-paste))" -i /tmp/color.png -a "Hyprpicker"
-
- # Remove the temporal file
- rm /tmp/color.png
-
- # Exit
- exit 0
-
-}
-
-pick_hex() {
-
- # Execute hyprpicker and save the output to a variable
- hyprpicker -a -n -f hex && sleep 0.1
-
- # Create a temporal 64x64 PNG file with the color in /tmp using convert
- magick -size 64x64 xc:"$(wl-paste)" /tmp/color.png
-
- # Send a notification using the file as an icon
- notify-send "HEX color picked" "$(wl-paste)" -i /tmp/color.png -a "Hyprpicker"
-
- # Remove the temporal file
- rm /tmp/color.png
-
- # Exit
- exit 0
-
-}
-
-pick_hsv() {
-
- # Copy the color to the clipboard
- echo -n "$(hyprpicker -n -f hsv)" | wl-copy -n
-
- # Create a temporal 64x64 PNG file with the color in /tmp using convert
- magick -size 64x64 xc:"hsv($(wl-paste))" /tmp/color.png
-
- # Send a notification using the file as an icon
- notify-send "HSV color picked" "hsv($(wl-paste))" -i /tmp/color.png -a "Hyprpicker"
-
- # Remove the temporal file
- rm /tmp/color.png
-
- # Exit
- exit 0
-
-}
-
-case "$1" in
--rgb)
- pick_rgb
- ;;
--hsv)
- pick_hsv
- ;;
--hex)
- pick_hex
- ;;
-
-*)
- echo "Usage: $0 [-rgb|-hex|-hsv]"
- exit 1
- ;;
-esac
diff --git a/scripts/screen-capture.sh b/scripts/screen-capture.sh
deleted file mode 100755
index a1fd8997..00000000
--- a/scripts/screen-capture.sh
+++ /dev/null
@@ -1,704 +0,0 @@
-#!/bin/env bash
-
-# Script name
-SCRIPT_NAME=$(basename "$0")
-
-# Function to display usage
-usage() {
- cat < [options]
-
-Commands:
- screenshot Take a screenshot
- Targets:
- selection - Screenshot selected area
- eDP-1 - Screenshot eDP-1 display
- HDMI-A-1 - Screenshot HDMI-A-1 display
- both - Screenshot both displays
-
- record Start/stop recording (with audio)
- Targets:
- selection - Record selected area
- eDP-1 - Record eDP-1 display
- HDMI-A-1 - Record HDMI-A-1 display
- stop - Stop current recording
-
- record-noaudio Start/stop recording (no audio)
- Targets:
- selection - Record selected area without audio
- eDP-1 - Record eDP-1 display without audio
- HDMI-A-1 - Record HDMI-A-1 display without audio
- stop - Stop current recording
-
- record-hq Start/stop high-quality recording (for YouTube)
- Targets:
- selection - Record selected area in high quality
- eDP-1 - Record eDP-1 display in high quality
- HDMI-A-1 - Record HDMI-A-1 display in high quality
- stop - Stop current recording
-
- record-gif Start/stop GIF recording
- Targets:
- selection - Record selected area as GIF
- eDP-1 - Record eDP-1 display as GIF
- HDMI-A-1 - Record HDMI-A-1 display as GIF
- stop - Stop current recording
-
- status Check if recording is active (exit 0 if recording, 1 if not)
-
- convert [file] Convert recordings
- Formats:
- webm - Convert latest MKV to WebM (or specify file)
- iphone - Convert latest MKV to iPhone format (or specify file)
- youtube - Convert latest video to YouTube format (or specify file)
- gif - Convert latest video to GIF (or specify file)
-
- Optional [file] parameter:
- - If not provided, converts the latest recorded video
- - If provided, converts the specified file (full path or filename in Recordings folder)
-
-Examples:
- $SCRIPT_NAME screenshot selection
- $SCRIPT_NAME record eDP-1
- $SCRIPT_NAME record-noaudio selection
- $SCRIPT_NAME record-hq eDP-1
- $SCRIPT_NAME record-gif selection
- $SCRIPT_NAME record stop
- $SCRIPT_NAME convert gif # Convert latest video to GIF
- $SCRIPT_NAME convert youtube # Convert latest video for YouTube
- $SCRIPT_NAME convert webm /path/to/video.mkv # Convert specific file to WebM
-
-EOF
- exit 1
-}
-
-# Check if no arguments provided
-if [ $# -eq 0 ]; then
- usage
-fi
-
-# Function to send screenshot notification with action buttons
-send_screenshot_notification() {
- local full_path="$1"
- local save_dir=$(dirname "$full_path")
-
- ACTION=$(notify-send -a "Modus" -i "$full_path" "Screenshot saved" "in $full_path" \
- -A "view=View" -A "edit=Edit" -A "open=Open Folder")
-
- case "$ACTION" in
- view) xdg-open "$full_path" ;;
- edit) swappy -f "$full_path" ;;
- open) xdg-open "$save_dir" ;;
- esac
-}
-
-# Function to send recording notification with action buttons
-send_recording_notification() {
- local full_path="$1"
- local save_dir=$(dirname "$full_path")
-
- ACTION=$(notify-send -a "Modus" -i "camera-video-symbolic" "Recording saved" "in $full_path" \
- -A "view=View" -A "open=Open Folder")
-
- case "$ACTION" in
- view) xdg-open "$full_path" ;;
- open) xdg-open "$save_dir" ;;
- esac
-}
-
-wf-recorder_check() {
- if pgrep -x "wf-recorder" >/dev/null; then
- pkill -INT -x wf-recorder
- # Get the recording file path and send notification with actions
- if [ -f /tmp/recording.txt ]; then
- recording_file=$(cat /tmp/recording.txt)
- wl-copy <"$recording_file"
- send_recording_notification "$recording_file"
- else
- notify-send "Recording stopped" "wf-recorder process terminated"
- fi
- # Clean up recording start time file
- rm -f /tmp/recording_start_time.txt
- exit 0
- fi
-}
-
-# Function to record with standard settings
-record_video() {
- local output_file="$1"
- shift
-
- wf-recorder "$@" -f "$output_file" -c libvpx-vp9 --pixel-format yuv420p -F "eq=brightness=0.12:contrast=1.1"
-}
-
-# Function to record without audio
-record_video_noaudio() {
- local output_file="$1"
- shift
-
- wf-recorder "$@" -f "$output_file" -c libvpx-vp9 --pixel-format yuv420p -F "eq=brightness=0.12:contrast=1.1" --no-audio
-}
-
-record_high_quality() {
- local output_file="$1"
- shift
-
- # High quality settings for YouTube uploads
- # - h264_vaapi for hardware encoding (if available) or libx264 for software
- # - yuv420p pixel format for maximum compatibility
- # - High bitrate (8000k) for quality
- # - GOP size of 30 for better seeking
- # - Preset 'slow' for better compression efficiency
- # - CRF 18 for high quality (lower = better quality, 0-51 scale)
- # - Audio at 192k bitrate
- # - 60 FPS for smooth motion
- # - No color filters to maintain original colors
-
- # Check if VAAPI hardware encoding is available
- if vainfo &>/dev/null && wf-recorder --help | grep -q "h264_vaapi"; then
- # Use hardware encoding for better performance
- wf-recorder "$@" -f "$output_file" \
- -c h264_vaapi \
- -p "preset=slow" \
- -p "crf=18" \
- -r 60 \
- -b 8000000 \
- -B 192000 \
- --pixel-format yuv420p \
- -g 30
- else
- # Fallback to software encoding
- wf-recorder "$@" -f "$output_file" \
- -c libx264 \
- -p "preset=slow" \
- -p "crf=18" \
- -r 60 \
- -b 8000000 \
- -B 192000 \
- --pixel-format yuv420p \
- -g 30
- fi
-}
-
-record_gif() {
- local output_file="$1"
- shift
-
- # Record temporary video first (MKV format for better quality)
- local temp_video="/tmp/gif_recording_$(date +%s).mkv"
- echo "$temp_video" >/tmp/gif_temp_video.txt
-
- # GIF-optimized recording settings:
- # - Lower framerate (15 fps) for smaller file size
- # - No audio recording
- # - Standard codec for compatibility
- wf-recorder "$@" -f "$temp_video" \
- -c libvpx-vp9 \
- -r 15 \
- --pixel-format yuv420p \
- --no-audio
-
- # After recording stops, convert to GIF
- if [ -f "$temp_video" ]; then
- notify-send "Converting to GIF" "Processing recording..."
-
- # Create high-quality GIF with optimized palette
- # Using ffmpeg with palette generation for better colors
- ffmpeg -i "$temp_video" \
- -vf "fps=15,scale=iw:-1:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=128:stats_mode=diff[p];[s1][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle" \
- -loop 0 \
- "$output_file" 2>/tmp/gif_conversion.log
-
- if [ $? -eq 0 ]; then
- # Clean up temp file
- rm -f "$temp_video"
- rm -f /tmp/gif_temp_video.txt
-
- # Copy to clipboard and send notification with actions
- wl-copy <"$output_file"
- send_recording_notification "$output_file"
- else
- error=$(cat /tmp/gif_conversion.log | tail -n 5)
- notify-send "GIF Conversion Failed" "Error: $error"
- rm -f "$temp_video"
- rm -f /tmp/gif_temp_video.txt
- fi
- fi
-}
-
-# Function to find the latest video file for conversion
-find_latest_video() {
- local format="$1"
- local recording_dir="${HOME}/Videos/Recordings"
-
- case "$format" in
- "webm"|"iphone")
- # For webm and iphone, only look for MKV files
- find "$recording_dir" -name "*.mkv" -type f -printf '%T@ %p\n' 2>/dev/null | sort -n | tail -1 | cut -d' ' -f2-
- ;;
- "youtube"|"gif")
- # For youtube and gif, look for both MKV and MP4 files
- find "$recording_dir" \( -name "*.mkv" -o -name "*.mp4" \) -type f -printf '%T@ %p\n' 2>/dev/null | sort -n | tail -1 | cut -d' ' -f2-
- ;;
- *)
- echo ""
- ;;
- esac
-}
-
-# Function to resolve video file path
-resolve_video_file() {
- local format="$1"
- local file_param="$2"
- local recording_dir="${HOME}/Videos/Recordings"
-
- if [ -n "$file_param" ]; then
- # File parameter provided
- if [ -f "$file_param" ]; then
- # Full path provided and exists
- echo "$file_param"
- elif [ -f "$recording_dir/$file_param" ]; then
- # Filename provided, exists in recordings folder
- echo "$recording_dir/$file_param"
- else
- echo ""
- fi
- else
- # No file parameter, find latest
- find_latest_video "$format"
- fi
-}
-
-# Parse command
-COMMAND="$1"
-TARGET="$2"
-FILE_PARAM="$3" # Optional file parameter for convert command
-
-# Set up file paths
-IMG="${HOME}/Pictures/Screenshots/$(date +%Y-%m-%d_%H-%m-%s).png"
-VID="${HOME}/Videos/Recordings/$(date +%Y-%m-%d_%H-%m-%s).mkv"
-
-case "$COMMAND" in
-"screenshot")
- case "$TARGET" in
- "selection")
- grim -g "$(slurp)" "$IMG"
- wl-copy <"$IMG"
- send_screenshot_notification "$IMG"
- ;;
- "eDP-1")
- grim -c -o eDP-1 "$IMG"
- wl-copy <"$IMG"
- send_screenshot_notification "$IMG"
- ;;
- "HDMI-A-1")
- grim -c -o HDMI-A-1 "$IMG"
- wl-copy <"$IMG"
- send_screenshot_notification "$IMG"
- ;;
- "both")
- grim -c -o eDP-1 "${IMG//.png/-eDP-1.png}"
- grim -c -o HDMI-A-1 "${IMG//.png/-HDMI-A-1.png}"
- montage "${IMG//.png/-eDP-1.png}" "${IMG//.png/-HDMI-A-1.png}" -tile 2x1 -geometry +0+0 "$IMG"
- wl-copy <"$IMG"
- rm "${IMG//.png/-eDP-1.png}" "${IMG//.png/-HDMI-A-1.png}"
- send_screenshot_notification "$IMG"
- ;;
- *)
- echo "Error: Invalid screenshot target '$TARGET'"
- usage
- ;;
- esac
- ;;
-
-"record")
- case "$TARGET" in
- "stop")
- wf-recorder_check
- ;;
- "selection")
- wf-recorder_check
- echo "$VID" >/tmp/recording.txt
- date +%s >/tmp/recording_start_time.txt
- record_video "$VID" -g "$(slurp)"
- ;;
- "eDP-1")
- wf-recorder_check
- echo "$VID" >/tmp/recording.txt
- date +%s >/tmp/recording_start_time.txt
- record_video "$VID" -a -o eDP-1
- ;;
- "HDMI-A-1")
- wf-recorder_check
- echo "$VID" >/tmp/recording.txt
- date +%s >/tmp/recording_start_time.txt
- record_video "$VID" -a -o HDMI-A-1
- ;;
- *)
- echo "Error: Invalid record target '$TARGET'"
- usage
- ;;
- esac
- ;;
-
-"record-noaudio")
- case "$TARGET" in
- "stop")
- wf-recorder_check
- ;;
- "selection")
- wf-recorder_check
- echo "$VID" >/tmp/recording.txt
- date +%s >/tmp/recording_start_time.txt
- record_video_noaudio "$VID" -g "$(slurp)"
- ;;
- "eDP-1")
- wf-recorder_check
- echo "$VID" >/tmp/recording.txt
- date +%s >/tmp/recording_start_time.txt
- record_video_noaudio "$VID" -o eDP-1
- ;;
- "HDMI-A-1")
- wf-recorder_check
- echo "$VID" >/tmp/recording.txt
- date +%s >/tmp/recording_start_time.txt
- record_video_noaudio "$VID" -o HDMI-A-1
- ;;
- *)
- echo "Error: Invalid record-noaudio target '$TARGET'"
- usage
- ;;
- esac
- ;;
-
-"record-hq")
- # Change file extension to mp4 for high quality recordings
- VID_HQ="${HOME}/Videos/Recordings/$(date +%Y-%m-%d_%H-%m-%s)-hq.mp4"
-
- case "$TARGET" in
- "stop")
- wf-recorder_check
- ;;
- "selection")
- wf-recorder_check
- echo "$VID_HQ" >/tmp/recording.txt
- date +%s >/tmp/recording_start_time.txt
- notify-send "High Quality Recording" "Starting YouTube-quality recording..."
- record_high_quality "$VID_HQ" -g "$(slurp)"
- ;;
- "eDP-1")
- wf-recorder_check
- echo "$VID_HQ" >/tmp/recording.txt
- date +%s >/tmp/recording_start_time.txt
- notify-send "High Quality Recording" "Starting YouTube-quality recording on eDP-1..."
- record_high_quality "$VID_HQ" -a -o eDP-1
- ;;
- "HDMI-A-1")
- wf-recorder_check
- echo "$VID_HQ" >/tmp/recording.txt
- date +%s >/tmp/recording_start_time.txt
- notify-send "High Quality Recording" "Starting YouTube-quality recording on HDMI-A-1..."
- record_high_quality "$VID_HQ" -a -o HDMI-A-1
- ;;
- *)
- echo "Error: Invalid record-hq target '$TARGET'"
- usage
- ;;
- esac
- ;;
-
-"record-gif")
- # GIF files go to a specific location
- GIF="${HOME}/Videos/Recordings/$(date +%Y-%m-%d_%H-%m-%s).gif"
-
- case "$TARGET" in
- "stop")
- wf-recorder_check
- ;;
- "selection")
- wf-recorder_check
- echo "$GIF" >/tmp/recording.txt
- date +%s >/tmp/recording_start_time.txt
- notify-send "GIF Recording" "Starting GIF recording (15 FPS)..."
- record_gif "$GIF" -g "$(slurp)"
- ;;
- "eDP-1")
- wf-recorder_check
- echo "$GIF" >/tmp/recording.txt
- date +%s >/tmp/recording_start_time.txt
- notify-send "GIF Recording" "Starting GIF recording on eDP-1..."
- record_gif "$GIF" -o eDP-1
- ;;
- "HDMI-A-1")
- wf-recorder_check
- echo "$GIF" >/tmp/recording.txt
- date +%s >/tmp/recording_start_time.txt
- notify-send "GIF Recording" "Starting GIF recording on HDMI-A-1..."
- record_gif "$GIF" -o HDMI-A-1
- ;;
- *)
- echo "Error: Invalid record-gif target '$TARGET'"
- usage
- ;;
- esac
- ;;
-
-"status")
- # Check if wf-recorder is running
- if pgrep -x "wf-recorder" >/dev/null; then
- echo "true"
- exit 0
- else
- echo "false"
- exit 0
- fi
- ;;
-
-"convert")
- case "$TARGET" in
- "webm")
- # Check if ffmpeg is installed
- if ! command -v ffmpeg >/dev/null 2>&1; then
- notify-send "Error" "ffmpeg is not installed. Please install it to use this feature."
- exit 1
- fi
-
- # Resolve the video file to convert
- video_file=$(resolve_video_file "webm" "$FILE_PARAM")
-
- if [ -z "$video_file" ] || [ ! -f "$video_file" ]; then
- if [ -n "$FILE_PARAM" ]; then
- notify-send "WebM Conversion Error" "File not found: $FILE_PARAM"
- else
- notify-send "WebM Conversion Error" "No MKV files found in Recordings folder"
- fi
- exit 1
- fi
-
- # Ensure it's an MKV file
- if [[ "$video_file" != *.mkv ]]; then
- notify-send "WebM Conversion Error" "Only MKV files can be converted to WebM. Found: $(basename "$video_file")"
- exit 1
- fi
-
- webm_file="${video_file%.mkv}.webm"
-
- # Check if webm version doesn't already exist
- if [ -f "$webm_file" ]; then
- notify-send "WebM Conversion Skipped" "WebM version already exists: $(basename "$webm_file")"
- exit 0
- fi
-
- notify-send "Converting to WebM" "Processing: $(basename "$video_file")"
-
- # Convert the file
- ffmpeg -y -i "$video_file" -c:v libvpx -b:v 1M -c:a libvorbis "$webm_file" 2>/tmp/ffmpeg_error.log
-
- if [ $? -eq 0 ]; then
- file_size=$(du -h "$webm_file" | cut -f1)
- notify-send "WebM Conversion Success" "$(basename "$video_file") โ $(basename "$webm_file") ($file_size)"
- else
- error=$(cat /tmp/ffmpeg_error.log | tail -n 5)
- notify-send "WebM Conversion Failed" "Error: $error"
- fi
- ;;
-
- "iphone")
- # Check if ffmpeg is installed
- if ! command -v ffmpeg >/dev/null 2>&1; then
- notify-send "Error" "ffmpeg is not installed. Please install it to use this feature."
- exit 1
- fi
-
- # Resolve the video file to convert
- video_file=$(resolve_video_file "iphone" "$FILE_PARAM")
-
- if [ -z "$video_file" ] || [ ! -f "$video_file" ]; then
- if [ -n "$FILE_PARAM" ]; then
- notify-send "iPhone Conversion Error" "File not found: $FILE_PARAM"
- else
- notify-send "iPhone Conversion Error" "No MKV files found in Recordings folder"
- fi
- exit 1
- fi
-
- # Ensure it's an MKV file
- if [[ "$video_file" != *.mkv ]]; then
- notify-send "iPhone Conversion Error" "Only MKV files can be converted for iPhone. Found: $(basename "$video_file")"
- exit 1
- fi
-
- base_filename=$(basename "$video_file")
-
- # Skip files with "iphone" in the filename
- if [[ $base_filename == *"iphone"* ]]; then
- notify-send "iPhone Conversion Skipped" "File already appears to be iPhone format: $(basename "$video_file")"
- exit 0
- fi
-
- iphone_file="${video_file%.mkv}-iphone.mp4"
-
- # Check if iPhone version doesn't already exist
- if [ -f "$iphone_file" ]; then
- notify-send "iPhone Conversion Skipped" "iPhone version already exists: $(basename "$iphone_file")"
- exit 0
- fi
-
- notify-send "Converting for iPhone" "Processing: $(basename "$video_file")"
-
- # Convert the file
- ffmpeg -y -i "$video_file" -vcodec h264 -acodec aac "$iphone_file" 2>/tmp/ffmpeg_error.log
-
- if [ $? -eq 0 ]; then
- file_size=$(du -h "$iphone_file" | cut -f1)
- notify-send "iPhone Conversion Success" "$(basename "$video_file") โ $(basename "$iphone_file") ($file_size)"
- else
- error=$(cat /tmp/ffmpeg_error.log | tail -n 5)
- notify-send "iPhone Conversion Failed" "Error: $error"
- fi
- ;;
-
- "youtube")
- # Check if ffmpeg is installed
- if ! command -v ffmpeg >/dev/null 2>&1; then
- notify-send "Error" "ffmpeg is not installed. Please install it to use this feature."
- exit 1
- fi
-
- # Resolve the video file to convert
- video_file=$(resolve_video_file "youtube" "$FILE_PARAM")
-
- if [ -z "$video_file" ] || [ ! -f "$video_file" ]; then
- if [ -n "$FILE_PARAM" ]; then
- notify-send "YouTube Conversion Error" "File not found: $FILE_PARAM"
- else
- notify-send "YouTube Conversion Error" "No video files found in Recordings folder"
- fi
- exit 1
- fi
-
- base_filename=$(basename "$video_file")
-
- # Skip files already marked as YouTube uploads
- if [[ $base_filename == *"youtube"* ]]; then
- notify-send "YouTube Conversion Skipped" "File already appears to be YouTube format: $(basename "$video_file")"
- exit 0
- fi
-
- # Create YouTube optimized filename
- youtube_file="${video_file%.*}-youtube.mp4"
-
- # Check if YouTube version doesn't already exist
- if [ -f "$youtube_file" ]; then
- notify-send "YouTube Conversion Skipped" "YouTube version already exists: $(basename "$youtube_file")"
- exit 0
- fi
-
- notify-send "Converting for YouTube" "Processing: $(basename "$video_file")"
-
- # YouTube recommended settings:
- # - H.264 codec with High profile
- # - 1080p or source resolution
- # - 60fps or source framerate
- # - High bitrate for quality (8-12 Mbps for 1080p60)
- # - AAC audio at 384kbps
- # - yuv420p pixel format for compatibility
- # - Keyframe interval of 2 seconds (GOP)
- # - No filters to preserve original colors
-
- ffmpeg -y -i "$video_file" \
- -c:v libx264 \
- -profile:v high \
- -preset slow \
- -crf 18 \
- -pix_fmt yuv420p \
- -c:a aac \
- -b:a 384k \
- -movflags +faststart \
- "$youtube_file" 2>/tmp/ffmpeg_error.log
-
- if [ $? -eq 0 ]; then
- file_size=$(du -h "$youtube_file" | cut -f1)
- notify-send "YouTube Conversion Success" "$(basename "$video_file") โ $(basename "$youtube_file") ($file_size)"
- else
- error=$(cat /tmp/ffmpeg_error.log | tail -n 5)
- notify-send "YouTube Conversion Failed" "Error converting $(basename "$video_file"): $error"
- fi
- ;;
-
- "gif")
- # Check if ffmpeg is installed
- if ! command -v ffmpeg >/dev/null 2>&1; then
- notify-send "Error" "ffmpeg is not installed. Please install it to use this feature."
- exit 1
- fi
-
- # Resolve the video file to convert
- video_file=$(resolve_video_file "gif" "$FILE_PARAM")
-
- if [ -z "$video_file" ] || [ ! -f "$video_file" ]; then
- if [ -n "$FILE_PARAM" ]; then
- notify-send "GIF Conversion Error" "File not found: $FILE_PARAM"
- else
- notify-send "GIF Conversion Error" "No video files found in Recordings folder"
- fi
- exit 1
- fi
-
- base_filename=$(basename "$video_file")
-
- # Skip files already GIFs
- if [[ $base_filename == *.gif ]]; then
- notify-send "GIF Conversion Skipped" "File is already a GIF: $(basename "$video_file")"
- exit 0
- fi
-
- # Create GIF filename
- gif_file="${video_file%.*}.gif"
-
- # Check if GIF version doesn't already exist
- if [ -f "$gif_file" ]; then
- notify-send "GIF Conversion Skipped" "GIF version already exists: $(basename "$gif_file")"
- exit 0
- fi
-
- notify-send "Converting to GIF" "Processing: $(basename "$video_file")"
-
- # Get video dimensions for scaling
- width=$(ffprobe -v error -select_streams v:0 -show_entries stream=width -of csv=s=x:p=0 "$video_file")
-
- # Scale down if wider than 800px to keep file size reasonable
- if [ "$width" -gt 800 ]; then
- scale_filter="scale=800:-1:flags=lanczos,"
- else
- scale_filter=""
- fi
-
- # Create high-quality GIF with optimized palette
- ffmpeg -i "$video_file" \
- -vf "${scale_filter}fps=15,split[s0][s1];[s0]palettegen=max_colors=256:stats_mode=diff[p];[s1][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle" \
- -loop 0 \
- "$gif_file" 2>/tmp/ffmpeg_error.log
-
- if [ $? -eq 0 ]; then
- file_size=$(du -h "$gif_file" | cut -f1)
- notify-send "GIF Conversion Success" "$(basename "$video_file") โ $(basename "$gif_file") ($file_size)"
- send_recording_notification "$gif_file"
- else
- error=$(cat /tmp/ffmpeg_error.log | tail -n 5)
- notify-send "GIF Conversion Failed" "Error converting $(basename "$video_file"): $error"
- fi
- ;;
-
- *)
- echo "Error: Invalid convert format '$TARGET'"
- usage
- ;;
- esac
- ;;
-
-*)
- echo "Error: Invalid command '$COMMAND'"
- usage
- ;;
-esac
diff --git a/services/auth.py b/services/auth.py
deleted file mode 100644
index 3a7b0708..00000000
--- a/services/auth.py
+++ /dev/null
@@ -1,348 +0,0 @@
-import json
-import os
-import subprocess
-import time
-from datetime import datetime
-from pathlib import Path
-from urllib.parse import parse_qs, urlparse
-
-import pyotp
-from PIL import Image
-from pyzbar.pyzbar import decode
-
-import config.data as data
-
-
-def get_otp_file_path():
- """Returns the path to the OTP file inside the cache directory."""
- cache_dir = Path(data.CACHE_DIR) / "otp"
- # Create the directory if it doesn't exist
- cache_dir.mkdir(parents=True, exist_ok=True)
-
- file_path = cache_dir / "otp_codes.json"
-
- # Create the file if it doesn't exist
- if not file_path.exists():
- with open(file_path, "w") as f:
- json.dump([], f)
- print(f"File {file_path} created successfully!")
-
- return file_path
-
-
-def capture_selected_area(filename="/tmp/screenshot.png"):
- """
- Uses slurp to let the user select an area of the screen,
- then captures that area using grim.
- """
- try:
- # slurp returns coordinates in format: x,y widthxheight (e.g. 100,200 300x400)
- result = subprocess.run(["slurp"], check=True, capture_output=True, text=True)
- geometry = result.stdout.strip()
- if not geometry:
- print("No area selected with slurp.")
- return None
- except subprocess.CalledProcessError as e:
- print("Error selecting area with slurp:", e)
- return None
-
- try:
- # grim uses -g to capture a specific region
- subprocess.run(["grim", "-g", geometry, filename], check=True)
- except subprocess.CalledProcessError as e:
- print("Error capturing screenshot with grim:", e)
- return None
-
- return filename
-
-
-def read_and_save_to_json():
- # Get the full path to the JSON file
- json_file = get_otp_file_path()
-
- screenshot = capture_selected_area()
- if screenshot is None:
- print("Failed to capture the selected area.")
- return False
-
- # Short delay to ensure the file is written
- time.sleep(1)
-
- # Open the captured image
- try:
- img = Image.open(screenshot)
- except Exception as e:
- print("Error opening the image:", e)
- return False
-
- # Decode QR Code(s) from the image
- decoded_objects = decode(img)
- if not decoded_objects:
- print("No QR Code detected in the selected area.")
- return False
-
- results = []
-
- for obj in decoded_objects:
- data = obj.data.decode("utf-8")
- print("QR Code detected:", data)
-
- result_entry = {"timestamp": datetime.now().isoformat(), "qr_data": data}
-
- if data.startswith("otpauth://"):
- parsed = urlparse(data)
- query = parse_qs(parsed.query)
-
- # Extract the secret properly
- secret = query.get("secret", [None])[0]
- issuer_from_query = query.get("issuer", [None])[0]
-
- # Extract the label (path), which may contain issuer and account
- label = parsed.path.lstrip("/") if parsed.path else ""
- account_name = label
- issuer_from_path = None
-
- if ":" in label:
- parts = label.split(":", 1)
- issuer_from_path = parts[0]
- account_name = parts[1]
-
- # Prefer issuer from path if available, otherwise use from query
- issuer = issuer_from_path or issuer_from_query
-
- # Extract the period (default is 30 seconds)
- period = int(query.get("period", ["30"])[0])
-
- # Create TOTP object with the correct interval
- totp = pyotp.TOTP(secret, interval=period)
- current_otp = totp.now()
- print(f"Generated OTP: {current_otp} (valid for {period} seconds)")
-
- result_entry.update(
- {
- "type": "otp",
- "secret": secret,
- "issuer": issuer,
- "account_name": account_name,
- }
- )
- else:
- result_entry["type"] = "unknown"
- print("Unrecognized format. Expected a URI like otpauth://")
- return False
-
- results.append(result_entry)
-
- # Load existing data if the file exists
- existing_data = []
- if os.path.exists(json_file):
- try:
- with open(json_file, "r") as f:
- existing_data = json.load(f)
- except json.JSONDecodeError:
- print(f"Error reading the file {json_file}. Creating a new one.")
-
- # Append new results
- existing_data.extend(results)
-
- # Save to JSON file
- with open(json_file, "w") as f:
- json.dump(existing_data, f, indent=4)
-
- print(f"OTP data saved to {json_file}")
- return True
-
-
-def CodeOTP(uri):
- parsed = urlparse(uri)
- query = parse_qs(parsed.query)
- secret = query.get("secret", [None])[0]
-
- if secret is None:
- return None
- else:
- totp = pyotp.TOTP(secret)
- return totp.now()
-
-
-# TOTP/OTP utility functions
-def generate_totp(secret: str) -> str:
- """Generate TOTP code from secret."""
- try:
- return pyotp.TOTP(secret).now()
- except Exception as e:
- print(f"Error generating TOTP: {e}")
- return None
-
-
-def get_time_remaining() -> int:
- """Get seconds remaining until next token refresh."""
- return 30 - (int(time.time()) % 30)
-
-
-def get_time_remaining_with_blink() -> str:
- """Get time remaining with blinking effect."""
- time_remaining = get_time_remaining()
- current_second = int(time.time())
- should_blink = current_second % 2 == 0
-
- if should_blink:
- return f"{time_remaining}s "
- else:
- return f"{time_remaining}s"
-
-
-def validate_base32_secret(secret: str) -> dict:
- """Validate and clean Base32 secret."""
- import base64
- import re
-
- try:
- # Clean up the secret - remove spaces, dashes, and convert to uppercase
- clean_secret = secret.replace(" ", "").replace("-", "").replace("_", "").upper()
-
- # Remove any non-base32 characters
- clean_secret = re.sub(r"[^A-Z2-7]", "", clean_secret)
-
- # Add padding if needed (Base32 requires padding to multiple of 8)
- while len(clean_secret) % 8 != 0:
- clean_secret += "="
-
- # Validate Base32 format
- try:
- base64.b32decode(clean_secret)
- except Exception as e:
- return {"success": False, "error": f"Invalid Base32 secret: {str(e)}"}
-
- # Test if the secret can generate a valid TOTP
- try:
- test_totp = pyotp.TOTP(clean_secret)
- test_code = test_totp.now()
- if not test_code or len(test_code) != 6:
- raise ValueError("Generated invalid TOTP code")
- except Exception as e:
- return {"success": False, "error": f"Cannot generate TOTP: {str(e)}"}
-
- return {"success": True, "secret": clean_secret}
- except Exception as e:
- return {"success": False, "error": f"Unexpected error: {str(e)}"}
-
-
-def parse_otpauth_uri(uri: str, account_name: str = "") -> dict:
- """Parse otpauth URI and extract account information."""
- try:
- parsed = urlparse(uri)
- if parsed.scheme != "otpauth" or parsed.netloc != "totp":
- return {
- "success": False,
- "error": "Only otpauth://totp/ URIs are supported",
- }
-
- if not account_name:
- account_path = parsed.path.lstrip("/")
- if ":" in account_path:
- issuer, extracted_name = account_path.split(":", 1)
- account_name = extracted_name
- else:
- account_name = account_path
-
- params = parse_qs(parsed.query)
- secret = params.get("secret", [""])[0]
- issuer = params.get("issuer", [""])[0]
- algorithm = params.get("algorithm", ["SHA1"])[0]
- digits = int(params.get("digits", ["6"])[0])
- period = int(params.get("period", ["30"])[0])
-
- if not secret:
- return {"success": False, "error": "No secret found in URI"}
-
- return {
- "success": True,
- "account_name": account_name,
- "secret": secret,
- "issuer": issuer,
- "algorithm": algorithm,
- "digits": digits,
- "period": period,
- }
- except Exception as e:
- return {"success": False, "error": f"Error parsing otpauth URI: {str(e)}"}
-
-
-def scan_qr_and_add_account(account_name: str, secrets_file_path: str) -> dict:
- """Scan QR code and add OTP account to secrets file."""
- try:
- # Capture QR code from screen
- screenshot_path = capture_selected_area()
- if not screenshot_path:
- return {"success": False, "error": "QR scan cancelled or failed"}
-
- # Decode QR code
- try:
- img = Image.open(screenshot_path)
- decoded_objects = decode(img)
-
- if not decoded_objects:
- return {
- "success": False,
- "error": "No QR code detected in selected area",
- }
-
- # Process the first QR code found
- qr_data = decoded_objects[0].data.decode("utf-8")
- print(f"QR Code detected: {qr_data}")
-
- if qr_data.startswith("otpauth://"):
- # Parse otpauth URI
- result = parse_otpauth_uri(qr_data, account_name)
- if not result["success"]:
- return result
-
- # Load existing secrets
- secrets = {}
- if os.path.exists(secrets_file_path):
- try:
- with open(secrets_file_path, "r", encoding="utf-8") as f:
- secrets = json.load(f)
- except Exception as e:
- print(f"Error loading secrets: {e}")
-
- # Add new account
- secrets[result["account_name"]] = {
- "secret": result["secret"],
- "issuer": result["issuer"],
- "algorithm": result["algorithm"],
- "digits": result["digits"],
- "period": result["period"],
- }
-
- # Save secrets
- try:
- os.makedirs(os.path.dirname(secrets_file_path), exist_ok=True)
- with open(secrets_file_path, "w", encoding="utf-8") as f:
- json.dump(secrets, f, indent=2)
- except Exception as e:
- return {
- "success": False,
- "error": f"Error saving secrets: {str(e)}",
- }
-
- display_name = (
- f"{result['issuer']} - {result['account_name']}"
- if result["issuer"]
- else result["account_name"]
- )
- return {
- "success": True,
- "account_name": result["account_name"],
- "display_name": display_name,
- "message": f"Successfully added OTP account: {display_name}",
- }
- else:
- return {"success": False, "error": "QR code is not an otpauth URI"}
-
- except Exception as e:
- return {"success": False, "error": f"Error processing QR code: {str(e)}"}
-
- except Exception as e:
- return {"success": False, "error": f"Error during QR scan: {str(e)}"}
diff --git a/services/battery.py b/services/battery.py
deleted file mode 100644
index a28cceb8..00000000
--- a/services/battery.py
+++ /dev/null
@@ -1,251 +0,0 @@
-import psutil
-from gi.repository import GLib
-from pydbus import SystemBus
-
-from fabric.core import Property, Service, Signal
-
-DeviceState = {
- 0: "UNKNOWN",
- 1: "CHARGING",
- 2: "DISCHARGING",
- 3: "EMPTY",
- 4: "FULLY_CHARGED",
- 5: "PENDING_CHARGE",
- 6: "PENDING_DISCHARGE",
-}
-
-
-class Battery(Service):
- @staticmethod
- def seconds_to_hours_minutes(seconds):
- hours = seconds // 3600
- minutes = (seconds % 3600) // 60
- return f"{hours}h {minutes}m" if hours else f"{minutes}m"
-
- @staticmethod
- def get_battery_icon_level(percentage):
- """Get battery icon level based on percentage"""
- if percentage >= 90:
- return "100"
- elif percentage >= 80:
- return "090"
- elif percentage >= 70:
- return "080"
- elif percentage >= 60:
- return "070"
- elif percentage >= 50:
- return "060"
- elif percentage >= 40:
- return "050"
- elif percentage >= 30:
- return "040"
- elif percentage >= 20:
- return "030"
- elif percentage >= 10:
- return "020"
- else:
- return "010"
-
- @staticmethod
- def get_battery_icon_file(percentage, is_charging, base_path=""):
- """Get battery icon file path"""
- level = Battery.get_battery_icon_level(percentage)
- suffix = "-charging" if is_charging else ""
- return f"{base_path}battery/battery-{level}{suffix}.svg"
-
- @staticmethod
- def get_profile_display_name(profile: str) -> str:
- """Get user-friendly display name for power profile"""
- profile_names = {
- "power-saver": "Power Saver",
- "powersave": "Power Saver",
- "power_saver": "Power Saver",
- "balanced": "Balanced",
- "balance": "Balanced",
- "performance": "Performance",
- "performance-mode": "Performance",
- }
- return profile_names.get(profile, profile.title())
-
- @Signal
- def changed(self) -> None: ...
-
- @Signal
- def profile_changed(self, value: str) -> None: ...
-
- @Property(int, "readable")
- def percentage(self):
- if self._use_psutil_fallback:
- if self._psutil_battery:
- return int(self._psutil_battery.percent)
- return 0
- return int(self._battery.Percentage)
-
- @Property(str, "readable")
- def temperature(self):
- if self._use_psutil_fallback:
- return "N/A" # psutil doesn't provide temperature
- return (
- f"{self._battery.Temperature}ยฐC"
- if hasattr(self._battery, "Temperature")
- else "N/A"
- )
-
- @Property(str, "readable")
- def time_to_empty(self):
- if self._use_psutil_fallback:
- if self._psutil_battery and hasattr(self._psutil_battery, "secsleft"):
- return self.seconds_to_hours_minutes(self._psutil_battery.secsleft)
- return "N/A"
- return self.seconds_to_hours_minutes(getattr(self._battery, "TimeToEmpty", 0))
-
- @Property(str, "readable")
- def time_to_full(self):
- if self._use_psutil_fallback:
- return "N/A" # psutil doesn't provide time to full
- return self.seconds_to_hours_minutes(getattr(self._battery, "TimeToFull", 0))
-
- @Property(str, "readable")
- def icon_name(self):
- if self._use_psutil_fallback:
- return "battery" # Generic icon name for psutil fallback
- return self._battery.IconName
-
- @Property(str, "readable")
- def state(self):
- if self._use_psutil_fallback:
- if self._psutil_battery:
- # psutil returns power_plugged boolean, convert to state
- if self._psutil_battery.power_plugged:
- if self._psutil_battery.percent >= 100:
- return "FULLY_CHARGED"
- else:
- return "CHARGING"
- else:
- return "DISCHARGING"
- return "UNKNOWN"
- return DeviceState.get(self._battery.State, "UNKNOWN")
-
- @Property(str, "readable")
- def capacity(self):
- if self._use_psutil_fallback:
- return "N/A" # psutil doesn't provide capacity info
- return f"{int(self._battery.Capacity)}%"
-
- @Property(bool, "readable", default_value=False)
- def is_present(self):
- if self._use_psutil_fallback:
- return self._psutil_battery is not None
- return self._battery.IsPresent
-
- @Property(str, "readable")
- def power_profile(self):
- if hasattr(self, "_profile_proxy") and self._profile_proxy:
- try:
- return self._profile_proxy.ActiveProfile
- except Exception:
- return None
- return None
-
- @Property(list, "readable")
- def available_profiles(self):
- if hasattr(self, "_profile_proxy") and self._profile_proxy:
- try:
- profiles = []
- for p in self._profile_proxy.Profiles:
- if hasattr(p, "Profile"):
- profiles.append(p.Profile)
- elif isinstance(p, dict) and "Profile" in p:
- profiles.append(p["Profile"])
- elif isinstance(p, str):
- profiles.append(p)
- return profiles
- except Exception:
- return []
- return []
-
- def change_power_profile(self, profile: str) -> bool:
- if not hasattr(self, "_profile_proxy") or not self._profile_proxy:
- return False
-
- # Get available profiles using the same logic as available_profiles property
- available_profiles = []
- try:
- for p in self._profile_proxy.Profiles:
- if hasattr(p, "Profile"):
- available_profiles.append(p.Profile)
- elif isinstance(p, dict) and "Profile" in p:
- available_profiles.append(p["Profile"])
- elif isinstance(p, str):
- available_profiles.append(p)
- except Exception:
- return False
-
- if profile not in available_profiles:
- return False
-
- try:
- self._profile_proxy.ActiveProfile = profile
- self.profile_changed.emit(profile)
- self.changed.emit()
- return True
- except Exception:
- return False
-
- def __init__(self):
- super().__init__()
- self._bus = SystemBus()
- self._use_psutil_fallback = False
- self._psutil_battery = None
- self._profile_proxy = None # Initialize to None first
-
- # Battery device
- try:
- self._battery = self._bus.get(
- "org.freedesktop.UPower", "/org/freedesktop/UPower/devices/battery_BAT0"
- )
- self._battery.onPropertiesChanged = self.handle_battery_change
- except Exception:
- # Fallback to psutil if UPower is not available
- self._use_psutil_fallback = True
- try:
- self._psutil_battery = psutil.sensors_battery()
- if self._psutil_battery is None:
- return # No battery found
- # Start periodic updates for psutil fallback - increased interval
- GLib.timeout_add_seconds(10, self._update_psutil_battery)
- except Exception:
- return # psutil battery not available either
-
- # PowerProfiles - Initialize after other attributes
- try:
- self._profile_proxy = self._bus.get(
- "net.hadess.PowerProfiles", "/net/hadess/PowerProfiles"
- )
- # Use onPropertiesChanged for consistency with battery device
- self._profile_proxy.onPropertiesChanged = (
- lambda _, changed, __: self._handle_profile_props_changed(changed)
- )
- except Exception:
- self._profile_proxy = None
-
- self.changed.emit()
-
- def _update_psutil_battery(self):
- """Update psutil battery data periodically"""
- try:
- self._psutil_battery = psutil.sensors_battery()
- self.changed.emit()
- except Exception:
- pass # Continue trying
- return True # Keep the timeout active
-
- def _handle_profile_props_changed(self, changed):
- """Internal handler for property changes that processes only the changed properties"""
- if "ActiveProfile" in changed:
- new_profile = changed["ActiveProfile"]
- self.profile_changed.emit(new_profile)
- self.changed.emit()
-
- def handle_battery_change(self, iface, changed, invalidated):
- self.changed.emit()
diff --git a/services/mpris.py b/services/mpris.py
deleted file mode 100644
index 539a7274..00000000
--- a/services/mpris.py
+++ /dev/null
@@ -1,303 +0,0 @@
-# Standard library imports
-import contextlib
-
-import gi
-
-# Fabric imports
-from fabric.core.service import Property, Service, Signal
-from fabric.utils import bulk_connect
-from gi.repository import GLib
-from loguru import logger
-
-
-class PlayerctlImportError(ImportError):
- def __init__(self, *args):
- super().__init__(
- "Playerctl is not installed, please install it first",
- *args,
- )
-
-
-try:
- gi.require_version("Playerctl", "2.0")
- from gi.repository import Playerctl
-except ValueError:
- raise PlayerctlImportError
-
-
-class MprisPlayer(Service):
- """A service to manage a mpris player."""
-
- @Signal
- def exit(self, value: bool) -> bool: ...
-
- @Signal
- def changed(self) -> None: ...
-
- def __init__(
- self,
- player: Playerctl.Player,
- **kwargs,
- ):
- self._signal_connectors: dict = {}
- self._player: Playerctl.Player = player
- super().__init__(**kwargs)
- for sn in ["playback-status", "loop-status", "shuffle"]:
- self._signal_connectors[sn] = self._player.connect(
- sn,
- lambda *args, sn=sn: self.notifier(sn, args),
- )
-
- self._signal_connectors["exit"] = self._player.connect(
- "exit",
- self.on_player_exit,
- )
- self._signal_connectors["metadata"] = self._player.connect(
- "metadata",
- lambda *_: self.update_status(),
- )
- GLib.idle_add(self.update_status_once)
-
- def update_status(self):
- # schedule each notifier asynchronously.
- def notify_property(prop):
- if self.get_property(prop) is not None:
- self.notifier(prop)
-
- for prop in [
- "metadata",
- "title",
- "artist",
- "arturl",
- "length",
- ]:
- GLib.idle_add(lambda p=prop: (notify_property(p), False))
- for prop in [
- "can-seek",
- "can-pause",
- "can-shuffle",
- "can-go-next",
- "can-go-previous",
- ]:
- GLib.idle_add(lambda p=prop: (self.notifier(p), False))
-
- def update_status_once(self):
- # schedule notifier calls for each property
- def notify_all():
- for prop in self.list_properties(): # type: ignore
- self.notifier(prop.name)
- return False
-
- GLib.idle_add(notify_all, priority=GLib.PRIORITY_DEFAULT_IDLE)
-
- def notifier(self, name: str, args=None):
- def notify_and_emit():
- self.notify(name)
- self.emit("changed")
- return False
-
- GLib.idle_add(notify_and_emit, priority=GLib.PRIORITY_DEFAULT_IDLE)
-
- def on_player_exit(self, player):
- for id in list(self._signal_connectors.values()):
- with contextlib.suppress(Exception):
- self._player.disconnect(id)
- del self._signal_connectors
- GLib.idle_add(lambda: (self.emit("exit", True), False))
- del self._player
-
- def toggle_shuffle(self, *_):
- if self.can_shuffle:
- # schedule the shuffle toggle in the GLib idle loop
- GLib.idle_add(lambda: (setattr(self, "shuffle", not self.shuffle), False))
- # else do nothing
-
- def play_pause(self, *_):
- if self.can_pause:
- GLib.idle_add(lambda: (self._player.play_pause(), False))
-
- def next(self, *_):
- if self.can_go_next:
- GLib.idle_add(lambda: (self._player.next(), False))
-
- def previous(self, *_):
- if self.can_go_previous:
- GLib.idle_add(lambda: (self._player.previous(), False))
-
- # Properties
- @Property(str, "readable")
- def player_name(self) -> int:
- return self._player.get_property("player-name") # type: ignore
-
- @Property(int, "read-write", default_value=0)
- def position(self) -> int:
- return self._player.get_property("position") # type: ignore
-
- @position.setter
- def position(self, new_pos: int):
- self._player.set_position(new_pos)
-
- @Property(object, "readable")
- def metadata(self) -> dict:
- return self._player.get_property("metadata") # type: ignore
-
- @Property(str or None, "readable")
- def arturl(self) -> str | None:
- if "mpris:artUrl" in self.metadata.keys(): # type: ignore # noqa: SIM118
- return self.metadata["mpris:artUrl"] # type: ignore
- return None
-
- @Property(str or None, "readable")
- def length(self) -> str | None:
- if "mpris:length" in self.metadata.keys(): # type: ignore # noqa: SIM118
- return self.metadata["mpris:length"] # type: ignore
- return None
-
- @Property(str, "readable")
- def artist(self) -> str:
- artist = self._player.get_artist() # type: ignore
- if isinstance(artist, (list, tuple)):
- return ", ".join(artist)
- return artist
-
- @Property(str, "readable")
- def album(self) -> str:
- return self._player.get_album() # type: ignore
-
- @Property(str, "readable")
- def title(self):
- return self._player.get_title()
-
- @Property(bool, "read-write", default_value=False)
- def shuffle(self) -> bool:
- return self._player.get_property("shuffle") # type: ignore
-
- @shuffle.setter
- def shuffle(self, do_shuffle: bool):
- self.notifier("shuffle")
- return self._player.set_shuffle(do_shuffle)
-
- @Property(str, "readable")
- def playback_status(self) -> str:
- return {
- Playerctl.PlaybackStatus.PAUSED: "paused",
- Playerctl.PlaybackStatus.PLAYING: "playing",
- Playerctl.PlaybackStatus.STOPPED: "stopped",
- # type: ignore
- }.get(self._player.get_property("playback_status"), "unknown")
-
- @Property(str, "read-write")
- def loop_status(self) -> str:
- return {
- Playerctl.LoopStatus.NONE: "none",
- Playerctl.LoopStatus.TRACK: "track",
- Playerctl.LoopStatus.PLAYLIST: "playlist",
- }.get(
- self._player.get_property("loop_status"), "unknown"
- ) # type: ignore
-
- @loop_status.setter
- def loop_status(self, status: str):
- loop_status = {
- "none": Playerctl.LoopStatus.NONE,
- "track": Playerctl.LoopStatus.TRACK,
- "playlist": Playerctl.LoopStatus.PLAYLIST,
- }.get(status)
- self._player.set_loop_status(loop_status) if loop_status else None
-
- @Property(bool, "readable", default_value=False)
- def can_go_next(self) -> bool:
- return self._player.get_property("can_go_next") # type: ignore
-
- @Property(bool, "readable", default_value=False)
- def can_go_previous(self) -> bool:
- return self._player.get_property("can_go_previous") # type: ignore
-
- @Property(bool, "readable", default_value=False)
- def can_seek(self) -> bool:
- return self._player.get_property("can_seek") # type: ignore
-
- @Property(bool, "readable", default_value=False)
- def can_pause(self) -> bool:
- return self._player.get_property("can_pause") # type: ignore
-
- @Property(bool, "readable", default_value=False)
- def can_shuffle(self) -> bool:
- try:
- self._player.set_shuffle(self._player.get_property("shuffle"))
- return True
- except Exception:
- return False
-
- @Property(bool, "readable", default_value=False)
- def can_loop(self) -> bool:
- try:
- self._player.set_shuffle(self._player.get_property("shuffle"))
- return True
- except Exception:
- return False
-
-
-class MprisPlayerManager(Service):
- """A service to manage mpris players."""
-
- @Signal
- def player_appeared(self, player: Playerctl.Player) -> Playerctl.Player: ...
-
- @Signal
- def player_vanished(self, player_name: str) -> str: ...
-
- def __init__(
- self,
- **kwargs,
- ):
- self._manager = Playerctl.PlayerManager.new()
- self._signal_connections = []
-
- # Track signal connections for cleanup
- connections = bulk_connect(
- self._manager,
- {
- "name-appeared": self.on_name_appeared,
- "name-vanished": self.on_name_vanished,
- },
- )
- # Store as (object, handler_id) tuples
- for handler_id in connections:
- self._signal_connections.append((self._manager, handler_id))
-
- self.add_players()
- super().__init__(**kwargs)
-
- def destroy(self):
- """Clean up resources when the manager is destroyed."""
- # Disconnect all signal connections
- for obj, handler_id in self._signal_connections:
- try:
- obj.disconnect(handler_id)
- except Exception as e:
- logger.warning(f"Failed to disconnect manager signal: {e}")
- self._signal_connections.clear()
-
- # Clean up the manager
- if hasattr(self, '_manager'):
- del self._manager
-
- def on_name_appeared(self, manager, player_name: Playerctl.PlayerName):
- logger.info(f"[MprisPlayer] {player_name.name} appeared")
- new_player = Playerctl.Player.new_from_name(player_name)
- manager.manage_player(new_player)
- self.emit("player-appeared", new_player) # type: ignore
-
- def on_name_vanished(self, manager, player_name: Playerctl.PlayerName):
- logger.info(f"[MprisPlayer] {player_name.name} vanished")
- self.emit("player-vanished", player_name.name) # type: ignore
-
- def add_players(self):
- # type: ignore
- for player in self._manager.get_property("player-names"):
- self._manager.manage_player(Playerctl.Player.new_from_name(player)) # type: ignore
-
- @Property(object, "readable")
- def players(self):
- return self._manager.get_property("players") # type: ignore
diff --git a/src/__init__.py b/src/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/config/assets/icons/applets/bluetooth-clear.svg b/src/assets/icons/applets/bluetooth-clear.svg
similarity index 100%
rename from config/assets/icons/applets/bluetooth-clear.svg
rename to src/assets/icons/applets/bluetooth-clear.svg
diff --git a/config/assets/icons/applets/bluetooth-off-clear.svg b/src/assets/icons/applets/bluetooth-off-clear.svg
similarity index 100%
rename from config/assets/icons/applets/bluetooth-off-clear.svg
rename to src/assets/icons/applets/bluetooth-off-clear.svg
diff --git a/config/assets/icons/applets/bluetooth-off.svg b/src/assets/icons/applets/bluetooth-off.svg
similarity index 100%
rename from config/assets/icons/applets/bluetooth-off.svg
rename to src/assets/icons/applets/bluetooth-off.svg
diff --git a/config/assets/icons/applets/bluetooth-paired.svg b/src/assets/icons/applets/bluetooth-paired.svg
similarity index 100%
rename from config/assets/icons/applets/bluetooth-paired.svg
rename to src/assets/icons/applets/bluetooth-paired.svg
diff --git a/config/assets/icons/applets/bluetooth.svg b/src/assets/icons/applets/bluetooth.svg
similarity index 100%
rename from config/assets/icons/applets/bluetooth.svg
rename to src/assets/icons/applets/bluetooth.svg
diff --git a/config/assets/icons/applets/brightness.svg b/src/assets/icons/applets/brightness.svg
similarity index 100%
rename from config/assets/icons/applets/brightness.svg
rename to src/assets/icons/applets/brightness.svg
diff --git a/config/assets/icons/applets/caffeine-off.svg b/src/assets/icons/applets/caffeine-off.svg
similarity index 100%
rename from config/assets/icons/applets/caffeine-off.svg
rename to src/assets/icons/applets/caffeine-off.svg
diff --git a/config/assets/icons/applets/caffeine-on.svg b/src/assets/icons/applets/caffeine-on.svg
similarity index 100%
rename from config/assets/icons/applets/caffeine-on.svg
rename to src/assets/icons/applets/caffeine-on.svg
diff --git a/config/assets/icons/applets/dnd-clear.svg b/src/assets/icons/applets/dnd-clear.svg
similarity index 100%
rename from config/assets/icons/applets/dnd-clear.svg
rename to src/assets/icons/applets/dnd-clear.svg
diff --git a/config/assets/icons/applets/dnd-off.svg b/src/assets/icons/applets/dnd-off.svg
similarity index 100%
rename from config/assets/icons/applets/dnd-off.svg
rename to src/assets/icons/applets/dnd-off.svg
diff --git a/config/assets/icons/applets/dnd.svg b/src/assets/icons/applets/dnd.svg
similarity index 100%
rename from config/assets/icons/applets/dnd.svg
rename to src/assets/icons/applets/dnd.svg
diff --git a/config/assets/icons/applets/flight-off.svg b/src/assets/icons/applets/flight-off.svg
similarity index 100%
rename from config/assets/icons/applets/flight-off.svg
rename to src/assets/icons/applets/flight-off.svg
diff --git a/config/assets/icons/applets/flight-on.svg b/src/assets/icons/applets/flight-on.svg
similarity index 100%
rename from config/assets/icons/applets/flight-on.svg
rename to src/assets/icons/applets/flight-on.svg
diff --git a/config/assets/icons/applets/network-wired-offline.svg b/src/assets/icons/applets/network-wired-offline.svg
similarity index 100%
rename from config/assets/icons/applets/network-wired-offline.svg
rename to src/assets/icons/applets/network-wired-offline.svg
diff --git a/config/assets/icons/applets/network-wired.svg b/src/assets/icons/applets/network-wired.svg
similarity index 100%
rename from config/assets/icons/applets/network-wired.svg
rename to src/assets/icons/applets/network-wired.svg
diff --git a/config/assets/icons/applets/redshift-status-off.svg b/src/assets/icons/applets/redshift-status-off.svg
similarity index 100%
rename from config/assets/icons/applets/redshift-status-off.svg
rename to src/assets/icons/applets/redshift-status-off.svg
diff --git a/config/assets/icons/applets/redshift-status-on.svg b/src/assets/icons/applets/redshift-status-on.svg
similarity index 100%
rename from config/assets/icons/applets/redshift-status-on.svg
rename to src/assets/icons/applets/redshift-status-on.svg
diff --git a/config/assets/icons/applets/wifi-clear.svg b/src/assets/icons/applets/wifi-clear.svg
similarity index 100%
rename from config/assets/icons/applets/wifi-clear.svg
rename to src/assets/icons/applets/wifi-clear.svg
diff --git a/config/assets/icons/applets/wifi-off-clear.svg b/src/assets/icons/applets/wifi-off-clear.svg
similarity index 100%
rename from config/assets/icons/applets/wifi-off-clear.svg
rename to src/assets/icons/applets/wifi-off-clear.svg
diff --git a/config/assets/icons/applets/wifi-off.svg b/src/assets/icons/applets/wifi-off.svg
similarity index 100%
rename from config/assets/icons/applets/wifi-off.svg
rename to src/assets/icons/applets/wifi-off.svg
diff --git a/config/assets/icons/applets/wifi.svg b/src/assets/icons/applets/wifi.svg
similarity index 100%
rename from config/assets/icons/applets/wifi.svg
rename to src/assets/icons/applets/wifi.svg
diff --git a/config/assets/icons/battery/battery-000-charging.svg b/src/assets/icons/battery/battery-000-charging.svg
similarity index 100%
rename from config/assets/icons/battery/battery-000-charging.svg
rename to src/assets/icons/battery/battery-000-charging.svg
diff --git a/config/assets/icons/battery/battery-000.svg b/src/assets/icons/battery/battery-000.svg
similarity index 100%
rename from config/assets/icons/battery/battery-000.svg
rename to src/assets/icons/battery/battery-000.svg
diff --git a/config/assets/icons/battery/battery-010-charging.svg b/src/assets/icons/battery/battery-010-charging.svg
similarity index 100%
rename from config/assets/icons/battery/battery-010-charging.svg
rename to src/assets/icons/battery/battery-010-charging.svg
diff --git a/config/assets/icons/battery/battery-010.svg b/src/assets/icons/battery/battery-010.svg
similarity index 100%
rename from config/assets/icons/battery/battery-010.svg
rename to src/assets/icons/battery/battery-010.svg
diff --git a/config/assets/icons/battery/battery-020-charging.svg b/src/assets/icons/battery/battery-020-charging.svg
similarity index 100%
rename from config/assets/icons/battery/battery-020-charging.svg
rename to src/assets/icons/battery/battery-020-charging.svg
diff --git a/config/assets/icons/battery/battery-020.svg b/src/assets/icons/battery/battery-020.svg
similarity index 100%
rename from config/assets/icons/battery/battery-020.svg
rename to src/assets/icons/battery/battery-020.svg
diff --git a/config/assets/icons/battery/battery-030-charging.svg b/src/assets/icons/battery/battery-030-charging.svg
similarity index 100%
rename from config/assets/icons/battery/battery-030-charging.svg
rename to src/assets/icons/battery/battery-030-charging.svg
diff --git a/config/assets/icons/battery/battery-030.svg b/src/assets/icons/battery/battery-030.svg
similarity index 100%
rename from config/assets/icons/battery/battery-030.svg
rename to src/assets/icons/battery/battery-030.svg
diff --git a/config/assets/icons/battery/battery-040-charging.svg b/src/assets/icons/battery/battery-040-charging.svg
similarity index 100%
rename from config/assets/icons/battery/battery-040-charging.svg
rename to src/assets/icons/battery/battery-040-charging.svg
diff --git a/config/assets/icons/battery/battery-040.svg b/src/assets/icons/battery/battery-040.svg
similarity index 100%
rename from config/assets/icons/battery/battery-040.svg
rename to src/assets/icons/battery/battery-040.svg
diff --git a/config/assets/icons/battery/battery-050-charging.svg b/src/assets/icons/battery/battery-050-charging.svg
similarity index 100%
rename from config/assets/icons/battery/battery-050-charging.svg
rename to src/assets/icons/battery/battery-050-charging.svg
diff --git a/config/assets/icons/battery/battery-050.svg b/src/assets/icons/battery/battery-050.svg
similarity index 100%
rename from config/assets/icons/battery/battery-050.svg
rename to src/assets/icons/battery/battery-050.svg
diff --git a/config/assets/icons/battery/battery-060-charging.svg b/src/assets/icons/battery/battery-060-charging.svg
similarity index 100%
rename from config/assets/icons/battery/battery-060-charging.svg
rename to src/assets/icons/battery/battery-060-charging.svg
diff --git a/config/assets/icons/battery/battery-060.svg b/src/assets/icons/battery/battery-060.svg
similarity index 100%
rename from config/assets/icons/battery/battery-060.svg
rename to src/assets/icons/battery/battery-060.svg
diff --git a/config/assets/icons/battery/battery-070-charging.svg b/src/assets/icons/battery/battery-070-charging.svg
similarity index 100%
rename from config/assets/icons/battery/battery-070-charging.svg
rename to src/assets/icons/battery/battery-070-charging.svg
diff --git a/config/assets/icons/battery/battery-070.svg b/src/assets/icons/battery/battery-070.svg
similarity index 100%
rename from config/assets/icons/battery/battery-070.svg
rename to src/assets/icons/battery/battery-070.svg
diff --git a/config/assets/icons/battery/battery-080-charging.svg b/src/assets/icons/battery/battery-080-charging.svg
similarity index 100%
rename from config/assets/icons/battery/battery-080-charging.svg
rename to src/assets/icons/battery/battery-080-charging.svg
diff --git a/config/assets/icons/battery/battery-080.svg b/src/assets/icons/battery/battery-080.svg
similarity index 100%
rename from config/assets/icons/battery/battery-080.svg
rename to src/assets/icons/battery/battery-080.svg
diff --git a/config/assets/icons/battery/battery-090-charging.svg b/src/assets/icons/battery/battery-090-charging.svg
similarity index 100%
rename from config/assets/icons/battery/battery-090-charging.svg
rename to src/assets/icons/battery/battery-090-charging.svg
diff --git a/config/assets/icons/battery/battery-090.svg b/src/assets/icons/battery/battery-090.svg
similarity index 100%
rename from config/assets/icons/battery/battery-090.svg
rename to src/assets/icons/battery/battery-090.svg
diff --git a/config/assets/icons/battery/battery-100-charging.svg b/src/assets/icons/battery/battery-100-charging.svg
similarity index 100%
rename from config/assets/icons/battery/battery-100-charging.svg
rename to src/assets/icons/battery/battery-100-charging.svg
diff --git a/config/assets/icons/battery/battery-100.svg b/src/assets/icons/battery/battery-100.svg
similarity index 100%
rename from config/assets/icons/battery/battery-100.svg
rename to src/assets/icons/battery/battery-100.svg
diff --git a/config/assets/icons/brightness/brightness-0.svg b/src/assets/icons/brightness/brightness-0.svg
similarity index 100%
rename from config/assets/icons/brightness/brightness-0.svg
rename to src/assets/icons/brightness/brightness-0.svg
diff --git a/config/assets/icons/brightness/brightness-1.svg b/src/assets/icons/brightness/brightness-1.svg
similarity index 100%
rename from config/assets/icons/brightness/brightness-1.svg
rename to src/assets/icons/brightness/brightness-1.svg
diff --git a/config/assets/icons/brightness/brightness-2.svg b/src/assets/icons/brightness/brightness-2.svg
similarity index 100%
rename from config/assets/icons/brightness/brightness-2.svg
rename to src/assets/icons/brightness/brightness-2.svg
diff --git a/config/assets/icons/brightness/brightness-3.svg b/src/assets/icons/brightness/brightness-3.svg
similarity index 100%
rename from config/assets/icons/brightness/brightness-3.svg
rename to src/assets/icons/brightness/brightness-3.svg
diff --git a/config/assets/icons/brightness/brightness.svg b/src/assets/icons/brightness/brightness.svg
similarity index 100%
rename from config/assets/icons/brightness/brightness.svg
rename to src/assets/icons/brightness/brightness.svg
diff --git a/config/assets/icons/mic/microphone-0.svg b/src/assets/icons/mic/microphone-0.svg
similarity index 100%
rename from config/assets/icons/mic/microphone-0.svg
rename to src/assets/icons/mic/microphone-0.svg
diff --git a/config/assets/icons/mic/microphone-1.svg b/src/assets/icons/mic/microphone-1.svg
similarity index 100%
rename from config/assets/icons/mic/microphone-1.svg
rename to src/assets/icons/mic/microphone-1.svg
diff --git a/config/assets/icons/mic/microphone-2.svg b/src/assets/icons/mic/microphone-2.svg
similarity index 100%
rename from config/assets/icons/mic/microphone-2.svg
rename to src/assets/icons/mic/microphone-2.svg
diff --git a/config/assets/icons/mic/microphone-3.svg b/src/assets/icons/mic/microphone-3.svg
similarity index 100%
rename from config/assets/icons/mic/microphone-3.svg
rename to src/assets/icons/mic/microphone-3.svg
diff --git a/config/assets/icons/mic/microphone.svg b/src/assets/icons/mic/microphone.svg
similarity index 100%
rename from config/assets/icons/mic/microphone.svg
rename to src/assets/icons/mic/microphone.svg
diff --git a/src/assets/icons/misc/caps-lock-off.svg b/src/assets/icons/misc/caps-lock-off.svg
new file mode 100644
index 00000000..8dd8dbfc
--- /dev/null
+++ b/src/assets/icons/misc/caps-lock-off.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/src/assets/icons/misc/caps-lock.svg b/src/assets/icons/misc/caps-lock.svg
new file mode 100644
index 00000000..41c18aad
--- /dev/null
+++ b/src/assets/icons/misc/caps-lock.svg
@@ -0,0 +1,16 @@
+
+
+
+
+
diff --git a/config/assets/icons/misc/chevron-left.svg b/src/assets/icons/misc/chevron-left.svg
similarity index 100%
rename from config/assets/icons/misc/chevron-left.svg
rename to src/assets/icons/misc/chevron-left.svg
diff --git a/config/assets/icons/misc/chevron-right.svg b/src/assets/icons/misc/chevron-right.svg
similarity index 100%
rename from config/assets/icons/misc/chevron-right.svg
rename to src/assets/icons/misc/chevron-right.svg
diff --git a/config/assets/icons/misc/control-center.svg b/src/assets/icons/misc/control-center.svg
similarity index 100%
rename from config/assets/icons/misc/control-center.svg
rename to src/assets/icons/misc/control-center.svg
diff --git a/config/assets/icons/misc/control.svg b/src/assets/icons/misc/control.svg
similarity index 100%
rename from config/assets/icons/misc/control.svg
rename to src/assets/icons/misc/control.svg
diff --git a/config/assets/icons/misc/imac.svg b/src/assets/icons/misc/imac.svg
similarity index 100%
rename from config/assets/icons/misc/imac.svg
rename to src/assets/icons/misc/imac.svg
diff --git a/src/assets/icons/misc/keyboard-layout.svg b/src/assets/icons/misc/keyboard-layout.svg
new file mode 100644
index 00000000..b20cca72
--- /dev/null
+++ b/src/assets/icons/misc/keyboard-layout.svg
@@ -0,0 +1,16 @@
+
+
+
+
+
diff --git a/config/assets/icons/misc/logo.svg b/src/assets/icons/misc/logo.svg
similarity index 100%
rename from config/assets/icons/misc/logo.svg
rename to src/assets/icons/misc/logo.svg
diff --git a/config/assets/icons/misc/media-record.svg b/src/assets/icons/misc/media-record.svg
similarity index 100%
rename from config/assets/icons/misc/media-record.svg
rename to src/assets/icons/misc/media-record.svg
diff --git a/config/assets/icons/misc/search.svg b/src/assets/icons/misc/search.svg
similarity index 100%
rename from config/assets/icons/misc/search.svg
rename to src/assets/icons/misc/search.svg
diff --git a/src/assets/icons/music.svg b/src/assets/icons/music.svg
new file mode 100644
index 00000000..a95e0cb6
--- /dev/null
+++ b/src/assets/icons/music.svg
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/config/assets/icons/notification.png b/src/assets/icons/notification.png
similarity index 100%
rename from config/assets/icons/notification.png
rename to src/assets/icons/notification.png
diff --git a/config/assets/icons/notifications/notification-active.svg b/src/assets/icons/notifications/notification-active.svg
similarity index 100%
rename from config/assets/icons/notifications/notification-active.svg
rename to src/assets/icons/notifications/notification-active.svg
diff --git a/config/assets/icons/notifications/notification-disabled.svg b/src/assets/icons/notifications/notification-disabled.svg
similarity index 100%
rename from config/assets/icons/notifications/notification-disabled.svg
rename to src/assets/icons/notifications/notification-disabled.svg
diff --git a/config/assets/icons/notifications/notification-inactive.svg b/src/assets/icons/notifications/notification-inactive.svg
similarity index 100%
rename from config/assets/icons/notifications/notification-inactive.svg
rename to src/assets/icons/notifications/notification-inactive.svg
diff --git a/config/assets/icons/player/Pause.svg b/src/assets/icons/player/Pause.svg
similarity index 100%
rename from config/assets/icons/player/Pause.svg
rename to src/assets/icons/player/Pause.svg
diff --git a/config/assets/icons/player/Rewind.svg b/src/assets/icons/player/Rewind.svg
similarity index 100%
rename from config/assets/icons/player/Rewind.svg
rename to src/assets/icons/player/Rewind.svg
diff --git a/config/assets/icons/player/audio-switcher.svg b/src/assets/icons/player/audio-switcher.svg
similarity index 100%
rename from config/assets/icons/player/audio-switcher.svg
rename to src/assets/icons/player/audio-switcher.svg
diff --git a/config/assets/icons/player/fwd.svg b/src/assets/icons/player/fwd.svg
similarity index 100%
rename from config/assets/icons/player/fwd.svg
rename to src/assets/icons/player/fwd.svg
diff --git a/config/assets/icons/player/play.svg b/src/assets/icons/player/play.svg
similarity index 100%
rename from config/assets/icons/player/play.svg
rename to src/assets/icons/player/play.svg
diff --git a/config/assets/icons/power_modes/battery-balanced.svg b/src/assets/icons/power_modes/battery-balanced.svg
similarity index 100%
rename from config/assets/icons/power_modes/battery-balanced.svg
rename to src/assets/icons/power_modes/battery-balanced.svg
diff --git a/config/assets/icons/power_modes/battery-performance.svg b/src/assets/icons/power_modes/battery-performance.svg
similarity index 100%
rename from config/assets/icons/power_modes/battery-performance.svg
rename to src/assets/icons/power_modes/battery-performance.svg
diff --git a/config/assets/icons/power_modes/battery-power.svg b/src/assets/icons/power_modes/battery-power.svg
similarity index 100%
rename from config/assets/icons/power_modes/battery-power.svg
rename to src/assets/icons/power_modes/battery-power.svg
diff --git a/config/assets/icons/todo/checkbox-check.svg b/src/assets/icons/todo/checkbox-check.svg
similarity index 100%
rename from config/assets/icons/todo/checkbox-check.svg
rename to src/assets/icons/todo/checkbox-check.svg
diff --git a/config/assets/icons/todo/checkbox-uncheck.svg b/src/assets/icons/todo/checkbox-uncheck.svg
similarity index 100%
rename from config/assets/icons/todo/checkbox-uncheck.svg
rename to src/assets/icons/todo/checkbox-uncheck.svg
diff --git a/config/assets/icons/todo/delete-symbolic.svg b/src/assets/icons/todo/delete-symbolic.svg
similarity index 100%
rename from config/assets/icons/todo/delete-symbolic.svg
rename to src/assets/icons/todo/delete-symbolic.svg
diff --git a/config/assets/icons/todo/edit.svg b/src/assets/icons/todo/edit.svg
similarity index 100%
rename from config/assets/icons/todo/edit.svg
rename to src/assets/icons/todo/edit.svg
diff --git a/config/assets/icons/todo/plus-symbolic.svg b/src/assets/icons/todo/plus-symbolic.svg
similarity index 100%
rename from config/assets/icons/todo/plus-symbolic.svg
rename to src/assets/icons/todo/plus-symbolic.svg
diff --git a/config/assets/icons/todo/to-do-app-symbolic.svg b/src/assets/icons/todo/to-do-app-symbolic.svg
similarity index 100%
rename from config/assets/icons/todo/to-do-app-symbolic.svg
rename to src/assets/icons/todo/to-do-app-symbolic.svg
diff --git a/config/assets/icons/volume/audio-volume-0.svg b/src/assets/icons/volume/audio-volume-0.svg
similarity index 100%
rename from config/assets/icons/volume/audio-volume-0.svg
rename to src/assets/icons/volume/audio-volume-0.svg
diff --git a/config/assets/icons/volume/audio-volume-1.svg b/src/assets/icons/volume/audio-volume-1.svg
similarity index 100%
rename from config/assets/icons/volume/audio-volume-1.svg
rename to src/assets/icons/volume/audio-volume-1.svg
diff --git a/config/assets/icons/volume/audio-volume-2.svg b/src/assets/icons/volume/audio-volume-2.svg
similarity index 100%
rename from config/assets/icons/volume/audio-volume-2.svg
rename to src/assets/icons/volume/audio-volume-2.svg
diff --git a/config/assets/icons/volume/audio-volume-3.svg b/src/assets/icons/volume/audio-volume-3.svg
similarity index 100%
rename from config/assets/icons/volume/audio-volume-3.svg
rename to src/assets/icons/volume/audio-volume-3.svg
diff --git a/config/assets/icons/volume/audio-volume.svg b/src/assets/icons/volume/audio-volume.svg
similarity index 100%
rename from config/assets/icons/volume/audio-volume.svg
rename to src/assets/icons/volume/audio-volume.svg
diff --git a/src/assets/icons/weather/weather-clear-night.svg b/src/assets/icons/weather/weather-clear-night.svg
new file mode 100644
index 00000000..1c56d8c1
--- /dev/null
+++ b/src/assets/icons/weather/weather-clear-night.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/icons/weather/weather-clear.svg b/src/assets/icons/weather/weather-clear.svg
new file mode 100644
index 00000000..7da66b94
--- /dev/null
+++ b/src/assets/icons/weather/weather-clear.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/icons/weather/weather-clouds-night.svg b/src/assets/icons/weather/weather-clouds-night.svg
new file mode 100644
index 00000000..c6cf30c8
--- /dev/null
+++ b/src/assets/icons/weather/weather-clouds-night.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/icons/weather/weather-clouds.svg b/src/assets/icons/weather/weather-clouds.svg
new file mode 100644
index 00000000..cc2e2b51
--- /dev/null
+++ b/src/assets/icons/weather/weather-clouds.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/icons/weather/weather-few-clouds-night.svg b/src/assets/icons/weather/weather-few-clouds-night.svg
new file mode 100644
index 00000000..7448ada8
--- /dev/null
+++ b/src/assets/icons/weather/weather-few-clouds-night.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/icons/weather/weather-few-clouds.svg b/src/assets/icons/weather/weather-few-clouds.svg
new file mode 100644
index 00000000..23f386a4
--- /dev/null
+++ b/src/assets/icons/weather/weather-few-clouds.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/icons/weather/weather-fog.svg b/src/assets/icons/weather/weather-fog.svg
new file mode 100644
index 00000000..0838f14c
--- /dev/null
+++ b/src/assets/icons/weather/weather-fog.svg
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/assets/icons/weather/weather-freezing-rain.svg b/src/assets/icons/weather/weather-freezing-rain.svg
new file mode 100644
index 00000000..ac8a18bf
--- /dev/null
+++ b/src/assets/icons/weather/weather-freezing-rain.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/icons/weather/weather-hail.svg b/src/assets/icons/weather/weather-hail.svg
new file mode 100644
index 00000000..13f4cdf1
--- /dev/null
+++ b/src/assets/icons/weather/weather-hail.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/icons/weather/weather-many-clouds.svg b/src/assets/icons/weather/weather-many-clouds.svg
new file mode 100644
index 00000000..adf556d9
--- /dev/null
+++ b/src/assets/icons/weather/weather-many-clouds.svg
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/assets/icons/weather/weather-none-available.svg b/src/assets/icons/weather/weather-none-available.svg
new file mode 100644
index 00000000..cc31231a
--- /dev/null
+++ b/src/assets/icons/weather/weather-none-available.svg
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/assets/icons/weather/weather-overcast-night.svg b/src/assets/icons/weather/weather-overcast-night.svg
new file mode 100644
index 00000000..e830d22f
--- /dev/null
+++ b/src/assets/icons/weather/weather-overcast-night.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/assets/icons/weather/weather-overcast.svg b/src/assets/icons/weather/weather-overcast.svg
new file mode 100644
index 00000000..27d0593d
--- /dev/null
+++ b/src/assets/icons/weather/weather-overcast.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/assets/icons/weather/weather-severe-alert.svg b/src/assets/icons/weather/weather-severe-alert.svg
new file mode 100644
index 00000000..9812e233
--- /dev/null
+++ b/src/assets/icons/weather/weather-severe-alert.svg
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/assets/icons/weather/weather-showers-day.svg b/src/assets/icons/weather/weather-showers-day.svg
new file mode 100644
index 00000000..de8527ee
--- /dev/null
+++ b/src/assets/icons/weather/weather-showers-day.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/icons/weather/weather-showers-night.svg b/src/assets/icons/weather/weather-showers-night.svg
new file mode 100644
index 00000000..85b1dd9b
--- /dev/null
+++ b/src/assets/icons/weather/weather-showers-night.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/icons/weather/weather-showers-scattered-day.svg b/src/assets/icons/weather/weather-showers-scattered-day.svg
new file mode 100644
index 00000000..07e7c053
--- /dev/null
+++ b/src/assets/icons/weather/weather-showers-scattered-day.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/icons/weather/weather-showers-scattered-night.svg b/src/assets/icons/weather/weather-showers-scattered-night.svg
new file mode 100644
index 00000000..9a03c614
--- /dev/null
+++ b/src/assets/icons/weather/weather-showers-scattered-night.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/icons/weather/weather-showers-scattered.svg b/src/assets/icons/weather/weather-showers-scattered.svg
new file mode 100644
index 00000000..66e03d9d
--- /dev/null
+++ b/src/assets/icons/weather/weather-showers-scattered.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/icons/weather/weather-showers.svg b/src/assets/icons/weather/weather-showers.svg
new file mode 100644
index 00000000..b7cb07a3
--- /dev/null
+++ b/src/assets/icons/weather/weather-showers.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/icons/weather/weather-snow-night.svg b/src/assets/icons/weather/weather-snow-night.svg
new file mode 100644
index 00000000..d577ca24
--- /dev/null
+++ b/src/assets/icons/weather/weather-snow-night.svg
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/assets/icons/weather/weather-snow-rain.svg b/src/assets/icons/weather/weather-snow-rain.svg
new file mode 100644
index 00000000..518c9cdd
--- /dev/null
+++ b/src/assets/icons/weather/weather-snow-rain.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/icons/weather/weather-snow-scattered-day.svg b/src/assets/icons/weather/weather-snow-scattered-day.svg
new file mode 100644
index 00000000..fba0379f
--- /dev/null
+++ b/src/assets/icons/weather/weather-snow-scattered-day.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/icons/weather/weather-snow-scattered-night.svg b/src/assets/icons/weather/weather-snow-scattered-night.svg
new file mode 100644
index 00000000..52c79452
--- /dev/null
+++ b/src/assets/icons/weather/weather-snow-scattered-night.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/icons/weather/weather-snow-scattered.svg b/src/assets/icons/weather/weather-snow-scattered.svg
new file mode 100644
index 00000000..e88f7124
--- /dev/null
+++ b/src/assets/icons/weather/weather-snow-scattered.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/icons/weather/weather-snow.svg b/src/assets/icons/weather/weather-snow.svg
new file mode 100644
index 00000000..a27de549
--- /dev/null
+++ b/src/assets/icons/weather/weather-snow.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/icons/weather/weather-storm-night.svg b/src/assets/icons/weather/weather-storm-night.svg
new file mode 100644
index 00000000..2231272f
--- /dev/null
+++ b/src/assets/icons/weather/weather-storm-night.svg
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/assets/icons/weather/weather-storm-tornado.svg b/src/assets/icons/weather/weather-storm-tornado.svg
new file mode 100644
index 00000000..41b7c6bc
--- /dev/null
+++ b/src/assets/icons/weather/weather-storm-tornado.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/assets/icons/weather/weather-storm.svg b/src/assets/icons/weather/weather-storm.svg
new file mode 100644
index 00000000..b752ca57
--- /dev/null
+++ b/src/assets/icons/weather/weather-storm.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/icons/weather/weather-windy.svg b/src/assets/icons/weather/weather-windy.svg
new file mode 100644
index 00000000..e76d9daf
--- /dev/null
+++ b/src/assets/icons/weather/weather-windy.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/config/assets/icons/wifi/network-wireless-0.svg b/src/assets/icons/wifi/network-wireless-0.svg
similarity index 100%
rename from config/assets/icons/wifi/network-wireless-0.svg
rename to src/assets/icons/wifi/network-wireless-0.svg
diff --git a/config/assets/icons/wifi/network-wireless-100.svg b/src/assets/icons/wifi/network-wireless-100.svg
similarity index 100%
rename from config/assets/icons/wifi/network-wireless-100.svg
rename to src/assets/icons/wifi/network-wireless-100.svg
diff --git a/config/assets/icons/wifi/network-wireless-20.svg b/src/assets/icons/wifi/network-wireless-20.svg
similarity index 100%
rename from config/assets/icons/wifi/network-wireless-20.svg
rename to src/assets/icons/wifi/network-wireless-20.svg
diff --git a/config/assets/icons/wifi/network-wireless-40.svg b/src/assets/icons/wifi/network-wireless-40.svg
similarity index 100%
rename from config/assets/icons/wifi/network-wireless-40.svg
rename to src/assets/icons/wifi/network-wireless-40.svg
diff --git a/config/assets/icons/wifi/network-wireless-60.svg b/src/assets/icons/wifi/network-wireless-60.svg
similarity index 100%
rename from config/assets/icons/wifi/network-wireless-60.svg
rename to src/assets/icons/wifi/network-wireless-60.svg
diff --git a/config/assets/icons/wifi/network-wireless-80.svg b/src/assets/icons/wifi/network-wireless-80.svg
similarity index 100%
rename from config/assets/icons/wifi/network-wireless-80.svg
rename to src/assets/icons/wifi/network-wireless-80.svg
diff --git a/config/assets/icons/wifi/wifi-connecting.svg b/src/assets/icons/wifi/wifi-connecting.svg
similarity index 100%
rename from config/assets/icons/wifi/wifi-connecting.svg
rename to src/assets/icons/wifi/wifi-connecting.svg
diff --git a/assets/modus.png b/src/assets/modus.png
similarity index 100%
rename from assets/modus.png
rename to src/assets/modus.png
diff --git a/assets/screenshots/home.png b/src/assets/screenshots/home.png
similarity index 100%
rename from assets/screenshots/home.png
rename to src/assets/screenshots/home.png
diff --git a/assets/screenshots/lock.png b/src/assets/screenshots/lock.png
similarity index 100%
rename from assets/screenshots/lock.png
rename to src/assets/screenshots/lock.png
diff --git a/assets/wallpapers_example/example-1.png b/src/assets/wallpaper_example/example-1.png
similarity index 100%
rename from assets/wallpapers_example/example-1.png
rename to src/assets/wallpaper_example/example-1.png
diff --git a/assets/wallpapers_example/example-2.png b/src/assets/wallpaper_example/example-2.png
similarity index 100%
rename from assets/wallpapers_example/example-2.png
rename to src/assets/wallpaper_example/example-2.png
diff --git a/assets/wallpapers_example/example-3.jpg b/src/assets/wallpaper_example/example-3.jpg
similarity index 100%
rename from assets/wallpapers_example/example-3.jpg
rename to src/assets/wallpaper_example/example-3.jpg
diff --git a/assets/wallpapers_example/example-4.png b/src/assets/wallpaper_example/example-4.png
similarity index 100%
rename from assets/wallpapers_example/example-4.png
rename to src/assets/wallpaper_example/example-4.png
diff --git a/assets/wallpapers_example/example-5.png b/src/assets/wallpaper_example/example-5.png
similarity index 100%
rename from assets/wallpapers_example/example-5.png
rename to src/assets/wallpaper_example/example-5.png
diff --git a/lock.py b/src/lock.py
similarity index 91%
rename from lock.py
rename to src/lock.py
index 86a889c7..f548990b 100644
--- a/lock.py
+++ b/src/lock.py
@@ -1,36 +1,32 @@
-from widgets.circle_image import CircleImage as Image
-from modules.panel.components.indicators import (
+import getpass
+
+import pam
+from fabric import Application
+from fabric.utils import Gdk, GLib, get_relative_path, logger, os
+from fabric.widgets.box import Box
+from fabric.widgets.centerbox import CenterBox
+from fabric.widgets.datetime import DateTime
+from fabric.widgets.entry import Entry
+from fabric.widgets.label import Label
+from fabric.widgets.window import Window
+from gi.repository import GtkSessionLock # pyright: ignore[reportAttributeAccessIssue]
+
+from shared.widgets.circle_image import CircleImage as Image
+from utils.functions import set_process_name
+from window.panel.components.indicators import (
BatteryIndicator,
BluetoothIndicator,
NetworkIndicator,
)
-from gi.repository import (
- Gdk, # pyright: ignore[reportMissingModuleSource]
- GLib,
- GtkSessionLock, # pyright: ignore[reportAttributeAccessIssue]
-)
-from fabric.widgets.window import Window
-from fabric.widgets.label import Label
-from fabric.widgets.entry import Entry
-from fabric.widgets.datetime import DateTime
-from fabric.widgets.centerbox import CenterBox
-from fabric.widgets.box import Box
-from fabric.utils import get_relative_path
-from fabric import Application
-import os
-import getpass
-import setproctitle
-import gi
-import pam
-gi.require_version("Gdk", "3.0")
-gi.require_version("Gtk", "3.0")
-
-
-gi.require_version("GtkSessionLock", "0.1")
-# from fabric.widgets.image import Image
-
-# from widgets.wayland import WaylandWindow as Window
+for log in [
+ "fabric.audio.service",
+ "fabric.core.application",
+ "fabric.bluetooth.service",
+ "fabric.notifications.service",
+ "services.network",
+]:
+ logger.disable(log)
class IndicatorBox(Box):
@@ -216,19 +212,24 @@ def initialize():
lockscreen.show()
-if __name__ == "__main__":
- setproctitle.setproctitle("lockscreen")
+def main():
+ set_process_name("lockscreen")
initialize()
lockscreen = LockScreen(GtkSessionLock.Lock())
+ global app
app = Application("lock", lockscreen)
def set_css():
app.set_stylesheet_from_file(
- get_relative_path("main.css"),
+ get_relative_path("shared/styles/main.css"),
)
app.set_css = set_css # pyright: ignore[reportAttributeAccessIssue]
app.set_css() # pyright: ignore[reportAttributeAccessIssue]
app.run()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/main.py b/src/main.py
new file mode 100644
index 00000000..12c35459
--- /dev/null
+++ b/src/main.py
@@ -0,0 +1,88 @@
+import os
+from fabric import Application
+from fabric.utils import get_relative_path, logger, monitor_file
+
+from shared.data import APP_NAME, load_config
+from utils.functions import set_process_name
+from window.desktop.widget import Deskwidgets
+from window.dock import Dock
+from window.launcher.main import Launcher
+from window.notification.notification import ModusNoti
+from window.osd.main import OSDWindow
+from window.panel.main import Panel
+from window.switcher import ApplicationSwitcher
+
+for log in [
+ "fabric",
+ "services",
+ "window",
+ "utils",
+]:
+ logger.disable(log)
+
+
+def main():
+
+ # Generate colors.css if it doesn't exist
+ colors_css_path = get_relative_path("shared/styles/colors.css")
+ if not os.path.exists(colors_css_path):
+ from utils.utils import generate_colors_from_wallpaper
+
+ default_wallpaper = get_relative_path("assets/wallpaper_example/example-1.png")
+ if os.path.exists(default_wallpaper):
+ generate_colors_from_wallpaper(default_wallpaper)
+
+ set_process_name(APP_NAME)
+
+ load_config()
+ switcher = ApplicationSwitcher()
+ panel = Panel()
+ modusnoti = ModusNoti()
+ launcher = Launcher()
+ deskwidget = Deskwidgets()
+ dock = Dock()
+ osd = OSDWindow()
+
+ # Monitor CSS files for changes
+ css_file = monitor_file(get_relative_path("shared/styles"))
+ _ = css_file.connect("changed", lambda *_: set_css())
+
+ app = Application(
+ f"{APP_NAME}",
+ panel,
+ modusnoti,
+ deskwidget.top_left,
+ deskwidget.bottom_left,
+ osd,
+ launcher,
+ switcher,
+ dock,
+ )
+
+ def set_css():
+ app.set_stylesheet_from_file(
+ get_relative_path("shared/styles/main.css"),
+ )
+
+ app.set_css = set_css
+ app.set_css()
+
+ # Inject into the executing module's namespace (__main__)
+ # to emulate what happened when this file was run directly.
+ # This allows fabric-cli exec to execute commands flawlessly.
+ import __main__
+
+ __main__.app = app
+ __main__.switcher = switcher
+ __main__.panel = panel
+ __main__.modusnoti = modusnoti
+ __main__.launcher = launcher
+ __main__.deskwidget = deskwidget
+ __main__.dock = dock
+ __main__.osd = osd
+
+ app.run()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/services/__init__.py b/src/services/__init__.py
new file mode 100644
index 00000000..7d00053c
--- /dev/null
+++ b/src/services/__init__.py
@@ -0,0 +1,10 @@
+from fabric.audio import Audio # noqa: F401
+from .battery import BatteryService # noqa: F401
+from .brightness import Brightness # noqa: F401
+from .capslock import CapsLock # noqa: F401
+from .keyboard_layout import KeyboardLayout # noqa: F401
+from .network import NetworkClient, Wifi, Ethernet, AccessPoint # noqa: F401
+from .mpris import MprisPlayer, MprisPlayerManager # noqa: F401
+from .todo import TodoService # noqa: F401
+from .modus import ModusService # noqa: F401
+from .config import on_config_change, start_config_service, ConfigService # noqa: F401
diff --git a/src/services/battery.py b/src/services/battery.py
new file mode 100644
index 00000000..554c8c72
--- /dev/null
+++ b/src/services/battery.py
@@ -0,0 +1,202 @@
+from typing import Literal, Optional
+
+from fabric.core.service import Property, Service, Signal
+from fabric.utils import Gio, GLib, logger
+
+from utils.dbus_helper import GioDBusHelper
+
+DeviceState = {
+ 0: "UNKNOWN",
+ 1: "CHARGING",
+ 2: "DISCHARGING",
+ 3: "EMPTY",
+ 4: "FULLY_CHARGED",
+ 5: "PENDING_CHARGE",
+ 6: "PENDING_DISCHARGE",
+}
+
+PowerProfile = {
+ "power-saver": "Power Saver",
+ "balanced": "Balanced",
+ "performance": "Performance",
+}
+
+
+class BatteryService(Service):
+ """Service to interact with UPower and Power Profiles via GIO D-Bus"""
+
+ @Signal
+ def changed(self) -> None:
+ """Signal emitted when battery changes."""
+
+ @Signal
+ def power_profile_changed(self) -> None:
+ """Signal emitted when power profile changes."""
+
+ _instance = None
+
+ def __new__(cls):
+ if cls._instance is None:
+ cls._instance = super().__new__(cls)
+ return cls._instance
+
+ def __init__(self, **kwargs):
+ super().__init__(**kwargs)
+
+ # UPower D-Bus configuration
+ self.bus_name = "org.freedesktop.UPower"
+ self.object_path = "/org/freedesktop/UPower/devices/DisplayDevice"
+ self.interface_name = "org.freedesktop.UPower.Device"
+
+ self.dbus_helper = GioDBusHelper(
+ bus_type=Gio.BusType.SYSTEM,
+ bus_name=self.bus_name,
+ object_path=self.object_path,
+ interface_name=self.interface_name,
+ )
+
+ self.proxy = self.dbus_helper.proxy
+
+ # Listen for PropertiesChanged signals from UPower
+ self.dbus_helper.listen_signal(
+ member="PropertiesChanged",
+ callback=self.handle_property_change,
+ )
+
+ # Power Profiles D-Bus configuration
+ self.power_profile_bus_name = "net.hadess.PowerProfiles"
+ self.power_profile_object_path = "/net/hadess/PowerProfiles"
+ self.power_profile_interface_name = "net.hadess.PowerProfiles"
+
+ try:
+ self.power_profile_helper = GioDBusHelper(
+ bus_type=Gio.BusType.SYSTEM,
+ bus_name=self.power_profile_bus_name,
+ object_path=self.power_profile_object_path,
+ interface_name=self.power_profile_interface_name,
+ )
+
+ self.power_profile_proxy = self.power_profile_helper.proxy
+
+ # Listen for PropertiesChanged signals from Power Profiles
+ self.power_profile_helper.listen_signal(
+ member="PropertiesChanged",
+ callback=self.handle_power_profile_change,
+ )
+
+ self._power_profiles_available = True
+ except Exception as e:
+ logger.warning(f"[Battery] Power Profiles daemon not available: {e}")
+ self.power_profile_helper = None
+ self.power_profile_proxy = None
+ self._power_profiles_available = False
+
+ def get_property(
+ self,
+ property: Literal[
+ "Percentage",
+ "Temperature",
+ "TimeToEmpty",
+ "TimeToFull",
+ "IconName",
+ "State",
+ "Capacity",
+ "IsPresent",
+ "Vendor",
+ ],
+ ):
+ try:
+ result = self.proxy.get_cached_property(property)
+ return result.unpack() if result is not None else None
+ except Exception as e:
+ logger.exception(f"[Battery] Error retrieving '{property}': {e}")
+ return None
+
+ def get_power_profile(self) -> Optional[str]:
+ """Get the current active power profile."""
+ if not self._power_profiles_available:
+ return None
+
+ try:
+ result = self.power_profile_proxy.get_cached_property("ActiveProfile")
+ return result.unpack() if result is not None else None
+ except Exception as e:
+ logger.exception(f"[Battery] Error retrieving active power profile: {e}")
+ return None
+
+ def set_power_profile(
+ self, profile: Literal["power-saver", "balanced", "performance"]
+ ) -> bool:
+ """Set the active power profile."""
+ if not self._power_profiles_available:
+ logger.warning("[Battery] Power Profiles daemon not available")
+ return False
+
+ if profile not in PowerProfile:
+ return False
+
+ try:
+ self.power_profile_helper.set_property(
+ interface_name=self.power_profile_interface_name,
+ property_name="ActiveProfile",
+ value_variant=GLib.Variant("s", profile),
+ )
+ return True
+ except Exception as e:
+ logger.exception(
+ f"[Battery] Error setting power profile to '{profile}': {e}"
+ )
+ return False
+
+ def get_available_power_profiles(self) -> Optional[list]:
+ """Get list of available power profiles."""
+ if not self._power_profiles_available:
+ return None
+
+ try:
+ result = self.power_profile_proxy.get_cached_property("Profiles")
+ if result is not None:
+ profiles_data = result.unpack()
+ # Extract profile names from the array of dictionaries
+ profiles = []
+ for profile_dict in profiles_data:
+ if "Profile" in profile_dict:
+ profiles.append(profile_dict["Profile"])
+ return profiles
+ return None
+ except Exception as e:
+ logger.exception(
+ f"[Battery] Error retrieving available power profiles: {e}"
+ )
+ return None
+
+ def is_power_profiles_available(self) -> bool:
+ """Check if power profiles daemon is available."""
+ return self._power_profiles_available
+
+ def get_power_profile_display_name(self, profile: str) -> str:
+ """Get display name for a power profile."""
+ return PowerProfile.get(profile, profile.title())
+
+ @Property(int, "readable", default_value=0)
+ def percentage(self) -> int:
+ return self.get_property("Percentage") or 0
+
+ @Property(int, "readable", default_value=0)
+ def state(self) -> int:
+ return self.get_property("State") or 0
+
+ @Property(bool, "readable", default_value=False)
+ def is_present(self) -> bool:
+ return self.get_property("IsPresent") or False
+
+ def handle_property_change(self, *args):
+ # Notify about property changes for OSD and other listeners
+ self.notify("percentage")
+ self.notify("state")
+ self.notify("is-present")
+ self.emit("changed")
+
+ def handle_power_profile_change(self, *_):
+ """Handle power profile property changes."""
+ self.emit("power_profile_changed")
diff --git a/services/brightness.py b/src/services/brightness.py
similarity index 96%
rename from services/brightness.py
rename to src/services/brightness.py
index 10314441..0723e2f4 100644
--- a/services/brightness.py
+++ b/src/services/brightness.py
@@ -1,10 +1,5 @@
-import os
-
-from gi.repository import GLib
-from loguru import logger
-
from fabric.core.service import Property, Service, Signal
-from fabric.utils import exec_shell_command_async, monitor_file
+from fabric.utils import GLib, exec_shell_command_async, logger, monitor_file, os
def exec_brightnessctl_async(args: str):
diff --git a/src/services/capslock.py b/src/services/capslock.py
new file mode 100644
index 00000000..931c4205
--- /dev/null
+++ b/src/services/capslock.py
@@ -0,0 +1,100 @@
+from pathlib import Path
+
+from fabric.core.service import Property, Service, Signal
+from fabric.utils import GLib, logger
+
+# Discover CapsLock LED device
+capslock_leds = list(Path("/sys/class/leds").glob("input*::capslock"))
+capslock_device = capslock_leds[0] if capslock_leds else None
+
+
+class CapsLock(Service):
+ instance = None
+
+ @staticmethod
+ def get_initial():
+ if CapsLock.instance is None:
+ CapsLock.instance = CapsLock()
+
+ return CapsLock.instance
+
+ @Signal
+ def state_changed(self, is_on: bool) -> None:
+ """Signal emitted when CapsLock state changes."""
+
+ def __init__(self, **kwargs):
+ super().__init__(**kwargs)
+
+ self.capslock_led_path = capslock_device
+ self._timeout_id = None
+ self._last_state = None
+ self._monitoring_started = False
+
+ if capslock_device is None:
+ logger.warning("CapsLock device not found, CapsLock service disabled")
+ return
+
+ # Don't access is_on property during init to avoid starting monitoring
+ # The last_state will be set when monitoring actually starts
+
+ def _start_led_monitoring(self):
+ """Start efficient polling for CapsLock LED state changes."""
+ brightness_path = self.capslock_led_path / "brightness"
+ if not brightness_path.exists():
+ logger.warning(
+ f"CapsLock brightness file does not exist: {brightness_path}"
+ )
+ return
+
+ self._start_efficient_polling()
+
+ def _start_efficient_polling(self):
+ """Start efficient polling with 100ms intervals."""
+ if self._timeout_id is None:
+ self._timeout_id = GLib.timeout_add(100, self._efficient_poll)
+
+ def _efficient_poll(self) -> bool:
+ """Monitor CapsLock state with 100ms polling intervals.
+
+ Returns:
+ bool: True to continue polling, False to stop.
+ """
+ try:
+ current_state = self._get_current_state()
+ if current_state != self._last_state:
+ self._last_state = current_state
+ self.emit("state_changed", current_state)
+ except Exception as e:
+ logger.error(f"CapsLock polling error: {e}")
+ self._timeout_id = None
+ return False # Stop polling on error
+
+ return True # Continue polling
+
+ def stop(self):
+ if self._timeout_id is not None:
+ GLib.source_remove(self._timeout_id)
+ self._timeout_id = None
+
+ def _ensure_monitoring_started(self):
+ if not self._monitoring_started and self.capslock_led_path:
+ self._monitoring_started = True
+ # Set initial state before starting monitoring
+ self._last_state = self._get_current_state()
+ self._start_led_monitoring()
+
+ def _get_current_state(self) -> bool:
+ if not self.capslock_led_path:
+ return False
+ brightness_path = self.capslock_led_path / "brightness"
+ if brightness_path.exists():
+ try:
+ return bool(int(brightness_path.read_text().strip()))
+ except (ValueError, OSError):
+ return False
+ return False
+
+ @Property(bool, "read-write", default_value=False)
+ def is_on(self) -> bool:
+ self._ensure_monitoring_started()
+ return self._get_current_state()
diff --git a/src/services/config.py b/src/services/config.py
new file mode 100644
index 00000000..74f8d90b
--- /dev/null
+++ b/src/services/config.py
@@ -0,0 +1,201 @@
+"""
+Centralized configuration service with file watching and dynamic reloads.
+
+This service encapsulates the logic for loading the application's JSON config,
+watching for file changes, and notifying registered listeners when the config
+changes. It is implemented as a singleton and intended to be reused by any
+module that needs dynamic configuration.
+"""
+
+from __future__ import annotations
+
+import json
+from typing import Any, Callable, Dict, List, Optional
+
+from fabric.utils import Gio, GLib, get_relative_path, logger, os
+
+
+class ConfigService:
+ """Singleton service handling config state and reload notifications."""
+
+ _instance: Optional["ConfigService"] = None
+
+ def __new__(cls):
+ if cls._instance is None:
+ cls._instance = super().__new__(cls)
+ return cls._instance
+
+ def __init__(self):
+ if getattr(self, "_initialized", False):
+ return
+
+ self._initialized = True
+ self._config: Dict[str, Any] = {}
+ self._reload_callbacks: List[
+ Callable[[Dict[str, Any], Dict[str, Any]], None]
+ ] = []
+ self._config_file: str = get_relative_path("../../config/config.json")
+ self._monitors: List[Gio.FileMonitor] = []
+ self._reload_pending: bool = False
+ self.RELOAD_DELAY_MS: int = 100
+
+ self._load_config()
+ self._last_notified_config = self._config.copy()
+ self._setup_monitors()
+
+ def get(self, key: str, default: Any = None) -> Any:
+ return self._config.get(key, default)
+
+ def get_all(self) -> Dict[str, Any]:
+ return self._config.copy()
+
+ def has_changed(self, key: str, old_config: Dict[str, Any]) -> bool:
+ return self._config.get(key) != old_config.get(key)
+
+ def set(self, key: str, value: Any) -> None:
+ """Set a config value (local state only)."""
+ self._config[key] = value
+
+ def save(self) -> bool:
+ """Persist the current config state to disk and trigger reloads."""
+ try:
+ os.makedirs(os.path.dirname(self._config_file), exist_ok=True)
+ with open(self._config_file, "w") as f:
+ json.dump(self._config, f, indent=4)
+
+ # Manually trigger a reload to notify listeners immediately
+ GLib.idle_add(self._reload_config)
+ return True
+ except Exception as e:
+ logger.error(f"[ConfigService] Failed to save config: {e}")
+ return False
+
+ def register_reload_callback(
+ self, callback: Callable[[Dict[str, Any], Dict[str, Any]], None]
+ ) -> None:
+ if callback not in self._reload_callbacks:
+ self._reload_callbacks.append(callback)
+
+ def unregister_reload_callback(
+ self, callback: Callable[[Dict[str, Any], Dict[str, Any]], None]
+ ) -> None:
+ if callback in self._reload_callbacks:
+ self._reload_callbacks.remove(callback)
+
+ def stop(self) -> None:
+ for monitor in self._monitors:
+ try:
+ monitor.cancel()
+ except Exception:
+ pass
+ self._monitors.clear()
+
+ def _load_config(self) -> None:
+ try:
+ if os.path.exists(self._config_file):
+ with open(self._config_file, "r") as f:
+ self._config = json.load(f)
+ else:
+ try:
+ from utils.constants import DEFAULT
+
+ # Ensure the config directory exists
+ os.makedirs(os.path.dirname(self._config_file), exist_ok=True)
+ with open(self._config_file, "w") as f:
+ json.dump(DEFAULT, f, indent=4)
+
+ self._config = DEFAULT.copy()
+ logger.info(
+ f"[ConfigService] Generated default config at {self._config_file}"
+ )
+ except ImportError as e:
+ logger.error(f"[ConfigService] Failed to import defaults: {e}")
+ self._config = {}
+ except Exception as e:
+ logger.error(f"[ConfigService] Failed to load config: {e}")
+ self._config = {}
+
+ def _setup_monitors(self) -> None:
+ files_to_watch = [self._config_file, os.path.dirname(self._config_file)]
+ for file_path in files_to_watch:
+ if not os.path.exists(file_path):
+ continue
+ try:
+ gio_file = Gio.File.new_for_path(file_path)
+ monitor = gio_file.monitor_file(Gio.FileMonitorFlags.NONE, None)
+ monitor.connect("changed", self._on_file_changed, file_path)
+ self._monitors.append(monitor)
+ except Exception as e:
+ logger.error(f"[ConfigService] Failed to monitor {file_path}: {e}")
+
+ def _on_file_changed(self, monitor, file, other_file, event_type, file_path: str):
+ # Trigger on various change events to be more robust across editors/OSs
+ valid_events = [
+ Gio.FileMonitorEvent.CHANGES_DONE_HINT,
+ Gio.FileMonitorEvent.CHANGED,
+ Gio.FileMonitorEvent.CREATED,
+ Gio.FileMonitorEvent.DELETED,
+ ]
+ if event_type in valid_events and not self._reload_pending:
+ self._reload_pending = True
+ GLib.timeout_add(self.RELOAD_DELAY_MS, self._reload_config)
+
+ def _reload_config(self) -> bool:
+ try:
+ self._reload_pending = False
+ old_config = self._last_notified_config.copy()
+ self._load_config()
+
+ # Always notify listeners on reload request to be safe
+ for callback in list(self._reload_callbacks):
+ try:
+ callback(self._config, old_config)
+ except Exception as e:
+ logger.error(f"[ConfigService] Callback failed: {e}")
+
+ self._last_notified_config = self._config.copy()
+ return False
+ except Exception as e:
+ logger.error(f"[ConfigService] Failed to reload configuration: {e}")
+ self._reload_pending = False
+ return False
+
+
+_service: Optional[ConfigService] = None
+
+
+def start_config_service() -> ConfigService:
+ global _service
+ if _service is None:
+ _service = ConfigService()
+ return _service
+
+
+def stop_config_service() -> None:
+ global _service
+ if _service is not None:
+ _service.stop()
+ _service = None
+
+
+# Convenience helpers for concise usage
+def config() -> ConfigService:
+ """Get the singleton service (concise alias)."""
+ return start_config_service()
+
+
+def get_config(key: str, default: Any = None) -> Any:
+ """Get a single config value from the singleton service."""
+ return start_config_service().get(key, default)
+
+
+def get_config_all() -> Dict[str, Any]:
+ """Get the entire current configuration state."""
+ return start_config_service().get_all()
+
+
+def on_config_change(
+ callback: Callable[[Dict[str, Any], Dict[str, Any]], None],
+) -> None:
+ """Register a reload callback on the singleton service."""
+ start_config_service().register_reload_callback(callback)
diff --git a/services/custom_notification.py b/src/services/custom_notification.py
similarity index 70%
rename from services/custom_notification.py
rename to src/services/custom_notification.py
index 76198875..c7cf3c35 100644
--- a/services/custom_notification.py
+++ b/src/services/custom_notification.py
@@ -1,18 +1,6 @@
-# Standard library imports
import json
-import os
-import time
from typing import List
-# Fabric imports
-import gi
-
-gi.require_version("Gtk", "3.0")
-gi.require_version("GdkPixbuf", "2.0")
-
-from gi.repository import GdkPixbuf
-
-import config.data as data
from fabric.core.service import Property, Service, Signal
from fabric.notifications import (
Notification,
@@ -20,9 +8,10 @@
NotificationImagePixmap,
Notifications,
)
+from fabric.utils import GdkPixbuf, logger, os, time
-gi.require_version("Gtk", "3.0")
-gi.require_version("GdkPixbuf", "2.0")
+import shared.data as data
+from services.config import config, on_config_change
NOTIFICATION_CACHE_FILE = f"{data.CACHE_DIR}/notification_history.json"
@@ -36,11 +25,11 @@ def create_from_dict(cls, data, **kwargs):
Service.__init__(self, **kwargs)
self._notification = Notification.deserialize(data)
self._cache_id = data["cached-id"] # Set directly to private var
-
+
# Store cache metadata for cleanup
self.cache_metadata = data.get("cache_metadata", {})
self.timestamp = data.get("timestamp", int(time.time()))
-
+
return self
@Signal
@@ -109,33 +98,37 @@ def image_pixbuf(self) -> GdkPixbuf.Pixbuf | None:
@Property(dict, "readable")
def serialized(self) -> dict:
"""Enhanced serialization with cache metadata - stores only cache keys"""
- from modules.notification.notification import (
+ from window.notification.notification import (
get_cache_key,
get_notification_image_cache_key,
)
-
+
# Get better cache keys for icons
app_icon_cache_key = None
notification_image_cache_key = None
-
+
if self.app_icon:
app_icon_cache_key = get_cache_key(self.app_icon, (35, 35), self.app_name)
-
+
# Only try to get notification image cache key if we can safely access image_pixbuf
if self.id:
try:
# First check if we already have the cache key stored
- if hasattr(self, 'cache_metadata') and self.cache_metadata:
- notification_image_cache_key = self.cache_metadata.get('notification_image_cache_key')
-
+ if hasattr(self, "cache_metadata") and self.cache_metadata:
+ notification_image_cache_key = self.cache_metadata.get(
+ "notification_image_cache_key"
+ )
+
# If not, try to generate it safely
- if not notification_image_cache_key and hasattr(self._notification, 'image_pixbuf'):
+ if not notification_image_cache_key and hasattr(
+ self._notification, "image_pixbuf"
+ ):
try:
# Check if image_pixbuf exists and can be accessed without loading from file
- image_pixbuf = getattr(self._notification, 'image_pixbuf', None)
+ image_pixbuf = getattr(self._notification, "image_pixbuf", None)
if image_pixbuf:
- notification_image_cache_key = get_notification_image_cache_key(
- self.id, image_pixbuf
+ notification_image_cache_key = (
+ get_notification_image_cache_key(self.id, image_pixbuf)
)
except (AttributeError, OSError, Exception):
# If temp file is gone or any other error, just mark as None
@@ -143,7 +136,7 @@ def serialized(self) -> dict:
except Exception:
# If any error occurs during cache key generation, skip it
pass
-
+
return {
"cached-id": self.cache_id,
"id": self.id,
@@ -164,8 +157,8 @@ def serialized(self) -> dict:
"app_icon_cache_key": app_icon_cache_key,
"notification_image_cache_key": notification_image_cache_key,
"has_cached_image": notification_image_cache_key is not None,
- "cache_timestamp": int(time.time())
- }
+ "cache_timestamp": int(time.time()),
+ },
}
def __init__(self, notification: Notification, cache_id: int, **kwargs):
@@ -211,7 +204,7 @@ def count(self) -> int:
def dont_disturb(self) -> bool:
"""Return the pause status."""
return self._dont_disturb
-
+
def set_dont_disturb(self, value: bool):
"""Set the pause status."""
self._dont_disturb = value
@@ -224,15 +217,49 @@ def __init__(self, **kwargs):
self._dont_disturb = False
self._count = 0
self._next_cache_id = 1 # Track next available cache ID
- self._session_start_time = int(time.time()) # Track session start time for deduplication
+ self._session_start_time = int(
+ time.time()
+ ) # Track session start time for deduplication
self.load_cached_notifications()
-
- # Connect to the notification_added signal to cache new notifications
- # Note: self here refers to the CachedNotifications service, which inherits from Notifications
- # So we connect to our own notification_added signal
+
super().notification_added.connect(self.on_notification_added)
+ # Listen for config changes to prune history if needed
+ on_config_change(self._on_config_changed)
+
+ def _on_config_changed(self, new_config, old_config):
+ # If ignored apps changed, remove them from history
+ if config().has_changed("notification_ignored_apps", old_config):
+ ignored_apps = new_config.get("notification_ignored_apps", [])
+ ids_to_remove = [
+ cid
+ for cid, cnotif in self._cached_notifications.items()
+ if cnotif.app_name in ignored_apps
+ ]
+ for cid in ids_to_remove:
+ self.remove_cached_notification(cid)
+ if ids_to_remove:
+ logger.debug(
+ f"Hot-reload: Removed {len(ids_to_remove)} notifications from ignored apps"
+ )
+
+ # If limited apps changed, prune duplicates
+ if config().has_changed("notification_limited_apps_history", old_config):
+ limited_apps = new_config.get("notification_limited_apps_history", [])
+ for app in limited_apps:
+ app_notifs = [
+ (cid, cnotif.timestamp)
+ for cid, cnotif in self._cached_notifications.items()
+ if cnotif.app_name == app
+ ]
+ if len(app_notifs) > 1:
+ # Sort by timestamp descending and keep only the first (latest)
+ app_notifs.sort(key=lambda x: x[1], reverse=True)
+ for cid, _ in app_notifs[1:]:
+ self.remove_cached_notification(cid)
+ logger.debug(f"Hot-reload: Pruned history for limited app {app}")
+
def load_cached_notifications(self) -> dict[int, CachedNotification]:
"""Load cached notifications from a JSON file (deserialization)."""
try:
@@ -247,12 +274,10 @@ def load_cached_notifications(self) -> dict[int, CachedNotification]:
cached_notification = CachedNotification.create_from_dict(notification)
cache_id = cached_notification.cache_id
max_cache_id = max(max_cache_id, cache_id)
-
+
handler_id = cached_notification.connect(
"removed-from-cache",
- lambda *args: self.remove_cached_notification(
- notification_id=cache_id
- ),
+ lambda *args: self.remove_cached_notification(notification_id=cache_id),
)
self._signal_handlers[cache_id] = handler_id
self._cached_notifications[cache_id] = cached_notification
@@ -277,16 +302,16 @@ def cache_notifications(self) -> None:
def clear_all_cached_notifications(self):
"""Empty the notifications with enhanced cache cleanup"""
# Clean up all cached files before clearing notifications
- from modules.notification.notification import cleanup_all_notification_caches
-
+ from window.notification.notification import cleanup_all_notification_caches
+
for cached_notification in self._cached_notifications.values():
handler_id = self._signal_handlers.pop(cached_notification.cache_id, None)
if handler_id:
cached_notification.disconnect(handler_id)
-
+
# Clear all notification caches (icons and images)
cleanup_all_notification_caches()
-
+
self._cached_notifications = {}
self.cache_notifications()
self._count = 0
@@ -297,55 +322,78 @@ def clear_all_cached_notifications(self):
def on_notification_added(self, service, notification_id: int) -> None:
"""Handle notification added and cache it with enhanced metadata - GUARANTEED STORAGE"""
# Don't call super() - we're handling this ourselves
-
+
# Import logger at the top of the function
- from loguru import logger
-
notification = self.get_notification_from_id(notification_id)
if not notification:
- logger.error(f"CRITICAL: Failed to get notification with ID {notification_id}")
+ logger.error(
+ f"CRITICAL: Failed to get notification with ID {notification_id}"
+ )
return
# Import here to avoid circular imports
- from config import data
- from modules.notification.notification import (
- preload_notification_assets,
+ from window.notification.notification import (
cache_notification_icon,
cache_notification_image,
get_cache_key,
- get_notification_image_cache_key
+ get_notification_image_cache_key,
+ preload_notification_assets,
)
# Check if this app should be ignored for history (don't cache)
- if notification.app_name in data.NOTIFICATION_IGNORED_APPS_HISTORY:
+ ignored_apps = config().get("notification_ignored_apps", [])
+ if notification.app_name in ignored_apps:
# Don't cache notifications from ignored apps, but still allow popup display
- logger.debug(f"Ignoring notification from {notification.app_name} (in ignore list)")
+ logger.debug(
+ f"Ignoring notification from {notification.app_name} (in ignore list)"
+ )
return
# Check for duplicates using both notification ID and timestamp to avoid session restart issues
existing_notification = None
current_time = int(time.time())
-
+
for cached_notif in self._cached_notifications.values():
# Only consider it a duplicate if:
# 1. Same notification ID AND
- # 2. Notification was cached in the current session (after session start time) AND
+ # 2. Notification was cached in the current session (after session start time) AND
# 3. Notification was cached recently (within last 5 minutes)
- cached_time = getattr(cached_notif, 'timestamp', 0)
+ cached_time = getattr(cached_notif, "timestamp", 0)
is_recent = (current_time - cached_time) < 300 # 5 minutes
is_current_session = cached_time >= self._session_start_time
-
- if (cached_notif._notification.id == notification.id and
- is_current_session and is_recent):
+
+ if (
+ cached_notif._notification.id == notification.id
+ and is_current_session
+ and is_recent
+ ):
existing_notification = cached_notif
break
-
+
if existing_notification:
- logger.debug(f"Notification ID {notification.id} already cached in current session, skipping")
+ logger.debug(
+ f"Notification ID {notification.id} already cached in current session, skipping"
+ )
return
- logger.debug(f"Caching new notification: ID={notification.id}, App={notification.app_name}, Summary={notification.summary[:50]}...")
+ logger.debug(
+ f"Caching new notification: ID={notification.id}, App={notification.app_name}, Summary={notification.summary[:50]}..."
+ )
+
+ # Handle limited history apps - remove previous notifications from same app
+ limited_apps = config().get("notification_limited_apps_history", [])
+ if notification.app_name in limited_apps:
+ ids_to_remove = [
+ cid
+ for cid, cnotif in self._cached_notifications.items()
+ if cnotif.app_name == notification.app_name
+ ]
+ for cid in ids_to_remove:
+ self.remove_cached_notification(cid)
+ logger.debug(
+ f"Pruned {len(ids_to_remove)} old notifications for limited app {notification.app_name}"
+ )
# GUARANTEED STORAGE: Always create and store notification to history first
cache_id = self._next_cache_id
@@ -355,17 +403,17 @@ def on_notification_added(self, service, notification_id: int) -> None:
cached_notification = CachedNotification(
notification=notification, cache_id=cache_id
)
- # Set cache_id directly since it's read-only property
+ # Set cache_id directly since it's read-only property
cached_notification._cache_id = cache_id
-
+
# Initialize cache metadata (will be populated below)
cached_notification.cache_metadata = {
"app_icon_cache_key": None,
"notification_image_cache_key": None,
"has_cached_image": False,
- "cache_timestamp": int(time.time())
+ "cache_timestamp": int(time.time()),
}
-
+
# IMMEDIATELY store to history before attempting any caching operations
handler_id = cached_notification.connect(
"removed-from-cache",
@@ -373,83 +421,110 @@ def on_notification_added(self, service, notification_id: int) -> None:
)
self._signal_handlers[cache_id] = handler_id
self._cached_notifications[cache_id] = cached_notification
-
+
# Save to JSON file immediately - GUARANTEED STORAGE
try:
self.cache_notifications()
logger.debug(f"GUARANTEED: Notification {cache_id} stored to history")
except Exception as e:
- logger.error(f"CRITICAL: Failed to save notification {cache_id} to history: {e}")
-
+ logger.error(
+ f"CRITICAL: Failed to save notification {cache_id} to history: {e}"
+ )
+
# Now attempt asset caching (failures here won't affect history storage)
try:
# Preload assets and store cache metadata
preload_notification_assets(notification)
-
+
# Store enhanced cache metadata
app_icon_cache_key = None
notification_image_cache_key = None
-
+
if notification.app_icon:
try:
# Only cache at 35x35 to reduce disk usage - headers will scale this down
- app_icon_cache_key = get_cache_key(notification.app_icon, (35, 35), notification.app_name)
- cache_notification_icon(notification.app_icon, (35, 35), notification.app_name)
- cached_notification.cache_metadata["app_icon_cache_key"] = app_icon_cache_key
+ app_icon_cache_key = get_cache_key(
+ notification.app_icon, (35, 35), notification.app_name
+ )
+ cache_notification_icon(
+ notification.app_icon, (35, 35), notification.app_name
+ )
+ cached_notification.cache_metadata["app_icon_cache_key"] = (
+ app_icon_cache_key
+ )
logger.debug(f"Cached app icon (35x35) for notification {cache_id}")
except Exception as e:
- logger.warning(f"Failed to cache app icon for notification {cache_id}: {e}")
-
- if hasattr(notification, 'image_pixbuf'):
+ logger.warning(
+ f"Failed to cache app icon for notification {cache_id}: {e}"
+ )
+
+ if hasattr(notification, "image_pixbuf"):
try:
# Safely try to access image_pixbuf
- image_pixbuf = getattr(notification, 'image_pixbuf', None)
+ image_pixbuf = getattr(notification, "image_pixbuf", None)
if image_pixbuf:
notification_image_cache_key = get_notification_image_cache_key(
notification.id, image_pixbuf
)
- cache_notification_image(notification.id, image_pixbuf, (35, 35))
- cached_notification.cache_metadata["notification_image_cache_key"] = notification_image_cache_key
+ cache_notification_image(
+ notification.id, image_pixbuf, (35, 35)
+ )
+ cached_notification.cache_metadata[
+ "notification_image_cache_key"
+ ] = notification_image_cache_key
cached_notification.cache_metadata["has_cached_image"] = True
- logger.debug(f"Cached notification image for notification {cache_id}")
+ logger.debug(
+ f"Cached notification image for notification {cache_id}"
+ )
except (AttributeError, OSError, Exception) as e:
- logger.warning(f"Failed to cache notification image for notification {cache_id}: {e}")
-
+ logger.warning(
+ f"Failed to cache notification image for notification {cache_id}: {e}"
+ )
+
# Update cached notification with final metadata
self._cached_notifications[cache_id] = cached_notification
-
+
# Save updated metadata to JSON
self.cache_notifications()
-
+
except Exception as e:
- logger.error(f"Asset caching failed for notification {cache_id}, but notification is still stored: {e}")
+ logger.error(
+ f"Asset caching failed for notification {cache_id}, but notification is still stored: {e}"
+ )
# Always emit signals regardless of caching success
self.notify("count")
self.emit("cached-notification-added", cached_notification)
-
- logger.debug(f"Successfully processed notification: Cache ID={cache_id}, Total cached={len(self._cached_notifications)}")
+
+ logger.debug(
+ f"Successfully processed notification: Cache ID={cache_id}, Total cached={len(self._cached_notifications)}"
+ )
def remove_cached_notification(self, notification_id: int):
"""Remove the notification of given id with enhanced cache cleanup"""
if notification_id in self._cached_notifications:
cached_notification = self._cached_notifications.pop(notification_id)
-
+
# Enhanced cache cleanup using stored metadata
- if hasattr(cached_notification, 'cache_metadata'):
+ if hasattr(cached_notification, "cache_metadata"):
cache_metadata = cached_notification.cache_metadata
-
+
# Clean up specific cached files using stored keys
- from modules.notification.notification import cleanup_notification_specific_caches
+ from window.notification.notification import (
+ cleanup_notification_specific_caches,
+ )
+
cleanup_notification_specific_caches(
app_icon_source=cached_notification.app_icon,
- notification_image_cache_key=cache_metadata.get('notification_image_cache_key')
+ notification_image_cache_key=cache_metadata.get(
+ "notification_image_cache_key"
+ ),
)
-
+
self.cache_notifications() # Update JSON
self._count -= 1
self.notify("count")
-
+
# Get the stored signal handler ID and disconnect it
handler_id = self._signal_handlers.pop(notification_id, None)
if handler_id:
diff --git a/src/services/gamemode.py b/src/services/gamemode.py
new file mode 100644
index 00000000..449d81c3
--- /dev/null
+++ b/src/services/gamemode.py
@@ -0,0 +1,102 @@
+"""
+Hyprland Game Mode Toggle Script
+
+This script toggles game mode in Hyprland by disabling/enabling animations
+and other visual effects for better performance during gaming.
+
+Uses a marker file in /tmp to track state.
+"""
+
+import subprocess
+import sys
+from datetime import datetime
+from typing import Literal
+
+from utils.functions import read_json_file, write_json_file
+
+STATE_FILE = "/tmp/hyprland_gamemode"
+
+
+def run_hyprctl(command: str) -> str:
+ """Run a hyprctl command and return the output (raises on error)."""
+ result = subprocess.run(
+ ["hyprctl"] + command.split(), capture_output=True, text=True, check=True
+ )
+ return result.stdout.strip()
+
+
+def check_gamemode() -> Literal["t", "f"]:
+ """Check if game mode is active."""
+ state = read_json_file(STATE_FILE)
+ return "t" if state and state.get("enabled") else "f"
+
+
+def enable_gamemode():
+ """Enable game mode by disabling visual effects."""
+ batch_commands = [
+ "keyword animations:enabled 0",
+ "keyword decoration:shadow:enabled 0",
+ "keyword decoration:blur:enabled 0",
+ "keyword general:gaps_in 0",
+ "keyword general:gaps_out 0",
+ "keyword general:border_size 1",
+ "keyword decoration:rounding 0",
+ ]
+ run_hyprctl(f'--batch "{"; ".join(batch_commands)}"')
+
+ state = {
+ "enabled": True,
+ "last_toggled": datetime.now().isoformat(timespec="seconds"),
+ }
+ write_json_file(state, STATE_FILE)
+ print("Game mode enabled - visual effects disabled for better performance")
+
+
+def disable_gamemode():
+ """Disable game mode by reloading Hyprland configuration."""
+ run_hyprctl("reload")
+
+ state = {
+ "enabled": False,
+ "last_toggled": datetime.now().isoformat(timespec="seconds"),
+ }
+ write_json_file(state, STATE_FILE)
+ print("Game mode disabled - visual effects restored")
+
+
+def toggle_gamemode():
+ """Toggle game mode state using JSON file."""
+ state = read_json_file(STATE_FILE) or {}
+ if state.get("enabled"):
+ disable_gamemode()
+ else:
+ enable_gamemode()
+
+
+def print_usage():
+ print("Usage:")
+ print(" gamemode check - Check if game mode is active (t/f)")
+ print(" gamemode toggle - Toggle game mode")
+
+
+def main():
+ if len(sys.argv) < 2:
+ toggle_gamemode()
+ return
+
+ command = sys.argv[1].lower()
+
+ if command == "check":
+ print(check_gamemode())
+ elif command == "toggle":
+ toggle_gamemode()
+ elif command in ["help", "-h", "--help"]:
+ print_usage()
+ else:
+ print(f"Unknown command: {command}", file=sys.stderr)
+ print_usage()
+ sys.exit(1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/services/keyboard_layout.py b/src/services/keyboard_layout.py
new file mode 100644
index 00000000..717672ac
--- /dev/null
+++ b/src/services/keyboard_layout.py
@@ -0,0 +1,154 @@
+import json
+import socket
+import threading
+from pathlib import Path
+
+from fabric.core.service import Property, Service, Signal
+from fabric.utils import GLib, logger, os
+
+import shared.data as data
+from services.config import on_config_change
+from utils.functions import read_json_file, run_command, write_json_file
+
+HYPRCTL_BIN = "hyprctl"
+
+
+class KeyboardLayout(Service):
+ """Service to manage keyboard layout switching and monitoring via Hyprland."""
+
+ _instance = None
+
+ def __new__(cls):
+ if cls._instance is None:
+ cls._instance = super().__new__(cls)
+ return cls._instance
+
+ @staticmethod
+ def get_initial():
+ if KeyboardLayout._instance is None:
+ KeyboardLayout._instance = KeyboardLayout()
+ return KeyboardLayout._instance
+
+ @Signal
+ def layout_changed(self, layout: str) -> None:
+ """Signal emitted when keyboard layout changes."""
+
+ def __init__(self, **kwargs):
+ if getattr(self, "_initialized", False):
+ return
+ super().__init__(**kwargs)
+ self._initialized = True
+
+ self.layout_json_file = Path(data.CACHE_DIR) / "kb_layout.json"
+ self._last_layout = None
+ self.layouts = []
+ self.current_index = 0
+
+ self._init_layout_config()
+ self._start_event_listener()
+
+ on_config_change(self._on_config_change)
+
+ def _init_layout_config(self):
+ config_layouts = data.load_config().get("keyboard_layouts", ["us", "np"])
+ json_data = read_json_file(self.layout_json_file)
+
+ if json_data:
+ self.layouts = json_data.get("layouts", config_layouts)
+ self.current_index = json_data.get("current_index", 0)
+ else:
+ self.layouts = config_layouts
+ self.current_index = 0
+ self._save_layout_json()
+
+ # Sync with actual Hyprland state
+ self._sync_with_hyprland()
+
+ def _sync_with_hyprland(self):
+ try:
+ result = run_command([HYPRCTL_BIN, "devices", "-j"])
+ devices = json.loads(result)
+ keyboards = devices.get("keyboards", [])
+ for k in keyboards:
+ if k.get("main"):
+ layout_name = k.get("active_keymap")
+ if layout_name:
+ self._last_layout = layout_name
+ # Try to match with our list to update index
+ for i, layout_item in enumerate(self.layouts):
+ if layout_item.lower() in layout_name.lower():
+ self.current_index = i
+ break
+ break
+ except Exception as e:
+ logger.error(f"[KeyboardLayout] Sync error: {e}")
+
+ def _save_layout_json(self):
+ write_json_file(
+ {
+ "layouts": self.layouts,
+ "current_index": self.current_index,
+ },
+ self.layout_json_file,
+ )
+
+ def _on_config_change(self, new_config, old_config):
+ new_layouts = new_config.get("keyboard_layouts")
+ if new_layouts and new_layouts != self.layouts:
+ self.layouts = new_layouts
+ self._save_layout_json()
+ logger.info(f"[KeyboardLayout] Layouts updated from config: {self.layouts}")
+
+ def _start_event_listener(self):
+ """Start a thread to listen for Hyprland layout events."""
+ thread = threading.Thread(target=self._event_loop, daemon=True)
+ thread.start()
+
+ def _event_loop(self):
+ his = os.getenv("HYPRLAND_INSTANCE_SIGNATURE")
+ xdg_runtime_dir = os.getenv("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")
+ socket_path = f"{xdg_runtime_dir}/hypr/{his}/.socket2.sock"
+
+ if not os.path.exists(socket_path):
+ logger.error(f"[KeyboardLayout] Hyprland socket not found: {socket_path}")
+ return
+
+ try:
+ with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
+ s.connect(socket_path)
+ logger.info("[KeyboardLayout] Connected to Hyprland event socket.")
+ while True:
+ data = s.recv(1024).decode("utf-8")
+ if not data:
+ break
+ for line in data.split("\n"):
+ if line.startswith("activelayout>>"):
+ # Format: activelayout>>keyboardname,layoutname
+ parts = line.split(">>")[1].split(",")
+ if len(parts) >= 2:
+ layout_name = parts[1]
+ if layout_name != self._last_layout:
+ self._last_layout = layout_name
+ GLib.idle_add(
+ self.emit, "layout_changed", layout_name
+ )
+ except Exception as e:
+ logger.error(f"[KeyboardLayout] Event listener error: {e}")
+
+ def switch_to_next(self) -> bool:
+ if not self.layouts:
+ return False
+
+ self.current_index = (self.current_index + 1) % len(self.layouts)
+ self.layouts[self.current_index]
+
+ # Use hyprctl to switch layout for all devices
+ # We assume the layouts in Hyprland config match self.layouts in order
+ run_command([HYPRCTL_BIN, "switchxkblayout", "all", "next"])
+
+ self._save_layout_json()
+ return True
+
+ @Property(str, "readable")
+ def current_layout(self) -> str:
+ return self._last_layout or "Unknown"
diff --git a/services/modus.py b/src/services/modus.py
similarity index 91%
rename from services/modus.py
rename to src/services/modus.py
index f9e94443..ce467681 100644
--- a/services/modus.py
+++ b/src/services/modus.py
@@ -2,7 +2,7 @@
from fabric.core.service import Property, Service, Signal
from fabric.hyprland.service import Hyprland
-from loguru import logger
+from fabric.utils import logger
from services.custom_notification import CachedNotifications
@@ -31,6 +31,9 @@ def dont_disturb_changed(self, value: bool) -> None: ...
@Signal
def current_active_app_name_changed(self, value: str) -> None: ...
+ @Signal
+ def current_active_wm_class_changed(self, value: str) -> None: ...
+
@Signal
def current_workspace_changed(self, value: str) -> None: ...
@@ -62,6 +65,10 @@ def notification_count_changed(self, value: int) -> None: ...
def current_active_app_name(self) -> str:
return self._current_active_app_name
+ @Property(str, flags="read-write")
+ def current_active_wm_class(self) -> str:
+ return self._current_active_wm_class
+
@Property(str, flags="read-write")
def current_workspace(self) -> str:
return self._current_workspace
@@ -124,6 +131,12 @@ def current_active_app_name(self, value: str):
self._current_active_app_name = value
self.current_active_app_name_changed(value)
+ @current_active_wm_class.setter
+ def current_active_wm_class(self, value: str):
+ if value != self._current_active_wm_class:
+ self._current_active_wm_class = value
+ self.current_active_wm_class_changed(value)
+
@current_workspace.setter
def current_workspace(self, value: str):
if value != self._current_workspace:
@@ -231,6 +244,7 @@ def __init__(self):
self._dock_apps = ""
self._dont_disturb = False
self._current_active_app_name = "Finder" # Changed from "Hyprland" to "Finder"
+ self._current_active_wm_class = ""
self._current_workspace = "1"
self._music = ""
self._current_dropdown = None
@@ -269,23 +283,24 @@ def _setup_workspace_monitoring(self):
def _setup_active_window_monitoring(self):
"""Setup active window monitoring"""
try:
- if not hasattr(self, '_hyprland_connection') or not self._hyprland_connection:
+ if (
+ not hasattr(self, "_hyprland_connection")
+ or not self._hyprland_connection
+ ):
return
- # Get initial active window
self._update_active_window()
-
- # Note: The HyprlandActiveWindow widget from Fabric library
- # should handle active window updates automatically.
- # We just need to ensure the initial state is correct.
-
except Exception as e:
- logger.error(f"[ModusService] Failed to setup active window monitoring: {e}")
+ logger.error(
+ f"[ModusService] Failed to setup active window monitoring: {e}"
+ )
def _update_active_window(self):
- """Update the current active app name based on active window"""
try:
- if not hasattr(self, '_hyprland_connection') or not self._hyprland_connection:
+ if (
+ not hasattr(self, "_hyprland_connection")
+ or not self._hyprland_connection
+ ):
return
window_data = self._hyprland_connection.send_command("j/activewindow").reply
@@ -297,15 +312,12 @@ def _update_active_window(self):
wmclass = window_info.get("class", "")
title = window_info.get("title", "")
- # Handle the case when there's no active window
if not title and not wmclass:
self.current_active_app_name = "Finder"
return
- # Simple app name formatting without circular import
name = wmclass if wmclass else title
if name:
- # Basic formatting: capitalize first letter and remove file extensions
name = str(name).title()
if "." in name:
name = name.split(".")[-1]
@@ -313,13 +325,13 @@ def _update_active_window(self):
name = "Finder"
self.current_active_app_name = name
+ self.current_active_wm_class = wmclass
except Exception as e:
logger.error(f"[ModusService] Error updating active window: {e}")
self.current_active_app_name = "Finder"
def _on_workspace_changed(self, obj, signal):
- """Handle workspace change events from Hyprland"""
try:
workspace_name = json.loads(signal.data[0])
self.current_workspace = str(workspace_name)
diff --git a/src/services/mpris.py b/src/services/mpris.py
new file mode 100644
index 00000000..2b6becc4
--- /dev/null
+++ b/src/services/mpris.py
@@ -0,0 +1,444 @@
+from typing import Literal, Optional
+
+from fabric.core.service import Property, Service, Signal
+from fabric.utils import Gio, GLib, logger, GObject
+from fabric.utils.helpers import (
+ bulk_connect,
+ clamp,
+ pascal_case_to_snake_case,
+ snake_case_to_kebab_case,
+)
+
+from utils.dbus_helper import GioDBusHelper
+
+MPRIS_MEDIAPLAYER_BUS_NAME = "org.mpris.MediaPlayer2"
+MPRIS_MEDIAPLAYER_BUS_PATH = "/org/mpris/MediaPlayer2"
+MPRIS_MEDIAPLAYER_PLAYER_BUS_NAME = "org.mpris.MediaPlayer2.Player"
+
+
+# TODO Shuffle is broken because there is no CanShuffle property, it relies on can_control but thats not all inclusive
+
+
+class MprisPlayer(Service):
+ @Signal
+ def closed(self, value: bool) -> bool: ...
+
+ @Signal
+ def changed(self) -> None: ...
+
+ seeked = Signal(name="seeked", arg_types=(GObject.TYPE_INT64,))
+
+ @Property(str, "readable")
+ def player_name(self):
+ return self.bus_name.split(".", 3)[-1].split(".")[0]
+
+ @Property(str, "readable")
+ def playback_status(self) -> Literal["Playing", "Paused", "Stopped"]:
+ # type: ignore
+ return self._dbus_helper.proxy.get_cached_property(
+ "PlaybackStatus"
+ ).get_string()
+
+ @Property(str, "read-write", default_value="None")
+ def loop_status(self) -> Literal["None", "Track", "Playlist"]:
+ # type: ignore
+ return self._dbus_helper.proxy.get_cached_property("LoopStatus").get_string()
+
+ @loop_status.setter
+ def loop_status(self, status: Literal["None", "Track", "Playlist"]) -> None:
+ if self._dbus_helper.proxy.get_cached_property("LoopStatus") is None:
+ return
+
+ (
+ self._dbus_helper.set_property(
+ MPRIS_MEDIAPLAYER_PLAYER_BUS_NAME,
+ "LoopStatus",
+ GLib.Variant("s", value=status),
+ )
+ if self.can_control
+ else None
+ )
+
+ # TODO: Playback rate??? (Rate) idk i dont really see why someone would use this ngl
+
+ @Property(bool, "read-write", default_value=False)
+ def shuffle(self) -> bool:
+ if self._dbus_helper.proxy.get_cached_property("Shuffle"):
+ # type: ignore
+ return self._dbus_helper.proxy.get_cached_property("Shuffle").get_boolean()
+ return False
+
+ @shuffle.setter
+ def shuffle(self, is_shuffle: bool) -> None:
+ if self._dbus_helper.proxy.get_cached_property("Shuffle") is None:
+ return
+
+ (
+ self._dbus_helper.set_property(
+ MPRIS_MEDIAPLAYER_PLAYER_BUS_NAME,
+ "Shuffle",
+ GLib.Variant("b", value=is_shuffle),
+ )
+ if self.can_control
+ else None
+ )
+
+ @Property(dict, "readable")
+ def metadata(self) -> dict:
+ prop: GLib.Variant | None = self._dbus_helper.proxy.get_cached_property(
+ "Metadata"
+ ) # type: ignore
+ return dict(prop) if prop else {} # type: ignore
+
+ # RELY ON METADATA
+ @Property(str, "readable")
+ def arturl(self) -> str:
+ return self.metadata.get("mpris:artUrl", "")
+
+ @Property(int, "readable", default_value=0)
+ def length(self) -> int:
+ return self.metadata.get("mpris:length", 0)
+
+ @Property(list, "readable")
+ def artist(self) -> list:
+ return self.metadata.get("xesam:artist", "")
+
+ @Property(str, "readable")
+ def album(self) -> str:
+ return self.metadata.get("xesam:album", "")
+
+ @Property(str, "readable")
+ def title(self):
+ return self.metadata.get("xesam:title", "")
+
+ # END RELY ON METADATA
+
+ @Property(float, "read-write")
+ def volume(self) -> float:
+ # type: ignore
+ return self._dbus_helper.proxy.get_cached_property("Volume").get_double()
+
+ @volume.setter
+ def volume(self, volume: float) -> None:
+ (
+ self._dbus_helper.set_property(
+ MPRIS_MEDIAPLAYER_PLAYER_BUS_NAME,
+ "Volume",
+ GLib.Variant("d", clamp(value=volume, min_value=0.0, max_value=1.0)),
+ )
+ if self.can_control
+ else None
+ )
+
+ @Property(int, "read-write", default_value=0)
+ def position(self) -> int:
+ # type: ignore
+ return self._dbus_helper.proxy.get_cached_property("Position").get_int64()
+
+ @position.setter
+ def position(self, new_pos: int) -> None:
+ self._dbus_helper.call_method(
+ self.bus_name,
+ MPRIS_MEDIAPLAYER_BUS_PATH,
+ MPRIS_MEDIAPLAYER_PLAYER_BUS_NAME,
+ "SetPosition",
+ GLib.Variant(
+ "(ox)",
+ (self.metadata["mpris:trackid"], new_pos),
+ ),
+ )
+
+ # TODO: consider MinimumRate, MaximumRate
+
+ @Property(bool, "readable", default_value=False)
+ def can_go_next(self) -> bool:
+ return self._dbus_helper.proxy.get_cached_property("CanGoNext").get_boolean()
+
+ @Property(bool, "readable", default_value=False)
+ def can_go_previous(self) -> bool:
+ return self._dbus_helper.proxy.get_cached_property(
+ "CanGoPrevious"
+ ).get_boolean()
+
+ @Property(bool, "readable", default_value=False)
+ def can_play(self) -> bool:
+ return self._dbus_helper.proxy.get_cached_property("CanPlay").get_boolean()
+
+ @Property(bool, "readable", default_value=False)
+ def can_pause(self) -> bool:
+ return self._dbus_helper.proxy.get_cached_property("CanPause").get_boolean()
+
+ @Property(bool, "readable", default_value=False)
+ def can_seek(self) -> bool:
+ return self._dbus_helper.proxy.get_cached_property("CanSeek").get_boolean()
+
+ @Property(bool, "readable", default_value=False)
+ def can_control(self) -> bool:
+ return self._dbus_helper.proxy.get_cached_property("CanControl").get_boolean()
+
+ def __init__(self, bus_name: str, **kwargs):
+ super().__init__(**kwargs)
+ self.bus_name: str = bus_name
+ self._dbus_helper: GioDBusHelper | None = None
+
+ # Ahoy!
+ self.do_register()
+
+ def do_register(self) -> None: # the bus id
+ self._dbus_helper = GioDBusHelper(
+ self.bus_name,
+ MPRIS_MEDIAPLAYER_BUS_PATH,
+ MPRIS_MEDIAPLAYER_PLAYER_BUS_NAME,
+ Gio.BusType.SESSION,
+ )
+
+ if not self._dbus_helper.proxy:
+ return
+
+ bulk_connect(
+ self._dbus_helper.proxy,
+ {
+ "g-properties-changed": self._do_handle_properties_changed,
+ "g-signal": self._do_handle_signal_changed,
+ "notify::g-name-owner": self._on_name_owner_change,
+ },
+ )
+ # Update all properties to start
+ self.update_all_properties()
+
+ def update_all_properties(self):
+ def on_update_finish(proxy, task):
+ try:
+ self._do_handle_properties_changed(
+ self._dbus_helper.proxy, proxy.call_finish(task)[0], ""
+ )
+ except Exception:
+ logger.error(f"[MPRIS-{self.bus_name}] Failed to retrieve properties")
+
+ self._dbus_helper.proxy.call(
+ "org.freedesktop.DBus.Properties.GetAll",
+ GLib.Variant.new_tuple(
+ GLib.Variant.new_string("org.mpris.MediaPlayer2.Player")
+ ),
+ Gio.DBusCallFlags.NONE,
+ -1,
+ None,
+ on_update_finish,
+ ) # User data
+
+ def _do_handle_properties_changed(
+ self, proxy: Gio.DBusProxy, changed_properties, invalidated_properties: str
+ ):
+ for prop_name in set(
+ [
+ snake_case_to_kebab_case(pascal_case_to_snake_case(x))
+ for x in changed_properties.keys()
+ ]
+ ).intersection([prop.name for prop in self.get_properties()]):
+ self.notifier(prop_name)
+
+ if prop_name == "metadata":
+ for sub_prop in ["arturl", "album", "artist", "length", "title"]:
+ self.notifier(sub_prop)
+
+ def notifier(self, prop):
+ self.notify(prop)
+ self.changed()
+
+ def _do_handle_signal_changed(
+ self,
+ proxy: Gio.DBusProxy,
+ sender_name: str,
+ signal_name: str,
+ params: tuple[GLib.Variant],
+ ):
+ # Only One Signal for Mpris
+ if signal_name == "Seeked":
+ self.seeked(params[0])
+
+ def _proxy_call(self, method_name: str, parameter: Optional[GLib.Variant]):
+ self._dbus_helper.proxy.call(
+ method_name,
+ parameter,
+ Gio.DBusCallFlags.NONE,
+ self._dbus_helper.proxy.get_default_timeout(),
+ None,
+ self._do_method_callback,
+ pascal_case_to_snake_case(method_name),
+ )
+
+ def _do_method_callback(self, _, res: Gio.AsyncResult, user_data):
+ try:
+ self._dbus_helper.proxy.call_finish(res)
+ except Exception as e:
+ print(e)
+ logger.error(
+ f"[MPRIS-{self.bus_name}] Failed to invoke method: {user_data}"
+ )
+
+ def _on_name_owner_change(self, proxy: Gio.DBusProxy, _):
+ if not self._dbus_helper.proxy.get_name_owner():
+ # proxy is automatically destroyed
+ self.closed(True)
+
+ # Methods
+ def next(self):
+ self._proxy_call("Next", None) if self.can_go_next else None
+
+ def previous(self):
+ self._proxy_call("Previous", None) if self.can_go_previous else None
+
+ def pause(self):
+ self._proxy_call("Pause", None) if self.can_pause else None
+
+ def play_pause(self):
+ (
+ self._proxy_call("PlayPause", None)
+ if self.can_pause
+ else logger.error(
+ f"[MPRIS-{self.bus_name}] `play_pause` is not supported by this player"
+ )
+ )
+
+ def stop(self):
+ (
+ self._proxy_call("Stop", None)
+ if self.can_control
+ else logger.error(
+ f"[MPRIS-{self.bus_name}] `stop` is not supported by this player"
+ )
+ )
+
+ def play(self):
+ self._proxy_call("Play", None) if self.can_play else None
+
+ def seek(self, time_in_us: int):
+ (
+ self._proxy_call("Seek", GLib.Variant.new_int64(time_in_us))
+ if self.can_seek
+ else None
+ )
+
+ # No need for SetPositition, that is handled by the setter
+
+ # Ignoring OpenUri
+
+
+class MprisPlayerManager(Service):
+ @Signal
+ def player_appeared(self, player: MprisPlayer) -> MprisPlayer:
+ logger.info(f"[MPRIS] Found Player: {player.bus_name}")
+ self._players[player.bus_name] = player
+ self.notify("players")
+ return player
+
+ @Signal
+ def player_vanished(self, bus_name: str) -> str:
+ logger.info(f"[MPRIS] Lost Player: {bus_name}")
+ self._players.pop(bus_name)
+ self.notify("players")
+ return bus_name
+
+ @Property(dict[str, MprisPlayer], "readable")
+ def players(self):
+ return self._players
+
+ def __init__(self, **kwargs):
+ super().__init__(**kwargs)
+ self._players: dict[str, MprisPlayer] = {}
+ self._dbus_helper: GioDBusHelper | None = None
+ self._subscription_id: int | None = None
+
+ # ahoy
+ self.do_register()
+
+ def do_register(self):
+ self._dbus_helper = GioDBusHelper(
+ "org.freedesktop.DBus",
+ "/org/freedesktop/DBus",
+ "org.freedesktop.DBus",
+ Gio.BusType.SESSION,
+ )
+
+ self._subscription_id = self._dbus_helper.listen_signal(
+ "NameOwnerChanged",
+ self.on_name_owner_change,
+ "org.freedesktop.DBus",
+ )
+
+ # Defer player discovery to avoid blocking initialization
+ GLib.idle_add(self._get_available_players)
+
+ def destroy(self):
+ """Unsubscribe bus signal and clear players."""
+ try:
+ if self._subscription_id is not None and self._dbus_helper is not None:
+ self._dbus_helper.unsubscribe_signal(self._subscription_id)
+ self._subscription_id = None
+ except Exception:
+ pass
+
+ try:
+ # notify vanish for all remaining players
+ for bus_name in list(self._players.keys()):
+ try:
+ self.player_vanished(bus_name)
+ except Exception:
+ pass
+ self._players.clear()
+ except Exception:
+ pass
+
+ def _list_names_callback(
+ self,
+ proxy: Gio.DBusProxy,
+ res: Gio.AsyncResult,
+ ):
+ try:
+ reply = proxy.call_finish(res)
+ for player in filter(
+ lambda x: x.startswith(MPRIS_MEDIAPLAYER_BUS_NAME),
+ reply.get_child_value(0), # type: ignore
+ ):
+ self.do_handle_new_player(player)
+
+ except Exception:
+ logger.error("[MPRIS MANAGER] Failed to ListNames")
+
+ def _get_available_players(self):
+ self._dbus_helper.proxy.call(
+ "ListNames",
+ GLib.Variant("()", ()),
+ Gio.DBusCallFlags.NONE,
+ -1,
+ None,
+ self._list_names_callback,
+ )
+
+ def on_name_owner_change(
+ self,
+ conn,
+ sender_name: str,
+ object_path: str,
+ interface_name: str,
+ signal_name: str,
+ user_data,
+ *params,
+ ) -> None:
+ name, old_owner, new_owner = user_data
+ if not name.startswith(MPRIS_MEDIAPLAYER_BUS_NAME):
+ return
+
+ if old_owner == "" and new_owner != "":
+ self.do_handle_new_player(name)
+
+ def do_handle_new_player(self, bus_name: str):
+ if bus_name in self._players.keys():
+ return
+
+ player = MprisPlayer(bus_name)
+ player.connect(
+ "closed",
+ lambda *_: self.player_vanished(bus_name),
+ )
+ self.player_appeared(player)
diff --git a/services/network.py b/src/services/network.py
similarity index 98%
rename from services/network.py
rename to src/services/network.py
index 619e81d5..07a7350a 100644
--- a/services/network.py
+++ b/src/services/network.py
@@ -1,9 +1,15 @@
-from gi.repository import NM, GLib
+from typing import Optional
+
import gi
-from typing import List, Optional
from fabric.core.service import Property, Service, Signal
-from fabric.utils import bulk_connect, get_enum_member_name, snake_case_to_kebab_case
-from loguru import logger
+from fabric.utils import (
+ GLib,
+ bulk_connect,
+ get_enum_member_name,
+ logger,
+ snake_case_to_kebab_case,
+)
+from gi.repository import NM
gi.require_version("NM", "1.0") # Ensure the correct version is loaded
@@ -333,7 +339,9 @@ def __init__(self, client: NetworkClient, device: NM.DeviceWifi, **kwargs):
bulk_connect(
self._device,
{
- "notify::active-access-point": lambda *args: self.on_access_point_activated(),
+ "notify::active-access-point": lambda *args: (
+ self.on_access_point_activated()
+ ),
"access-point-added": lambda _, ap: self.on_access_point_added(ap=ap),
"access-point-removed": lambda _, ap: self.on_access_point_removed(
ap=ap
diff --git a/src/services/screencapture.py b/src/services/screencapture.py
new file mode 100644
index 00000000..8f6cc58a
--- /dev/null
+++ b/src/services/screencapture.py
@@ -0,0 +1,409 @@
+import json
+from datetime import datetime
+from pathlib import Path
+
+from fabric import Service, Signal
+from fabric.core.service import Property
+from fabric.utils import (
+ Gio,
+ exec_shell_command,
+ exec_shell_command_async,
+ logger,
+ time,
+)
+
+from utils.functions import is_app_running, run_command, kill_process
+
+
+class ScreenCapture(Service):
+ """Service for screen capture and recording functionality"""
+
+ @Signal
+ def screenshot_taken(self, path: str) -> None:
+ """Signal emitted when screenshot is taken."""
+
+ @Signal
+ def recording_started(self, path: str) -> None:
+ """Signal emitted when recording starts."""
+
+ @Signal
+ def recording_stopped(self, path: str) -> None:
+ """Signal emitted when recording stops."""
+
+ _instance = None
+
+ def __new__(cls):
+ if cls._instance is None:
+ cls._instance = super().__new__(cls)
+ return cls._instance
+
+ def __init__(self, **kwargs):
+ super().__init__(**kwargs)
+ self.home = Path.home()
+ self.screenshots_dir = self.home / "Pictures" / "Screenshots"
+ self.recordings_dir = self.home / "Videos" / "Recordings"
+ self.recording_file = Path("/tmp/recording.txt")
+ self.recording_start_time_file = Path("/tmp/recording_start_time.txt")
+
+ self.screenshots_dir.mkdir(parents=True, exist_ok=True)
+ self.recordings_dir.mkdir(parents=True, exist_ok=True)
+
+ def notify_send(self, title, message, icon=None, actions=None):
+ cmd = ["notify-send", "-a", "Modus"]
+
+ if icon:
+ cmd.extend(["-i", str(icon)])
+
+ if actions:
+ for action in actions:
+ cmd.extend(["-A", f"{action}={action}"])
+
+ cmd.extend([title, message])
+ return run_command(cmd)
+
+ def send_screenshot_notification(self, file_path=None):
+ cmd = ["notify-send"]
+ cmd.extend(
+ [
+ "-A",
+ "files=Show in Files",
+ "-A",
+ "view=View",
+ "-A",
+ "edit=Edit",
+ "-i",
+ "camera-photo-symbolic",
+ "-a",
+ "Fabric Screenshot Utility",
+ "-h",
+ f"STRING:image-path:{file_path}",
+ "Screenshot Saved",
+ f"Saved Screenshot at {file_path}",
+ ]
+ if file_path
+ else ["Screenshot Sent to Clipboard"]
+ )
+
+ proc: Gio.Subprocess = Gio.Subprocess.new(cmd, Gio.SubprocessFlags.STDOUT_PIPE)
+
+ def do_callback(process: Gio.Subprocess, task: Gio.Task):
+ try:
+ _, stdout, stderr = process.communicate_utf8_finish(task)
+ except Exception:
+ logger.error(
+ f"[SCREENSHOT] Failed read notification action with error {stderr}"
+ )
+ return
+
+ match stdout.strip("\n"):
+ case "files":
+ exec_shell_command_async(f"xdg-open {self.screenshots_dir}")
+ case "view":
+ exec_shell_command_async(f"xdg-open {file_path}")
+ case "edit":
+ exec_shell_command_async(f"satty -f {file_path}")
+
+ proc.communicate_utf8_async(None, None, do_callback)
+ self.screenshot_taken(file_path)
+
+ def send_recording_notification(self, file_path):
+ cmd = ["notify-send"]
+ cmd.extend(
+ [
+ "-A",
+ "files=Show in Files",
+ "-A",
+ "view=View",
+ "-i",
+ "camera-video-symbolic",
+ "-a",
+ "Fabric Screenshot Utility",
+ "Screencast Saved",
+ f"Saved Screencast at {file_path}",
+ ]
+ )
+
+ proc: Gio.Subprocess = Gio.Subprocess.new(cmd, Gio.SubprocessFlags.STDOUT_PIPE)
+
+ def do_callback(process: Gio.Subprocess, task: Gio.Task):
+ try:
+ _, stdout, stderr = process.communicate_utf8_finish(task)
+ except Exception:
+ logger.error(
+ f"[SCREENCAST] Failed read notification action with error {stderr}"
+ )
+ return
+
+ match stdout.strip("\n"):
+ case "files":
+ exec_shell_command_async(f"xdg-open {self.recordings_dir}")
+ case "view":
+ exec_shell_command_async(f"xdg-open {file_path}")
+
+ proc.communicate_utf8_async(None, None, do_callback)
+ self.recording_stopped(file_path)
+
+ def check_wf_recorder(self):
+ if not is_app_running("wf-recorder"):
+ return False
+
+ kill_process("wf-recorder")
+
+ if self.recording_file.exists():
+ recording_file = self.recording_file.read_text().strip()
+ self.send_recording_notification(recording_file)
+
+ if self.recording_start_time_file.exists():
+ self.recording_start_time_file.unlink()
+
+ return True
+
+ def record_video(self, output_file, *args):
+ cmd = [
+ "wf-recorder",
+ *args,
+ "-f",
+ str(output_file),
+ "-c",
+ "libvpx-vp9",
+ "--pixel-format",
+ "yuv420p",
+ "-F",
+ "eq=brightness=0.12:contrast=1.1",
+ ]
+ return run_command(cmd)
+
+ def record_video_noaudio(self, output_file, *args):
+ cmd = [
+ "wf-recorder",
+ *args,
+ "-f",
+ str(output_file),
+ "-c",
+ "libvpx-vp9",
+ "--pixel-format",
+ "yuv420p",
+ "-F",
+ "eq=brightness=0.12:contrast=1.1",
+ "--no-audio",
+ ]
+ return run_command(cmd)
+
+ def _get_active_monitor(self):
+ """Get the name of the currently focused monitor."""
+ try:
+ monitors_json = exec_shell_command("hyprctl monitors -j")
+ if monitors_json:
+ monitors = json.loads(monitors_json)
+ for monitor in monitors:
+ if monitor.get("focused"):
+ return monitor.get("name")
+ except Exception as e:
+ logger.error(f"[SCREENSHOT] Failed to get active monitor: {e}")
+ return "eDP-1" # Fallback
+
+ def screenshot(self, target="region"):
+ """
+ Take a screenshot.
+ :param target: 'region', 'active', 'output', 'both', or a specific display name like 'eDP-1'
+ """
+ timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
+ screenshot_path = self.screenshots_dir / f"{timestamp}.png"
+
+ if target == "both":
+ # Special case for multiple displays, similar to the shell script
+ temp_edp = self.screenshots_dir / f"{timestamp}_eDP-1.png"
+ temp_hdmi = self.screenshots_dir / f"{timestamp}_HDMI-A-1.png"
+
+ cmd = f"grim -c -o eDP-1 {temp_edp} && grim -c -o HDMI-A-1 {temp_hdmi} && montage {temp_edp} {temp_hdmi} -tile 2x1 -geometry +0+0 {screenshot_path} && rm {temp_edp} {temp_hdmi}"
+ exec_shell_command_async(
+ cmd, lambda *_: self.send_screenshot_notification(str(screenshot_path))
+ )
+ return True
+
+ command = [
+ "hyprshot",
+ "-s",
+ "-o",
+ str(self.screenshots_dir),
+ "-f",
+ f"{timestamp}.png",
+ ]
+
+ if target == "region":
+ command.extend(["-m", "region"])
+ elif target == "active":
+ active_monitor = self._get_active_monitor()
+ command.extend(["-m", "output", "-m", active_monitor])
+ elif target == "output":
+ command.extend(["-m", "output"])
+ else:
+ # Specific display name
+ command.extend(["-m", "output", "-m", target])
+
+ command.append("-- ls")
+
+ try:
+ exec_shell_command_async(
+ " ".join(command),
+ self._after_screenshot,
+ )
+ except Exception as e:
+ logger.error(f"Screenshot failed: {e}")
+ return False
+
+ return True
+
+ def _after_screenshot(self, *_):
+ try:
+ screenshot_files = list(self.screenshots_dir.glob("*.png"))
+ if screenshot_files:
+ latest_file = max(screenshot_files, key=lambda f: f.stat().st_mtime)
+ self.send_screenshot_notification(file_path=str(latest_file))
+ time.sleep(0.2)
+ else:
+ # No file found after command: likely user cancelled the selection
+ self.notify_send(
+ "Screenshot cancelled",
+ "Selection was cancelled",
+ icon="camera-photo-symbolic",
+ )
+ except Exception as e:
+ logger.error(f"Screenshot notification failed: {e}")
+
+ def record(self, target="selection", no_audio=False, mode="standard"):
+ """
+ Start recording.
+ :param target: 'selection', 'eDP-1', 'HDMI-A-1', etc.
+ :param no_audio: bool
+ :param mode: 'standard', 'hq', 'gif'
+ """
+ if self.is_recording:
+ logger.error(
+ "[SCREENRECORD] Another instance of wf-recorder is already running"
+ )
+ return False
+
+ timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
+ ext = "mp4" if mode == "hq" else "gif" if mode == "gif" else "mkv"
+ output_file = self.recordings_dir / f"{timestamp}.{ext}"
+
+ self._current_recording_path = str(output_file)
+ self.recording_file.write_text(str(output_file))
+ self.recording_start_time_file.write_text(str(int(time.time())))
+
+ area = ""
+ if target == "selection":
+ geometry = exec_shell_command("slurp")
+ if not geometry or not str(geometry).strip():
+ self.notify_send(
+ "Recording cancelled",
+ "Selection was cancelled",
+ icon="camera-video-symbolic",
+ )
+ return False
+ area = f"-g '{geometry}'"
+ elif target == "active":
+ active_monitor = self._get_active_monitor()
+ area = f"-o {active_monitor}"
+ else:
+ area = f"-o {target}"
+
+ if mode == "gif":
+ # GIF optimized recording (lower fps, temp mkv then convert)
+ temp_video = f"/tmp/gif_recording_{int(time.time())}.mkv"
+ self.recording_file.write_text(temp_video) # Update tracking to temp file
+ command = f"wf-recorder -f {temp_video} -c libvpx-vp9 -r 15 --pixel-format yuv420p --no-audio {area}"
+
+ def after_gif_recording(*_):
+ if Path(temp_video).exists():
+ self.notify_send("Converting to GIF", "Processing recording...")
+ conv_cmd = f"ffmpeg -i {temp_video} -vf 'fps=15,scale=iw:-1:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=128:stats_mode=diff[p];[s1][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle' -loop 0 {output_file}"
+ exec_shell_command_async(
+ conv_cmd,
+ lambda *_: (
+ Path(temp_video).unlink(),
+ self.send_recording_notification(str(output_file)),
+ ),
+ )
+
+ exec_shell_command_async(command, after_gif_recording)
+ elif mode == "hq":
+ # High quality preset
+ preset_flags = "-c h264_vaapi -p 'preset=slow' -p 'crf=18' -r 60 -b 8000000 -B 192000 -g 30"
+ if no_audio:
+ preset_flags += " --no-audio"
+ command = f"wf-recorder --file={output_file} --pixel-format yuv420p {preset_flags} {area}"
+ exec_shell_command_async(command)
+ else:
+ # Standard mode
+ audio_flag = "--no-audio" if no_audio else ""
+ command = f"wf-recorder --file={output_file} --pixel-format yuv420p {audio_flag} {area}"
+ exec_shell_command_async(command)
+
+ self.recording_started(str(output_file))
+ return True
+
+ def stop_recording(self):
+ kill_process("wf-recorder")
+ if hasattr(self, "_current_recording_path"):
+ self.recording_stopped(self._current_recording_path)
+ return True
+
+ def convert(self, format_type, file_path=None):
+ """
+ Convert video to specified format.
+ :param format_type: 'webm', 'iphone', 'youtube', 'gif'
+ :param file_path: Optional path to specific file
+ """
+ if not file_path:
+ # Find latest recording
+ files = list(self.recordings_dir.glob("*.[mkv|mp4]*"))
+ if not files:
+ self.notify_send("Conversion Error", "No recordings found to convert")
+ return False
+ file_path = str(max(files, key=lambda f: f.stat().st_mtime))
+
+ file_path_obj = Path(file_path)
+ if not file_path_obj.exists():
+ self.notify_send("Conversion Error", f"File not found: {file_path}")
+ return False
+
+ output_path = file_path_obj.with_suffix(f".{format_type}")
+ if format_type == "iphone":
+ output_path = file_path_obj.with_name(f"{file_path_obj.stem}-iphone.mp4")
+ elif format_type == "youtube":
+ output_path = file_path_obj.with_name(f"{file_path_obj.stem}-youtube.mp4")
+
+ self.notify_send(
+ f"Converting to {format_type.upper()}", f"Processing: {file_path_obj.name}"
+ )
+
+ if format_type == "webm":
+ cmd = f"ffmpeg -y -i {file_path} -c:v libvpx -b:v 1M -c:a libvorbis {output_path}"
+ elif format_type == "iphone":
+ cmd = f"ffmpeg -y -i {file_path} -vcodec h264 -acodec aac {output_path}"
+ elif format_type == "youtube":
+ cmd = f"ffmpeg -y -i {file_path} -c:v libx264 -profile:v high -preset slow -crf 18 -pix_fmt yuv420p -c:a aac -b:a 384k -movflags +faststart {output_path}"
+ elif format_type == "gif":
+ cmd = f"ffmpeg -i {file_path} -vf 'fps=15,scale=800:-1:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=256:stats_mode=diff[p];[s1][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle' -loop 0 {output_path}"
+ else:
+ return False
+
+ def on_done(*_):
+ self.notify_send(
+ f"{format_type.upper()} Conversion Success",
+ f"Saved to {output_path.name}",
+ )
+ if format_type == "gif":
+ exec_shell_command_async(f"wl-copy < {output_path}")
+
+ exec_shell_command_async(cmd, on_done)
+ return True
+
+ @Property(bool, "readable", default_value=False)
+ def is_recording(self):
+ return is_app_running("wf-recorder") or is_app_running("gpu-screen-recorder")
+
+
+screen_capture_service = ScreenCapture()
diff --git a/services/todo.py b/src/services/todo.py
similarity index 95%
rename from services/todo.py
rename to src/services/todo.py
index 876505e8..97365f8a 100644
--- a/services/todo.py
+++ b/src/services/todo.py
@@ -8,7 +8,7 @@
from fabric.core.service import Property, Service
# Local imports
-import config.data as data
+import shared.data as data
class TodoService(Service):
@@ -164,6 +164,12 @@ def get_stats(self) -> dict:
}
-# Global service instance
-todo_service = TodoService()
+# Global service instance getter
+_todo_service = None
+
+def get_todo_service() -> TodoService:
+ global _todo_service
+ if _todo_service is None:
+ _todo_service = TodoService()
+ return _todo_service
diff --git a/src/shared/__init__.py b/src/shared/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/config/data.py b/src/shared/data.py
similarity index 55%
rename from config/data.py
rename to src/shared/data.py
index e4aa977f..429635ef 100644
--- a/config/data.py
+++ b/src/shared/data.py
@@ -1,51 +1,17 @@
import json
-import os
-import gi
-from fabric.utils.helpers import get_relative_path
-from gi.repository import Gdk, GLib
+from fabric.utils import Gdk, GLib, get_relative_path, os
-gi.require_version("Gtk", "3.0")
+from utils.functions import parse_timeout_string
-APP_NAME = "modus"
+APP_NAME = "modus1"
APP_NAME_CAP = "Modus"
-def parse_timeout_string(timeout_str):
- """
- Parse timeout string in format like '5s', '10m', '30s' etc.
- Returns timeout in milliseconds.
- """
- if not timeout_str or not isinstance(timeout_str, str):
- return 5000
-
- timeout_str = timeout_str.strip().lower()
-
- if timeout_str.endswith("s"):
- try:
- seconds = int(timeout_str[:-1])
- return seconds * 1000
- except ValueError:
- return 5000
- elif timeout_str.endswith("m"):
- try:
- minutes = int(timeout_str[:-1])
- return minutes * 60 * 1000
- except ValueError:
- return 5000
- else:
- try:
- seconds = int(timeout_str)
- return seconds * 1000
- except ValueError:
- return 5000
-
-
CACHE_DIR = str(GLib.get_user_cache_dir()) + f"/{APP_NAME}"
-
USERNAME = os.getlogin()
HOSTNAME = os.uname().nodename
-HOME_DIR = os.path.expanduser("~")
+HOME_DIR = GLib.get_home_dir()
CONFIG_DIR = os.path.expanduser(f"~/.config/{APP_NAME}")
@@ -55,22 +21,29 @@ def parse_timeout_string(timeout_str):
WALLPAPERS_DIR_DEFAULT = get_relative_path("../assets/wallpapers_example/")
-CONFIG_FILE = get_relative_path("../config/assets/config.json")
+CONFIG_FILE = get_relative_path("../../config/config.json")
MATUGEN_STATE_FILE = os.path.join(CONFIG_DIR, "matugen")
def load_config():
"""Load the configuration from config.json"""
- config = {}
+ try:
+ from services.config import start_config_service
- if os.path.exists(CONFIG_FILE):
- try:
- with open(CONFIG_FILE, "r") as f:
- config = json.load(f)
- except Exception as e:
- print(f"Error loading config: {e}")
+ service = start_config_service()
+ return service.get_all()
+ except ImportError:
+ # Fallback to direct file loading
+ config = {}
- return config
+ if os.path.exists(CONFIG_FILE):
+ try:
+ with open(CONFIG_FILE, "r") as f:
+ config = json.load(f)
+ except Exception as e:
+ print(f"Error loading config: {e}")
+
+ return config
if os.path.exists(CONFIG_FILE):
@@ -79,7 +52,6 @@ def load_config():
wallpapers_dir_from_config = config.get("wallpapers_dir", WALLPAPERS_DIR_DEFAULT)
WALLPAPERS_DIR = os.path.expanduser(wallpapers_dir_from_config)
DOCK_POSITION = config.get("dock_position", "Bottom")
- TERMINAL_COMMAND = config.get("terminal_command", "kitty -e")
DOCK_ENABLED = config.get("dock_enabled", True)
DOCK_AUTO_HIDE = config.get("dock_auto_hide", True)
DOCK_ALWAYS_OCCLUDED = config.get("dock_always_occluded", False)
@@ -99,15 +71,27 @@ def load_config():
"notification_limited_apps_history", ["Spotify"]
)
+ PANEL_COMPONENTS_VISIBILITY = {
+ "imac_button": config.get("imac_button", True),
+ "systray": config.get("systray", True),
+ "control_center": config.get("control_center", True),
+ "search": config.get("search", True),
+ "global_menu": config.get("global_menu", True),
+ "network": config.get("network", True),
+ "battery": config.get("battery", True),
+ "notification_center": config.get("notification_center", True),
+ "workspace_indicator": config.get("workspace_indicator", True),
+ "bluetooth": config.get("bluetooth", True),
+ "date_time": config.get("date_time", True),
+ }
+
else:
WALLPAPERS_DIR = WALLPAPERS_DIR_DEFAULT
DOCK_POSITION = "Bottom"
DOCK_ENABLED = True
DOCK_ALWAYS_OCCLUDED = False
DOCK_AUTO_HIDE = True
- TERMINAL_COMMAND = "kitty -e"
- DOCK_THEME = "Pills"
- DOCK_ICON_SIZE = 60
+ DOCK_ICON_SIZE = 52
WINDOW_SWITCHER_ITEMS_PER_ROW = 10
HIDE_SPECIAL_WORKSPACE = True
DOCK_HIDE_SPECIAL_WORKSPACE_APPS = True
@@ -116,3 +100,17 @@ def load_config():
NOTIFICATION_TIMEOUT = parse_timeout_string(NOTIFICATION_TIMEOUT_STR)
NOTIFICATION_IGNORED_APPS_HISTORY = ["Hyprshot"]
NOTIFICATION_LIMITED_APPS_HISTORY = ["Spotify"]
+
+ PANEL_COMPONENTS_VISIBILITY = {
+ "imac_button": True,
+ "systray": True,
+ "control_center": True,
+ "search": True,
+ "global_menu": True,
+ "network": True,
+ "battery": True,
+ "notification_center": True,
+ "workspace_indicator": True,
+ "bluetooth": True,
+ "date_time": True,
+ }
diff --git a/modules/about.py b/src/shared/dialogs/about.py
similarity index 76%
rename from modules/about.py
rename to src/shared/dialogs/about.py
index 46c7f1cb..0c41648a 100644
--- a/modules/about.py
+++ b/src/shared/dialogs/about.py
@@ -1,16 +1,10 @@
-import re
import subprocess
-import os
-import gi
+from fabric.utils import GdkPixbuf, Gtk, get_relative_path, os, re
-gi.require_version("GdkPixbuf", "2.0")
-gi.require_version("Gtk", "3.0")
-from gi.repository import GdkPixbuf, Gtk # type: ignore
-
-from fabric.utils.helpers import get_relative_path
-from utils.roam import modus_service
+from utils.functions import escape_markup_text
from utils.icon_resolver import IconResolver
+from utils.utils import setup_cursor_hover
def read_dmi(field):
@@ -106,58 +100,79 @@ def get_app_info(wmclass):
"/usr/local/share/applications",
]
+ # Search for desktop files
for path in desktop_paths:
if not os.path.exists(path):
continue
- # Try exact match first
- exact_matches = [
- f for f in os.listdir(path) if f.lower() == f"{wmclass.lower()}.desktop"
- ]
-
- # Then try starts with
- startswith_matches = [
- f
- for f in os.listdir(path)
- if f.startswith(wmclass.lower()) and f.endswith(".desktop")
- ]
-
- # Finally try contains
- contains_matches = [
- f
- for f in os.listdir(path)
- if wmclass.lower() in f.lower() and f.endswith(".desktop")
- ]
-
- # Process matches in order of preference
- for matches in [exact_matches, startswith_matches, contains_matches]:
- for filename in matches:
- desktop_file = os.path.join(path, filename)
+ search_terms = [wmclass]
+ if "." in wmclass:
+ search_terms.append(wmclass.split(".")[-1])
+
+ # Add lowercase versions
+ search_terms = list(dict.fromkeys([s.lower() for s in search_terms]))
+
+ files = os.listdir(path)
+ desktop_file = None
+
+ # 1. Try exact matches with .desktop suffix
+ for term in search_terms:
+ if f"{term}.desktop" in files:
+ desktop_file = os.path.join(path, f"{term}.desktop")
+ break
+
+ # 2. Try prefix matches
+ if not desktop_file:
+ for term in search_terms:
+ matches = [
+ f for f in files if f.startswith(term) and f.endswith(".desktop")
+ ]
+ if matches:
+ desktop_file = os.path.join(path, matches[0])
+ break
+
+ # 3. Try content searching (if no file match found yet)
+ if not desktop_file:
+ for f in files:
+ if not f.endswith(".desktop"):
+ continue
try:
- with open(desktop_file, "r", encoding="utf-8") as f:
- content = f.read()
-
- name = wmclass.title()
- version = ""
- comment = ""
- icon = wmclass.lower()
- exec_cmd = ""
- categories = ""
-
- # Parse desktop file
- in_desktop_entry = False
- for line in content.split("\n"):
- line = line.strip()
- if line == "[Desktop Entry]":
- in_desktop_entry = True
- continue
- elif line.startswith("[") and line.endswith("]"):
- in_desktop_entry = False
- continue
-
- if not in_desktop_entry or "=" not in line:
- continue
+ full_path = os.path.join(path, f)
+ with open(full_path, "r", encoding="utf-8") as df:
+ content = df.read()
+ if (
+ f"StartupWMClass={wmclass}" in content
+ or f"Exec={wmclass}" in content
+ ):
+ desktop_file = full_path
+ break
+ except Exception:
+ continue
+ if desktop_file:
+ try:
+ with open(desktop_file, "r", encoding="utf-8") as f:
+ content = f.read()
+
+ name = wmclass.title()
+ version = ""
+ comment = ""
+ icon = wmclass.lower()
+ exec_cmd = ""
+ categories = ""
+
+ # Parse desktop file
+ current_section = None
+ for line in content.split("\n"):
+ line = line.strip()
+ if not line or line.startswith("#"):
+ continue
+
+ if line.startswith("[") and line.endswith("]"):
+ current_section = line
+ continue
+
+ if current_section == "[Desktop Entry]" and "=" in line:
key, value = line.split("=", 1)
if key == "Name":
name = value
@@ -166,7 +181,6 @@ def get_app_info(wmclass):
elif key == "Comment":
comment = value
elif key == "GenericName" and not comment:
- # Use GenericName as fallback description
comment = value
elif key == "Icon":
icon = value
@@ -175,21 +189,20 @@ def get_app_info(wmclass):
elif key == "Categories":
categories = value
- # Get executable location
- location = get_executable_path(exec_cmd)
-
- return {
- "name": name,
- "version": version,
- "comment": comment,
- "icon": icon,
- "exec": exec_cmd,
- "location": location or "",
- "categories": categories,
- "desktop_file": desktop_file,
- }
- except Exception:
- continue
+ location = get_executable_path(exec_cmd)
+
+ return {
+ "name": name,
+ "version": version,
+ "comment": comment,
+ "icon": icon,
+ "exec": exec_cmd,
+ "location": location or "",
+ "categories": categories,
+ "desktop_file": desktop_file,
+ }
+ except Exception:
+ continue
# Fallback: try to find executable in PATH
location = ""
@@ -241,29 +254,32 @@ def setup_ui(self):
# App Icon
logo_box = Gtk.Box(halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER)
+ logo = None
+
try:
- # Use IconResolver's get_icon_pixbuf method like other parts of the project
+ # 1. Try resolving via IconResolver
icon_pixbuf = self.icon_resolver.get_icon_pixbuf(app_info["icon"], 128)
if icon_pixbuf:
logo = Gtk.Image.new_from_pixbuf(icon_pixbuf)
- else:
- raise Exception("Icon pixbuf not found")
except Exception:
- # Fallback: try direct file path if it's an absolute path
+ pass
+
+ if not logo:
try:
- if app_info["icon"].startswith("/") and os.path.exists(
- app_info["icon"]
- ):
+ # 2. Try direct file path
+ icon_path = app_info["icon"]
+ if icon_path.startswith("/") and os.path.exists(icon_path):
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
- app_info["icon"], 128, 128, preserve_aspect_ratio=True
+ icon_path, 128, 128, True
)
logo = Gtk.Image.new_from_pixbuf(pixbuf)
- else:
- raise Exception("Direct path failed")
except Exception:
- # Final fallback: emoji
- logo = Gtk.Label()
- logo.set_markup("๐ฑ ")
+ pass
+
+ if not logo:
+ # 3. Final fallback
+ logo = Gtk.Label()
+ logo.set_markup("๐ฆ ")
logo_box.pack_start(logo, False, False, 0)
logo_box.set_margin_bottom(15)
@@ -271,7 +287,7 @@ def setup_ui(self):
# App Name
app_name_label = Gtk.Label()
app_name_label.set_markup(
- f"{app_info['name']} "
+ f"{escape_markup_text(app_info['name'])} "
)
app_name_label.set_halign(Gtk.Align.CENTER)
app_name_label.set_margin_bottom(5)
@@ -295,7 +311,9 @@ def setup_ui(self):
desc_frame.set_shadow_type(Gtk.ShadowType.IN)
description_label = Gtk.Label()
- description_label.set_markup(f"{app_info['comment']} ")
+ description_label.set_markup(
+ f"{escape_markup_text(app_info['comment'])} "
+ )
description_label.set_justify(Gtk.Justification.CENTER)
description_label.set_halign(Gtk.Align.CENTER)
description_label.set_line_wrap(True)
@@ -345,7 +363,11 @@ def make_info_row(label_text, value_text, row):
label.set_markup(f"{label_text}: ")
value = Gtk.Label()
- value.set_markup(f'{value_text} ')
+ value.set_markup(
+ f"{
+ escape_markup_text(str(value_text))
+ } "
+ )
value.set_halign(Gtk.Align.START)
value.set_line_wrap(True)
value.set_max_width_chars(30)
@@ -409,6 +431,7 @@ def toggle(self, b):
class About(Gtk.Window):
def __init__(self):
super().__init__(title="About Menu")
+ self.set_wmclass("modus-about", "Modus")
self.set_default_size(300, 550)
self.set_size_request(300, 500)
self.set_resizable(False)
@@ -425,7 +448,7 @@ def __init__(self):
# Logo
logo_box = Gtk.Box(halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER)
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
- get_relative_path("../config/assets/icons/misc/imac.svg"),
+ get_relative_path("../../assets/icons/misc/imac.svg"),
158,
108,
preserve_aspect_ratio=True,
@@ -438,7 +461,9 @@ def __init__(self):
name_label = Gtk.Label()
name_label.set_margin_top(30)
name_label.set_markup(
- f"{read_dmi('product_name')} "
+ f"{
+ escape_markup_text(read_dmi('product_name'))
+ } "
)
vendor_label = Gtk.Label(label=read_dmi("sys_vendor"))
@@ -504,6 +529,7 @@ def make_label(text, align_end=False, name=None):
button_box = Gtk.Box(halign=Gtk.Align.CENTER)
button_box.set_margin_top(20)
more_info_button = Gtk.Button(label="More Info...", name="more-info-button")
+ setup_cursor_hover(more_info_button, "pointer")
more_info_button.connect("clicked", self.open_more_info)
button_box.pack_start(more_info_button, False, False, 0)
@@ -530,8 +556,19 @@ def open_more_info(self, button):
# TODO: Implement the logic to open more information
pass
- def toggle(self, b):
+ def toggle(self, b=None):
if self.get_visible():
self.hide()
else:
self.show_all()
+ self.present()
+
+
+_about_window = None
+
+
+def get_about_window():
+ global _about_window
+ if _about_window is None:
+ _about_window = About()
+ return _about_window
diff --git a/widgets/wifi_password_dialog.py b/src/shared/dialogs/wifi_password_dialog.py
similarity index 98%
rename from widgets/wifi_password_dialog.py
rename to src/shared/dialogs/wifi_password_dialog.py
index 9d53586e..566b3deb 100644
--- a/widgets/wifi_password_dialog.py
+++ b/src/shared/dialogs/wifi_password_dialog.py
@@ -1,18 +1,11 @@
-import gi
-from gi.repository import Gdk, GLib
-
+from fabric.utils import Gdk, GLib
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.entry import Entry
from fabric.widgets.image import Image
from fabric.widgets.label import Label
-
-# from widgets.wayland import WaylandWindow as Window
-
from fabric.widgets.window import Window
-gi.require_version("Gtk", "3.0")
-
class WiFiPasswordDialog(Window):
def __init__(
@@ -280,7 +273,7 @@ def _focus_and_select_password(self):
self.password_entry.grab_focus()
self.password_entry.select_region(0, -1)
return False
- except:
+ except Exception:
return False
def get_password(self):
diff --git a/styles/about.css b/src/shared/styles/about.css
similarity index 97%
rename from styles/about.css
rename to src/shared/styles/about.css
index 77ace66b..709515b4 100644
--- a/styles/about.css
+++ b/src/shared/styles/about.css
@@ -68,7 +68,6 @@
}
#more-info-button:hover {
background-color: rgb(72, 130, 255);
- background-color: #2369ff;
}
#more-info-button label {
diff --git a/styles/battery-widget.css b/src/shared/styles/battery-widget.css
similarity index 93%
rename from styles/battery-widget.css
rename to src/shared/styles/battery-widget.css
index 763cfe46..27035c35 100644
--- a/styles/battery-widget.css
+++ b/src/shared/styles/battery-widget.css
@@ -1,3 +1,11 @@
+#battery-window {
+ margin: 6px;
+ /* background-color: alpha(#fff, 0.09); */
+ border: 1px solid alpha(#111, 0.3);
+ box-shadow: inset 0 0 200px 0 alpha(#111, 0.3);
+ border-radius: 8px;
+}
+
#battery-widget {
background: rgba(0, 0, 0, 0);
padding-left: 5px;
diff --git a/src/shared/styles/constant.css b/src/shared/styles/constant.css
new file mode 100644
index 00000000..0d4bfe4c
--- /dev/null
+++ b/src/shared/styles/constant.css
@@ -0,0 +1,6 @@
+@define-color bg alpha(#fff, 0.07);
+@define-color hover alpha(#fff, 0.1);
+
+@define-color border var(--outline);
+@define-color active alpha(#fff, 0.9);
+@define-color urgent alpha(var(--on-error), 0.7);
diff --git a/styles/controlcenter.css b/src/shared/styles/controlcenter.css
similarity index 96%
rename from styles/controlcenter.css
rename to src/shared/styles/controlcenter.css
index a60738d2..863966f8 100644
--- a/styles/controlcenter.css
+++ b/src/shared/styles/controlcenter.css
@@ -3,6 +3,7 @@
font-family: "SF Pro Rounded";
font-weight: bold;
}
+
#control-center-menu {
background-color: transparent;
border-radius: 12px;
@@ -32,6 +33,7 @@
font-size: 16px;
font-weight: bold;
}
+
#control-center-widgets {
background-color: alpha(#010101, 0.01);
box-shadow: inset 0 0 0 1px alpha(#aaa, 0.4);
@@ -39,8 +41,9 @@
border-radius: 12px;
padding: 0.5rem;
}
-#focus-widget {
-}
+
+#focus-widget {}
+
#wb-widget,
#brightness-menu {
background-color: alpha(#000, 0.08);
@@ -48,6 +51,7 @@
border: 1px solid alpha(#111, 0.4);
border-radius: 12px;
}
+
/* Widgets */
#wifi-widget,
#bluetooth-widget,
@@ -301,9 +305,11 @@
margin-top: -10px;
/* border: 1px solid alpha(#fff, 1); */
}
+
#volume-widget-slider {
/* margin-bottom: 2px; */
}
+
#volume-widget-icon {
font-size: 22px;
margin-top: -35px;
@@ -344,6 +350,7 @@
/* min-height: 40px; */
/* margin: -9px; */
}
+
#control-center-menu scale {
background-color: transparent;
margin-top: 10px;
@@ -377,7 +384,7 @@
/* Per-app volume control styles */
#per-app-volume-control {
min-height: 140px;
- min-width: 350px;
+ min-width: 328px;
/* background-color: alpha(#000, 0.3); */
/* background-color: alpha(#999, 0.1); */
/* box-shadow: inset 0 0 200px 0 alpha(#111, 0.3); */
@@ -392,6 +399,7 @@
min-height: 120px;
min-width: 120px;
}
+
#back-button {
padding: 8px 12px;
background-color: alpha(#fff, 0.1);
@@ -402,6 +410,7 @@
font-weight: 500;
transition: background-color 0.2s ease;
}
+
#back-button:hover {
background-color: alpha(#fff, 0.18);
}
@@ -617,8 +626,10 @@
/* macOS-style expanded player - more compact */
#macos-outer-player-box {
- min-height: 80px; /* Reduced height */
- min-width: 380px; /* Slightly wider to accommodate expanded track info */
+ min-height: 80px;
+ /* Reduced height */
+ min-width: 380px;
+ /* Slightly wider to accommodate expanded track info */
/* margin: 10px; */
padding: 10px;
/* background-color: alpha(#000, 0.4); */
@@ -633,6 +644,7 @@
min-height: 70px;
border-radius: 9px;
}
+
#macos-album-image {
min-width: 70px;
min-height: 70px;
@@ -651,6 +663,7 @@
background-size: cover;
box-shadow: 0 2px 8px alpha(#000, 0.3);
}
+
#macos-album-image image {
min-width: 70px;
min-height: 70px;
@@ -686,6 +699,7 @@
color: alpha(#999, 0.8);
margin-bottom: 8px;
}
+
/* macOS seek bar styling */
#macos-seek-bar {
background-color: transparent;
@@ -735,17 +749,20 @@
/* macOS control buttons - more compact */
#macos-button-box {
- margin-top: -1px; /* Reduced top margin */
+ margin-top: -1px;
+ /* Reduced top margin */
}
#macos-control-button {
background: transparent;
border: none;
border-radius: 50%;
- min-width: 28px; /* Slightly smaller */
+ min-width: 28px;
+ /* Slightly smaller */
opacity: 0.7;
min-height: 28px;
- padding: 5px; /* Reduced padding */
+ padding: 5px;
+ /* Reduced padding */
color: #ffffff;
transition: all 0.15s ease;
}
@@ -758,10 +775,12 @@
background: transparent;
border: none;
border-radius: 50%;
- min-width: 36px; /* Slightly smaller */
+ min-width: 36px;
+ /* Slightly smaller */
opacity: 0.7;
min-height: 36px;
- padding: 7px; /* Reduced padding */
+ padding: 7px;
+ /* Reduced padding */
color: #ffffff;
transition: all 0.15s ease;
}
@@ -772,16 +791,19 @@
/* macOS player switcher dots - compact horizontal layout */
#macos-stack-buttons-box {
- margin: 2px 5px; /* Minimal margins */
+ margin: 2px 5px;
+ /* Minimal margins */
}
.macos-switcher-dot {
background: alpha(#fff, 0.3);
/* border: none; */
border-radius: 50%;
- min-width: 12px; /* Smaller dots */
+ min-width: 12px;
+ /* Smaller dots */
min-height: 12px;
- margin: 2px; /* Reduced margins */
+ margin: 2px;
+ /* Reduced margins */
/* transition: all 0.2s ease; */
}
@@ -791,7 +813,8 @@
.macos-switcher-dot.active {
background: #ffffff;
- box-shadow: 0 0 0 1px alpha(#fff, 0.3); /* Smaller glow */
+ box-shadow: 0 0 0 1px alpha(#fff, 0.3);
+ /* Smaller glow */
}
/* Per app-volume-item */
@@ -801,9 +824,11 @@
padding-right: 14px;
border-bottom: 1px solid alpha(#fff, 0.1);
}
+
#app-icon {
margin-top: 8px;
}
+
.app-name-compact {
font-size: 14px;
font-weight: bold;
@@ -914,6 +939,7 @@
#wifi-other-button:hover {
background-color: rgba(255, 255, 255, 0.1);
}
+
#wifi-other-button:last-child {
margin-bottom: 3px;
}
@@ -922,6 +948,7 @@
/* background-color: #000; */
border-radius: 8px;
}
+
.device-slot:hover {
background-color: rgba(255, 255, 255, 0.05);
}
@@ -935,6 +962,7 @@
font-size: 12px;
font-weight: bold;
}
+
#app-volume-header {
font-size: 16px;
padding-left: 5px;
@@ -949,6 +977,7 @@
padding: 2px;
/* margin-right: 8px; */
}
+
.wifi-icon-box-connected {
background-color: #007aff;
border-radius: 50%;
@@ -967,6 +996,7 @@
font-family: "SF Pro Rounded";
font-weight: bold;
}
+
.status-label {
font-size: 11px;
font-weight: 500;
@@ -981,8 +1011,9 @@
#app-control-box {
min-width: 100px;
}
+
#caffeine-widget,
#flight-widget {
padding: 0px 0 5px 0;
min-width: 50px;
-}
+}
\ No newline at end of file
diff --git a/styles/dock.css b/src/shared/styles/dock.css
similarity index 57%
rename from styles/dock.css
rename to src/shared/styles/dock.css
index 24eb0f75..639cf068 100644
--- a/styles/dock.css
+++ b/src/shared/styles/dock.css
@@ -1,27 +1,28 @@
#dock {
- background-color: alpha(#fff, 0.07);
- padding: 4px 4px;
- margin: 4px 4px;
- border: none;
- border-radius: 16px;
- transition: all 0.2s cubic-bezier(0.165, 0.84, 0.44, 1);
+ background-color: @bg;
+ padding: 4px 4px;
+ margin: 4px 4px;
+ border: none;
+ border-radius: 16px;
+ transition: all 0.2s cubic-bezier(0.165, 0.84, 0.44, 1);
}
#dock.shown {
- transition: all 0.2s cubic-bezier(0.165, 0.84, 0.44, 1);
+ transition: all 0.2s cubic-bezier(0.165, 0.84, 0.44, 1);
}
#dock_item_main_container {
- transition: all 0.2s cubic-bezier(0.165, 0.84, 0.44, 1);
+ transition: all 0.2s cubic-bezier(0.165, 0.84, 0.44, 1);
}
#dock_item.shown:hover #dock_item_main_container {
- margin-top: -12px;
+ margin-top: -12px;
}
#dock_item.shown.semi_hovered #dock_item_main_container {
- margin-top: -7px;
+ margin-top: -7px;
}
+
/**/
/* #dock_item.shown.semi_hovered #dock_item_indicator { */
/* margin-top: 11px; */
@@ -40,8 +41,8 @@
/* } */
#dock_item.shown.activated #dock_item_icon {
- opacity: 1;
- /* background-color: #01458e; */
+ opacity: 1;
+ /* background-color: #01458e; */
}
/* #dock_item.shown.activated #dock_item_indicator { */
@@ -51,9 +52,9 @@
/* } */
#dock_item_icon {
- border-radius: 13px;
- opacity: 0.7;
- transition: all 0.2s cubic-bezier(0.25, 0.1, 0.25, 1);
+ border-radius: 13px;
+ opacity: 0.7;
+ transition: all 0.2s cubic-bezier(0.25, 0.1, 0.25, 1);
}
/* #dock_item_indicator { */
@@ -63,31 +64,31 @@
/* } */
#dock_item {
- transition: margin 0.25s cubic-bezier(0.25, 0.1, 0.25, 1);
+ transition: margin 0.25s cubic-bezier(0.25, 0.1, 0.25, 1);
}
#dock_item.shown {
- padding-left: 4px;
- opacity: 1;
+ padding-left: 4px;
+ opacity: 1;
}
#dock_item.shown:first-child {
- padding-left: 0px;
+ padding-left: 0px;
}
.dock_separator {
- transition: all 0.25s cubic-bezier(0.25, 0.1, 0.25, 1);
- min-width: 1.5px;
- min-height: 50px;
- margin: 0px 4px;
- border-radius: 2px;
- background-color: #2b5ea7;
+ transition: all 0.25s cubic-bezier(0.25, 0.1, 0.25, 1);
+ min-width: 1.5px;
+ min-height: 50px;
+ margin: 0px 4px;
+ border-radius: 2px;
+ background-color: #2b5ea7;
}
.dock_separator.hidden {
- min-width: 0px;
- background-color: transparent;
- margin: 0px;
+ min-width: 0px;
+ background-color: transparent;
+ margin: 0px;
}
/* #dock_item.shown:not(.activated) { */
@@ -95,4 +96,4 @@
/* min-height: 0px; */
/* margin-top: 0px; */
/* background-color: transparent; */
-/* } */
+/* } */
\ No newline at end of file
diff --git a/styles/dropdown.css b/src/shared/styles/dropdown.css
similarity index 100%
rename from styles/dropdown.css
rename to src/shared/styles/dropdown.css
diff --git a/src/shared/styles/launcher.css b/src/shared/styles/launcher.css
new file mode 100644
index 00000000..236d78d0
--- /dev/null
+++ b/src/shared/styles/launcher.css
@@ -0,0 +1,230 @@
+#launcher {
+ background-color: alpha(#000, 0.3);
+ padding: 0;
+ border-radius: 12px;
+ min-width: 640px;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+}
+
+/* #launcher-search { */
+/* color: #000; */
+/* } */
+#header_box {
+ padding: 0px 20px 0 20px;
+ color: #000;
+}
+
+#close-button,
+#config-button {
+ background-color: transparent;
+ border-radius: 6px;
+ padding: 6px;
+ transition: all 0.15s ease;
+}
+
+#close-button:hover,
+#close-button:focus,
+#config-button:hover,
+#config-button:focus {
+ background-color: rgba(255, 255, 255, 0.1);
+ border-radius: 6px;
+}
+
+#close-button.focused,
+#config-button.focused {
+ background-color: rgba(0, 122, 255, 0.2);
+ border-radius: 6px;
+ box-shadow: 0 0 0 2px rgba(0, 122, 255, 0.4);
+}
+
+#close-button.focused #close-label {
+ color: #007aff;
+}
+
+#config-button.focused #config-label {
+ color: #007aff;
+}
+
+#close-button:active {
+ background-color: rgba(255, 255, 255, 0.2);
+ border-radius: 6px;
+}
+
+#close-label {
+ color: rgba(255, 255, 255, 0.7);
+ font-size: 18px;
+}
+
+#close-button:active #close-label {
+ color: rgba(255, 255, 255, 0.9);
+}
+
+#config-button:active {
+ background-color: rgba(255, 255, 255, 0.2);
+ border-radius: 6px;
+}
+
+#config-label {
+ color: rgba(255, 255, 255, 0.7);
+ font-size: 18px;
+}
+
+#config-button:active #config-label {
+ color: rgba(255, 255, 255, 0.9);
+}
+
+#launcher-icon-label {
+ font-size: 20px;
+ padding: 6px;
+ color: rgba(255, 255, 255, 0.8);
+}
+
+#launcher-search {
+ font-weight: 400;
+ font-size: 36px;
+ background-color: transparent;
+ color: rgba(255, 255, 255, 0.95);
+ border: none;
+ border-radius: 0;
+ padding: 12px 0;
+ margin: 0;
+}
+
+#launcher-search:focus {
+ background-color: transparent;
+ box-shadow: none;
+ border: none;
+}
+
+#launcher-search selection {
+ color: white;
+ background-color: rgba(0, 122, 255, 0.8);
+}
+
+#launcher-results-scroll {
+ margin: 0;
+ border-radius: 0;
+ background: transparent;
+ padding: 0 20px 20px 20px;
+}
+
+#launcher-results-scroll scrollbar {
+ border-radius: 0;
+ background-color: transparent;
+ padding: 0;
+ margin: 0;
+ min-width: 0;
+}
+
+#launcher-results-scroll scrollbar slider {
+ border-radius: 2px;
+ min-width: 4px;
+ min-height: 20px;
+ background-color: rgba(255, 255, 255, 0.3);
+ margin: 0;
+}
+
+#launcher-results-scroll scrollbar:hover slider {
+ background-color: rgba(255, 255, 255, 0.5);
+}
+
+#launcher-results {
+ background: transparent;
+ margin-top: 8px;
+}
+
+#launcher-result-item {
+ border-radius: 8px;
+ padding: 12px 16px;
+ margin: 2px 0;
+ min-height: 64px;
+ transition: all 0.15s ease;
+ background: transparent;
+}
+
+@keyframes loadSlot {
+ 0% {
+ opacity: 0;
+ }
+
+ 100% {
+ opacity: 1;
+ }
+}
+
+#launcher-result-item:focus,
+#launcher-result-item:selected,
+#launcher-result-item:hover,
+#launcher-result-item.selected {
+ border-radius: 8px;
+ background-color: rgba(0, 122, 255, 0.8);
+ padding: 12px 16px;
+ margin: 2px 0;
+}
+
+#launcher-result-item.selected #result-item-title {
+ color: white;
+ font-weight: 600;
+}
+
+#launcher-result-item.selected #result-item-subtitle {
+ color: rgba(255, 255, 255, 0.8);
+}
+
+#launcher-result-item.selected #result-item-plugin {
+ color: rgba(255, 255, 255, 0.6);
+}
+
+#result-item-main {
+ min-height: 56px;
+}
+
+#launcher-result-item {
+ min-height: 56px;
+}
+
+#result-item-icon {
+ min-width: 56px;
+ min-height: 56px;
+ margin-right: 16px;
+ border-radius: 12px;
+}
+
+#result-item-title {
+ font-size: 18px;
+ font-weight: 500;
+ color: rgba(255, 255, 255, 0.95);
+ margin-top: 4px;
+ margin-bottom: 2px;
+}
+
+#result-item-subtitle {
+ font-size: 14px;
+ color: rgba(255, 255, 255, 0.6);
+ margin-bottom: 4px;
+}
+
+#result-item-plugin {
+ font-size: 12px;
+ color: rgba(255, 255, 255, 0.4);
+ font-style: normal;
+ margin-bottom: 4px;
+ opacity: 1;
+ font-weight: 400;
+}
+
+#network-password-entry {
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ background: rgba(255, 255, 255, 0.05);
+ color: rgba(255, 255, 255, 0.9);
+ padding: 12px;
+ border-radius: 8px;
+ margin-bottom: 8px;
+ font-size: 14px;
+}
+
+#network-password-entry:focus {
+ border: 1px solid rgba(0, 122, 255, 0.6);
+ background: rgba(255, 255, 255, 0.1);
+ box-shadow: 0 0 0 3px rgba(0, 122, 255, 0.2);
+}
diff --git a/styles/lock.css b/src/shared/styles/lock.css
similarity index 98%
rename from styles/lock.css
rename to src/shared/styles/lock.css
index 00fe279f..930f614e 100644
--- a/styles/lock.css
+++ b/src/shared/styles/lock.css
@@ -50,6 +50,7 @@
#face-icon image {
margin-bottom: 10px;
+ border-radius: 99px;
}
#face-icon {
padding-bottom: 10px;
diff --git a/src/shared/styles/main.css b/src/shared/styles/main.css
new file mode 100644
index 00000000..e4abf1b4
--- /dev/null
+++ b/src/shared/styles/main.css
@@ -0,0 +1,27 @@
+@import url("./colors.css");
+@import url("./constant.css");
+@import url("./panel.css");
+@import url("./dock.css");
+@import url("./switcher.css");
+@import url("./osd.css");
+@import url("./controlcenter.css");
+@import url("./notification.css");
+@import url("./dropdown.css");
+@import url("./about.css");
+@import url("./notification-center.css");
+@import url("./player.css");
+@import url("./widgets.css");
+@import url("./tray.css");
+@import url("./battery-widget.css");
+@import url("./lock.css");
+@import url("./todo.css");
+@import url("./launcher.css");
+@import url("./switcher.css");
+@import url("./settings.css");
+
+* {
+ all: unset;
+ color: var(--foreground);
+ font-size: unset;
+ font-family: "SF Pro Rounded";
+}
\ No newline at end of file
diff --git a/styles/notification-center.css b/src/shared/styles/notification-center.css
similarity index 100%
rename from styles/notification-center.css
rename to src/shared/styles/notification-center.css
diff --git a/styles/notification.css b/src/shared/styles/notification.css
similarity index 95%
rename from styles/notification.css
rename to src/shared/styles/notification.css
index 35a7db00..04d4d463 100644
--- a/styles/notification.css
+++ b/src/shared/styles/notification.css
@@ -34,7 +34,11 @@
color: var(--shadow);
}
-#notification-image image {
+#notification-icon {
+ border-radius: 16px;
+}
+
+#notification-image {
border-radius: 16px;
color: var(--on-surface);
}
diff --git a/src/shared/styles/osd.css b/src/shared/styles/osd.css
new file mode 100644
index 00000000..4c4d8eb2
--- /dev/null
+++ b/src/shared/styles/osd.css
@@ -0,0 +1,37 @@
+#osd-container {
+ background-color: alpha(#fff, 0.09);
+ padding: 12px 20px;
+ margin: 70px;
+ min-width: 180px;
+ min-height: 200px;
+ border-radius: 16px;
+}
+
+#osd-container scale {
+ min-width: 180px;
+}
+
+#osd-container trough {
+ background: var(--surface);
+ min-height: 15px;
+ margin-right: 4px;
+ transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275);
+ border-radius: 16px;
+}
+
+#osd-container trough highlight {
+ border-radius: 100px;
+ background: var(--primary);
+ transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275);
+}
+
+#osd-container.muted trough highlight,
+#osd-container.muted slider,
+#osd-container scale.muted trough highlight,
+#osd-container scale.muted slider {
+ background-color: var(--surface-bright);
+}
+
+#osd-image.muted {
+ color: var(--outline);
+}
\ No newline at end of file
diff --git a/src/shared/styles/panel.css b/src/shared/styles/panel.css
new file mode 100644
index 00000000..997e1536
--- /dev/null
+++ b/src/shared/styles/panel.css
@@ -0,0 +1,73 @@
+#panel {
+ background-color: @bg;
+ margin: -4px 0;
+ transition: all 120ms ease-in-out;
+}
+
+#modules-left,
+#modules-right {
+ margin: 0 5px;
+ padding: 2px 0;
+}
+
+#panel-button:hover,
+#panel-button.active,
+#battery-button:hover,
+#battery-button.active,
+#network-button:hover,
+#network-button.active,
+#bt-button:hover,
+#bt-button.active,
+#tray-button:hover,
+#tray-button.active,
+#global-menu:hover,
+#global-menu.active,
+#workspaces>button:hover {
+ background-color: @hover;
+}
+
+#panel-button,
+#battery-button,
+#network-button,
+#bt-button,
+#tray-button {
+ margin: 5px 0;
+ padding: 0 2px;
+ border-radius: 8px;
+}
+
+#menubar label,
+#battery-label,
+#date-time {
+ font-weight: 500;
+}
+
+#global-menu {
+ margin: 5px 2px;
+ padding: 0 5px;
+ border-radius: 5px;
+}
+
+#date-time {
+ margin: 0 4px;
+}
+
+#workspaces>button {
+ margin: 6px 0;
+ padding: 0 16px;
+ border-radius: 8px;
+ min-width: 12px;
+ border: 1px solid @border;
+}
+
+#workspaces>button.active {
+ background-color: @active;
+}
+
+#workspaces>button.active label {
+ color: var(--shadow);
+}
+
+#workspaces>button.urgent {
+ background-color: @urgent;
+}
diff --git a/src/shared/styles/password-dialog.css b/src/shared/styles/password-dialog.css
new file mode 100644
index 00000000..62230274
--- /dev/null
+++ b/src/shared/styles/password-dialog.css
@@ -0,0 +1,132 @@
+#wifi-password-dialog {
+ background-color: transparent;
+}
+
+#wifi-dialog-background {
+ background-color: alpha(#fff, 0.05);
+ border-radius: 12px;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ padding: 20px;
+ min-width: 450px;
+}
+
+#wifi-dialog-title-container {
+ /* margin-bottom: 12px; */
+}
+
+#wifi-dialog-icon {
+ color: #007aff;
+ margin-right: 4px;
+}
+
+#wifi-dialog-title {
+ color: #ffffff;
+ font-size: 14px;
+ font-weight: 500;
+}
+
+#wifi-dialog-error {
+ color: #ff4444;
+ font-size: 12px;
+ font-weight: 500;
+ /* margin-bottom: 8px; */
+}
+
+#wifi-dialog-password-container {
+ margin: 5px 0;
+}
+
+#wifi-dialog-password-label {
+ color: #ffffff;
+ font-size: 13px;
+ font-weight: 500;
+ margin-bottom: 4px;
+}
+
+#wifi-dialog-password-entry {
+ background-color: rgba(255, 255, 255, 0.1);
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ border-radius: 6px;
+ padding: 8px 12px;
+ color: #ffffff;
+ font-size: 13px;
+}
+
+#wifi-dialog-password-entry:focus {
+ border-color: #007aff;
+ box-shadow: 0 0 0 2px rgba(0, 122, 255, 0.3);
+ background-color: rgba(255, 255, 255, 0.15);
+}
+
+#wifi-dialog-show-password-box {
+ margin-top: 4px;
+}
+
+#wifi-dialog-show-password-button {
+ background-color: transparent;
+ border: none;
+ padding: 4px;
+ border-radius: 4px;
+ min-width: 24px;
+ min-height: 24px;
+}
+
+#wifi-dialog-show-password-button:hover {
+ background-color: alpha(#fff, 0.1);
+}
+
+#wifi-dialog-show-password-button image {
+ color: #ffffff;
+}
+
+#wifi-dialog-show-password-label {
+ color: #ffffff;
+ font-size: 12px;
+}
+
+#wifi-dialog-button-box {
+ margin-top: -15px;
+}
+
+#wifi-dialog-cancel-button,
+#wifi-dialog-join-button {
+ border-radius: 6px;
+ font-size: 13px;
+ font-weight: 500;
+ min-width: 80px;
+ min-height: 30px;
+}
+
+#wifi-dialog-cancel-button {
+ background-color: rgba(255, 255, 255, 0.1);
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ color: #ffffff;
+}
+
+#wifi-dialog-cancel-button:hover {
+ background-color: rgba(255, 255, 255, 0.15);
+}
+
+#wifi-dialog-join-button {
+ background-color: #007aff;
+ border: 1px solid #007aff;
+ color: #ffffff;
+}
+
+#wifi-dialog-join-button:hover {
+ background-color: #0056cc;
+ border-color: #0056cc;
+}
+
+#wifi-dialog-join-button.disabled {
+ opacity: 0.5;
+ background-color: #555555;
+ border-color: #555555;
+ color: #aaaaaa;
+}
+
+#wifi-dialog-join-button.disabled:hover {
+ background-color: #555555;
+ border-color: #555555;
+ color: #aaaaaa;
+}
diff --git a/styles/player.css b/src/shared/styles/player.css
similarity index 99%
rename from styles/player.css
rename to src/shared/styles/player.css
index 1a9b6513..437084b4 100644
--- a/styles/player.css
+++ b/src/shared/styles/player.css
@@ -40,7 +40,7 @@
}
#outer-no-player-box-c {
min-height: 55px;
- min-width: 310px;
+ min-width: 345px;
border-radius: 8px;
/* margin: 0 6px; */
/* background-color: alpha(#000, 0.3); */
diff --git a/src/shared/styles/settings.css b/src/shared/styles/settings.css
new file mode 100644
index 00000000..4f61e87b
--- /dev/null
+++ b/src/shared/styles/settings.css
@@ -0,0 +1,146 @@
+#settings-window {
+ background-color: var(--background);
+ border-radius: 12px;
+ border: 1px solid var(--border);
+ min-width: 850px;
+ min-height: 600px;
+}
+
+#settings-main-box {
+ padding: 0;
+}
+
+#settings-sidebar {
+ background-color: rgba(0, 0, 0, 0.05);
+ padding: 20px 10px;
+ min-width: 200px;
+ border-right: 1px solid var(--border);
+}
+
+#settings-sidebar-button {
+ padding: 10px 15px;
+ border-radius: 8px;
+ margin-bottom: 2px;
+}
+
+#settings-sidebar-button:hover {
+ background-color: rgba(255, 255, 255, 0.05);
+}
+
+#settings-sidebar-button Label {
+ font-size: 14px;
+ font-weight: 500;
+}
+
+#settings-sidebar-button.active {
+ background-color: var(--accent);
+}
+
+#settings-sidebar-button.active Label {
+ color: var(--accent-foreground);
+}
+
+#settings-content-wrapper {
+ padding: 30px;
+}
+
+#settings-page-title {
+ font-size: 24px;
+ font-weight: 700;
+ margin-bottom: 20px;
+ color: var(--foreground);
+}
+
+#settings-row {
+ padding: 12px 15px;
+ border-radius: 8px;
+ background-color: rgba(255, 255, 255, 0.03);
+ margin-bottom: 2px;
+}
+
+#settings-row-label {
+ font-size: 15px;
+ font-weight: 500;
+}
+
+#settings-row-description {
+ font-size: 12px;
+ color: var(--foreground-dim);
+}
+
+#settings-switch {
+ background-color: rgba(255, 255, 255, 0.1);
+ padding: 6px 12px;
+ border-radius: 20px;
+ min-width: 50px;
+ transition: all 0.2s ease;
+}
+
+#settings-switch.active {
+ background-color: var(--accent);
+ color: var(--accent-foreground);
+}
+
+#settings-entry {
+ background-color: rgba(255, 255, 255, 0.05);
+ padding: 8px 12px;
+ border-radius: 6px;
+ border: 1px solid var(--border);
+ min-width: 200px;
+ color: var(--foreground);
+}
+
+#settings-entry:focus {
+ border-color: var(--accent);
+}
+
+#settings-combo {
+ background-color: rgba(255, 255, 255, 0.05);
+ padding: 4px 8px;
+ border-radius: 6px;
+ border: 1px solid var(--border);
+ min-width: 150px;
+ color: var(--foreground);
+}
+
+#settings-combo:hover {
+ background-color: rgba(255, 255, 255, 0.1);
+}
+
+#settings-list-tags {
+ margin-bottom: 5px;
+}
+
+#settings-list-tag {
+ background-color: rgba(255, 255, 255, 0.08);
+ border-radius: 15px;
+ padding: 4px 10px;
+ margin-right: 5px;
+ margin-bottom: 5px;
+}
+
+#settings-list-tag:hover {
+ background-color: rgba(255, 255, 255, 0.15);
+}
+
+#settings-list-tag Label {
+ font-size: 13px;
+}
+
+#settings-list-tag-close {
+ color: var(--foreground-dim);
+ font-size: 10px;
+ margin-left: 3px;
+}
+
+#settings-list-tag-close:hover {
+ color: var(--accent);
+}
+
+#settings-list-entry {
+ background-color: rgba(255, 255, 255, 0.03);
+ border: 1px dashed var(--border);
+ padding: 6px 12px;
+ border-radius: 6px;
+ font-size: 13px;
+}
\ No newline at end of file
diff --git a/src/shared/styles/switcher.css b/src/shared/styles/switcher.css
new file mode 100644
index 00000000..8691d055
--- /dev/null
+++ b/src/shared/styles/switcher.css
@@ -0,0 +1,48 @@
+#application-switcher-container {
+ background-color: var(--shadow);
+ border-radius: 20px;
+ padding: 24px;
+}
+
+#application-switcher-view {
+ padding: 0px;
+ margin-bottom: 12px;
+}
+
+#switcher-button {
+ padding: 12px;
+ min-width: 100px;
+ transition: all 0.2s ease;
+}
+
+#window-button {
+ background-color: transparent;
+ border-radius: 12px;
+}
+
+#window-button.active {
+ background-color: var(--surface);
+}
+
+.switcher-selection-label {
+ color: var(--foreground);
+ font-size: 16px;
+ font-weight: 600;
+ margin-top: 8px;
+}
+
+.switcher-workspace-label {
+ color: var(--foreground);
+ opacity: 0.6;
+ font-size: 12px;
+ margin-top: 4px;
+}
+
+#switcher-icon-box {
+ border-radius: 12px;
+ padding: 4px;
+}
+
+#window-row {
+ padding: 0px;
+}
\ No newline at end of file
diff --git a/src/shared/styles/todo.css b/src/shared/styles/todo.css
new file mode 100644
index 00000000..e69de29b
diff --git a/styles/tray.css b/src/shared/styles/tray.css
similarity index 100%
rename from styles/tray.css
rename to src/shared/styles/tray.css
diff --git a/styles/widgets.css b/src/shared/styles/widgets.css
similarity index 70%
rename from styles/widgets.css
rename to src/shared/styles/widgets.css
index 8656200e..acd217c8 100644
--- a/styles/widgets.css
+++ b/src/shared/styles/widgets.css
@@ -1,41 +1,89 @@
-/* Weather Widget CSS */
#weather-container {
- background: linear-gradient(to bottom, #202020, #141414);
margin: 5px 5px 20px 5px;
min-width: 170px;
min-height: 170px;
- border-radius: 16px;
- transition: background 0.8s ease-in-out;
+ border-radius: 18px;
+ transition: all 0.5s ease-in-out;
+ background: #202020;
}
-#weather-widget label {
- margin-left: 12px;
+/* Base Weather Layout */
+#weather-widget {
+ padding: 12px 14px;
}
#city {
- margin-top: 10px;
- color: var(--blue-dim);
+ color: rgba(255, 255, 255, 0.85);
font-weight: 600;
- font-size: 20px;
+ font-size: 18px;
}
+
#temperature {
- font-size: 50px;
- font-weight: 400;
+ font-size: 64px;
+ font-weight: 600;
+ color: white;
+ margin-top: -5px;
+ margin-bottom: -5px;
}
#condition-emoji {
- margin-top: 5px;
- font-size: 16px;
+ margin-top: -2px;
}
#condition {
- font-size: 15px;
+ font-size: 16px;
font-weight: 600;
+ color: white;
}
+
#feels-like {
- font-weight: 600;
+ font-weight: 500;
font-size: 15px;
+ color: rgba(255, 255, 255, 0.8);
+}
+
+/* Weather Condition Gradients */
+.weather-clear {
+ background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
+}
+
+.weather-mostly-clear {
+ background: linear-gradient(135deg, #439cfb 0%, #6bc1f9 100%);
+}
+
+.weather-partly-cloudy {
+ background: linear-gradient(135deg, #6a85b6 0%, #bac8e0 100%);
+}
+
+.weather-overcast {
+ background: linear-gradient(135deg, #485563 0%, #29323c 100%);
+}
+
+.weather-fog {
+ background: linear-gradient(135deg, #757f9a 0%, #d7dde8 100%);
+}
+
+.weather-light-rain {
+ background: linear-gradient(135deg, #4b6cb7 0%, #182848 100%);
+}
+
+.weather-rain {
+ background: linear-gradient(135deg, #2b5876 0%, #4e4376 100%);
+}
+
+.weather-heavy-rain {
+ background: linear-gradient(135deg, #141e30 0%, #243b55 100%);
+}
+
+.weather-snow,
+.weather-heavy-snow {
+ background: linear-gradient(135deg, #83a4d4 0%, #b6fbff 100%);
}
+
+.weather-storm {
+ background: linear-gradient(135deg, #0f2027 0%, #203a43 50%, #2c5364 100%);
+}
+
/* # */
/* Calendar Widget */
@@ -119,6 +167,7 @@
#date-widget label {
margin-top: 10px;
}
+
#day label,
#month label {
color: var(--foreground);
@@ -136,6 +185,7 @@
font-size: 90px;
font-weight: 600;
}
+
#day label {
color: alpha(var(--blue-dim), 0.9);
}
@@ -156,14 +206,17 @@
min-height: 170px;
border-radius: 16px;
}
+
#ram-progress {
margin-left: 12px;
color: var(--foreground);
}
+
#progress-label {
font-weight: 600;
font-size: 13px;
}
+
#progress {
border: solid 8px var(--blue-dim);
color: alpha(#aaaaaa, 0.7);
@@ -213,6 +266,7 @@
font-size: 14px;
font-weight: 600;
}
+
#component-name {
font-size: 10px;
-}
+}
\ No newline at end of file
diff --git a/src/shared/widgets/animator.py b/src/shared/widgets/animator.py
new file mode 100644
index 00000000..96b8a945
--- /dev/null
+++ b/src/shared/widgets/animator.py
@@ -0,0 +1,263 @@
+# Author: Yousef EL-Darsh
+# License (SPDX): AGPL-3.0-or-later
+
+from functools import cache
+from typing import Protocol, cast
+
+from fabric.core.service import Property, Service, Signal
+from fabric.utils import GLib, Gtk, clamp
+
+
+@cache
+def lerp(start: float, end: float, progress: float) -> float:
+ return start + (end - start) * progress
+
+
+@cache
+def steps(n: int, progress: float, start_jump: bool = False) -> float:
+ if start_jump:
+ return min(int(progress * n), n - 1) / (n - 1) if n > 1 else 0.0
+ return min(int(progress * n + 1e-10), n) / n
+
+
+@cache
+def cubic_bezier(
+ x1: float, y1: float, x2: float, y2: float, progress: float, epsilon=1e-6
+) -> float:
+ # implementation yanked off of the internet, don't blame me about anything.
+ # Fast-path boundaries to avoid overshoot and unnecessary work
+ if progress <= 0.0 or progress >= 1.0:
+ return clamp(progress, 0.0, 1.0)
+
+ t_guess = progress
+ for _ in range(8):
+ t = t_guess
+ t_sq = t * t
+ omt = 1.0 - t
+ omt_sq = omt * omt
+
+ x = 3 * x1 * omt_sq * t + 3 * x2 * omt * t_sq + t * t_sq
+ dx = 3 * x1 * omt_sq + 6 * (x2 - x1) * omt * t + 3 * (1 - x2) * t_sq
+
+ if abs(dx) < epsilon:
+ break
+
+ delta = (x - progress) / dx
+ t_guess -= delta
+ t_guess = clamp(t_guess, 0.0, 1.0)
+
+ if abs(delta) < epsilon:
+ break
+
+ t = clamp(t_guess, 0.0, 1.0)
+ t_sq = t * t
+ omt = 1.0 - t
+ return 3 * y1 * omt * omt * t + 3 * y2 * omt * t_sq + t * t_sq
+
+
+def ease_linear(progress: float) -> float:
+ return cubic_bezier(1, 1, 0, 0, progress)
+
+
+def ease_in(progress: float) -> float:
+ return cubic_bezier(0.4, 0, 1, 1, progress)
+
+
+def ease_out(progress: float) -> float:
+ return cubic_bezier(0, 0, 0.2, 1, progress)
+
+
+def ease_in_out(progress: float) -> float:
+ return cubic_bezier(0.4, 0, 0.2, 1, progress)
+
+
+class TimingFunctionCallback(Protocol):
+ def __call__(self, progress: float, *args, **kwargs) -> float: ...
+
+
+class Animator(Service):
+ """
+ An animator is a simple way for animating a value on
+ a set timeline based on a given timing function
+ """
+
+ @Signal
+ def finished(self) -> None: ...
+
+ @Property(TimingFunctionCallback, "read-write")
+ def timing_function(self) -> TimingFunctionCallback:
+ return self._timing_function
+
+ @timing_function.setter
+ def timing_function(self, value: TimingFunctionCallback):
+ self._timing_function = value
+ return
+
+ @Property(float, "read-write")
+ def duration(self):
+ return self._duration
+
+ @duration.setter
+ def duration(self, value: float):
+ if value <= 0.0:
+ raise ValueError("duration can't be smaller than or equal to 0.0")
+
+ self._duration = value
+ return
+
+ @Property(float, "read-write")
+ def value(self):
+ return self._value
+
+ @value.setter
+ def value(self, value: float):
+ self._value = value
+ return
+
+ @Property(float, "read-write")
+ def max_value(self):
+ return self._max_value
+
+ @max_value.setter
+ def max_value(self, value: float):
+ self._max_value = value
+ return
+
+ @Property(float, "read-write")
+ def min_value(self):
+ return self._min_value
+
+ @min_value.setter
+ def min_value(self, value: float):
+ self._min_value = value
+ return
+
+ @Property(bool, "read-write", default_value=False)
+ def playing(self):
+ return self._playing
+
+ @playing.setter
+ def playing(self, value: bool): # this setter is intended for internal usage only
+ self._playing = value
+ return
+
+ @Property(bool, "read-write", default_value=False)
+ def repeat(self):
+ return self._repeat
+
+ @repeat.setter
+ def repeat(self, value: bool):
+ self._repeat = value
+ return
+
+ def __init__(
+ self,
+ duration: float = 0.8,
+ timing_function: TimingFunctionCallback = ease_linear,
+ value: float = 0.0,
+ min_value: float = 0.0,
+ max_value: float = 1.0,
+ repeat: bool = False,
+ tick_widget: Gtk.Widget | None = None,
+ tick_interval: int = 16,
+ **kwargs,
+ ):
+ super().__init__(**kwargs)
+ self._playing = False
+ self._value = value
+ self._min_value = 0.0
+ self._max_value = 1.0
+ self._repeat = False
+ self._duration = 0.8
+ self._timing_function = timing_function
+ self._tick_widget = tick_widget
+ self._tick_interval = tick_interval
+
+ self.timing_function = timing_function
+ self.repeat = repeat
+ self.duration = duration
+ self.min_value = min_value
+ self.max_value = max_value
+ self.value = value
+ self.playing = False
+
+ self._start_time = None
+ self._tick_handler = None
+ self._timeline_pos = 0.0
+
+ def do_get_time_now(self):
+ return GLib.get_monotonic_time() / 1_000_000
+
+ def do_update_value(self, delta_time: float):
+ if not self._playing:
+ return
+
+ elapsed_time = delta_time - cast(float, self._start_time)
+
+ self._timeline_pos = min(1.0, elapsed_time / self._duration)
+
+ self.value = lerp(
+ self._min_value,
+ self._max_value,
+ self._timing_function(progress=self._timeline_pos),
+ )
+
+ if not self._timeline_pos >= 1.0:
+ return
+
+ if not self._repeat:
+ # all done..
+ self.value = self._max_value
+ self.finished()
+ self.pause()
+ return
+
+ self._start_time = delta_time
+ self._timeline_pos = 0.0
+ return
+
+ def do_handle_tick(self, *_):
+ current_time = self.do_get_time_now()
+ self.do_update_value(current_time)
+ return True
+
+ def do_remove_tick_handlers(self):
+ if not self._tick_handler:
+ return
+
+ if self._tick_widget:
+ self._tick_widget.remove_tick_callback(self._tick_handler)
+ else:
+ GLib.source_remove(self._tick_handler)
+ self._tick_handler = None
+ return
+
+ def play(self):
+ if self._playing:
+ return
+
+ self.playing = True
+ self._start_time = self.do_get_time_now()
+
+ if self._tick_handler:
+ return
+
+ if self._tick_widget:
+ self._tick_handler = self._tick_widget.add_tick_callback(
+ self.do_handle_tick
+ )
+ return
+
+ self._tick_handler = GLib.timeout_add(self._tick_interval, self.do_handle_tick)
+ return
+
+ def pause(self):
+ self.playing = False
+ return self.do_remove_tick_handlers()
+
+ def stop(self):
+ if not self._tick_handler:
+ self._timeline_pos = 0
+ self.playing = False
+ return
+ return self.do_remove_tick_handlers()
diff --git a/widgets/circle_image.py b/src/shared/widgets/circle_image.py
similarity index 96%
rename from widgets/circle_image.py
rename to src/shared/widgets/circle_image.py
index aefd7e08..8488219c 100644
--- a/widgets/circle_image.py
+++ b/src/shared/widgets/circle_image.py
@@ -1,14 +1,8 @@
-import math
from typing import Literal
-
-import cairo
-import gi
from fabric.core.service import Property
+from fabric.utils import Gdk, GdkPixbuf, Gtk, cairo, math # noqa: E402
from fabric.widgets.widget import Widget
-gi.require_version("Gtk", "3.0")
-from gi.repository import Gdk, GdkPixbuf, Gtk # noqa: E402
-
class CircleImage(Gtk.DrawingArea, Widget):
"""A widget that displays an image in a circular shape with a 1:1 aspect ratio."""
diff --git a/src/shared/widgets/clipping_box.py b/src/shared/widgets/clipping_box.py
new file mode 100644
index 00000000..dc143061
--- /dev/null
+++ b/src/shared/widgets/clipping_box.py
@@ -0,0 +1,46 @@
+import math
+from typing import cast
+
+import cairo
+from fabric.widgets.box import Box
+
+
+class ClippingBox(Box):
+ """A regular `Box` that replicates the CSS behaviour of `overflow: hidden` because GTK failed at it.
+
+ NOTE: use instead of the old `CustomImage` snippet.
+ """
+
+ @staticmethod
+ def render_shape(cr: cairo.Context, width: int, height: int, radius: int = 0):
+ cr.move_to(radius, 0)
+ cr.line_to(width - radius, 0)
+ cr.arc(width - radius, radius, radius, -(math.pi / 2), 0)
+ cr.line_to(width, height - radius)
+ cr.arc(width - radius, height - radius, radius, 0, (math.pi / 2))
+ cr.line_to(radius, height)
+ cr.arc(radius, height - radius, radius, (math.pi / 2), math.pi)
+ cr.line_to(0, radius)
+ cr.arc(radius, radius, radius, math.pi, (3 * (math.pi / 2)))
+
+ return cr.close_path()
+
+ def do_draw(self, cr: cairo.Context):
+ cr.save()
+ ClippingBox.render_shape(
+ cr,
+ self.get_allocated_width(),
+ self.get_allocated_height(),
+ cast(
+ int,
+ self.get_style_context().get_property(
+ "border-radius", self.get_state_flags()
+ ),
+ ),
+ )
+ cr.clip()
+
+ Box.do_draw(self, cr)
+
+ cr.restore()
+ return True
diff --git a/widgets/custom_image.py b/src/shared/widgets/custom_image.py
similarity index 97%
rename from widgets/custom_image.py
rename to src/shared/widgets/custom_image.py
index 1aae7f6a..a62612e5 100644
--- a/widgets/custom_image.py
+++ b/src/shared/widgets/custom_image.py
@@ -2,7 +2,7 @@
from typing import cast
import cairo
-from gi.repository import Gtk
+from fabric.utils import Gtk
from fabric.widgets.image import Image
diff --git a/src/shared/widgets/customrevealer.py b/src/shared/widgets/customrevealer.py
new file mode 100644
index 00000000..2ca10a39
--- /dev/null
+++ b/src/shared/widgets/customrevealer.py
@@ -0,0 +1,188 @@
+from fabric.utils import Gtk, math
+
+from shared.widgets.animator import Animator
+
+
+# macOS-style easing functions for natural motion
+def ease_out_quint(progress):
+ return 1 - math.pow(1 - progress, 5)
+
+
+def ease_in_cubic(progress):
+ return progress * progress * progress
+
+
+class SlideRevealer(Gtk.Overlay):
+ def __init__(self, child: Gtk.Widget, direction="right", duration=350, size=None):
+ super().__init__()
+
+ self.child = child
+ self.direction = direction
+ self.duration = duration
+ self.fixed_size = size
+ self._revealed = False
+ self._cached_dimensions = None
+
+ self.animator = Animator(
+ duration=duration / 1000.0,
+ timing_function=ease_out_quint,
+ tick_interval=8, # 120 FPS for smoothness
+ tick_widget=self,
+ )
+ self.animator.connect("notify::value", self._on_animator_value_changed)
+ self.animator.connect("finished", self._on_animator_finished)
+
+ self._fixed = Gtk.Fixed()
+ self._fixed.set_has_window(False)
+ self._fixed.add(child)
+ self.add_overlay(self._fixed)
+
+ if self.fixed_size:
+ self.set_size_request(self.fixed_size[0], self.fixed_size[1])
+ child.hide()
+ self.show_all()
+ else:
+ child.connect("size-allocate", self._on_size_allocate)
+ child.hide()
+ self.show_all()
+
+ def _on_size_allocate(self, _widget, allocation):
+ if not self.fixed_size:
+ current_req = self.get_size_request()
+ if (
+ current_req[0] != allocation.width
+ or current_req[1] != allocation.height
+ ):
+ self.set_size_request(allocation.width, allocation.height)
+
+ def set_reveal_child(self, reveal: bool):
+ if reveal:
+ self.reveal()
+ else:
+ self.hide()
+
+ def reveal(self):
+ if self._revealed and not self.animator.playing:
+ return
+ self._revealed = True
+
+ if self.get_realized():
+ self._start_animation(show=True)
+ else:
+
+ def on_realize(*_):
+ self._start_animation(show=True)
+ self.disconnect_by_func(on_realize)
+
+ self.connect("realize", on_realize)
+
+ def hide(self):
+ if not self._revealed and not self.animator.playing:
+ return
+ self._revealed = False
+ self._start_animation(show=False)
+
+ def _start_animation(self, show: bool):
+ self.animator.stop()
+
+ self._cached_dimensions = self._get_dimensions()
+ if self._cached_dimensions[0] == 0 or self._cached_dimensions[1] == 0:
+ return
+
+ self._show_animation = show
+
+ # Configure animator based on direction
+ if show:
+ self.child.show()
+ self.animator.timing_function = ease_out_quint
+ # We animate the 'progress' from 0 to 1
+ self.animator.min_value = 0.0
+ self.animator.max_value = 1.0
+ else:
+ self.animator.timing_function = ease_in_cubic
+ self.animator.min_value = 0.0
+ self.animator.max_value = 1.0
+
+ self.animator.play()
+
+ def _on_animator_value_changed(self, animator, _pspec):
+ if not self._cached_dimensions:
+ return
+
+ progress = animator.value
+ x, y = self._get_position_at_progress_cached(progress)
+
+ # Round to nearest pixel for actual positioning
+ pixel_x, pixel_y = int(round(x)), int(round(y))
+ self._fixed.move(self.child, pixel_x, pixel_y)
+ self.queue_draw()
+
+ def _on_animator_finished(self, _animator):
+ self._cached_dimensions = None
+ if not self._revealed:
+ self.child.hide()
+
+ def _get_container_for_redraw(self):
+ return self
+
+ def _get_dimensions(self):
+ if self.fixed_size:
+ return self.fixed_size
+ else:
+ alloc = self.child.get_allocation()
+ return alloc.width, alloc.height
+
+ def _get_offscreen_pos_cached(self):
+ w, h = self._cached_dimensions
+ if self.direction == "left":
+ return -w, 0
+ elif self.direction == "right":
+ return w, 0
+ elif self.direction == "top":
+ return 0, -h
+ elif self.direction == "bottom":
+ return 0, h
+ return 0, 0
+
+ def _get_position_at_progress_cached(self, progress):
+ w, h = self._cached_dimensions
+ if self._show_animation:
+ # Showing animation: slide from offscreen to onscreen (0,0)
+ if self.direction == "left":
+ return -w + w * progress, 0.0
+ elif self.direction == "right":
+ return w - w * progress, 0.0
+ elif self.direction == "top":
+ return 0.0, -h + h * progress
+ elif self.direction == "bottom":
+ return 0.0, h - h * progress
+ else:
+ # Hiding animation: slide from onscreen (0,0) to offscreen
+ if self.direction == "left":
+ return -w * progress, 0.0 # Slide left (negative x)
+ elif self.direction == "right":
+ return w * progress, 0.0 # Slide right (positive x)
+ elif self.direction == "top":
+ return 0.0, -h * progress # Slide up (negative y)
+ elif self.direction == "bottom":
+ return 0.0, h * progress # Slide down (positive y)
+ return 0.0, 0.0
+
+ def set_slide_direction(self, direction):
+ self.direction = direction
+
+ def is_revealed(self):
+ return self._revealed
+
+ def is_animating(self):
+ return self.animator.playing
+
+ def get_child_revealed(self):
+ return self._revealed
+
+ def stop_animation(self):
+ self.animator.stop()
+
+ def destroy(self):
+ self.stop_animation()
+ super().destroy()
diff --git a/modules/controlcenter/battery.py b/src/shared/window/battery_widget.py
similarity index 87%
rename from modules/controlcenter/battery.py
rename to src/shared/window/battery_widget.py
index 068de6e3..beba25a3 100644
--- a/modules/controlcenter/battery.py
+++ b/src/shared/window/battery_widget.py
@@ -1,17 +1,15 @@
-import subprocess
-
-from fabric.utils import get_relative_path
+from fabric.utils import GLib
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.centerbox import CenterBox
from fabric.widgets.image import Image
-from fabric.widgets.box import Box
from fabric.widgets.label import Label
from fabric.widgets.separator import Separator
-from fabric.widgets.svg import Svg
-from gi.repository import GLib
+from services.gamemode import check_gamemode, toggle_gamemode
-from services.battery import Battery
+from services.battery import BatteryService, DeviceState
+from utils.functions import clear_children, format_duration
+from utils.utils import svg_file
class EnergyModeButton(Box):
@@ -20,7 +18,7 @@ def __init__(
profile_name: str,
display_name: str,
icon_name: str,
- battery_service: Battery,
+ battery_service: BatteryService,
parent,
**kwargs,
):
@@ -29,13 +27,8 @@ def __init__(
self.battery_service = battery_service
self.parent = parent
- self.mode_icon_svg = Svg(
- # icon_name=f"battery-{icon_name}-symbolic",
- svg_file=get_relative_path(
- f"../../config/assets/icons/power_modes/battery-{icon_name}.svg"
- ),
- size=24,
- )
+ self.mode_icon_svg = svg_file(f"power_modes/battery-{icon_name}.svg", size=24)
+
self.mode_icon = Box(
children=[self.mode_icon_svg],
name="energy-mode-icon",
@@ -67,7 +60,7 @@ def __init__(
self.update_state()
def on_clicked(self, *args):
- success = self.battery_service.change_power_profile(self.profile_name)
+ success = self.battery_service.set_power_profile(self.profile_name)
if success:
# Update all profile buttons in parent
self.parent.update_energy_mode_buttons()
@@ -79,7 +72,8 @@ def _reset_icon_state(self):
return False # Remove timeout
def update_state(self):
- is_active = self.battery_service.power_profile == self.profile_name
+ current_profile = self.battery_service.get_power_profile()
+ is_active = current_profile == self.profile_name
if is_active:
self.mode_icon.add_style_class("connected")
else:
@@ -124,9 +118,7 @@ def __init__(self, parent, **kwargs):
def on_clicked(self, *args):
try:
- script_path = get_relative_path("../../scripts/gamemode.sh")
- subprocess.run([script_path], check=False)
-
+ toggle_gamemode()
GLib.timeout_add(500, lambda: self.update_state())
except Exception as e:
print(f"Failed to toggle game mode: {e}")
@@ -138,19 +130,13 @@ def _reset_icon_state(self):
def update_state(self):
try:
- script_path = get_relative_path("../../scripts/gamemode.sh")
- result = subprocess.run(
- [script_path, "check"], capture_output=True, text=True, check=False
- )
- is_active = result.stdout.strip() == "t"
-
+ is_active = check_gamemode() == "t"
if is_active:
self.game_icon.add_style_class("connected")
else:
self.game_icon.remove_style_class("connected")
except Exception as e:
print(f"Failed to check game mode status: {e}")
- # Default to inactive state on error
self.game_icon.remove_style_class("connected")
return False # Remove timeout if called from GLib.timeout_add
@@ -167,7 +153,7 @@ def __init__(self, parent, **kwargs):
self.set_size_request(354, -1)
self.parent = parent
- self.battery_service = Battery()
+ self.battery_service = BatteryService()
self.energy_mode_buttons = []
self.battery_widget = Box(
@@ -264,7 +250,7 @@ def __init__(self, parent, **kwargs):
self.add(self.battery_widget)
self.battery_service.connect("changed", self.on_battery_changed)
- self.battery_service.connect("profile_changed", self.on_profile_changed)
+ self.battery_service.connect("power_profile_changed", self.on_profile_changed)
# Initialize display
self.update_battery_info()
@@ -282,7 +268,7 @@ def create_energy_mode_buttons(self):
self.energy_mode_buttons.clear()
# Get available profiles
- available_profiles = self.battery_service.available_profiles
+ available_profiles = self.battery_service.get_available_power_profiles() or []
if not available_profiles:
no_profiles_label = Label(
@@ -337,30 +323,35 @@ def update_energy_mode_buttons(self):
def create_game_mode_button(self):
# Clear existing game mode button if any
- for child in list(self.game_mode_container.get_children()):
- child.destroy()
+ clear_children(self.game_mode_container)
# Create game mode button
self.game_mode_button = GameModeButton(parent=self)
self.game_mode_container.add(self.game_mode_button)
+ def _format_time(self, seconds: int) -> str:
+ return format_duration(seconds)
+
def update_battery_info(self):
- if not self.battery_service.is_present:
+ is_present = bool(self.battery_service.get_property("IsPresent"))
+ if not is_present:
self.battery_percentage_label.set_label("No Battery")
self.power_source_label.set_label("Power Source: Not Present")
self.charging_time_label.set_label("")
return
# Update percentage in header
- percentage = self.battery_service.percentage
+ percentage = int(self.battery_service.get_property("Percentage") or 0)
self.battery_percentage_label.set_label(f"{percentage}%")
# Update power source and charging info
- state = self.battery_service.state
+ state_code = self.battery_service.get_property("State")
+ state = DeviceState.get(state_code, "UNKNOWN")
if state in ["CHARGING", "PENDING_CHARGE"]:
self.power_source_label.set_label("Power Source: Power Adapter")
- time_to_full = self.battery_service.time_to_full
+ seconds_to_full = int(self.battery_service.get_property("TimeToFull") or 0)
+ time_to_full = self._format_time(seconds_to_full)
if time_to_full != "N/A" and time_to_full != "0m":
self.charging_time_label.set_label(
f"{time_to_full} until fully charged"
@@ -372,7 +363,10 @@ def update_battery_info(self):
self.charging_time_label.set_label("Fully Charged")
elif state in ["DISCHARGING", "PENDING_DISCHARGE"]:
self.power_source_label.set_label("Power Source: Battery")
- time_to_empty = self.battery_service.time_to_empty
+ seconds_to_empty = int(
+ self.battery_service.get_property("TimeToEmpty") or 0
+ )
+ time_to_empty = self._format_time(seconds_to_empty)
if time_to_empty != "N/A" and not time_to_empty.startswith(
"4553h"
): # Filter out unrealistic times
@@ -389,5 +383,5 @@ def update_battery_info(self):
def on_battery_changed(self, *args):
self.update_battery_info()
- def on_profile_changed(self, service, new_profile):
+ def on_profile_changed(self, service, *args):
self.update_energy_mode_buttons()
diff --git a/widgets/dropdown.py b/src/shared/window/dropdown.py
similarity index 88%
rename from widgets/dropdown.py
rename to src/shared/window/dropdown.py
index cb159c9e..2b838873 100644
--- a/widgets/dropdown.py
+++ b/src/shared/window/dropdown.py
@@ -2,7 +2,7 @@
from fabric.widgets.centerbox import CenterBox
from fabric.widgets.eventbox import EventBox
from utils.roam import modus_service
-from widgets.popup_window import PopupWindow
+from shared.window.popup_window import PopupWindow
dropdowns = []
@@ -51,7 +51,6 @@ def __init__(self, dropdown_children=None, dropdown_id=None, **kwargs):
)
self.children = [self.event_box]
- self.connect("button-press-event", self.hide_dropdown)
self.add_keybinding("Escape", self.hide_dropdown)
def toggle_dropdown(self, button, parent=None):
@@ -89,3 +88,16 @@ def on_cursor_leave(self, *_):
return
self.set_visible(False)
modus_service.dropdowns_hide = not modus_service.dropdowns_hide
+
+ def destroy(self):
+ """Clean up resources and global references"""
+ global dropdowns
+ if self in dropdowns:
+ dropdowns.remove(self)
+
+ try:
+ modus_service.disconnect_by_func(self.hide_dropdown)
+ except Exception:
+ pass
+
+ super().destroy()
diff --git a/src/shared/window/mousecapture.py b/src/shared/window/mousecapture.py
new file mode 100644
index 00000000..5ef51816
--- /dev/null
+++ b/src/shared/window/mousecapture.py
@@ -0,0 +1,298 @@
+from fabric.utils import Any, Gdk, cairo, GLib
+from fabric.widgets.eventbox import EventBox
+from fabric.widgets.wayland import WaylandWindow as Window
+from fabric.widgets.widget import Widget
+from gi.repository import GtkLayerShell # type: ignore
+
+from utils.monitors import HyprlandWithMonitors
+from utils.roam import modus_service
+
+
+class MouseCapture(Window):
+ """A background overlay that captures outside clicks without blocking child window interactions"""
+
+ def __init__(self, layer: str, child_window: Window, **kwargs):
+ super().__init__(
+ layer=layer, # Use the passed layer
+ anchor="top bottom left right",
+ exclusivity="auto",
+ title="modus",
+ name="MouseCapture",
+ keyboard_mode="none", # Don't steal keyboard
+ all_visible=False,
+ visible=False,
+ **kwargs,
+ )
+
+ GtkLayerShell.set_exclusive_zone(self, -1)
+
+ self.child_window = child_window
+
+ # Ensure child window is on overlay layer to be above this capture
+ if hasattr(self.child_window, "layer"):
+ self.child_window.layer = "overlay"
+
+ if hasattr(self.child_window, "_init_mousecapture"):
+ self.child_window._init_mousecapture(self)
+
+ # Create transparent event box that captures clicks
+ self.event_box = EventBox(
+ events=[
+ "button-press-event",
+ "button-release-event",
+ "pointer-motion-mask", # Listen for mouse movement
+ "enter-notify-mask",
+ ],
+ all_visible=True,
+ )
+ self.event_box.connect("button-press-event", self.on_overlay_click)
+ self.event_box.connect("motion-notify-event", self.on_mouse_motion)
+ self.event_box.connect("enter-notify-event", self.on_mouse_motion)
+ self.children = [self.event_box]
+
+ self._hyprland = HyprlandWithMonitors()
+ self._cursor_pointer = Gdk.Cursor.new_from_name(
+ Gdk.Display.get_default(), "pointer"
+ )
+ self._cursor_default = None # Resets to default arrow
+
+ # Make the overlay transparent
+ self.set_app_paintable(True)
+ self.connect("draw", self.on_draw)
+ self.connect("size-allocate", lambda *_: self.update_input_region())
+ self.connect("map", lambda *_: self.update_input_region())
+ self.connect("notify::visible", lambda *_: self.update_input_region())
+
+ # Add escape key binding to child window
+ if hasattr(self.child_window, "add_keybinding"):
+ self.child_window.add_keybinding("Escape", self.hide_child_window)
+
+ def on_draw(self, _widget, cr):
+ """Make overlay transparent"""
+ cr.set_source_rgba(0, 0, 0, 0) # Fully transparent
+ cr.set_operator(cairo.OPERATOR_SOURCE)
+ cr.paint()
+ return False
+
+ def _get_child_window_bounds(self) -> tuple[int, int, int, int]:
+ """Calculates absolute screen bounds for the child window using Gdk origin"""
+ try:
+ window = self.child_window.get_window()
+ if not window:
+ # Fallback to allocation if window isn't mapped yet
+ alloc = self.child_window.get_allocation()
+ return 0, 0, alloc.width, alloc.height
+
+ # get_origin provides absolute screen coordinates
+ success, x, y = window.get_origin()
+ if not success:
+ alloc = self.child_window.get_allocation()
+ return 0, 0, alloc.width, alloc.height
+
+ alloc = self.child_window.get_allocation()
+ return x, y, alloc.width, alloc.height
+
+ except Exception as e:
+ print(f"Error calculating child window bounds: {e}")
+ alloc = self.child_window.get_allocation()
+ return 0, 0, alloc.width, alloc.height
+
+ def _get_widget_absolute_bounds(self, widget: Widget) -> tuple[int, int, int, int]:
+ """Calculates absolute screen bounds for a widget in another window"""
+ try:
+ toplevel = widget.get_toplevel()
+ if not toplevel or not toplevel.get_window():
+ return 0, 0, 0, 0
+
+ # Get the origin of the window containing the widget
+ success, wx, wy = toplevel.get_window().get_origin()
+ if not success:
+ return 0, 0, 0, 0
+
+ # Get relative position within toplevel
+ alloc = widget.get_allocation()
+ rx, ry = widget.translate_coordinates(toplevel, 0, 0) or (0, 0)
+
+ return wx + rx, wy + ry, alloc.width, alloc.height
+ except Exception as e:
+ print(f"Error calculating widget absolute bounds: {e}")
+ return 0, 0, 0, 0
+
+ def update_input_region(self):
+ """Punches holes in the input region for the trigger button and child window"""
+ window = self.get_window()
+ if not window:
+ return
+
+ try:
+ # Full screen region
+ alloc = self.get_allocation()
+ region = cairo.Region(cairo.RectangleInt(0, 0, alloc.width, alloc.height))
+
+ # Punch hole for trigger button (relative to this overlay window)
+ pointing_widget = getattr(self.child_window, "_pointing_widget", None)
+ if pointing_widget:
+ # get_widget_absolute_bounds returns screen-absolute coords.
+ # Since overlay is full screen on its monitor, we just need to subtract its monitor's X/Y
+ px, py, pw, ph = self._get_widget_absolute_bounds(pointing_widget)
+ monitor = self._hyprland.display.get_monitor_at_window(window)
+ if monitor:
+ mx, my = monitor.get_geometry().x, monitor.get_geometry().y
+ region.subtract(cairo.RectangleInt(px - mx, py - my, pw, ph))
+
+ # Punch hole for child window
+ cx, cy, cw, ch = self._get_child_window_bounds()
+ monitor = self._hyprland.display.get_monitor_at_window(window)
+ if monitor:
+ mx, my = monitor.get_geometry().x, monitor.get_geometry().y
+ region.subtract(cairo.RectangleInt(cx - mx, cy - my, cw, ch))
+
+ window.input_shape_combine_region(region, 0, 0)
+ except Exception as e:
+ print(f"Error updating input region: {e}")
+
+ def on_mouse_motion(self, _widget, event):
+ """Update cursor feedback based on position"""
+ # (This is mostly redundant now with input regions but kept for extra safety)
+ if not self.child_window.is_visible():
+ return False
+
+ return False
+
+ def on_overlay_click(self, _widget, event):
+ """Handle overlay clicks - check if click is outside child window"""
+ if not self.child_window.is_visible():
+ return False
+
+ # Support double and triple clicks too
+ if event.type not in [
+ Gdk.EventType.BUTTON_PRESS,
+ Gdk.EventType._2BUTTON_PRESS,
+ Gdk.EventType._3BUTTON_PRESS,
+ ]:
+ return False
+
+ # Use window-local coordinates for robust detection
+ click_x = event.x
+ click_y = event.y
+
+ # Get window position relative to monitor for absolute -> local conversion
+ window = self.get_window()
+ mx, my = 0, 0
+ if window:
+ monitor = self._hyprland.display.get_monitor_at_window(window)
+ if monitor:
+ geom = monitor.get_geometry()
+ mx, my = geom.x, geom.y
+
+ # Check if click is on the "trigger" (pointing) widget
+ pointing_widget = getattr(self.child_window, "_pointing_widget", None)
+ if pointing_widget:
+ px_abs, py_abs, pw, ph = self._get_widget_absolute_bounds(pointing_widget)
+ px, py = px_abs - mx, py_abs - my
+ if px <= click_x <= px + pw and py <= click_y <= py + ph:
+ # Clicked the button that opened us - hide and consume
+ self.hide_child_window()
+ return True
+
+ # Get child window bounds (monitor-relative)
+ cx_abs, cy_abs, cw, ch = self._get_child_window_bounds()
+ cx, cy = cx_abs - mx, cy_abs - my
+
+ # Check if click is inside child window bounds
+ inside_child = cx <= click_x <= cx + cw and cy <= click_y <= cy + ch
+
+ if not inside_child:
+ # Click is outside child window - hide it immediately
+ # No delay to avoid race conditions with toggle buttons
+ self.hide_child_window()
+ return True # Consume the event
+
+ # Click is inside child window - don't consume event
+ return False
+
+ def show_child_window(self, widget: Widget = None, event: Any = None) -> None:
+ self.set_child_window_visible(True)
+
+ def hide_child_window(self, widget: Widget = None, event: Any = None) -> None:
+
+ self.set_child_window_visible(False)
+
+ def set_child_window_visible(self, visible: bool) -> None:
+ if visible:
+ # Connect to child window size changes to keep input region in sync
+ self._size_handler = self.child_window.connect(
+ "size-allocate", lambda *_: self.update_input_region()
+ )
+ self.child_window.show()
+ self.show()
+ # Force update with a small delay to ensure window is mapped
+ GLib.timeout_add(50, self.update_input_region)
+ else:
+ if hasattr(self, "_size_handler") and self._size_handler:
+ self.child_window.disconnect(self._size_handler)
+ self._size_handler = None
+ self.child_window.hide()
+ self.hide()
+
+ # Update styling on the trigger button
+ pointing_widget = getattr(self.child_window, "_pointing_widget", None)
+ if pointing_widget:
+ if visible:
+ pointing_widget.add_style_class("active")
+ else:
+ pointing_widget.remove_style_class("active")
+
+ if hasattr(self.child_window, "_set_mousecapture"):
+ self.child_window._set_mousecapture(visible)
+
+ def toggle_mousecapture(self, *_) -> None:
+ if self.is_visible():
+ self.set_child_window_visible(False)
+ else:
+ self.set_child_window_visible(True)
+
+
+class DropDownMouseCapture(MouseCapture):
+ """A specialized MouseCapture for dropdown menus with service integration"""
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ modus_service.connect("dropdowns-hide-changed", self.dropdowns_hide_changed)
+
+ def hide_child_window(self, widget: Widget = None, event: Any = None) -> None:
+ """Hide child window and update dropdown service state"""
+ # Update service state before hiding to prevent conflicts
+ if hasattr(self.child_window, "id"):
+ if str(modus_service.current_dropdown) == str(self.child_window.id):
+ modus_service.current_dropdown = None
+ super().hide_child_window(widget, event)
+
+ def dropdowns_hide_changed(self, widget: Widget = None, event: Any = None) -> None:
+ """Handle dropdown service hide changes"""
+ if hasattr(self.child_window, "id"):
+ if modus_service.current_dropdown == self.child_window.id:
+ return
+ return self.hide_child_window(widget, event)
+
+ def destroy(self):
+ """Clean up signal connections"""
+ try:
+ modus_service.disconnect_by_func(self.dropdowns_hide_changed)
+ except Exception:
+ pass
+ super().destroy()
+
+
+def add_destroy_to_mousecapture():
+ # Base MouseCapture
+ def mc_destroy(self):
+ # Child window should be destroyed by its owner, but we should null references
+ self.child_window = None
+ Window.destroy(self)
+
+ MouseCapture.destroy = mc_destroy
+
+
+# Apply destroy to base class
+add_destroy_to_mousecapture()
diff --git a/widgets/popup_window.py b/src/shared/window/popup_window.py
similarity index 95%
rename from widgets/popup_window.py
rename to src/shared/window/popup_window.py
index d6d600ce..be1df90f 100644
--- a/widgets/popup_window.py
+++ b/src/shared/window/popup_window.py
@@ -1,11 +1,10 @@
import contextlib
-import gi # type: ignore
-from gi.repository import Gdk, Gtk, GtkLayerShell # type: ignore
-from widgets.wayland import WaylandWindow
-from utils.monitors import HyprlandWithMonitors
+from fabric.utils import Gdk, Gtk
+from fabric.widgets.wayland import WaylandWindow
+from gi.repository import GtkLayerShell
-gi.require_version("GtkLayerShell", "0.1")
+from utils.monitors import HyprlandWithMonitors
class PopupWindow(WaylandWindow):
@@ -114,7 +113,10 @@ def do_reposition(self, move_axe: str):
monitor = self._hyprland.display.get_monitor(current_monitor_id)
monitor_geometry = monitor.get_geometry()
monitor_x, monitor_y = monitor_geometry.x, monitor_geometry.y
- monitor_width, monitor_height = monitor_geometry.width, monitor_geometry.height
+ monitor_width, monitor_height = (
+ monitor_geometry.width,
+ monitor_geometry.height,
+ )
else:
# Fallback to default screen
screen = Gdk.Screen.get_default()
@@ -136,8 +138,7 @@ def do_reposition(self, move_axe: str):
if self._is_centered:
# Calculate centered position with boundary checking
centered_x = (
- (monitor_width / 2 - self._parent.get_allocated_width() / 2)
- - width / 2
+ (monitor_width / 2 - self._parent.get_allocated_width() / 2) - width / 2
) + coords_centered[0]
# Apply boundary checking only if enabled
diff --git a/services/__init__.py b/src/utils/__init__.py
similarity index 100%
rename from services/__init__.py
rename to src/utils/__init__.py
diff --git a/utils/app_name_resolver.py b/src/utils/app_name_resolver.py
similarity index 88%
rename from utils/app_name_resolver.py
rename to src/utils/app_name_resolver.py
index 13b76dff..6757cc32 100644
--- a/utils/app_name_resolver.py
+++ b/src/utils/app_name_resolver.py
@@ -70,6 +70,7 @@ def format_app_name(self, title, wmclass, update=False):
if update:
modus_service.current_active_app_name = name
+ modus_service.current_active_wm_class = wmclass
return name
@@ -78,16 +79,11 @@ def format_app_name(self, title, wmclass, update=False):
def format_window(title, wmclass):
- # Handle the case when HyprlandActiveWindow passes "unknown" instead of empty strings
- if (not title or title == "unknown") and (not wmclass or wmclass == "unknown"):
- return "Finder"
-
- # Clean up "unknown" values
+ # Clean up "unknown" values to ensure they are treated as empty
if title == "unknown":
title = ""
if wmclass == "unknown":
wmclass = ""
-
- name = app_name_resolver.format_app_name(title, wmclass, True)
- return name
+ # Always call format_app_name with update=True to keep service state in sync
+ return app_name_resolver.format_app_name(title, wmclass, True)
diff --git a/src/utils/constants.py b/src/utils/constants.py
new file mode 100644
index 00000000..db8a21a4
--- /dev/null
+++ b/src/utils/constants.py
@@ -0,0 +1,28 @@
+DEFAULT = {
+ "wallpapers_dir": "~/Pictures/Wallpapers/",
+ "dock_enabled": True,
+ "dock_auto_hide": True,
+ "dock_always_occluded": False,
+ "dock_icon_size": 52,
+ "window_switcher_items_per_row": 10,
+ "hide_special_workspace": True,
+ "dock_hide_special_workspace_apps": True,
+ "notification_timeout": "5s",
+ "notification_ignored_apps": ["Hyprshot"],
+ "notification_limited_apps_history": ["Spotify"],
+ "imac_button": True,
+ "systray": True,
+ "control_center": True,
+ "search": True,
+ "global_menu": True,
+ "network": True,
+ "battery": True,
+ "notification_center": True,
+ "workspace_indicator": True,
+ "bluetooth": True,
+ "date_time": True,
+ "keyboard_layouts": ["us", "np"],
+ "window_switcher": True,
+ "osd": True,
+ "systray_ignore": ["blueman", "network"],
+}
diff --git a/utils/conversion.py b/src/utils/conversion.py
similarity index 90%
rename from utils/conversion.py
rename to src/utils/conversion.py
index c39b4da9..650e6600 100644
--- a/utils/conversion.py
+++ b/src/utils/conversion.py
@@ -3,7 +3,7 @@
from concurrent.futures import ThreadPoolExecutor
from typing import Dict, Optional, Tuple
-import requests
+import httpx
class CurrencyCache:
@@ -79,17 +79,33 @@ def _fetch_rates_background(self, from_code: str):
"""Fetch exchange rates in background thread."""
try:
url = f"https://www.floatrates.com/daily/{from_code}.json"
- response = requests.get(url, timeout=self._request_timeout)
-
+ if self._request_timeout is None:
+ self._request_timeout = 5
+ response = httpx.get(
+ url,
+ timeout=10,
+ follow_redirects=True,
+ headers={
+ "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
+ },
+ )
if response.status_code == 200:
- rates_data = response.json()
- current_time = time.time()
-
- with self._cache_lock:
- self._cache[from_code] = {
- "rates": rates_data,
- "timestamp": current_time,
- }
+ try:
+ rates_data = response.json()
+ current_time = time.time()
+ with self._cache_lock:
+ self._cache[from_code] = {
+ "rates": rates_data,
+ "timestamp": current_time,
+ }
+ except Exception as je:
+ print(f"Failed to parse JSON for {from_code}: {je}")
+ else:
+ print(
+ f"Background fetch for {from_code} returned status {
+ response.status_code
+ }"
+ )
except Exception as e:
print(f"Background currency fetch failed for {from_code}: {e}")
@@ -417,6 +433,18 @@ def __init__(self):
"mm2": 1e-6,
}
+ self.CURRENCY_SYMBOLS: dict[str, str] = {
+ "$": "USD",
+ "โฌ": "EUR",
+ "ยฃ": "GBP",
+ "ยฅ": "JPY",
+ "โน": "INR",
+ "C$": "CAD",
+ "A$": "AUD",
+ "โฃ": "CHF",
+ "ๅ
": "CNY",
+ }
+
# We no longer use currency_converter here.
@@ -523,11 +551,24 @@ def _convert_currency_via_floatrates(
return value
url = f"https://www.floatrates.com/daily/{from_lower}.json"
- resp = requests.get(url, timeout=5)
- if resp.status_code != 200:
- raise ValueError(f"Error getting data from floatrates for {from_code}")
-
- data = resp.json()
+ try:
+ resp = httpx.get(
+ url,
+ timeout=10,
+ follow_redirects=True,
+ headers={
+ "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
+ },
+ )
+ if resp.status_code != 200:
+ raise ValueError(
+ f"Error getting data from floatrates for {from_code}: Status {
+ resp.status_code
+ }"
+ )
+ data = resp.json()
+ except Exception as e:
+ raise ValueError(f"Error getting data from floatrates for {from_code}: {e}")
if to_lower not in data:
raise ValueError(
f"Target currency '{to_code}' not found in floatrates response for '{
@@ -578,9 +619,12 @@ def parse_input_and_convert(self, input: str):
def clean_type(self, type: str) -> str:
"""
+ If it's currency symbol, convert to code.
If it's currency (3 letters), convert to uppercase.
If it ends in 's' (and is not 'celsius'), remove the 's' for
other units."""
+ if type in self.units.CURRENCY_SYMBOLS:
+ return self.units.CURRENCY_SYMBOLS[type]
if len(type) == 3 and type.isalpha():
return type.upper()
if type.endswith("s") and type.lower() != "celsius":
diff --git a/src/utils/dbus_helper.py b/src/utils/dbus_helper.py
new file mode 100644
index 00000000..60ca14ee
--- /dev/null
+++ b/src/utils/dbus_helper.py
@@ -0,0 +1,83 @@
+from fabric.utils import Gio, GLib
+
+
+class GioDBusHelper:
+ """A helper class for interacting with D-Bus using the Gio library."""
+
+ def __init__(
+ self,
+ bus_name,
+ object_path,
+ interface_name,
+ bus_type=Gio.BusType.SYSTEM,
+ ):
+ self.bus = Gio.bus_get_sync(bus_type, None)
+
+ self.bus_name = bus_name
+ self.object_path = object_path
+ self.proxy = Gio.DBusProxy.new_sync(
+ self.bus,
+ Gio.DBusProxyFlags.NONE,
+ None,
+ bus_name,
+ object_path,
+ interface_name,
+ None,
+ )
+
+ def call_method(
+ self,
+ bus_name,
+ object_path,
+ interface_name,
+ method_name,
+ parameters=None,
+ timeout=-1,
+ ):
+ if parameters is None:
+ parameters = GLib.Variant("()", ())
+ result = self.bus.call_sync(
+ bus_name,
+ object_path,
+ interface_name,
+ method_name,
+ parameters,
+ None,
+ Gio.DBusCallFlags.NONE,
+ timeout,
+ None,
+ )
+ return result.unpack()
+
+ def listen_signal(
+ self, member, callback, interface_name="org.freedesktop.DBus.Properties"
+ ):
+ """Register a signal listener (conn, sender, path, iface, signal, parameters). Returns subscription id."""
+ return self.bus.signal_subscribe(
+ self.bus_name,
+ interface_name,
+ member,
+ self.object_path,
+ arg0=None,
+ flags=Gio.DBusSignalFlags.NONE,
+ callback=callback,
+ )
+
+ def unsubscribe_signal(self, subscription_id: int):
+ """Unsubscribe a previously registered signal using its subscription id."""
+ try:
+ self.bus.signal_unsubscribe(subscription_id)
+ except Exception:
+ pass
+
+ def set_property(self, interface_name, property_name, value_variant):
+ """Sets a D-Bus property using the standard D-Bus Properties interface."""
+ return self.call_method(
+ bus_name=self.bus_name,
+ object_path=self.object_path,
+ interface_name="org.freedesktop.DBus.Properties",
+ method_name="Set",
+ parameters=GLib.Variant(
+ "(ssv)", (interface_name, property_name, value_variant)
+ ),
+ )
diff --git a/src/utils/debounce.py b/src/utils/debounce.py
new file mode 100644
index 00000000..058febc3
--- /dev/null
+++ b/src/utils/debounce.py
@@ -0,0 +1,70 @@
+import asyncio
+import functools
+import typing as t
+
+from fabric.utils import GLib
+
+
+def debounce(delay: float) -> t.Callable[..., t.Any]:
+ def decorator(func: t.Callable[..., t.Any]) -> t.Callable[..., t.Any]:
+ task: asyncio.Task[None] | None = None
+
+ @functools.wraps(func)
+ async def wrapper(*args: t.Any, **kwargs: t.Any) -> None:
+ nonlocal task
+
+ if task is not None and not task.done():
+ task.cancel()
+ try:
+ await task
+ except asyncio.CancelledError:
+ pass
+
+ async def delayed_call() -> None:
+ await asyncio.sleep(delay)
+ await func(*args, **kwargs)
+
+ task = asyncio.create_task(delayed_call())
+
+ return wrapper
+
+ return decorator
+
+
+def sync_debounce(
+ delay: int, min_n_times: int = 0, immediate: bool = False
+) -> t.Callable[..., t.Any]:
+ def decorator(func: t.Callable[..., t.Any]) -> t.Callable[..., t.Any]:
+ n_times = 0
+ timeout_id: int | None = None
+ last_args: tuple[t.Any, ...] = ()
+ last_kwargs: dict[str, t.Any] = {}
+
+ def call_func() -> bool:
+ nonlocal timeout_id, n_times
+ if n_times > min_n_times:
+ func(*last_args, **last_kwargs)
+ n_times = 0
+ timeout_id = None
+ return False
+
+ def wrapper(*args: t.Any, **kwargs: t.Any) -> None:
+ nonlocal timeout_id, last_args, last_kwargs, n_times
+
+ last_args = args
+ last_kwargs = kwargs
+
+ if timeout_id is not None:
+ GLib.source_remove(timeout_id)
+
+ if not immediate or n_times < min_n_times:
+ n_times += 1
+ timeout_id = GLib.timeout_add(delay, call_func)
+ else:
+ n_times = 0
+ timeout_id = None
+ func(*last_args, **last_kwargs)
+
+ return functools.wraps(func)(wrapper)
+
+ return decorator
diff --git a/src/utils/functions.py b/src/utils/functions.py
new file mode 100644
index 00000000..b90eb447
--- /dev/null
+++ b/src/utils/functions.py
@@ -0,0 +1,286 @@
+import ctypes
+import html
+import json
+import os
+import subprocess
+import threading
+from typing import Dict, NamedTuple, Optional, TypeVar
+
+from fabric.utils import (
+ exec_shell_command,
+ exec_shell_command_async,
+ logger,
+)
+
+T = TypeVar("T")
+
+
+def set_process_name(name: str):
+ libc = ctypes.CDLL("libc.so.6")
+ libc.prctl(15, name.encode("utf-8"), 0, 0, 0) # 15 = PR_SET_NAME
+
+
+def parse_timeout_string(timeout_str):
+ """
+ Parse timeout string in format like '5s', '10m', '30s' etc.
+ Returns timeout in milliseconds.
+ """
+ if not timeout_str or not isinstance(timeout_str, str):
+ return 5000
+
+ timeout_str = timeout_str.strip().lower()
+
+ if timeout_str.endswith("s"):
+ try:
+ seconds = int(timeout_str[:-1])
+ return seconds * 1000
+ except ValueError:
+ return 5000
+ elif timeout_str.endswith("m"):
+ try:
+ minutes = int(timeout_str[:-1])
+ return minutes * 60 * 1000
+ except ValueError:
+ return 5000
+ else:
+ try:
+ seconds = int(timeout_str)
+ return seconds * 1000
+ except ValueError:
+ return 5000
+
+
+# Threading helper functions
+def thread(target, *args, **kwargs) -> threading.Thread:
+ """
+ Simply run the given function in a thread.
+ The provided args and kwargs will be passed to the function.
+ """
+ th = threading.Thread(target=target, args=args, kwargs=kwargs, daemon=True)
+ th.start()
+ return th
+
+
+def run_in_thread(func):
+ """
+ Decorator to run the decorated function in a thread.
+ """
+
+ def wrapper(*args, **kwargs):
+ return thread(func, *args, **kwargs)
+
+ return wrapper
+
+
+def write_json_file(data: Dict, path: str):
+ try:
+ with open(path, "w") as f:
+ json.dump(data, f, indent=4, ensure_ascii=False)
+ except Exception as e:
+ logger.warning(f"Failed to write json: {e}")
+
+
+def read_json_file(file_path: str) -> Optional[Dict]:
+ if not os.path.exists(file_path):
+ logger.error(f"JSON file {file_path} does not exist.")
+ return None
+
+ with open(file_path, "r") as file:
+ try:
+ return json.load(file)
+ except json.JSONDecodeError as e:
+ logger.error(f"Failed to read JSON file {file_path}: {e}")
+ return None
+
+
+def get_wifi_icon_for_strength(strength: int) -> str:
+ """
+ Get the appropriate WiFi icon based on signal strength.
+
+ Args:
+ strength: Signal strength from 0-100
+
+ Returns:
+ Relative path to the appropriate WiFi icon
+ """
+ if strength >= 80:
+ icon_name = "network-wireless-100.svg"
+ elif strength >= 60:
+ icon_name = "network-wireless-80.svg"
+ elif strength >= 40:
+ icon_name = "network-wireless-60.svg"
+ elif strength >= 20:
+ icon_name = "network-wireless-40.svg"
+ elif strength > 0:
+ icon_name = "network-wireless-20.svg"
+ else:
+ icon_name = "network-wireless-0.svg"
+
+ return f"wifi/{icon_name}"
+
+
+def get_wifi_connecting_icon() -> str:
+ """
+ Get the WiFi connecting icon path.
+
+ Returns:
+ Relative path to the WiFi connecting icon
+ """
+ return "wifi/wifi-connecting.svg"
+
+
+def is_special_workspace_id(ws_id) -> bool:
+ """
+ Check if a workspace ID represents a special workspace.
+
+ Args:
+ ws_id: Workspace ID (can be int, string, or other types)
+
+ Returns:
+ True if the workspace is special, False otherwise
+ """
+ try:
+ # Convert to int if it's a string
+ workspace_id = int(ws_id)
+ # Special workspaces have negative IDs
+ return workspace_id < 0
+ except (ValueError, TypeError):
+ # If it's a string, check if it starts with "special:"
+ if isinstance(ws_id, str) and ws_id.startswith("special:"):
+ return True
+ return False
+
+
+def is_special_workspace(client: dict) -> bool:
+ """
+ Check if a client is in a special workspace.
+
+ Args:
+ client: Client data dictionary from Hyprland
+
+ Returns:
+ True if the client is in a special workspace, False otherwise
+ """
+ if "workspace" not in client:
+ return False
+
+ workspace = client["workspace"]
+
+ # Check workspace name first
+ if "name" in workspace:
+ workspace_name = str(workspace["name"])
+ # Special workspaces typically start with "special:" or have negative IDs
+ if workspace_name.startswith("special:"):
+ return True
+
+ # Check workspace ID
+ if "id" in workspace:
+ workspace_id = workspace["id"]
+ # Special workspaces have negative IDs
+ if workspace_id < 0:
+ return True
+
+ return False
+
+
+def escape_markup_text(text: str) -> str:
+ return html.escape(text.replace("\n", " "))
+
+
+# Function to toggle a shell command
+def toggle_command(command: str, full_command: str):
+ if is_app_running(command):
+ kill_process(command)
+ else:
+ subprocess.Popen(
+ full_command.split(" "),
+ stdin=subprocess.DEVNULL, # No input stream
+ stdout=subprocess.DEVNULL, # Optionally discard the output
+ stderr=subprocess.DEVNULL, # Optionally discard the error output
+ start_new_session=True, # This prevents the process from being killed
+ )
+
+
+# Function to execute a shell command asynchronously
+def kill_process(process_name: str):
+ exec_shell_command_async(f"pkill {process_name}", lambda *_: None)
+
+
+# Function to check if an app is running
+def is_app_running(app_name: str) -> bool:
+ return len(exec_shell_command(f"pidof {app_name}")) != 0
+
+
+# General utilities
+def format_duration(seconds: int) -> str:
+ """
+ Convert a duration in seconds to a compact human-friendly string.
+
+ Examples:
+ 0 -> "N/A"
+ 59 -> "0m"
+ 61 -> "1m"
+ 3600 -> "1h 0m"
+ """
+ try:
+ total = int(seconds)
+ except (TypeError, ValueError):
+ return "N/A"
+
+ if total <= 0:
+ return "N/A"
+
+ hours = total // 3600
+ minutes = (total % 3600) // 60
+ if hours > 0:
+ return f"{hours}h {minutes}m"
+ return f"{minutes}m"
+
+
+def clear_children(container) -> None:
+ """
+ Destroy all children of a GTK container-like widget which exposes get_children().
+ """
+ try:
+ for child in list(container.get_children()):
+ child.destroy()
+ except Exception as e:
+ logger.warning(f"clear_children failed: {e}")
+
+
+class CommandResult(NamedTuple):
+ returncode: int
+ stdout: bytes | str
+ stderr: bytes | str
+
+
+def run_command(
+ args: list[str],
+ timeout: float | None = None,
+ cwd: Optional[str] = None,
+ env: Optional[Dict[str, str]] = None,
+ *,
+ input: bytes | str | None = None,
+ text: bool = True,
+) -> CommandResult:
+ try:
+ result = subprocess.run(
+ args,
+ capture_output=True,
+ text=text,
+ timeout=timeout,
+ cwd=cwd,
+ env=env,
+ input=input,
+ )
+ return CommandResult(result.returncode, result.stdout, result.stderr)
+ except subprocess.TimeoutExpired as e:
+ return CommandResult(
+ -1,
+ (e.stdout or b"" if not text else e.stdout or ""),
+ (e.stderr or (b"timeout" if not text else "timeout")),
+ )
+ except FileNotFoundError as e:
+ return CommandResult(127, "", str(e))
+ except Exception as e:
+ return CommandResult(1, "", str(e))
diff --git a/src/utils/icon_resolver.py b/src/utils/icon_resolver.py
new file mode 100644
index 00000000..ae3a0c88
--- /dev/null
+++ b/src/utils/icon_resolver.py
@@ -0,0 +1,166 @@
+import json
+
+from fabric.utils import GLib, Gtk, logger, os
+
+import shared.data as data
+
+ICON_CACHE_FILE = data.CACHE_DIR + "/icons.json"
+if not os.path.exists(data.CACHE_DIR):
+ os.makedirs(data.CACHE_DIR)
+
+
+class IconResolver:
+ def __init__(
+ self, default_applicaiton_icon: str = "application-x-executable-symbolic"
+ ):
+ if os.path.exists(ICON_CACHE_FILE):
+ with open(ICON_CACHE_FILE) as f:
+ try:
+ self._icon_dict = json.load(f)
+ except json.JSONDecodeError:
+ logger.info("[ICONS] Cache file does not exist or is corrupted")
+ self._icon_dict = {}
+ else:
+ self._icon_dict = {}
+
+ self.default_applicaiton_icon = default_applicaiton_icon
+
+ def get_icon_name(self, app_id: str):
+ if app_id in self._icon_dict:
+ return self._icon_dict[app_id]
+ new_icon = self._compositor_find_icon(app_id)
+ logger.info(
+ f"[ICONS] found new icon: '{new_icon}' for app id: '{app_id}', storing..."
+ )
+ self._store_new_icon(app_id, new_icon)
+ return new_icon
+
+ def get_icon_pixbuf(self, app_id: str, size: int = 16):
+ icon_theme = Gtk.IconTheme.get_default()
+ icon_name = self.get_icon_name(app_id)
+ try:
+ # Try to load the resolved icon.
+ return icon_theme.load_icon(icon_name, size, Gtk.IconLookupFlags.FORCE_SIZE)
+ except GLib.Error as primary_error:
+ logger.warning(
+ f"Warning: Icon '{icon_name}' not found in theme. Error: {primary_error}"
+ )
+ try:
+ # Fallback to the default application icon.
+ return icon_theme.load_icon(
+ self.default_applicaiton_icon, size, Gtk.IconLookupFlags.FORCE_SIZE
+ )
+ except GLib.Error as fallback_error:
+ logger.error(
+ f"Error: Fallback icon '{self.default_applicaiton_icon}' also not found. Error: {fallback_error}"
+ )
+ return None
+
+ def _store_new_icon(self, app_id: str, icon: str):
+ self._icon_dict[app_id] = icon
+ with open(ICON_CACHE_FILE, "w") as f:
+ json.dump(self._icon_dict, f)
+
+ def _get_icon_from_desktop_file(self, desktop_file_path: str):
+ # Retrieve the icon specified in the [Desktop Entry] section.
+ try:
+ with open(desktop_file_path) as f:
+ in_desktop_entry = False
+ for line in f:
+ line = line.strip()
+ if line == "[Desktop Entry]":
+ in_desktop_entry = True
+ elif line.startswith("[") and line.endswith("]"):
+ in_desktop_entry = False
+
+ if in_desktop_entry and line.startswith("Icon="):
+ return line[5:].strip()
+ except Exception as e:
+ logger.error(f"[ICONS] Error reading desktop file {desktop_file_path}: {e}")
+
+ return self.default_applicaiton_icon
+
+ def _get_desktop_file(self, app_id: str) -> str | None:
+ if not app_id:
+ return None
+
+ search_dirs = [os.path.join(GLib.get_user_data_dir(), "applications")] + [
+ os.path.join(d, "applications") for d in GLib.get_system_data_dirs()
+ ]
+
+ # Normalize search IDs (full ID and parts for Reverse DNS or decorated names)
+ search_ids = [app_id.lower()]
+ for sep in [".", "_", "-"]:
+ if sep in app_id:
+ parts = app_id.split(sep)
+ for part in parts:
+ if part and len(part) > 2 and part.lower() not in search_ids:
+ search_ids.append(part.lower())
+
+ # 1. Try exact filename matches first (highest priority)
+ for data_dir in search_dirs:
+ if not os.path.exists(data_dir):
+ continue
+
+ files = os.listdir(data_dir)
+ for sid in search_ids:
+ target = f"{sid}.desktop"
+ for f in files:
+ if f.lower() == target:
+ return os.path.join(data_dir, f)
+
+ # Try basename match without .desktop
+ for f in files:
+ basename = f[:-8] if f.lower().endswith(".desktop") else f
+ if basename.lower() == sid:
+ return os.path.join(data_dir, f)
+
+ # 2. Try matching StartupWMClass or Name inside desktop files (more expensive)
+ for data_dir in search_dirs:
+ if not os.path.exists(data_dir):
+ continue
+
+ for f in os.listdir(data_dir):
+ if not f.endswith(".desktop"):
+ continue
+ path = os.path.join(data_dir, f)
+ try:
+ with open(path, "r", errors="ignore") as file:
+ for line in file:
+ line = line.strip()
+ if line.startswith("StartupWMClass="):
+ wm_class = line[15:].strip()
+ if wm_class.lower() == app_id.lower():
+ return path
+ elif line.startswith("Name="):
+ name = line[5:].strip().lower()
+ if any(sid == name for sid in search_ids):
+ return path
+ except Exception:
+ continue
+
+ return None
+
+ def _compositor_find_icon(self, app_id: str):
+ if not app_id:
+ return self.default_applicaiton_icon
+
+ icon_theme = Gtk.IconTheme.get_default()
+
+ # Try direct icon name match
+ if icon_theme.has_icon(app_id):
+ return app_id
+
+ # Try with common suffixes
+ for suffix in ["-desktop", "-symbolic"]:
+ if icon_theme.has_icon(app_id + suffix):
+ return app_id + suffix
+
+ # Try finding desktop file
+ desktop_file = self._get_desktop_file(app_id)
+ if desktop_file:
+ icon = self._get_icon_from_desktop_file(desktop_file)
+ if icon and icon_theme.has_icon(icon):
+ return icon
+
+ return self.default_applicaiton_icon
diff --git a/utils/inhibit.py b/src/utils/inhibit.py
similarity index 95%
rename from utils/inhibit.py
rename to src/utils/inhibit.py
index 6b5a1064..9899483c 100644
--- a/utils/inhibit.py
+++ b/src/utils/inhibit.py
@@ -6,10 +6,14 @@
import subprocess
import sys
from dataclasses import dataclass
+from pathlib import Path
+
+# Add the project root to sys.path to allow absolute imports from 'utils'
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+
from signal import SIGINT, SIGTERM, signal
from threading import Event, Timer
-import setproctitle
from pywayland.client.display import Display
from pywayland.protocol.idle_inhibit_unstable_v1.zwp_idle_inhibit_manager_v1 import (
ZwpIdleInhibitManagerV1,
@@ -18,6 +22,8 @@
from pywayland.protocol.wayland.wl_registry import WlRegistryProxy
from pywayland.protocol.wayland.wl_surface import WlSurface
+from utils.functions import set_process_name
+
@dataclass
class GlobalRegistry:
@@ -182,5 +188,5 @@ def shutdown() -> None:
if __name__ == "__main__":
- setproctitle.setproctitle("modus-inhibit")
+ set_process_name("modus-inhibit")
main()
diff --git a/utils/monitors.py b/src/utils/monitors.py
similarity index 98%
rename from utils/monitors.py
rename to src/utils/monitors.py
index 01b706e1..6a2eb6ed 100644
--- a/utils/monitors.py
+++ b/src/utils/monitors.py
@@ -1,10 +1,11 @@
import json
+import time
import warnings
+from functools import lru_cache
from typing import Dict
-import time
+
from fabric.hyprland import Hyprland
-from gi.repository import Gdk
-from functools import lru_cache
+from fabric.utils import Gdk
warnings.filterwarnings("ignore", category=DeprecationWarning)
diff --git a/utils/occlusion.py b/src/utils/occlusion.py
similarity index 99%
rename from utils/occlusion.py
rename to src/utils/occlusion.py
index b81ee751..a7b86020 100644
--- a/utils/occlusion.py
+++ b/src/utils/occlusion.py
@@ -1,7 +1,7 @@
import json
import subprocess
-import config.data as data
+import shared.data as data
def get_current_workspace():
diff --git a/utils/roam.py b/src/utils/roam.py
similarity index 86%
rename from utils/roam.py
rename to src/utils/roam.py
index 4127d987..20a791ed 100644
--- a/utils/roam.py
+++ b/src/utils/roam.py
@@ -1,8 +1,11 @@
-from loguru import logger
-
from fabric.audio import Audio
+from fabric.utils import logger
-from services.modus import ModusService, notification_service as notification_service_instance
+# ruff: noqa: I001
+from services.modus import (
+ ModusService,
+ notification_service as notification_service_instance,
+)
global modus_service
try:
diff --git a/src/utils/utils.py b/src/utils/utils.py
new file mode 100644
index 00000000..44baf23c
--- /dev/null
+++ b/src/utils/utils.py
@@ -0,0 +1,156 @@
+from pathlib import Path
+from typing import Literal
+
+from fabric.utils import Gdk, bulk_connect, exec_shell_command, exec_shell_command_async
+from fabric.widgets.scale import Scale, ScaleMark
+from fabric.widgets.svg import Svg
+
+from shared.widgets.animator import Animator
+
+
+class AnimatedScale(Scale):
+ def __init__(self, **kwargs):
+ super().__init__(**kwargs)
+ self.animator = None
+
+ def animate_value(self, value: float):
+ if not self.animator:
+ self.animator = Animator(
+ bezier_curve=(0.34, 1.56, 0.64, 1.0),
+ duration=0.8,
+ min_value=self.min_value,
+ max_value=self.value,
+ tick_widget=self,
+ notify_value=lambda p, *_: self.set_value(p.value),
+ )
+ self.animator.pause()
+ self.animator.min_value = self.value
+ self.animator.max_value = value
+ self.animator.play()
+
+
+def is_app_running(app_name: str) -> bool:
+ return len(exec_shell_command(f"pidof {app_name}")) != 0
+
+
+# Function to set up cursor hover
+def setup_cursor_hover(
+ widget, cursor_name: Literal["pointer", "crosshair", "grab"] = "pointer"
+):
+ display = Gdk.Display.get_default()
+ cursor = Gdk.Cursor.new_from_name(display, cursor_name)
+
+ def on_enter_notify_event(widget, _):
+ widget.get_window().set_cursor(cursor)
+
+ def on_leave_notify_event(widget, _):
+ # Restore default cursor when leaving the widget's area
+ widget.get_window().set_cursor(None)
+
+ bulk_connect(
+ widget,
+ {
+ "enter-notify-event": on_enter_notify_event,
+ "leave-notify-event": on_leave_notify_event,
+ },
+ )
+
+
+# Create a scale widget
+def create_scale(
+ name,
+ marks=None,
+ value=0,
+ min_value: float = 0,
+ max_value: float = 100,
+ increments=(1, 1),
+ curve=(0.34, 1.56, 0.64, 1.0),
+ orientation="h",
+ h_expand=True,
+ h_align="center",
+ style_classes="",
+ duration=0.8,
+ **kwargs,
+) -> AnimatedScale:
+ if marks is None:
+ marks = (ScaleMark(value=i) for i in range(1, 100, 10))
+
+ return AnimatedScale(
+ name=name,
+ marks=marks,
+ value=value,
+ min_value=min_value,
+ max_value=max_value,
+ increments=increments,
+ orientation=orientation,
+ curve=curve,
+ h_expand=h_expand,
+ h_align=h_align,
+ duration=duration,
+ style_classes=style_classes,
+ **kwargs,
+ )
+
+
+# Base path to your SVG assets (resolve from repository root)
+BASE_SVG_PATH = (Path(__file__).resolve().parent.parent / "assets" / "icons").resolve()
+
+
+def svg_file(relative_path: str, **kwargs) -> Svg:
+ """
+ Return a fresh Svg widget for a given relative path.
+
+ Avoids reusing the same widget instance across multiple containers, which
+ can cause GTK warnings. Example:
+ svg_file("misc/logo.svg", size=16)
+ """
+
+ def _resolve_path(path_like):
+ s = str(path_like)
+ marker = "/src/assets/icons/"
+ if marker in s:
+ rel = s.split(marker, 1)[1]
+ return (BASE_SVG_PATH / rel).resolve()
+ p = Path(s)
+ if p.is_absolute():
+ return p
+ return (BASE_SVG_PATH / p).resolve()
+
+ full_path = _resolve_path(relative_path)
+ svg = Svg(svg_file=str(full_path), **kwargs)
+
+ original_set_from_file = svg.set_from_file
+
+ def _set_from_file_resolved(file_path):
+ return original_set_from_file(str(_resolve_path(file_path)))
+
+ svg.set_from_file = _set_from_file_resolved # type: ignore[attr-defined]
+
+ # Provide a convenience alias for dynamic updates
+ def _dynamic_file(file_path):
+ _set_from_file_resolved(file_path)
+ return svg
+
+ svg.dynamic_file = _dynamic_file # type: ignore[attr-defined]
+
+ # Convenience method to update style and return the same instance
+ def _dynamic_style(
+ style: str,
+ *,
+ compiled: bool = True,
+ append: bool = False,
+ add_brackets: bool = True,
+ ):
+ svg.set_style(
+ style, compiled=compiled, append=append, add_brackets=add_brackets
+ )
+ return svg
+
+ svg.dynamic_style = _dynamic_style # type: ignore[attr-defined]
+
+ return svg
+
+
+def generate_colors_from_wallpaper(image_path: str) -> bool:
+ cmd = f'matugen image "{image_path}" --source-color-index 0'
+ return bool(exec_shell_command_async(cmd))
diff --git a/src/window/__init__.py b/src/window/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/window/controlcenter/__init__.py b/src/window/controlcenter/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/window/controlcenter/battery.py b/src/window/controlcenter/battery.py
new file mode 100644
index 00000000..e5c28b04
--- /dev/null
+++ b/src/window/controlcenter/battery.py
@@ -0,0 +1,650 @@
+import subprocess
+
+from fabric.bluetooth import BluetoothClient, BluetoothDevice
+from fabric.utils import Gdk, GLib, Gtk, exec_shell_command, logger
+from fabric.widgets.box import Box
+from fabric.widgets.button import Button
+from fabric.widgets.centerbox import CenterBox
+from fabric.widgets.image import Image
+from fabric.widgets.label import Label
+from fabric.widgets.revealer import Revealer
+from fabric.widgets.scrolledwindow import ScrolledWindow
+from fabric.widgets.separator import Separator
+
+from utils.utils import svg_file
+
+
+def get_battery_icon_file(
+ percentage: int, is_charging: bool, icon_path: str = ""
+) -> str:
+ """
+ Get the battery icon file path based on percentage and charging status.
+
+ Args:
+ percentage: Battery percentage (0-100)
+ is_charging: Whether the device is charging
+ icon_path: Base path for icons (unused, kept for compatibility)
+
+ Returns:
+ Relative path to the battery icon file
+ """
+ clamped = max(0, min(100, percentage))
+ step = (clamped // 10) * 10
+ filename = f"battery-{step:03d}{'-charging' if is_charging else ''}.svg"
+ return f"battery/{filename}"
+
+
+def set_bluetooth_enabled_with_fallback(client, enabled: bool):
+ try:
+ # Try fabric bluetooth first
+ client.set_enabled(enabled)
+ except Exception as e:
+ logger.warning(f"Fabric bluetooth set_enabled({enabled}) failed: {e}")
+
+ # Fallback to rfkill to unblock/block bluetooth
+ if enabled:
+ command = "rfkill unblock bluetooth"
+ else:
+ command = "rfkill block bluetooth"
+
+ result = exec_shell_command(command)
+ if result is False:
+ logger.error(f"rfkill fallback failed: command '{command}' returned False")
+ elif isinstance(result, str) and result.strip():
+ # If result is a non-empty string, it might be an error message
+ logger.warning(f"rfkill command output: {result.strip()}")
+ else:
+ logger.info(f"rfkill fallback succeeded: {command}")
+
+
+class BluetoothDeviceSlot(CenterBox):
+ def __init__(self, device: BluetoothDevice, **kwargs):
+ super().__init__(h_expand=True, name="device-button", **kwargs)
+ self.device = device
+ self._destroyed = False
+ self._signal_ids = []
+ self._signal_ids.append(self.device.connect("changed", self.on_changed))
+ self._signal_ids.append(
+ self.device.connect(
+ "notify::closed", lambda *_: self.device.closed and self.destroy()
+ )
+ )
+
+ self.styles = [
+ "connected" if self.device.connected else "",
+ "paired" if self.device.paired else "",
+ ]
+
+ self.dimage = Image(
+ icon_name=device.icon_name + "-symbolic", # type: ignore
+ size=5,
+ name="device-icon",
+ style_classes=" ".join(self.styles),
+ )
+
+ self.device_button = Button(
+ on_clicked=lambda *_: self.toggle_connecting(),
+ child=Box(
+ orientation="h",
+ h_expand=True,
+ children=[self.dimage, Label(label=device.name)],
+ ), # type: ignore
+ )
+ self.start_children = [self.device_button]
+
+ # # Add battery info if available
+ if hasattr(device, "battery_percentage") and device.battery_percentage > 0:
+ battery_box = Box(orientation="h", spacing=4)
+
+ # Create battery icon
+ battery_icon = svg_file(
+ get_battery_icon_file(
+ device.battery_percentage,
+ False, # Not charging for bluetooth devices
+ )
+ )
+
+ # Create battery percentage label
+ battery_label = Label(
+ label=f"{device.battery_percentage:.0f}%", name="battery-label"
+ )
+
+ battery_box.children = [battery_icon, battery_label]
+ self.end_children = [battery_box]
+
+ self.device_button.connect("enter-notify-event", self.on_button_enter)
+ self.device_button.connect("leave-notify-event", self.on_button_leave)
+ self.device.emit("changed") # to update display status
+
+ def destroy(self):
+ """Clean up device signal connections to prevent memory leaks."""
+ if self._destroyed:
+ return
+ self._destroyed = True
+ for sig_id in self._signal_ids:
+ try:
+ self.device.disconnect(sig_id)
+ except Exception:
+ pass
+ self._signal_ids.clear()
+ self.device = None
+ super().destroy()
+
+ def on_button_enter(self, widget, event):
+ self.add_style_class("button-hovered")
+
+ def on_button_leave(self, widget, event):
+ self.remove_style_class("button-hovered")
+
+ def toggle_connecting(self):
+ self.device.emit("changed")
+ self.device.set_connecting(not self.device.connected)
+
+ def on_changed(self, *_):
+ if self._destroyed or self.device is None:
+ return
+ try:
+ # Update connection and pairing status
+ new_styles = [
+ "connected" if self.device.connected else "",
+ "paired" if self.device.paired else "",
+ ]
+
+ self.styles = new_styles
+ self.dimage.set_property("style-classes", " ".join(self.styles))
+ except Exception:
+ return
+
+ # Update battery info if available
+ if (
+ hasattr(self.device, "battery_percentage")
+ and self.device.battery_percentage > 0
+ ):
+ if not self.end_children: # Add battery display if not already present
+ battery_box = Box(orientation="h", spacing=4)
+
+ # Create battery icon
+ battery_icon = svg_file(
+ get_battery_icon_file(
+ self.device.battery_percentage,
+ False, # Not charging for bluetooth devices
+ )
+ )
+
+ # Create battery percentage label
+ battery_label = Label(
+ label=f"{self.device.battery_percentage:.0f}%", name="battery-label"
+ )
+
+ battery_box.children = [battery_icon, battery_label]
+ self.end_children = [battery_box]
+ else: # Update existing battery display
+ battery_box = self.end_children[0]
+ if hasattr(battery_box, "children") and len(battery_box.children) >= 2:
+ battery_icon = battery_box.children[0]
+ battery_label = battery_box.children[1]
+
+ # Update battery icon
+ battery_icon.dynamic_file(
+ get_battery_icon_file(
+ self.device.battery_percentage,
+ False, # Not charging for bluetooth devices
+ )
+ )
+
+ # Update battery percentage
+ battery_label.set_label(f"{self.device.battery_percentage:.0f}%")
+ elif self.end_children: # Remove battery display if no longer available
+ self.end_children = []
+
+ return
+
+
+class BluetoothConnections(Box):
+ def __init__(
+ self, parent, show_hidden_devices: bool = False, show_back_button=True, **kwargs
+ ):
+ super().__init__(
+ spacing=8,
+ orientation="vertical",
+ name="bluetooth-connections",
+ **kwargs,
+ )
+
+ self.parent = parent
+ self.show_hidden_devices = show_hidden_devices
+ self.is_scanning = False # Track scanning state
+ self.refresh_timer = None # Timer for periodic device refresh
+ self._update_in_progress = False # Prevent concurrent updates
+ self._destroyed = False # Track if widget is destroyed
+ self._client_signal_ids = [] # Track BluetoothClient signal IDs
+
+ self.client = BluetoothClient(on_device_added=self.on_device_added)
+
+ # Create pull-to-refresh indicator
+ self.refresh_indicator = Label(
+ name="bluetooth-refresh-indicator",
+ label="โ Pull to scan for devices",
+ h_align="center",
+ visible=False,
+ style="color: #fff; font-size: 12px; padding: 5px;",
+ )
+
+ # Create title with optional back button
+ title_children = []
+ if show_back_button:
+ title_children.append(
+ Button(
+ image=Image(icon_name="back", size=10),
+ on_clicked=lambda *_: self.parent.close_bluetooth(),
+ )
+ )
+ title_children.append(Label("Bluetooth", name="bluetooth-title"))
+
+ self.title = Box(
+ orientation="h",
+ children=title_children,
+ )
+
+ self.toggle_button = Gtk.Switch(visible=True, name="toggle-button")
+
+ # Safely set initial state
+ self.toggle_button.set_active(self.client.enabled)
+ self.toggle_button.connect(
+ "notify::active",
+ lambda *_: set_bluetooth_enabled_with_fallback(
+ self.client, self.toggle_button.get_active()
+ ),
+ )
+
+ # Connect client signals โ track IDs for disconnect on destroy
+ self._client_signal_ids.append(
+ self.client.connect(
+ "notify::enabled",
+ lambda *_: self.toggle_button.set_active(self.client.enabled),
+ )
+ )
+ self._client_signal_ids.append(
+ self.client.connect("notify::scanning", lambda *_: self.update_scan_label())
+ )
+
+ # Connect to device changes
+ self._client_signal_ids.append(
+ self.client.connect("device-added", self.update_devices)
+ )
+ self._client_signal_ids.append(
+ self.client.connect("device-removed", self.update_devices)
+ )
+
+ # Connect to additional signals for better real-time monitoring
+ self._client_signal_ids.append(
+ self.client.connect("changed", self.on_client_changed)
+ )
+
+ # Create Devices section
+ self.paired_devices_label = Label(
+ label="Devices", h_align="start", name="networks-title"
+ )
+ self.paired_devices = Box(
+ spacing=4, orientation="vertical", name="known-networks"
+ )
+
+ # Create "No devices available" message
+ self.no_devices_label = Label(
+ label="No devices available",
+ h_align="center",
+ name="no-networks-label",
+ visible=False,
+ )
+
+ # Create Other Devices section with clickable title
+ self.other_devices_button = Button(
+ child=Label("Other Devices", h_align="start"),
+ name="wifi-other-button",
+ on_clicked=self.toggle_other_devices,
+ )
+ self.other_devices = Box(spacing=4, orientation="vertical")
+
+ # Create scrolled window for other devices
+ self.other_devices_scrolled = ScrolledWindow(
+ min_content_size=(303, 150),
+ child=self.other_devices,
+ overlay_scroll=True,
+ )
+
+ # Add pull-to-refresh functionality to scrolled window
+ self.setup_pull_to_refresh()
+
+ # Create revealer for Other Devices section
+ self.other_devices_revealer = Revealer(
+ child=self.other_devices_scrolled,
+ transition_type="slide-down",
+ transition_duration=100,
+ child_revealed=False,
+ )
+
+ # Create More Settings button (same style as Other Devices button)
+ self.more_settings_button = Button(
+ child=Label("More Settings", h_align="start"),
+ name="wifi-other-button",
+ on_clicked=self.open_bluetooth_settings,
+ )
+
+ self.children = [
+ CenterBox(
+ start_children=self.title,
+ end_children=self.toggle_button,
+ name="bluetooth-widget-top",
+ ),
+ self.refresh_indicator,
+ Separator(orientation="h", name="separator"),
+ self.paired_devices_label,
+ self.paired_devices,
+ self.no_devices_label,
+ Separator(orientation="h", name="separator"),
+ self.other_devices_button,
+ self.other_devices_revealer,
+ Separator(orientation="h", name="separator"),
+ self.more_settings_button,
+ ]
+
+ # Connect cleanup on destroy
+ self.connect("destroy", self.on_destroy)
+
+ self.client.notify("scanning")
+ self.client.notify("enabled")
+
+ # Initial device update
+ self.update_devices()
+
+ # Start periodic device monitoring for real-time updates
+ self.start_device_monitoring()
+
+ def toggle_other_devices(self, *_):
+ """Toggle the visibility of other devices section"""
+ current_state = self.other_devices_revealer.child_revealed
+ self.other_devices_revealer.child_revealed = not current_state
+
+ # Handle scanning based on section visibility
+ if self.client:
+ if self.other_devices_revealer.child_revealed:
+ # Start scanning when revealing other devices section
+ if not self.client.scanning:
+ self.client.toggle_scan()
+ # Also force an immediate device refresh to catch any missed connections
+ self.force_device_refresh()
+ else:
+ # Stop scanning when hiding other devices section
+ if self.client.scanning:
+ self.client.toggle_scan()
+
+ def open_bluetooth_settings(self, *_):
+ """Open Blueman bluetooth manager"""
+ try:
+ subprocess.Popen(["blueman-manager"], start_new_session=True)
+ if self.parent and hasattr(self.parent, "hide_controlcenter"):
+ self.parent.hide_controlcenter()
+ except FileNotFoundError:
+ pass
+ except Exception:
+ pass
+
+ def update_scan_label(self):
+ """Update scanning state appearance"""
+ if self.client.scanning:
+ # Show scanning feedback in refresh indicator
+ self.refresh_indicator.set_label("Scanning for devices...")
+ self.refresh_indicator.set_visible(True)
+ self.refresh_indicator.add_style_class("scanning")
+ else:
+ # Hide scanning feedback
+ self.refresh_indicator.set_visible(False)
+ self.refresh_indicator.remove_style_class("scanning")
+
+ def update_devices(self, *_):
+ """Update the list of available devices"""
+ # Prevent concurrent updates and check if destroyed
+ if self._update_in_progress or self._destroyed or not self.client:
+ return
+
+ self._update_in_progress = True
+
+ try:
+ # Store current device addresses to detect changes
+ current_paired_addresses = {
+ child.device.address
+ for child in self.paired_devices.get_children()
+ if hasattr(child, "device")
+ }
+ current_other_addresses = {
+ child.device.address
+ for child in self.other_devices.get_children()
+ if hasattr(child, "device")
+ }
+
+ # Get current devices safely
+ devices = self.client.devices
+ paired_devices = []
+ other_devices = []
+ new_paired_addresses = set()
+ new_other_addresses = set()
+
+ for device in devices:
+ try:
+ if device.name and device.name != "Unknown":
+ # Categorize devices: paired devices go to "Devices (Paired)"
+ # All others go to "Other Devices"
+ if device.paired:
+ paired_devices.append(device)
+ new_paired_addresses.add(device.address)
+ else:
+ other_devices.append(device)
+ new_other_addresses.add(device.address)
+ except Exception:
+ continue
+
+ # Check if we need to update (devices added/removed)
+ paired_changed = current_paired_addresses != new_paired_addresses
+ other_changed = current_other_addresses != new_other_addresses
+
+ # Only rebuild if something actually changed
+ if paired_changed or other_changed:
+ # Clear existing devices safely
+ for child in list(self.paired_devices.get_children()):
+ if not self._destroyed:
+ child.destroy()
+ for child in list(self.other_devices.get_children()):
+ if not self._destroyed:
+ child.destroy()
+
+ # Add paired devices
+ for device in paired_devices:
+ if not self._destroyed:
+ device_slot = BluetoothDeviceSlot(device)
+ self.paired_devices.add(device_slot)
+
+ # Add other devices
+ for device in other_devices:
+ if not self._destroyed:
+ device_slot = BluetoothDeviceSlot(device)
+ self.other_devices.add(device_slot)
+
+ # Show/hide sections based on available devices
+ if not self._destroyed:
+ has_paired_devices = len(paired_devices) > 0
+ has_other_devices = len(other_devices) > 0
+ has_any_devices = has_paired_devices or has_other_devices
+
+ # Show paired devices section only if there are paired devices
+ self.paired_devices_label.set_visible(has_paired_devices)
+ self.paired_devices.set_visible(has_paired_devices)
+
+ # Show "No devices available" message if no devices at all
+ self.no_devices_label.set_visible(not has_any_devices)
+
+ # Always show the other devices button, regardless of available devices
+ self.other_devices_button.set_visible(True) # Always visible
+
+ except Exception:
+ pass
+ finally:
+ self._update_in_progress = False
+
+ def start_device_monitoring(self):
+ """Start periodic monitoring for device changes"""
+ # Monitor for device changes every 5 seconds (less aggressive)
+ # This helps catch devices that connect from external sources
+ self.refresh_timer = GLib.timeout_add_seconds(5, self.periodic_device_refresh)
+
+ def stop_device_monitoring(self):
+ """Stop periodic monitoring"""
+ if self.refresh_timer:
+ GLib.source_remove(self.refresh_timer)
+ self.refresh_timer = None
+
+ def periodic_device_refresh(self):
+ """Periodically refresh device list to catch external connections"""
+ # If destroyed, remove the GLib source by returning False
+ if self._destroyed:
+ self.refresh_timer = None
+ return False
+
+ # Skip if update in progress or client not available/enabled
+ if self._update_in_progress or not self.client or not self.client.enabled:
+ return True # Continue monitoring
+
+ try:
+ self.update_devices()
+ except Exception:
+ pass
+
+ return True # Continue monitoring
+
+ def force_device_refresh(self):
+ """Force an immediate refresh of the device list"""
+ if self._update_in_progress or self._destroyed:
+ return
+
+ try:
+ # Simply trigger update_devices which has its own safety checks
+ # Avoid forcing signal emissions to prevent race conditions
+ self.update_devices()
+ except Exception:
+ pass
+
+ def on_client_changed(self, *_):
+ """Handle when the bluetooth client state changes"""
+ # Update devices when client state changes
+ self.update_devices()
+
+ def on_destroy(self, widget):
+ """Cleanup when widget is destroyed"""
+ # Mark as destroyed to prevent further updates
+ self._destroyed = True
+ # Stop monitoring timer
+ self.stop_device_monitoring()
+ # Disconnect all BluetoothClient signals
+ for sig_id in self._client_signal_ids:
+ try:
+ self.client.disconnect(sig_id)
+ except Exception:
+ pass
+ self._client_signal_ids.clear()
+ # Collapse other devices revealer
+ try:
+ self.other_devices_revealer.child_revealed = False
+ except Exception:
+ pass
+
+ def close_bluetooth(self):
+ """Called when Bluetooth panel is being closed"""
+ # Collapse the other devices section when closing
+ self.other_devices_revealer.child_revealed = False
+
+ def setup_pull_to_refresh(self):
+ """Setup pull-to-refresh gesture for the scrolled window"""
+ # Get the scrolled window's vertical adjustment
+ self.vadjustment = self.other_devices_scrolled.get_vadjustment()
+
+ # Track gesture state
+ self.pull_start_y = 0
+ self.is_pulling = False
+ self.pull_threshold = 50 # pixels to trigger refresh
+
+ # Connect to scroll events
+ self.other_devices_scrolled.connect("scroll-event", self.on_scroll_event)
+ self.other_devices_scrolled.connect("button-press-event", self.on_button_press)
+ self.other_devices_scrolled.connect(
+ "button-release-event", self.on_button_release
+ )
+ self.other_devices_scrolled.connect(
+ "motion-notify-event", self.on_motion_notify
+ )
+
+ # Enable events
+ self.other_devices_scrolled.set_events(
+ Gdk.EventMask.SCROLL_MASK
+ | Gdk.EventMask.BUTTON_PRESS_MASK
+ | Gdk.EventMask.BUTTON_RELEASE_MASK
+ | Gdk.EventMask.POINTER_MOTION_MASK
+ )
+
+ def on_scroll_event(self, widget, event):
+ """Handle scroll events for pull-to-refresh"""
+ # Only handle pull-to-refresh when at the top
+ if self.vadjustment.get_value() <= 0:
+ if event.direction == Gdk.ScrollDirection.UP:
+ # Scrolling up at the top - toggle scan and force refresh
+ self.client.toggle_scan()
+ self.force_device_refresh()
+ return True # Consume the event
+ return False # Let normal scrolling continue
+
+ def on_button_press(self, widget, event):
+ """Handle button press for touch/drag gestures"""
+ if self.vadjustment.get_value() <= 0:
+ self.pull_start_y = event.y
+ self.is_pulling = True
+ return False
+
+ def on_button_release(self, widget, event):
+ """Handle button release for touch/drag gestures"""
+ if self.is_pulling:
+ pull_distance = event.y - self.pull_start_y
+ if pull_distance > self.pull_threshold:
+ # Toggle scan and force refresh
+ self.client.toggle_scan()
+ self.force_device_refresh()
+ # Hide refresh indicator
+ self.refresh_indicator.set_visible(False)
+ self.refresh_indicator.remove_style_class("ready-to-refresh")
+ self.is_pulling = False
+ return False
+
+ def on_motion_notify(self, widget, event):
+ """Handle motion events for visual feedback during pull"""
+ if self.is_pulling and self.vadjustment.get_value() <= 0:
+ pull_distance = event.y - self.pull_start_y
+ if pull_distance > 0:
+ # Show refresh indicator when pulling down
+ self.refresh_indicator.set_visible(True)
+ if pull_distance >= self.pull_threshold:
+ if self.client.scanning:
+ self.refresh_indicator.set_label("โ Release to stop scanning")
+ else:
+ self.refresh_indicator.set_label("โ Release to scan")
+ self.refresh_indicator.add_style_class("ready-to-refresh")
+ else:
+ if self.client.scanning:
+ self.refresh_indicator.set_label("โ Pull to stop scanning")
+ else:
+ self.refresh_indicator.set_label("โ Pull to scan for devices")
+ self.refresh_indicator.remove_style_class("ready-to-refresh")
+ else:
+ self.refresh_indicator.set_visible(False)
+ return False
+
+ def on_device_added(self, client: BluetoothClient, address: str):
+ """Handle when a new device is added"""
+ # Update the device list when devices are added
+ self.update_devices()
diff --git a/modules/controlcenter/bluetooth.py b/src/window/controlcenter/bluetooth.py
similarity index 83%
rename from modules/controlcenter/bluetooth.py
rename to src/window/controlcenter/bluetooth.py
index 08346aa6..031eed41 100644
--- a/modules/controlcenter/bluetooth.py
+++ b/src/window/controlcenter/bluetooth.py
@@ -1,8 +1,7 @@
import subprocess
-import gi
from fabric.bluetooth import BluetoothClient, BluetoothDevice
-from fabric.utils import get_relative_path
+from fabric.utils import Gdk, GLib, Gtk, exec_shell_command, logger
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.centerbox import CenterBox
@@ -11,14 +10,28 @@
from fabric.widgets.revealer import Revealer
from fabric.widgets.scrolledwindow import ScrolledWindow
from fabric.widgets.separator import Separator
-from fabric.widgets.svg import Svg
-from gi.repository import Gdk, GLib, Gtk
-from loguru import logger
-from services.battery import Battery
+from utils.utils import svg_file
-gi.require_version("Gtk", "3.0")
-gi.require_version("Gdk", "3.0")
+
+def get_battery_icon_file(
+ percentage: int, is_charging: bool, icon_path: str = ""
+) -> str:
+ """
+ Get the battery icon file path based on percentage and charging status.
+
+ Args:
+ percentage: Battery percentage (0-100)
+ is_charging: Whether the device is charging
+ icon_path: Base path for icons (unused, kept for compatibility)
+
+ Returns:
+ Relative path to the battery icon file
+ """
+ clamped = max(0, min(100, percentage))
+ step = int((clamped // 10) * 10)
+ filename = f"battery-{step:03d}{'-charging' if is_charging else ''}.svg"
+ return f"battery/{filename}"
def set_bluetooth_enabled_with_fallback(client, enabled: bool):
@@ -27,28 +40,34 @@ def set_bluetooth_enabled_with_fallback(client, enabled: bool):
client.set_enabled(enabled)
except Exception as e:
logger.warning(f"Fabric bluetooth set_enabled({enabled}) failed: {e}")
+
# Fallback to rfkill to unblock/block bluetooth
if enabled:
- command = ["rfkill", "unblock", "bluetooth"]
+ command = "rfkill unblock bluetooth"
else:
- command = ["rfkill", "block", "bluetooth"]
-
- try:
- # Execute the rfkill command
- subprocess.run(
- command, capture_output=True, text=True, timeout=10, check=True
- )
- except Exception as subprocess_error:
- logger.error(f"rfkill fallback failed with exception: {subprocess_error}")
+ command = "rfkill block bluetooth"
+
+ result = exec_shell_command(command)
+ if result is False:
+ logger.error(f"rfkill fallback failed: command '{command}' returned False")
+ elif isinstance(result, str) and result.strip():
+ # If result is a non-empty string, it might be an error message
+ logger.warning(f"rfkill command output: {result.strip()}")
+ else:
+ logger.info(f"rfkill fallback succeeded: {command}")
class BluetoothDeviceSlot(CenterBox):
def __init__(self, device: BluetoothDevice, **kwargs):
super().__init__(h_expand=True, name="device-button", **kwargs)
self.device = device
- self.device.connect("changed", self.on_changed)
- self.device.connect(
- "notify::closed", lambda *_: self.device.closed and self.destroy()
+ self._destroyed = False
+ self._signal_ids = []
+ self._signal_ids.append(self.device.connect("changed", self.on_changed))
+ self._signal_ids.append(
+ self.device.connect(
+ "notify::closed", lambda *_: self.device.closed and self.destroy()
+ )
)
self.styles = [
@@ -73,21 +92,16 @@ def __init__(self, device: BluetoothDevice, **kwargs):
)
self.start_children = [self.device_button]
- # Add battery info if available
+ # # Add battery info if available
if hasattr(device, "battery_percentage") and device.battery_percentage > 0:
battery_box = Box(orientation="h", spacing=4)
# Create battery icon
- battery_icon = Svg(
- size=16,
- svg_file=get_relative_path(
- Battery.get_battery_icon_file(
- device.battery_percentage,
- False, # Not charging for bluetooth devices
- "../../config/assets/icons/",
- )
- ),
- name="battery-icon",
+ battery_icon = svg_file(
+ get_battery_icon_file(
+ device.battery_percentage,
+ False, # Not charging for bluetooth devices
+ )
)
# Create battery percentage label
@@ -102,6 +116,20 @@ def __init__(self, device: BluetoothDevice, **kwargs):
self.device_button.connect("leave-notify-event", self.on_button_leave)
self.device.emit("changed") # to update display status
+ def destroy(self):
+ """Clean up device signal connections to prevent memory leaks."""
+ if self._destroyed:
+ return
+ self._destroyed = True
+ for sig_id in self._signal_ids:
+ try:
+ self.device.disconnect(sig_id)
+ except Exception:
+ pass
+ self._signal_ids.clear()
+ self.device = None
+ super().destroy()
+
def on_button_enter(self, widget, event):
self.add_style_class("button-hovered")
@@ -113,11 +141,13 @@ def toggle_connecting(self):
self.device.set_connecting(not self.device.connected)
def on_changed(self, *_):
+ if self._destroyed or self.device is None:
+ return
try:
# Update connection and pairing status
new_styles = [
- "connected" if self.device.connected else "",
- "paired" if self.device.paired else "",
+ "connected" if getattr(self.device, "connected", False) else "",
+ "paired" if getattr(self.device, "paired", False) else "",
]
self.styles = new_styles
@@ -134,16 +164,11 @@ def on_changed(self, *_):
battery_box = Box(orientation="h", spacing=4)
# Create battery icon
- battery_icon = Svg(
- size=16,
- svg_file=get_relative_path(
- Battery.get_battery_icon_file(
- self.device.battery_percentage,
- False, # Not charging for bluetooth devices
- "../../config/assets/icons/",
- )
- ),
- name="battery-icon",
+ battery_icon = svg_file(
+ get_battery_icon_file(
+ self.device.battery_percentage,
+ False, # Not charging for bluetooth devices
+ )
)
# Create battery percentage label
@@ -160,13 +185,10 @@ def on_changed(self, *_):
battery_label = battery_box.children[1]
# Update battery icon
- battery_icon.set_from_file(
- get_relative_path(
- Battery.get_battery_icon_file(
- self.device.battery_percentage,
- False, # Not charging for bluetooth devices
- "../../config/assets/icons/",
- )
+ battery_icon.dynamic_file(
+ get_battery_icon_file(
+ self.device.battery_percentage,
+ False, # Not charging for bluetooth devices
)
)
@@ -195,6 +217,7 @@ def __init__(
self.refresh_timer = None # Timer for periodic device refresh
self._update_in_progress = False # Prevent concurrent updates
self._destroyed = False # Track if widget is destroyed
+ self._client_signal_ids = [] # Track BluetoothClient signal IDs
self.client = BluetoothClient(on_device_added=self.on_device_added)
@@ -234,19 +257,29 @@ def __init__(
),
)
- # Connect client signals
- self.client.connect(
- "notify::enabled",
- lambda *_: self.toggle_button.set_active(self.client.enabled),
+ # Connect client signals โ track IDs for disconnect on destroy
+ self._client_signal_ids.append(
+ self.client.connect(
+ "notify::enabled",
+ lambda *_: self.toggle_button.set_active(self.client.enabled),
+ )
+ )
+ self._client_signal_ids.append(
+ self.client.connect("notify::scanning", lambda *_: self.update_scan_label())
)
- self.client.connect("notify::scanning", lambda *_: self.update_scan_label())
# Connect to device changes
- self.client.connect("device-added", self.update_devices)
- self.client.connect("device-removed", self.update_devices)
+ self._client_signal_ids.append(
+ self.client.connect("device-added", self.update_devices)
+ )
+ self._client_signal_ids.append(
+ self.client.connect("device-removed", self.update_devices)
+ )
# Connect to additional signals for better real-time monitoring
- self.client.connect("changed", self.on_client_changed)
+ self._client_signal_ids.append(
+ self.client.connect("changed", self.on_client_changed)
+ )
# Create Devices section
self.paired_devices_label = Label(
@@ -471,20 +504,17 @@ def stop_device_monitoring(self):
def periodic_device_refresh(self):
"""Periodically refresh device list to catch external connections"""
- # Skip if update in progress, destroyed, or client not available
- if (
- self._update_in_progress
- or self._destroyed
- or not self.client
- or not self.client.enabled
- ):
+ # If destroyed, remove the GLib source by returning False
+ if self._destroyed:
+ self.refresh_timer = None
+ return False
+
+ # Skip if update in progress or client not available/enabled
+ if self._update_in_progress or not self.client or not self.client.enabled:
return True # Continue monitoring
try:
- # Simple check - just trigger update_devices which has its own safety checks
- # Don't force signal emissions as that can cause race conditions
self.update_devices()
-
except Exception:
pass
@@ -511,13 +541,20 @@ def on_destroy(self, widget):
"""Cleanup when widget is destroyed"""
# Mark as destroyed to prevent further updates
self._destroyed = True
- # Stop monitoring
+ # Stop monitoring timer
self.stop_device_monitoring()
- # Make sure other devices revealer is collapsed when closing
+ # Disconnect all BluetoothClient signals
+ for sig_id in self._client_signal_ids:
+ try:
+ self.client.disconnect(sig_id)
+ except Exception:
+ pass
+ self._client_signal_ids.clear()
+ # Collapse other devices revealer
try:
self.other_devices_revealer.child_revealed = False
- except:
- pass # Widget might already be destroyed
+ except Exception:
+ pass
def close_bluetooth(self):
"""Called when Bluetooth panel is being closed"""
diff --git a/modules/controlcenter/expanded_player.py b/src/window/controlcenter/expanded_player.py
similarity index 80%
rename from modules/controlcenter/expanded_player.py
rename to src/window/controlcenter/expanded_player.py
index 99d0ad4c..73ffe0cd 100644
--- a/modules/controlcenter/expanded_player.py
+++ b/src/window/controlcenter/expanded_player.py
@@ -1,32 +1,38 @@
# Standard library imports
-import os
+import gc
import re
import tempfile
-import urllib.parse
-import urllib.request
import threading
+import urllib.parse
+import httpx
import weakref
-from typing import List, Optional, Dict, Set
-import gc
-
-# Fabric imports
-from fabric.widgets.scale import Scale
-from widgets.wayland import WaylandWindow as Window
-from fabric.widgets.button import Button
-from fabric.widgets.label import Label
+from typing import Dict, List, Optional
+
+from fabric.utils import (
+ bulk_connect,
+ cooldown,
+ invoke_repeater,
+ GLib,
+ GObject,
+ logger,
+ os,
+)
from fabric.widgets.box import Box
-from fabric.utils import bulk_connect, invoke_repeater, cooldown
-from fabric.utils.helpers import get_relative_path
+from fabric.widgets.button import Button
from fabric.widgets.image import Image
+from fabric.widgets.label import Label
from fabric.widgets.overlay import Overlay
+
+# Fabric imports
+from fabric.widgets.scale import Scale
from fabric.widgets.stack import Stack
-from fabric.widgets.svg import Svg
-from gi.repository import GLib, GObject
-from loguru import logger
+from fabric.widgets.wayland import WaylandWindow as Window
+
+import shared.data as data
# Local imports
from services.mpris import MprisPlayer, MprisPlayerManager
-import config.data as data
+from utils.utils import svg_file
CACHE_DIR = f"{data.CACHE_DIR}/media"
@@ -46,18 +52,18 @@ def get_shared_mpris_manager():
def cleanup_artwork_cache():
- """Clean up artwork cache to prevent memory leaks."""
+ """Clean up artwork cache immediately when size limit is reached."""
global _artwork_cache
if len(_artwork_cache) > _max_artwork_cache_size:
- # Keep only the most recent items
+ # Keep only the most recent items immediately
items = list(_artwork_cache.items())
_artwork_cache = dict(items[-_max_artwork_cache_size:])
- # Force garbage collection
+ # Immediate garbage collection
gc.collect()
def cleanup_old_cache_files():
- """Clean up old artwork cache files aggressively to save memory."""
+ """Clean up old artwork cache files immediately (no lazy loading)."""
try:
if not os.path.exists(CACHE_DIR):
return
@@ -92,7 +98,7 @@ def get_artwork_cached(url: str) -> Optional[str]:
if url in _artwork_cache:
return _artwork_cache[url]
- # Clean cache if too large
+ # Clean cache immediately if too large (no lazy loading)
cleanup_artwork_cache()
try:
@@ -117,26 +123,17 @@ def get_artwork_cached(url: str) -> Optional[str]:
return cache_file
# Download and cache
- urllib.request.urlretrieve(url, cache_file)
- _artwork_cache[url] = cache_file
- return cache_file
+ response = httpx.get(url, timeout=10, follow_redirects=True)
+ if response.status_code == 200:
+ with open(cache_file, "wb") as f:
+ f.write(response.content)
+ _artwork_cache[url] = cache_file
+ return cache_file
except Exception as e:
logger.warning(f"Failed to cache artwork from {url}: {e}")
return None
- for filename in os.listdir(CACHE_DIR):
- filepath = os.path.join(CACHE_DIR, filename)
- try:
- if os.path.isfile(filepath):
- file_mtime = os.path.getmtime(filepath)
- if file_mtime < six_hours_ago:
- os.unlink(filepath)
- except Exception:
- pass # Ignore individual file errors
- except Exception:
- pass # Ignore all errors in cleanup
-
class EmbeddedExpandedPlayer(Box):
"""Embedded expanded player widget for use inside control center."""
@@ -162,15 +159,6 @@ def __init__(self, control_center, **kwargs):
# Create expanded player content
self.player_content = PlayerBoxStack(self.mpris_manager)
- # Add escape key binding for navigation back
- self._keybinding_added = False
- try:
- if hasattr(self.control_center, "add_keybinding"):
- self.control_center.add_keybinding("Escape", self._on_back_clicked)
- self._keybinding_added = True
- except Exception:
- pass # Ignore if keybinding fails
-
self.children = [
Box(
orientation="horizontal",
@@ -199,6 +187,20 @@ def refresh(self):
# This will automatically update as MPRIS players change
pass
+ def suspend(self):
+ """Suspend updates when control center is hidden"""
+ if hasattr(self, "player_stack") and self.player_stack:
+ for child in self.player_stack.get_children():
+ if hasattr(child, "suspend"):
+ child.suspend()
+
+ def resume(self):
+ """Resume updates when control center is shown"""
+ if hasattr(self, "player_stack") and self.player_stack:
+ for child in self.player_stack.get_children():
+ if hasattr(child, "resume"):
+ child.resume()
+
def destroy(self):
"""Clean up resources and prevent memory leaks"""
logger.debug("๐๏ธ EmbeddedExpandedPlayer cleanup starting")
@@ -221,14 +223,6 @@ def destroy(self):
except Exception as e:
logger.warning(f"Failed to destroy child widget: {e}")
- # Clean up keybinding if it was added
- if hasattr(self, "_keybinding_added") and self._keybinding_added:
- try:
- if hasattr(self.control_center, "remove_keybinding"):
- self.control_center.remove_keybinding("Escape")
- except Exception as e:
- logger.warning(f"Failed to remove keybinding: {e}")
-
# Aggressively clean global caches
global _widget_cache, _artwork_cache
_widget_cache.clear()
@@ -250,35 +244,12 @@ def destroy(self):
finally:
super().destroy()
- def _periodic_cleanup(self):
- """Light cleanup for EmbeddedExpandedPlayer reuse"""
- try:
- logger.debug("๐งน EmbeddedExpandedPlayer light cleanup starting")
-
- # Only clean up the player content lightly
- if hasattr(self, "player_content") and hasattr(
- self.player_content, "_periodic_cleanup"
- ):
- self.player_content._periodic_cleanup()
-
- # Clean global caches but don't destroy core functionality
- global _artwork_cache
- _artwork_cache.clear()
-
- # Light garbage collection
- gc.collect()
-
- logger.debug("๐งน EmbeddedExpandedPlayer light cleanup completed")
-
- except Exception as e:
- logger.warning(f"Error during EmbeddedExpandedPlayer light cleanup: {e}")
-
class PlayerBoxStack(Box):
"""Memory-optimized widget that displays current player information."""
def __init__(self, mpris_manager: MprisPlayerManager, **kwargs):
- # Clean up old cache files on startup
+ # Clean up old cache files immediately (no lazy loading)
cleanup_old_cache_files()
# The player stack with memory-efficient settings
@@ -319,63 +290,16 @@ def __init__(self, mpris_manager: MprisPlayerManager, **kwargs):
self._signal_connections.append((self.mpris_manager, handler_id))
# Process existing players
- for player in self.mpris_manager.players: # type: ignore
- logger.info(
- f"[PLAYER MANAGER] player found: {player.get_property('player-name')}"
- )
+ for player in self.mpris_manager.players.values(): # type: ignore
+ logger.info(f"[PLAYER MANAGER] player found: {player.player_name}")
self.on_new_player(self.mpris_manager, player)
- # Schedule periodic memory cleanup and store source ID
- self._cleanup_source_id = GLib.timeout_add_seconds(
- 300, self._periodic_cleanup
- ) # Every 5 minutes
-
- def _periodic_cleanup(self):
- """Light cleanup for widget reuse - preserve functionality."""
- try:
- logger.debug("Starting light expanded player cleanup for reuse")
-
- # Clean artwork cache to reduce memory
- cleanup_artwork_cache()
-
- # Reset visual state but preserve connections and core functionality
- # Only clear the player widgets that can be recreated
- for widget in list(self._player_widgets.values()):
- try:
- if widget and hasattr(widget, "get_parent") and widget.get_parent():
- # Only remove from parent, don't destroy (let GTK handle it)
- widget.get_parent().remove(widget)
- except Exception:
- pass
- # Don't clear the _player_widgets dict - just let them be recreated
-
- # Reset stack to show no_media_box without destroying children
- try:
- if hasattr(self, "player_stack") and hasattr(self, "no_media_box"):
- self.player_stack.set_visible_child(self.no_media_box)
- self.current_stack_pos = 0
- except Exception:
- pass
-
- # Light garbage collection
- gc.collect()
-
- logger.debug("Light expanded player cleanup completed")
-
- except Exception as e:
- logger.warning(f"Error during light cleanup: {e}")
-
- return True # Continue timer
+ # No periodic cleanup - immediate cleanup when needed
def destroy(self):
"""Clean up resources when the widget is destroyed."""
try:
- # Cancel any pending cleanup timer
- if hasattr(self, "_cleanup_source_id") and self._cleanup_source_id:
- try:
- GLib.source_remove(self._cleanup_source_id)
- except Exception:
- pass # Timer may have already been removed
+ # No periodic cleanup timer to cancel (removed lazy loading)
# Disconnect all signal connections
for obj, handler_id in self._signal_connections:
@@ -576,7 +500,7 @@ def on_new_player(self, mpris_manager, player):
self.set_visible(True)
- new_player_box = PlayerBox(player=MprisPlayer(player), player_stack=self)
+ new_player_box = PlayerBox(player=player, player_stack=self)
self.player_stack.children = [
*self.player_stack.children,
new_player_box,
@@ -602,7 +526,7 @@ def on_lost_player(self, mpris_manager, player_name):
for player_box in players:
if (
hasattr(player_box, "player")
- and player_box.player.player_name == player_name
+ and player_box.player.bus_name == player_name
):
player_box_to_remove = player_box
break
@@ -714,6 +638,8 @@ def __init__(self, player: MprisPlayer, player_stack=None, **kwargs):
self.current_download_thread = None # Track current download thread
self._download_cancelled = False # Flag to cancel downloads
self._signal_connections = [] # Track signal connections
+ self._property_bindings = [] # Track GObject property bindings
+ self._seekbar_signal_ids = [] # Track seek_bar widget signal IDs
# Use same CSS background approach as small player for consistency
self.album_cover = Box(
@@ -786,10 +712,18 @@ def __init__(self, player: MprisPlayer, player_stack=None, **kwargs):
size=1,
h_expand=True,
)
- self.seek_bar.connect("value-changed", self._on_scale_value_changed)
- self.seek_bar.connect("button-press-event", self._on_seek_start)
- self.seek_bar.connect("button-release-event", self._on_seek_end)
- self.player.bind("can-seek", "sensitive", self.seek_bar)
+ self._seekbar_signal_ids.append(
+ self.seek_bar.connect("value-changed", self._on_scale_value_changed)
+ )
+ self._seekbar_signal_ids.append(
+ self.seek_bar.connect("button-press-event", self._on_seek_start)
+ )
+ self._seekbar_signal_ids.append(
+ self.seek_bar.connect("button-release-event", self._on_seek_end)
+ )
+ self._property_bindings.append(
+ self.player.bind_property("can_seek", self.seek_bar, "sensitive")
+ )
# Position and length labels for seek bar
self.position_label = Label(
@@ -862,33 +796,45 @@ def __init__(self, player: MprisPlayer, player_stack=None, **kwargs):
],
)
- # Bind player properties
- self.player.bind_property(
- "title",
- self.track_title,
- "label",
- GObject.BindingFlags.DEFAULT,
- lambda _, x: (
- re.sub(r"\r?\n", " ", x) if x != "" and x is not None else "No Title"
- ), # type: ignore
+ # Bind player properties โ track bindings so we can unbind on destroy
+ self._property_bindings.append(
+ self.player.bind_property(
+ "title",
+ self.track_title,
+ "label",
+ GObject.BindingFlags.DEFAULT,
+ lambda _, x: (
+ re.sub(r"\r?\n", " ", x)
+ if x != "" and x is not None
+ else "No Title"
+ ), # type: ignore
+ )
)
- self.player.bind_property(
- "artist",
- self.track_artist,
- "label",
- GObject.BindingFlags.DEFAULT,
- lambda _, x: (
- re.sub(r"\r?\n", " ", x) if x != "" and x is not None else "No Artist"
- ), # type: ignore
+ self._property_bindings.append(
+ self.player.bind_property(
+ "artist",
+ self.track_artist,
+ "label",
+ GObject.BindingFlags.DEFAULT,
+ lambda _, x: (
+ re.sub(r"\r?\n", " ", ", ".join(x))
+ if isinstance(x, list) and len(x) > 0
+ else "No Artist"
+ ), # type: ignore
+ )
)
- self.player.bind_property(
- "album",
- self.track_album,
- "label",
- GObject.BindingFlags.DEFAULT,
- lambda _, x: (
- re.sub(r"\r?\n", " ", x) if x != "" and x is not None else "No Album"
- ), # type: ignore
+ self._property_bindings.append(
+ self.player.bind_property(
+ "album",
+ self.track_album,
+ "label",
+ GObject.BindingFlags.DEFAULT,
+ lambda _, x: (
+ re.sub(r"\r?\n", " ", x)
+ if x != "" and x is not None
+ else "No Album"
+ ), # type: ignore
+ )
)
# Player switcher buttons box (compact, minimal space)
@@ -904,21 +850,9 @@ def __init__(self, player: MprisPlayer, player_stack=None, **kwargs):
self.stack_buttons_box.hide() # Initially hidden
# Create SVG icons from player directory
- self.skip_next_icon = Svg(
- name="btn",
- style_classes=["control-buttons"],
- svg_file=get_relative_path("../../config/assets/icons/player/fwd.svg"),
- )
- self.skip_prev_icon = Svg(
- name="btn",
- style_classes=["control-buttons"],
- svg_file=get_relative_path("../../config/assets/icons/player/Rewind.svg"),
- )
- self.play_pause_icon = Svg(
- name="btn",
- style_classes=["control-buttons"],
- svg_file=get_relative_path("../../config/assets/icons/player/Pause.svg"),
- )
+ self.skip_next_icon = svg_file("player/fwd.svg", size=22)
+ self.skip_prev_icon = svg_file("player/Rewind.svg", size=22)
+ self.play_pause_icon = svg_file("player/Pause.svg", size=22)
self.play_pause_button = Button(
style_classes=["control-buttons"],
@@ -927,7 +861,9 @@ def __init__(self, player: MprisPlayer, player_stack=None, **kwargs):
on_clicked=self.player.play_pause,
)
- self.player.bind_property("can_pause", self.play_pause_button, "sensitive")
+ self._property_bindings.append(
+ self.player.bind_property("can_pause", self.play_pause_button, "sensitive")
+ )
self.next_button = Button(
style_classes=["control-buttons"],
@@ -935,7 +871,9 @@ def __init__(self, player: MprisPlayer, player_stack=None, **kwargs):
child=self.skip_next_icon,
on_clicked=self._on_player_next,
)
- self.player.bind_property("can_go_next", self.next_button, "sensitive")
+ self._property_bindings.append(
+ self.player.bind_property("can_go_next", self.next_button, "sensitive")
+ )
self.prev_button = Button(
name="macos-control-button",
@@ -943,6 +881,9 @@ def __init__(self, player: MprisPlayer, player_stack=None, **kwargs):
style_classes=["control-buttons"],
on_clicked=self._on_player_prev,
)
+ self._property_bindings.append(
+ self.player.bind_property("can_go_previous", self.prev_button, "sensitive")
+ )
self.button_box.children = (
self.prev_button,
self.play_pause_button,
@@ -989,7 +930,7 @@ def __init__(self, player: MprisPlayer, player_stack=None, **kwargs):
connections = bulk_connect(
self.player,
{
- "exit": self._on_player_exit,
+ "closed": self._on_player_exit,
"notify::playback-status": self._on_playback_change,
"notify::metadata": self._on_metadata,
},
@@ -998,6 +939,12 @@ def __init__(self, player: MprisPlayer, player_stack=None, **kwargs):
for handler_id in connections:
self._signal_connections.append((self.player, handler_id))
+ # Start seek bar timer immediately for live updates (regardless of playback status)
+ self._seekbar_timer_id = invoke_repeater(1000, self._move_seekbar)
+
+ # Connect to realize signal to initialize seek bar properly
+ self.seek_bar.connect("realize", self._on_seek_bar_realized)
+
def destroy(self):
"""Clean up all resources when the widget is destroyed."""
# Set exit flag FIRST to stop any running timers
@@ -1006,24 +953,42 @@ def destroy(self):
# Cancel any ongoing downloads immediately
self._download_cancelled = True
- # Cancel seek bar timer
+ # Cancel seek bar timer โ critical: prevents the repeater from holding
+ # a reference to this widget after destruction
if self._seekbar_timer_id:
try:
- from gi.repository import GLib
-
GLib.source_remove(self._seekbar_timer_id)
- except:
+ except Exception:
pass
self._seekbar_timer_id = None
# Wait for download thread to finish (with timeout)
if self.current_download_thread and self.current_download_thread.is_alive():
try:
- self.current_download_thread.join(timeout=1.0) # 1 second timeout
+ self.current_download_thread.join(timeout=1.0)
except Exception:
pass
+ self.current_download_thread = None
- # Disconnect all signal connections
+ # Unbind all GObject property bindings โ these keep the player alive
+ # if not explicitly unbound
+ for binding in self._property_bindings:
+ try:
+ binding.unbind()
+ except Exception:
+ pass
+ self._property_bindings.clear()
+
+ # Disconnect seek_bar widget signals
+ for sig_id in self._seekbar_signal_ids:
+ try:
+ if hasattr(self, "seek_bar") and self.seek_bar:
+ self.seek_bar.disconnect(sig_id)
+ except Exception:
+ pass
+ self._seekbar_signal_ids.clear()
+
+ # Disconnect all player/mpris signal connections
for obj, handler_id in self._signal_connections:
try:
obj.disconnect(handler_id)
@@ -1031,7 +996,7 @@ def destroy(self):
logger.warning(f"Failed to disconnect signal: {e}")
self._signal_connections.clear()
- # Clean up temp files aggressively
+ # Clean up temp artwork files
self._cleanup_temp_files()
# Clear image references
@@ -1041,6 +1006,10 @@ def destroy(self):
except Exception:
pass
+ # Drop strong references to avoid reference cycles
+ self.player = None
+ self.player_stack = None
+
super().destroy()
def __del__(self):
@@ -1116,29 +1085,60 @@ def length_str(self, length):
return f"{minutes}:{seconds:02d}"
def _on_metadata(self, *_):
+ if self.exit or self.player is None:
+ return
self._set_image()
duration = self.player.length
- if duration:
+ if duration is None:
+ duration = 0
+
+ if duration and duration > 0:
self.length_label.set_label(self.length_str(duration))
# Clamp duration to avoid 32-bit integer overflow in the scale widget
max_int32 = 2147483647 # 2^31 - 1
safe_duration = min(max_int32, duration)
- self.seek_bar.set_range(0, safe_duration)
- # Cancel existing timer before starting a new one
+ # Only set range if seek bar is ready
+ if self.seek_bar.get_realized():
+ self.seek_bar.set_range(0, safe_duration)
+ else:
+ self.length_label.set_label("0:00")
+ if self.seek_bar.get_realized():
+ self.seek_bar.set_range(0, 100)
+
+ # Restart timer to ensure it's running with updated metadata
if self._seekbar_timer_id:
try:
- from gi.repository import GLib
-
GLib.source_remove(self._seekbar_timer_id)
- except:
+ except Exception:
pass
self._seekbar_timer_id = None
# Start new timer and store its ID
self._seekbar_timer_id = invoke_repeater(1000, self._move_seekbar)
+ def suspend(self):
+ """Stop timer and other active updates when hidden"""
+ if self._seekbar_timer_id:
+ try:
+ GLib.source_remove(self._seekbar_timer_id)
+ except Exception:
+ pass
+ self._seekbar_timer_id = None
+ logger.debug(
+ f"[PlayerBox] Suspended timer for {self.player.player_name if self.player else 'unknown'}"
+ )
+
+ def resume(self):
+ """Restart timer and updates when shown"""
+ if self.exit or self.player is None:
+ return
+
+ if not self._seekbar_timer_id:
+ self._seekbar_timer_id = invoke_repeater(1000, self._move_seekbar)
+ logger.debug(f"[PlayerBox] Resumed timer for {self.player.player_name}")
+
def _cleanup_temp_files(self):
"""Clean up temporary artwork files."""
for temp_file in self.temp_artwork_files:
@@ -1155,23 +1155,27 @@ def _on_player_exit(self, _, value):
self.destroy()
def _on_player_next(self, *_):
- self.player.next()
+ if self.player is not None:
+ self.player.next()
def _on_player_prev(self, *_):
- self.player.previous()
+ if self.player is not None:
+ self.player.previous()
def _on_playback_change(self, player, status):
- status = player.get_property("playback-status")
+ if self.exit or self.player is None:
+ return
+ status = player.playback_status
- if status == "paused":
- self.play_pause_icon.set_from_file(
- get_relative_path("../../config/assets/icons/player/play.svg")
- )
+ if status == "Paused":
+ self.play_pause_icon.dynamic_file("player/play.svg")
+ # Keep timer running for live updates even when paused
- if status == "playing":
- self.play_pause_icon.set_from_file(
- get_relative_path("../../config/assets/icons/player/Pause.svg")
- )
+ if status == "Playing":
+ self.play_pause_icon.dynamic_file("player/Pause.svg")
+ # Ensure timer is running for live updates
+ if not self._seekbar_timer_id:
+ self._seekbar_timer_id = invoke_repeater(1000, self._move_seekbar)
def _update_image(self, image_path):
if image_path and os.path.isfile(image_path):
@@ -1245,10 +1249,13 @@ def _download_and_set_artwork(self, arturl):
parsed = urllib.parse.urlparse(arturl)
suffix = os.path.splitext(parsed.path)[1] or ".png"
- with urllib.request.urlopen(arturl, timeout=10) as response: # Add timeout
- if self._download_cancelled:
- return
- data = response.read()
+ response = httpx.get(arturl, timeout=10, follow_redirects=True)
+ if self._download_cancelled:
+ return
+ if response.status_code == 200:
+ data = response.content
+ else:
+ return
# Check one more time if cancelled
if self._download_cancelled:
@@ -1284,27 +1291,49 @@ def _download_and_set_artwork(self, arturl):
return None
def _move_seekbar(self, *_):
- if self.player is None or self.exit or self._user_seeking:
- return True # Continue the timer but don't update while user is seeking
+ # Stop the timer if the widget is destroyed or exiting
+ if self.exit:
+ self._seekbar_timer_id = None
+ return False # Remove the GLib source
+
+ if self.player is None:
+ self._seekbar_timer_id = None
+ return False # Player gone, stop timer
+
+ if self._user_seeking:
+ return True # Continue timer but skip update while user drags
# Additional safety checks to prevent GTK errors
if not hasattr(self, "seek_bar") or self.seek_bar is None:
+ self._seekbar_timer_id = None
return False # Stop the timer
try:
+ # Check if seek bar is realized and has a valid adjustment
+ if not self.seek_bar.get_realized():
+ return True # Continue timer, widget not ready yet
+
position = self.player.position
+ if position is None:
+ position = 0
+
self.position_label.set_label(self.length_str(position))
- # Only update seek bar if user is not currently seeking
- if not self._user_seeking:
- # Clamp position to avoid 32-bit integer overflow
- max_int32 = 2147483647 # 2^31 - 1
- safe_position = min(max_int32, position) if position else 0
+ # Clamp position to avoid 32-bit integer overflow
+ max_int32 = 2147483647 # 2^31 - 1
+ safe_position = min(max_int32, position) if position else 0
+
+ # Only set value if seek bar has a valid range
+ if (
+ self.seek_bar.get_adjustment()
+ and self.seek_bar.get_adjustment().get_upper() > 0
+ ):
self.seek_bar.set_value(safe_position)
except Exception as e:
# If any error occurs (widget destroyed, etc), stop the timer
logger.warning(f"Seek bar update failed, stopping timer: {e}")
+ self._seekbar_timer_id = None
return False
return True
@@ -1319,6 +1348,24 @@ def _on_seek_end(self, widget, event):
self._user_seeking = False
return False
+ def _on_seek_bar_realized(self, widget):
+ """Initialize seek bar when it's realized"""
+ try:
+ if self.exit or self.player is None:
+ return
+ duration = self.player.length
+ if duration is None:
+ duration = 0
+
+ if duration and duration > 0:
+ max_int32 = 2147483647 # 2^31 - 1
+ safe_duration = min(max_int32, duration)
+ self.seek_bar.set_range(0, safe_duration)
+ else:
+ self.seek_bar.set_range(0, 100)
+ except Exception as e:
+ logger.warning(f"Failed to initialize seek bar: {e}")
+
def _on_scale_value_changed(self, scale: Scale):
"""Handle seek bar value changes - only when user is seeking"""
if self.player and not self.exit and self._user_seeking:
diff --git a/modules/controlcenter/main.py b/src/window/controlcenter/main.py
similarity index 61%
rename from modules/controlcenter/main.py
rename to src/window/controlcenter/main.py
index cfa00bcd..4012b5a2 100644
--- a/modules/controlcenter/main.py
+++ b/src/window/controlcenter/main.py
@@ -1,32 +1,26 @@
import subprocess
-from fabric.utils import idle_add
-from fabric.utils.helpers import (
- get_relative_path,
-)
+from fabric.utils import Gdk, GLib, get_relative_path, idle_add, logger
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.centerbox import CenterBox
from fabric.widgets.label import Label
from fabric.widgets.scale import Scale
-from fabric.widgets.svg import Svg
-from gi.repository import Gdk, GLib
-from loguru import logger
+from fabric.widgets.wayland import WaylandWindow as Window
-from modules.controlcenter.bluetooth import (
- BluetoothConnections,
- set_bluetooth_enabled_with_fallback,
-)
-from modules.controlcenter.expanded_player import EmbeddedExpandedPlayer
-from modules.controlcenter.nightlight import create_night_light_widget
-from modules.controlcenter.per_app_volume import PerAppVolumeControl
-from modules.controlcenter.player import PlayerBoxStack
-from modules.controlcenter.wifi import WifiConnections
from services.brightness import Brightness
-from services.mpris import MprisPlayerManager
from services.network import NetworkClient
from utils.roam import audio_service, modus_service
-from widgets.wayland import WaylandWindow as Window
+from utils.utils import svg_file
+from window.controlcenter.bluetooth import (
+ BluetoothConnections,
+ set_bluetooth_enabled_with_fallback,
+)
+from window.controlcenter.expanded_player import EmbeddedExpandedPlayer
+from window.controlcenter.nightlight import create_night_light_widget
+from window.controlcenter.per_app_volume import PerAppVolumeControl
+from window.controlcenter.player import PlayerBoxStack, get_shared_mpris_manager
+from window.controlcenter.wifi import WifiConnections
brightness_service = Brightness.get_initial()
@@ -53,10 +47,10 @@ def __init__(self, **kwargs):
self.caffeine_mode = False
self._caffeine_process = None
- # Lazy loading flags
- self._music_initialized = False
+ # Loading flags - music and expanded player are no longer lazy loaded
self._per_app_volume_initialized = False
- self._expanded_player_initialized = False
+ self._signals_connected = False # Track if signals are connected
+ self._resources_initialized = False # Track if resources are initialized
# Store references for cleanup - initialize all as None
self._signal_connections = []
@@ -65,33 +59,22 @@ def __init__(self, **kwargs):
self._expanded_player_widget = None
self._mpris_manager = None # Shared MPRIS manager instance
- # Initialize network service for WiFi toggle
- self.network_service = NetworkClient()
+ # Initialize network service for WiFi toggle - lazy load
+ self.network_service = None
self.wifi_service = None
- # Initialize flight mode and caffeine states
- self._check_initial_states()
-
- # Wait for network service to be ready
+ # Initialize flight mode and caffeine states - lazy load
+ self.caffeine_mode = False
+ self.flight_mode = False
+ # Add keybinding immediately (this is fast)
self.add_keybinding("Escape", self.hide_controlcenter)
volume = 100
- wlan = modus_service.sc("wlan-changed", self.wlan_changed)
- bluetooth = modus_service.sc("bluetooth-changed", self.bluetooth_changed)
- music = modus_service.sc("music-changed", self.audio_changed)
-
- self.network_service.connect("wifi-device-added", self.on_network_ready)
- # Store signal connections for cleanup
- self._signal_connections.extend(
- [
- audio_service.connect("changed", self.audio_changed),
- audio_service.connect("changed", self.volume_changed),
- modus_service.connect("dont-disturb-changed", self.dnd_changed),
- ]
- )
+ # Get initial values and connect signals - store connection IDs for cleanup
+ wlan = modus_service.wlan if modus_service.wlan else "No Connection"
+ bluetooth = modus_service.bluetooth if modus_service.bluetooth else "Off"
- print(wlan)
self.wlan_label = Label(
label=wlan,
name="wifi-widget-label",
@@ -124,8 +107,6 @@ def __init__(self, **kwargs):
size=30,
h_expand=True,
)
- self.volume_scale.connect("change-value", self.set_volume)
- self.volume_scale.connect("scroll-event", self.on_volume_scroll)
current_brightness = brightness_service.screen_brightness
brightness_percentage = (
@@ -146,43 +127,29 @@ def __init__(self, **kwargs):
# Only connect brightness controls if brightness service is available
if brightness_service.max_screen > 0:
- self.brightness_scale.connect("change-value", self.set_brightness)
- self.brightness_scale.connect("scroll-event", self.on_brightness_scroll)
- self._signal_connections.append(
- brightness_service.connect("screen", self.brightness_changed)
- )
+ pass
else:
# Disable brightness scale if no backlight device available
self.brightness_scale.set_sensitive(False)
- # Create placeholder music widget - lazy load content when needed
- self.music_widget = Box(
- name="music-widget",
- h_align="start",
- children=[], # Empty initially
- )
+ self._mpris_manager = get_shared_mpris_manager()
+ self.music_widget = PlayerBoxStack(self._mpris_manager, control_center=self)
self.has_bluetooth_open = False
self.has_wifi_open = False
self.has_per_app_volume_open = False
self.has_expanded_player_open = False
- self.bluetooth_svg = Svg(
- name="bluetooth-icon",
- svg_file=get_relative_path(
- "../../config/assets/icons/applets/bluetooth.svg"
+ self.bluetooth_svg = svg_file(
+ (
+ "applets/bluetooth.svg"
if bluetooth != "disabled"
- else "../../config/assets/icons/applets/bluetooth-off.svg"
+ else "applets/bluetooth-off.svg"
),
size=42,
)
- self.wifi_svg = Svg(
- name="wifi-icon",
- svg_file=get_relative_path(
- "../../config/assets/icons/applets/wifi.svg"
- if wlan != "No Connection"
- else "../../config/assets/icons/applets/wifi-off.svg"
- ),
+ self.wifi_svg = svg_file(
+ "applets/wifi.svg" if wlan != "No Connection" else "applets/wifi-off.svg",
size=42,
)
@@ -244,13 +211,8 @@ def __init__(self, **kwargs):
],
)
- self.focus_icon = Svg(
- name="focus-icon",
- svg_file=get_relative_path(
- "../../config/assets/icons/applets/dnd.svg"
- if self.focus_mode
- else "../../config/assets/icons/applets/dnd-off.svg"
- ),
+ self.focus_icon = svg_file(
+ "applets/dnd.svg" if self.focus_mode else "applets/dnd-off.svg",
size=42,
)
@@ -287,13 +249,8 @@ def __init__(self, **kwargs):
on_clicked=self.set_dont_disturb,
)
- self.flight_icon = Svg(
- name="flight-icon",
- svg_file=get_relative_path(
- "../../config/assets/icons/applets/flight-on.svg"
- if self.flight_mode
- else "../../config/assets/icons/applets/flight-off.svg"
- ),
+ self.flight_icon = svg_file(
+ "applets/flight-on.svg" if self.flight_mode else "applets/flight-off.svg",
size=42,
)
@@ -318,12 +275,11 @@ def __init__(self, **kwargs):
on_clicked=self.toggle_flight_mode,
)
- self.caffeine_icon = Svg(
- name="caffeine-icon",
- svg_file=get_relative_path(
- "../../config/assets/icons/applets/caffeine-on.svg"
+ self.caffeine_icon = svg_file(
+ (
+ "applets/caffeine-on.svg"
if self.caffeine_mode
- else "../../config/assets/icons/applets/caffeine-off.svg"
+ else "applets/caffeine-off.svg"
),
size=42,
)
@@ -350,16 +306,6 @@ def __init__(self, **kwargs):
style_classes="title-widget",
h_align="center",
),
- # Box(
- # orientation="vertical",
- # v_expand=True,
- # v_align="center",
- # h_align="center",
- # h_expand=True,
- # children=[
- # # self.caffeine_status_label,
- # ],
- # ),
],
),
on_clicked=self.toggle_caffeine,
@@ -368,7 +314,7 @@ def __init__(self, **kwargs):
# Create night light widget
self.night_light_widget = create_night_light_widget(self)
- # Create main widgets directly without XML
+ # Create main widgets directly without XML - defer heavy operations
self.widgets = Box(
orientation="vertical",
h_expand=True,
@@ -476,12 +422,10 @@ def __init__(self, **kwargs):
Button(
name="per-app-volume-button",
size=(36, 36),
- child=Svg(
- svg_file=get_relative_path(
- "../../config/assets/icons/player/audio-switcher.svg"
- ),
+ child=svg_file(
+ "player/audio-switcher.svg",
name="per-app-volume-icon",
- sidze=32,
+ size=32,
),
on_clicked=self.open_per_app_volume,
),
@@ -501,18 +445,32 @@ def __init__(self, **kwargs):
self.has_bluetooth_open = False
self.has_wifi_open = False
- # Lazy-loaded widgets - create placeholders
+ # Create expanded player widgets immediately (no lazy loading)
+ self._expanded_player_widget = EmbeddedExpandedPlayer(self)
+ self.expanded_player_widgets = Box(
+ orientation="vertical",
+ h_expand=True,
+ name="control-center-widgets",
+ children=[
+ self._expanded_player_widget,
+ ],
+ )
+
+ # Lazy-loaded widgets - create placeholders for others
self.bluetooth_widgets = None
self.wifi_widgets = None
self.per_app_volume_widgets = None
- self.expanded_player_widgets = None
# Create main content boxes
self.center_box = CenterBox(start_children=[self.widgets])
self.bluetooth_center_box = None
self.wifi_center_box = None
self.per_app_volume_center_box = None
- self.expanded_player_center_box = None
+ # Create expanded player center box immediately
+ self.expanded_player_center_box = CenterBox(
+ start_children=[self.expanded_player_widgets]
+ )
+ self.expanded_player_center_box.set_size_request(300, -1)
# Create revealers for crossfade transitions
@@ -527,70 +485,142 @@ def __init__(self, **kwargs):
self.connect("notify::visible", self._on_visibility_changed)
def _on_visibility_changed(self, widget, param):
- """Handle visibility changes for memory management"""
+ """Handle visibility changes for resource management"""
if not self.get_visible():
- self._cleanup_when_hidden()
+ # Suspend player updates
+ if hasattr(self, "music_widget") and hasattr(self.music_widget, "suspend"):
+ self.music_widget.suspend()
+ if hasattr(self, "_expanded_player_widget") and hasattr(
+ self._expanded_player_widget, "suspend"
+ ):
+ self._expanded_player_widget.suspend()
+
+ # Just disconnect signals and reset state flags - don't destroy widgets
+ self._disconnect_signals_when_hidden()
+ else:
+ self._initialize_resources()
+
+ # Resume player updates
+ if hasattr(self, "music_widget") and hasattr(self.music_widget, "resume"):
+ self.music_widget.resume()
+ if hasattr(self, "_expanded_player_widget") and hasattr(
+ self._expanded_player_widget, "resume"
+ ):
+ self._expanded_player_widget.resume()
+
+ def _initialize_resources(self):
+ """Initialize resources and connect signals when the control center becomes visible."""
+ if self._resources_initialized:
+ return
+
+ try:
+ logger.debug("Initializing control center resources...")
+
+ # Initialize network service lazily
+ if self.network_service is None:
+ self.network_service = NetworkClient()
+ # Connect network signal
+ self.network_service.connect("wifi-device-added", self.on_network_ready)
+
+ # Check initial states lazily (only when needed)
+ self._check_initial_states()
+
+ # Store signal connections as (obj, handler_id) tuples for proper cleanup
+ self._signal_connections.extend(
+ [
+ (
+ audio_service,
+ audio_service.connect("changed", self.audio_changed),
+ ),
+ (
+ audio_service,
+ audio_service.connect("changed", self.volume_changed),
+ ),
+ (
+ modus_service,
+ modus_service.connect("wlan-changed", self.wlan_changed),
+ ),
+ (
+ modus_service,
+ modus_service.connect(
+ "bluetooth-changed", self.bluetooth_changed
+ ),
+ ),
+ (
+ modus_service,
+ modus_service.connect("dont-disturb-changed", self.dnd_changed),
+ ),
+ ]
+ )
+
+ # Connect brightness controls if brightness service is available
+ if brightness_service.max_screen > 0:
+ self.brightness_scale.connect("change-value", self.set_brightness)
+ self.brightness_scale.connect("scroll-event", self.on_brightness_scroll)
+ self._signal_connections.append(
+ (
+ brightness_service,
+ brightness_service.connect("screen", self.brightness_changed),
+ )
+ )
- def _cleanup_when_hidden(self):
- """Aggressively clean up resources when widget is hidden to reduce memory usage"""
+ # Connect volume scale signals
+ self.volume_scale.connect("change-value", self.set_volume)
+ self.volume_scale.connect("scroll-event", self.on_volume_scroll)
+
+ # Mark signals as connected
+ self._signals_connected = True
+ self._resources_initialized = True
+
+ logger.debug("Control center resources initialized successfully")
+
+ except Exception as e:
+ logger.error(f"Failed to initialize control center resources: {e}")
+ # Reset flags on failure
+ self._signals_connected = False
+ self._resources_initialized = False
+
+ def _disconnect_signals_when_hidden(self):
+ """Disconnect signals when hidden to reduce resource usage, but keep widgets intact"""
try:
- # Clean up music widget content if it exists
- if self._music_widget_content:
- # Remove from the parent container
- current_children = list(self.music_widget.children)
- if self._music_widget_content in current_children:
- current_children.remove(self._music_widget_content)
- self.music_widget.children = current_children
-
- # Trigger periodic cleanup before destroying
- if hasattr(self._music_widget_content, "_periodic_cleanup"):
- self._music_widget_content._periodic_cleanup()
-
- # Properly destroy the music widget content
+ # Actually disconnect the signals we tracked
+ for obj, handler_id in self._signal_connections:
try:
- self._music_widget_content.destroy()
+ obj.disconnect(handler_id)
except Exception as e:
- logger.warning(f"Failed to destroy music widget content: {e}")
- self._music_widget_content = None
+ logger.warning(f"Failed to disconnect signal: {e}")
+ self._signal_connections.clear()
- # Clean up shared MPRIS manager when hidden to free memory
- if self._mpris_manager:
+ # Disconnect direct scale signals (connected in _initialize_resources)
+ if hasattr(self, "volume_scale"):
try:
- self._mpris_manager.destroy()
- except Exception as e:
- logger.warning(
- f"Failed to destroy MPRIS manager during cleanup: {e}"
- )
- self._mpris_manager = None
+ self.volume_scale.disconnect_by_func(self.set_volume)
+ self.volume_scale.disconnect_by_func(self.on_volume_scroll)
+ except Exception:
+ pass
+ if hasattr(self, "brightness_scale"):
+ try:
+ self.brightness_scale.disconnect_by_func(self.set_brightness)
+ self.brightness_scale.disconnect_by_func(self.on_brightness_scroll)
+ except Exception:
+ pass
- # Reset initialization flags to force recreation next time
- self._music_initialized = False
+ self._signals_connected = False
+ self._resources_initialized = False
- # Force garbage collection
- import gc
+ # Reset state flags
+ self.has_bluetooth_open = False
+ self.has_wifi_open = False
+ self.has_per_app_volume_open = False
+ self.has_expanded_player_open = False
- gc.collect()
+ # Reset current view
+ self.current_view = "main"
- logger.debug("Control center aggressive cleanup completed")
+ logger.debug("Control center signals disconnected while hidden")
except Exception as e:
- logger.warning(f"Control center cleanup failed: {e}")
-
- def _ensure_music_widget(self):
- """Lazy load music widget content - reuse MPRIS manager"""
- if not self._music_initialized:
- # Create shared MPRIS manager if it doesn't exist
- if self._mpris_manager is None:
- self._mpris_manager = MprisPlayerManager()
-
- self._music_widget_content = PlayerBoxStack(
- self._mpris_manager, control_center=self
- )
- # Add to the music widget's children list
- current_children = list(self.music_widget.children)
- current_children.append(self._music_widget_content)
- self.music_widget.children = current_children
- self._music_initialized = True
+ logger.warning(f"Control center signal disconnection failed: {e}")
def _ensure_bluetooth_widgets(self):
"""Lazy load bluetooth widgets"""
@@ -599,24 +629,7 @@ def _ensure_bluetooth_widgets(self):
orientation="vertical",
h_expand=True,
v_expand=True,
- children=[
- self.bluetooth_man,
- # Box(
- # orientation="horizontal",
- # name="top-widget",
- # h_expand=True,
- # children=[
- # Box(
- # orientation="vertical",
- # name="wb-widget",
- # style_classes="menu",
- # spacing=5,
- # children=[
- # ],
- # ),
- # ],
- # ),
- ],
+ children=[self.bluetooth_man],
)
self.bluetooth_center_box = Box(
h_expand=True, v_expand=True, children=[self.bluetooth_widgets]
@@ -658,33 +671,18 @@ def _ensure_per_app_volume_widgets(self):
)
self.per_app_volume_center_box.set_size_request(300, -1)
- def _ensure_expanded_player_widgets(self):
- """Lazy load expanded player widgets"""
- if self.expanded_player_widgets is None:
- if self._expanded_player_widget is None:
- self._expanded_player_widget = EmbeddedExpandedPlayer(self)
-
- self.expanded_player_widgets = Box(
- orientation="vertical",
- h_expand=True,
- name="control-center-widgets",
- children=[
- self._expanded_player_widget,
- ],
- )
- self.expanded_player_center_box = CenterBox(
- start_children=[self.expanded_player_widgets]
- )
- self.expanded_player_center_box.set_size_request(300, -1)
-
def _check_initial_states(self):
- # Check if caffeine is already running
+ # Check if caffeine is already running - use faster method
try:
+ # Use faster check with timeout
result = subprocess.run(
- ["pgrep", "-f", "modus-inhibit"], capture_output=True, text=True
+ ["pgrep", "-f", "modus-inhibit"],
+ capture_output=True,
+ text=True,
+ timeout=0.5, # Add timeout to prevent hanging
)
self.caffeine_mode = bool(result.stdout.strip())
- except Exception:
+ except (subprocess.TimeoutExpired, Exception):
self.caffeine_mode = False
# Flight mode starts as False (normal mode)
@@ -699,12 +697,8 @@ def _update_initial_labels(self):
def set_dont_disturb(self, *_):
self.focus_mode = not self.focus_mode
modus_service.dont_disturb = self.focus_mode
- self.focus_icon.set_from_file(
- get_relative_path(
- "../../config/assets/icons/applets/dnd.svg"
- if self.focus_mode
- else "../../config/assets/icons/applets/dnd-off.svg"
- )
+ self.focus_icon.dynamic_file(
+ "applets/dnd.svg" if self.focus_mode else "applets/dnd-off.svg"
)
self.focus_status_label.set_label("On" if self.focus_mode else "Off")
@@ -730,12 +724,10 @@ def toggle_flight_mode(self, *_):
self.bluetooth_man.client.set_enabled(True)
# Update icon
- self.flight_icon.set_from_file(
- get_relative_path(
- "../../config/assets/icons/applets/flight-on.svg"
- if self.flight_mode
- else "../../config/assets/icons/applets/flight-off.svg"
- )
+ self.flight_icon.dynamic_file(
+ "applets/flight-on.svg"
+ if self.flight_mode
+ else "applets/flight-off.svg"
)
except Exception as e:
@@ -744,30 +736,26 @@ def toggle_flight_mode(self, *_):
def toggle_caffeine(self, *_):
try:
if self.caffeine_mode:
- # Turn off caffeine
inhibit_script = get_relative_path("../../utils/inhibit.py")
subprocess.run(["python3", inhibit_script, "off"], check=False)
self.caffeine_mode = False
if self._caffeine_process:
try:
self._caffeine_process.terminate()
- except:
+ except Exception:
pass
self._caffeine_process = None
else:
- # Turn on caffeine
inhibit_script = get_relative_path("../../utils/inhibit.py")
self._caffeine_process = subprocess.Popen(
["python3", inhibit_script, "on"], start_new_session=True
)
self.caffeine_mode = True
- self.caffeine_icon.set_from_file(
- get_relative_path(
- "../../config/assets/icons/applets/caffeine-on.svg"
- if self.caffeine_mode
- else "../../config/assets/icons/applets/caffeine-off.svg"
- )
+ self.caffeine_icon.dynamic_file(
+ "applets/caffeine-on.svg"
+ if self.caffeine_mode
+ else "applets/caffeine-off.svg"
)
self.caffeine_status_label.set_label("On" if self.caffeine_mode else "Off")
@@ -775,18 +763,22 @@ def toggle_caffeine(self, *_):
logger.warning(f"Failed to toggle caffeine: {e}")
def set_volume(self, _, __, volume):
+ if not self._signals_connected:
+ return
self._updating_volume = True
audio_service.speaker.volume = round(volume)
self._updating_volume = False
def set_brightness(self, _, __, brightness):
+ if not self._signals_connected:
+ return
self._updating_brightness = True
brightness_value = int((brightness / 100) * brightness_service.max_screen)
brightness_service.screen_brightness = brightness_value
self._updating_brightness = False
def brightness_changed(self, _, brightness_value):
- if self._updating_brightness:
+ if not self._signals_connected or self._updating_brightness:
return
if brightness_service.max_screen > 0:
@@ -799,6 +791,8 @@ def brightness_changed(self, _, brightness_value):
)
def on_volume_scroll(self, widget, event):
+ if not self._signals_connected:
+ return False
current_value = self.volume_scale.get_value()
scroll_step = 5
if event.direction == Gdk.ScrollDirection.UP:
@@ -812,6 +806,8 @@ def on_volume_scroll(self, widget, event):
return True
def on_brightness_scroll(self, widget, event):
+ if not self._signals_connected:
+ return False
current_value = self.brightness_scale.get_value()
scroll_step = 5
if event.direction == Gdk.ScrollDirection.UP:
@@ -825,8 +821,9 @@ def on_brightness_scroll(self, widget, event):
return True
def toggle_bluetooth(self, *_):
+ if not self._resources_initialized:
+ return
try:
- # Access the bluetooth client from the bluetooth manager
if hasattr(self, "bluetooth_man") and hasattr(self.bluetooth_man, "client"):
current_state = self.bluetooth_man.client.enabled
set_bluetooth_enabled_with_fallback(
@@ -838,32 +835,26 @@ def toggle_bluetooth(self, *_):
logger.warning(f"Failed to toggle bluetooth: {e}")
def on_network_ready(self, *_):
- """Called when network service is ready"""
self.wifi_service = self.network_service.wifi_device
if self.wifi_service:
- # Connect to WiFi state changes to update icon
self.wifi_service.connect("notify::wireless-enabled", self.update_wifi_icon)
def update_wifi_icon(self, *_):
- """Update WiFi icon based on current state"""
try:
if self.wifi_service and hasattr(self, "wifi_svg"):
is_enabled = self.wifi_service.wireless_enabled
- icon_file = (
- "../../config/assets/icons/applets/wifi.svg"
- if is_enabled
- else "../../config/assets/icons/applets/wifi-off.svg"
+ self.wifi_svg.dynamic_file(
+ "applets/wifi.svg" if is_enabled else "applets/wifi-off.svg"
)
- self.wifi_svg.set_from_file(get_relative_path(icon_file))
except Exception as e:
logger.warning(f"Failed to update WiFi icon: {e}")
def toggle_wifi(self, *_):
- """Toggle wifi on/off"""
+ if not self._resources_initialized:
+ return
try:
if self.wifi_service:
self.wifi_service.toggle_wifi()
- # Update icon immediately after toggle
GLib.timeout_add(100, self.update_wifi_icon)
else:
logger.warning("WiFi device not available for toggling")
@@ -874,11 +865,15 @@ def set_children(self, children):
self.children = children
def open_bluetooth(self, *_):
+ if not self._resources_initialized:
+ return
self._ensure_bluetooth_widgets()
idle_add(lambda *_: self.set_children(self.bluetooth_center_box))
self.has_bluetooth_open = True
def open_wifi(self, *_):
+ if not self._resources_initialized:
+ return
self._ensure_wifi_widgets()
idle_add(lambda *_: self.set_children(self.wifi_center_box))
self.has_wifi_open = True
@@ -898,6 +893,8 @@ def close_wifi(self, *_):
self.has_wifi_open = False
def open_per_app_volume(self, *_):
+ if not self._resources_initialized:
+ return
self._ensure_per_app_volume_widgets()
if self.current_view == "expanded_player":
# If coming from expanded player, use crossfade
@@ -923,33 +920,30 @@ def close_per_app_volume(self, *_):
self.has_per_app_volume_open = False
def open_expanded_player(self, *_):
- self._ensure_expanded_player_widgets()
+ if not self._resources_initialized:
+ return
self._crossfade_to_view("expanded_player")
self.has_expanded_player_open = True
- # Refresh the player when opening
if self._expanded_player_widget:
self._expanded_player_widget.refresh()
def close_expanded_player(self, *_):
+ # Just hide the expanded player widget, don't destroy it
self._crossfade_to_view("main")
self.has_expanded_player_open = False
def _crossfade_to_view(self, view_name):
"""Handle transitions between views"""
if view_name == "expanded_player":
- # Show expanded player
- self._ensure_expanded_player_widgets()
idle_add(lambda *_: self.set_children(self.expanded_player_center_box))
self.current_view = "expanded_player"
elif view_name == "main":
- # Show main view
idle_add(lambda *_: self.set_children(self.center_box))
self.current_view = "main"
def _set_mousecapture(self, visible: bool):
if visible:
- # Lazy load music widget when becoming visible
- self._ensure_music_widget()
+ GLib.idle_add(self._initialize_resources)
self.set_visible(visible)
if not visible:
@@ -962,7 +956,7 @@ def volume_changed(
self,
_,
):
- if self._updating_volume:
+ if not self._signals_connected or self._updating_volume:
return
GLib.idle_add(
@@ -970,12 +964,10 @@ def volume_changed(
)
def wlan_changed(self, _, wlan):
- self.wifi_svg.set_from_file(
- get_relative_path(
- "../../config/assets/icons/applets/wifi.svg"
- if wlan != "No Connection"
- else "../../config/assets/icons/applets/wifi-off.svg"
- )
+ if not self._signals_connected:
+ return
+ self.wifi_svg.dynamic_file(
+ "applets/wifi.svg" if wlan != "No Connection" else "applets/wifi-off.svg"
)
if wlan != "No Connection":
if wlan.startswith("connected:"):
@@ -995,12 +987,12 @@ def wlan_changed(self, _, wlan):
GLib.idle_add(lambda: self.wlan_label.set_property("label", wlan))
def bluetooth_changed(self, _, bluetooth):
- self.bluetooth_svg.set_from_file(
- get_relative_path(
- "../../config/assets/icons/applets/bluetooth.svg"
- if bluetooth != "disabled"
- else "../../config/assets/icons/applets/bluetooth-off.svg"
- )
+ if not self._signals_connected:
+ return
+ self.bluetooth_svg.dynamic_file(
+ "applets/bluetooth.svg"
+ if bluetooth != "disabled"
+ else "applets/bluetooth-off.svg"
)
if bluetooth != "disabled":
if bluetooth.startswith("connected:"):
@@ -1018,61 +1010,219 @@ def bluetooth_changed(self, _, bluetooth):
GLib.idle_add(lambda: self.bluetooth_label.set_label("Off"))
def audio_changed(self, *_):
+ if not self._signals_connected:
+ return
pass
def dnd_changed(self, _, dnd_state):
+ if not self._signals_connected:
+ return
self.focus_mode = dnd_state
- self.focus_icon.set_from_file(
- get_relative_path(
- "../../config/assets/icons/applets/dnd.svg"
- if self.focus_mode
- else "../../config/assets/icons/applets/dnd-off.svg"
- )
+ self.focus_icon.dynamic_file(
+ "applets/dnd.svg" if self.focus_mode else "applets/dnd-off.svg"
)
self.focus_status_label.set_label("On" if self.focus_mode else "Off")
def _init_mousecapture(self, mousecapture):
self._mousecapture_parent = mousecapture
- def hide_controlcenter(self, *_):
- self._mousecapture_parent.toggle_mousecapture()
- self.set_visible(False)
+ def _disconnect_all_signals(self):
+ """Disconnect all signal connections to prevent memory leaks"""
+ try:
+ # _signal_connections stores (obj, handler_id) tuples
+ for obj, handler_id in self._signal_connections:
+ try:
+ obj.disconnect(handler_id)
+ except Exception as e:
+ logger.warning(f"Failed to disconnect signal: {e}")
+ self._signal_connections.clear()
- def destroy(self):
- """Clean up resources when widget is destroyed"""
- # Disconnect all signal connections
- for connection in self._signal_connections:
- try:
- connection.disconnect()
- except:
- pass
+ # Disconnect scale widget signals (connected directly, not tracked)
+ if hasattr(self, "volume_scale") and self.volume_scale:
+ try:
+ self.volume_scale.disconnect_by_func(self.set_volume)
+ except Exception:
+ pass
+ try:
+ self.volume_scale.disconnect_by_func(self.on_volume_scroll)
+ except Exception:
+ pass
- # Clean up caffeine process
- if self._caffeine_process:
+ if hasattr(self, "brightness_scale") and self.brightness_scale:
+ try:
+ self.brightness_scale.disconnect_by_func(self.set_brightness)
+ except Exception:
+ pass
+ try:
+ self.brightness_scale.disconnect_by_func(self.on_brightness_scroll)
+ except Exception:
+ pass
+
+ # Disconnect the visibility change signal on self
try:
- self._caffeine_process.terminate()
- except:
+ self.disconnect_by_func(self._on_visibility_changed)
+ except Exception:
pass
- self._caffeine_process = None
-
- # Clean up heavy components
- if hasattr(self, "wifi_man") and self.wifi_man:
- self.wifi_man.destroy()
- if hasattr(self, "bluetooth_man") and self.bluetooth_man:
- self.bluetooth_man.destroy()
- if self._music_widget_content:
- self._music_widget_content.destroy()
- if self._per_app_volume_widget:
- self._per_app_volume_widget.destroy()
- if self._expanded_player_widget:
- self._expanded_player_widget.destroy()
- # Clean up shared MPRIS manager
- if self._mpris_manager:
- try:
- self._mpris_manager.destroy()
- except Exception as e:
- logger.warning(f"Failed to destroy MPRIS manager: {e}")
- self._mpris_manager = None
+ self._signals_connected = False
+ logger.debug("All signals disconnected successfully")
+
+ except Exception as e:
+ logger.warning(f"Signal disconnection failed: {e}")
- super().destroy()
+ def _cleanup_managers(self):
+ """Clean up all manager instances"""
+ try:
+ if hasattr(self, "wifi_man") and self.wifi_man:
+ try:
+ self.wifi_man.destroy()
+ except Exception as e:
+ logger.warning(f"Failed to destroy WiFi manager: {e}")
+ self.wifi_man = None
+
+ if hasattr(self, "bluetooth_man") and self.bluetooth_man:
+ try:
+ self.bluetooth_man.destroy()
+ except Exception as e:
+ logger.warning(f"Failed to destroy Bluetooth manager: {e}")
+ self.bluetooth_man = None
+
+ if hasattr(self, "network_service") and self.network_service:
+ try:
+ self.network_service.destroy()
+ except Exception as e:
+ logger.warning(f"Failed to destroy network service: {e}")
+ self.network_service = None
+
+ if hasattr(self, "wifi_service") and self.wifi_service:
+ try:
+ self.wifi_service.disconnect_by_func(self.update_wifi_icon)
+ except Exception as e:
+ logger.warning(f"Failed to disconnect WiFi service: {e}")
+ self.wifi_service = None
+
+ self._resources_initialized = False
+
+ logger.debug("All managers cleaned up successfully")
+
+ except Exception as e:
+ logger.warning(f"Manager cleanup failed: {e}")
+
+ def _cleanup_widgets(self):
+ try:
+ # Clean up main widgets
+ if hasattr(self, "widgets") and self.widgets:
+ try:
+ self.widgets.destroy()
+ except Exception as e:
+ logger.warning(f"Failed to destroy main widgets: {e}")
+ self.widgets = None
+
+ # Clean up center box
+ if hasattr(self, "center_box") and self.center_box:
+ try:
+ self.center_box.destroy()
+ except Exception as e:
+ logger.warning(f"Failed to destroy center box: {e}")
+ self.center_box = None
+
+ # Clean up individual widgets
+ widget_attrs = [
+ "wlan_widget",
+ "bluetooth_widget",
+ "focus_widget",
+ "flight_widget",
+ "caffeine_widget",
+ "night_light_widget",
+ "volume_scale",
+ "brightness_scale",
+ "wlan_label",
+ "bluetooth_label",
+ "focus_status_label",
+ "caffeine_status_label",
+ "wlan_svg",
+ "bluetooth_svg",
+ "focus_icon",
+ "flight_icon",
+ "caffeine_icon",
+ ]
+
+ for attr in widget_attrs:
+ if hasattr(self, attr) and getattr(self, attr):
+ try:
+ widget = getattr(self, attr)
+ if hasattr(widget, "destroy"):
+ widget.destroy()
+ except Exception as e:
+ logger.warning(f"Failed to destroy {attr}: {e}")
+ setattr(self, attr, None)
+
+ logger.debug("All widgets cleaned up successfully")
+
+ except Exception as e:
+ logger.warning(f"Widget cleanup failed: {e}")
+
+ def _cleanup_processes(self):
+ """Clean up any running processes"""
+ try:
+ # Clean up caffeine process
+ if self._caffeine_process:
+ try:
+ self._caffeine_process.terminate()
+ self._caffeine_process.wait(timeout=1)
+ except Exception as e:
+ logger.warning(f"Failed to terminate caffeine process: {e}")
+ finally:
+ self._caffeine_process = None
+
+ logger.debug("All processes cleaned up successfully")
+
+ except Exception as e:
+ logger.warning(f"Process cleanup failed: {e}")
+
+ def _complete_cleanup(self):
+ """Perform complete cleanup of all resources"""
+ try:
+ logger.debug("Starting complete cleanup...")
+ self._disconnect_all_signals()
+ self._cleanup_managers()
+ self._cleanup_widgets()
+ self._cleanup_processes()
+ self._disconnect_signals_when_hidden()
+
+ # Force garbage collection
+ import gc
+
+ gc.collect()
+
+ logger.debug("Complete cleanup finished successfully")
+
+ except Exception as e:
+ logger.error(f"Complete cleanup failed: {e}")
+
+ def hide_controlcenter(self, *_):
+ try:
+ # Just disconnect signals when hiding, don't destroy widgets
+ self._disconnect_signals_when_hidden()
+
+ # Hide the control center
+ if hasattr(self, "_mousecapture_parent"):
+ self._mousecapture_parent.toggle_mousecapture()
+ self.set_visible(False)
+
+ except Exception as e:
+ logger.error(f"Failed to hide control center: {e}")
+ # Still try to hide even if cleanup fails
+ self.set_visible(False)
+
+ def destroy(self):
+ try:
+ self._complete_cleanup()
+ super().destroy()
+ logger.debug("Control center destroyed successfully")
+ except Exception as e:
+ logger.error(f"Failed to destroy control center: {e}")
+ try:
+ super().destroy()
+ except Exception:
+ pass
diff --git a/modules/controlcenter/nightlight.py b/src/window/controlcenter/nightlight.py
similarity index 85%
rename from modules/controlcenter/nightlight.py
rename to src/window/controlcenter/nightlight.py
index c5414c2e..d1ab0d4b 100644
--- a/modules/controlcenter/nightlight.py
+++ b/src/window/controlcenter/nightlight.py
@@ -1,15 +1,11 @@
-# Standard library imports
import subprocess
-# Fabric imports
-from fabric.utils.helpers import get_relative_path
+from fabric.utils import logger
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.label import Label
-from fabric.widgets.svg import Svg
-# Local imports
-from loguru import logger
+from utils.utils import svg_file
class NightLightControl:
@@ -73,12 +69,11 @@ def create_night_light_widget(control_center):
night_light = NightLightControl()
# Create icon
- night_light_icon = Svg(
- name="nightlight-icon",
- svg_file=get_relative_path(
- "../../config/assets/icons/applets/redshift-status-on.svg"
+ night_light_icon = svg_file(
+ (
+ "applets/redshift-status-on.svg"
if night_light.is_active
- else "../../config/assets/icons/applets/redshift-status-off.svg"
+ else "applets/redshift-status-off.svg"
),
size=42,
)
@@ -97,12 +92,10 @@ def toggle_night_light(*_):
"""Toggle night light and update UI"""
if night_light.toggle():
# Update icon
- night_light_icon.set_from_file(
- get_relative_path(
- "../../config/assets/icons/applets/redshift-status-on.svg"
- if night_light.is_active
- else "../../config/assets/icons/applets/redshift-status-off.svg"
- )
+ night_light_icon.dynamic_file(
+ "applets/redshift-status-on.svg"
+ if night_light.is_active
+ else "applets/redshift-status-off.svg"
)
# Update status label
night_light_status_label.set_label("On" if night_light.is_active else "Off")
@@ -143,4 +136,3 @@ def toggle_night_light(*_):
)
return night_light_widget
-
diff --git a/modules/controlcenter/per_app_volume.py b/src/window/controlcenter/per_app_volume.py
similarity index 92%
rename from modules/controlcenter/per_app_volume.py
rename to src/window/controlcenter/per_app_volume.py
index 08ba18a5..af63e82f 100644
--- a/modules/controlcenter/per_app_volume.py
+++ b/src/window/controlcenter/per_app_volume.py
@@ -1,13 +1,10 @@
-# Standard library imports
-from gi.repository import GLib
-
-# Fabric imports
+from fabric.utils import GLib
from fabric.widgets.box import Box
from fabric.widgets.button import Button
+from fabric.widgets.image import Image
from fabric.widgets.label import Label
from fabric.widgets.scale import Scale
from fabric.widgets.scrolledwindow import ScrolledWindow
-from fabric.widgets.image import Image
from fabric.widgets.separator import Separator
# Local imports
@@ -29,6 +26,8 @@ def __init__(self, control_center, **kwargs):
self._updating_volumes = set()
self._app_widgets = {}
self._signal_connections = []
+ self._destroyed = False
+ self._refresh_timer = None
# Header with back button
self.header = Box(
@@ -83,6 +82,9 @@ def __init__(self, control_center, **kwargs):
def _auto_refresh(self):
"""Auto-refresh the application list every 2 seconds"""
+ if self._destroyed:
+ self._refresh_timer = None
+ return False # Remove GLib source
self._populate_apps()
return True # Continue the timer
@@ -117,7 +119,6 @@ def _get_app_icon(self, app):
"visual studio code": "vscode",
"telegram": "telegram-desktop",
"pulse": "audio-card",
- "zen": "zen-browser",
"pipewire": "audio-card",
"alsa": "audio-card",
"sink": "audio-speakers",
@@ -169,7 +170,12 @@ def _format_app_name(self, name):
def _populate_apps(self):
"""Populate the widget with current audio applications"""
- # Clear existing widgets
+ # Clear and DESTROY existing widgets to prevent memory leaks
+ for child in list(self.apps_container.get_children()):
+ try:
+ child.destroy()
+ except Exception:
+ pass
self.apps_container.children = []
self._app_widgets.clear()
@@ -310,7 +316,8 @@ def _set_app_volume(self, app, volume_value):
def _on_stream_changed(self, *_):
"""Handle when audio streams are added or removed"""
- GLib.idle_add(self._populate_apps)
+ if not self._destroyed:
+ GLib.idle_add(self._populate_apps)
def refresh(self):
"""Manually refresh the application list"""
@@ -318,19 +325,27 @@ def refresh(self):
def destroy(self):
"""Clean up resources"""
- if hasattr(self, "_refresh_timer"):
- GLib.source_remove(self._refresh_timer)
+ self._destroyed = True
+
+ # Stop the auto-refresh timer
+ if self._refresh_timer is not None:
+ try:
+ GLib.source_remove(self._refresh_timer)
+ except Exception:
+ pass
+ self._refresh_timer = None
- # Disconnect fabric audio service signals
+ # Disconnect audio service signals
if audio_service:
for connection in self._signal_connections:
try:
audio_service.disconnect(connection)
- except:
+ except Exception:
pass
self._signal_connections.clear()
self._app_widgets.clear()
self._updating_volumes.clear()
+ self.control_center = None
super().destroy()
diff --git a/modules/controlcenter/player.py b/src/window/controlcenter/player.py
similarity index 60%
rename from modules/controlcenter/player.py
rename to src/window/controlcenter/player.py
index d5a6fd4c..14cfa2d4 100644
--- a/modules/controlcenter/player.py
+++ b/src/window/controlcenter/player.py
@@ -1,30 +1,50 @@
-import os
import re
-import tempfile
-import urllib.parse
-import urllib.request
+import time
from typing import List
-import threading
-from fabric.utils import (
- bulk_connect,
-)
-from fabric.utils.helpers import get_relative_path
from fabric.widgets.box import Box
from fabric.widgets.button import Button
+from fabric.widgets.centerbox import CenterBox
from fabric.widgets.image import Image
from fabric.widgets.label import Label
from fabric.widgets.overlay import Overlay
from fabric.widgets.stack import Stack
-from fabric.widgets.svg import Svg
-from gi.repository import GLib, GObject
-from fabric.widgets.centerbox import CenterBox
-from loguru import logger
+from fabric.utils import GLib, GObject, Gio, logger, bulk_connect, get_relative_path, os
+
+import shared.data as data
from services.mpris import MprisPlayer, MprisPlayerManager
-import config.data as data
+from utils.utils import svg_file
CACHE_DIR = f"{data.CACHE_DIR}/media"
+MEDIA_CACHE = CACHE_DIR
+if not os.path.exists(CACHE_DIR):
+ os.makedirs(CACHE_DIR)
+if not os.path.exists(MEDIA_CACHE):
+ os.makedirs(MEDIA_CACHE)
+
+# Global shared MPRIS manager
+_shared_mpris_manager = None
+
+
+def get_shared_mpris_manager():
+ """Get shared MPRIS manager instance to reduce memory usage."""
+ global _shared_mpris_manager
+ if _shared_mpris_manager is None:
+ # Create MPRIS manager immediately but don't wait for DBus discovery
+ _shared_mpris_manager = MprisPlayerManager()
+ return _shared_mpris_manager
+
+
+def cleanup_shared_mpris_manager():
+ """Clean up the shared MPRIS manager to free memory."""
+ global _shared_mpris_manager
+ if _shared_mpris_manager is not None:
+ try:
+ _shared_mpris_manager.destroy()
+ except Exception as e:
+ logger.warning(f"Failed to destroy shared MPRIS manager: {e}")
+ _shared_mpris_manager = None
def cleanup_old_cache_files():
@@ -33,8 +53,6 @@ def cleanup_old_cache_files():
if not os.path.exists(CACHE_DIR):
return
- import time
-
current_time = time.time()
one_day_ago = current_time - (24 * 60 * 60) # 24 hours
cache_files = []
@@ -85,10 +103,10 @@ class PlayerBoxStack(Box):
"""A widget that displays the current player information."""
def __init__(
- self, mpris_manager: MprisPlayerManager, control_center=None, **kwargs
+ self, mpris_manager: MprisPlayerManager = None, control_center=None, **kwargs
):
- # Clean up old cache files on startup
- cleanup_old_cache_files()
+ # Defer cache cleanup to avoid blocking startup
+ GLib.idle_add(cleanup_old_cache_files)
# The player stack
self.player_stack = Stack(
@@ -114,25 +132,96 @@ def __init__(
self.player_stack.children = [self.no_media_box]
self.set_visible(True)
+ # Use shared MPRIS manager if none provided
self.mpris_manager = mpris_manager
- # Track connections for cleanup - store (object, handler_id) tuples
- connections = bulk_connect(
- self.mpris_manager,
- {
- "player-appeared": self.on_new_player,
- "player-vanished": self.on_lost_player,
- },
- )
- # Store as (object, handler_id) tuples
- for handler_id in connections:
- self._signal_connections.append((self.mpris_manager, handler_id))
+ # Initialize MPRIS manager if provided, otherwise defer
+ if self.mpris_manager is not None:
+ self._init_mpris_manager()
+ else:
+ # Will be initialized later via _init_mpris_manager()
+ pass
+
+ # Connect to visibility changes for cleanup
+ self.connect("notify::visible", self._on_visibility_changed)
+
+ def _on_visibility_changed(self, widget, param):
+ """Monitor visibility changes to debug hiding issues."""
+ is_visible = self.get_visible()
+ logger.debug(f"[PlayerBoxStack] Visibility changed to: {is_visible}")
- for player in self.mpris_manager.players: # type: ignore
- logger.info(
- f"[PLAYER MANAGER] player found: {player.get_property('player-name')}",
+ if not is_visible:
+ # Check if we should be visible
+ current_children = self.player_stack.get_children()
+ if len(current_children) > 0:
+ logger.warning(
+ f"[PlayerBoxStack] Widget was hidden but has {len(current_children)} children - forcing visibility"
+ )
+ GLib.idle_add(self.set_visible, True)
+
+ def set_visible(self, visible: bool) -> None:
+ """Override set_visible to ensure the widget is never hidden when it should show content."""
+ # Always ensure the widget is visible if we have content to show
+ if not visible:
+ current_children = self.player_stack.get_children()
+ if len(current_children) > 0:
+ # Don't allow hiding if we have content
+ logger.debug(
+ "[PlayerBoxStack] Preventing widget from being hidden - has content"
+ )
+ return
+
+ super().set_visible(visible)
+
+ def get_visible(self) -> bool:
+ """Override get_visible to ensure the widget always reports as visible when it has content."""
+ # Check if we have content to show
+ current_children = self.player_stack.get_children()
+ if len(current_children) > 0:
+ # If we have content, we should always be visible
+ return True
+
+ # Otherwise, use the parent's visibility state
+ return super().get_visible()
+
+ def _init_mpris_manager(self):
+ """Initialize MPRIS manager and connect signals"""
+ if self.mpris_manager is None:
+ return
+
+ try:
+ # Track connections for cleanup - store (object, handler_id) tuples
+ connections = bulk_connect(
+ self.mpris_manager,
+ {
+ "player-appeared": self.on_new_player,
+ "player-vanished": self.on_lost_player,
+ },
)
- self.on_new_player(self.mpris_manager, player)
+ # Store as (object, handler_id) tuples
+ for handler_id in connections:
+ self._signal_connections.append((self.mpris_manager, handler_id))
+
+ # Process existing players asynchronously to avoid blocking
+ def process_existing_players():
+ try:
+ for player in self.mpris_manager.players.values(): # type: ignore
+ self.on_new_player(self.mpris_manager, player)
+ logger.debug("PlayerBoxStack initialized successfully")
+ except Exception as e:
+ logger.error(f"Failed to process existing players: {e}")
+
+ # Use idle_add to process players asynchronously
+ GLib.idle_add(process_existing_players)
+
+ # Also check the playing state after a short delay to ensure consistency
+ GLib.timeout_add(1000, self._check_and_update_playing_state)
+
+ # Ensure the widget is always visible
+ GLib.timeout_add(500, self._ensure_visibility)
+
+ except Exception as e:
+ logger.error(f"Failed to initialize PlayerBoxStack signals: {e}")
def destroy(self):
"""Clean up resources when the widget is destroyed."""
@@ -160,8 +249,30 @@ def destroy(self):
except Exception:
pass
+ # Clean up shared MPRIS manager if this is the last instance
+ if (
+ hasattr(self, "mpris_manager")
+ and self.mpris_manager == _shared_mpris_manager
+ ):
+ # Only cleanup if no other widgets are using it
+ cleanup_shared_mpris_manager()
+
super().destroy()
+ def suspend(self):
+ """Suspend all child players when hidden"""
+ for child in self.player_stack.get_children():
+ if hasattr(child, "suspend"):
+ child.suspend()
+ logger.debug("[PlayerBoxStack] Suspended child players")
+
+ def resume(self):
+ """Resume all child players when shown"""
+ for child in self.player_stack.get_children():
+ if hasattr(child, "resume"):
+ child.resume()
+ logger.debug("[PlayerBoxStack] Resumed child players")
+
def _periodic_cleanup(self):
"""Enhanced cleanup for reuse - clean internal state and free memory"""
try:
@@ -191,18 +302,12 @@ def _periodic_cleanup(self):
# Clean up old cache files more aggressively
cleanup_old_cache_files()
- # Force garbage collection
- import gc
-
- gc.collect()
-
- logger.debug("PlayerBoxStack enhanced cleanup completed")
except Exception as e:
logger.warning(f"PlayerBoxStack enhanced cleanup failed: {e}")
def _create_no_media_box(self):
"""Create a placeholder box for when no media is playing."""
- fallback_cover_path = f"{data.HOME_DIR}/.current.wall"
+ fallback_cover_path = get_relative_path("../../assets/icons/music.svg")
# Album cover with fallback image
album_cover = Box(style_classes="album-image-c")
@@ -305,7 +410,7 @@ def _find_playing_player_index(self):
for i, player_box in enumerate(players):
if (
hasattr(player_box, "player")
- and player_box.player.playback_status == "playing"
+ and str(player_box.player.playback_status).lower() == "playing"
):
return i
return None
@@ -314,28 +419,104 @@ def _switch_to_playing_player(self):
"""Switch to the currently playing player if one exists."""
playing_index = self._find_playing_player_index()
if playing_index is not None and playing_index != self.current_stack_pos:
- logger.info(
- f"[PlayerBoxStack] Auto-switching to playing player at index {
- playing_index
- }"
- )
self.on_player_clicked_by_index(playing_index)
+ def _check_and_update_playing_state(self):
+ """Check if any players are playing and update the display accordingly."""
+ players: List[PlayerBox] = self.player_stack.get_children()
+ has_playing_player = False
+
+ # Check if any player is currently playing
+ for player_box in players:
+ if (
+ hasattr(player_box, "player")
+ and str(player_box.player.playback_status).lower() == "playing"
+ ):
+ has_playing_player = True
+ break
+
+ # If no players are playing, show the no media box
+ if not has_playing_player:
+ # Check if we already have the no media box visible
+ current_children = self.player_stack.get_children()
+ if len(current_children) == 1 and current_children[0] == self.no_media_box:
+ # Already showing no media, no need to change
+ return
+
+ # Hide all player boxes and show no media
+ for player_box in players:
+ if hasattr(player_box, "destroy"):
+ try:
+ player_box.destroy()
+ except Exception as e:
+ logger.warning(f"Failed to destroy player box: {e}")
+
+ # Clear player buttons
+ self.player_buttons.clear()
+
+ # Show the no media box
+ self.player_stack.children = [self.no_media_box]
+ self.current_stack_pos = 0
+
+ # Ensure the widget is visible
+ self.set_visible(True)
+ self.player_stack.set_visible_child(self.no_media_box)
+
+ logger.debug(
+ "[PlayerBoxStack] No players playing, showing 'No media playing'"
+ )
+
+ def _ensure_visibility(self):
+ """Ensure the widget is always visible and showing appropriate content."""
+ try:
+ # Always ensure the widget is visible
+ self.set_visible(True)
+
+ # Check current state
+ current_children = self.player_stack.get_children()
+
+ # If no children, show no media box
+ if len(current_children) == 0:
+ self.player_stack.children = [self.no_media_box]
+ self.current_stack_pos = 0
+ logger.debug("[PlayerBoxStack] No children found, showing no media box")
+
+ # If only no_media_box is visible, ensure it's the active child
+ elif (
+ len(current_children) == 1 and current_children[0] == self.no_media_box
+ ):
+ self.player_stack.set_visible_child(self.no_media_box)
+ logger.debug("[PlayerBoxStack] Ensuring no media box is visible")
+
+ # Force the widget to be visible and show content
+ self.set_visible(True)
+ self.player_stack.set_visible(True)
+
+ # If we have the no media box, make sure it's visible
+ if self.no_media_box in current_children:
+ self.no_media_box.set_visible(True)
+
+ logger.debug(
+ f"[PlayerBoxStack] Current state: {len(current_children)} children, visible: {self.get_visible()}, stack_visible: {self.player_stack.get_visible()}"
+ )
+
+ except Exception as e:
+ logger.error(f"[PlayerBoxStack] Error in _ensure_visibility: {e}")
+
def on_player_playback_changed(self, player_box, status):
"""Called when a player's playback status changes."""
+ status = str(status).lower() if isinstance(status, str) else status
if status == "playing":
# Find this player's index and switch to it
players: List[PlayerBox] = self.player_stack.get_children()
for i, pb in enumerate(players):
if pb == player_box:
if i != self.current_stack_pos:
- logger.info(
- f"[PlayerBoxStack] Switching to playing player: {
- player_box.player.player_name
- }"
- )
self.on_player_clicked_by_index(i)
break
+ elif status in ["paused", "stopped"]:
+ # Check if any other players are playing
+ self._check_and_update_playing_state()
def on_player_clicked(self, type):
# unset active from prev active button
@@ -357,11 +538,6 @@ def on_player_clicked(self, type):
# set new active button
if self.player_buttons and self.current_stack_pos < len(self.player_buttons):
- print(
- f"[PlayerBoxStack] Switching to player at index {
- self.current_stack_pos
- }"
- )
self.player_buttons[self.current_stack_pos].add_style_class("active")
self.player_stack.set_visible_child(
self.player_stack.get_children()[self.current_stack_pos],
@@ -389,11 +565,15 @@ def on_player_clicked_by_index(self, index):
self._update_all_player_buttons()
def on_new_player(self, mpris_manager, player):
- player_name = player.props.player_name
# if player_name in self.config.get("ignore", []):
# return
+ # Show all players, but we'll handle their playback status in the UI
+ # if player.playback_status != "playing":
+ # logger.debug(f"[PlayerBoxStack] Player {player_name} is not playing (status: {player.playback_status}), skipping display")
+ # return
+
# Remove the no media box if it's the only child
if (
len(self.player_stack.get_children()) == 1
@@ -405,7 +585,7 @@ def on_new_player(self, mpris_manager, player):
self.set_visible(True)
new_player_box = PlayerBox(
- player=MprisPlayer(player),
+ player=player,
player_stack=self,
control_center=self.control_center,
)
@@ -415,9 +595,6 @@ def on_new_player(self, mpris_manager, player):
]
self.make_new_player_button(self.player_stack.get_children()[-1])
- logger.info(
- f"[PLAYER MANAGER] adding new player: {player.get_property('player-name')}",
- )
if self.player_buttons and self.current_stack_pos < len(self.player_buttons):
self.player_buttons[self.current_stack_pos].set_style_classes(["active"])
@@ -427,9 +604,8 @@ def on_new_player(self, mpris_manager, player):
# Check if this new player is playing and switch to it
self._switch_to_playing_player()
- def on_lost_player(self, mpris_manager, player_name):
+ def on_lost_player(self, mpris_manager, bus_name):
# the playerBox is automatically removed from mprisbox children on being removed
- logger.info(f"[PLAYER_MANAGER] Player Removed {player_name}")
players: List[PlayerBox] = self.player_stack.get_children()
# Find and properly destroy the player box
@@ -437,7 +613,7 @@ def on_lost_player(self, mpris_manager, player_name):
for player_box in players:
if (
hasattr(player_box, "player")
- and player_box.player.player_name == player_name
+ and getattr(player_box.player, "bus_name", None) == bus_name
):
player_box_to_remove = player_box
break
@@ -510,11 +686,7 @@ def cleanup_button(*_):
def _update_all_player_buttons(self):
"""Update all player boxes with the current button state"""
players: List[PlayerBox] = self.player_stack.get_children()
- logger.info(
- f"[PlayerBoxStack] Updating buttons for {len(players)} players, {
- len(self.player_buttons)
- } buttons"
- )
+
for player_box in players:
if hasattr(player_box, "update_buttons"):
player_box.update_buttons(self.player_buttons, len(players) > 1)
@@ -540,7 +712,7 @@ def __init__(
self.player: MprisPlayer = player
self.player_stack = player_stack
self.control_center = control_center
- self.fallback_cover_path = f"{data.HOME_DIR}/.current.wall"
+ self.cover_path = get_relative_path("../../assets/icons/music.svg")
# Add controls_box attribute early for compatibility
# Temporary placeholder
@@ -552,24 +724,28 @@ def __init__(
# State
self.exit = False
self.skipped = False
+ self._signal_connections = [] # Track signal connections
+ self._property_bindings = [] # Track GObject property bindings for unbind on destroy
# Memory management
self.temp_artwork_files = [] # Track temp files for cleanup
- self.current_download_thread = None # Track current download thread
self._download_cancelled = False # Flag to cancel downloads
- self._signal_connections = [] # Track signal connections
+ # Album art with existing Box widget
self.album_cover = Box(style_classes="album-image-c")
- self.album_cover.set_style(
- f"background-image:url('{self.fallback_cover_path}')"
- )
+ self.album_cover.set_style(f"background-image:url('{self.cover_path}')")
self.image_stack = Box(
h_align="start",
v_align="center",
name="player-image-stack",
)
- self.image_stack.children = [*self.image_stack.children, self.album_cover]
+ self.image_stack.children = [self.album_cover]
+
+ # Connect to arturl changes โ track so we can disconnect on destroy
+ self._signal_connections.append(
+ (self.player, self.player.connect("notify::arturl", self.set_image))
+ )
self.app_icon = Box(
children=Image(
@@ -585,6 +761,7 @@ def __init__(
self.app_icon,
],
)
+
# Track Info
self.track_title = Label(
@@ -606,23 +783,27 @@ def __init__(
visible=True,
)
- self.player.bind_property(
- "title",
- self.track_title,
- "label",
- GObject.BindingFlags.DEFAULT,
- lambda _, x: (
- re.sub(r"\r?\n", " ", x) if x != "" and x is not None else "No Title"
- ), # type: ignore
+ self._property_bindings.append(
+ self.player.bind_property(
+ "title",
+ self.track_title,
+ "label",
+ GObject.BindingFlags.DEFAULT,
+ lambda _, x: (
+ re.sub(r"\r?\n", " ", x)
+ if x != "" and x is not None
+ else "No Title"
+ ), # type: ignore
+ )
)
- self.player.bind_property(
- "artist",
- self.track_artist,
- "label",
- GObject.BindingFlags.DEFAULT,
- lambda _, x: (
- re.sub(r"\r?\n", " ", x) if x != "" and x is not None else "No Artist"
- ), # type: ignore
+ self._property_bindings.append(
+ self.player.bind_property(
+ "artist",
+ self.track_artist,
+ "label",
+ GObject.BindingFlags.DEFAULT,
+ lambda _, x: ", ".join(x) if x and isinstance(x, list) else "No Artist", # type: ignore
+ )
)
self.track_info = Box(
@@ -645,16 +826,8 @@ def __init__(
)
# Create SVG icons with consistent sizing
- self.skip_next_icon = Svg(
- name="control-buttons",
- size=(22, 22),
- svg_file=get_relative_path("../../config/assets/icons/player/fwd.svg"),
- )
- self.play_pause_icon = Svg(
- name="control-buttons",
- size=(22, 22),
- svg_file=get_relative_path("../../config/assets/icons/player/Pause.svg"),
- )
+ self.skip_next_icon = svg_file("player/fwd.svg", size=22)
+ self.play_pause_icon = svg_file("player/Pause.svg", size=22)
# Fixed size buttons to prevent layout shifts
self.play_pause_button = Button(
@@ -664,16 +837,18 @@ def __init__(
)
# Set consistent button size
- self.player.bind_property("can_pause", self.play_pause_button, "sensitive")
+ self._property_bindings.append(
+ self.player.bind_property("can_pause", self.play_pause_button, "sensitive")
+ )
self.next_button = Button(
name="player-button",
child=self.skip_next_icon,
on_clicked=self._on_player_next,
)
- # Set consistent button size
- # self.next_button.set_size_request(32, 32)
- self.player.bind_property("can_go_next", self.next_button, "sensitive")
+ self._property_bindings.append(
+ self.player.bind_property("can_go_next", self.next_button, "sensitive")
+ )
self.button_box.children = (
self.play_pause_button,
@@ -709,15 +884,9 @@ def __init__(
spacing=5,
name="outer-player-box-c",
h_expand=True,
- # style="background-color:#fff",
on_clicked=self._on_outer_box_clicked,
h_align="start",
- # children=[
child=self.inner_box,
- # self.inner_box,
- # self.player_info_box,
- # self.image,
- # ],
)
self.box = CenterBox(
@@ -726,12 +895,12 @@ def __init__(
h_align="center",
start_children=[
self.outer_box,
- # self.stack_buttons_box,
],
end_children=[
self.button_box,
],
)
+ self.box.set_size_request(352, -1)
self.children = [
*self.children,
@@ -742,7 +911,7 @@ def __init__(
connections = bulk_connect(
self.player,
{
- "exit": self._on_player_exit,
+ "closed": self._on_player_exit,
"notify::playback-status": self._on_playback_change,
"notify::metadata": self._on_metadata,
},
@@ -751,12 +920,54 @@ def __init__(
for handler_id in connections:
self._signal_connections.append((self.player, handler_id))
+ def suspend(self):
+ """Disconnect signals when hidden"""
+ self.exit = True
+ for obj, handler_id in self._signal_connections:
+ try:
+ obj.disconnect(handler_id)
+ except Exception:
+ pass
+ self._signal_connections.clear()
+ logger.debug(
+ f"[PlayerBox] Suspended signals for {self.player.player_name if self.player else 'unknown'}"
+ )
+
+ def resume(self):
+ """Reconnect signals when shown"""
+ if self.player is None:
+ return
+ self.exit = False
+ if not self._signal_connections:
+ connections = bulk_connect(
+ self.player,
+ {
+ "closed": self._on_player_exit,
+ "notify::playback-status": self._on_playback_change,
+ "notify::metadata": self._on_metadata,
+ },
+ )
+ for handler_id in connections:
+ self._signal_connections.append((self.player, handler_id))
+ logger.debug(f"[PlayerBox] Resumed signals for {self.player.player_name}")
+
def destroy(self):
"""Clean up all resources when the widget is destroyed."""
- # Cancel any ongoing downloads
+ # Set exit flag first โ stops all callbacks from doing work
+ self.exit = True
+
+ # Cancel any ongoing async artwork downloads
self._download_cancelled = True
- # Disconnect all signal connections
+ # Unbind all GObject property bindings โ these keep self.player alive
+ for binding in self._property_bindings:
+ try:
+ binding.unbind()
+ except Exception:
+ pass
+ self._property_bindings.clear()
+
+ # Disconnect all signal connections (includes arturl notify)
for obj, handler_id in self._signal_connections:
try:
obj.disconnect(handler_id)
@@ -764,9 +975,14 @@ def destroy(self):
logger.warning(f"Failed to disconnect signal: {e}")
self._signal_connections.clear()
- # Clean up temp files
+ # Clean up temp artwork files
self._cleanup_temp_files()
+ # Drop strong references to avoid reference cycles
+ self.player = None
+ self.player_stack = None
+ self.control_center = None
+
super().destroy()
def __del__(self):
@@ -797,16 +1013,15 @@ def _on_outer_box_clicked(self, *_):
self.control_center.open_expanded_player()
except Exception as e:
logger.warning(f"Failed to handle outer box click: {e}")
- import traceback
-
- logger.error(f"Full traceback: {traceback.format_exc()}")
def update_buttons(self, player_buttons, show_buttons):
# """Update the stack switcher buttons in this player box"""
pass
def _on_metadata(self, *_):
- self._set_image()
+ if self.exit or self.player is None:
+ return
+ self.set_image()
def _cleanup_temp_files(self):
"""Clean up temporary artwork files."""
@@ -820,135 +1035,87 @@ def _cleanup_temp_files(self):
def _on_player_exit(self, _, value):
self.exit = value
- self._cleanup_temp_files() # Clean up temp files before destroying
+ self._cleanup_temp_files()
self.destroy()
def _on_player_next(self, *_):
- self.player.next()
+ if self.player is not None:
+ self.player.next()
def _on_player_prev(self, *_):
- self.player.previous()
+ if self.player is not None:
+ self.player.previous()
def _on_playback_change(self, player, status):
+ if self.exit or self.player is None:
+ return
status = player.get_property("playback-status")
+ status_l = str(status).lower() if isinstance(status, str) else status
- if status == "paused":
- self.play_pause_icon.set_from_file(
- get_relative_path("../../config/assets/icons/player/play.svg")
- )
-
- if status == "playing":
- self.play_pause_icon.set_from_file(
- get_relative_path("../../config/assets/icons/player/Pause.svg")
- )
- # Notify the player stack that this player started playing
- if self.player_stack and hasattr(
- self.player_stack, "on_player_playback_changed"
- ):
- self.player_stack.on_player_playback_changed(self, status)
+ if status_l == "paused":
+ self.play_pause_icon.dynamic_file("player/play.svg")
- def _update_image(self, image_path):
- if image_path and os.path.isfile(image_path):
- self.album_cover.set_style(f"background-image:url('{image_path}')")
- else:
- self.album_cover.set_style(
- f"background-image:url('{self.fallback_cover_path}')"
- )
-
- def _set_image(self, *_):
- art_url = self.player.arturl
+ if status_l == "playing":
+ self.play_pause_icon.dynamic_file("player/Pause.svg")
- parsed = urllib.parse.urlparse(art_url)
- if parsed.scheme == "file":
- local_arturl = urllib.parse.unquote(parsed.path)
- self._update_image(local_arturl)
- elif parsed.scheme in ("http", "https"):
- # Cancel any existing download to prevent memory buildup
- self._download_cancelled = True
-
- # Use threading.Thread instead of GLib.Thread for better control
- if self.current_download_thread and self.current_download_thread.is_alive():
- # Thread will check _download_cancelled flag and exit early
- pass
-
- self._download_cancelled = False
- self.current_download_thread = threading.Thread(
- target=self._download_and_set_artwork,
- args=(art_url,),
- daemon=True, # Dies with main thread
- )
- self.current_download_thread.start()
- else:
- self._update_image(art_url)
-
- def _download_and_set_artwork(self, arturl):
- """
- Download the artwork from the given URL asynchronously and update the cover
- using GLib.idle_add to ensure UI updates occur on the main thread.
- """
- local_arturl = self.fallback_cover_path
- temp_file_path = None
+ # Notify the player stack about playback status changes
+ if self.player_stack and hasattr(
+ self.player_stack, "on_player_playback_changed"
+ ):
+ self.player_stack.on_player_playback_changed(self, status_l)
+ def img_callback(self, source: Gio.File, result: Gio.AsyncResult):
try:
- # Check if download was cancelled
- if self._download_cancelled:
+ # Guard: widget may have been destroyed before the async copy finished
+ if self.exit:
return
+ if os.path.isfile(self.cover_path):
+ self.update_image()
+ except ValueError:
+ logger.error("[PLAYER] Failed to grab artUrl")
- # Clean up old temp files first (keep only last 1 to reduce memory)
- if len(self.temp_artwork_files) > 1:
- old_files = self.temp_artwork_files[:-1]
- for old_file in old_files:
- try:
- if os.path.exists(old_file):
- os.unlink(old_file)
- except Exception:
- pass
- self.temp_artwork_files = self.temp_artwork_files[-1:]
-
- # Check again if cancelled
- if self._download_cancelled:
- return
+ def update_image(self):
+ if self.exit:
+ return
+ self.album_cover.set_style(f"background-image:url('{self.cover_path}')")
- # Download artwork
- parsed = urllib.parse.urlparse(arturl)
- suffix = os.path.splitext(parsed.path)[1] or ".png"
+ def set_image(self, *args):
+ if self.exit or self.player is None:
+ return
+ url = self.player.arturl
- with urllib.request.urlopen(arturl, timeout=10) as response: # Add timeout
- if self._download_cancelled:
- return
- data = response.read()
+ if url is None or url == "":
+ return
- # Check one more time if cancelled
- if self._download_cancelled:
- return
+ new_cover_path = (
+ (
+ MEDIA_CACHE
+ + "/"
+ # type: ignore
+ + GLib.compute_checksum_for_string(GLib.ChecksumType.SHA1, url, -1)
+ )
+ if "file://" != url[0:7]
+ else url[7:]
+ )
- # Create temp file in cache directory instead of system temp
- os.makedirs(CACHE_DIR, exist_ok=True)
- with tempfile.NamedTemporaryFile(
- delete=False, suffix=suffix, dir=CACHE_DIR
- ) as temp_file:
- temp_file.write(data)
- temp_file_path = temp_file.name
- local_arturl = temp_file_path
+ if new_cover_path == self.cover_path:
+ self.update_image()
+ return
- # Track temp file for cleanup
- if temp_file_path and not self._download_cancelled:
- self.temp_artwork_files.append(temp_file_path)
+ self.cover_path = new_cover_path
- except Exception as e:
- if not self._download_cancelled:
- logger.warning(f"Failed to download artwork from {arturl}: {e}")
- # Clean up failed temp file
- if temp_file_path and os.path.exists(temp_file_path):
- try:
- os.unlink(temp_file_path)
- except Exception:
- pass
+ if os.path.exists(self.cover_path):
+ self.update_image()
return
- # Only update UI if not cancelled
- if not self._download_cancelled:
- GLib.idle_add(self._update_image, local_arturl)
+ Gio.File.new_for_uri(uri=url).copy_async(
+ Gio.File.new_for_path(self.cover_path),
+ Gio.FileCopyFlags.OVERWRITE,
+ GLib.PRIORITY_DEFAULT,
+ None,
+ None,
+ self.img_callback,
+ )
def close_bluetooth(self, *args):
"""Placeholder method for compatibility"""
diff --git a/modules/controlcenter/wifi.py b/src/window/controlcenter/wifi.py
similarity index 92%
rename from modules/controlcenter/wifi.py
rename to src/window/controlcenter/wifi.py
index 6cb612d8..929a8f33 100644
--- a/modules/controlcenter/wifi.py
+++ b/src/window/controlcenter/wifi.py
@@ -1,22 +1,20 @@
-from widgets.wifi_password_dialog import WiFiPasswordDialog
-from services.network import NetworkClient
-from fabric.widgets.scrolledwindow import ScrolledWindow
-from fabric.widgets.revealer import Revealer
-from fabric.widgets.label import Label
-from fabric.widgets.image import Image
-from fabric.widgets.centerbox import CenterBox
-from fabric.widgets.button import Button
+import subprocess
+
+from fabric.utils import Gdk, GLib, Gtk
from fabric.widgets.box import Box
-from fabric.widgets.svg import Svg
-from fabric.utils import get_relative_path
-from gi.repository import Gdk, GLib, Gtk
+from fabric.widgets.button import Button
+from fabric.widgets.centerbox import CenterBox
+from fabric.widgets.image import Image
+from fabric.widgets.label import Label
+from fabric.widgets.revealer import Revealer
+from fabric.widgets.scrolledwindow import ScrolledWindow
from fabric.widgets.separator import Separator
-from utils.functions import get_wifi_icon_for_strength, get_wifi_connecting_icon
-import gi
-import subprocess
-gi.require_version("Gtk", "3.0")
-gi.require_version("Gdk", "3.0")
+from services.network import NetworkClient
+from utils.functions import get_wifi_connecting_icon, get_wifi_icon_for_strength
+from utils.utils import svg_file
+from shared.dialogs.wifi_password_dialog import WiFiPasswordDialog
+from gi.repository import NM
class WifiNetworkSlot(Box):
@@ -42,11 +40,7 @@ def __init__(self, access_point, wifi_service, parent=None, **kwargs):
# Create connection status indicator using dynamic WiFi icon based on signal strength
wifi_icon_path = get_wifi_icon_for_strength(self.strength)
- self.dimage = Svg(
- svg_file=wifi_icon_path,
- size=28,
- name="device-icon",
- )
+ self.dimage = svg_file(wifi_icon_path, size=28)
self.wifi_icon_box = Box(
children=[self.dimage],
style_classes=["wifi-icon-box"],
@@ -209,7 +203,6 @@ def on_connection_result(success, message):
if success:
self.is_connected = True
# Remove connecting state after a short delay
- from gi.repository import GLib
GLib.timeout_add(500, lambda: self._reset_connect_state())
@@ -269,9 +262,17 @@ def __init__(self, parent, show_back_button=True, **kwargs):
self.refresh_timer = None # Timer for periodic network refresh
self._update_in_progress = False # Prevent concurrent updates
self._destroyed = False # Track if widget is destroyed
-
- # Wait for network service to be ready
- self.network_service.connect("wifi-device-added", self.on_network_ready)
+ self._signal_ids = [] # Track all service signal IDs for cleanup
+
+ # Wait for network service to be ready โ track signal ID
+ self._signal_ids.append(
+ (
+ self.network_service,
+ self.network_service.connect(
+ "wifi-device-added", self.on_network_ready
+ ),
+ )
+ )
# Create pull-to-refresh indicator
self.refresh_indicator = Label(
@@ -394,13 +395,33 @@ def on_network_ready(self, *_):
self.toggle_button.set_active(self.wifi_service.wireless_enabled)
self.toggle_button.connect("notify::active", self.on_toggle_changed)
- # Connect to WiFi service signals
- self.wifi_service.connect(
- "notify::wireless-enabled", self.on_wifi_enabled_changed
+ # Connect to WiFi service signals โ track IDs for cleanup
+ self._signal_ids.append(
+ (
+ self.wifi_service,
+ self.wifi_service.connect(
+ "notify::wireless-enabled", self.on_wifi_enabled_changed
+ ),
+ )
+ )
+ self._signal_ids.append(
+ (
+ self.wifi_service,
+ self.wifi_service.connect("changed", self.update_networks),
+ )
+ )
+ self._signal_ids.append(
+ (
+ self.wifi_service,
+ self.wifi_service.connect("ap-added", self.update_networks),
+ )
+ )
+ self._signal_ids.append(
+ (
+ self.wifi_service,
+ self.wifi_service.connect("ap-removed", self.update_networks),
+ )
)
- self.wifi_service.connect("changed", self.update_networks)
- self.wifi_service.connect("ap-added", self.update_networks)
- self.wifi_service.connect("ap-removed", self.update_networks)
# Initial network update
self.update_networks()
@@ -551,8 +572,6 @@ def _is_saved_network(self, access_point):
# Compare SSIDs
connection_ssid_bytes = wifi_setting.get_ssid()
if connection_ssid_bytes:
- from gi.repository import NM
-
connection_ssid = NM.utils_ssid_to_utf8(
connection_ssid_bytes.get_data()
)
@@ -590,17 +609,20 @@ def stop_network_monitoring(self):
def periodic_network_refresh(self):
"""Periodically refresh network list to catch external connections"""
- # Skip if update in progress, destroyed, or wifi service not available
+ # If destroyed, remove the GLib source by returning False
+ if self._destroyed:
+ self.refresh_timer = None
+ return False
+
+ # Skip if update in progress or wifi not available/enabled
if (
self._update_in_progress
- or self._destroyed
or not self.wifi_service
or not self.wifi_service.wireless_enabled
):
return True # Continue monitoring
try:
- # Simple check - just trigger update_networks which has its own safety checks
self.update_networks()
except Exception:
pass
@@ -701,13 +723,23 @@ def on_destroy(self, widget):
"""Cleanup when widget is destroyed"""
# Mark as destroyed to prevent further updates
self._destroyed = True
- # Stop monitoring
+ # Stop monitoring timer
self.stop_network_monitoring()
- # Make sure other networks revealer is collapsed when closing
+ # Disconnect all tracked service signals
+ for obj, sig_id in self._signal_ids:
+ try:
+ obj.disconnect(sig_id)
+ except Exception:
+ pass
+ self._signal_ids.clear()
+ # Drop strong references
+ self.wifi_service = None
+ self.network_service = None
+ # Collapse other networks revealer
try:
self.other_networks_revealer.child_revealed = False
- except:
- pass # Widget might already be destroyed
+ except Exception:
+ pass
def close_wifi(self):
"""Called when WiFi panel is being closed"""
diff --git a/src/window/desktop/__init__.py b/src/window/desktop/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/window/desktop/constants.py b/src/window/desktop/constants.py
new file mode 100644
index 00000000..38545c24
--- /dev/null
+++ b/src/window/desktop/constants.py
@@ -0,0 +1,159 @@
+import datetime
+
+WEATHER_UPDATE_INTERVAL = 600 # 10 minutes
+WEATHER_CACHE_TIMEOUT = 1800 # 30 minutes
+SYSTEM_UPDATE_INTERVAL = 60000 # 1 minute instead of 1 second
+CALENDAR_UPDATE_INTERVAL = int(
+ (
+ (
+ datetime.datetime.combine(
+ datetime.date.today() + datetime.timedelta(days=1), datetime.time.min
+ )
+ - datetime.datetime.now()
+ ).total_seconds()
+ )
+ * 1000
+) # Calculate time till midnight
+LOCATION_CACHE_TIMEOUT = 604800 # 7 days (extended from 24h)
+
+
+WEATHER_GRADIENT_MAP = {
+ # Clear/Sunny conditions - bright blue to lighter blue
+ 0: "weather-clear", # Clear sky
+ 1: "weather-mostly-clear", # Mainly clear
+ # Cloudy conditions - grey gradients
+ 2: "weather-partly-cloudy", # Partly cloudy
+ 3: "weather-overcast", # Overcast
+ # Fog conditions - muted grey/blue
+ 45: "weather-fog", # Fog
+ 48: "weather-fog", # Depositing rime fog
+ # Light rain/drizzle - blue-grey gradients
+ 51: "weather-light-rain", # Light drizzle
+ 53: "weather-rain", # Moderate drizzle
+ 55: "weather-rain", # Dense drizzle
+ 61: "weather-light-rain", # Slight rain
+ 80: "weather-light-rain", # Slight rain showers
+ # Heavy rain - darker blue-grey
+ 63: "weather-heavy-rain", # Moderate rain
+ 65: "weather-heavy-rain", # Heavy rain
+ 81: "weather-heavy-rain", # Moderate rain showers
+ 82: "weather-storm", # Violent rain showers
+ # Snow conditions - blue-white gradients
+ 56: "weather-snow", # Light freezing drizzle
+ 57: "weather-snow", # Dense freezing drizzle
+ 66: "weather-snow", # Light freezing rain
+ 67: "weather-snow", # Heavy freezing rain
+ 71: "weather-snow", # Slight snow fall
+ 73: "weather-heavy-snow", # Moderate snow fall
+ 75: "weather-heavy-snow", # Heavy snow fall
+ 77: "weather-snow", # Snow grains
+ 85: "weather-snow", # Slight snow showers
+ 86: "weather-heavy-snow", # Heavy snow showers
+ # Storm conditions - dark dramatic gradients
+ 95: "weather-storm", # Thunderstorm
+ 96: "weather-storm", # Thunderstorm with slight hail
+ 99: "weather-storm", # Thunderstorm with heavy hail
+}
+
+# Weather condition to emoji mapping
+WEATHER_EMOJI_MAP = {
+ 0: "โ๏ธ", # Clear sky
+ 1: "๐ค๏ธ", # Mainly clear
+ 2: "โ
", # Partly cloudy
+ 3: "โ๏ธ", # Overcast
+ 45: "๐ซ๏ธ", # Fog
+ 48: "๐ซ๏ธ", # Depositing rime fog
+ 51: "๐ฆ๏ธ", # Light drizzle
+ 53: "๐ง๏ธ", # Moderate drizzle
+ 55: "๐ง๏ธ", # Dense drizzle
+ 56: "๐จ๏ธ", # Light freezing drizzle
+ 57: "๐จ๏ธ", # Dense freezing drizzle
+ 61: "๐ฆ๏ธ", # Slight rain
+ 63: "๐ง๏ธ", # Moderate rain
+ 65: "๐ง๏ธ", # Heavy rain
+ 66: "๐จ๏ธ", # Light freezing rain
+ 67: "๐จ๏ธ", # Heavy freezing rain
+ 71: "๐จ๏ธ", # Slight snow fall
+ 73: "โ๏ธ", # Moderate snow fall
+ 75: "โ๏ธ", # Heavy snow fall
+ 77: "๐จ๏ธ", # Snow grains
+ 80: "๐ฆ๏ธ", # Slight rain showers
+ 81: "๐ง๏ธ", # Moderate rain showers
+ 82: "โ๏ธ", # Violent rain showers
+ 85: "๐จ๏ธ", # Slight snow showers
+ 86: "โ๏ธ", # Heavy snow showers
+ 95: "โ๏ธ", # Thunderstorm
+ 96: "โ๏ธ", # Thunderstorm with slight hail
+ 99: "โ๏ธ", # Thunderstorm with heavy hail
+}
+
+# Weather condition to SVG icon mapping
+WEATHER_ICON_MAP = {
+ 0: "weather-clear",
+ 1: "weather-few-clouds",
+ 2: "weather-clouds",
+ 3: "weather-overcast",
+ 45: "weather-fog",
+ 48: "weather-fog",
+ 51: "weather-showers-scattered",
+ 53: "weather-showers",
+ 55: "weather-showers",
+ 61: "weather-showers-scattered",
+ 80: "weather-showers-scattered",
+ 63: "weather-showers",
+ 65: "weather-showers",
+ 81: "weather-showers",
+ 82: "weather-storm",
+ 56: "weather-freezing-rain",
+ 57: "weather-freezing-rain",
+ 66: "weather-freezing-rain",
+ 67: "weather-freezing-rain",
+ 71: "weather-snow",
+ 73: "weather-snow",
+ 75: "weather-snow",
+ 77: "weather-hail",
+ 85: "weather-snow-scattered",
+ 86: "weather-snow-scattered",
+ 95: "weather-storm",
+ 96: "weather-storm",
+ 99: "weather-storm",
+}
+
+# Weather condition descriptions
+WEATHER_DESC_MAP = {
+ 0: "Clear sky",
+ 1: "Mainly clear",
+ 2: "Partly cloudy",
+ 3: "Overcast",
+ 45: "Fog",
+ 48: "Depositing rime fog",
+ 51: "Light drizzle",
+ 53: "Moderate drizzle",
+ 55: "Dense drizzle",
+ 56: "Light freezing drizzle",
+ 57: "Dense freezing drizzle",
+ 61: "Slight rain",
+ 63: "Moderate rain",
+ 65: "Heavy rain",
+ 66: "Light freezing rain",
+ 67: "Heavy freezing rain",
+ 71: "Slight snow",
+ 73: "Moderate snow",
+ 75: "Heavy snow",
+ 77: "Snow grains",
+ 80: "Light rain showers",
+ 81: "Moderate rain showers",
+ 82: "Violent rain showers",
+ 85: "Slight snow showers",
+ 86: "Heavy snow showers",
+ 95: "Thunderstorm",
+ 96: "Thunderstorm with hail",
+ 99: "Thunderstorm with heavy hail",
+}
+
+# Location APIs in order of preference (fastest first)
+LOCATION_APIS = [
+ "https://ipapi.co/json/", # Fastest, 200ms average
+ "http://ip-api.com/json/", # Fast fallback, 150ms average
+ "https://ipinfo.io/json", # Original fallback
+]
diff --git a/src/window/desktop/widget.py b/src/window/desktop/widget.py
new file mode 100644
index 00000000..a25f5234
--- /dev/null
+++ b/src/window/desktop/widget.py
@@ -0,0 +1,697 @@
+import calendar
+import datetime
+import urllib.parse
+import httpx
+from concurrent.futures import ThreadPoolExecutor
+from typing import Any, Dict, List, Optional, Tuple
+
+import psutil
+from fabric.utils import GLib, invoke_repeater, time
+from fabric.widgets.box import Box
+from fabric.widgets.circularprogressbar import CircularProgressBar
+from fabric.widgets.datetime import DateTime
+from fabric.widgets.label import Label
+from fabric.widgets.overlay import Overlay
+from fabric.widgets.wayland import WaylandWindow as Window
+
+from shared.data import load_config
+from utils.debounce import sync_debounce
+from utils.utils import svg_file
+from window.desktop.constants import (
+ CALENDAR_UPDATE_INTERVAL,
+ LOCATION_APIS,
+ LOCATION_CACHE_TIMEOUT,
+ SYSTEM_UPDATE_INTERVAL,
+ WEATHER_CACHE_TIMEOUT,
+ WEATHER_DESC_MAP,
+ WEATHER_GRADIENT_MAP,
+ WEATHER_ICON_MAP,
+ WEATHER_UPDATE_INTERVAL,
+)
+
+# Thread pool for async operations
+executor = ThreadPoolExecutor(max_workers=4)
+
+# Global cache for weather data
+_weather_cache: Dict[str, Tuple[Any, float]] = {}
+_location_cache: Dict[str, Tuple[float, float, float]] = {}
+
+
+def http_get_json(
+ url: str, timeout: int = 3, headers: Optional[Dict] = None
+) -> Optional[Dict]:
+ """Helper to perform GET requests and return JSON using httpx."""
+ try:
+ # Some APIs require a User-Agent or they will return 403 Forbidden
+ default_headers = {"User-Agent": "Modus-Desktop/1.0"}
+ if headers:
+ default_headers.update(headers)
+ response = httpx.get(
+ url, headers=default_headers, timeout=timeout, follow_redirects=True
+ )
+ if response.status_code == 200:
+ return response.json()
+ except Exception as e:
+ print(f"HTTP Request to {url} failed: {e}")
+ return None
+
+
+def get_location() -> str:
+ """Get current location from config or multiple IP geolocation APIs with fallback."""
+ # Try to get location from config first
+ try:
+ config = load_config()
+ manual_location = config.get("weather_location")
+ if manual_location:
+ return manual_location
+ except Exception:
+ pass
+
+ # Fallback to IP geolocation APIs
+ for api_url in LOCATION_APIS:
+ data = http_get_json(api_url, timeout=2)
+ if data:
+ city = data.get("city", "")
+ if city:
+ return city
+
+ print("All location APIs failed")
+ return ""
+
+
+def get_coordinates(city: str) -> Optional[Tuple[float, float]]:
+ """Get coordinates for a city using Nominatim geocoding API."""
+ cache_key = city.lower()
+ current_time = time.time()
+
+ if cache_key in _location_cache:
+ lat, lon, timestamp = _location_cache[cache_key]
+ if current_time - timestamp < LOCATION_CACHE_TIMEOUT:
+ return lat, lon
+
+ encoded_city = urllib.parse.quote(city)
+ url = f"https://nominatim.openstreetmap.org/search?q={encoded_city}&format=json&limit=1"
+
+ data = http_get_json(url, timeout=3, headers={"User-Agent": "Modus-Desktop/1.0"})
+
+ if data and isinstance(data, list) and len(data) > 0:
+ try:
+ lat = float(data[0]["lat"])
+ lon = float(data[0]["lon"])
+ _location_cache[cache_key] = (lat, lon, current_time)
+ return lat, lon
+ except (ValueError, KeyError):
+ pass
+
+ return None
+
+
+def get_weather_data(lat: float, lon: float) -> Optional[Dict[str, Any]]:
+ """Fetch weather data from Open-Meteo API."""
+ url = (
+ f"https://api.open-meteo.com/v1/forecast?"
+ f"latitude={lat}&longitude={lon}"
+ f"¤t_weather=true"
+ f"&daily=temperature_2m_max,temperature_2m_min"
+ f"&timezone=auto"
+ f"&forecast_days=1"
+ )
+ return http_get_json(url, timeout=3)
+
+
+def format_weather_data(weather_data: Dict[str, Any], city: str) -> List[str]:
+ """Format weather data into the expected format."""
+ try:
+ current = weather_data["current_weather"]
+ daily = weather_data["daily"]
+
+ weather_code = current["weathercode"]
+ is_day = current.get("is_day", 1) # Default to day if missing
+
+ base_icon = WEATHER_ICON_MAP.get(weather_code, "weather-none-available")
+
+ # Determine day/night variant if applicable
+ icon_name = base_icon
+ if not is_day:
+ night_variant = f"{base_icon}-night"
+
+ # Fast check if night variant exists using predefined list or checking the path
+ # Since we know the variants from earlier, let's just optimistically build it
+ # then logic in svg_file will handle resolution seamlessly (fallbacks can be tricky, but we assume it's correct)
+ if base_icon in [
+ "weather-clear",
+ "weather-clouds",
+ "weather-few-clouds",
+ "weather-overcast",
+ "weather-showers",
+ "weather-showers-scattered",
+ "weather-snow",
+ "weather-snow-scattered",
+ "weather-storm",
+ ]:
+ icon_name = night_variant
+
+ condition = WEATHER_DESC_MAP.get(weather_code, "Unknown")
+ gradient_class = WEATHER_GRADIENT_MAP.get(weather_code, "weather-clear")
+
+ temp = f"{round(current['temperature'])}ยฐ"
+ max_temp = f"{round(daily['temperature_2m_max'][0])}ยฐ"
+ min_temp = f"{round(daily['temperature_2m_min'][0])}ยฐ"
+
+ return [icon_name, temp, condition, city, max_temp, min_temp, gradient_class]
+ except (KeyError, IndexError, TypeError) as e:
+ print(f"Error formatting weather data: {e}")
+ return None
+
+
+def get_weather(callback):
+ """Fetch weather data asynchronously."""
+
+ def fetch_weather():
+ location = get_location()
+ if not location:
+ return GLib.idle_add(callback, None)
+
+ cache_key = location.lower()
+ current_time = time.time()
+
+ if cache_key in _weather_cache:
+ cached_data, timestamp = _weather_cache[cache_key]
+ if current_time - timestamp < WEATHER_CACHE_TIMEOUT:
+ return GLib.idle_add(callback, cached_data)
+
+ coords = get_coordinates(location)
+ if not coords:
+ return GLib.idle_add(callback, None)
+
+ lat, lon = coords
+ weather_data = get_weather_data(lat, lon)
+ if not weather_data:
+ return GLib.idle_add(callback, None)
+
+ formatted_data = format_weather_data(weather_data, location)
+ if formatted_data:
+ _weather_cache[cache_key] = (formatted_data, current_time)
+ GLib.idle_add(callback, formatted_data)
+ else:
+ GLib.idle_add(callback, None)
+
+ executor.submit(fetch_weather)
+
+
+def update_weather(widget):
+ """Update weather widget with new data."""
+
+ def perform_fetch():
+ get_weather(lambda weather_info: update_widget(widget, weather_info))
+
+ debounced_perform_fetch = sync_debounce(1000, immediate=True)(perform_fetch)
+
+ def fetch_and_update():
+ debounced_perform_fetch()
+ return True
+
+ def initial_fetch():
+ debounced_perform_fetch()
+ return False
+
+ # Trigger first fetch immediately - MUST return False to not loop in idle
+ GLib.idle_add(initial_fetch)
+
+ return GLib.timeout_add_seconds(WEATHER_UPDATE_INTERVAL, fetch_and_update)
+
+
+def update_widget(widget, weather_info):
+ """Update widget labels with weather information."""
+ if weather_info:
+ widget.weatherinfo = weather_info
+ widget.update_labels(weather_info)
+
+
+class Weather(Box):
+ def __init__(self, parent, **kwargs):
+ super().__init__(
+ name="weather-widget",
+ h_expand=True,
+ v_expand=True,
+ justification="right",
+ orientation="v",
+ all_visible=False,
+ **kwargs,
+ )
+ self.parent = parent
+ self.weatherinfo = None
+ self._weather_timer_id = None
+ self._create_labels()
+ self._layout_labels()
+ self._weather_timer_id = update_weather(self)
+
+ def _create_labels(self):
+ self.header = Box(orientation="h", h_expand=True)
+ self.body = Box(orientation="v", v_expand=True, valign="end")
+
+ self.city = Label(
+ name="city",
+ label="Loading...",
+ justification="left",
+ h_align="start",
+ max_chars_width=15,
+ ellipsization="end",
+ )
+ self.temperature = Label(name="temperature", label="--ยฐ", h_align="start")
+ self.condition_em = svg_file(
+ "weather/weather-none-available.svg", size=(35, 35), name="condition-emoji"
+ )
+ self.condition = Label(
+ name="condition",
+ label="Loading...",
+ max_chars_width=18,
+ ellipsization="end",
+ h_align="start",
+ )
+ self.feels_like = Label(name="feels-like", label="L:-- H:--", h_align="start")
+
+ def _layout_labels(self):
+ # Header: City (left) and Icon (right)
+ self.header.add(self.city)
+ self.header.pack_end(self.condition_em, False, False, 0)
+
+ # Body: Temp, Condition, High/Low
+ self.body.add(self.temperature)
+ self.body.add(self.condition)
+ self.body.add(self.feels_like)
+
+ self.add(self.header)
+ self.add(self.body)
+
+ def update_labels(self, weather_info: List[str]):
+ if not weather_info or len(weather_info) != 7:
+ return
+ icon_name, temp, condition, location, maxtemp, mintemp, gradient_class = (
+ weather_info
+ )
+ maxmin = f"L:{mintemp} H:{maxtemp}"
+
+ self.city.set_label(location)
+ self.temperature.set_label(temp)
+
+ # update SVG File
+ self.condition_em.dynamic_file(f"weather/{icon_name}.svg")
+
+ self.condition.set_label(condition)
+ self.feels_like.set_label(maxmin)
+
+ # Apply gradient class to container
+ if hasattr(self, "parent") and self.parent:
+ # Remove old weather classes
+ for cls in self.parent.get_style_context().list_classes():
+ if cls.startswith("weather-"):
+ self.parent.remove_style_class(cls)
+ # Add new one
+ self.parent.add_style_class(gradient_class)
+
+ self.parent.set_visible(True)
+
+ def destroy(self):
+ """Cleanup weather update timer"""
+ if self._weather_timer_id:
+ GLib.source_remove(self._weather_timer_id)
+ self._weather_timer_id = None
+ super().destroy()
+
+
+class WeatherContainer(Box):
+ def __init__(self, **kwargs):
+ super().__init__(
+ orientation="v",
+ name="weather-container",
+ v_expand=True,
+ v_align="center",
+ size=(170, 170),
+ visible=True,
+ h_align="center",
+ children=[Weather(self)],
+ **kwargs,
+ )
+
+
+class Date(Box):
+ def __init__(self, **kwargs):
+ super().__init__(
+ name="date-widget",
+ h_expand=True,
+ v_expand=True,
+ justification="center",
+ h_align="center",
+ v_align="start",
+ orientation="v",
+ **kwargs,
+ )
+ self.top = Box(orientation="h", name="date-top", h_expand=True)
+ date_interval = 10000
+ self.dateone = DateTime(formatters=["%a"], interval=date_interval, name="day")
+ self.datetwo = DateTime(formatters=["%b"], interval=date_interval, name="month")
+ self.datethree = DateTime(
+ formatters=["%-d"], interval=date_interval, name="date"
+ )
+ self.top.add(self.dateone)
+ self.top.add(self.datetwo)
+ self.add(self.top)
+ self.add(self.datethree)
+
+
+class DateContainer(Box):
+ def __init__(self, **kwargs):
+ super().__init__(
+ orientation="v",
+ name="date-container",
+ v_expand=True,
+ size=(170, 170),
+ v_align="center",
+ h_align="center",
+ children=[Date()],
+ **kwargs,
+ )
+
+
+class Calendar(Box):
+ def __init__(self, **kwargs):
+ calendar.setfirstweekday(6)
+ super().__init__(
+ name="calendar-widget",
+ h_expand=True,
+ v_expand=True,
+ orientation="v",
+ **kwargs,
+ )
+ self._update_current_date()
+ self._create_header()
+ self._create_days_header()
+ self._create_calendar_grid()
+ self.add(self.month_label)
+ self.add(self.days_header)
+ self.add(self.calendar_grid)
+ self._calendar_timer_id = invoke_repeater(
+ CALENDAR_UPDATE_INTERVAL, self.update_calendar_if_needed
+ )
+
+ def destroy(self):
+ """Cleanup calendar update timer"""
+ if hasattr(self, "_calendar_timer_id") and self._calendar_timer_id:
+ GLib.source_remove(self._calendar_timer_id)
+ self._calendar_timer_id = None
+ super().destroy()
+
+ def _update_current_date(self):
+ now = datetime.datetime.now()
+ self.current_month, self.current_year, self.current_day = (
+ now.month,
+ now.year,
+ now.day,
+ )
+
+ def _create_header(self):
+ self.month_label = Label(
+ name="calendar-month",
+ label=calendar.month_name[self.current_month],
+ h_align="start",
+ justification="left",
+ )
+
+ def _create_days_header(self):
+ self.days_header = Box(
+ name="calendar-days-header", orientation="h", h_expand=True, spacing=2
+ )
+ for i, day_name in enumerate(["S", "M", "T", "W", "T", "F", "S"]):
+ self.days_header.add(
+ Label(
+ name="calendar-day-header-weekend"
+ if i in (0, 6)
+ else "calendar-day-header",
+ label=day_name,
+ h_align="center",
+ h_expand=True,
+ )
+ )
+
+ def _create_calendar_grid(self):
+ self.calendar_grid = Box(name="calendar-grid", orientation="v", spacing=1)
+ self.update_calendar()
+
+ def update_calendar_if_needed(self) -> bool:
+ now = datetime.datetime.now()
+ if (now.month, now.year, now.day) != (
+ self.current_month,
+ self.current_year,
+ self.current_day,
+ ):
+ self._update_current_date()
+ self.update_calendar()
+ return True
+
+ def update_calendar(self):
+ for child in self.calendar_grid.get_children():
+ self.calendar_grid.remove(child)
+ self.month_label.set_label(calendar.month_name[self.current_month])
+ cal = calendar.monthcalendar(self.current_year, self.current_month)
+ for week in cal:
+ week_box = Box(orientation="h", spacing=2, h_expand=True)
+ for i, day in enumerate(week):
+ if day == 0:
+ label = Label(
+ name="calendar-day-empty",
+ label="",
+ h_align="center",
+ h_expand=True,
+ )
+ else:
+ is_today = (
+ day == self.current_day
+ and self.current_month == datetime.datetime.now().month
+ )
+ name = (
+ "calendar-day-today"
+ if is_today
+ else ("calendar-day-weekend" if i in (0, 6) else "calendar-day")
+ )
+ label = Label(
+ name=name, label=str(day), h_align="center", h_expand=True
+ )
+ week_box.add(label)
+ self.calendar_grid.add(week_box)
+
+
+class CalendarContainer(Box):
+ def __init__(self, **kwargs):
+ super().__init__(
+ orientation="v",
+ name="calendar-box-widget",
+ v_expand=True,
+ size=(170, 170),
+ v_align="center",
+ h_align="center",
+ children=[Calendar()],
+ **kwargs,
+ )
+
+
+class SystemInfoBase(Box):
+ @staticmethod
+ def create_progress_bar(name: str = "progress-bar", size: int = 80, **kwargs):
+ return CircularProgressBar(
+ name=name,
+ start_angle=270,
+ end_angle=630,
+ min_value=0,
+ max_value=100,
+ size=size,
+ **kwargs,
+ )
+
+ def __init__(self, name: str, **kwargs):
+ super().__init__(
+ layer="bottom",
+ title="sysinfo",
+ name=name,
+ visible=True,
+ size=(170, 170),
+ h_expand=True,
+ v_expand=True,
+ all_visible=True,
+ **kwargs,
+ )
+ self.progress = self.create_progress_bar(name="progress")
+ self.main_label = Label(
+ label="0%\nLoading", justification="center", name="progress-label"
+ )
+ self.info_container = Box(
+ name="info-container", orientation="v", spacing=2, h_align="center"
+ )
+ self.add(
+ Box(
+ name="progress-bar-container",
+ h_expand=True,
+ v_expand=True,
+ orientation="v",
+ spacing=12,
+ h_align="center",
+ v_align="center",
+ children=[
+ Box(
+ children=[
+ Overlay(
+ child=self.progress,
+ tooltip_text="",
+ overlays=self.main_label,
+ )
+ ]
+ ),
+ Box(
+ h_align="center",
+ justification="centre",
+ orientation="v",
+ children=[self.info_container],
+ ),
+ ],
+ )
+ )
+
+ def start_updates(self):
+ self._system_timer_id = invoke_repeater(SYSTEM_UPDATE_INTERVAL, self.update)
+
+ def destroy(self):
+ """Cleanup system info update timer"""
+ if hasattr(self, "_system_timer_id") and self._system_timer_id:
+ GLib.source_remove(self._system_timer_id)
+ self._system_timer_id = None
+ super().destroy()
+
+ def create_info_line(
+ self, indicator_name: str, info_text: str, value_text: str
+ ) -> Box:
+ line = Box(
+ orientation="h",
+ spacing=4,
+ h_align="start",
+ children=[
+ Label(label="โ ", name=indicator_name),
+ Label(label=info_text, name="info-text"),
+ Label(label=value_text, name="info-value"),
+ ],
+ )
+ line.value_label = line.get_children()[2]
+ return line
+
+ def update(self) -> bool:
+ raise NotImplementedError
+
+
+class RamInfo(SystemInfoBase):
+ def __init__(self, **kwargs):
+ super().__init__("info-box-widget", **kwargs)
+ self.used_line = self.create_info_line("used-color-indicator", "Used", "0.0GB")
+ self.free_line = self.create_info_line("free-color-indicator", "Free", "0.0GB")
+ self.info_container.add(self.used_line)
+ self.info_container.add(self.free_line)
+ self.start_updates()
+
+ def update(self) -> bool:
+ try:
+ mem = psutil.virtual_memory()
+ self.main_label.set_label(f" {round(mem.percent):<2} %\nRAM")
+ self.used_line.value_label.set_label(f"{round(mem.used / (1024**3), 1)}GB")
+ self.free_line.value_label.set_label(
+ f"{round(mem.available / (1024**3), 1)}GB"
+ )
+ GLib.idle_add(self.progress.set_value, mem.percent)
+ except Exception as e:
+ print(f"Error: {e}")
+ return True
+
+
+class CpuInfo(SystemInfoBase):
+ def __init__(self, **kwargs):
+ super().__init__("info-box-widget", **kwargs)
+ self.temp_value = Label(label="0ยฐC", name="info-value")
+ self.info_container.add(
+ Box(
+ orientation="h",
+ spacing=4,
+ h_align="start",
+ children=[Label(label="Temp", name="info-text"), self.temp_value],
+ )
+ )
+ self.start_updates()
+
+ def get_cpu_temp(self) -> Optional[float]:
+ try:
+ temps = psutil.sensors_temperatures()
+ for name, entries in temps.items():
+ if any(s in name.lower() for s in ["coretemp", "k10temp", "cpu"]):
+ for entry in entries:
+ if any(
+ p in (entry.label or "").lower()
+ for p in ["package id 0", "core 0", ""]
+ ):
+ return round(entry.current, 1)
+ except Exception:
+ pass
+ return None
+
+ def update(self) -> bool:
+ try:
+ cpu = psutil.cpu_percent()
+ self.main_label.set_label(f" {round(cpu):<2} %\nCPU")
+ temp = self.get_cpu_temp()
+ self.temp_value.set_label(f"{temp}ยฐC" if temp else "N/A")
+ GLib.idle_add(self.progress.set_value, cpu)
+ except Exception as e:
+ print(f"Error: {e}")
+ return True
+
+
+class Deskwidgets:
+ """Desktop widgets manager - handles all desktop widgets."""
+
+ config = load_config()
+
+ def __init__(self):
+ # This class now just manages other windows instead of being one itself
+
+ # Create separate independent windows as attributes
+ self.top_left = Window(
+ anchor="top left",
+ title="modus-widgets-topleft",
+ exclusivity="none",
+ orientation="h",
+ layer="bottom",
+ visible=False, # Start hidden until content ready
+ child=Box(
+ name="desktop-widgets-container",
+ children=[
+ DateContainer(),
+ WeatherContainer(),
+ CalendarContainer(),
+ ],
+ ),
+ )
+
+ self.bottom_left = Window(
+ anchor="bottom right",
+ title="modus-widgets-bottomright",
+ orientation="h",
+ layer="bottom",
+ exclusivity="none",
+ visible=False, # Start hidden until content ready
+ child=Box(
+ name="desktop-widgets-container",
+ children=[
+ CpuInfo(),
+ RamInfo(),
+ ],
+ ),
+ )
+
+ # Show widgets after initialization is complete
+ self.top_left.set_visible(True)
+ self.bottom_left.set_visible(True)
diff --git a/modules/dock.py b/src/window/dock.py
similarity index 57%
rename from modules/dock.py
rename to src/window/dock.py
index 0c836f43..fa117b43 100644
--- a/modules/dock.py
+++ b/src/window/dock.py
@@ -1,9 +1,16 @@
import json
-import os
-import re
import subprocess
-from fabric.utils.helpers import get_desktop_applications, get_relative_path
+from fabric.utils import (
+ GLib,
+ Gtk,
+ get_desktop_applications,
+ get_relative_path,
+ logger,
+ os,
+ random,
+ re,
+)
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.eventbox import EventBox
@@ -11,18 +18,21 @@
from fabric.widgets.label import Label
from fabric.widgets.overlay import Overlay
from fabric.widgets.revealer import Revealer
-from gi.repository import GLib, Gtk
-from loguru import logger
+from fabric.widgets.wayland import WaylandWindow as Window
-import config.data as data
+from services.config import config, on_config_change
from services.modus import modus_service
-from utils.functions import read_json_file, write_json_file, is_special_workspace_id
+from utils.functions import (
+ clear_children,
+ is_special_workspace_id,
+ read_json_file,
+ write_json_file,
+)
from utils.icon_resolver import IconResolver
from utils.occlusion import check_occlusion
-from widgets.wayland import WaylandWindow as Window
# Pinned apps file
-PINNED_APPS_FILE = get_relative_path("../config/assets/dock.json")
+PINNED_APPS_FILE = get_relative_path("../../config/dock.json")
class AppBar(Box):
@@ -34,15 +44,10 @@ def __init__(self, parent: Window):
self.pinned_items_pos = []
self._parent = parent
- # Set orientation based on dock position
- orientation = (
- "vertical" if data.DOCK_POSITION in ["Left", "Right"] else "horizontal"
- )
-
super().__init__(
spacing=0,
name="dock",
- orientation=orientation,
+ orientation="horizontal",
children=[],
)
self.icon_resolver = IconResolver()
@@ -74,12 +79,56 @@ def update_running_apps():
logger.error(f"[AppBar] Error updating apps: {e}")
return True
- GLib.timeout_add(250, update_running_apps)
+ self._app_monitor_timer_id = GLib.timeout_add(250, update_running_apps)
GLib.idle_add(self.update_dock_apps)
+ def update_icon_size(self):
+ """Update all icons in the dock to the current config size"""
+ desktop_apps = get_desktop_applications(include_hidden=False)
+
+ # Update pinned apps
+ for app_id, button in self.pinned_buttons.items():
+ if hasattr(button, "icon_image"):
+ if app_id == "trash" or getattr(button, "is_trash", False):
+ icon_size = config().get("dock_icon_size", 52)
+ pixbuf = self.icon_resolver.get_icon_pixbuf("user-trash", icon_size)
+ button.icon_image.set_from_pixbuf(pixbuf)
+ else:
+ # Find app data to get icon
+ app_data = next(
+ (
+ a
+ for a in self.pinned_apps
+ if self._matches_app_identifier(a, app_id)
+ ),
+ app_id,
+ )
+ app = self._find_desktop_app(app_id, desktop_apps)
+ pixbuf = self._get_app_icon(app_data, app)
+ button.icon_image.set_from_pixbuf(pixbuf)
+
+ # Update running apps
+ for addr, button in self.client_buttons.items():
+ if hasattr(button, "icon_image"):
+ app_class = getattr(button, "app_class", "")
+ app = self._find_desktop_app(app_class, desktop_apps)
+ pixbuf = self._get_app_icon(app_class, app)
+ button.icon_image.set_from_pixbuf(pixbuf)
+
+ def destroy(self):
+ """Clean up timers and children"""
+ if hasattr(self, "_app_monitor_timer_id") and self._app_monitor_timer_id:
+ GLib.source_remove(self._app_monitor_timer_id)
+ self._app_monitor_timer_id = None
+
+ # Destroy context menu
+ if self.menu:
+ self.menu.destroy()
+
+ super().destroy()
+
def _populate_pinned_apps(self):
- for child in self.pinned_apps_container.get_children():
- self.pinned_apps_container.remove(child)
+ clear_children(self.pinned_apps_container)
self.pinned_buttons = {}
self.pinned_items_pos = []
@@ -96,36 +145,25 @@ def _populate_pinned_apps(self):
self._create_trash_button()
def _create_pinned_button(self, app_data, desktop_apps):
- if isinstance(app_data, dict):
- app_identifier = app_data.get("name", "") or app_data.get(
- "window_class", ""
+ app = self._find_desktop_app(app_data, desktop_apps)
+ app_identifier = self._get_app_identifier(app_data)
+ display_name = (
+ app.display_name
+ if app
+ else (
+ app_data.get("display_name", app_identifier)
+ if isinstance(app_data, dict)
+ else app_identifier
)
- display_name = app_data.get("display_name", app_identifier)
- app = self._find_desktop_app_from_data(app_data, desktop_apps)
-
- if app:
- icon_pixbuf = app.get_icon_pixbuf(data.DOCK_ICON_SIZE)
- else:
- icon_name = app_data.get("window_class", "") or app_data.get("name", "")
- icon_pixbuf = self.icon_resolver.get_icon_pixbuf(
- icon_name, data.DOCK_ICON_SIZE
- )
- else:
- app_identifier = app_data
- app = self._find_desktop_app_by_id(app_data, desktop_apps)
- if not app:
- return
-
- display_name = app.display_name or app.name
- icon_pixbuf = app.get_icon_pixbuf(data.DOCK_ICON_SIZE)
+ )
- pinned_image = Image(name="dock_item_icon")
- pinned_image.set_from_pixbuf(icon_pixbuf)
+ icon_pixbuf = self._get_app_icon(app_data, app)
+ icon_image = Image(name="dock_item_icon", pixbuf=icon_pixbuf)
main_container = Box(
name="dock_item_main_container",
orientation="v",
- children=[pinned_image],
+ children=[icon_image],
)
pinned_button = Button(
@@ -135,34 +173,46 @@ def _create_pinned_button(self, app_data, desktop_apps):
on_button_press_event=lambda _, event: self._handle_pinned_app_click(
event, app_data
),
- on_enter_notify_event=lambda *_: self._handle_item_hovered(
- pinned_button, True
+ on_enter_notify_event=lambda *_: self._set_item_hover_state(
+ pinned_button, True, pinned=True
),
- on_leave_notify_event=lambda *_: self._handle_item_unhovered(
- pinned_button, True
+ on_leave_notify_event=lambda *_: self._set_item_hover_state(
+ pinned_button, False, pinned=True
),
)
pinned_button.add_style_class("shown")
+ pinned_button.icon_image = icon_image
self.pinned_buttons[app_identifier] = pinned_button
self.pinned_apps_container.add(pinned_button)
self.pinned_items_pos.append(pinned_button)
- def _create_trash_button(self):
- """Create a trash button that opens the trash in file manager"""
- # Get trash icon
- trash_icon_pixbuf = self.icon_resolver.get_icon_pixbuf(
- "user-trash", data.DOCK_ICON_SIZE
+ def _get_app_icon(self, app_data, app=None):
+ icon_size = config().get("dock_icon_size", 52)
+ if app:
+ return app.get_icon_pixbuf(icon_size)
+
+ icon_name = ""
+ if isinstance(app_data, dict):
+ icon_name = app_data.get("window_class") or app_data.get("name")
+ elif isinstance(app_data, str):
+ icon_name = app_data
+
+ return self.icon_resolver.get_icon_pixbuf(
+ icon_name or "application-x-executable", icon_size
)
- trash_image = Image(name="dock_item_icon")
- trash_image.set_from_pixbuf(trash_icon_pixbuf)
+ def _create_trash_button(self):
+ """Create a trash button that opens the trash in file manager"""
+ icon_size = config().get("dock_icon_size", 52)
+ trash_icon_pixbuf = self.icon_resolver.get_icon_pixbuf("user-trash", icon_size)
+ trash_icon_image = Image(name="dock_item_icon", pixbuf=trash_icon_pixbuf)
main_container = Box(
name="dock_item_main_container",
orientation="v",
- children=[trash_image],
+ children=[trash_icon_image],
)
trash_button = Button(
@@ -170,67 +220,113 @@ def _create_trash_button(self):
child=main_container,
tooltip_text="Trash",
on_button_press_event=lambda _, event: self._handle_trash_click(event),
- on_enter_notify_event=lambda *_: self._handle_item_hovered(
- trash_button, True
+ on_enter_notify_event=lambda *_: self._set_item_hover_state(
+ trash_button, True, pinned=True
),
- on_leave_notify_event=lambda *_: self._handle_item_unhovered(
- trash_button, True
+ on_leave_notify_event=lambda *_: self._set_item_hover_state(
+ trash_button, False, pinned=True
),
)
trash_button.add_style_class("shown")
trash_button.is_trash = True
+ trash_button.icon_image = trash_icon_image
self.pinned_buttons["trash"] = trash_button
self.pinned_apps_container.add(trash_button)
self.pinned_items_pos.append(trash_button)
- def _find_desktop_app_from_data(self, app_data: dict, desktop_apps):
+ def _find_desktop_app(self, app_info, desktop_apps):
+ """Find a desktop app from either a string ID or a data dictionary"""
+ if not app_info:
+ return None
+
+ # Extract normalized search terms
+ search_terms = []
+ if isinstance(app_info, dict):
+ search_terms = [
+ app_info.get("window_class"),
+ app_info.get("name"),
+ app_info.get("executable"),
+ ]
+ else:
+ search_terms = [app_info]
+
+ search_terms = [s.lower() for s in search_terms if s and isinstance(s, str)]
+ if not search_terms:
+ return None
+
+ # Handle Reverse DNS or decorated names (e.g., org.gnome.Nautilus, float_kitty)
+ expanded_terms = list(search_terms)
+ for term in search_terms:
+ # Handle dots (Reverse DNS)
+ if "." in term:
+ parts = term.split(".")
+ if parts[-1] and parts[-1] not in expanded_terms:
+ expanded_terms.append(parts[-1])
+
+ # Handle underscores and hyphens (decorated names)
+ for sep in ["_", "-"]:
+ if sep in term:
+ for part in term.split(sep):
+ if len(part) > 2 and part not in expanded_terms:
+ expanded_terms.append(part)
+
+ # Priority 1: Exact matches for window_class
for app in desktop_apps:
- if (
- (
- app_data.get("name")
- and app.name
- and app.name.lower() == app_data["name"].lower()
- )
- or (
- app_data.get("window_class")
- and hasattr(app, "window_class")
- and app.window_class
- and app.window_class.lower() == app_data["window_class"].lower()
- )
- or (
- app_data.get("executable")
- and app.executable
- and (
- app.executable.lower() == app_data["executable"].lower()
- or os.path.basename(app.executable).lower()
- == os.path.basename(app_data["executable"]).lower()
- )
- )
- ):
+ app_class = getattr(app, "window_class", None)
+ if app_class and app_class.lower() in search_terms:
return app
- return None
- def _find_desktop_app_by_id(self, app_id: str, desktop_apps):
+ # Priority 2: Exact matches for app name or display name
for app in desktop_apps:
- if (
- (app.name and app.name.lower() == app_id.lower())
- or (app.display_name and app.display_name.lower() == app_id.lower())
- or (
- hasattr(app, "window_class")
- and app.window_class
- and app.window_class.lower() == app_id.lower()
- )
- or (
- app.executable
- and (
- app.executable.lower() == app_id.lower()
- or os.path.basename(app.executable).lower() == app_id.lower()
- )
- )
- ):
+ app_names = [
+ getattr(app, "name", None),
+ getattr(app, "display_name", None),
+ ]
+ app_names = [n.lower() for n in app_names if n]
+ if any(name in expanded_terms for name in app_names):
return app
+
+ # Priority 3: Exact matches for executable basename
+ for app in desktop_apps:
+ if app.executable:
+ exe_base = os.path.basename(app.executable).lower()
+ if exe_base in expanded_terms:
+ # If this is a common terminal or shell, only match if the name also matches
+ # This prevents Neovim (which uses 'kitty' as its executable wrapper) from overriding Kitty
+ common_wrappers = [
+ "kitty",
+ "bash",
+ "sh",
+ "zsh",
+ "python",
+ "python3",
+ ]
+ if exe_base in common_wrappers:
+ app_names = [
+ getattr(app, "name", ""),
+ getattr(app, "display_name", ""),
+ ]
+ if not any(exe_base in n.lower() for n in app_names if n):
+ continue
+ return app
+
+ # Priority 4: Fuzzy name matching (containment)
+ for app in desktop_apps:
+ app_terms = [
+ getattr(app, "name", ""),
+ getattr(app, "display_name", ""),
+ getattr(app, "window_class", ""),
+ ]
+ app_terms = [t.lower() for t in app_terms if t]
+
+ for term in expanded_terms:
+ if len(term) < 3:
+ continue
+ if any(term in app_term for app_term in app_terms):
+ return app
+
return None
def show_menu(self, app_id: str, client=None, instance_address=None):
@@ -272,13 +368,12 @@ def _close_running_app(self, instance_address):
logger.error(f"[AppBar] Error closing window: {e}")
def _handle_pinned_app_click(self, event, app_data):
+ app_identifier = self._get_app_identifier(app_data)
if event.button == 1: # Left click - launch app
- self._launch_app_data(app_data)
+ self._launch_app(app_data)
elif event.button == 2: # Middle click - unpin app
- app_identifier = self._get_app_identifier(app_data)
self._unpin_app(app_identifier)
elif event.button == 3: # Right click - show context menu
- app_identifier = self._get_app_identifier(app_data)
self.show_menu(app_identifier)
self.menu.popup_at_pointer(event)
@@ -309,97 +404,60 @@ def _handle_trash_click(self, event):
except Exception as e:
logger.error(f"[AppBar] Error opening trash: {e}")
- def _handle_item_hovered(self, item, pinned=False):
- if pinned:
- try:
- index = self.pinned_items_pos.index(item)
- if index > 0:
- self.pinned_items_pos[index - 1].add_style_class("semi_hovered")
- if index < len(self.pinned_items_pos) - 1:
- self.pinned_items_pos[index + 1].add_style_class("semi_hovered")
- except ValueError:
- pass
- else:
- try:
- index = self.running_items_pos.index(item)
- if index > 0:
- self.running_items_pos[index - 1].add_style_class("semi_hovered")
- if index < len(self.running_items_pos) - 1:
- self.running_items_pos[index + 1].add_style_class("semi_hovered")
- except ValueError:
- pass
-
- def _handle_item_unhovered(self, item, pinned=False):
- if pinned:
- try:
- index = self.pinned_items_pos.index(item)
- if index > 0:
- self.pinned_items_pos[index - 1].remove_style_class("semi_hovered")
- if index < len(self.pinned_items_pos) - 1:
- self.pinned_items_pos[index + 1].remove_style_class("semi_hovered")
- except ValueError:
- pass
- else:
- try:
- index = self.running_items_pos.index(item)
- if index > 0:
- self.running_items_pos[index - 1].remove_style_class("semi_hovered")
- if index < len(self.running_items_pos) - 1:
- self.running_items_pos[index + 1].remove_style_class("semi_hovered")
- except ValueError:
- pass
-
- def _get_app_identifier(self, app_data):
- if isinstance(app_data, dict):
- return app_data.get("name", "") or app_data.get("window_class", "")
- return app_data
+ def _set_item_hover_state(self, item, is_hovered, pinned=False):
+ if is_hovered:
+ self._parent.on_hover_enter()
- def _launch_app_data(self, app_data):
+ items_list = self.pinned_items_pos if pinned else self.running_items_pos
try:
- desktop_apps = get_desktop_applications(include_hidden=False)
+ index = items_list.index(item)
+ style_class = "semi_hovered"
+ action = "add" if is_hovered else "remove"
+
+ if index > 0:
+ getattr(items_list[index - 1], f"{action}_style_class")(style_class)
+ if index < len(items_list) - 1:
+ getattr(items_list[index + 1], f"{action}_style_class")(style_class)
+ except (ValueError, IndexError):
+ pass
+
+ def _launch_app(self, app_info):
+ """Launch an app using its desktop app instance or a data dictionary"""
+ try:
+ command_line = ""
+ if hasattr(app_info, "command_line"):
+ command_line = app_info.command_line
+ elif isinstance(app_info, dict):
+ command_line = app_info.get("command_line") or app_info.get(
+ "executable"
+ )
+ elif isinstance(app_info, str):
+ command_line = app_info
- if isinstance(app_data, dict):
- app = self._find_desktop_app_from_data(app_data, desktop_apps)
- if app:
- self._launch_app(app)
- else:
- self._launch_app_from_data(app_data)
- else:
- app = self._find_desktop_app_by_id(app_data, desktop_apps)
+ if not command_line:
+ # Fallback to desktop app search if it's just a string class
+ desktop_apps = get_desktop_applications(include_hidden=False)
+ app = self._find_desktop_app(app_info, desktop_apps)
if app:
- self._launch_app(app)
- except Exception as e:
- logger.error(f"[AppBar] Failed to launch app: {e}")
+ command_line = app.command_line
- def _launch_app(self, app):
- try:
- cleaned_command = re.sub(r"%\w+", "", app.command_line).strip()
- final_command = f"hyprctl dispatch exec 'uwsm app -- {cleaned_command}'"
- subprocess.Popen(final_command, shell=True)
- except Exception:
- try:
- app.launch()
- except Exception as fallback_error:
- logger.error(f"[AppBar] Failed to launch app: {fallback_error}")
-
- def _launch_app_from_data(self, app_data):
- try:
- command_line = app_data.get("command_line", "")
if command_line:
cleaned_command = re.sub(r"%\w+", "", command_line).strip()
final_command = f"hyprctl dispatch exec 'uwsm app -- {cleaned_command}'"
subprocess.Popen(final_command, shell=True)
- elif app_data.get("executable"):
- final_command = (
- f"hyprctl dispatch exec 'uwsm app -- {app_data['executable']}'"
- )
- subprocess.Popen(final_command, shell=True)
+ elif hasattr(app_info, "launch"):
+ app_info.launch()
else:
logger.error(
- f"[AppBar] No command or executable found for app: {app_data}"
+ f"[AppBar] Could not determine launch command for: {app_info}"
)
except Exception as e:
- logger.error(f"[AppBar] Failed to launch app from data: {e}")
+ logger.error(f"[AppBar] Failed to launch app: {e}")
+
+ def _get_app_identifier(self, app_data):
+ if isinstance(app_data, dict):
+ return app_data.get("name", "") or app_data.get("window_class", "")
+ return app_data
def _pin_app(self, app_class: str):
if self._is_app_pinned(app_class):
@@ -407,25 +465,17 @@ def _pin_app(self, app_class: str):
try:
desktop_apps = get_desktop_applications(include_hidden=False)
- app = self._find_desktop_app_by_id(app_class, desktop_apps)
-
- if app:
- app_data = {
- "name": app.name,
- "display_name": app.display_name or app.name,
- "window_class": getattr(app, "window_class", None) or app_class,
- "executable": app.executable,
- "command_line": app.command_line,
- }
- else:
- app_data = {
- "name": app_class,
- "display_name": app_class,
- "window_class": app_class,
- "executable": app_class,
- "command_line": app_class,
- }
-
+ app = self._find_desktop_app(app_class, desktop_apps)
+
+ app_data = {
+ "name": app.name if app else app_class,
+ "display_name": (app.display_name or app.name) if app else app_class,
+ "window_class": (getattr(app, "window_class", None) or app_class)
+ if app
+ else app_class,
+ "executable": app.executable if app else app_class,
+ "command_line": app.command_line if app else app_class,
+ }
self.pinned_apps.append(app_data)
except Exception:
self.pinned_apps.append(app_class)
@@ -489,12 +539,16 @@ def update_dock_apps(self):
try:
clients = self.get_clients()
focused_window = self.get_focused_window()
- focused_address = focused_window.get("address", "") if focused_window else ""
+ focused_address = (
+ focused_window.get("address", "") if focused_window else ""
+ )
current_instance_ids = set()
for client in clients:
- if client.get("hidden", False) or not self._should_show_app_instance(client):
+ if client.get("hidden", False) or not self._should_show_app_instance(
+ client
+ ):
continue
instance_address = client.get("address", "")
@@ -519,7 +573,7 @@ def update_dock_apps(self):
self._update_separator_visibility()
self._cleanup_removed_instances(current_instance_ids)
-
+
except Exception as e:
logger.error(f"[AppBar] Error in update_dock_apps: {e}")
@@ -550,8 +604,11 @@ def _cleanup_removed_instances(self, current_instance_ids):
]
# Clean up removed and orphaned buttons
- for instance_id in buttons_to_remove + [k for k, v in self.client_buttons.items()
- if not hasattr(v, 'instance_address') or not v.get_parent()]:
+ for instance_id in buttons_to_remove + [
+ k
+ for k, v in self.client_buttons.items()
+ if not hasattr(v, "instance_address") or not v.get_parent()
+ ]:
if instance_id in self.client_buttons:
button = self.client_buttons.pop(instance_id)
try:
@@ -571,15 +628,8 @@ def create_instance_button(self, instance_address, client, app_class):
try:
desktop_apps = get_desktop_applications(include_hidden=False)
- desktop_app = self._find_desktop_app_by_id(app_class, desktop_apps)
-
- if desktop_app:
- pixbuf = desktop_app.get_icon_pixbuf(data.DOCK_ICON_SIZE)
- else:
- pixbuf = self.icon_resolver.get_icon_pixbuf(
- app_class, data.DOCK_ICON_SIZE
- )
-
+ app = self._find_desktop_app(app_class, desktop_apps)
+ pixbuf = self._get_app_icon(app_class, app)
client_image.set_from_pixbuf(pixbuf)
except Exception as e:
logger.warning(f"[AppBar] Could not load icon for {app_class}: {e}")
@@ -616,11 +666,11 @@ def create_instance_button(self, instance_address, client, app_class):
on_button_press_event=lambda widget, event: self.handle_instance_click(
widget, event
),
- on_enter_notify_event=lambda *_: self._handle_item_hovered(
- client_button, False
+ on_enter_notify_event=lambda *_: self._set_item_hover_state(
+ client_button, True, pinned=False
),
- on_leave_notify_event=lambda *_: self._handle_item_unhovered(
- client_button, False
+ on_leave_notify_event=lambda *_: self._set_item_hover_state(
+ client_button, False, pinned=False
),
)
@@ -628,14 +678,17 @@ def create_instance_button(self, instance_address, client, app_class):
client_button.client_data = client
client_button.app_class = app_class
client_button.workspace_label = workspace_label
+ client_button.icon_image = client_image
client_button.add_style_class("shown")
self.client_buttons[instance_address] = client_button
self.running_apps_container.add(client_button)
self.running_items_pos.append(client_button)
-
+
except Exception as e:
- logger.error(f"[AppBar] Error creating instance button for {app_class}: {e}")
+ logger.error(
+ f"[AppBar] Error creating instance button for {app_class}: {e}"
+ )
def _get_workspace_id(self, client):
workspace_data = client.get("workspace", {})
@@ -645,18 +698,14 @@ def _get_workspace_id(self, client):
return workspace_data
return None
- def _is_special_workspace_id(self, ws_id):
- return is_special_workspace_id(ws_id)
-
def _should_show_app_instance(self, client):
- if not data.DOCK_HIDE_SPECIAL_WORKSPACE_APPS:
+ if not config().get("dock_hide_special_workspace_apps", True):
return True
workspace_id = self._get_workspace_id(client)
- if workspace_id is None:
- return True
-
- return not self._is_special_workspace_id(workspace_id)
+ return (
+ False if workspace_id is None else not is_special_workspace_id(workspace_id)
+ )
def update_instance_button(self, instance_address, client, app_class):
if instance_address not in self.client_buttons:
@@ -675,27 +724,30 @@ def update_instance_button(self, instance_address, client, app_class):
existing_label = getattr(button, "workspace_label", None)
container = button.get_child()
- if hasattr(container, "get_children"):
- children = container.get_children()
- if children:
- image_overlay = children[0]
- if isinstance(image_overlay, Overlay):
- # Remove existing workspace label
- if existing_label and existing_label.get_parent():
- image_overlay.remove_overlay(existing_label)
-
- # Add new workspace label if needed
- if workspace_id is not None:
- new_label = Label(
- label=str(workspace_id),
- name="workspace-indicator",
- h_align="end",
- v_align="end",
- )
- image_overlay.add_overlay(new_label)
- button.workspace_label = new_label
- else:
- button.workspace_label = None
+ if not hasattr(container, "get_children"):
+ return
+
+ children = container.get_children()
+ if not children or not isinstance(children[0], Overlay):
+ return
+
+ image_overlay = children[0]
+ # Remove existing workspace label
+ if existing_label and existing_label.get_parent():
+ image_overlay.remove_overlay(existing_label)
+
+ # Add new workspace label if needed
+ if workspace_id is not None:
+ new_label = Label(
+ label=str(workspace_id),
+ name="workspace-indicator",
+ h_align="end",
+ v_align="end",
+ )
+ image_overlay.add_overlay(new_label)
+ button.workspace_label = new_label
+ else:
+ button.workspace_label = None
def handle_instance_click(self, button_widget, event):
instance_address = getattr(button_widget, "instance_address", None)
@@ -736,23 +788,13 @@ def _update_separator_visibility(self):
class Dock(Window):
def __init__(self):
- if not data.DOCK_ENABLED:
- anchor = self._get_anchor_from_position()
- super().__init__(layer="top", title="dock", anchor=anchor)
- self.children = Box() # Empty dock if disabled
- return
-
- anchor = self._get_anchor_from_position()
- super().__init__(layer="top", anchor=anchor)
+ super().__init__(layer="top", anchor="bottom center", title="modus-dock")
self.app_bar = AppBar(self)
-
- transition_type = self._get_transition_type()
-
self.revealer = Revealer(
child=Box(children=[self.app_bar], style="padding: 20px 50px 5px 50px;"),
transition_duration=200,
- transition_type=transition_type,
+ transition_type="slide-up",
)
self.children = EventBox(
@@ -762,64 +804,86 @@ def __init__(self):
on_leave_notify_event=lambda *_: self.on_hover_leave(),
)
- self.revealer.set_reveal_child(True)
- self.app_bar.add_style_class("shown")
+ self._update_visibility()
self.dock_height = 100
self.is_hovered = False
- self.hide_timeout_id = None
+ self.hide_ticket = 0
- # Only setup occlusion monitoring if auto-hide is enabled
- if data.DOCK_AUTO_HIDE:
+ # Subscribe to config changes for hot-reloading
+ on_config_change(self._on_config_change)
+
+ if config().get("dock_auto_hide", True):
self.setup_occlusion_monitoring()
+ def _on_config_change(self, new_config, old_config):
+ # Handle dock visibility
+ if config().has_changed("dock_enabled", old_config):
+ self._update_visibility()
+
+ # Handle icon size changes
+ if config().has_changed("dock_icon_size", old_config):
+ self.app_bar.update_icon_size()
+
+ # Handle auto-hide changes
+ if config().has_changed("dock_auto_hide", old_config):
+ if new_config.get("dock_auto_hide", True):
+ self.setup_occlusion_monitoring()
+ else:
+ if hasattr(self, "_occlusion_timer_id") and self._occlusion_timer_id:
+ GLib.source_remove(self._occlusion_timer_id)
+ self._occlusion_timer_id = None
+ self.revealer.set_reveal_child(True)
+ self.app_bar.add_style_class("shown")
+
+ # Handle special workspace apps visibility
+ if config().has_changed("dock_hide_special_workspace_apps", old_config):
+ self.app_bar.update_dock_apps()
+
+ def _update_visibility(self):
+ enabled = config().get("dock_enabled", True)
+ if enabled:
+ self.show()
+ self.revealer.set_reveal_child(True)
+ self.app_bar.add_style_class("shown")
+ else:
+ self.hide()
+
def on_hover_enter(self):
self.is_hovered = True
- if self.hide_timeout_id:
- GLib.source_remove(self.hide_timeout_id)
- self.hide_timeout_id = None
+ self.hide_ticket = random.getrandbits(32)
self.revealer.set_reveal_child(True)
self.app_bar.add_style_class("shown")
def on_hover_leave(self):
self.is_hovered = False
- # Add small delay before potential hiding to prevent rapid show/hide cycles
- if self.hide_timeout_id:
- GLib.source_remove(self.hide_timeout_id)
- self.hide_timeout_id = GLib.timeout_add(100, lambda: None)
-
- def _get_anchor_from_position(self):
- if data.DOCK_POSITION == "Left":
- return "left center"
- elif data.DOCK_POSITION == "Right":
- return "right center"
- else: # Bottom (default)
- return "bottom center"
-
- def _get_transition_type(self):
- if data.DOCK_POSITION == "Left":
- return "slide-right"
- elif data.DOCK_POSITION == "Right":
- return "slide-left"
- else: # Bottom (default)
- return "slide-up"
-
- def _get_occlusion_position(self):
- if data.DOCK_POSITION == "Left":
- return ("left", self.dock_height)
- elif data.DOCK_POSITION == "Right":
- return ("right", self.dock_height)
- else: # Bottom (default)
- return ("bottom", self.dock_height)
+ self.hide_ticket = random.getrandbits(32)
+
+ def delayed_hide(ticket):
+ if ticket == self.hide_ticket and not self.is_hovered:
+ if config().get("dock_auto_hide", True):
+ is_occluded = config().get(
+ "dock_always_occluded", False
+ ) or check_occlusion(("bottom", self.dock_height))
+ if is_occluded:
+ self.revealer.set_reveal_child(False)
+ self.app_bar.remove_style_class("shown")
+ return False
+
+ GLib.timeout_add(500, delayed_hide, self.hide_ticket)
def setup_occlusion_monitoring(self):
+ # Remove existing timer if any
+ if hasattr(self, "_occlusion_timer_id") and self._occlusion_timer_id:
+ GLib.source_remove(self._occlusion_timer_id)
+ self._occlusion_timer_id = None
+
def check_dock_occlusion():
try:
- if data.DOCK_ALWAYS_OCCLUDED:
+ if config().get("dock_always_occluded", False):
is_occluded = True
else:
- occlusion_position = self._get_occlusion_position()
- is_occluded = check_occlusion(occlusion_position)
+ is_occluded = check_occlusion(("bottom", self.dock_height))
if (
is_occluded
@@ -840,4 +904,15 @@ def check_dock_occlusion():
return True
- GLib.timeout_add(300, check_dock_occlusion)
+ self._occlusion_timer_id = GLib.timeout_add(300, check_dock_occlusion)
+
+ def destroy(self):
+ """Clean up timers and children"""
+ if hasattr(self, "_occlusion_timer_id") and self._occlusion_timer_id:
+ GLib.source_remove(self._occlusion_timer_id)
+ self._occlusion_timer_id = None
+
+ if hasattr(self, "app_bar") and self.app_bar:
+ self.app_bar.destroy()
+
+ super().destroy()
diff --git a/modules/launcher/__init__.py b/src/window/launcher/__init__.py
similarity index 100%
rename from modules/launcher/__init__.py
rename to src/window/launcher/__init__.py
diff --git a/modules/launcher/main.py b/src/window/launcher/main.py
similarity index 98%
rename from modules/launcher/main.py
rename to src/window/launcher/main.py
index 30ada343..df6cdc23 100644
--- a/modules/launcher/main.py
+++ b/src/window/launcher/main.py
@@ -1,16 +1,16 @@
from typing import List, Optional, Tuple
-from gi.repository import Gdk, GLib
-
from fabric.core.service import Property
+from fabric.utils import Gdk, GLib
from fabric.widgets.box import Box
from fabric.widgets.entry import Entry
-from modules.launcher.plugin_manager import PluginManager
-from modules.launcher.result import Result
-from modules.launcher.result_item import ResultItem
-from modules.launcher.trigger_config import TriggerConfig
from fabric.widgets.scrolledwindow import ScrolledWindow
-from widgets.wayland import WaylandWindow as Window
+from fabric.widgets.wayland import WaylandWindow as Window
+
+from window.launcher.plugin_manager import PluginManager
+from window.launcher.result import Result
+from window.launcher.result_item import ResultItem
+from window.launcher.trigger_config import TriggerConfig
# Constants
SEARCH_DEBOUNCE_MS = 50
@@ -720,7 +720,10 @@ def _update_results_display(self):
# Clear existing results
for child in self.results_box.get_children():
- self.results_box.remove(child)
+ if isinstance(child, ResultItem):
+ child.destroy()
+ else:
+ self.results_box.remove(child)
# Add new results
for i, result in enumerate(self.results):
@@ -790,7 +793,10 @@ def _clear_results(self):
self.results = []
self.selected_index = 0
for child in self.results_box.get_children():
- self.results_box.remove(child)
+ if isinstance(child, ResultItem):
+ child.destroy()
+ else:
+ self.results_box.remove(child)
# Keep the results scroll visible even when empty
def _handle_escape_key(self) -> bool:
@@ -847,8 +853,11 @@ def check_trigger_after_backspace():
if should_exit_trigger:
self.triggered_plugin = None
self.active_trigger = ""
- self._clear_results()
- # Don't clear the text - let the user's edit stand
+
+ # Trigger a search with the new text instead of just clearing results
+ # This ensures we show applications if empty, or normal search results if not
+ self.query = new_text
+ GLib.timeout_add(50, self._perform_search, new_text)
# If we're still in trigger mode but the text changed, update the search
elif self.triggered_plugin and new_text != current_text:
@@ -1040,7 +1049,7 @@ def _find_focused_entry_in_widget(self, widget):
return widget
# Also check if this is the only Entry in the widget (likely to be the target)
return widget
- except:
+ except Exception:
# If focus checking fails, assume this Entry should handle the event
return widget
@@ -1207,7 +1216,7 @@ def _scroll_to_widget(self, widget):
allocation = selected_child.get_allocation()
item_height = allocation.height if allocation.height > 0 else 68
item_y = allocation.y
- except:
+ except Exception:
# Fallback to estimation
item_height = DEFAULT_ITEM_HEIGHT
item_y = self.selected_index * item_height
diff --git a/modules/launcher/plugin_base.py b/src/window/launcher/plugin_base.py
similarity index 99%
rename from modules/launcher/plugin_base.py
rename to src/window/launcher/plugin_base.py
index 13bd1fcd..f0898ecf 100644
--- a/modules/launcher/plugin_base.py
+++ b/src/window/launcher/plugin_base.py
@@ -1,7 +1,7 @@
from abc import ABC, abstractmethod
from typing import List
-from modules.launcher.result import Result
+from window.launcher.result import Result
class PluginBase(ABC):
diff --git a/modules/launcher/plugin_manager.py b/src/window/launcher/plugin_manager.py
similarity index 92%
rename from modules/launcher/plugin_manager.py
rename to src/window/launcher/plugin_manager.py
index caf002e9..5692375c 100644
--- a/modules/launcher/plugin_manager.py
+++ b/src/window/launcher/plugin_manager.py
@@ -3,7 +3,7 @@
import os
from typing import Dict, List, Type
-from modules.launcher.plugin_base import PluginBase
+from window.launcher.plugin_base import PluginBase
class PluginManager:
@@ -47,16 +47,16 @@ def _load_plugin_from_file(self, plugins_dir: str, plugin_name: str):
try:
plugin_path = os.path.join(plugins_dir, f"{plugin_name}.py")
spec = importlib.util.spec_from_file_location(
- f"modules.launcher.plugins.{plugin_name}", plugin_path
+ f"window.launcher.plugins.{plugin_name}", plugin_path
)
if spec and spec.loader:
module = importlib.util.module_from_spec(spec)
- # Add the module to sys.modules to support relative imports
+ # Add the module to sys.window to support relative imports
import sys
- sys.modules[f"modules.launcher.plugins.{plugin_name}"] = module
+ sys.modules[f"window.launcher.plugins.{plugin_name}"] = module
spec.loader.exec_module(module)
@@ -79,7 +79,6 @@ def _activate_default_plugins(self):
default_plugins = [
"applications",
"calculator",
- "system",
"clipboard",
"power",
"caffeine",
@@ -87,12 +86,6 @@ def _activate_default_plugins(self):
"emoji",
"wallpaper",
"websearch",
- "reminders",
- "otp",
- "password",
- "bookmarks",
- "bash_scripts",
- "tmux",
]
for plugin_name in default_plugins:
diff --git a/modules/launcher/plugins/__init__.py b/src/window/launcher/plugins/__init__.py
similarity index 100%
rename from modules/launcher/plugins/__init__.py
rename to src/window/launcher/plugins/__init__.py
diff --git a/modules/launcher/plugins/applications.py b/src/window/launcher/plugins/applications.py
similarity index 95%
rename from modules/launcher/plugins/applications.py
rename to src/window/launcher/plugins/applications.py
index 832d75b8..fa272a8d 100644
--- a/modules/launcher/plugins/applications.py
+++ b/src/window/launcher/plugins/applications.py
@@ -1,13 +1,12 @@
import json
-import re
-from typing import List
import subprocess
+from typing import List
+
+from fabric.utils import DesktopApp, get_desktop_applications, get_relative_path, re
-from fabric.utils import DesktopApp
-from fabric.utils.helpers import get_desktop_applications, get_relative_path
-from modules.launcher.plugin_base import PluginBase
-from modules.launcher.result import Result
from utils.roam import modus_service
+from window.launcher.plugin_base import PluginBase
+from window.launcher.result import Result
class ApplicationsPlugin(PluginBase):
@@ -24,7 +23,7 @@ def cleanup(self):
def _pin_application(self, app):
"""Pin an application to the dock."""
- config_path = get_relative_path("../../../config/assets/dock.json")
+ config_path = get_relative_path("../../../../config/dock.json")
try:
with open(config_path, "r") as file:
pinned_apps = json.load(file)
diff --git a/modules/launcher/plugins/caffeine.py b/src/window/launcher/plugins/caffeine.py
similarity index 96%
rename from modules/launcher/plugins/caffeine.py
rename to src/window/launcher/plugins/caffeine.py
index decde979..0df855a8 100644
--- a/modules/launcher/plugins/caffeine.py
+++ b/src/window/launcher/plugins/caffeine.py
@@ -2,15 +2,11 @@
from threading import Timer
from typing import List
-import gi
+from fabric.utils import exec_shell_command_async, get_relative_path
-import config.data as data
-from fabric.utils import get_relative_path
-from fabric.utils.helpers import exec_shell_command_async
-from modules.launcher.plugin_base import PluginBase
-from modules.launcher.result import Result
-
-gi.require_version("Gtk", "3.0")
+import shared.data as data
+from window.launcher.plugin_base import PluginBase
+from window.launcher.result import Result
class CaffeinePlugin(PluginBase):
diff --git a/modules/launcher/plugins/calculator.py b/src/window/launcher/plugins/calculator.py
similarity index 92%
rename from modules/launcher/plugins/calculator.py
rename to src/window/launcher/plugins/calculator.py
index f97dddbe..342c4937 100644
--- a/modules/launcher/plugins/calculator.py
+++ b/src/window/launcher/plugins/calculator.py
@@ -4,9 +4,9 @@
import time
from typing import List
-from modules.launcher.plugin_base import PluginBase
-from modules.launcher.result import Result
from utils.conversion import Conversion
+from window.launcher.plugin_base import PluginBase
+from window.launcher.result import Result
class CalculatorPlugin(PluginBase):
@@ -48,7 +48,7 @@ def __init__(self):
self.expression_pattern = re.compile(r"[\d+\-*/^()=]")
self.number_pattern = re.compile(r"\d")
self.conversion_pattern = re.compile(
- r"(\d+(?:\.\d+)?)\s*([a-zA-Z]+)\s*(?:to|in|=)\s*([a-zA-Z]+)"
+ r"(\d+(?:\.\d+)?)\s*([^0-9\s]+)\s*(?:to|in|=)\s*([^0-9\s]+)"
)
# Cache for conversion results
@@ -89,7 +89,11 @@ def query(self, query: str) -> List[Result]:
subtitle = f"{value} {from_unit} = {result:.6g} {to_unit}"
else:
# Use the conversion utility
- result = self.converter.convert(value, from_unit, to_unit)
+ from_unit_clean = self.converter.clean_type(from_unit)
+ to_unit_clean = self.converter.clean_type(to_unit)
+ result = self.converter.convert(
+ value, from_unit_clean, to_unit_clean
+ )
# Cache the result
self._conversion_cache[cache_key] = result
subtitle = f"{value} {from_unit} = {result:.6g} {to_unit}"
diff --git a/modules/launcher/plugins/clipboard.py b/src/window/launcher/plugins/clipboard.py
similarity index 98%
rename from modules/launcher/plugins/clipboard.py
rename to src/window/launcher/plugins/clipboard.py
index e332d193..7db592f2 100644
--- a/modules/launcher/plugins/clipboard.py
+++ b/src/window/launcher/plugins/clipboard.py
@@ -1,16 +1,14 @@
-import os
import subprocess
import sys
import tempfile
import threading
-import time
from concurrent.futures import ThreadPoolExecutor
from typing import Dict, List, Optional
-from gi.repository import GdkPixbuf, GLib
+from fabric.utils import GdkPixbuf, GLib, os, time
-from modules.launcher.plugin_base import PluginBase
-from modules.launcher.result import Result
+from window.launcher.plugin_base import PluginBase
+from window.launcher.result import Result
class ClipboardPlugin(PluginBase):
@@ -85,7 +83,6 @@ def invalidate_cache(self):
def _force_launcher_refresh(self):
"""Force the launcher to refresh its results."""
try:
- from gi.repository import GLib
def trigger_refresh():
try:
diff --git a/src/window/launcher/plugins/colorpicker.py b/src/window/launcher/plugins/colorpicker.py
new file mode 100644
index 00000000..424ad9c9
--- /dev/null
+++ b/src/window/launcher/plugins/colorpicker.py
@@ -0,0 +1,146 @@
+import os
+import re
+import subprocess
+from typing import List
+
+from fabric.utils import exec_shell_command, exec_shell_command_async, logger
+from shared.data import APP_NAME
+from window.launcher.plugin_base import PluginBase
+from window.launcher.result import Result
+
+
+class ColorPickerPlugin(PluginBase):
+ """
+ Plugin for picking colors from the screen using hyprpicker.
+ """
+
+ def __init__(self):
+ super().__init__()
+ self.display_name = "Color Picker"
+ self.description = "Pick colors from the screen (HEX, RGB, HSV)"
+ self.tmp_icon_path = "/tmp/color.png"
+
+ self.options = [
+ {"name": "Pick HEX", "arg": "hex", "icon": "color-select-symbolic"},
+ {"name": "Pick RGB", "arg": "rgb", "icon": "color-select-symbolic"},
+ {"name": "Pick HSV", "arg": "hsv", "icon": "color-select-symbolic"},
+ ]
+
+ def initialize(self):
+ """Initialize the color picker plugin."""
+ self.set_triggers(["color", "cp"])
+
+ def cleanup(self):
+ """Cleanup resources."""
+ if os.path.exists(self.tmp_icon_path):
+ try:
+ os.remove(self.tmp_icon_path)
+ except OSError:
+ pass
+
+ def query(self, query_string: str) -> List[Result]:
+ """Process a search query and return results."""
+ q = query_string.strip().lower()
+
+ results = []
+
+ # Filter options based on query
+ filtered = (
+ self.options
+ if not q
+ else [o for o in self.options if q in o["name"].lower() or q == o["arg"]]
+ )
+
+ for option in filtered:
+ mode = option["arg"]
+ results.append(
+ Result(
+ title=f"Color Picker: {option['name']}",
+ subtitle=f"Pick and copy {mode.upper()} color to clipboard",
+ icon_name=option["icon"],
+ action=lambda m=mode: self._execute_pick(m),
+ relevance=1.0,
+ plugin_name=self.display_name,
+ )
+ )
+
+ return results
+
+ def _execute_pick(self, mode: str):
+ """Run hyprpicker, process output, copy to clipboard and notify."""
+ try:
+ # Run hyprpicker
+ # -n: do not print a newline
+ # -f: format
+ raw = exec_shell_command(f"hyprpicker -n -f {mode}")
+ text = raw if isinstance(raw, str) else ""
+
+ color = self._sanitize_color_output(mode, text)
+ if not color:
+ logger.warning(
+ f"[ColorPicker] Failed to parse color from output: {text}"
+ )
+ return
+
+ # Copy to clipboard
+ self._copy_to_clipboard(color)
+
+ # Generate preview and notify
+ self._send_notification(mode, color)
+
+ except Exception as e:
+ logger.error(f"[ColorPicker] Error during pick: {e}")
+
+ def _sanitize_color_output(self, mode: str, text: str) -> str:
+ """Extract color value from hyprpicker output."""
+ joined = " ".join(ln.strip() for ln in text.splitlines() if ln.strip())
+
+ if mode == "hex":
+ matches = re.findall(r"#?[0-9A-Fa-f]{6,8}", joined)
+ if not matches:
+ return ""
+ val = matches[-1]
+ return val if val.startswith("#") else f"#{val}"
+
+ if mode in ("rgb", "hsv"):
+ # Matches "r, g, b" or "h, s, v"
+ matches = re.findall(
+ r"\d+(?:\.\d+)?\s*,\s*\d+(?:\.\d+)?\s*,\s*\d+(?:\.\d+)?", joined
+ )
+ return matches[-1] if matches else ""
+
+ return ""
+
+ def _copy_to_clipboard(self, text: str):
+ """Copy text to clipboard using wl-copy and cliphist."""
+ try:
+ subprocess.run(["wl-copy"], input=text.encode(), check=True)
+ subprocess.run(["cliphist", "store"], input=text.encode(), check=True)
+ except subprocess.CalledProcessError:
+ pass
+
+ def _send_notification(self, mode: str, color: str):
+ """Create a color preview icon and send a notification."""
+ # CSS-like color specification for ImageMagick
+ color_spec = (
+ color
+ if mode == "hex"
+ else (f"rgb({color})" if mode == "rgb" else f"hsv({color})")
+ )
+
+ # Cleanup existing tmp icon
+ if os.path.exists(self.tmp_icon_path):
+ try:
+ os.remove(self.tmp_icon_path)
+ except OSError:
+ pass
+
+ # Use magick to create a 64x64 color block
+ # Then send notification and cleanup
+ cmd = (
+ f"magick -size 64x64 xc:'{color_spec}' {self.tmp_icon_path} >/dev/null 2>&1; "
+ f"notify-send 'Color Picked' '{mode.upper()}: {color}' -i {self.tmp_icon_path} -a '{APP_NAME}' -e; "
+ f"rm -f {self.tmp_icon_path}"
+ )
+
+ exec_shell_command_async(["bash", "-lc", cmd])
diff --git a/src/window/launcher/plugins/emoji.py b/src/window/launcher/plugins/emoji.py
new file mode 100644
index 00000000..9b0fa8bc
--- /dev/null
+++ b/src/window/launcher/plugins/emoji.py
@@ -0,0 +1,291 @@
+import json
+import subprocess
+from collections import OrderedDict
+from typing import Dict, List
+
+from fabric.utils import os, time, GLib, logger
+
+import shared.data as data
+from window.launcher.plugin_base import PluginBase
+from window.launcher.result import Result
+
+
+class EmojiPlugin(PluginBase):
+ """
+ Plugin for searching and copying emojis.
+ """
+
+ def __init__(self):
+ super().__init__()
+ self.name = "emoji"
+ self.display_name = "Emoji"
+ self.description = "Search and copy emojis"
+ self.emoji_data = {}
+
+ # Language detection (default to en)
+ self.lang = os.getenv("LANG", "en").split("_")[0].split(".")[0]
+ if not self.lang:
+ self.lang = "en"
+
+ # Paths
+ self.state_dir = os.path.join(GLib.get_user_state_dir(), data.APP_NAME)
+ self.emoji_path = os.path.join(self.state_dir, f"emojis_{self.lang}.json")
+
+ # Use cache directory for recent emojis
+ self.recent_emoji_path = os.path.join(data.CACHE_DIR, "recent_emoji.json")
+ self.recent_emojis = OrderedDict()
+ self.max_recent_emojis = 20
+ self._is_downloading = False
+
+ def initialize(self):
+ """Initialize the emoji plugin."""
+ self.set_triggers(["em"])
+ self._load_recent_emojis()
+
+ # Check if local data exists, if not trigger download
+ if not os.path.exists(self.emoji_path):
+ self._download_emoji_data()
+ else:
+ self._load_emoji_data()
+
+ def cleanup(self):
+ """Cleanup the emoji plugin."""
+ pass
+
+ def _download_emoji_data(self, force=False):
+ """Download emoji annotations from CLDR repository."""
+ if self._is_downloading and not force:
+ return
+
+ os.makedirs(self.state_dir, exist_ok=True)
+ url = (
+ "https://raw.githubusercontent.com/unicode-org/cldr-json/main/"
+ f"cldr-json/cldr-annotations-full/annotations/{self.lang}/annotations.json"
+ )
+
+ self._is_downloading = True
+
+ def on_done(*args):
+ self._is_downloading = False
+ if os.path.exists(self.emoji_path):
+ self._load_emoji_data()
+ logger.info(f"[Emoji] Data updated for language: {self.lang}")
+ else:
+ logger.error(f"[Emoji] Download failed for URL: {url}")
+
+ logger.info(f"[Emoji] Starting download: {url}")
+ from fabric.utils import exec_shell_command_async
+
+ exec_shell_command_async(f"curl -L '{url}' -o '{self.emoji_path}'", on_done)
+
+ def _load_emoji_data(self):
+ """Load emoji data from JSON file and parse CLDR structure."""
+ try:
+ if os.path.exists(self.emoji_path):
+ with open(self.emoji_path, "r", encoding="utf-8") as f:
+ raw_data = json.load(f)
+ self.emoji_data = self._parse_cldr_json(raw_data)
+ else:
+ logger.warning(f"[Emoji] Local data missing: {self.emoji_path}")
+ except Exception as e:
+ logger.error(f"[Emoji] Error loading emoji data: {e}")
+
+ def _parse_cldr_json(self, raw_data: Dict) -> Dict:
+ """Parse CLDR annotations JSON into a flatter format for searching."""
+ try:
+ # Structure matches individual file: annotations -> annotations
+ annotations_container = raw_data.get("annotations", {})
+ raw_annotations = annotations_container.get("annotations", {})
+
+ if not raw_annotations:
+ # Fallback to the nested structure just in case it's a full bundle
+ main = raw_data.get("main", {})
+ lang_data = main.get(self.lang, main.get("en", {}))
+ annotations_container = lang_data.get("annotations", {})
+ raw_annotations = annotations_container.get("annotations", {})
+
+ parsed = {}
+ for emoji, info in raw_annotations.items():
+ # Extract TTL (name)
+ tts = info.get("tts", [""])
+ name = tts[0] if isinstance(tts, list) else tts
+
+ # Extract keywords
+ keywords = info.get("default", [])
+ if isinstance(keywords, str):
+ keywords = [keywords]
+
+ parsed[emoji] = {
+ "name": name,
+ "keywords": keywords,
+ }
+ return parsed
+ except Exception as e:
+ logger.error(f"[Emoji] CLDR parsing error: {e}")
+ return {}
+
+ def _load_recent_emojis(self):
+ """Load recently used emojis from JSON file."""
+ try:
+ if os.path.exists(self.recent_emoji_path):
+ with open(self.recent_emoji_path, "r", encoding="utf-8") as f:
+ recent_data = json.load(f)
+ # Convert to OrderedDict to maintain order
+ self.recent_emojis = OrderedDict(recent_data)
+ else:
+ # Create empty recent emojis file
+ self.recent_emojis = OrderedDict()
+ self._save_recent_emojis()
+ except Exception as e:
+ print(f"Error loading recent emoji data: {e}")
+ self.recent_emojis = OrderedDict()
+
+ def _save_recent_emojis(self):
+ """Save recently used emojis to JSON file."""
+ try:
+ # Ensure the cache directory exists
+ os.makedirs(data.CACHE_DIR, exist_ok=True)
+
+ with open(self.recent_emoji_path, "w", encoding="utf-8") as f:
+ json.dump(dict(self.recent_emojis), f, ensure_ascii=False, indent=2)
+ except Exception as e:
+ print(f"Error saving recent emoji data: {e}")
+
+ def _add_to_recent(self, emoji: str):
+ """Add an emoji to the recent list."""
+ # Remove if already exists (to move it to front)
+ if emoji in self.recent_emojis:
+ del self.recent_emojis[emoji]
+
+ # Add to front with current timestamp
+ self.recent_emojis[emoji] = time.time()
+
+ # Keep only the most recent emojis
+ while len(self.recent_emojis) > self.max_recent_emojis:
+ # Remove the oldest item
+ self.recent_emojis.popitem(last=False)
+
+ # Save to file
+ self._save_recent_emojis()
+
+ def _copy_to_clipboard(self, emoji: str):
+ """Copy emoji to clipboard and track usage."""
+ try:
+ # Try Wayland first
+ try:
+ subprocess.run(["wl-copy"], input=emoji.encode(), check=True)
+ except subprocess.SubprocessError:
+ # Fall back to X11
+ subprocess.run(
+ ["xclip", "-selection", "clipboard"],
+ input=emoji.encode(),
+ check=True,
+ )
+
+ # Track this emoji as recently used
+ self._add_to_recent(emoji)
+
+ except Exception as e:
+ print(f"Failed to copy to clipboard: {e}")
+
+ def query(self, query_string: str) -> List[Result]:
+ """Search emojis based on query."""
+ results = []
+ query = query_string.lower().strip()
+
+ # Check if we need to download/reload data
+ if not self.emoji_data and not self._is_downloading:
+ if not os.path.exists(self.emoji_path):
+ self._download_emoji_data()
+ else:
+ self._load_emoji_data()
+
+ # Handle update command
+ if query == "updatejson":
+ self._download_emoji_data(force=True)
+ return [
+ Result(
+ title="Updating Emoji Database...",
+ subtitle=f"Downloading latest annotations for {self.lang}",
+ icon_name="view-refresh-symbolic",
+ relevance=1.0,
+ plugin_name=self.display_name,
+ )
+ ]
+
+ # If data is not yet loaded and not downloading, try loading it
+ if not self.emoji_data and not self._is_downloading:
+ self._load_emoji_data()
+
+ # Show status if downloading
+ if self._is_downloading:
+ results.append(
+ Result(
+ title="Downloading Emoji Data...",
+ subtitle="Results will appear once finished",
+ icon_name="view-refresh-symbolic",
+ relevance=0.1,
+ plugin_name=self.display_name,
+ )
+ )
+
+ # If no query, show recently used emojis
+ if not query:
+ if self.recent_emojis:
+ for emoji in reversed(list(self.recent_emojis.keys())):
+ if emoji in self.emoji_data:
+ info = self.emoji_data[emoji]
+ results.append(self._create_emoji_result(emoji, info, 1.0))
+ return results
+
+ # Search by name, keywords, or the emoji itself
+ for emoji, info in self.emoji_data.items():
+ relevance = 0
+ name = info.get("name", "").lower()
+ keywords = info.get("keywords", [])
+
+ # Exact match with emoji
+ if query == emoji:
+ relevance = 1.0
+ # Exact match with name
+ elif query == name:
+ relevance = 0.95
+ # Starts with name
+ elif name.startswith(query):
+ relevance = 0.9
+ # Contains name
+ elif query in name:
+ relevance = 0.8
+ # In keywords
+ elif any(query == k.lower() for k in keywords):
+ relevance = 0.7
+ elif any(query in k.lower() for k in keywords):
+ relevance = 0.6
+
+ if relevance > 0:
+ results.append(self._create_emoji_result(emoji, info, relevance))
+
+ # Sort by relevance and limit
+ results.sort(key=lambda x: x.relevance, reverse=True)
+ return results[:20]
+
+ def _create_emoji_result(self, emoji: str, info: Dict, relevance: float) -> Result:
+ """Create a Result object for an emoji."""
+ name = info.get("name", "").capitalize()
+ keywords = ", ".join(info.get("keywords", [])[:3])
+
+ is_recent = emoji in self.recent_emojis
+ subtitle = f"{keywords}" + (" โข Recent" if is_recent else "")
+
+ # Use larger font size for emojis to make them visible
+ icon_markup = f"{emoji} "
+
+ return Result(
+ title=name,
+ subtitle=subtitle,
+ icon_markup=icon_markup,
+ action=lambda e=emoji: self._copy_to_clipboard(e),
+ relevance=relevance,
+ plugin_name=self.display_name,
+ data={"emoji": emoji, "name": name, "recent": is_recent},
+ )
diff --git a/modules/launcher/plugins/power.py b/src/window/launcher/plugins/power.py
similarity index 97%
rename from modules/launcher/plugins/power.py
rename to src/window/launcher/plugins/power.py
index 38b9cd86..d6c2dd55 100644
--- a/modules/launcher/plugins/power.py
+++ b/src/window/launcher/plugins/power.py
@@ -1,8 +1,9 @@
from typing import List
from fabric.utils import exec_shell_command_async
-from modules.launcher.plugin_base import PluginBase
-from modules.launcher.result import Result
+
+from window.launcher.plugin_base import PluginBase
+from window.launcher.result import Result
class PowerPlugin(PluginBase):
diff --git a/src/window/launcher/plugins/screencapture.py b/src/window/launcher/plugins/screencapture.py
new file mode 100644
index 00000000..d746813e
--- /dev/null
+++ b/src/window/launcher/plugins/screencapture.py
@@ -0,0 +1,442 @@
+from typing import List
+from services.screencapture import screen_capture_service
+from window.launcher.plugin_base import PluginBase
+from window.launcher.result import Result
+
+
+class ScreencapturePlugin(PluginBase):
+ """
+ Plugin for taking screenshots and screen recordings using screen-capture.sh script.
+ """
+
+ def __init__(self):
+ super().__init__()
+ self.display_name = "Screencapture"
+ self.description = "Take screenshots and screen recordings"
+
+ def initialize(self):
+ """Initialize the screencapture plugin."""
+ self.set_triggers(["sc"])
+
+ def cleanup(self):
+ """Cleanup the screencapture plugin."""
+ pass
+
+ def get_commands(self):
+ """Return available commands for this plugin."""
+ return {
+ # Screenshot commands
+ "screenshot": "Take a screenshot of the main display",
+ "screenshot-region": "Take a screenshot of selected region",
+ "screenshot-both": "Take a screenshot of both displays",
+ "screenshot-active": "Take a screenshot of the active display",
+ # Recording commands (with audio)
+ "record": "Start recording main display with audio",
+ "record-region": "Start recording selected region with audio",
+ "record-active": "Start recording active display with audio",
+ # Recording overrides
+ "record-noaudio": "Start recording main display without audio",
+ "record-hq": "Start high-quality recording (eDP-1)",
+ "record-gif": "Start GIF recording (eDP-1)",
+ # Control commands
+ "stop": "Stop current recording",
+ # Conversion commands
+ "convert-webm": "Convert latest MKV recording to WebM format",
+ "convert-iphone": "Convert latest recordings for iPhone",
+ "convert-youtube": "Convert latest recordings for YouTube",
+ "convert-gif": "Convert latest recordings to GIF",
+ # File conversion
+ "convert-file": "Convert specific file (Usage: convert-file [type] [path])",
+ }
+
+ def _activate_default_plugins(self):
+ """No action needed for script cleanup."""
+ pass
+
+ def _is_recording(self):
+ """Check if recording is currently active."""
+ return screen_capture_service.is_recording
+
+ def _get_command_result(self, command: str) -> Result:
+ """Get a Result object for a specific command."""
+ # Map aliases manually if needed
+ if command == "ss":
+ command = "screenshot"
+ if command == "rec":
+ command = "record"
+ if command == "conv":
+ command = "convert"
+
+ command_info = {
+ # Screenshot commands
+ "screenshot": (
+ "Take Screenshot (eDP-1)",
+ "Capture the main display",
+ "camera-photo-symbolic",
+ lambda: screen_capture_service.screenshot("eDP-1"),
+ ),
+ "screenshot-region": (
+ "Take Region Screenshot",
+ "Capture a selected region",
+ "camera-photo-symbolic",
+ lambda: screen_capture_service.screenshot("region"),
+ ),
+ "screenshot-both": (
+ "Take Screenshot (Both Displays)",
+ "Capture both displays combined",
+ "video-joined-displays-symbolic",
+ lambda: screen_capture_service.screenshot("both"),
+ ),
+ "screenshot-active": (
+ "Take Screenshot (Active)",
+ "Capture the active display",
+ "camera-photo-symbolic",
+ lambda: screen_capture_service.screenshot("active"),
+ ),
+ # Recording commands (with audio)
+ "record": (
+ "Start Recording (eDP-1)",
+ "Record the main display with audio",
+ "media-record-symbolic",
+ lambda: screen_capture_service.record("eDP-1"),
+ ),
+ "record-region": (
+ "Start Region Recording",
+ "Record a selected region with audio",
+ "media-record-symbolic",
+ lambda: screen_capture_service.record("selection"),
+ ),
+ "record-active": (
+ "Start Recording (Active)",
+ "Record the active display with audio",
+ "media-record-symbolic",
+ lambda: screen_capture_service.record("active"),
+ ),
+ # Recording overrides
+ "record-noaudio": (
+ "Start Recording No Audio (eDP-1)",
+ "Record without audio",
+ "media-record-symbolic",
+ lambda: screen_capture_service.record("eDP-1", no_audio=True),
+ ),
+ "record-hq": (
+ "Start HQ Recording (eDP-1)",
+ "High-quality recording for YouTube",
+ "media-record-symbolic",
+ lambda: screen_capture_service.record("eDP-1", mode="hq"),
+ ),
+ "record-gif": (
+ "Start GIF Recording (eDP-1)",
+ "Record as optimized GIF",
+ "media-record-symbolic",
+ lambda: screen_capture_service.record("eDP-1", mode="gif"),
+ ),
+ # Control commands
+ "stop": (
+ "Stop Recording",
+ "Stop the current screen recording",
+ "media-playback-stop-symbolic",
+ lambda: screen_capture_service.stop_recording(),
+ ),
+ # Conversion commands
+ "convert-webm": (
+ "Convert Latest to WebM",
+ "Convert latest MKV recording to WebM format",
+ "video-x-generic-symbolic",
+ lambda: screen_capture_service.convert("webm"),
+ ),
+ "convert-iphone": (
+ "Convert Latest for iPhone",
+ "Convert latest MKV recording for iPhone",
+ "video-x-generic-symbolic",
+ lambda: screen_capture_service.convert("iphone"),
+ ),
+ "convert-youtube": (
+ "Convert Latest for YouTube",
+ "Convert latest recording for YouTube",
+ "video-x-generic-symbolic",
+ lambda: screen_capture_service.convert("youtube"),
+ ),
+ "convert-gif": (
+ "Convert Latest to GIF",
+ "Convert latest recording to GIF",
+ "image-x-generic-symbolic",
+ lambda: screen_capture_service.convert("gif"),
+ ),
+ }
+
+ if command in command_info:
+ title, subtitle, icon, action = command_info[command]
+ if action is not None: # Regular command
+ return Result(
+ title=title,
+ subtitle=subtitle,
+ icon_name=icon,
+ action=action,
+ relevance=1.0,
+ plugin_name=self.display_name,
+ )
+ else: # File-based command, show instruction
+ return Result(
+ title=title,
+ subtitle=subtitle,
+ icon_name=icon,
+ action=lambda: None, # No action for instruction
+ relevance=1.0,
+ plugin_name=self.display_name,
+ )
+
+ return None
+
+ def query(self, query_string: str) -> List[Result]:
+ """Search for screencapture actions based on query."""
+ # Import here to avoid circular imports
+
+ # Clean the query string
+ query = query_string.strip().lower()
+
+ results = []
+
+ # Parse query for file-based commands
+ query_parts = query.split()
+ if len(query_parts) >= 3 and query_parts[0] == "convert-file":
+ format_type = query_parts[1]
+ file_param = " ".join(query_parts[2:])
+ if format_type in ["webm", "iphone", "youtube", "gif"]:
+ return [
+ Result(
+ title=f"Convert {file_param} to {format_type.upper()}",
+ subtitle=f"Convert specified file to {format_type} format",
+ icon_name=(
+ "video-x-generic-symbolic"
+ if format_type != "gif"
+ else "image-x-generic-symbolic"
+ ),
+ action=lambda fp=file_param, ft=format_type: (
+ screen_capture_service.convert(ft, fp)
+ ),
+ relevance=1.0,
+ plugin_name=self.display_name,
+ )
+ ]
+
+ # Check if query matches a command and return it as a result
+ command_result = self._get_command_result(query)
+ if command_result:
+ return [command_result]
+
+ # Check recording status
+ is_recording = self._is_recording()
+
+ # If recording is active, show stop button first with highest relevance
+ if is_recording:
+ results.append(
+ Result(
+ title="Stop Recording",
+ subtitle="Stop the current screen recording",
+ icon_name="media-playback-stop-symbolic",
+ action=lambda: screen_capture_service.stop_recording(),
+ relevance=2.0, # Highest relevance to appear at top
+ plugin_name=self.display_name,
+ )
+ )
+
+ # Screenshot actions
+ results.extend(
+ [
+ Result(
+ title="Take Screenshot",
+ subtitle="Capture the entire screen (eDP-1)",
+ icon_name="camera-photo-symbolic",
+ action=lambda: screen_capture_service.screenshot("eDP-1"),
+ relevance=1.0,
+ plugin_name=self.display_name,
+ ),
+ Result(
+ title="Take Region Screenshot",
+ subtitle="Capture a selected region",
+ icon_name="camera-photo-symbolic",
+ action=lambda: screen_capture_service.screenshot("region"),
+ relevance=0.9,
+ plugin_name=self.display_name,
+ ),
+ Result(
+ title="Take Screenshot (Both Displays)",
+ subtitle="Capture both displays combined",
+ icon_name="video-joined-displays-symbolic",
+ action=lambda: screen_capture_service.screenshot("both"),
+ relevance=0.8,
+ plugin_name=self.display_name,
+ ),
+ Result(
+ title="Take Screenshot (HDMI-A-1)",
+ subtitle="Capture HDMI display",
+ icon_name="video-display-symbolic",
+ action=lambda: screen_capture_service.screenshot("HDMI-A-1"),
+ relevance=0.7,
+ plugin_name=self.display_name,
+ ),
+ ]
+ )
+
+ # Standard recording actions
+ results.extend(
+ [
+ Result(
+ title="Start Recording (eDP-1)",
+ subtitle="Record the main display with audio",
+ icon_name="media-record-symbolic",
+ action=lambda: screen_capture_service.record("eDP-1"),
+ relevance=0.7,
+ plugin_name=self.display_name,
+ ),
+ Result(
+ title="Start Region Recording",
+ subtitle="Record a selected region",
+ icon_name="media-record-symbolic",
+ action=lambda: screen_capture_service.record("selection"),
+ relevance=0.6,
+ plugin_name=self.display_name,
+ ),
+ Result(
+ title="Start Recording (HDMI-A-1)",
+ subtitle="Record HDMI display with audio",
+ icon_name="media-record-symbolic",
+ action=lambda: screen_capture_service.record("HDMI-A-1"),
+ relevance=0.5,
+ plugin_name=self.display_name,
+ ),
+ ]
+ )
+
+ # No-audio recording actions
+ results.extend(
+ [
+ Result(
+ title="Start Recording No Audio (eDP-1)",
+ subtitle="Record the main display without audio",
+ icon_name="media-record-symbolic",
+ action=lambda: screen_capture_service.record(
+ "eDP-1", no_audio=True
+ ),
+ relevance=0.65,
+ plugin_name=self.display_name,
+ ),
+ Result(
+ title="Start Region Recording No Audio",
+ subtitle="Record a selected region without audio",
+ icon_name="media-record-symbolic",
+ action=lambda: screen_capture_service.record(
+ "selection", no_audio=True
+ ),
+ relevance=0.55,
+ plugin_name=self.display_name,
+ ),
+ Result(
+ title="Start Recording No Audio (HDMI-A-1)",
+ subtitle="Record HDMI display without audio",
+ icon_name="media-record-symbolic",
+ action=lambda: screen_capture_service.record(
+ "HDMI-A-1", no_audio=True
+ ),
+ relevance=0.45,
+ plugin_name=self.display_name,
+ ),
+ ]
+ )
+
+ # High-quality recording actions
+ results.extend(
+ [
+ Result(
+ title="Start HQ Recording (eDP-1)",
+ subtitle="High-quality recording for YouTube",
+ icon_name="media-record-symbolic",
+ action=lambda: screen_capture_service.record("eDP-1", mode="hq"),
+ relevance=0.4,
+ plugin_name=self.display_name,
+ ),
+ Result(
+ title="Start HQ Region Recording",
+ subtitle="High-quality region recording",
+ icon_name="media-record-symbolic",
+ action=lambda: screen_capture_service.record(
+ "selection", mode="hq"
+ ),
+ relevance=0.3,
+ plugin_name=self.display_name,
+ ),
+ Result(
+ title="Start HQ Recording (HDMI-A-1)",
+ subtitle="High-quality HDMI recording",
+ icon_name="media-record-symbolic",
+ action=lambda: screen_capture_service.record("HDMI-A-1", mode="hq"),
+ relevance=0.2,
+ plugin_name=self.display_name,
+ ),
+ ]
+ )
+
+ # GIF recording actions
+ results.extend(
+ [
+ Result(
+ title="Start GIF Recording (eDP-1)",
+ subtitle="Record as optimized GIF",
+ icon_name="media-record-symbolic",
+ action=lambda: screen_capture_service.record("eDP-1", mode="gif"),
+ relevance=0.1,
+ plugin_name=self.display_name,
+ ),
+ Result(
+ title="Start GIF Region Recording",
+ subtitle="Record selected region as GIF",
+ icon_name="media-record-symbolic",
+ action=lambda: screen_capture_service.record(
+ "selection", mode="gif"
+ ),
+ relevance=0.05,
+ plugin_name=self.display_name,
+ ),
+ ]
+ )
+
+ # Conversion actions
+ results.extend(
+ [
+ Result(
+ title="Convert Latest to WebM",
+ subtitle="Convert latest MKV recording to WebM format",
+ icon_name="video-x-generic-symbolic",
+ action=lambda: screen_capture_service.convert("webm"),
+ relevance=0.01,
+ plugin_name=self.display_name,
+ ),
+ Result(
+ title="Convert Latest for iPhone",
+ subtitle="Convert latest MKV recording for iPhone compatibility",
+ icon_name="video-x-generic-symbolic",
+ action=lambda: screen_capture_service.convert("iphone"),
+ relevance=0.01,
+ plugin_name=self.display_name,
+ ),
+ Result(
+ title="Convert Latest for YouTube",
+ subtitle="Convert latest recording for YouTube upload",
+ icon_name="video-x-generic-symbolic",
+ action=lambda: screen_capture_service.convert("youtube"),
+ relevance=0.01,
+ plugin_name=self.display_name,
+ ),
+ Result(
+ title="Convert Latest to GIF",
+ subtitle="Convert latest recording to GIF format",
+ icon_name="image-x-generic-symbolic",
+ action=lambda: screen_capture_service.convert("gif"),
+ relevance=0.01,
+ plugin_name=self.display_name,
+ ),
+ ]
+ )
+
+ return results
diff --git a/modules/launcher/plugins/wallpaper.py b/src/window/launcher/plugins/wallpaper.py
similarity index 97%
rename from modules/launcher/plugins/wallpaper.py
rename to src/window/launcher/plugins/wallpaper.py
index 3d4aa28f..ce482dd3 100644
--- a/modules/launcher/plugins/wallpaper.py
+++ b/src/window/launcher/plugins/wallpaper.py
@@ -1,21 +1,23 @@
import colorsys
import hashlib
import json
-import os
-import random
-import re
import threading
-import time
from typing import Dict, List, Optional
-from loguru import logger
-from gi.repository import GdkPixbuf
-from PIL import Image
+from fabric.utils import (
+ GdkPixbuf,
+ GLib,
+ exec_shell_command_async,
+ logger,
+ os,
+ random,
+ re,
+ time,
+)
-import config.data as data
-from fabric.utils.helpers import exec_shell_command_async
-from modules.launcher.plugin_base import PluginBase
-from modules.launcher.result import Result
+import shared.data as data
+from window.launcher.plugin_base import PluginBase
+from window.launcher.result import Result
class WallpaperPlugin(PluginBase):
@@ -124,7 +126,6 @@ def _clear_launcher_query(self):
"""Clear the launcher search query and reset to trigger."""
try:
# Try to access the launcher through the fabric Application
- from gi.repository import GLib
from fabric import Application
@@ -179,10 +180,10 @@ def _create_thumbnail(self, filename: str) -> str:
if not os.path.exists(cache_path):
try:
- with Image.open(full_path) as img:
- # Use faster thumbnail creation with smaller size for better performance
- img.thumbnail((32, 32), Image.Resampling.LANCZOS)
- img.save(cache_path, "PNG", optimize=True)
+ pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
+ full_path, 32, 32, True
+ )
+ pixbuf.savev(cache_path, "png", [], [])
except Exception as e:
logger.error(f"Error creating thumbnail for {filename}: {e}")
return None
@@ -289,7 +290,7 @@ def _set_wallpaper(self, filename: str, scheme: str = None):
# Always set the wallpaper image
exec_shell_command_async(
- f'swww img "{
+ f'awww img "{
full_path
}" -t outer --transition-duration 1.5 --transition-step 255 --transition-fps 60 -f Nearest'
)
@@ -297,7 +298,9 @@ def _set_wallpaper(self, filename: str, scheme: str = None):
# If Matugen is enabled, also apply the color scheme
matugen_enabled = self._get_matugen_state()
if matugen_enabled:
- exec_shell_command_async(f'matugen image "{full_path}" -t {scheme}')
+ exec_shell_command_async(
+ f'matugen image "{full_path}" -t {scheme} --source-color-index 0'
+ )
def _set_random_wallpaper(self):
"""Set a random wallpaper."""
@@ -314,11 +317,11 @@ def _set_random_wallpaper(self):
)
return filename
- def _hsl_to_rgb_hex(self, h: float, s: float = 1.0, l: float = 0.5) -> str:
+ def _hsl_to_rgb_hex(self, h: float, s: float = 1.0, l_val: float = 0.5) -> str:
"""Convert HSL color value to RGB HEX string."""
# colorsys uses HLS, not HSL, and expects values between 0.0 and 1.0
hue = h / 360.0
- r, g, b = colorsys.hls_to_rgb(hue, l, s) # Note the order: H, L, S
+ r, g, b = colorsys.hls_to_rgb(hue, l_val, s) # Note the order: H, L, S
r_int, g_int, b_int = int(r * 255), int(g * 255), int(b * 255)
return f"#{r_int:02X}{g_int:02X}{b_int:02X}"
@@ -374,7 +377,7 @@ def _set_current_scheme(self, scheme: str):
if os.path.exists(wallpaper_path):
# Apply the new scheme to current wallpaper
exec_shell_command_async(
- f'matugen image "{wallpaper_path}" -t {scheme}'
+ f'matugen image "{wallpaper_path}" -t {scheme} --source-color-index 0'
)
# Send notification
@@ -413,7 +416,9 @@ def _apply_hex_color(self, hex_color: str, scheme: str = None):
if scheme is None:
scheme = self._get_current_scheme()
- exec_shell_command_async(f'matugen color hex "{hex_color}" -t {scheme}')
+ exec_shell_command_async(
+ f'matugen color hex "{hex_color}" -t {scheme} --source-color-index 0'
+ )
def _apply_hex_color_direct(self, hex_color: str, scheme: str = None):
"""Apply hex color using matugen (following example_wallpapers.py pattern)."""
@@ -423,7 +428,9 @@ def _apply_hex_color_direct(self, hex_color: str, scheme: str = None):
if scheme is None:
scheme = self._get_current_scheme()
- exec_shell_command_async(f'matugen color hex "{hex_color}" -t {scheme}')
+ exec_shell_command_async(
+ f'matugen color hex "{hex_color}" -t {scheme} --source-color-index 0'
+ )
# Send notification
exec_shell_command_async(
@@ -539,8 +546,8 @@ def query(self, query_string: str) -> List[Result]:
status_text
}",
icon_name="color-picker-symbolic",
- action=lambda c=hex_color, s=scheme: self._apply_hex_color_direct(
- c, s
+ action=lambda c=hex_color, s=scheme: (
+ self._apply_hex_color_direct(c, s)
),
relevance=1.0,
plugin_name=self.display_name,
diff --git a/modules/launcher/plugins/websearch.py b/src/window/launcher/plugins/websearch.py
similarity index 99%
rename from modules/launcher/plugins/websearch.py
rename to src/window/launcher/plugins/websearch.py
index 80f992b8..d48b3582 100644
--- a/modules/launcher/plugins/websearch.py
+++ b/src/window/launcher/plugins/websearch.py
@@ -2,8 +2,8 @@
import urllib.parse
from typing import List
-from modules.launcher.plugin_base import PluginBase
-from modules.launcher.result import Result
+from window.launcher.plugin_base import PluginBase
+from window.launcher.result import Result
class WebSearchPlugin(PluginBase):
diff --git a/modules/launcher/result.py b/src/window/launcher/result.py
similarity index 97%
rename from modules/launcher/result.py
rename to src/window/launcher/result.py
index fce35507..860225b0 100644
--- a/modules/launcher/result.py
+++ b/src/window/launcher/result.py
@@ -1,7 +1,7 @@
from dataclasses import dataclass
from typing import Any, Callable, Optional
-from gi.repository import GdkPixbuf, Gtk
+from fabric.utils import GdkPixbuf, Gtk
@dataclass
diff --git a/modules/launcher/result_item.py b/src/window/launcher/result_item.py
similarity index 98%
rename from modules/launcher/result_item.py
rename to src/window/launcher/result_item.py
index baaaa43c..8576f247 100644
--- a/modules/launcher/result_item.py
+++ b/src/window/launcher/result_item.py
@@ -1,12 +1,10 @@
-import gi
from fabric.core.service import Signal
from fabric.widgets.box import Box
from fabric.widgets.eventbox import EventBox
from fabric.widgets.image import Image
from fabric.widgets.label import Label
-from modules.launcher.result import Result
-gi.require_version("Gtk", "3.0")
+from window.launcher.result import Result
class ResultItem(EventBox):
diff --git a/modules/launcher/trigger_config.py b/src/window/launcher/trigger_config.py
similarity index 96%
rename from modules/launcher/trigger_config.py
rename to src/window/launcher/trigger_config.py
index 3a568c4d..bbf77e2b 100644
--- a/modules/launcher/trigger_config.py
+++ b/src/window/launcher/trigger_config.py
@@ -8,7 +8,7 @@
class TriggerConfig:
def __init__(self, config_path: str = None):
if config_path is None:
- config_path = get_relative_path("../../config/assets/launcher.json")
+ config_path = get_relative_path("../../../config/launcher.json")
self.config_path = config_path
diff --git a/src/window/notification/__init__.py b/src/window/notification/__init__.py
new file mode 100644
index 00000000..8b137891
--- /dev/null
+++ b/src/window/notification/__init__.py
@@ -0,0 +1 @@
+
diff --git a/modules/notification/notification.py b/src/window/notification/notification.py
similarity index 80%
rename from modules/notification/notification.py
rename to src/window/notification/notification.py
index 77be7469..e5d6cab4 100644
--- a/modules/notification/notification.py
+++ b/src/window/notification/notification.py
@@ -1,113 +1,62 @@
-import os
import hashlib
import time
-import uuid
-from fabric.utils import get_relative_path
-from gi.repository import Gdk, GdkPixbuf, GLib, Gtk # type: ignore
-from loguru import logger
-
-import config.data as data
-from .unified_cache import (
- get_unified_cache_key,
- save_to_cache,
- get_from_cache,
- cleanup_cache,
- get_fallback_icon,
- ensure_cache_dir
-)
from fabric.notifications import (
Notification,
NotificationAction,
NotificationCloseReason,
)
+from fabric.utils import Gdk, GdkPixbuf, GLib, Gtk, logger, os
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.centerbox import CenterBox
from fabric.widgets.eventbox import EventBox
from fabric.widgets.image import Image
from fabric.widgets.label import Label
-from utils.roam import modus_service
-from utils.functions import escape_markup_text
-from widgets.custom_image import CustomImage
-from widgets.customrevealer import SlideRevealer
-from widgets.wayland import WaylandWindow as Window
+from fabric.widgets.wayland import WaylandWindow as Window
+
+import shared.data as data
+from services.config import get_config, on_config_change
from services.modus import notification_service
+from shared.widgets.clipping_box import ClippingBox
+from shared.widgets.custom_image import CustomImage
+from shared.widgets.customrevealer import SlideRevealer
+from utils.functions import escape_markup_text, parse_timeout_string
+from utils.roam import modus_service
-NOTIFICATION_WIDTH = 360
-NOTIFICATION_IMAGE_SIZE = 48
+# ruff: noqa: I001
+from window.notification.unified_cache import (
+ UNIFIED_NOTIFICATION_CACHE_DIR as _SHARED_NOTIFICATION_CACHE_DIR,
+ cleanup_cache as _unified_cleanup_cache,
+ cleanup_old_cache_files as _unified_cleanup_old_cache_files,
+ ensure_cache_dir as _ensure_unified_cache_dir,
+ get_fallback_icon as _shared_fallback_icon,
+ get_from_cache as _unified_get_from_cache,
+ get_unified_cache_key,
+ save_to_cache as _unified_save_to_cache,
+)
NOTIFICATION_WIDTH = 360
NOTIFICATION_IMAGE_SIZE = 48
-# Unified notification cache directory (for both app icons and notification images)
-UNIFIED_NOTIFICATION_CACHE_DIR = os.path.join(data.CACHE_DIR, "notifications")
-
-# Backward compatibility constants
-NOTIFICATION_ICON_CACHE_DIR = UNIFIED_NOTIFICATION_CACHE_DIR
-NOTIFICATION_IMAGE_CACHE_DIR = UNIFIED_NOTIFICATION_CACHE_DIR
+# Use shared unified cache directory from unified_cache
+NOTIFICATION_ICON_CACHE_DIR = _SHARED_NOTIFICATION_CACHE_DIR
+NOTIFICATION_IMAGE_CACHE_DIR = _SHARED_NOTIFICATION_CACHE_DIR
def ensure_notification_cache_dirs():
"""Ensure unified notification cache directory exists"""
- os.makedirs(UNIFIED_NOTIFICATION_CACHE_DIR, exist_ok=True)
+ _ensure_unified_cache_dir()
def cleanup_old_cache_files():
"""Clean up old notification cache files (older than 7 days)"""
try:
- if not os.path.exists(UNIFIED_NOTIFICATION_CACHE_DIR):
- return
-
- current_time = time.time()
- week_ago = current_time - (7 * 24 * 60 * 60) # 7 days
-
- for filename in os.listdir(UNIFIED_NOTIFICATION_CACHE_DIR):
- filepath = os.path.join(UNIFIED_NOTIFICATION_CACHE_DIR, filename)
- try:
- if os.path.isfile(filepath):
- file_mtime = os.path.getmtime(filepath)
- if file_mtime < week_ago:
- os.unlink(filepath)
- logger.debug(f"Cleaned up old notification cache: {filename}")
- except Exception as e:
- logger.warning(f"Failed to cleanup cache file {filename}: {e}")
+ _unified_cleanup_old_cache_files()
except Exception as e:
logger.warning(f"Failed to cleanup notification cache: {e}")
-def get_unified_cache_key(source_data, size=None, app_name=None):
- """Generate a unified cache key that works for both app icons and notification images"""
- try:
- if hasattr(source_data, "get_pixels"):
- # For pixbuf data - use hash of pixel data for deterministic caching
- try:
- pixel_data = source_data.get_pixels()
- image_hash = hashlib.md5(pixel_data).hexdigest()[:8]
- return image_hash
- except Exception:
- # Fallback to timestamp if pixel data fails
- return str(int(time.time()))[:8]
- elif isinstance(source_data, str):
- # For file paths - create hash-based name
- if source_data.startswith("file://"):
- source_data = source_data[7:]
-
- # Create hash from file path and size
- hash_input = source_data
- if size:
- hash_input += f"_{size[0]}x{size[1]}"
-
- return hashlib.md5(hash_input.encode()).hexdigest()[:8]
- else:
- # Fallback to timestamp
- return str(int(time.time()))[:8]
- except Exception:
- # Ultimate fallback
- return str(int(time.time()))[:8]
-
-
-# Backward compatibility
get_cache_key = get_unified_cache_key
@@ -115,15 +64,11 @@ def save_pixbuf_to_cache(pixbuf, cache_key, cache_dir):
"""Save a pixbuf to the specified cache directory"""
try:
ensure_notification_cache_dirs()
- cache_path = os.path.join(cache_dir, f"{cache_key}.png")
-
- # Don't overwrite existing cache
- if os.path.exists(cache_path):
- return cache_path
-
- pixbuf.savev(cache_path, "png", [], [])
- logger.debug(f"Cached notification icon: {cache_key}")
- return cache_path
+ # Delegate to unified cache; ignore returned key for compatibility
+ result = _unified_save_to_cache(pixbuf, cache_key)
+ if result and result[0]:
+ return result[0]
+ return None
except Exception as e:
logger.warning(f"Failed to cache notification icon: {e}")
return None
@@ -135,13 +80,9 @@ def get_cached_pixbuf(cache_key, fallback_size=(48, 48), cache_dir=None):
cache_dir = NOTIFICATION_ICON_CACHE_DIR
try:
- cache_path = os.path.join(cache_dir, f"{cache_key}.png")
- if os.path.exists(cache_path):
- logger.debug(f"Using cached notification icon: {cache_key}")
- logger.debug(f"Using cached notification icon: {cache_key}")
- return GdkPixbuf.Pixbuf.new_from_file_at_scale(
- cache_path, fallback_size[0], fallback_size[1], True
- )
+ pixbuf = _unified_get_from_cache(cache_key, fallback_size)
+ if pixbuf:
+ return pixbuf
except Exception as e:
logger.warning(f"Failed to load cached notification icon: {e}")
return None
@@ -220,26 +161,11 @@ def load_and_cache_local_icon(file_path, cache_key, size):
def load_and_cache_theme_icon(icon_name, cache_key, size):
"""Load an icon from the current theme and cache it"""
# For simplicity, just use fallback since theme icon loading is complex in GTK4
- logger.debug(f"Using fallback for theme icon {icon_name}")
return get_fallback_notification_icon(size)
def get_fallback_notification_icon(size=(48, 48)):
- """Get the fallback notification icon"""
- try:
- fallback_path = get_relative_path("../../config/assets/icons/notification.png")
- return GdkPixbuf.Pixbuf.new_from_file_at_scale(
- fallback_path, size[0], size[1], True
- )
- except Exception as e:
- logger.warning(f"Failed to load fallback notification icon: {e}")
- # Create a simple colored rectangle as ultimate fallback
- try:
- return GdkPixbuf.Pixbuf.new(
- GdkPixbuf.Colorspace.RGB, True, 8, size[0], size[1]
- )
- except:
- return None
+ return _shared_fallback_icon(size)
def get_notification_image_cache_key(notification_id, image_pixbuf):
@@ -256,11 +182,13 @@ def get_notification_image_cache_key(notification_id, image_pixbuf):
try:
width = image_pixbuf.get_width()
height = image_pixbuf.get_height()
- dimension_hash = hashlib.md5(f"{width}x{height}".encode()).hexdigest()[:8]
+ dimension_hash = hashlib.md5(
+ f"{width}x{height}".encode()
+ ).hexdigest()[:8]
return dimension_hash
except Exception:
pass
-
+
# Fallback to timestamp for invalid pixbufs
return str(int(time.time()))[:8]
except Exception:
@@ -275,23 +203,16 @@ def cache_notification_image(notification_id, image_pixbuf, size=(64, 64)):
# Generate deterministic cache key based on image content
cache_key = get_notification_image_cache_key(notification_id, image_pixbuf)
- cache_path = os.path.join(NOTIFICATION_IMAGE_CACHE_DIR, f"{cache_key}.png")
-
- # Check if already cached to avoid redundant work
- if os.path.exists(cache_path):
- logger.debug(f"Image cache hit - already exists: {cache_key}")
- return cache_path, cache_key
-
- # Try to scale and save the image
try:
- scaled_pixbuf = image_pixbuf.scale_simple(
- size[0], size[1], GdkPixbuf.InterpType.BILINEAR
- )
- scaled_pixbuf.savev(cache_path, "png", [], [])
- logger.debug(f"Generated and cached notification image: {cache_key}")
- return cache_path, cache_key
+ # Let unified cache handle scaling and saving
+ saved_path, _ = _unified_save_to_cache(image_pixbuf, cache_key, size)
+ if saved_path:
+ return saved_path, cache_key
+ return None, None
except Exception as scale_error:
- logger.debug(f"Failed to cache image (temp file likely gone): {scale_error}")
+ logger.debug(
+ f"Failed to cache image (temp file likely gone): {scale_error}"
+ )
return None, None
except Exception as e:
@@ -302,9 +223,7 @@ def cache_notification_image(notification_id, image_pixbuf, size=(64, 64)):
def get_cached_notification_image(cache_key):
"""Get a cached notification image or return None if not found"""
try:
- cache_path = os.path.join(NOTIFICATION_IMAGE_CACHE_DIR, f"{cache_key}.png")
- if os.path.exists(cache_path):
- return GdkPixbuf.Pixbuf.new_from_file(cache_path)
+ return _unified_get_from_cache(cache_key)
except Exception as e:
logger.warning(f"Failed to load cached notification image: {e}")
return None
@@ -314,23 +233,7 @@ def cleanup_notification_image_cache(cache_key=None):
"""Clean up notification image cache - specific key or all"""
try:
ensure_notification_cache_dirs()
-
- if cache_key:
- # Remove specific cached image
- cache_path = os.path.join(NOTIFICATION_IMAGE_CACHE_DIR, f"{cache_key}.png")
- if os.path.exists(cache_path):
- os.unlink(cache_path)
- logger.debug(f"Cleaned up cached notification image: {cache_key}")
- else:
- # Remove all cached images
- for filename in os.listdir(NOTIFICATION_IMAGE_CACHE_DIR):
- if filename.endswith(".png"):
- filepath = os.path.join(NOTIFICATION_IMAGE_CACHE_DIR, filename)
- try:
- os.unlink(filepath)
- logger.debug(f"Cleaned up cached notification image: {filename}")
- except Exception as e:
- logger.warning(f"Failed to cleanup cache file {filename}: {e}")
+ _unified_cleanup_cache(cache_key)
except Exception as e:
logger.warning(f"Failed to cleanup notification image cache: {e}")
@@ -342,18 +245,13 @@ def cleanup_notification_specific_caches(
try:
# Clean up notification image cache
if notification_image_cache_key:
- cleanup_notification_image_cache(notification_image_cache_key)
+ _unified_cleanup_cache(notification_image_cache_key)
# Clean up app icon cache for this specific source (only 35x35 version)
if app_icon_source:
# Only clean 35x35 version since we only cache this size now
cache_key_35 = get_unified_cache_key(app_icon_source, (35, 35))
- cache_path_35 = os.path.join(
- NOTIFICATION_ICON_CACHE_DIR, f"{cache_key_35}.png"
- )
- if os.path.exists(cache_path_35):
- os.unlink(cache_path_35)
- logger.debug(f"Cleaned up cached app icon (35x35): {cache_key_35}")
+ _unified_cleanup_cache(cache_key_35)
except Exception as e:
logger.warning(f"Failed to cleanup notification specific caches: {e}")
@@ -362,22 +260,7 @@ def cleanup_notification_specific_caches(
def cleanup_all_notification_caches():
"""Clean up ALL notification caches (icons and images)"""
try:
- # Clean icon cache
- if os.path.exists(NOTIFICATION_ICON_CACHE_DIR):
- for filename in os.listdir(NOTIFICATION_ICON_CACHE_DIR):
- if filename.endswith(".png"):
- filepath = os.path.join(NOTIFICATION_ICON_CACHE_DIR, filename)
- try:
- os.unlink(filepath)
- logger.debug(f"Cleaned up cached notification icon: {filename}")
- except Exception as e:
- logger.warning(
- f"Failed to cleanup icon cache file {filename}: {e}"
- )
-
- # Clean image cache
- cleanup_notification_image_cache()
- logger.info("Cleaned up all notification caches")
+ _unified_cleanup_cache()
except Exception as e:
logger.warning(f"Failed to cleanup all notification caches: {e}")
@@ -400,28 +283,18 @@ def verify_cache_persistence():
if f.endswith(".png")
]
- logger.info(
- f"Cache persistence check: {len(icon_cache_files)} icons, {
- len(image_cache_files)
- } images cached"
- )
-
# Test loading a few cached items to verify they work
for cache_file in icon_cache_files[:2]: # Test first 2 icon files
try:
cache_path = os.path.join(NOTIFICATION_ICON_CACHE_DIR, cache_file)
- test_pixbuf = GdkPixbuf.Pixbuf.new_from_file(cache_path)
- if test_pixbuf:
- logger.debug(f"Successfully verified cached icon: {cache_file}")
+ GdkPixbuf.Pixbuf.new_from_file(cache_path)
except Exception as e:
logger.warning(f"Failed to load cached icon {cache_file}: {e}")
for cache_file in image_cache_files[:2]: # Test first 2 image files
try:
cache_path = os.path.join(NOTIFICATION_IMAGE_CACHE_DIR, cache_file)
- test_pixbuf = GdkPixbuf.Pixbuf.new_from_file(cache_path)
- if test_pixbuf:
- logger.debug(f"Successfully verified cached image: {cache_file}")
+ GdkPixbuf.Pixbuf.new_from_file(cache_path)
except Exception as e:
logger.warning(f"Failed to load cached image {cache_file}: {e}")
@@ -452,11 +325,7 @@ def migrate_persistent_notifications():
if hasattr(notification, "app_icon") and notification.app_icon:
cache_notification_icon(notification.app_icon, (35, 35))
migrated_count += 1
- logger.debug(
- f"Migrated notification for {
- notification.app_name
- } to use cached app icon"
- )
+
except Exception as cache_error:
logger.debug(
f"Failed to cache app icon for {notification.app_name}: {
@@ -464,11 +333,6 @@ def migrate_persistent_notifications():
}"
)
- if migrated_count > 0:
- logger.info(
- f"Migrated {migrated_count} persistent notifications to use cached assets"
- )
-
except Exception as e:
logger.warning(f"Failed to migrate persistent notifications: {e}")
@@ -502,7 +366,9 @@ def preload_notification_assets(notification):
# Cache notification image if available
if hasattr(notification, "image_pixbuf") and notification.image_pixbuf:
- cache_notification_image(notification.id, notification.image_pixbuf, (35, 35))
+ cache_notification_image(
+ notification.id, notification.image_pixbuf, (35, 35)
+ )
except Exception as e:
logger.warning(f"Failed to preload notification assets: {e}")
@@ -551,11 +417,15 @@ class NotificationWidget(Box):
def __init__(
self,
notification: Notification,
- timeout_ms=data.NOTIFICATION_TIMEOUT,
+ timeout_ms=None,
show_close_button=True,
name="notification",
**kwargs,
):
+ # Get current timeout from config manager if not provided
+ if timeout_ms is None:
+ timeout_ms = self._get_current_notification_timeout()
+
self.show_close_button = show_close_button
self.close_button = None
self._is_hovered = False
@@ -600,22 +470,28 @@ def create_header(self, notification):
header_icon_pixbuf = cached_app_icon_pixbuf.scale_simple(
24, 24, GdkPixbuf.InterpType.BILINEAR
)
- app_icon = CustomImage(pixbuf=header_icon_pixbuf)
- app_icon.set_name("notification-icon")
+ app_icon = ClippingBox(
+ name="notification-icon",
+ children=Image(pixbuf=header_icon_pixbuf),
+ )
else:
# Fallback to theme icon if caching fails completely
- app_icon = Image(
+ app_icon = ClippingBox(
name="notification-icon",
- icon_name="notifications",
- icon_size=24,
+ children=Image(
+ icon_name="notifications",
+ icon_size=24,
+ ),
)
except Exception as e:
logger.warning(f"Failed to load cached header icon: {e}")
# Ultimate fallback
- app_icon = Image(
+ app_icon = ClippingBox(
name="notification-icon",
- icon_name="notifications",
- icon_size=24,
+ children=Image(
+ icon_name="notifications",
+ icon_size=24,
+ ),
)
return CenterBox(
@@ -643,11 +519,9 @@ def create_content(self, notification):
name="notification-content",
spacing=8,
children=[
- Box(
+ ClippingBox(
name="notification-image",
- children=CustomImage(
- pixbuf=self._get_notification_pixbuf(notification)
- ),
+ children=Image(pixbuf=self._get_notification_pixbuf(notification)),
),
Box(
name="notification-text",
@@ -660,7 +534,9 @@ def create_content(self, notification):
children=[
Label(
name="notification-summary",
- markup=escape_markup_text(notification.summary.replace("\n", " ")),
+ markup=escape_markup_text(
+ notification.summary.replace("\n", " ")
+ ),
h_align="start",
max_chars_width=40,
ellipsization="end",
@@ -675,7 +551,9 @@ def create_content(self, notification):
),
(
Label(
- markup=escape_markup_text(notification.body.replace("\n", " ")),
+ markup=escape_markup_text(
+ notification.body.replace("\n", " ")
+ ),
h_align="start",
max_chars_width=45,
ellipsization="end",
@@ -734,7 +612,6 @@ def get_pixbuf(self, icon_path, width, height):
icon_path = icon_path[7:]
if not os.path.exists(icon_path):
- logger.warning(f"Icon path does not exist: {icon_path}")
return get_fallback_notification_icon((width, height))
try:
@@ -755,7 +632,9 @@ def _get_notification_pixbuf(self, notification):
# Try to get cached notification image first
if hasattr(notification, "image_pixbuf") and notification.image_pixbuf:
try:
- cache_key = get_notification_image_cache_key(notification_id, notification.image_pixbuf)
+ cache_key = get_notification_image_cache_key(
+ notification_id, notification.image_pixbuf
+ )
cached_image = get_cached_notification_image(cache_key)
if cached_image:
return cached_image
@@ -825,9 +704,8 @@ def destroy(self):
self, "notification_image_cache_key", None
),
)
- logger.debug(f"Cleaned up caches for manually dismissed notification")
else:
- logger.debug(f"Preserved caches for timeout/auto-dismissed notification")
+ logger.debug("Preserved caches for timeout/auto-dismissed notification")
super().destroy()
# @staticmethod
@@ -845,6 +723,20 @@ def unhover_button(self, button):
# Don't resume timeout here since the notification itself might still be hovered
self.set_pointer_cursor(button, "arrow")
+ @staticmethod
+ def _get_current_notification_timeout():
+ """Get the current notification timeout from config manager."""
+ try:
+ timeout_str = get_config(
+ "notification_timeout", data.NOTIFICATION_TIMEOUT_STR
+ )
+ from shared.data import parse_timeout_string
+
+ return parse_timeout_string(timeout_str)
+ except Exception as e:
+ logger.warning(f"Failed to get notification timeout from config: {e}")
+ return data.NOTIFICATION_TIMEOUT
+
class NotificationRevealer(SlideRevealer):
def __init__(
@@ -879,6 +771,7 @@ def __init__(
# Animation state
self._animation_in_progress = False
self._spring_timer_id = None
+ self._anim_timeout_id = None
self._css_provider = None
# Wrap notification in EventBox for swipe detection
@@ -899,8 +792,8 @@ def __init__(
smooth_revealer_animation(self)
- # Connect our own handler that manages the slide animation
- self.notification.connect("closed", self.on_resolved)
+ # Connect our own handler that manages the slide animation - track ID for cleanup
+ self._closed_handler_id = self.notification.connect("closed", self.on_resolved)
self._animation_in_progress = True
@@ -928,8 +821,6 @@ def _apply_transform(self, offset_x, opacity, scale):
if style_context:
# Use CSS provider for smooth transforms
if not hasattr(self, "_css_provider") or not self._css_provider:
- from gi.repository import Gtk
-
self._css_provider = Gtk.CssProvider()
style_context.add_provider(
self._css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
@@ -972,7 +863,7 @@ def animate_step():
self.notif_box._should_cleanup_cache = True
try:
self.notification.close("dismissed-by-user")
- except:
+ except Exception:
pass
return False
@@ -1034,12 +925,37 @@ def on_resolved(
self.hide()
# Consistent timing for smooth transitions
timeout_duration = self.duration + 50
- GLib.timeout_add(timeout_duration, lambda: self._on_animation_complete(True))
+ self._anim_timeout_id = GLib.timeout_add(
+ timeout_duration, lambda: self._on_animation_complete(True)
+ )
def destroy(self):
# Clean up CSS provider and timers
if self._spring_timer_id:
GLib.source_remove(self._spring_timer_id)
+ self._spring_timer_id = None
+ if self._anim_timeout_id:
+ GLib.source_remove(self._anim_timeout_id)
+ self._anim_timeout_id = None
+
+ # Disconnect notification signal
+ if hasattr(self, "_closed_handler_id") and self._closed_handler_id:
+ try:
+ self.notification.disconnect(self._closed_handler_id)
+ except Exception:
+ pass
+ self._closed_handler_id = 0
+
+ # Clean up CSS provider from style context
+ if self._css_provider:
+ try:
+ style_context = self.notif_box.get_style_context()
+ if style_context:
+ style_context.remove_provider(self._css_provider)
+ except Exception:
+ pass
+ self._css_provider = None
+
super().destroy()
@@ -1052,6 +968,17 @@ class NotificationState:
class ModusNoti(Window):
def __init__(self):
+ # Local config state mirroring services.config usage
+ self._current_config = {
+ "notification_timeout": data.NOTIFICATION_TIMEOUT_STR,
+ "notification_ignored_apps": data.NOTIFICATION_IGNORED_APPS_HISTORY,
+ "notification_limited_apps_history": data.NOTIFICATION_LIMITED_APPS_HISTORY,
+ }
+
+ # Subscribe to config changes and apply initial config
+ on_config_change(self._on_config_changed)
+ self._apply_initial_config()
+
self._server = notification_service
self.notifications = Box(
@@ -1076,7 +1003,8 @@ def __init__(self):
self.DEBOUNCE_DELAY = 50 # Prevent rapid fire notifications
self._server.connect("notification-added", self.on_new_notification)
- super().__init__(
+ Window.__init__(
+ self,
anchor="top right",
child=self.notifications,
layer="overlay",
@@ -1088,7 +1016,7 @@ def __init__(self):
def on_new_notification(self, fabric_notif, id):
notification: Notification = fabric_notif.get_notification_from_id(id)
-
+
# Check if notification still exists (might have been removed already)
if not notification:
return
@@ -1097,11 +1025,15 @@ def on_new_notification(self, fabric_notif, id):
# Notification is already cached by the service, just don't show popup
return
+ # Check if notification should be ignored based on current config
+ if self._should_ignore_notification(notification):
+ return
+
# Preload assets immediately for optimal caching and display performance
preload_notification_assets(notification)
# Implement smart queue management for smooth transitions
- current_time = GLib.get_monotonic_time() / 1000
+ GLib.get_monotonic_time() / 1000
# If queue is getting full, remove oldest notifications smoothly
if len(self.notification_queue) >= self.MAX_QUEUE_SIZE:
@@ -1110,7 +1042,7 @@ def on_new_notification(self, fabric_notif, id):
oldest = self.notification_queue.pop(0)
try:
oldest.close("dismissed-by-user")
- except:
+ except Exception:
pass
# Add new notification to queue
@@ -1161,7 +1093,7 @@ def _start_smooth_transition(self):
# Force close current notification with smooth animation
try:
self.current_notification.notification.close("expired")
- except:
+ except Exception:
pass
def _show_next_notification(self):
@@ -1172,14 +1104,14 @@ def _show_next_notification(self):
return
notification = self.notification_queue.pop(0)
-
+
# Check if notification is still valid (might have been removed)
- if not notification or not hasattr(notification, 'app_icon'):
+ if not notification or not hasattr(notification, "app_icon"):
# Skip invalid notifications and try next one
if self.notification_queue:
self._show_next_notification()
return
-
+
self.notification_state = NotificationState.SHOWING
new_box = NotificationRevealer(
@@ -1194,7 +1126,7 @@ def _show_next_notification(self):
for child in list(self.notifications.children):
try:
self.notifications.remove(child)
- except:
+ except Exception:
pass
self.notifications.children = [new_box]
@@ -1226,7 +1158,7 @@ def _on_notification_finished(self, notification_box):
try:
if notification_box in self.notifications.children:
self.notifications.remove(notification_box)
- except:
+ except Exception:
pass
# Reset state
@@ -1259,7 +1191,7 @@ def clear_notification_queue(self):
for notification in list(self.notification_queue):
try:
notification.close("dismissed-by-user")
- except:
+ except Exception:
pass
self.notification_queue.clear()
@@ -1279,3 +1211,80 @@ def clear_notification_queue(self):
def get_queue_length(self):
return len(self.notification_queue)
+
+ def _should_ignore_notification(self, notification):
+ """Check if notification should be ignored based on current config."""
+ try:
+ ignored_apps = self._current_config.get("notification_ignored_apps", [])
+ app_name = getattr(notification, "app_name", "")
+
+ # Check if app name is in ignored list
+ if app_name in ignored_apps:
+ return True
+
+ return False
+ except Exception as e:
+ logger.warning(f"Failed to check if notification should be ignored: {e}")
+ return False
+
+ def update_config(self, new_config):
+ """Update notification configuration dynamically."""
+ try:
+ # Handle notification timeout changes
+ if "notification_timeout" in new_config:
+ timeout_str = new_config["notification_timeout"]
+ # Parse the timeout string to milliseconds
+
+ parse_timeout_string(timeout_str)
+
+ # Handle ignored apps changes
+ if "notification_ignored_apps" in new_config:
+ new_config["notification_ignored_apps"]
+
+ # Handle limited apps history changes
+ if "notification_limited_apps_history" in new_config:
+ new_config["notification_limited_apps_history"]
+
+ # Update the current config
+ self._current_config.update(new_config)
+
+ except Exception as e:
+ logger.error(f"[ModusNoti] Failed to update config: {e}")
+
+ def _apply_initial_config(self):
+ try:
+ timeout_val = get_config(
+ "notification_timeout", data.NOTIFICATION_TIMEOUT_STR
+ )
+ ignored_apps = get_config(
+ "notification_ignored_apps", data.NOTIFICATION_IGNORED_APPS_HISTORY
+ )
+ limited_apps = get_config(
+ "notification_limited_apps_history",
+ data.NOTIFICATION_LIMITED_APPS_HISTORY,
+ )
+ initial = {
+ "notification_timeout": timeout_val,
+ "notification_ignored_apps": ignored_apps,
+ "notification_limited_apps_history": limited_apps,
+ }
+ # Only apply if different
+ self.update_config(initial)
+ except Exception as e:
+ logger.error(f"[ModusNoti] Failed to apply initial config: {e}")
+
+ def _on_config_changed(self, new_config: dict, old_config: dict):
+ try:
+ changes = {}
+ for key in (
+ "notification_timeout",
+ "notification_ignored_apps",
+ "notification_limited_apps_history",
+ ):
+ if key in new_config and new_config.get(key) != old_config.get(key):
+ changes[key] = new_config.get(key)
+
+ if changes:
+ self.update_config(changes)
+ except Exception as e:
+ logger.error(f"[ModusNoti] Error handling config change: {e}")
diff --git a/modules/notification/notification_center.py b/src/window/notification/notification_center.py
similarity index 89%
rename from modules/notification/notification_center.py
rename to src/window/notification/notification_center.py
index 3aa77b08..e5193899 100644
--- a/modules/notification/notification_center.py
+++ b/src/window/notification/notification_center.py
@@ -1,32 +1,35 @@
from collections import defaultdict
-import time
+from fabric.utils import GLib, logger
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.centerbox import CenterBox
from fabric.widgets.eventbox import EventBox
+from fabric.widgets.image import Image
from fabric.widgets.label import Label
from fabric.widgets.revealer import Revealer
from fabric.widgets.scrolledwindow import ScrolledWindow
-from gi.repository import GLib, GdkPixbuf
-from loguru import logger
+from fabric.widgets.wayland import WaylandWindow as Window
-from modules.notification.notification import (
+import shared.data as data
+from services.modus import notification_service
+from shared.widgets.clipping_box import ClippingBox
+from shared.widgets.custom_image import CustomImage
+from utils.functions import escape_markup_text
+from window.notification.notification import (
NotificationWidget,
cache_notification_icon,
- cache_notification_image,
- get_cached_notification_image,
- get_notification_image_cache_key,
- preload_notification_assets,
cleanup_all_notification_caches,
cleanup_notification_specific_caches,
- get_fallback_notification_icon,
+ get_cached_notification_image,
+ preload_notification_assets,
+)
+from window.notification.unified_cache import (
+ get_fallback_icon as get_fallback_notification_icon,
+)
+from window.notification.unified_cache import (
+ get_from_cache,
)
-from services.modus import notification_service
-from utils.functions import escape_markup_text
-from widgets.custom_image import CustomImage
-from widgets.wayland import WaylandWindow as Window
-from config import data
class ExpandableNotificationGroup(Box):
@@ -95,9 +98,9 @@ def create_collapsed_state(self):
name="stack-main-notification",
spacing=8,
children=[
- Box(
+ ClippingBox(
name="notification-image",
- children=CustomImage(
+ children=Image(
pixbuf=self._get_notification_pixbuf_for_group(
latest_notification
)
@@ -123,9 +126,11 @@ def create_collapsed_state(self):
),
Label(
name="notification-body",
- markup=escape_markup_text(latest_notification._notification.summary.replace(
- "\n", " "
- )),
+ markup=escape_markup_text(
+ latest_notification._notification.summary.replace(
+ "\n", " "
+ )
+ ),
max_chars_width=25,
h_align="start",
ellipsization="end",
@@ -142,8 +147,10 @@ def create_collapsed_state(self):
icon_name="close-symbolic", icon_size=18
),
visible=True,
- on_clicked=lambda *_: self._close_single_notification_and_stop_propagation(
- latest_notification
+ on_clicked=lambda *_: (
+ self._close_single_notification_and_stop_propagation(
+ latest_notification
+ )
),
),
Label(
@@ -166,7 +173,7 @@ def create_collapsed_state(self):
def _get_notification_pixbuf_for_group(self, cached_notification):
"""Get notification pixbuf using cached image key - fallback to app icon"""
notification = cached_notification._notification
- notification_id = getattr(notification, "id", None)
+ getattr(notification, "id", None)
# First try to get cached notification image using stored cache key
if (
@@ -182,9 +189,6 @@ def _get_notification_pixbuf_for_group(self, cached_notification):
notification_image_cache_key
)
if cached_image:
- logger.debug(
- f"Using cached notification image: {notification_image_cache_key}"
- )
return cached_image
except Exception as e:
logger.debug(f"Failed to load cached notification image: {e}")
@@ -199,11 +203,8 @@ def _get_notification_pixbuf_for_group(self, cached_notification):
)
if app_icon_cache_key:
try:
- from modules.notification.unified_cache import get_from_cache
-
cached_app_icon = get_from_cache(app_icon_cache_key, (35, 35))
if cached_app_icon:
- # logger.debug(f"Using cached app icon: {app_icon_cache_key}")
return cached_app_icon
except Exception as e:
logger.debug(f"Failed to load cached app icon: {e}")
@@ -214,15 +215,11 @@ def _get_notification_pixbuf_for_group(self, cached_notification):
if app_icon_source:
cached_app_icon = cache_notification_icon(app_icon_source, (35, 35))
if cached_app_icon:
- logger.debug(
- f"Using directly cached app icon for: {app_icon_source}"
- )
return cached_app_icon
except Exception as e:
logger.debug(f"Failed to get directly cached app icon: {e}")
# Ultimate fallback
- logger.debug("Using fallback notification icon")
return get_fallback_notification_icon((35, 35))
def create_expanded_state(self):
@@ -333,7 +330,6 @@ def expand(self, *args):
# Small delay then animate notifications sliding down
GLib.timeout_add(50, lambda: self.notifications_revealer.set_reveal_child(True))
- logger.debug(f"Expanded notification group: {self.app_name}")
def collapse(self, *args):
"""Collapse with header sliding up, notifications crossfading, then sliding up"""
@@ -351,8 +347,6 @@ def collapse(self, *args):
260, lambda: self.notifications_revealer.set_reveal_child(False)
)
- logger.debug(f"Collapsed notification group: {self.app_name}")
-
def _show_collapsed_midway(self):
"""Show collapsed state and hide expanded container to prevent deformation"""
self.collapsed_eventbox.set_visible(True)
@@ -372,10 +366,6 @@ def close_all(self, *args):
cache_metadata = getattr(notification, "cache_metadata", {})
# Clean up caches using stored metadata
- from modules.notification.notification import (
- cleanup_notification_specific_caches,
- )
-
cleanup_notification_specific_caches(
app_icon_source=notification._notification.app_icon,
notification_image_cache_key=cache_metadata.get(
@@ -383,10 +373,6 @@ def close_all(self, *args):
),
)
- logger.debug(
- f"Cleaned up caches for notification ID: {notification._notification.id}"
- )
-
notification_service.remove_cached_notification(notification.cache_id)
except Exception as e:
logger.error(
@@ -400,10 +386,6 @@ def _close_single_notification(self, notification):
cache_metadata = getattr(notification, "cache_metadata", {})
# Clean up caches using stored metadata
- from modules.notification.notification import (
- cleanup_notification_specific_caches,
- )
-
cleanup_notification_specific_caches(
app_icon_source=notification._notification.app_icon,
notification_image_cache_key=cache_metadata.get(
@@ -411,12 +393,7 @@ def _close_single_notification(self, notification):
),
)
- logger.debug(
- f"Cleaned up caches for notification ID: {notification._notification.id}"
- )
-
notification_service.remove_cached_notification(notification.cache_id)
- logger.debug(f"Closed single notification: {notification.cache_id}")
except Exception as e:
logger.error(
f"Error removing single notification {notification.cache_id}: {e}"
@@ -463,7 +440,7 @@ def __init__(self, notification, **kwargs):
def _get_notification_pixbuf(self, notification):
"""Get notification pixbuf using cached image key - fallback to app icon"""
- notification_id = getattr(notification, "id", None)
+ getattr(notification, "id", None)
# First try to get cached notification image using stored cache key
if self.cache_metadata:
@@ -476,9 +453,6 @@ def _get_notification_pixbuf(self, notification):
notification_image_cache_key
)
if cached_image:
- logger.debug(
- f"Using cached notification image: {notification_image_cache_key}"
- )
return cached_image
except Exception as e:
logger.debug(f"Failed to load cached notification image: {e}")
@@ -488,11 +462,8 @@ def _get_notification_pixbuf(self, notification):
app_icon_cache_key = self.cache_metadata.get("app_icon_cache_key")
if app_icon_cache_key:
try:
- from modules.notification.unified_cache import get_from_cache
-
cached_app_icon = get_from_cache(app_icon_cache_key, (35, 35))
if cached_app_icon:
- # logger.debug(f"Using cached app icon: {app_icon_cache_key}")
return cached_app_icon
except Exception as e:
logger.debug(f"Failed to load cached app icon: {e}")
@@ -503,15 +474,11 @@ def _get_notification_pixbuf(self, notification):
if app_icon_source:
cached_app_icon = cache_notification_icon(app_icon_source, (35, 35))
if cached_app_icon:
- logger.debug(
- f"Using directly cached app icon for: {app_icon_source}"
- )
return cached_app_icon
except Exception as e:
logger.debug(f"Failed to get directly cached app icon: {e}")
# Ultimate fallback
- logger.debug("Using fallback notification icon")
return get_fallback_notification_icon((35, 35))
def create_content(self, notification):
@@ -537,11 +504,9 @@ def create_content(self, notification):
name="notification-content",
spacing=8,
children=[
- Box(
+ ClippingBox(
name="notification-image",
- children=CustomImage(
- pixbuf=self._get_notification_pixbuf(notification)
- ),
+ children=Image(pixbuf=self._get_notification_pixbuf(notification)),
),
Box(
name="notification-text",
@@ -554,7 +519,9 @@ def create_content(self, notification):
children=[
Label(
name="notification-summary",
- markup=escape_markup_text(notification.summary.replace("\n", " ")),
+ markup=escape_markup_text(
+ notification.summary.replace("\n", " ")
+ ),
h_align="start",
max_chars_width=25,
ellipsization="end",
@@ -563,7 +530,9 @@ def create_content(self, notification):
),
(
Label(
- markup=escape_markup_text(notification.body.replace("\n", " ")),
+ markup=escape_markup_text(
+ notification.body.replace("\n", " ")
+ ),
h_align="start",
max_chars_width=35,
ellipsization="end",
@@ -598,10 +567,6 @@ def _on_close_clicked(self, *args):
cache_metadata = self.cache_metadata
# Clean up caches using stored metadata
- from modules.notification.notification import (
- cleanup_notification_specific_caches,
- )
-
cleanup_notification_specific_caches(
app_icon_source=self.notification.app_icon,
notification_image_cache_key=cache_metadata.get(
@@ -609,10 +574,6 @@ def _on_close_clicked(self, *args):
),
)
- logger.debug(
- f"Cleaned up caches for notification center ID: {self.notification.id}"
- )
-
notification_service.remove_cached_notification(self.notification_id)
except Exception as e:
logger.error(f"Error removing notification {self.notification_id}: {e}")
@@ -700,7 +661,6 @@ def __init__(self):
self._rebuild_notification_groups()
self.add_keybinding("Escape", self._on_escape_pressed)
- self.connect("destroy", self._on_destroy)
def _rebuild_notification_groups(self):
"""Rebuild notification groups from scratch with enhanced asset preloading and debugging"""
@@ -716,7 +676,7 @@ def _rebuild_notification_groups(self):
rebuild_count = 0
for cached_notification in notification_service.cached_notifications:
app_name = cached_notification._notification.app_name
- notification_id = getattr(cached_notification._notification, "id", None)
+ getattr(cached_notification._notification, "id", None)
# Skip ignored apps during rebuild
if app_name in data.NOTIFICATION_IGNORED_APPS_HISTORY:
@@ -728,12 +688,6 @@ def _rebuild_notification_groups(self):
self.notification_groups[app_name].append(cached_notification)
rebuild_count += 1
- logger.info(
- f"Rebuilt {rebuild_count} notifications into {
- len(self.notification_groups)
- } groups"
- )
-
# Create group widgets and handle limited apps
for app_name, notifications in self.notification_groups.items():
# Sort notifications by ID (highest ID first - newest notifications)
@@ -813,7 +767,6 @@ def on_notification_added(self, service, cached_notification):
self.notifications_box.pack_start(group_widget, False, False, 0)
group_widget.show_all()
- logger.debug(f"Added notification to group {app_name}")
except Exception as e:
logger.error(f"Error adding notification to group: {e}")
@@ -864,7 +817,6 @@ def on_notification_removed(self, service, cached_notification):
),
)
- logger.debug(f"Removed notification from group {app_name}")
except Exception as e:
logger.error(f"Error removing notification from group: {e}")
@@ -878,7 +830,6 @@ def on_clear_all(self, service):
cleanup_all_notification_caches()
for child in self.notifications_box.get_children():
child.destroy()
- logger.debug("Cleared all notification groups and remaining cached images")
except Exception as e:
logger.error(f"Error clearing notification groups: {e}")
@@ -916,8 +867,3 @@ def _set_mousecapture(self, visible):
self.main_revealer.set_reveal_child(True)
else:
self.main_revealer.set_reveal_child(False)
- logger.debug(f"Notification center visibility set to: {visible}")
-
- def _on_destroy(self, *_):
- # Signals will be automatically disconnected when the object is destroyed
- pass
diff --git a/modules/notification/unified_cache.py b/src/window/notification/unified_cache.py
similarity index 88%
rename from modules/notification/unified_cache.py
rename to src/window/notification/unified_cache.py
index 0bd673b3..d690d894 100644
--- a/modules/notification/unified_cache.py
+++ b/src/window/notification/unified_cache.py
@@ -1,13 +1,10 @@
-import os
import hashlib
import time
import uuid
-from fabric.utils import get_relative_path
-from gi.repository import GdkPixbuf
-from loguru import logger
+from fabric.utils import GdkPixbuf, get_relative_path, logger, os
-import config.data as data
+import shared.data as data
# Unified notification cache directory (for both app icons and notification images)
UNIFIED_NOTIFICATION_CACHE_DIR = os.path.join(data.CACHE_DIR, "notifications")
@@ -67,7 +64,6 @@ def save_to_cache(pixbuf, cache_key, size=None):
)
pixbuf.savev(cache_path, "png", [], [])
- logger.debug(f"Cached notification asset: {cache_key}")
return cache_path, cache_key
except Exception as e:
logger.warning(f"Failed to cache notification asset: {e}")
@@ -103,7 +99,6 @@ def cleanup_cache(cache_key=None):
)
if os.path.exists(cache_path):
os.unlink(cache_path)
- logger.debug(f"Cleaned up cached asset: {cache_key}")
else:
# Remove all cached assets
for filename in os.listdir(UNIFIED_NOTIFICATION_CACHE_DIR):
@@ -111,7 +106,6 @@ def cleanup_cache(cache_key=None):
filepath = os.path.join(UNIFIED_NOTIFICATION_CACHE_DIR, filename)
try:
os.unlink(filepath)
- logger.debug(f"Cleaned up cached asset: {filename}")
except Exception as e:
logger.warning(f"Failed to cleanup cache file {filename}: {e}")
except Exception as e:
@@ -134,7 +128,6 @@ def cleanup_old_cache_files():
file_mtime = os.path.getmtime(filepath)
if file_mtime < week_ago:
os.unlink(filepath)
- logger.debug(f"Cleaned up old cache: {filename}")
except Exception as e:
logger.warning(f"Failed to cleanup cache file {filename}: {e}")
except Exception as e:
@@ -153,15 +146,11 @@ def verify_cache_persistence():
if f.endswith(".png")
]
- logger.info(f"Cache persistence check: {len(cache_files)} assets cached")
-
# Test loading a few cached items to verify they work
for cache_file in cache_files[:2]: # Test first 2 files
try:
cache_path = os.path.join(UNIFIED_NOTIFICATION_CACHE_DIR, cache_file)
- test_pixbuf = GdkPixbuf.Pixbuf.new_from_file(cache_path)
- if test_pixbuf:
- logger.debug(f"Successfully verified cached asset: {cache_file}")
+ GdkPixbuf.Pixbuf.new_from_file(cache_path)
except Exception as e:
logger.warning(f"Failed to load cached asset {cache_file}: {e}")
@@ -175,7 +164,7 @@ def verify_cache_persistence():
def get_fallback_icon(size=(48, 48)):
"""Get the fallback notification icon"""
try:
- fallback_path = get_relative_path("../../config/assets/icons/notification.png")
+ fallback_path = get_relative_path("../../assets/icons/notification.png")
return GdkPixbuf.Pixbuf.new_from_file_at_scale(
fallback_path, size[0], size[1], True
)
@@ -186,7 +175,7 @@ def get_fallback_icon(size=(48, 48)):
return GdkPixbuf.Pixbuf.new(
GdkPixbuf.Colorspace.RGB, True, 8, size[0], size[1]
)
- except:
+ except Exception:
return None
@@ -194,4 +183,3 @@ def get_fallback_icon(size=(48, 48)):
ensure_cache_dir()
cleanup_old_cache_files()
verify_cache_persistence()
-
diff --git a/src/window/osd/__init__.py b/src/window/osd/__init__.py
new file mode 100644
index 00000000..d65876c7
--- /dev/null
+++ b/src/window/osd/__init__.py
@@ -0,0 +1,8 @@
+from .main import OSDWindow # noqa: F401
+from .components import ( # noqa: F401
+ AudioOSDContainer,
+ BrightnessOSDContainer,
+ MicrophoneOSDContainer,
+ CapsLockOSDContainer,
+ KeyboardLayoutOSDContainer,
+)
diff --git a/src/window/osd/components/__init__.py b/src/window/osd/components/__init__.py
new file mode 100644
index 00000000..0a7ca48f
--- /dev/null
+++ b/src/window/osd/components/__init__.py
@@ -0,0 +1,5 @@
+from .audio import AudioOSDContainer # noqa: F401
+from .brightness import BrightnessOSDContainer # noqa: F401
+from .microphone import MicrophoneOSDContainer # noqa: F401
+from .capslock import CapsLockOSDContainer # noqa: F401
+from .keyboard_layout import KeyboardLayoutOSDContainer # noqa: F401
diff --git a/src/window/osd/components/animated_scale.py b/src/window/osd/components/animated_scale.py
new file mode 100644
index 00000000..46b8868d
--- /dev/null
+++ b/src/window/osd/components/animated_scale.py
@@ -0,0 +1,30 @@
+from functools import partial
+
+from fabric.widgets.scale import Scale
+
+from shared.widgets.animator import Animator, cubic_bezier
+
+
+class AnimatedScale(Scale):
+ def __init__(self, **kwargs):
+ super().__init__(**kwargs)
+ self.animator = (
+ Animator(
+ duration=0.8,
+ timing_function=partial(cubic_bezier, 0.34, 1.56, 0.64, 1.0),
+ min_value=self.min_value,
+ max_value=self.value,
+ tick_widget=self,
+ notify_value=lambda anim: self.set_value(anim.value),
+ )
+ .build()
+ .play()
+ .unwrap()
+ )
+
+ def animate_value(self, value: float):
+ if self.animator:
+ self.animator.pause()
+ self.animator.min_value = self.value
+ self.animator.max_value = value
+ self.animator.play()
diff --git a/src/window/osd/components/audio.py b/src/window/osd/components/audio.py
new file mode 100644
index 00000000..049ac127
--- /dev/null
+++ b/src/window/osd/components/audio.py
@@ -0,0 +1,110 @@
+import math
+from fabric.audio import Audio
+from utils.utils import svg_file
+from fabric.widgets.scale import ScaleMark
+from .animated_scale import AnimatedScale
+from .base import BaseOSDContainer
+
+
+class AudioOSDContainer(BaseOSDContainer):
+ def __init__(self, window, **kwargs):
+ super().__init__(window, **kwargs)
+ self.audio = Audio()
+ self._last_volume = None
+ self._last_muted = None
+ self._setup_specific_components()
+ self._connect_specific_signals()
+
+ def _setup_specific_components(self):
+ self.osd_window_image = svg_file(
+ "volume/audio-volume.svg",
+ size=(100, 100),
+ name="osd-image",
+ h_align="center",
+ v_align="center",
+ h_expand=True,
+ v_expand=True,
+ )
+ self.scale = AnimatedScale(
+ marks=(ScaleMark(value=i) for i in range(1, 100, 10)),
+ value=70,
+ min_value=0,
+ max_value=100,
+ increments=(1, 1),
+ orientation="h",
+ )
+ self.add(self.osd_window_image)
+ self.add(self.scale)
+
+ def _connect_specific_signals(self):
+ self.audio.connect("notify::speaker", self._on_speaker_changed)
+ if self.audio.speaker:
+ self._connect_speaker_signals()
+
+ def _connect_speaker_signals(self):
+ if self.audio.speaker:
+ self.audio.speaker.connect("changed", self._on_speaker_stream_changed)
+ self._sync_with_audio()
+
+ def _on_speaker_changed(self, *_):
+ self._connect_speaker_signals()
+ self.update()
+
+ def _on_speaker_stream_changed(self, *_):
+ if self.audio.speaker:
+ current_volume = round(self.audio.speaker.volume)
+ current_muted = self.audio.speaker.muted
+ if self._last_volume != current_volume or self._last_muted != current_muted:
+ self._last_volume = current_volume
+ self._last_muted = current_muted
+ self.update()
+
+ def _sync_with_audio(self):
+ if self.audio.speaker:
+ self._last_volume = round(self.audio.speaker.volume)
+ self._last_muted = self.audio.speaker.muted
+ self.scale.set_value(self._last_volume)
+ self._update_display()
+
+ def _update_display(self):
+ if not self.audio.speaker:
+ return
+
+ volume = (
+ self._last_volume
+ if self._last_volume is not None
+ else round(self.audio.speaker.volume)
+ )
+ muted = (
+ self._last_muted
+ if self._last_muted is not None
+ else self.audio.speaker.muted
+ )
+
+ display_volume = 0 if (volume == 0 or muted) else volume
+ level = (
+ 0 if display_volume == 0 else min(int(math.ceil(display_volume / 33)), 3)
+ )
+
+ self.osd_window_image.set_from_file(f"volume/audio-volume-{level}.svg")
+
+ if muted or volume == 0:
+ self.scale.add_style_class("muted")
+ else:
+ self.scale.remove_style_class("muted")
+
+ self.scale.animate_value(volume)
+
+ def update(self, *_):
+ self._update_display()
+ super().update()
+
+ def destroy(self):
+ """Disconnect signals from Audio service"""
+ try:
+ self.audio.disconnect_by_func(self._on_speaker_changed)
+ if self.audio.speaker:
+ self.audio.speaker.disconnect_by_func(self._on_speaker_stream_changed)
+ except Exception:
+ pass
+ super().destroy()
diff --git a/src/window/osd/components/base.py b/src/window/osd/components/base.py
new file mode 100644
index 00000000..20034ebe
--- /dev/null
+++ b/src/window/osd/components/base.py
@@ -0,0 +1,149 @@
+from abc import abstractmethod
+from fabric.utils import Gdk, GLib, invoke_repeater, remove_handler, time
+from fabric.widgets.box import Box
+from fabric.widgets.wayland import WaylandWindow as Window
+
+
+class BaseOSDContainer(Box):
+ MAX_VISIBLE_TIME = 6.0
+ COOLDOWN_MS = 200
+
+ def __init__(self, window: Window, **kwargs):
+ super().__init__(**kwargs, orientation="v", spacing=12, name="osd-container")
+ self.last_handler: int = 0
+ self.window = window
+ self.osd = None
+ self.show_timestamp: float = 0.0
+ self.watchdog_handler: int = 0
+ self._update_in_progress = False
+ self._is_hovered = False
+ self._last_trigger_time: float = 0.0
+
+ GLib.idle_add(self._setup_window_signals)
+
+ def _setup_window_signals(self):
+ self.window.add_events(
+ Gdk.EventMask.ENTER_NOTIFY_MASK | Gdk.EventMask.LEAVE_NOTIFY_MASK
+ )
+ self.window.connect("enter-notify-event", self._on_enter_notify)
+ self.window.connect("leave-notify-event", self._on_leave_notify)
+ return False
+
+ def destroy(self):
+ self.cleanup_all_handlers()
+ super().destroy()
+
+ def _on_enter_notify(self, widget, event):
+ if event.detail != Gdk.NotifyType.INFERIOR:
+ self._is_hovered = True
+
+ def _on_leave_notify(self, widget, event):
+ if event.detail != Gdk.NotifyType.INFERIOR:
+ self._is_hovered = False
+
+ def is_hovered(self):
+ return self._is_hovered
+
+ def remove_last_handler(self):
+ if self.last_handler:
+ try:
+ remove_handler(self.last_handler)
+ except Exception:
+ pass
+ self.last_handler = 0
+
+ def remove_watchdog_handler(self):
+ if self.watchdog_handler:
+ try:
+ GLib.source_remove(self.watchdog_handler)
+ except Exception:
+ pass
+ self.watchdog_handler = 0
+
+ def cleanup_all_handlers(self):
+ self.remove_last_handler()
+ self.remove_watchdog_handler()
+ self.show_timestamp = 0.0
+ self._update_in_progress = False
+
+ def hide_window(self):
+ if self.osd and hasattr(self.osd, "revealer"):
+ self.osd.revealer.set_reveal_child(False)
+ GLib.timeout_add(150, lambda: self.window.hide() or False)
+ else:
+ self.window.hide()
+
+ def watchdog_force_hide(self, *_):
+ if not self.window.get_visible():
+ self.cleanup_all_handlers()
+ return False
+
+ elapsed = time.time() - self.show_timestamp
+ if elapsed >= self.MAX_VISIBLE_TIME:
+ if not self.is_hovered():
+ self.hide_window()
+ self.cleanup_all_handlers()
+ return False
+ else:
+ return True
+ return True
+
+ def update(self, *_):
+ if self._update_in_progress:
+ return
+
+ current_time = time.time()
+ time_since_last_trigger = (current_time - self._last_trigger_time) * 1000
+
+ if time_since_last_trigger < self.COOLDOWN_MS:
+ return
+
+ self._last_trigger_time = current_time
+ self._update_in_progress = True
+
+ self.cleanup_all_handlers()
+ self.show_timestamp = time.time()
+
+ if self.osd:
+ self.osd.show_container(self)
+
+ self.window.show()
+
+ self.focus()
+ self.last_handler = invoke_repeater(1700, self.unfocus, initial_call=False)
+ self.watchdog_handler = GLib.timeout_add(1000, self.watchdog_force_hide)
+ self._update_in_progress = False
+
+ def focus(self, *_):
+ self.add_style_class("focused")
+ return False
+
+ def unfocus(self, *_):
+ if not self.window.get_visible():
+ self.cleanup_all_handlers()
+ return False
+
+ self.remove_style_class("focused")
+ self.remove_last_handler()
+ self.last_handler = invoke_repeater(1700, self.unpop, initial_call=False)
+ return False
+
+ def unpop(self, *_):
+ if not self.window.get_visible():
+ self.cleanup_all_handlers()
+ return False
+
+ if not self.is_hovered():
+ self.hide_window()
+ self.cleanup_all_handlers()
+ else:
+ self.last_handler = invoke_repeater(500, self.unpop, initial_call=False)
+ return False
+
+ @abstractmethod
+ def _setup_specific_components(self):
+ pass
+
+ @abstractmethod
+ def _connect_specific_signals(self):
+ pass
diff --git a/src/window/osd/components/brightness.py b/src/window/osd/components/brightness.py
new file mode 100644
index 00000000..8499d723
--- /dev/null
+++ b/src/window/osd/components/brightness.py
@@ -0,0 +1,69 @@
+import math
+from services.brightness import Brightness
+from utils.utils import svg_file
+from fabric.widgets.scale import ScaleMark
+from .animated_scale import AnimatedScale
+from .base import BaseOSDContainer
+
+
+class BrightnessOSDContainer(BaseOSDContainer):
+ def __init__(self, window, **kwargs):
+ super().__init__(window, **kwargs)
+ self.brightness_service = Brightness.get_initial()
+ self._setup_specific_components()
+ self._connect_specific_signals()
+
+ def _setup_specific_components(self):
+ self.osd_window_image = svg_file(
+ "brightness/brightness.svg",
+ size=(100, 100),
+ name="osd-image",
+ h_align="center",
+ v_align="center",
+ h_expand=True,
+ v_expand=True,
+ )
+ self.scale = AnimatedScale(
+ marks=(ScaleMark(value=i) for i in range(0, 101, 10)),
+ value=70,
+ min_value=0,
+ max_value=100,
+ increments=(1, 1),
+ orientation="h",
+ )
+ self.add(self.osd_window_image)
+ self.add(self.scale)
+
+ def _connect_specific_signals(self):
+ self.brightness_service.connect("screen", self._on_screen_brightness_changed)
+
+ def _on_screen_brightness_changed(self, _sender, value, *_args):
+ self.update()
+
+ def _get_normalized_brightness(self):
+ return (
+ self.brightness_service.screen_brightness
+ / self.brightness_service.max_screen
+ ) * 100
+
+ def _update_display(self):
+ normalized = self._get_normalized_brightness()
+ level = 0 if normalized == 0 else min(int(math.ceil(normalized / 33)), 3)
+
+ self.osd_window_image.set_from_file(f"brightness/brightness-{level}.svg")
+
+ self.scale.animate_value(normalized)
+
+ def update(self, *_):
+ self._update_display()
+ super().update()
+
+ def destroy(self):
+ """Disconnect signals from Brightness service"""
+ try:
+ self.brightness_service.disconnect_by_func(
+ self._on_screen_brightness_changed
+ )
+ except Exception:
+ pass
+ super().destroy()
diff --git a/src/window/osd/components/capslock.py b/src/window/osd/components/capslock.py
new file mode 100644
index 00000000..febea10a
--- /dev/null
+++ b/src/window/osd/components/capslock.py
@@ -0,0 +1,55 @@
+from fabric.widgets.label import Label
+from utils.utils import svg_file
+from .base import BaseOSDContainer
+from services.capslock import CapsLock
+
+
+class CapsLockOSDContainer(BaseOSDContainer):
+ def __init__(self, window, **kwargs):
+ super().__init__(window, **kwargs)
+ self.capslock = CapsLock.get_initial()
+ self._setup_specific_components()
+ self._connect_specific_signals()
+
+ def _setup_specific_components(self):
+ self.osd_image = svg_file(
+ "misc/caps-lock.svg",
+ size=(100, 100),
+ name="osd-image",
+ h_align="center",
+ v_align="center",
+ h_expand=True,
+ v_expand=True,
+ )
+ self.label = Label(name="osd-label")
+ self.add(self.osd_image)
+ self._update_display(self.capslock.is_on)
+
+ def _connect_specific_signals(self):
+ self.capslock.connect("state_changed", self._on_caps_lock_state_changed)
+
+ def _on_caps_lock_state_changed(self, _, is_on: bool):
+ self._update_display(is_on)
+ self.update()
+
+ def _update_display(self, is_on: bool):
+ icon_path = "caps-lock.svg" if is_on else "caps-lock-off.svg"
+ self.osd_image.set_from_file(f"misc/{icon_path}")
+ if is_on:
+ self.add_style_class("capslock-on")
+ self.remove_style_class("capslock-off")
+ else:
+ self.add_style_class("capslock-off")
+ self.remove_style_class("capslock-on")
+
+ def update(self, *_):
+ # We don't need a separate display update here as it's handled in the signal
+ super().update()
+
+ def destroy(self):
+ """Disconnect signals from CapsLock service"""
+ try:
+ self.capslock.disconnect_by_func(self._on_caps_lock_state_changed)
+ except Exception:
+ pass
+ super().destroy()
diff --git a/src/window/osd/components/keyboard_layout.py b/src/window/osd/components/keyboard_layout.py
new file mode 100644
index 00000000..b4f9b8e7
--- /dev/null
+++ b/src/window/osd/components/keyboard_layout.py
@@ -0,0 +1,47 @@
+from fabric.widgets.label import Label
+from utils.utils import svg_file
+from .base import BaseOSDContainer
+from services.keyboard_layout import KeyboardLayout
+
+
+class KeyboardLayoutOSDContainer(BaseOSDContainer):
+ def __init__(self, window, **kwargs):
+ super().__init__(window, **kwargs)
+ self.keyboard_layout = KeyboardLayout.get_initial()
+ self._setup_specific_components()
+ self._connect_specific_signals()
+
+ def _setup_specific_components(self):
+ self.osd_image = svg_file(
+ "misc/keyboard-layout.svg",
+ size=(100, 100),
+ name="osd-image",
+ h_align="center",
+ v_align="center",
+ h_expand=True,
+ v_expand=True,
+ )
+ self.label = Label(name="osd-label")
+ self.add(self.osd_image)
+ self.add(self.label)
+ self.label.set_text(self.keyboard_layout.current_layout)
+
+ def _connect_specific_signals(self):
+ self.keyboard_layout.connect("layout_changed", self._on_layout_changed)
+
+ def _on_layout_changed(self, service, layout: str):
+ if not self.is_hovered():
+ self.label.set_text(layout)
+ self.update()
+
+ def update(self, *_):
+ self.label.set_text(self.keyboard_layout.current_layout)
+ super().update()
+
+ def destroy(self):
+ """Disconnect signals from KeyboardLayout service"""
+ try:
+ self.keyboard_layout.disconnect_by_func(self._on_layout_changed)
+ except Exception:
+ pass
+ super().destroy()
diff --git a/src/window/osd/components/microphone.py b/src/window/osd/components/microphone.py
new file mode 100644
index 00000000..461d6d92
--- /dev/null
+++ b/src/window/osd/components/microphone.py
@@ -0,0 +1,112 @@
+import math
+from fabric.audio import Audio
+from utils.utils import svg_file
+from fabric.widgets.scale import ScaleMark
+from .animated_scale import AnimatedScale
+from .base import BaseOSDContainer
+
+
+class MicrophoneOSDContainer(BaseOSDContainer):
+ def __init__(self, window, **kwargs):
+ super().__init__(window, **kwargs)
+ self.audio = Audio()
+ self._last_volume = None
+ self._last_muted = None
+ self._setup_specific_components()
+ self._connect_specific_signals()
+
+ def _setup_specific_components(self):
+ self.osd_window_image = svg_file(
+ "mic/microphone.svg",
+ size=(100, 100),
+ name="osd-image",
+ h_align="center",
+ v_align="center",
+ h_expand=True,
+ v_expand=True,
+ )
+ self.scale = AnimatedScale(
+ marks=(ScaleMark(value=i) for i in range(1, 100, 10)),
+ value=70,
+ min_value=0,
+ max_value=100,
+ increments=(1, 1),
+ orientation="h",
+ )
+ self.add(self.osd_window_image)
+ self.add(self.scale)
+
+ def _connect_specific_signals(self):
+ self.audio.connect("notify::microphone", self._on_microphone_changed)
+ if self.audio.microphone:
+ self._connect_microphone_signals()
+
+ def _connect_microphone_signals(self):
+ if self.audio.microphone:
+ self.audio.microphone.connect("changed", self._on_microphone_stream_changed)
+ self._sync_with_audio()
+
+ def _on_microphone_changed(self, *_):
+ self._connect_microphone_signals()
+ self.update()
+
+ def _on_microphone_stream_changed(self, *_):
+ if self.audio.microphone:
+ current_volume = round(self.audio.microphone.volume)
+ current_muted = self.audio.microphone.muted
+ if self._last_volume != current_volume or self._last_muted != current_muted:
+ self._last_volume = current_volume
+ self._last_muted = current_muted
+ self.update()
+
+ def _sync_with_audio(self):
+ if self.audio.microphone:
+ self._last_volume = round(self.audio.microphone.volume)
+ self._last_muted = self.audio.microphone.muted
+ self.scale.set_value(self._last_volume)
+ self._update_display()
+
+ def _update_display(self):
+ if not self.audio.microphone:
+ return
+
+ volume = (
+ self._last_volume
+ if self._last_volume is not None
+ else round(self.audio.microphone.volume)
+ )
+ muted = (
+ self._last_muted
+ if self._last_muted is not None
+ else self.audio.microphone.muted
+ )
+
+ display_volume = 0 if (volume == 0 or muted) else volume
+ level = (
+ 0 if display_volume == 0 else min(int(math.ceil(display_volume / 33)), 3)
+ )
+
+ self.osd_window_image.set_from_file(f"mic/microphone-{level}.svg")
+
+ if muted or volume == 0:
+ self.scale.add_style_class("muted")
+ else:
+ self.scale.remove_style_class("muted")
+
+ self.scale.animate_value(volume)
+
+ def update(self, *_):
+ self._update_display()
+ super().update()
+
+ def destroy(self):
+ """Disconnect signals from Audio service"""
+ try:
+ self.audio.disconnect_by_func(self._on_microphone_changed)
+ if self.audio.microphone:
+ self.audio.microphone.disconnect_by_func(
+ self._on_microphone_stream_changed
+ )
+ except Exception:
+ pass
+ super().destroy()
diff --git a/src/window/osd/main.py b/src/window/osd/main.py
new file mode 100644
index 00000000..9c1fa4b3
--- /dev/null
+++ b/src/window/osd/main.py
@@ -0,0 +1,83 @@
+from fabric.widgets.box import Box
+from fabric.widgets.revealer import Revealer
+from fabric.widgets.wayland import WaylandWindow as Window
+from .components import (
+ AudioOSDContainer,
+ BrightnessOSDContainer,
+ MicrophoneOSDContainer,
+ CapsLockOSDContainer,
+ KeyboardLayoutOSDContainer,
+)
+
+
+class OSD(Box):
+ def __init__(self, window: Window, **kwargs):
+ super().__init__(name="osd", **kwargs)
+ self.window = window
+
+ self.revealer = Revealer(
+ transition_type="slide-up",
+ transition_duration=100,
+ child_revealed=False,
+ )
+ self.children = [self.revealer]
+
+ self.containers = {
+ "audio": AudioOSDContainer(window),
+ "brightness": BrightnessOSDContainer(window),
+ "microphone": MicrophoneOSDContainer(window),
+ "capslock": CapsLockOSDContainer(window),
+ "kb_layout": KeyboardLayoutOSDContainer(window),
+ }
+
+ # Set the OSD reference in containers
+ for container in self.containers.values():
+ container.osd = self
+
+ self.current_container = None
+
+ def show_container(self, container):
+ if self.current_container != container:
+ self.revealer.children = [container]
+ self.current_container = container
+ self.revealer.set_reveal_child(True)
+
+ def show_audio_osd(self):
+ self.show_container(self.containers.get("audio"))
+
+ def show_brightness_osd(self):
+ self.show_container(self.containers.get("brightness"))
+
+ def show_microphone_osd(self):
+ self.show_container(self.containers.get("microphone"))
+
+ def show_capslock_osd(self):
+ self.show_container(self.containers.get("capslock"))
+
+ def show_kb_layout_osd(self):
+ self.show_container(self.containers.get("kb_layout"))
+
+
+class OSDWindow(Window):
+ def __init__(self, **kwargs):
+ super().__init__(
+ name="osd-window",
+ title="modus-osd",
+ anchor="bottom",
+ margin="0px 0px 40px 0px",
+ visible=False,
+ all_visible=False,
+ pass_through=True,
+ layer="overlay",
+ **kwargs,
+ )
+ self.osd = OSD(window=self)
+ self.add(self.osd)
+
+
+if __name__ == "__main__":
+ from fabric import Application
+
+ osd_window = OSDWindow()
+ app = Application("modus-osd", osd_window)
+ app.run()
diff --git a/src/window/panel/components/__init__.py b/src/window/panel/components/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/window/panel/components/enhanced_system_tray.py b/src/window/panel/components/enhanced_system_tray.py
new file mode 100644
index 00000000..f542e39c
--- /dev/null
+++ b/src/window/panel/components/enhanced_system_tray.py
@@ -0,0 +1,106 @@
+from pathlib import Path
+import weakref
+
+from fabric.system_tray.widgets import SystemTrayItem
+from fabric.utils import GdkPixbuf, GLib, logger
+
+from services.config import get_config, on_config_change
+
+# Track active tray items for hot-reloading
+_tracked_items = weakref.WeakSet()
+
+
+def should_hide(item) -> bool:
+ """Check if a tray item should be hidden based on config."""
+ ignore_list = get_config("systray_ignore", [])
+ if not ignore_list:
+ return False
+
+ title = item.title if item.title else ""
+ identifier = item.identifier if item.identifier else ""
+
+ # Check both title and identifier (case-insensitive for convenience)
+ for pattern in ignore_list:
+ p = pattern.lower()
+ if p in title.lower() or p in identifier.lower():
+ return True
+ return False
+
+
+def patched_do_update_properties(self, *_):
+ # Determine the icon name to use
+ item = self._item
+ icon_name = item.icon_name
+ attention_icon_name = item.attention_icon_name
+
+ # Log item info for user debugging
+ logger.info(
+ f"[SysTray] Item: title='{getattr(item, 'title', '')}', identifier='{getattr(item, 'identifier', '')}'"
+ )
+
+ # Visibility check
+ is_hidden = should_hide(item)
+ self.set_visible(not is_hidden)
+
+ if is_hidden:
+ return
+
+ if item.status == "NeedsAttention" and attention_icon_name:
+ preferred_icon_name = attention_icon_name
+ else:
+ preferred_icon_name = icon_name
+
+ # 1. Try the standard fabric/GTK implementation first
+ pixbuf = item.get_preferred_icon_pixbuf(self._icon_size)
+
+ # 2. If standard lookup fails, try your specific absolute path logic
+ if pixbuf is None and preferred_icon_name:
+ path = Path(preferred_icon_name)
+ if path.is_absolute() and path.exists():
+ try:
+ target_size = self._icon_size if self._icon_size else 24
+ pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
+ str(path),
+ target_size,
+ target_size,
+ True, # Preserve aspect ratio
+ )
+ except GLib.GError:
+ pixbuf = None
+
+ # Apply the resulting pixbuf or the fallback "missing" icon
+ if pixbuf:
+ self._image.set_from_pixbuf(pixbuf)
+ else:
+ self._image.set_from_icon_name("image-missing", self._icon_size)
+
+ tooltip = item.tooltip
+ self.set_tooltip_markup(
+ tooltip.description
+ or tooltip.title
+ or (item.title.title() if item.title else None)
+ or "Unknown"
+ )
+
+
+def apply_enhanced_system_tray():
+ # Patch __init__ to track instances
+ original_init = SystemTrayItem.__init__
+
+ def patched_init(self, *args, **kwargs):
+ original_init(self, *args, **kwargs)
+ _tracked_items.add(self)
+
+ SystemTrayItem.__init__ = patched_init
+
+ # Replace the method on the class so all tray items use this logic
+ SystemTrayItem.do_update_properties = patched_do_update_properties
+
+ # Setup hot-reload listener
+ def on_reload(new_config, old_config):
+ if new_config.get("systray_ignore") != old_config.get("systray_ignore"):
+ logger.info("[SysTray] Config changed, updating tray item visibility")
+ for item_widget in _tracked_items:
+ item_widget.do_update_properties()
+
+ on_config_change(on_reload)
diff --git a/src/window/panel/components/globalmenu.py b/src/window/panel/components/globalmenu.py
new file mode 100644
index 00000000..b0f0cd87
--- /dev/null
+++ b/src/window/panel/components/globalmenu.py
@@ -0,0 +1,372 @@
+from fabric.hyprland.widgets import HyprlandActiveWindow as ActiveWindow
+from fabric.utils import FormattedString, exec_shell_command_async
+from fabric.widgets.box import Box
+from fabric.widgets.button import Button
+from fabric.widgets.centerbox import CenterBox
+from fabric.widgets.label import Label
+
+from shared.dialogs.about import AboutApp, get_about_window
+from shared.window.dropdown import ModusDropdown, dropdown_divider, dropdowns
+from shared.window.mousecapture import DropDownMouseCapture
+from utils.app_name_resolver import format_window
+from utils.roam import modus_service
+from utils.utils import setup_cursor_hover
+from window.settings.main import get_settings_window
+
+
+def has_active_window():
+ return (
+ modus_service.current_active_app_name
+ and modus_service.current_active_app_name != "Finder"
+ )
+
+
+def create_menu_button(label, on_clicked=None):
+ button_kwargs = {
+ "label": label,
+ "name": "global-menu",
+ }
+
+ if on_clicked is not None:
+ button_kwargs["on_clicked"] = on_clicked
+
+ button = Button(**button_kwargs)
+ setup_cursor_hover(button, "pointer")
+ return button
+
+
+def create_dropdown_with_capture(dropdown_id, parent, dropdown_children, layer="top"):
+ dropdown = ModusDropdown(
+ dropdown_id=dropdown_id,
+ parent=parent,
+ dropdown_children=dropdown_children,
+ )
+ mouse_capture = DropDownMouseCapture(layer=layer, child_window=dropdown)
+ return mouse_capture
+
+
+def manage_button_style_classes(buttons, active_button=None, style_class="active"):
+ for button in buttons:
+ if button: # Check if button exists (some might be None)
+ button.remove_style_class(style_class)
+
+ if active_button:
+ active_button.add_style_class(style_class)
+
+
+def show_about_app(_=None):
+ if not has_active_window():
+ return
+
+ app_name = modus_service.current_active_app_name
+ wmclass = getattr(modus_service, "current_active_wm_class", "")
+ about_window = AboutApp(app_name=app_name, wmclass=wmclass)
+ about_window.toggle(None)
+
+
+def on_click_subthread(button, on_clicked, on_click):
+ if on_clicked:
+ on_clicked(button)
+ else:
+ exec_shell_command_async(on_click, lambda *_: None)
+
+ for dropdown in dropdowns:
+ if dropdown.is_visible():
+ dropdown.hide_via_mousecapture()
+ break
+
+
+def dropdown_option(
+ label: str = "",
+ keybind: str = "",
+ on_click='echo "ModusPanelDropdown Action"',
+ on_clicked=None,
+):
+ btn = Button(
+ child=CenterBox(
+ start_children=[
+ Label(label=label, h_align="start", name="dropdown-option-label"),
+ ],
+ end_children=[
+ Label(label=keybind, h_align="end", name="dropdown-option-keybind")
+ ],
+ orientation="horizontal",
+ h_align="fill",
+ h_expand=True,
+ v_expand=True,
+ ),
+ name="dropdown-option",
+ h_align="fill",
+ on_clicked=lambda button: on_click_subthread(button, on_clicked, on_click),
+ h_expand=True,
+ v_expand=True,
+ )
+ setup_cursor_hover(btn, "pointer")
+ return btn
+
+
+class SystemDropdown(ModusDropdown):
+ def __init__(self, parent, **kwargs):
+ super().__init__(
+ dropdown_id="os-menu",
+ parent=parent,
+ dropdown_children=[
+ dropdown_option(
+ "About this PC", on_clicked=lambda _: get_about_window().toggle()
+ ),
+ dropdown_divider("---------------------"),
+ dropdown_option(
+ "System Settings...",
+ on_clicked=lambda _: get_settings_window().toggle(),
+ ),
+ dropdown_divider("---------------------"),
+ dropdown_option("Force Quit", "", "hyprctl kill"),
+ dropdown_divider("---------------------"),
+ dropdown_option("Sleep", "", "systemctl suspend"),
+ dropdown_option("Restart", "", "systemctl reboot"),
+ dropdown_option("Shut Down", "", "shutdown now"),
+ dropdown_divider("---------------------"),
+ dropdown_option("Lock Screen", "๓ฐณ L", "hyprlock"),
+ ],
+ **kwargs,
+ )
+
+
+class GlobalMenuDropdowns:
+ def __init__(self, parent):
+ self.parent = parent
+
+ self.system_dropdown = SystemDropdown(parent=parent)
+ self.menu_button_dropdown = DropDownMouseCapture(
+ layer="top", child_window=self.system_dropdown
+ )
+ self.menu_button = Button(
+ label="Modus",
+ name="global-menu",
+ on_clicked=lambda _: self.menu_button_dropdown.toggle_mousecapture(),
+ )
+ self.menu_button_dropdown.child_window.set_pointing_to(self.menu_button)
+
+ self.global_title_menu_about = dropdown_option(
+ f"About {modus_service.current_active_app_name}",
+ on_clicked=show_about_app,
+ )
+ self.global_menu_title = create_dropdown_with_capture(
+ "global-menu-title",
+ parent,
+ [self.global_title_menu_about],
+ )
+
+ self.global_menu_view = create_dropdown_with_capture(
+ "global-menu-view",
+ parent,
+ [
+ dropdown_option(
+ "Enter Full Screen",
+ on_click="hyprctl dispatch fullscreen",
+ ),
+ ],
+ )
+ self.global_menu_window = create_dropdown_with_capture(
+ "global-menu-window",
+ parent,
+ [
+ dropdown_option(
+ "Zoom In",
+ "๓ฐ +",
+ on_click="hyprctl -q keyword cursor:zoom_factor $(hyprctl getoption cursor:zoom_factor -j | jq '.float * 1.1')",
+ ),
+ dropdown_option(
+ "Zoom Out",
+ "๓ฐ -",
+ on_click="hyprctl -q keyword cursor:zoom_factor $(hyprctl getoption cursor:zoom_factor -j | jq '(.float * 0.9) | if . < 1 then 1 else . end')",
+ ),
+ dropdown_divider("---------------------"),
+ dropdown_option(
+ "Move Window to Left",
+ on_click="hyprctl dispatch movewindow l",
+ ),
+ dropdown_option(
+ "Move Window to Right",
+ on_click="hyprctl dispatch movewindow r",
+ ),
+ dropdown_option(
+ "Cycle Through Windows",
+ on_click="hyprctl dispatch cyclenext",
+ ),
+ dropdown_divider("---------------------"),
+ dropdown_option("Float", on_click="hyprctl dispatch togglefloating"),
+ dropdown_option("Quit", on_click="hyprctl dispatch killactive"),
+ dropdown_option("Pseudo", on_click="hyprctl dispatch pseudo"),
+ dropdown_option(
+ "Toggle Split", on_click="hyprctl dispatch togglesplit"
+ ),
+ dropdown_option("Center", on_click="hyprctl dispatch centerwindow"),
+ dropdown_option("Group", on_click="hyprctl dispatch togglegroup"),
+ dropdown_option(
+ "Pin",
+ on_clicked=lambda _: exec_shell_command_async(
+ "bash ~/.config/scripts/winpin.sh", lambda *_: None
+ ),
+ ),
+ ],
+ )
+
+ self.global_menu_help = create_dropdown_with_capture(
+ "global-menu-help",
+ parent,
+ [
+ dropdown_option(
+ "Modus",
+ on_click="xdg-open https://github.com/S4NKALP/Modus/issues",
+ ),
+ dropdown_divider("---------------------"),
+ dropdown_option(
+ "Hyprland Wiki", on_click="xdg-open https://wiki.hyprland.org/"
+ ),
+ ],
+ )
+
+ modus_service.connect(
+ "current-active-app-name-changed", self._on_active_app_changed
+ )
+
+ self.global_menu_button_title = Button(
+ child=ActiveWindow(
+ formatter=FormattedString(
+ "{ format_window(win_title, win_class) }",
+ format_window=format_window,
+ )
+ ),
+ name="global-menu",
+ on_clicked=self._on_title_button_clicked,
+ )
+
+ self.global_menu_title.child_window.set_pointing_to(
+ self.global_menu_button_title
+ )
+ # File, Edit and Go buttons are placeholders - no dropdowns implemented yet
+ self.global_menu_button_file = create_menu_button("File")
+ self.global_menu_button_edit = create_menu_button("Edit")
+ self.global_menu_button_go = create_menu_button("Go")
+
+ self.global_menu_button_view = create_menu_button(
+ "View",
+ lambda _: self.global_menu_view.toggle_mousecapture(),
+ )
+ self.global_menu_view.child_window.set_pointing_to(self.global_menu_button_view)
+ self.global_menu_button_window = create_menu_button(
+ "Window",
+ lambda _: self.global_menu_window.toggle_mousecapture(),
+ )
+ self.global_menu_window.child_window.set_pointing_to(
+ self.global_menu_button_window
+ )
+ self.global_menu_button_help = create_menu_button(
+ "Help",
+ lambda _: self.global_menu_help.toggle_mousecapture(),
+ )
+ self.global_menu_help.child_window.set_pointing_to(self.global_menu_button_help)
+
+ self.all_menu_buttons = [
+ self.menu_button,
+ self.global_menu_button_title,
+ self.global_menu_button_file,
+ self.global_menu_button_edit,
+ self.global_menu_button_view,
+ self.global_menu_button_go,
+ self.global_menu_button_window,
+ self.global_menu_button_help,
+ ]
+
+ self.dropdown_button_map = {
+ "os-menu": self.menu_button,
+ "global-menu-title": self.global_menu_button_title,
+ "global-menu-file": self.global_menu_button_file,
+ "global-menu-edit": self.global_menu_button_edit,
+ "global-menu-view": self.global_menu_button_view,
+ "global-menu-go": self.global_menu_button_go,
+ "global-menu-window": self.global_menu_button_window,
+ "global-menu-help": self.global_menu_button_help,
+ }
+
+ modus_service.connect("current-dropdown-changed", self.changed_dropdown)
+ modus_service.connect("dropdowns-hide-changed", self.hide_dropdowns)
+
+ def _on_title_button_clicked(self, _):
+ if has_active_window():
+ self.global_menu_title.toggle_mousecapture()
+
+ def _on_active_app_changed(self, _, value):
+ self.global_title_menu_about.set_property("label", f"About {value}")
+
+ def hide_dropdowns(self, *_):
+ manage_button_style_classes(self.all_menu_buttons)
+
+ def changed_dropdown(self, _, dropdown_id):
+ active_button = self.dropdown_button_map.get(dropdown_id)
+ manage_button_style_classes(self.all_menu_buttons, active_button)
+
+ def destroy(self):
+ """Clean up all dropdowns and signal connections"""
+ try:
+ modus_service.disconnect_by_func(self._on_active_app_changed)
+ modus_service.disconnect_by_func(self.changed_dropdown)
+ modus_service.disconnect_by_func(self.hide_dropdowns)
+ except Exception:
+ pass
+
+ # Destroy all dropdown captures
+ dropdown_captures = [
+ getattr(self, "menu_button_dropdown", None),
+ getattr(self, "global_menu_title", None),
+ getattr(self, "global_menu_view", None),
+ getattr(self, "global_menu_window", None),
+ getattr(self, "global_menu_help", None),
+ ]
+ for capture in dropdown_captures:
+ try:
+ if capture and hasattr(capture, "destroy"):
+ capture.destroy()
+ except Exception:
+ pass
+
+ super().destroy()
+
+
+class GlobalMenu(Box):
+ def __init__(self, parent_window=None, **kwargs):
+ if parent_window is None:
+ parent_window = kwargs.pop("parent_window", None)
+
+ super().__init__(
+ name="globalmenu", orientation="horizontal", spacing=0, **kwargs
+ )
+
+ self.dropdown_system = GlobalMenuDropdowns(parent=parent_window)
+
+ self.children = [
+ self.dropdown_system.global_menu_button_title,
+ self.dropdown_system.global_menu_button_file,
+ self.dropdown_system.global_menu_button_edit,
+ self.dropdown_system.global_menu_button_view,
+ self.dropdown_system.global_menu_button_go,
+ self.dropdown_system.global_menu_button_window,
+ self.dropdown_system.global_menu_button_help,
+ ]
+
+ def show_system_dropdown(self, imac_button):
+ self.dropdown_system.menu_button_dropdown.child_window.set_pointing_to(
+ imac_button
+ )
+ mouse_capture = self.dropdown_system.menu_button_dropdown
+ mouse_capture.set_child_window_visible(not mouse_capture.is_visible())
+
+ def destroy(self):
+ """Clean up the global menu and its dropdowns"""
+ if hasattr(self, "dropdown_system") and self.dropdown_system:
+ try:
+ self.dropdown_system.destroy()
+ except Exception:
+ pass
+ super().destroy()
diff --git a/modules/panel/components/indicators.py b/src/window/panel/components/indicators.py
similarity index 51%
rename from modules/panel/components/indicators.py
rename to src/window/panel/components/indicators.py
index 7e8e277f..34a8f2a3 100644
--- a/modules/panel/components/indicators.py
+++ b/src/window/panel/components/indicators.py
@@ -1,19 +1,59 @@
from fabric.bluetooth import BluetoothClient
-from fabric.utils import get_relative_path
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.label import Label
-from fabric.widgets.svg import Svg
+from fabric.widgets.wayland import WaylandWindow as Window
-from modules.controlcenter.battery import BatteryControl
-from modules.controlcenter.bluetooth import BluetoothConnections
-from modules.controlcenter.wifi import WifiConnections
-from services.battery import Battery
+from services.battery import BatteryService, DeviceState
from services.network import NetworkClient
+from shared.window.battery_widget import BatteryControl
+from shared.window.mousecapture import DropDownMouseCapture
+from utils.functions import format_duration, get_wifi_icon_for_strength
from utils.roam import modus_service
-from utils.functions import get_wifi_icon_for_strength, get_wifi_connecting_icon
-from widgets.mousecapture import DropDownMouseCapture
-from widgets.wayland import WaylandWindow as Window
+from utils.utils import setup_cursor_hover, svg_file
+from window.controlcenter.bluetooth import BluetoothConnections
+from window.controlcenter.wifi import WifiConnections
+
+
+def create_control_window(name_prefix):
+ return Window(
+ layer="overlay",
+ title="modus",
+ anchor="top right",
+ margin="2px 10px 0px 0px",
+ exclusivity="auto",
+ keyboard_mode="on-demand",
+ name=f"{name_prefix}-window",
+ visible=False,
+ )
+
+
+def create_mouse_capture(window):
+ return DropDownMouseCapture(layer="top", child_window=window)
+
+
+def setup_control_center(
+ show_window, name_prefix, widget_class, parent, **widget_kwargs
+):
+ if not show_window:
+ return None, None, None
+
+ window = create_control_window(name_prefix)
+ widget = widget_class(parent, **widget_kwargs)
+ window.children = [widget]
+ mouse_capture = create_mouse_capture(window)
+
+ return window, widget, mouse_capture
+
+
+def handle_indicator_click(mouse_capture):
+ if mouse_capture:
+ mouse_capture.toggle_mousecapture()
+
+
+def hide_control_center(mouse_capture):
+ if mouse_capture:
+ mouse_capture.hide_child_window()
class BluetoothIndicator(Box):
@@ -22,13 +62,7 @@ def __init__(self, show_window=True, **kwargs):
self.show_window = show_window
self.bluetooth = BluetoothClient()
- self.bt_icon = Svg(
- name="bt-icon",
- size=22,
- svg_file=get_relative_path(
- "../../../config/assets/icons/applets/bluetooth-clear.svg"
- ),
- )
+ self.bt_icon = svg_file("applets/bluetooth-clear.svg", size=22)
self.bt_button = Button(
name="bt-button", child=self.bt_icon, on_clicked=self.on_bluetooth_clicked
@@ -36,30 +70,23 @@ def __init__(self, show_window=True, **kwargs):
self.add(self.bt_button)
- # Create Bluetooth control center widget only if show_window is True
- if self.show_window:
- self.bluetooth_window = Window(
- layer="overlay",
- title="modus",
- anchor="top right",
- margin="2px 10px 0px 0px",
- exclusivity="auto",
- keyboard_mode="on-demand",
- name="bluetooth-control-window",
- visible=False,
- )
-
- self.bluetooth_widget = BluetoothConnections(self, show_back_button=False)
- self.bluetooth_window.children = [self.bluetooth_widget]
-
- # Create mouse capture for Bluetooth widget
- self.bluetooth_mousecapture = DropDownMouseCapture(
- layer="top", child_window=self.bluetooth_window
- )
- else:
- self.bluetooth_window = None
- self.bluetooth_widget = None
- self.bluetooth_mousecapture = None
+ # Set pointer cursor on hover
+ setup_cursor_hover(self.bt_button, "pointer")
+
+ # Setup control center using shared function
+ (
+ self.bluetooth_window,
+ self.bluetooth_widget,
+ self.bluetooth_mousecapture,
+ ) = setup_control_center(
+ self.show_window,
+ "bluetooth",
+ BluetoothConnections,
+ self,
+ show_back_button=False,
+ )
+ if self.bluetooth_window:
+ self.bluetooth_window._pointing_widget = self.bt_button
modus_service.connect("bluetooth-changed", self.on_bluetooth_changed)
self.bluetooth.connect("changed", self.on_bluetooth_direct_changed)
@@ -71,26 +98,14 @@ def __init__(self, show_window=True, **kwargs):
def update_state(self):
if not self.bluetooth.enabled:
- self.bt_icon.set_from_file(
- get_relative_path(
- "../../../config/assets/icons/applets/bluetooth-off-clear.svg"
- )
- )
+ self.bt_icon.dynamic_file("applets/bluetooth-off-clear.svg")
tooltip = "Bluetooth disabled"
else:
connected_devices = self.bluetooth.connected_devices
if connected_devices:
- self.bt_icon.set_from_file(
- get_relative_path(
- "../../../config/assets/icons/applets/bluetooth-clear.svg"
- )
- )
+ self.bt_icon.dynamic_file("applets/bluetooth-clear.svg")
if len(connected_devices) >= 1:
- self.bt_icon.set_from_file(
- get_relative_path(
- "../../../config/assets/icons/applets/bluetooth-paired.svg"
- )
- )
+ self.bt_icon.dynamic_file("applets/bluetooth-paired.svg")
device = connected_devices[0]
tooltip = f"Connected to {device.alias}"
if device.battery_percentage > 0:
@@ -98,11 +113,7 @@ def update_state(self):
else:
tooltip = f"Connected to {len(connected_devices)} devices"
else:
- self.bt_icon.set_from_file(
- get_relative_path(
- "../../../config/assets/icons/applets/bluetooth-clear.svg"
- )
- )
+ self.bt_icon.dynamic_file("applets/bluetooth-clear.svg")
tooltip = "No devices connected"
self.bt_button.set_tooltip_text(tooltip)
@@ -144,19 +155,13 @@ def update_modus_service_bluetooth_state(self):
modus_service.bluetooth = bluetooth_state
def on_bluetooth_clicked(self, *args):
- """Handle Bluetooth indicator click"""
- if self.show_window and self.bluetooth_mousecapture:
- self.bluetooth_mousecapture.toggle_mousecapture()
+ handle_indicator_click(self.bluetooth_mousecapture)
def close_bluetooth(self, *args):
- """Close Bluetooth control center"""
- if self.show_window and self.bluetooth_mousecapture:
- self.bluetooth_mousecapture.hide_child_window()
+ hide_control_center(self.bluetooth_mousecapture)
def hide_controlcenter(self, *args):
- """Hide Bluetooth control center"""
- if self.show_window and self.bluetooth_mousecapture:
- self.bluetooth_mousecapture.hide_child_window()
+ hide_control_center(self.bluetooth_mousecapture)
class NetworkIndicator(Box):
@@ -166,13 +171,7 @@ def __init__(self, show_window=True, **kwargs):
self.network_service = NetworkClient()
- self.network_icon = Svg(
- name="network-icon",
- size=22,
- svg_file=get_relative_path(
- "../../../config/assets/icons/applets/wifi-clear.svg"
- ),
- )
+ self.network_icon = svg_file("applets/wifi-clear.svg", size=22)
self.network_button = Button(
name="network-button",
@@ -182,30 +181,23 @@ def __init__(self, show_window=True, **kwargs):
self.add(self.network_button)
- # Create WiFi control center widget only if show_window is True
- if self.show_window:
- self.wifi_window = Window(
- layer="overlay",
- title="modus",
- anchor="top right",
- margin="2px 10px 0px 0px",
- exclusivity="auto",
- keyboard_mode="on-demand",
- name="wifi-control-window",
- visible=False,
- )
-
- self.wifi_widget = WifiConnections(self, show_back_button=False)
- self.wifi_window.children = [self.wifi_widget]
-
- # Create mouse capture for WiFi widget
- self.wifi_mousecapture = DropDownMouseCapture(
- layer="top", child_window=self.wifi_window
- )
- else:
- self.wifi_window = None
- self.wifi_widget = None
- self.wifi_mousecapture = None
+ # Set pointer cursor on hover
+ setup_cursor_hover(self.network_button, "pointer")
+
+ # Setup control center using shared function
+ (
+ self.wifi_window,
+ self.wifi_widget,
+ self.wifi_mousecapture,
+ ) = setup_control_center(
+ self.show_window,
+ "wifi",
+ WifiConnections,
+ self,
+ show_back_button=False,
+ )
+ if self.wifi_window:
+ self.wifi_window._pointing_widget = self.network_button
modus_service.connect("wlan-changed", self.on_wlan_changed)
self.network_service.connect("wifi-device-added", self.on_wifi_device_added)
@@ -230,7 +222,6 @@ def on_wifi_device_added(self, *args):
self.update_state()
def on_ethernet_device_added(self, *args):
- """Called when Ethernet device is added"""
if self.network_service.ethernet_device:
self.network_service.ethernet_device.connect(
"changed", self.on_network_direct_changed
@@ -249,7 +240,6 @@ def on_network_changed(self, *args):
def update_modus_service_wlan_state(self):
wlan_state = "disconnected"
- # Check WiFi first (prioritize WiFi over Ethernet)
if self.network_service.wifi_device:
wifi = self.network_service.wifi_device
if not wifi.wireless_enabled:
@@ -315,25 +305,17 @@ def update_state(self):
icon_file = "network-wired-offline.svg"
tooltip = "Ethernet disconnected"
- self.network_icon.set_from_file(
- get_relative_path(f"../../../config/assets/icons/applets/{icon_file}")
- )
+ self.network_icon.dynamic_file(f"applets/{icon_file}")
self.network_button.set_tooltip_text(tooltip)
def on_wifi_clicked(self, *args):
- """Handle WiFi indicator click"""
- if self.show_window and self.wifi_mousecapture:
- self.wifi_mousecapture.toggle_mousecapture()
+ handle_indicator_click(self.wifi_mousecapture)
def close_wifi(self, *args):
- """Close WiFi control center"""
- if self.show_window and self.wifi_mousecapture:
- self.wifi_mousecapture.hide_child_window()
+ hide_control_center(self.wifi_mousecapture)
def hide_controlcenter(self, *args):
- """Hide WiFi control center"""
- if self.show_window and self.wifi_mousecapture:
- self.wifi_mousecapture.hide_child_window()
+ hide_control_center(self.wifi_mousecapture)
class BatteryIndicator(Box):
@@ -341,15 +323,9 @@ def __init__(self, show_window=True, **kwargs):
super().__init__(name="battery-indicator", orientation="h", **kwargs)
self.show_window = show_window
- self.battery_service = Battery()
+ self.battery_service = BatteryService()
- self.battery_icon = Svg(
- name="battery-icon",
- size=23,
- svg_file=get_relative_path(
- "../../../config/assets/icons/battery/battery-100.svg"
- ),
- )
+ self.battery_icon = svg_file("battery/battery-100.svg", size=23)
self.battery_button = Button(
name="battery-button",
@@ -362,30 +338,22 @@ def __init__(self, show_window=True, **kwargs):
self.add(self.battery_label)
self.add(self.battery_button)
- # Create Battery control center widget only if show_window is True
- if self.show_window:
- self.battery_window = Window(
- layer="top",
- title="modus",
- anchor="top right",
- margin="2px 10px 0px 0px",
- exclusivity="auto",
- keyboard_mode="on-demand",
- name="battery-control-window",
- visible=False,
- )
-
- self.battery_widget = BatteryControl(self, show_back_button=False)
- self.battery_window.children = [self.battery_widget]
-
- # Create mouse capture for Battery widget
- self.battery_mousecapture = DropDownMouseCapture(
- layer="top", child_window=self.battery_window
- )
- else:
- self.battery_window = None
- self.battery_widget = None
- self.battery_mousecapture = None
+ # Set pointer cursor on hover
+ setup_cursor_hover(self.battery_button, "pointer")
+
+ (
+ self.battery_window,
+ self.battery_widget,
+ self.battery_mousecapture,
+ ) = setup_control_center(
+ self.show_window,
+ "battery",
+ BatteryControl,
+ self,
+ show_back_button=False,
+ )
+ if self.battery_window:
+ self.battery_window._pointing_widget = self.battery_button
modus_service.connect("battery-changed", self.on_battery_changed)
self.battery_service.connect("changed", self.on_battery_direct_changed)
@@ -400,21 +368,46 @@ def on_battery_direct_changed(self, *args):
self.update_modus_service_battery_state()
self.update_state()
+ def _get_percentage(self):
+ return int(self.battery_service.get_property("Percentage") or 0)
+
+ def _get_state(self):
+ state_value = self.battery_service.get_property("State")
+ return DeviceState.get(state_value, "UNKNOWN")
+
+ def _is_present(self):
+ return bool(self.battery_service.get_property("IsPresent"))
+
+ def _get_time_to_empty(self):
+ return int(self.battery_service.get_property("TimeToEmpty") or 0)
+
+ def _get_time_to_full(self):
+ return int(self.battery_service.get_property("TimeToFull") or 0)
+
+ def _format_time(self, seconds: int) -> str:
+ return format_duration(seconds)
+
+ def _get_battery_icon_file(self, percentage: int, is_charging: bool) -> str:
+ clamped = max(0, min(100, percentage))
+ step = (clamped // 10) * 10
+ filename = f"battery-{step:03d}{'-charging' if is_charging else ''}.svg"
+ return f"battery/{filename}"
+
def update_modus_service_battery_state(self):
- if not self.battery_service.is_present:
+ if not self._is_present():
battery_state = "not_present"
else:
- percentage = self.battery_service.percentage
- state = self.battery_service.state.lower()
+ percentage = self._get_percentage()
+ state_str = self._get_state().lower()
- battery_state = f"{state}:{percentage}%"
+ battery_state = f"{state_str}:{percentage}%"
- if state == "discharging":
- time_to_empty = self.battery_service.time_to_empty
+ if state_str == "discharging":
+ time_to_empty = self._format_time(self._get_time_to_empty())
if time_to_empty != "N/A":
battery_state += f":{time_to_empty}"
- elif state == "charging":
- time_to_full = self.battery_service.time_to_full
+ elif state_str == "charging":
+ time_to_full = self._format_time(self._get_time_to_full())
if time_to_full != "N/A":
battery_state += f":{time_to_full}"
@@ -425,11 +418,11 @@ def get_battery_tooltip(self, percentage, state):
if state == "CHARGING":
tooltip += " (Charging)"
- time_to_full = self.battery_service.time_to_full
+ time_to_full = self._format_time(self._get_time_to_full())
if time_to_full != "N/A":
tooltip += f" - {time_to_full} until full"
elif state == "DISCHARGING":
- time_to_empty = self.battery_service.time_to_empty
+ time_to_empty = self._format_time(self._get_time_to_empty())
if time_to_empty != "N/A":
tooltip += f" - {time_to_empty} remaining"
elif state == "FULLY_CHARGED":
@@ -438,40 +431,129 @@ def get_battery_tooltip(self, percentage, state):
return tooltip
def update_state(self):
- if not self.battery_service.is_present:
- # Hide the entire battery component when no battery is present
+ if not self._is_present():
+ print("[Battery] No battery detected, hiding indicator")
self.set_visible(False)
return
else:
- # Show the battery component when battery is present
self.set_visible(True)
- percentage = self.battery_service.percentage
- state = self.battery_service.state
+ percentage = self._get_percentage()
+ state = self._get_state()
is_charging = state in ["CHARGING", "FULLY_CHARGED"]
- icon_file = Battery.get_battery_icon_file(
- percentage, is_charging, base_path="../../../config/assets/icons/"
- )
+ icon_file = self._get_battery_icon_file(percentage, is_charging)
tooltip = self.get_battery_tooltip(percentage, state)
percentage_text = f"{percentage}%"
- # Update icon, tooltip, and percentage label
- self.battery_icon.set_from_file(get_relative_path(icon_file))
+ self.battery_icon.dynamic_file(icon_file)
self.battery_button.set_tooltip_text(tooltip)
self.battery_label.set_label(percentage_text)
def on_battery_clicked(self, *args):
- """Handle Battery indicator click"""
- if self.show_window and self.battery_mousecapture:
- self.battery_mousecapture.toggle_mousecapture()
+ handle_indicator_click(self.battery_mousecapture)
def close_battery(self, *args):
- """Close Battery control center"""
- if self.show_window and self.battery_mousecapture:
- self.battery_mousecapture.hide_child_window()
+ hide_control_center(self.battery_mousecapture)
def hide_controlcenter(self, *args):
- """Hide Battery control center"""
- if self.show_window and self.battery_mousecapture:
- self.battery_mousecapture.hide_child_window()
+ hide_control_center(self.battery_mousecapture)
+
+ def destroy(self):
+ """Clean up resources when the indicator is destroyed."""
+ # Disconnect signals
+ try:
+ modus_service.disconnect_by_func(self.on_battery_changed)
+ if hasattr(self, "battery_service") and self.battery_service:
+ self.battery_service.disconnect_by_func(self.on_battery_direct_changed)
+ except Exception:
+ pass
+
+ # Destroy window and widget
+ try:
+ if hasattr(self, "battery_widget") and self.battery_widget:
+ self.battery_widget.destroy()
+ if hasattr(self, "battery_window") and self.battery_window:
+ self.battery_window.destroy()
+ if hasattr(self, "battery_mousecapture") and self.battery_mousecapture:
+ self.battery_mousecapture.destroy()
+ except Exception:
+ pass
+
+ super().destroy()
+
+
+# Add destroy methods to other classes in this file as well
+def add_destroy_to_indicators():
+ # BluetoothIndicator
+
+ def bt_destroy(self):
+ try:
+ modus_service.disconnect_by_func(self.on_bluetooth_changed)
+ if hasattr(self, "bluetooth") and self.bluetooth:
+ self.bluetooth.disconnect_by_func(self.on_bluetooth_direct_changed)
+ self.bluetooth.disconnect_by_func(self.on_device_added)
+ self.bluetooth.disconnect_by_func(self.on_device_removed)
+ except Exception:
+ pass
+ try:
+ if hasattr(self, "bluetooth_widget") and self.bluetooth_widget:
+ self.bluetooth_widget.destroy()
+ if hasattr(self, "bluetooth_window") and self.bluetooth_window:
+ self.bluetooth_window.destroy()
+ if hasattr(self, "bluetooth_mousecapture") and self.bluetooth_mousecapture:
+ self.bluetooth_mousecapture.destroy()
+ except Exception:
+ pass
+ Box.destroy(self)
+
+ BluetoothIndicator.destroy = bt_destroy
+
+ # NetworkIndicator
+ def net_destroy(self):
+ try:
+ modus_service.disconnect_by_func(self.on_wlan_changed)
+ if hasattr(self, "network_service") and self.network_service:
+ self.network_service.disconnect_by_func(self.on_wifi_device_added)
+ self.network_service.disconnect_by_func(self.on_ethernet_device_added)
+ self.network_service.disconnect_by_func(self.on_network_changed)
+
+ # Also disconnect from the devices themselves if they exist
+ if (
+ hasattr(self.network_service, "wifi_device")
+ and self.network_service.wifi_device
+ ):
+ try:
+ self.network_service.wifi_device.disconnect_by_func(
+ self.on_network_direct_changed
+ )
+ except Exception:
+ pass
+ if (
+ hasattr(self.network_service, "ethernet_device")
+ and self.network_service.ethernet_device
+ ):
+ try:
+ self.network_service.ethernet_device.disconnect_by_func(
+ self.on_network_direct_changed
+ )
+ except Exception:
+ pass
+ except Exception:
+ pass
+ try:
+ if hasattr(self, "wifi_widget") and self.wifi_widget:
+ self.wifi_widget.destroy()
+ if hasattr(self, "wifi_window") and self.wifi_window:
+ self.wifi_window.destroy()
+ if hasattr(self, "wifi_mousecapture") and self.wifi_mousecapture:
+ self.wifi_mousecapture.destroy()
+ except Exception:
+ pass
+ Box.destroy(self)
+
+ NetworkIndicator.destroy = net_destroy
+
+
+# Apply the destroy methods
+add_destroy_to_indicators()
diff --git a/modules/panel/components/recording_indicator.py b/src/window/panel/components/recording_indicator.py
similarity index 79%
rename from modules/panel/components/recording_indicator.py
rename to src/window/panel/components/recording_indicator.py
index ab318e36..241346d4 100644
--- a/modules/panel/components/recording_indicator.py
+++ b/src/window/panel/components/recording_indicator.py
@@ -1,20 +1,16 @@
-import os
-import subprocess
-import time
-
-from fabric.utils import get_relative_path
+from fabric.utils import GLib, os, time
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.label import Label
-from fabric.widgets.svg import Svg
-from gi.repository import GLib
+from services.screencapture import screen_capture_service
+
+from utils.utils import setup_cursor_hover, svg_file
class RecordingIndicator(Button):
def __init__(self, **kwargs):
super().__init__(name="panel-button", visible=True, **kwargs)
- self.script_path = get_relative_path("../../../scripts/screen-capture.sh")
self.recording_start_time = None
self.last_process_check = 0
self.process_check_interval = 1.0
@@ -24,13 +20,8 @@ def __init__(self, **kwargs):
self.timer_timeout_id = None
self.status_timeout_id = None
- self.recording_icon = Svg(
- name="indicators-icon",
- size=24,
- svg_file=get_relative_path(
- "../../../config/assets/icons/misc/media-record.svg"
- ),
- )
+ self.recording_icon = svg_file("misc/media-record.svg", size=24)
+
self.time_label = Label(
name="recording-time-label",
markup="00:00",
@@ -47,9 +38,19 @@ def __init__(self, **kwargs):
self.add(self.recording_box)
+ # Prevent container.show_all() from forcing this visible when no recording
+ try:
+ self.set_no_show_all(True)
+ except Exception:
+ pass
+
self.connect("clicked", self.on_stop_recording)
+ try:
+ setup_cursor_hover(self, "pointer")
+ except Exception:
+ pass
self.connect("button-press-event", self.on_button_press)
- self.hide()
+ self.set_visible(False)
GLib.timeout_add(100, self._delayed_init)
@@ -58,22 +59,7 @@ def on_button_press(self, *args):
return False
def is_recorder_running(self):
- # add more process names if needed
- recorder_processes = ["wf-recorder", "gpu-screen-recorder"]
-
- try:
- for proc in recorder_processes:
- result = subprocess.run(
- ["pgrep", "-x", proc],
- capture_output=True,
- text=True,
- timeout=1,
- )
- if result.returncode == 0:
- return True # Found a running recorder process
- return False # None found running
- except Exception:
- return False
+ return screen_capture_service.is_recording
def check_recording_status(self):
current_time = time.time()
@@ -95,9 +81,9 @@ def check_recording_status(self):
self.update_timer_display()
else:
- if self.get_visible():
- self.set_visible(False)
- self.cleanup_recording_state()
+ # Ensure it stays hidden when not recording
+ self.set_visible(False)
+ self.cleanup_recording_state()
except Exception as e:
print(f"[DEBUG] Error checking recording status: {e}")
@@ -169,18 +155,8 @@ def on_stop_recording(self, *args):
try:
self.set_visible(False)
self.cleanup_recording_state()
+ screen_capture_service.stop_recording()
- def send_stop_command():
- try:
- subprocess.Popen(
- [self.script_path, "record", "stop"],
- stdout=subprocess.DEVNULL,
- stderr=subprocess.DEVNULL,
- )
- except Exception as e:
- print(f"[DEBUG] Error sending stop command: {e}")
-
- GLib.idle_add(send_stop_command)
GLib.timeout_add(500, self._verify_recording_stopped)
GLib.timeout_add(1500, self._verify_recording_stopped)
GLib.timeout_add(3000, self._verify_recording_stopped)
diff --git a/src/window/panel/components/workspace.py b/src/window/panel/components/workspace.py
new file mode 100644
index 00000000..fffe298c
--- /dev/null
+++ b/src/window/panel/components/workspace.py
@@ -0,0 +1,93 @@
+from fabric.hyprland.widgets import HyprlandWorkspaces, WorkspaceButton
+from fabric.widgets.box import Box
+
+from services.config import get_config, on_config_change
+from utils.functions import is_special_workspace_id
+from utils.utils import setup_cursor_hover
+
+# TODO: Support multi-monitor setups
+
+
+class WorkspaceIndicator(Box):
+ def __init__(self, **kwargs):
+ Box.__init__(
+ self, name="workspace-indicator", orientation="h", spacing=4, **kwargs
+ )
+ self._current_config = {"hide_special_workspace": True}
+ on_config_change(self._on_config_changed)
+
+ self.workspaces = HyprlandWorkspaces(
+ name="workspaces",
+ spacing=4,
+ buttons_factory=self._get_button_factory(),
+ )
+
+ self.add(self.workspaces)
+ self.show_all()
+
+ self._apply_initial_config()
+
+ def _apply_initial_config(self):
+ new_value = get_config("hide_special_workspace", True)
+ if new_value != self._current_config["hide_special_workspace"]:
+ self._current_config["hide_special_workspace"] = new_value
+ self.update_config({"hide_special_workspace": new_value})
+
+ def _on_config_changed(self, new_config: dict, old_config: dict):
+ if "hide_special_workspace" in new_config and new_config.get(
+ "hide_special_workspace"
+ ) != old_config.get("hide_special_workspace"):
+ self._current_config["hide_special_workspace"] = new_config.get(
+ "hide_special_workspace", True
+ )
+ self.update_config(
+ {
+ "hide_special_workspace": self._current_config[
+ "hide_special_workspace"
+ ]
+ }
+ )
+
+ def _get_button_factory(self):
+ if self._current_config.get("hide_special_workspace", True):
+ return self._create_filtered_button
+ else:
+ return lambda ws_id: self._create_button_with_hover(ws_id)
+
+ def _create_filtered_button(self, ws_id):
+ if self._current_config.get(
+ "hide_special_workspace", True
+ ) and is_special_workspace_id(ws_id):
+ return None
+ return self._create_button_with_hover(ws_id)
+
+ def _create_button_with_hover(self, ws_id):
+ button = WorkspaceButton(id=ws_id, label=str(ws_id))
+ setup_cursor_hover(button, "pointer")
+ return button
+
+ def update_config(self, new_config: dict):
+ if "hide_special_workspace" in new_config:
+ button_factory = self._get_button_factory()
+ if hasattr(self, "workspaces") and self.workspaces:
+ self.workspaces.destroy()
+
+ self.workspaces = HyprlandWorkspaces(
+ name="workspaces",
+ spacing=4,
+ buttons_factory=button_factory,
+ )
+ self.add(self.workspaces)
+ self.workspaces.show_all()
+
+ def destroy(self):
+ try:
+ from services.config import _config_handlers
+
+ if self._on_config_changed in _config_handlers:
+ _config_handlers.remove(self._on_config_changed)
+ except Exception:
+ pass
+ if hasattr(self, "workspaces") and self.workspaces:
+ self.workspaces.destroy()
+ Box.destroy(self)
diff --git a/src/window/panel/main.py b/src/window/panel/main.py
new file mode 100644
index 00000000..04e4d94a
--- /dev/null
+++ b/src/window/panel/main.py
@@ -0,0 +1,346 @@
+from fabric.system_tray.widgets import SystemTray
+from fabric.widgets.box import Box
+from fabric.widgets.button import Button
+from fabric.widgets.centerbox import CenterBox
+from fabric.widgets.datetime import DateTime
+from fabric.widgets.revealer import Revealer
+from fabric.widgets.wayland import WaylandWindow as Window
+
+from services.config import on_config_change, get_config_all
+from services.modus import notification_service
+from shared.window.mousecapture import MouseCapture
+from utils.roam import modus_service
+from utils.utils import setup_cursor_hover, svg_file
+from window.controlcenter.main import ModusControlCenter
+from window.notification.notification_center import NotificationCenter
+from window.panel.components.enhanced_system_tray import apply_enhanced_system_tray
+from window.panel.components.globalmenu import GlobalMenu
+from window.panel.components.indicators import (
+ BatteryIndicator,
+ BluetoothIndicator,
+ NetworkIndicator,
+)
+from window.panel.components.recording_indicator import RecordingIndicator
+from window.panel.components.workspace import WorkspaceIndicator
+
+apply_enhanced_system_tray()
+
+
+class Panel(Window):
+ def __init__(self, **kwargs):
+ super().__init__(
+ name="bar",
+ title="modus",
+ layer="top",
+ anchor="left top right",
+ exclusivity="auto",
+ visible=False,
+ all_visible=False,
+ )
+ self.globalmenu = GlobalMenu(parent_window=self)
+
+ self.imac = Button(
+ name="panel-button",
+ child=svg_file("misc/logo.svg", size=18),
+ on_clicked=lambda *_: self.globalmenu.show_system_dropdown((self.imac)),
+ )
+ setup_cursor_hover(self.imac, "pointer")
+
+ self.tray = SystemTray(name="panel-button", spacing=4, icon_size=20)
+
+ self.tray_revealer = Revealer(
+ name="tray-revealer",
+ child=self.tray,
+ child_revealed=False,
+ transition_type="slide-left",
+ transition_duration=300,
+ )
+
+ self.chevron_button = Button(
+ name="panel-button",
+ child=svg_file("misc/chevron-right.svg", size=16),
+ on_clicked=self.toggle_tray,
+ )
+ setup_cursor_hover(self.chevron_button, "pointer")
+
+ # Hide tray elements if empty
+ self.tray.connect("add", self._update_tray_visibility)
+ self.tray.connect("remove", self._update_tray_visibility)
+
+ self.indicators = Box(
+ name="indicators",
+ orientation="h",
+ spacing=4,
+ )
+
+ self.search = Button(
+ name="panel-button", child=svg_file("misc/search.svg", size=22)
+ )
+ setup_cursor_hover(self.search, "pointer")
+
+ self.control_center = MouseCapture(
+ layer="top", child_window=ModusControlCenter()
+ )
+
+ self.control_center_btn = Button(
+ name="panel-button",
+ child=svg_file("misc/control-center.svg", size=22),
+ on_clicked=self.control_center.toggle_mousecapture,
+ )
+ setup_cursor_hover(self.control_center_btn, "pointer")
+ self.control_center.child_window._pointing_widget = self.control_center_btn
+
+ self.notification_center = MouseCapture(
+ layer="overlay", child_window=NotificationCenter()
+ )
+
+ self.notification_icon = svg_file(
+ "notifications/notification-inactive.svg", size=22
+ )
+
+ self.notification_center_btn = Button(
+ name="panel-button",
+ child=self.notification_icon,
+ on_clicked=self.on_notification_icon_clicked,
+ )
+ setup_cursor_hover(self.notification_center_btn, "pointer")
+ self.notification_center.child_window._pointing_widget = (
+ self.notification_center_btn
+ )
+
+ self.datetime_btn = Button(
+ name="panel-button",
+ child=DateTime(name="date-time", formatters=["%a %-d %b %I:%M %P"]),
+ )
+ setup_cursor_hover(self.datetime_btn, "pointer")
+
+ self.workspace_indicator = WorkspaceIndicator()
+ self.recording_indicator = RecordingIndicator()
+
+ # Create persistent indicators
+ self.battery_indicator = BatteryIndicator()
+ self.network_indicator = NetworkIndicator()
+ self.bluetooth_indicator = BluetoothIndicator()
+
+ self.indicators = Box(
+ name="indicators",
+ orientation="h",
+ spacing=4,
+ children=[
+ self.battery_indicator,
+ self.network_indicator,
+ self.bluetooth_indicator,
+ ],
+ )
+
+ # Create boxes and mount
+ self.left_box = Box(name="window-left")
+ self.center_box = Box(name="window-center", children=self.recording_indicator)
+ self.right_box = Box(name="window-right", spacing=4, orientation="h")
+
+ self.children = CenterBox(
+ name="panel",
+ start_children=self.left_box,
+ center_children=self.center_box,
+ end_children=self.right_box,
+ )
+
+ # Connect to DND state changes for notification icon
+ modus_service.connect("dont-disturb-changed", self.on_dnd_changed)
+
+ # Connect to notification service for icon state updates
+ notification_service.connect(
+ "notify::count", self.on_notification_count_changed
+ )
+
+ # Set initial notification icon state
+ self.update_notification_icon()
+
+ # Initial layout build
+ self._rebuild_layout_from_config(get_config_all())
+
+ # Live updates
+ on_config_change(self._on_config_changed)
+
+ self._update_tray_visibility()
+ self.show()
+
+ def on_dnd_changed(self, _, dnd_state):
+ self.update_notification_icon() # Update notification icon when DND changes
+
+ def on_notification_count_changed(self, service, *args):
+ self.update_notification_icon()
+
+ def on_notification_icon_clicked(self, *args):
+ count = notification_service.count
+ if count > 0:
+ # Only open notification center if there are notifications
+ self.notification_center.toggle_mousecapture()
+ # Do nothing if no notifications
+
+ def update_notification_icon(self):
+ count = notification_service.count
+ dnd_enabled = modus_service.dont_disturb
+
+ if dnd_enabled:
+ # DND is enabled - show disabled icon
+ icon_file = "notification-disabled.svg"
+ elif count > 0:
+ # Has notifications - show active icon
+ icon_file = "notification-active.svg"
+ else:
+ # No notifications - show inactive icon
+ icon_file = "notification-inactive.svg"
+
+ self.notification_icon.dynamic_file(f"notifications/{icon_file}")
+
+ def _rebuild_layout_from_config(self, config_data=None):
+ if config_data is None:
+ config_data = get_config_all()
+
+ print(f"[Panel] Rebuilding layout with config: {config_data}")
+
+ # Update indicator visibility directly
+ battery_visible = config_data.get("battery", True)
+ network_visible = config_data.get("network", True)
+ bluetooth_visible = config_data.get("bluetooth", True)
+
+ self.battery_indicator.set_visible(battery_visible)
+ self.network_indicator.set_visible(network_visible)
+ self.bluetooth_indicator.set_visible(bluetooth_visible)
+
+ # Ensure they update their internal state
+ if battery_visible:
+ self.battery_indicator.update_state()
+ if network_visible:
+ self.network_indicator.update_state()
+ if bluetooth_visible:
+ self.bluetooth_indicator.update_state()
+
+ # Left
+ left_children = []
+ if config_data.get("imac_button", True):
+ left_children.append(self.imac)
+ if config_data.get("global_menu", True):
+ left_children.append(self.globalmenu)
+
+ for child in left_children:
+ child.show()
+ self.left_box.children = left_children
+
+ # Right
+ right_children = []
+ if config_data.get("workspace_indicator", True):
+ right_children.append(self.workspace_indicator)
+
+ if config_data.get("systray", True):
+ right_children.extend([self.tray_revealer, self.chevron_button])
+
+ right_children.append(self.indicators)
+
+ if config_data.get("search", True):
+ right_children.append(self.search)
+ if config_data.get("control_center", True):
+ right_children.append(self.control_center_btn)
+ if config_data.get("date_time", True):
+ right_children.append(self.datetime_btn)
+ if config_data.get("notification_center", True):
+ right_children.append(self.notification_center_btn)
+
+ for child in right_children:
+ child.show()
+ self.right_box.children = right_children
+
+ self.indicators.show()
+ self._update_tray_visibility()
+ self.show()
+
+ # Force a layout recalculation
+ self.queue_resize()
+
+ print(
+ f"[Panel] Battery visibility state: {self.battery_indicator.get_visible()}"
+ )
+
+ def _on_config_changed(self, new_config, old_config):
+ print("[Panel] Config changed, checking keys...")
+ keys = {
+ "imac_button",
+ "global_menu",
+ "workspace_indicator",
+ "systray",
+ "battery",
+ "network",
+ "bluetooth",
+ "search",
+ "control_center",
+ "date_time",
+ "notification_center",
+ }
+ changed_keys = [k for k in keys if new_config.get(k) != old_config.get(k)]
+ if changed_keys:
+ print(f"[Panel] Rebuilding due to changes in: {changed_keys}")
+ self._rebuild_layout_from_config(new_config)
+
+ self._update_tray_visibility()
+
+ def _update_tray_visibility(self, *_):
+ # We check if there are any visible children in the tray
+ visible_children = [
+ child for child in self.tray.get_children() if child.get_visible()
+ ]
+ has_items = len(visible_children) > 0
+
+ self.tray_revealer.set_visible(has_items)
+ self.chevron_button.set_visible(has_items)
+
+ if not has_items:
+ # Reset state if it becomes hidden
+ self.tray_revealer.child_revealed = False
+ self.chevron_button.get_child().dynamic_file("misc/chevron-right.svg")
+ # Add extra spacing between workspace indicator and indicators when tray is hidden
+ self.indicators.set_margin_left(10)
+ else:
+ self.indicators.set_margin_left(0)
+
+ def toggle_tray(self, *_):
+ current_state = self.tray_revealer.child_revealed
+ self.tray_revealer.child_revealed = not current_state
+
+ if self.tray_revealer.child_revealed:
+ self.chevron_button.get_child().dynamic_file("misc/chevron-left.svg")
+ else:
+ self.chevron_button.get_child().dynamic_file("misc/chevron-right.svg")
+
+ def destroy(self):
+ """Clean up all signals and components"""
+ try:
+ modus_service.disconnect_by_func(self.on_dnd_changed)
+ notification_service.disconnect_by_func(self.on_notification_count_changed)
+ from services.config import _config_handlers
+
+ if self._on_config_changed in _config_handlers:
+ _config_handlers.remove(self._on_config_changed)
+ except Exception:
+ pass
+
+ # Destroy components
+ for component in [
+ self.globalmenu,
+ self.workspace_indicator,
+ self.recording_indicator,
+ self.indicators,
+ ]:
+ try:
+ component.destroy()
+ except Exception:
+ pass
+
+ # Destroy MouseCapture windows
+ for mc in [self.control_center, self.notification_center]:
+ try:
+ mc.destroy()
+ except Exception:
+ pass
+
+ super().destroy()
diff --git a/src/window/settings/main.py b/src/window/settings/main.py
new file mode 100644
index 00000000..b3569dd1
--- /dev/null
+++ b/src/window/settings/main.py
@@ -0,0 +1,489 @@
+from fabric.widgets.box import Box
+from fabric.widgets.button import Button
+from fabric.widgets.centerbox import CenterBox
+from fabric.widgets.entry import Entry
+from fabric.widgets.label import Label
+from fabric.widgets.scrolledwindow import ScrolledWindow
+from fabric.widgets.stack import Stack
+from fabric.utils import Gtk, GLib, Gdk
+
+from services.config import config, on_config_change
+from utils.utils import setup_cursor_hover, svg_file
+
+
+class SettingsRow(CenterBox):
+ def __init__(self, label: str, child=None, description: str = None, **kwargs):
+ label_widget = Label(label=label, name="settings-row-label", h_align="start")
+
+ start_children = [label_widget]
+ if description:
+ description_widget = Label(
+ label=description, name="settings-row-description", h_align="start"
+ )
+ start_children = [
+ Box(
+ orientation="v",
+ spacing=2,
+ children=[label_widget, description_widget],
+ )
+ ]
+
+ super().__init__(
+ name="settings-row",
+ start_children=start_children,
+ end_children=[child] if child else [],
+ **kwargs,
+ )
+ self.label_widget = label_widget
+ if description:
+ self.description_widget = description_widget
+
+
+class SettingsSwitch(Button):
+ def __init__(self, config_key: str, **kwargs):
+ self.config_key = config_key
+ self.active = config().get(config_key, False)
+
+ super().__init__(name="settings-switch", on_clicked=self.toggle, **kwargs)
+ self._update_style()
+ setup_cursor_hover(self, "pointer")
+
+ # Sync with external config changes
+ on_config_change(self._on_config_change)
+
+ def _on_config_change(self, new_config, old_config):
+ if config().has_changed(self.config_key, old_config):
+ self.active = new_config.get(self.config_key, False)
+ self._update_style()
+
+ def toggle(self, *args):
+ self.active = not self.active
+ config().set(self.config_key, self.active)
+ config().save()
+ self._update_style()
+
+ def _update_style(self):
+ if self.active:
+ self.add_style_class("active")
+ self.set_label("On")
+ else:
+ self.remove_style_class("active")
+ self.set_label("Off")
+
+
+class SettingsEntry(Entry):
+ def __init__(self, config_key: str, **kwargs):
+ self.config_key = config_key
+ initial_value = str(config().get(config_key, ""))
+
+ super().__init__(
+ name="settings-entry",
+ text=initial_value,
+ on_activate=self.on_change,
+ **kwargs,
+ )
+
+ # Sync with external config changes
+ on_config_change(self._on_config_change)
+
+ def _on_config_change(self, new_config, old_config):
+ if config().has_changed(self.config_key, old_config):
+ new_val = str(new_config.get(self.config_key, ""))
+ if self.get_text() != new_val:
+ self.set_text(new_val)
+
+ def on_change(self, entry, *args):
+ value = entry.get_text()
+ # Try to convert to int if possible
+ try:
+ if value.isdigit():
+ value = int(value)
+ except Exception:
+ pass
+
+ config().set(self.config_key, value)
+ config().save()
+
+
+class SettingsComboBox(Gtk.ComboBoxText):
+ def __init__(self, config_key: str, options: list, **kwargs):
+ super().__init__(**kwargs)
+ self.set_name("settings-combo")
+ self.set_halign(Gtk.Align.END)
+ self.config_key = config_key
+ self.options = options
+
+ for opt in options:
+ self.append_text(opt)
+
+ self._update_from_config()
+ self.connect("changed", self.on_change)
+
+ # Sync with external config changes
+ on_config_change(self._on_config_change)
+
+ def _update_from_config(self):
+ current_val = str(config().get(self.config_key, ""))
+ if current_val in self.options:
+ self.set_active(self.options.index(current_val))
+
+ def _on_config_change(self, new_config, old_config):
+ if config().has_changed(self.config_key, old_config):
+ self._update_from_config()
+
+ def on_change(self, combo):
+ value = combo.get_active_text()
+ if value:
+ config().set(self.config_key, value)
+ config().save()
+
+
+class SettingsList(Box):
+ """A list of tags that can be added or removed"""
+
+ def __init__(self, config_key: str, **kwargs):
+ super().__init__(name="settings-list", orientation="v", spacing=5, **kwargs)
+ self.config_key = config_key
+
+ self.list_box = Box(spacing=5, name="settings-list-tags")
+ self.entry = Entry(
+ name="settings-list-entry",
+ placeholder="Add new item...",
+ on_activate=self.on_add,
+ )
+
+ self.add(self.list_box)
+ self.add(self.entry)
+
+ self._update_list()
+ on_config_change(self._on_config_change)
+
+ def _on_config_change(self, new_config, old_config):
+ if config().has_changed(self.config_key, old_config):
+ self._update_list()
+
+ def _update_list(self):
+ items = config().get(self.config_key, [])
+ if not isinstance(items, list):
+ items = []
+
+ # Clear existing tags
+ self.list_box.children = []
+
+ for item in items:
+ tag = Button(
+ name="settings-list-tag",
+ child=Box(
+ spacing=5,
+ children=[
+ Label(label=item),
+ Label(label="โ", name="settings-list-tag-close"),
+ ],
+ ),
+ on_clicked=lambda *_, i=item: self.on_remove(i),
+ )
+ setup_cursor_hover(tag, "pointer")
+ self.list_box.pack_start(tag, False, False, 0)
+
+ self.list_box.show_all()
+
+ def on_add(self, entry):
+ val = entry.get_text().strip()
+ if not val:
+ return
+
+ items = config().get(self.config_key, [])
+ if val not in items:
+ items.append(val)
+ config().set(self.config_key, items)
+ config().save()
+
+ entry.set_text("")
+ self._update_list()
+
+ def on_remove(self, item):
+ items = config().get(self.config_key, [])
+ if item in items:
+ items.remove(item)
+ config().set(self.config_key, items)
+ config().save()
+ self._update_list()
+
+
+class SettingsPage(ScrolledWindow):
+ def __init__(self, title: str, rows: list, **kwargs):
+ container = Box(
+ name="settings-page-container",
+ orientation="v",
+ spacing=10,
+ h_expand=True,
+ v_expand=True,
+ children=[
+ Label(label=title, name="settings-page-title", h_align="start"),
+ Box(orientation="v", spacing=1, children=rows, h_expand=True),
+ ],
+ )
+ super().__init__(
+ name="settings-page",
+ h_expand=True,
+ v_expand=True,
+ propagate_width=True,
+ propagate_height=True,
+ min_content_width=600,
+ min_content_height=500,
+ **kwargs,
+ )
+ self.add(container)
+ self.show_all()
+ self._container = container
+
+
+class SettingsWindow(Gtk.Window):
+ def __init__(self, **kwargs):
+ GLib.set_prgname("modus-settings")
+ super().__init__(title="Modus Settings", **kwargs)
+ self.set_name("settings-window")
+ self.set_wmclass("modus-settings", "Modus")
+ self.set_default_size(850, 600)
+ self.set_resizable(False)
+ self.set_type_hint(Gtk.Window.get_type_hint(self) | Gdk.WindowTypeHint.DIALOG)
+ self.set_position(Gtk.WindowPosition.CENTER)
+ self.set_visible(False)
+
+ # Reset global singleton on destroy
+ self.connect("destroy", self._on_window_destroy)
+
+ self.stack = Stack(
+ name="settings-stack",
+ transition_type="none",
+ h_expand=True,
+ v_expand=True,
+ )
+
+ self.pages = {
+ "general": self._create_general_page(),
+ "dock": self._create_dock_page(),
+ "panel": self._create_panel_page(),
+ "notifications": self._create_notifications_page(),
+ }
+
+ for name, page in self.pages.items():
+ self.stack.add_named(page, name)
+
+ self.sidebar_buttons = {}
+ self.sidebar_buttons["general"] = self._create_sidebar_button(
+ "General", "general", "misc/logo.svg"
+ )
+ self.sidebar_buttons["dock"] = self._create_sidebar_button(
+ "Dock", "dock", "misc/control.svg"
+ )
+ self.sidebar_buttons["panel"] = self._create_sidebar_button(
+ "Panel", "panel", "misc/control-center.svg"
+ )
+ self.sidebar_buttons["notifications"] = self._create_sidebar_button(
+ "Notifications", "notifications", "notifications/notification-active.svg"
+ )
+
+ self.set_page("general")
+
+ self.sidebar = Box(
+ name="settings-sidebar",
+ orientation="v",
+ spacing=5,
+ children=list(self.sidebar_buttons.values()),
+ )
+
+ self.main_box = Box(
+ name="settings-main-box",
+ orientation="h",
+ h_expand=True,
+ v_expand=True,
+ children=[
+ self.sidebar,
+ Box(
+ name="settings-content-wrapper",
+ children=[self.stack],
+ h_expand=True,
+ v_expand=True,
+ ),
+ ],
+ )
+
+ self.add(self.main_box)
+
+ def _create_sidebar_button(self, label, page_name, icon_name):
+ btn = Button(
+ name="settings-sidebar-button",
+ child=Box(
+ orientation="h",
+ spacing=10,
+ children=[svg_file(icon_name, size=16), Label(label=label)],
+ ),
+ on_clicked=lambda *_: self.set_page(page_name),
+ )
+ setup_cursor_hover(btn, "pointer")
+ return btn
+
+ def set_page(self, page_name):
+ if page_name not in self.pages:
+ return
+ self.stack.set_visible_child(self.pages[page_name])
+ for name, btn in self.sidebar_buttons.items():
+ if name == page_name:
+ btn.add_style_class("active")
+ else:
+ btn.remove_style_class("active")
+
+ def _create_general_page(self):
+ return SettingsPage(
+ "General Settings",
+ [
+ SettingsRow(
+ "Wallpapers Directory",
+ SettingsEntry("wallpapers_dir"),
+ "Path to your wallpaper collection",
+ ),
+ SettingsRow(
+ "Weather Location",
+ SettingsEntry("weather_location"),
+ "City name for weather updates",
+ ),
+ SettingsRow(
+ "Keyboard Layouts",
+ SettingsList("keyboard_layouts"),
+ "Manage active keyboard input languages",
+ ),
+ SettingsRow(
+ "Window Switcher",
+ SettingsSwitch("window_switcher"),
+ "Enable the Alt-Tab window switcher",
+ ),
+ SettingsRow(
+ "Items Per Row",
+ SettingsEntry("window_switcher_items_per_row"),
+ "Maximum items in window switcher row",
+ ),
+ SettingsRow(
+ "Hide Special Workspace",
+ SettingsSwitch("hide_special_workspace"),
+ "Don't show special workspace in indicators",
+ ),
+ SettingsRow(
+ "OSD",
+ SettingsSwitch("osd"),
+ "On-screen display for volume/brightness",
+ ),
+ ],
+ )
+
+ def _create_dock_page(self):
+ return SettingsPage(
+ "Dock Settings",
+ [
+ SettingsRow(
+ "Enabled",
+ SettingsSwitch("dock_enabled"),
+ "Show the application dock",
+ ),
+ SettingsRow(
+ "Auto Hide",
+ SettingsSwitch("dock_auto_hide"),
+ "Hide dock when not in use",
+ ),
+ SettingsRow(
+ "Always Occluded",
+ SettingsSwitch("dock_always_occluded"),
+ "Keep dock behind other windows",
+ ),
+ SettingsRow(
+ "Icon Size",
+ SettingsEntry("dock_icon_size"),
+ "Size of dock icons in pixels",
+ ),
+ SettingsRow(
+ "Hide Special Apps",
+ SettingsSwitch("dock_hide_special_workspace_apps"),
+ "Hide apps from special workspace in dock",
+ ),
+ ],
+ )
+
+ def _create_panel_page(self):
+ # panel visibility settings
+ rows = []
+ panel_settings = [
+ ("iMac Button", "imac_button"),
+ ("Global Menu", "global_menu"),
+ ("Systray", "systray"),
+ ("Control Center", "control_center"),
+ ("Search", "search"),
+ ("Network", "network"),
+ ("Battery", "battery"),
+ ("Bluetooth", "bluetooth"),
+ ("Date & Time", "date_time"),
+ ("Workspace Indicator", "workspace_indicator"),
+ ("Notification Center", "notification_center"),
+ ]
+ for label, key in panel_settings:
+ rows.append(SettingsRow(label, SettingsSwitch(key)))
+
+ rows.append(
+ SettingsRow(
+ "Systray Ignore",
+ SettingsList("systray_ignore"),
+ "Icons to hide from the system tray",
+ )
+ )
+
+ return SettingsPage("Panel Settings", rows)
+
+ def _create_notifications_page(self):
+ return SettingsPage(
+ "Notification Settings",
+ [
+ SettingsRow(
+ "Timeout",
+ SettingsEntry("notification_timeout"),
+ "How long notifications stay on screen (e.g. 5s)",
+ ),
+ SettingsRow(
+ "Ignored Apps",
+ SettingsList("notification_ignored_apps"),
+ "Apps that won't show notifications",
+ ),
+ SettingsRow(
+ "Limited History",
+ SettingsList("notification_limited_apps_history"),
+ "Apps with only the latest notification shown",
+ ),
+ ],
+ )
+
+ def _on_window_destroy(self, *args):
+ global _settings_window
+ _settings_window = None
+
+ def toggle(self):
+ if not self.get_visible():
+ self.show_all()
+ self.present()
+
+ # Re-sync current page
+ current_page = "general"
+ for name, btn in self.sidebar_buttons.items():
+ if "active" in btn.get_style_context().list_classes():
+ current_page = name
+ break
+ self.set_page(current_page)
+ else:
+ self.hide()
+
+
+_settings_window = None
+
+
+def get_settings_window():
+ global _settings_window
+ if _settings_window is None:
+ _settings_window = SettingsWindow()
+ return _settings_window
diff --git a/modules/switcher.py b/src/window/switcher.py
similarity index 55%
rename from modules/switcher.py
rename to src/window/switcher.py
index 564c54a7..f8a86e20 100644
--- a/modules/switcher.py
+++ b/src/window/switcher.py
@@ -1,20 +1,16 @@
import json
-import gi
-from gi.repository import Gdk, Glace
-
-import config.data as data
from fabric.hyprland.widgets import get_hyprland_connection
+from fabric.utils import Gdk
from fabric.widgets.box import Box
from fabric.widgets.eventbox import EventBox
from fabric.widgets.image import Image
from fabric.widgets.label import Label
-from utils.icon_resolver import IconResolver
-from utils.occlusion import get_screen_dimensions
-from utils.functions import is_special_workspace
-from widgets.wayland import WaylandWindow as Window
+from fabric.widgets.wayland import WaylandWindow as Window
-gi.require_version("Glace", "0.1")
+from services.config import config, on_config_change
+from utils.functions import is_special_workspace
+from utils.icon_resolver import IconResolver
class ApplicationSwitcher(Window):
@@ -35,21 +31,10 @@ def __init__(self, **kwargs):
self.windows = []
self.current_index = 0
self.tab_pressed = False
- self.items_per_row = data.WINDOW_SWITCHER_ITEMS_PER_ROW
- self.icon_size = 64
+ self.icon_size = 96
- # Initialize Glace manager for window previews
- self._manager = Glace.Manager()
-
- # Calculate preview size based on screen ratio
- # Formula: screen_ratio = a:b, width = x, height = (x*b)/a
- screen_width, screen_height = get_screen_dimensions()
- preview_width = 150 # Base width
- preview_height = int((preview_width * screen_height) / screen_width)
- self.preview_size = [preview_width, preview_height]
-
- self.glace_clients = {} # Map window addresses to Glace clients
- self.window_previews = {} # Map window addresses to preview images
+ # Subscribe to config changes
+ on_config_change(self._on_config_changed)
container = Box(
name="application-switcher-container",
@@ -62,22 +47,49 @@ def __init__(self, **kwargs):
self.view = Box(
name="application-switcher-view",
- orientation="v",
- spacing=12,
+ orientation="h",
+ spacing=16,
h_align="center",
v_align="center",
)
container.add(self.view)
+
+ self.selection_label = Label(
+ name="switcher-selection-label",
+ label="",
+ h_align="center",
+ v_align="center",
+ style_classes=["switcher-selection-label"],
+ )
+ container.add(self.selection_label)
+
+ self.workspace_label = Label(
+ name="switcher-workspace-label",
+ label="",
+ h_align="center",
+ v_align="center",
+ style_classes=["switcher-workspace-label"],
+ )
+ container.add(self.workspace_label)
+
self.connect("key-press-event", self.on_key_press)
self.connect("key-release-event", self.on_key_release)
- # Connect to Glace manager signals to track clients
- self._manager.connect("client-added", self._on_glace_client_added)
- self._manager.connect("client-removed", self._on_glace_client_removed)
-
self.show_all()
self.hide()
+ @property
+ def items_per_row(self):
+ return config().get("window_switcher_items_per_row", 10)
+
+ def _on_config_changed(self, new_config, old_config):
+ # Refresh windows if settings that affect display change
+ if config().has_changed(
+ "hide_special_workspace", old_config
+ ) or config().has_changed("window_switcher_items_per_row", old_config):
+ if self.get_visible():
+ self.update_windows()
+
def show_switcher(self) -> None:
self.update_windows()
if not self.windows:
@@ -91,114 +103,27 @@ def hide_switcher(self) -> None:
self.hide()
self.ungrab_keyboard()
- def _on_glace_client_added(self, _, client):
- """Handle when a Glace client is added"""
- try:
- # Map the client by its window address for later lookup
- # We'll need to match this with Hyprland window data
- client_id = client.get_id()
- self.glace_clients[client_id] = client
- except Exception as e:
- print(f"Error adding Glace client: {e}")
-
- def _on_glace_client_removed(self, _, client):
- """Handle when a Glace client is removed"""
- try:
- client_id = client.get_id()
- if client_id in self.glace_clients:
- del self.glace_clients[client_id]
- except Exception as e:
- print(f"Error removing Glace client: {e}")
-
- def _find_glace_client_for_window(self, window):
- """Find the corresponding Glace client for a Hyprland window"""
- try:
- window_class = window.get("class", "").lower()
- window_title = window.get("title", "")
-
- # Try to match by app_id/class and title
- for _, client in self.glace_clients.items():
- try:
- client_app_id = client.get_app_id()
- client_title = client.get_title()
-
- if (
- client_app_id
- and client_app_id.lower() == window_class
- and client_title
- and client_title == window_title
- ):
- return client
- except Exception:
- continue
-
- # Fallback: try to match by class only
- for _, client in self.glace_clients.items():
- try:
- client_app_id = client.get_app_id()
- if client_app_id and client_app_id.lower() == window_class:
- return client
- except Exception:
- continue
-
- except Exception as e:
- print(f"Error finding Glace client: {e}")
-
- return None
-
- def create_preview_for_window(self, window):
- """Create a preview image for a specific window"""
- glace_client = self._find_glace_client_for_window(window)
-
- # Create a placeholder image first
- preview_image = Image()
-
- if glace_client:
-
- def capture_callback(pbuf, _):
- try:
- scaled_pixbuf = pbuf.scale_simple(
- self.preview_size[0],
- self.preview_size[1],
- 2, # GdkPixbuf.InterpType.BILINEAR
- )
- preview_image.set_from_pixbuf(scaled_pixbuf)
- except Exception as e:
- print(f"Error setting preview image: {e}")
-
- try:
- self._manager.capture_client(
- client=glace_client,
- overlay_cursor=False,
- callback=capture_callback,
- user_data=None,
- )
- except Exception as e:
- print(f"Error capturing client preview: {e}")
- # Fallback to icon if preview fails
- self._set_fallback_icon(preview_image, window)
- else:
- # Use icon as fallback if no Glace client found
- self._set_fallback_icon(preview_image, window)
-
- return preview_image
-
- def _set_fallback_icon(self, image_widget, window):
- """Set a fallback icon when preview is not available"""
+ def create_icon_for_window(self, window):
+ """Create an icon image for a specific window"""
class_name = window.get("class", "").lower()
icon_img = self.icon_resolver.get_icon_pixbuf(class_name, self.icon_size)
if not icon_img:
icon_img = self.icon_resolver.get_icon_pixbuf(
"application-x-executable-symbolic", self.icon_size
)
- image_widget.set_from_pixbuf(icon_img)
+
+ icon_image = Image()
+ if icon_img:
+ icon_image.set_from_pixbuf(icon_img)
+
+ return icon_image
def _is_special_workspace(self, client):
return is_special_workspace(client)
def update_windows(self) -> None:
for child in self.view.get_children():
- self.view.remove(child)
+ child.destroy()
try:
clients_data = self.conn.send_command("j/clients").reply
@@ -208,14 +133,13 @@ def update_windows(self) -> None:
# Filter out hidden windows and optionally special workspace windows
filtered_windows = []
+ hide_special = config().get("hide_special_workspace", True)
+
for c in clients:
if c.get("hidden", False):
continue
# Skip clients in special workspaces if the setting is enabled
- if (
- data.DOCK_HIDE_SPECIAL_WORKSPACE_APPS
- and self._is_special_workspace(c)
- ):
+ if hide_special and self._is_special_workspace(c):
continue
filtered_windows.append(c)
@@ -233,41 +157,39 @@ def update_windows(self) -> None:
self.current_index = i
break
- current_row = Box(
- name="window-row",
- orientation="h",
- spacing=12,
- h_align="center",
- v_align="center",
- )
- self.view.add(current_row)
+ # Create vertical container for rows
+ rows_box = Box(orientation="v", spacing=16)
+ self.view.add(rows_box)
+
+ current_row = None
+ items_per_row = self.items_per_row
for i, window in enumerate(self.windows):
- title = window.get("title", "")
+ if i % items_per_row == 0:
+ current_row = Box(
+ name="window-row",
+ orientation="h",
+ spacing=16,
+ h_align="center",
+ v_align="center",
+ )
+ rows_box.add(current_row)
- # Create preview image for this window
- preview_image = self.create_preview_for_window(window)
+ # Create icon image for this window
+ icon_image = self.create_icon_for_window(window)
button_content = Box(
name="switcher-button",
orientation="v",
- spacing=4,
h_align="center",
v_align="center",
children=[
Box(
- name="switcher-preview-box",
+ name="switcher-icon-box",
style_classes=["window-basic", "sleek-border"],
- children=[preview_image],
- h_align="center",
- v_align="center",
- ),
- Label(
- label=title[:15] + "..." if len(title) > 15 else title,
+ children=[icon_image],
h_align="center",
v_align="center",
- max_width_chars=15,
- ellipsize="end",
),
],
)
@@ -279,16 +201,6 @@ def update_windows(self) -> None:
)
current_row.add(event_box)
- if (i + 1) % self.items_per_row == 0 and i + 1 < len(self.windows):
- current_row = Box(
- name="window-row",
- orientation="h",
- spacing=12,
- h_align="center",
- v_align="center",
- )
- self.view.add(current_row)
-
self.view.show_all()
self.update_selection()
except Exception as e:
@@ -366,13 +278,30 @@ def on_key_release(self, _, event):
return False
def update_selection(self):
- for row in self.view.get_children():
- for i, child in enumerate(row.get_children()):
- index = self.view.get_children().index(row) * self.items_per_row + i
- if index == self.current_index:
- child.add_style_class("active")
- else:
- child.remove_style_class("active")
+ # Flatten all buttons from all rows to find them by index
+ all_buttons = []
+ rows_box = self.view.get_children()[0] if self.view.get_children() else None
+ if rows_box:
+ for row in rows_box.get_children():
+ all_buttons.extend(row.get_children())
+
+ for i, child in enumerate(all_buttons):
+ if i == self.current_index:
+ child.add_style_class("active")
+ # Update selection label
+ current_window = self.windows[self.current_index]
+ app_class = current_window.get("class", "Unknown")
+ # capitalize if it's all lowercase
+ if app_class.islower():
+ app_class = app_class.capitalize()
+ self.selection_label.set_label(app_class)
+
+ # Update workspace label
+ workspace = current_window.get("workspace", {})
+ workspace_name = workspace.get("name", "Unknown")
+ self.workspace_label.set_label(f"Workspace {workspace_name}")
+ else:
+ child.remove_style_class("active")
def activate_selected(self):
if not self.windows or self.current_index >= len(self.windows):
diff --git a/src/window/todo/__init__.py b/src/window/todo/__init__.py
new file mode 100644
index 00000000..c9af2aa8
--- /dev/null
+++ b/src/window/todo/__init__.py
@@ -0,0 +1 @@
+# Todo module
diff --git a/modules/todo/todo_widget.py b/src/window/todo/todo_widget.py
similarity index 88%
rename from modules/todo/todo_widget.py
rename to src/window/todo/todo_widget.py
index a19cb96d..a487ae56 100644
--- a/modules/todo/todo_widget.py
+++ b/src/window/todo/todo_widget.py
@@ -1,21 +1,16 @@
-# Standard library imports
from datetime import datetime
-# Fabric imports
-from fabric.utils import get_relative_path
+from fabric.utils import GLib
from fabric.widgets.box import Box
from fabric.widgets.button import Button
-from fabric.widgets.centerbox import CenterBox
from fabric.widgets.entry import Entry
from fabric.widgets.label import Label
from fabric.widgets.scrolledwindow import ScrolledWindow
-from fabric.widgets.svg import Svg
-from gi.repository import GLib
+from fabric.widgets.wayland import WaylandWindow as Window
-# Local imports
-from services.todo import todo_service
-from widgets.mousecapture import MouseCapture
-from widgets.wayland import WaylandWindow as Window
+from services.todo import get_todo_service
+from shared.window.mousecapture import MouseCapture
+from utils.utils import svg_file
class TodoItem(Box):
@@ -44,12 +39,10 @@ def _build_ui(self):
if self.todo_data["completed"]
else "checkbox-uncheck.svg"
)
- self.checkbox_icon = Svg(
+ self.checkbox_icon = svg_file(
+ "todo/" + checkbox_icon,
name="todo-checkbox-icon",
size=24,
- svg_file=get_relative_path(
- "../../config/assets/icons/todo/" + checkbox_icon
- ),
)
self.checkbox = Button(
name="todo-checkbox",
@@ -109,10 +102,10 @@ def _build_ui(self):
# )
#
# Edit button - using SVG icon
- self.edit_icon = Svg(
+ self.edit_icon = svg_file(
+ "todo/edit.svg",
name="todo-edit-icon",
size=12,
- svg_file=get_relative_path("../../config/assets/icons/todo/edit.svg"),
)
self.edit_button = Button(
name="todo-edit",
@@ -121,12 +114,10 @@ def _build_ui(self):
)
# Delete button - using SVG icon
- self.delete_icon = Svg(
+ self.delete_icon = svg_file(
+ "todo/delete-symbolic.svg",
name="todo-delete-icon",
size=12,
- svg_file=get_relative_path(
- "../../config/assets/icons/todo/delete-symbolic.svg"
- ),
)
self.delete_button = Button(
name="todo-delete",
@@ -154,14 +145,14 @@ def _build_ui(self):
def _toggle_completion(self, *_):
"""Toggle todo completion status"""
- todo_service.toggle_todo(self.todo_data["id"])
+ get_todo_service().toggle_todo(self.todo_data["id"])
def _cycle_priority(self, *_):
"""Cycle through priority levels"""
priorities = ["low", "medium", "high"]
current_index = priorities.index(self.todo_data["priority"])
new_priority = priorities[(current_index + 1) % len(priorities)]
- todo_service.set_priority(self.todo_data["id"], new_priority)
+ get_todo_service().set_priority(self.todo_data["id"], new_priority)
def _start_edit(self, *_):
"""Start editing the todo text"""
@@ -184,7 +175,7 @@ def _save_edit(self, *_):
new_text = self.text_entry.get_text().strip()
if new_text:
- todo_service.edit_todo(self.todo_data["id"], new_text)
+ get_todo_service().edit_todo(self.todo_data["id"], new_text)
self._cancel_edit()
@@ -199,7 +190,7 @@ def _cancel_edit(self):
def _delete_todo(self, *_):
"""Delete this todo"""
- todo_service.delete_todo(self.todo_data["id"])
+ get_todo_service().delete_todo(self.todo_data["id"])
def update_from_data(self, todo_data):
"""Update the widget from new todo data"""
@@ -209,12 +200,10 @@ def update_from_data(self, todo_data):
checkbox_icon = (
"checkbox-check.svg" if todo_data["completed"] else "checkbox-uncheck.svg"
)
- new_checkbox_icon = Svg(
+ new_checkbox_icon = svg_file(
+ "todo/" + checkbox_icon,
name="todo-checkbox-icon",
size=20,
- svg_file=get_relative_path(
- "../../config/assets/icons/todo/" + checkbox_icon
- ),
)
self.checkbox.set_child(new_checkbox_icon)
self.checkbox_icon = new_checkbox_icon
@@ -253,7 +242,7 @@ def __init__(self, **kwargs):
self.todo_items = {} # Maps todo IDs to TodoItem widgets
# Register callback with todo service
- todo_service.add_callback(self._on_todo_event)
+ get_todo_service().add_callback(self._on_todo_event)
self._build_ui()
self._refresh_todos()
@@ -294,12 +283,10 @@ def _build_ui(self):
self.new_todo_entry.connect("activate", self._add_todo)
# Add button - using SVG icon
- self.add_icon = Svg(
+ self.add_icon = svg_file(
+ "todo/plus-symbolic.svg",
name="add-todo-icon",
size=12,
- svg_file=get_relative_path(
- "../../config/assets/icons/todo/plus-symbolic.svg"
- ),
)
self.add_button = Button(
name="add-todo-button",
@@ -364,12 +351,12 @@ def _add_todo(self, *_):
"""Add a new todo"""
text = self.new_todo_entry.get_text().strip()
if text:
- todo_service.add_todo(text)
+ get_todo_service().add_todo(text)
self.new_todo_entry.set_text("")
def _clear_completed(self, *_):
"""Clear all completed todos"""
- todo_service.clear_completed()
+ get_todo_service().clear_completed()
def _on_todo_event(self, event_type, data=None):
"""Handle todo service events via callback"""
@@ -393,7 +380,7 @@ def _refresh_todos(self, *_):
self.todos_container.children = []
# Get all todos
- todos = todo_service.todos
+ todos = get_todo_service().todos
# Sort todos: incomplete first, then by priority, then by creation date
def sort_key(todo):
@@ -419,7 +406,7 @@ def sort_key(todo):
def _update_stats(self):
"""Update the statistics display"""
- stats = todo_service.get_stats()
+ stats = get_todo_service().get_stats()
stats_text = f"{stats['pending']} pending, {stats['completed']} completed"
self.stats_label.set_label(stats_text)
@@ -440,7 +427,7 @@ def _init_mousecapture(self, mousecapture):
def destroy(self):
"""Clean up when destroyed"""
# Remove callback from todo service
- todo_service.remove_callback(self._on_todo_event)
+ get_todo_service().remove_callback(self._on_todo_event)
super().destroy()
diff --git a/start.py b/start.py
new file mode 100644
index 00000000..ab3308b6
--- /dev/null
+++ b/start.py
@@ -0,0 +1,27 @@
+import os
+import sys
+
+# Ensure the 'src' directory is in the python path
+# This allows 'import main' and other src-relative imports to work from the root
+src_path = os.path.join(os.path.dirname(__file__), "src")
+if src_path not in sys.path:
+ sys.path.insert(0, src_path)
+
+
+def run_app():
+ from main import main as app_main
+
+ app_main()
+
+
+def run_lock():
+ from lock import main as lock_main
+
+ lock_main()
+
+
+if __name__ == "__main__":
+ if len(sys.argv) > 1 and sys.argv[1] == "lock":
+ run_lock()
+ else:
+ run_app()
diff --git a/styles/colors.css b/styles/colors.css
deleted file mode 100644
index 1a391d3d..00000000
--- a/styles/colors.css
+++ /dev/null
@@ -1,32 +0,0 @@
-:vars {
- --foreground: #e4e1e9;
- --background: #131318;
- --cursor: #e4e1e9;
- --primary: #bec2ff;
- --on-primary: #262b60;
- --secondary: #c5c4dd;
- --on-secondary: #2e2f42;
- --tertiary: #e7b9d5;
- --on-tertiary: #45263c;
- --surface: #131318;
- --surface-bright: #39393f;
- --error: #ffb4ab;
- --error-dim: #ff8678;
- --on-error: #690005;
- --error-container: #93000a;
- --outline: #91909a;
- --shadow: #000000;
- --red: #ffb2b9;
- --red-dim: #ff7f8b;
- --green: #95d5a7;
- --green-dim: #70c789;
- --yellow: #b8cf84;
- --yellow-dim: #a3c15f;
- --blue: #bec2ff;
- --blue-dim: #8b92ff;
- --magenta: #e4b7f3;
- --magenta-dim: #d48bec;
- --cyan: #82d3e2;
- --cyan-dim: #59c4d8;
- --white: #82d3e0;
-}
diff --git a/styles/launcher.css b/styles/launcher.css
deleted file mode 100644
index 24382a44..00000000
--- a/styles/launcher.css
+++ /dev/null
@@ -1,230 +0,0 @@
-#launcher {
- background-color: alpha(#000, 0.3);
- padding: 0;
- border-radius: 12px;
- min-width: 640px;
- border: 1px solid rgba(255, 255, 255, 0.1);
-}
-
-/* #launcher-search { */
-/* color: #000; */
-/* } */
-#header_box {
- padding: 0px 20px 0 20px;
- color: #000;
-}
-
-#close-button,
-#config-button {
- background-color: transparent;
- border-radius: 6px;
- padding: 6px;
- transition: all 0.15s ease;
-}
-
-#close-button:hover,
-#close-button:focus,
-#config-button:hover,
-#config-button:focus {
- background-color: rgba(255, 255, 255, 0.1);
- border-radius: 6px;
-}
-
-#close-button.focused,
-#config-button.focused {
- background-color: rgba(0, 122, 255, 0.2);
- border-radius: 6px;
- box-shadow: 0 0 0 2px rgba(0, 122, 255, 0.4);
-}
-
-#close-button.focused #close-label {
- color: #007aff;
-}
-
-#config-button.focused #config-label {
- color: #007aff;
-}
-
-#close-button:active {
- background-color: rgba(255, 255, 255, 0.2);
- border-radius: 6px;
-}
-
-#close-label {
- color: rgba(255, 255, 255, 0.7);
- font-size: 18px;
-}
-
-#close-button:active #close-label {
- color: rgba(255, 255, 255, 0.9);
-}
-
-#config-button:active {
- background-color: rgba(255, 255, 255, 0.2);
- border-radius: 6px;
-}
-
-#config-label {
- color: rgba(255, 255, 255, 0.7);
- font-size: 18px;
-}
-
-#config-button:active #config-label {
- color: rgba(255, 255, 255, 0.9);
-}
-
-#launcher-icon-label {
- font-size: 20px;
- padding: 6px;
- color: rgba(255, 255, 255, 0.8);
-}
-
-#launcher-search {
- font-weight: 400;
- font-size: 36px;
- background-color: transparent;
- color: rgba(255, 255, 255, 0.95);
- border: none;
- border-radius: 0;
- padding: 12px 0;
- margin: 0;
-}
-
-#launcher-search:focus {
- background-color: transparent;
- box-shadow: none;
- border: none;
-}
-
-#launcher-search selection {
- color: white;
- background-color: rgba(0, 122, 255, 0.8);
-}
-
-#launcher-results-scroll {
- margin: 0;
- border-radius: 0;
- background: transparent;
- padding: 0 20px 20px 20px;
-}
-
-#launcher-results-scroll scrollbar {
- border-radius: 0;
- background-color: transparent;
- padding: 0;
- margin: 0;
- min-width: 0;
-}
-
-#launcher-results-scroll scrollbar slider {
- border-radius: 2px;
- min-width: 4px;
- min-height: 20px;
- background-color: rgba(255, 255, 255, 0.3);
- margin: 0;
-}
-
-#launcher-results-scroll scrollbar:hover slider {
- background-color: rgba(255, 255, 255, 0.5);
-}
-
-#launcher-results {
- background: transparent;
- margin-top: 8px;
-}
-
-#launcher-result-item {
- border-radius: 8px;
- padding: 12px 16px;
- margin: 2px 0;
- min-height: 64px;
- transition: all 0.15s ease;
- background: transparent;
-}
-
-@keyframes loadSlot {
- 0% {
- opacity: 0;
- }
-
- 100% {
- opacity: 1;
- }
-}
-
-#launcher-result-item:focus,
-#launcher-result-item:selected,
-#launcher-result-item:hover,
-#launcher-result-item.selected {
- border-radius: 8px;
- background-color: rgba(0, 122, 255, 0.8);
- padding: 12px 16px;
- margin: 2px 0;
-}
-
-#launcher-result-item.selected #result-item-title {
- color: white;
- font-weight: 600;
-}
-
-#launcher-result-item.selected #result-item-subtitle {
- color: rgba(255, 255, 255, 0.8);
-}
-
-#launcher-result-item.selected #result-item-plugin {
- color: rgba(255, 255, 255, 0.6);
-}
-
-#result-item-main {
- min-height: 56px;
-}
-
-#launcher-result-item {
- min-height: 56px;
-}
-
-#result-item-icon {
- min-width: 56px;
- min-height: 56px;
- margin-right: 16px;
- border-radius: 12px;
-}
-
-#result-item-title {
- font-size: 18px;
- font-weight: 500;
- color: rgba(255, 255, 255, 0.95);
- margin-top: 4px;
- margin-bottom: 2px;
-}
-
-#result-item-subtitle {
- font-size: 14px;
- color: rgba(255, 255, 255, 0.6);
- margin-bottom: 4px;
-}
-
-#result-item-plugin {
- font-size: 12px;
- color: rgba(255, 255, 255, 0.4);
- font-style: normal;
- margin-bottom: 4px;
- opacity: 1;
- font-weight: 400;
-}
-
-#network-password-entry {
- border: 1px solid rgba(255, 255, 255, 0.2);
- background: rgba(255, 255, 255, 0.05);
- color: rgba(255, 255, 255, 0.9);
- padding: 12px;
- border-radius: 8px;
- margin-bottom: 8px;
- font-size: 14px;
-}
-
-#network-password-entry:focus {
- border: 1px solid rgba(0, 122, 255, 0.6);
- background: rgba(255, 255, 255, 0.1);
- box-shadow: 0 0 0 3px rgba(0, 122, 255, 0.2);
-}
diff --git a/styles/osd.css b/styles/osd.css
deleted file mode 100644
index c4c2082e..00000000
--- a/styles/osd.css
+++ /dev/null
@@ -1,38 +0,0 @@
-#osd {
- background-color: alpha(#fff, 0.09);
- padding: 12px 20px;
- margin: 70px;
- min-height: 200px;
- border-radius: 16px;
-}
-
-#osd scale {
- min-width: 180px;
-}
-
-#osd trough {
- background: var(--surface);
- min-height: 15px;
- margin-right: 4px;
- transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275);
- border-radius: 16px;
-}
-
-#osd trough highlight {
- border-radius: 100px;
- background: var(--primary);
- transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275);
-}
-
-#osd.muted trough highlight,
-#osd.muted slider,
-#osd scale.muted trough highlight,
-#osd scale.muted slider {
- background-color: var(--surface-bright);
-}
-
-#brighntess-icons.muted,
-#vol-icon.muted,
-#mic-icon.muted {
- color: var(--outline);
-}
diff --git a/styles/panel.css b/styles/panel.css
deleted file mode 100644
index 218c3585..00000000
--- a/styles/panel.css
+++ /dev/null
@@ -1,147 +0,0 @@
-#panel {
- background-color: alpha(#fff, 0.07);
-
- /* border-bottom: 1px solid alpha(#010101, 0.025); */
- margin-top: -4px;
- margin-bottom: -4px;
- transition: all 120ms ease-in-out;
-}
-
-#panel-icon {
- font-size: 18px;
-}
-#global-title-button {
- font-weight: bold;
-}
-
-#panel-button {
- margin: 0 4px 0 4px;
- min-width: 20px;
- min-height: 20px;
-}
-
-#panel-button:hover {
- background-color: alpha(#fff, 0.1);
-}
-
-#menubar {
- margin: 4px 0;
-}
-
-#menubar label {
- font-size: 13px;
- /* font-weight: 400; */
-}
-#battery-label {
- font-weight: 500;
- margin-right: 1px;
-}
-#battery-button {
- padding-right: 3px;
- padding-left: 3px;
- border-radius: 8px;
-}
-
-#battery-button:hover {
- background-color: alpha(#fff, 0.1);
-}
-
-#network-button {
- padding-right: 3px;
- padding-left: 3px;
- border-radius: 8px;
-}
-
-#network-button:hover {
- background-color: alpha(#fff, 0.1);
-}
-
-#bt-button {
- padding-right: 3px;
- padding-left: 3px;
- border-radius: 8px;
-}
-
-#bt-button:hover {
- background-color: alpha(#fff, 0.1);
-}
-
-#tray-button {
- border-radius: 8px;
- margin: 0 4px 0 0px;
- min-width: 20px;
- min-height: 20px;
-}
-
-#tray-button:hover {
- background-color: alpha(#fff, 0.1);
-}
-
-#date-time {
- margin: 0 10px;
- font-weight: 500;
-}
-
-#modules-left,
-#modules-right {
- margin: 0 5px;
- padding: 3px 0;
-}
-
-.button {
- border-radius: 5px;
- padding: 0 5px;
- margin: 0 2.5px;
- background: radial-gradient(alpha(#aaa, 0) 0%, transparent, transparent);
- transition: all 100ms ease-in-out;
-}
-
-.button:hover {
- background: radial-gradient(alpha(#aaa, 0.2) 100%, transparent, transparent);
-}
-
-#modus-button label {
- font-size: 17px;
- margin: 0 10px;
-}
-
-#workspace-indicator {
- margin: 0 4px;
-}
-
-#workspaces label {
- font-family: "SF Pro Rounded";
- color: white; /* fully transparent text */
-}
-#workspaces > button {
- padding-top: 0px;
- padding-right: 16px;
- font-family: "SF Pro Rounded";
- padding-left: 16px;
-
- margin: 6px 0px;
- min-width: 12px;
- border: 1px solid var(--outline);
- border-radius: 8px;
-}
-
-#workspaces > button:hover {
- background-color: alpha(#fff, 0.1);
-}
-
-#workspaces > button.active {
- background-color: alpha(#fff, 0.9);
- /* font-weight: bold; */
-}
-
-#workspaces > button.active label {
- color: alpha(#000, 1);
- font-family: "SF Pro Rounded";
-}
-#workspaces > button.urgent {
- background-color: alpha(var(--on-error), 0.7);
-}
-
-#workspaces > button.empty {
- background-color: transparent;
-}
diff --git a/styles/switcher.css b/styles/switcher.css
deleted file mode 100644
index 53022819..00000000
--- a/styles/switcher.css
+++ /dev/null
@@ -1,38 +0,0 @@
-#application-switcher-container {
- background: var(--shadow);
- border-radius: 16px;
-}
-
-#application-switcher-view {
- min-height: 120px;
- padding: 4px;
-}
-
-#switcher-button {
- padding: 4px;
- min-width: 100px;
-}
-
-#window-button {
- background-color: var(--surface);
- border-radius: 4px;
-}
-
-#switcher-button:hover {
- background-color: var(--surface);
-}
-
-#window-button.active {
- background-color: var(--surface-bright);
- border: 3px solid var(--surface-bright);
-}
-
-#switcher-button label {
- color: var(--foreground);
- font-size: 10px;
- margin-top: 4px;
-}
-
-#window-row {
- padding: 18px;
-}
diff --git a/styles/todo.css b/styles/todo.css
deleted file mode 100644
index a65ddf40..00000000
--- a/styles/todo.css
+++ /dev/null
@@ -1,258 +0,0 @@
-/* Todo List Styles - matching control center design */
-
-#todo-list-window {
- /* background-color: alpha(#fff, 0.05); */
- border-radius: 12px;
- box-shadow: none;
- margin: 0;
-}
-
-#todo-main-container {
- box-shadow: inset 0 0 0 1px alpha(#aaa, 0.4);
- border-radius: 12px;
- padding: 12px; /* Increased padding */
- min-height: 500px; /* Ensure minimum height */
- min-width: 350px; /* Ensure minimum width */
-}
-
-#todo-header {
- margin-bottom: 8px;
-}
-
-#todo-title {
- font-size: 16px;
- font-family: "SF Pro Rounded";
- font-weight: bold;
- color: #ffffff;
-}
-
-#todo-stats {
- font-size: 12px;
- font-weight: 500;
- color: #999;
- margin-top: -2px;
-}
-
-/* Add new todo section */
-#todo-add-section {
- box-shadow: inset 0 0 0 0.5px alpha(#aaa, 0.4);
- border: 1px solid alpha(#111, 0.4);
- border-radius: 12px;
- padding: 1rem;
- margin-bottom: 8px;
- min-height: 40px; /* Ensure minimum height */
-}
-
-#new-todo-entry {
- background-color: rgba(255, 255, 255, 0.1);
- border: 1px solid rgba(255, 255, 255, 0.2);
- border-radius: 6px;
- padding: 8px 12px;
- color: #ffffff;
- font-size: 13px;
- font-family: "SF Pro Rounded";
-}
-
-#new-todo-entry:focus {
- border-color: #007aff;
- box-shadow: 0 0 0 2px rgba(0, 122, 255, 0.3);
- background-color: rgba(255, 255, 255, 0.15);
-}
-
-#add-todo-button {
- /* background-color: #007aff; */
- /* border: 1px solid #007aff; */
- border-radius: 6px;
- min-width: 20px;
- min-height: 12px;
- color: #ffffff;
- font-size: 16px;
-}
-
-#add-todo-button:hover {
- /* background-color: #0056cc; */
- /* border-color: #0056cc; */
-}
-
-/* Todo items container */
-#todos-scrolled {
- background-color: transparent;
- min-height: 200px; /* Minimum height for content */
-}
-
-#todos-container {
- padding: 4px;
-}
-
-/* Individual todo items */
-#todo-item {
- background-color: alpha(#000, 0.2); /* More visible background */
- border: 1px solid alpha(#111, 0.4);
- border-radius: 12px;
- padding: 12px;
- margin: 4px 0;
- min-height: 40px; /* Ensure minimum height */
- transition: background-color 0.2s ease;
-}
-
-#todo-item:hover {
- background-color: alpha(#fff, 0.12);
-}
-
-/* Todo item controls */
-#todo-checkbox {
- background-color: transparent;
- border-radius: 50%; /* Circular appearance */
- min-width: 20px;
- min-height: 20px;
- margin-right: 8px;
-}
-
-#todo-checkbox:hover {
- border-color: #007aff;
-}
-
-/* SVG icon styling for checkboxes and buttons */
-#todo-checkbox-icon {
- color: #007aff;
-}
-
-#todo-edit-icon,
-#todo-delete-icon,
-#add-todo-icon {
- color: #ffffff;
- opacity: 0.8;
-}
-
-#todo-edit-icon:hover,
-#todo-delete-icon:hover,
-#add-todo-icon:hover {
- opacity: 1;
-}
-
-#todo-text {
- font-size: 13px;
- font-weight: 400;
- color: #ffffff;
- font-family: "SF Pro Rounded";
-}
-
-#todo-date {
- font-size: 10px;
- font-weight: 400;
- color: #999999;
- font-family: "SF Pro Rounded";
- margin-top: 2px;
- opacity: 0.8;
-}
-
-.todo-date-text {
- color: #999999;
- font-size: 10px;
-}
-
-#todo-text-entry {
- background-color: rgba(255, 255, 255, 0.1);
- border: 1px solid rgba(255, 255, 255, 0.2);
- border-radius: 4px;
- padding: 4px 8px;
- color: #ffffff;
- font-size: 14px;
- font-family: "SF Pro Rounded";
-}
-
-#todo-text-entry:focus {
- border-color: #007aff;
- box-shadow: 0 0 0 1px rgba(0, 122, 255, 0.3);
- background-color: rgba(255, 255, 255, 0.15);
-}
-
-/* Priority, edit, delete buttons */
-#todo-priority,
-#todo-edit,
-#todo-delete {
- background-color: transparent;
- border: none;
- border-radius: 4px;
- min-width: 20px;
- min-height: 20px;
- margin: 0 2px;
- transition: background-color 0.2s ease;
-}
-
-#todo-priority:hover,
-#todo-edit:hover,
-#todo-delete:hover {
- background-color: alpha(#fff, 0.1);
-}
-
-#todo-priority-label,
-#todo-edit-label,
-#todo-delete-label {
- font-size: 12px;
-}
-
-/* Clear completed button */
-#clear-completed-button {
- background-color: alpha(#fff, 0.1);
- border: 1px solid alpha(#999, 0.3);
- border-radius: 8px;
- padding: 8px 16px;
- color: #999;
- font-size: 12px;
- font-weight: 500;
- font-family: "SF Pro Rounded";
- margin-top: 8px;
- transition: background-color 0.2s ease;
-}
-
-#clear-completed-button:hover {
- background-color: alpha(#fff, 0.15);
- color: #ffffff;
-}
-
-/* Completed todos styling */
-#todo-item.completed {
- opacity: 0.6;
-}
-
-#todo-item.completed #todo-text {
- text-decoration: line-through;
- color: #999;
-}
-
-/* Priority styling */
-.priority-high #todo-priority-label {
- color: #ff4444;
-}
-
-.priority-medium #todo-priority-label {
- color: #ffaa00;
-}
-
-.priority-low #todo-priority-label {
- color: #44ff44;
-}
-
-/* Scrollbar styling to match control center */
-#todos-scrolled scrollbar {
- background-color: transparent;
- border-radius: 8px;
- margin: 2px;
- min-width: 8px;
-}
-
-#todos-scrolled scrollbar slider {
- background-color: alpha(#fff, 0.3);
- border-radius: 4px;
- min-width: 6px;
- margin: 1px;
-}
-
-#todos-scrolled scrollbar slider:hover {
- background-color: alpha(#fff, 0.5);
-}
-
-#todos-scrolled scrollbar slider:active {
- background-color: alpha(#fff, 0.7);
-}
diff --git a/utils/__init__.py b/utils/__init__.py
deleted file mode 100644
index 3f03f120..00000000
--- a/utils/__init__.py
+++ /dev/null
@@ -1,4 +0,0 @@
-"""
-Modus services package.
-Contains background services and utilities for the shell.
-"""
diff --git a/utils/animator.py b/utils/animator.py
deleted file mode 100644
index 7dd063d4..00000000
--- a/utils/animator.py
+++ /dev/null
@@ -1,180 +0,0 @@
-from typing import cast
-
-from gi.repository import GLib, Gtk
-
-from fabric import Property, Service, Signal
-
-
-class Animator(Service):
- @Signal
- def finished(self) -> None: ...
-
- @Property(tuple[float, float, float, float], "read-write")
- def bezier_curve(self) -> tuple[float, float, float, float]:
- return self._bezier_curve
-
- @bezier_curve.setter
- def bezier_curve(self, value: tuple[float, float, float, float]):
- self._bezier_curve = value
- return
-
- @Property(float, "read-write")
- def value(self):
- return self._value
-
- @value.setter
- def value(self, value: float):
- self._value = value
- return
-
- @Property(float, "read-write")
- def max_value(self):
- return self._max_value
-
- @max_value.setter
- def max_value(self, value: float):
- self._max_value = value
- return
-
- @Property(float, "read-write")
- def min_value(self):
- return self._min_value
-
- @min_value.setter
- def min_value(self, value: float):
- self._min_value = value
- return
-
- @Property(bool, "read-write", default_value=False)
- def playing(self):
- return self._playing
-
- @playing.setter
- def playing(self, value: bool):
- self._playing = value
- return
-
- @Property(bool, "read-write", default_value=False)
- def repeat(self):
- return self._repeat
-
- @repeat.setter
- def repeat(self, value: bool):
- self._repeat = value
- return
-
- def __init__(
- self,
- bezier_curve: tuple[float, float, float, float],
- duration: float,
- min_value: float = 0.0,
- max_value: float = 1.0,
- repeat: bool = False,
- tick_widget: Gtk.Widget | None = None,
- **kwargs,
- ):
- super().__init__(**kwargs)
- self._bezier_curve = (1, 0, 1, 1)
- self._duration = 5
- self._value = 0.0
- self._min_value = 0.0
- self._max_value = 1.0
- self._repeat = False
-
- self.bezier_curve = bezier_curve
- self.duration = duration
- self.value = min_value
- self.min_value = min_value
- self.max_value = max_value
- self.repeat = repeat
-
- self.playing = False
- self._start_time = None
- self._tick_handler = None
- self._timeline_pos = 0
- self._tick_widget = tick_widget
-
- def do_get_time_now(self):
- return GLib.get_monotonic_time() / 1_000_000
-
- def do_lerp(self, start: float, end: float, time: float) -> float:
- return start + (end - start) * time
-
- def do_interpolate_cubic_bezier(self, time: float) -> float:
- y_points = (0, self.bezier_curve[1], self.bezier_curve[3], 1)
- return (
- (1 - time) ** 3 * y_points[0]
- + 3 * (1 - time) ** 2 * time * y_points[1]
- + 3 * (1 - time) * time**2 * y_points[2]
- + time**3 * y_points[3]
- )
-
- def do_ease(self, time: float) -> float:
- return self.do_lerp(
- self.min_value, self.max_value, self.do_interpolate_cubic_bezier(time)
- )
-
- def do_update_value(self, delta_time: float):
- if not self.playing:
- return
-
- elapsed_time = delta_time - cast(float, self._start_time)
-
- self._timeline_pos = min(1, elapsed_time / self.duration)
-
- self.value = self.do_ease(self._timeline_pos)
-
- if not self._timeline_pos >= 1:
- return
-
- if not self.repeat:
- self.value = self.max_value
- self.finished()
- self.pause()
- return
-
- self._start_time = delta_time
- self._timeline_pos = 0
- return
-
- def do_handle_tick(self, *_):
- current_time = self.do_get_time_now()
- self.do_update_value(current_time)
- return True
-
- def do_remove_tick_handlers(self):
- if self._tick_handler:
- if self._tick_widget:
- self._tick_widget.remove_tick_callback(self._tick_handler)
- else:
- GLib.source_remove(self._tick_handler)
- self._tick_handler = None
- return
-
- def play(self):
- if self.playing:
- return
-
- self._start_time = self.do_get_time_now()
-
- if not self._tick_handler:
- if self._tick_widget:
- self._tick_handler = self._tick_widget.add_tick_callback(
- self.do_handle_tick
- )
- else:
- self._tick_handler = GLib.timeout_add(16, self.do_handle_tick)
-
- self.playing = True
- return
-
- def pause(self):
- self.playing = False
- return self.do_remove_tick_handlers()
-
- def stop(self):
- if not self._tick_handler:
- self._timeline_pos = 0
- self.playing = False
- return
- return self.do_remove_tick_handlers()
diff --git a/utils/functions.py b/utils/functions.py
deleted file mode 100644
index 7226ca91..00000000
--- a/utils/functions.py
+++ /dev/null
@@ -1,154 +0,0 @@
-import html
-import json
-import os
-import threading
-from typing import Dict, List, Optional
-
-from loguru import logger
-
-# Threading helper functions
-
-
-def thread(target, *args, **kwargs) -> threading.Thread:
- """
- Simply run the given function in a thread.
- The provided args and kwargs will be passed to the function.
- """
- th = threading.Thread(target=target, args=args, kwargs=kwargs, daemon=True)
- th.start()
- return th
-
-
-def run_in_thread(func):
- """
- Decorator to run the decorated function in a thread.
- """
-
- def wrapper(*args, **kwargs):
- return thread(func, *args, **kwargs)
-
- return wrapper
-
-
-@run_in_thread
-def write_json_file(data: Dict, path: str):
- try:
- with open(path, "w") as f:
- json.dump(data, f, indent=4, ensure_ascii=False)
- except Exception as e:
- logger.warning(f"Failed to write json: {e}")
-
-
-def read_json_file(file_path: str) -> Optional[List]:
- if not os.path.exists(file_path):
- logger.error(f"JSON file {file_path} does not exist.")
- return None
-
- with open(file_path, "r") as file:
- try:
- return json.load(file)
- except json.JSONDecodeError as e:
- logger.error(f"Failed to read JSON file {file_path}: {e}")
- return None
-
-
-def get_wifi_icon_for_strength(strength: int) -> str:
- """
- Get the appropriate WiFi icon based on signal strength.
-
- Args:
- strength: Signal strength from 0-100
-
- Returns:
- Absolute path to the appropriate WiFi icon
- """
- # Get the current directory where this script is located
- current_dir = os.path.dirname(os.path.abspath(__file__))
- # Get the project root (parent of utils directory)
- project_root = os.path.dirname(current_dir)
-
- if strength >= 80:
- icon_name = "network-wireless-100.svg"
- elif strength >= 60:
- icon_name = "network-wireless-80.svg"
- elif strength >= 40:
- icon_name = "network-wireless-60.svg"
- elif strength >= 20:
- icon_name = "network-wireless-40.svg"
- elif strength > 0:
- icon_name = "network-wireless-20.svg"
- else:
- icon_name = "network-wireless-0.svg"
-
- return os.path.join(project_root, "config", "assets", "icons", "wifi", icon_name)
-
-
-def get_wifi_connecting_icon() -> str:
- """
- Get the WiFi connecting icon path.
-
- Returns:
- Absolute path to the WiFi connecting icon
- """
- # Get the current directory where this script is located
- current_dir = os.path.dirname(os.path.abspath(__file__))
- # Get the project root (parent of utils directory)
- project_root = os.path.dirname(current_dir)
-
- return os.path.join(
- project_root, "config", "assets", "icons", "wifi", "wifi-connecting.svg"
- )
-
-
-# Function to check if a workspace ID is special
-def is_special_workspace_id(ws_id) -> bool:
- try:
- # Convert to int if it's a string
- workspace_id = int(ws_id)
- # Special workspaces have negative IDs
- return workspace_id < 0
- except (ValueError, TypeError):
- # If it's a string, check if it starts with "special:"
- if isinstance(ws_id, str) and ws_id.startswith("special:"):
- return True
- return False
-
-
-# Function to check if a client is on a special workspace
-def is_special_workspace(client: dict) -> bool:
- if "workspace" not in client:
- return False
-
- workspace = client["workspace"]
-
- # Check workspace name first
- if "name" in workspace:
- workspace_name = workspace["name"]
- if is_special_workspace_id(workspace_name):
- return True
-
- # Check workspace ID
- if "id" in workspace:
- workspace_id = workspace["id"]
- if is_special_workspace_id(workspace_id):
- return True
-
- return False
-
-
-def escape_markup_text(text: str) -> str:
- """
- Escape special characters in text to make it safe for Pango markup.
-
- Args:
- text: Raw text that may contain special characters
-
- Returns:
- Escaped text safe for use in Pango markup
- """
- if not text or not isinstance(text, str):
- return ""
-
- # Use html.escape to escape XML/HTML special characters
- # This handles &, <, >, and quotes
- return html.escape(text)
diff --git a/utils/icon_resolver.py b/utils/icon_resolver.py
deleted file mode 100644
index 58ac1e40..00000000
--- a/utils/icon_resolver.py
+++ /dev/null
@@ -1,106 +0,0 @@
-import json
-import os
-import re
-
-import gi
-
-gi.require_version("Gtk", "3.0")
-from gi.repository import GLib, Gtk
-from loguru import logger
-
-import config.data as data
-
-ICON_CACHE_FILE = data.CACHE_DIR + "/icons.json"
-if not os.path.exists(data.CACHE_DIR):
- os.makedirs(data.CACHE_DIR)
-
-
-class IconResolver:
- def __init__(
- self, default_applicaiton_icon: str = "application-x-executable-symbolic"
- ):
- if os.path.exists(ICON_CACHE_FILE):
- with open(ICON_CACHE_FILE) as f:
- try:
- self._icon_dict = json.load(f)
- except json.JSONDecodeError:
- logger.info("[ICONS] Cache file does not exist or is corrupted")
- self._icon_dict = {}
- else:
- self._icon_dict = {}
-
- self.default_applicaiton_icon = default_applicaiton_icon
-
- def get_icon_name(self, app_id: str):
- if app_id in self._icon_dict:
- return self._icon_dict[app_id]
- new_icon = self._compositor_find_icon(app_id)
- logger.info(
- f"[ICONS] found new icon: '{new_icon}' for app id: '{app_id}', storing..."
- )
- self._store_new_icon(app_id, new_icon)
- return new_icon
-
- def get_icon_pixbuf(self, app_id: str, size: int = 16):
- icon_theme = Gtk.IconTheme.get_default()
- icon_name = self.get_icon_name(app_id)
- try:
- # Try to load the resolved icon.
- return icon_theme.load_icon(icon_name, size, Gtk.IconLookupFlags.FORCE_SIZE)
- except GLib.Error as primary_error:
- logger.warning(
- f"Warning: Icon '{icon_name}' not found in theme. Error: {primary_error}"
- )
- try:
- # Fallback to the default application icon.
- return icon_theme.load_icon(
- self.default_applicaiton_icon, size, Gtk.IconLookupFlags.FORCE_SIZE
- )
- except GLib.Error as fallback_error:
- logger.error(
- f"Error: Fallback icon '{self.default_applicaiton_icon}' also not found. Error: {fallback_error}"
- )
- return None
-
- def _store_new_icon(self, app_id: str, icon: str):
- self._icon_dict[app_id] = icon
- with open(ICON_CACHE_FILE, "w") as f:
- json.dump(self._icon_dict, f)
-
- def _get_icon_from_desktop_file(self, desktop_file_path: str):
- # Retrieve the icon specified in the [Desktop Entry] section.
- with open(desktop_file_path) as f:
- for line in f.readlines():
- if "Icon=" in line:
- return "".join(line[5:].split())
- return self.default_applicaiton_icon
-
- def _get_desktop_file(self, app_id: str) -> str | None:
- data_dirs = GLib.get_system_data_dirs()
- for data_dir in data_dirs:
- data_dir = os.path.join(data_dir, "applications")
- if os.path.exists(data_dir):
- files = os.listdir(data_dir)
- matching = [
- s for s in files if "".join(app_id.lower().split()) in s.lower()
- ]
- if matching:
- return os.path.join(data_dir, matching[0])
- for word in list(filter(None, re.split(r"-|\.|_|\s", app_id))):
- matching = [s for s in files if word.lower() in s.lower()]
- if matching:
- return os.path.join(data_dir, matching[0])
- return None
-
- def _compositor_find_icon(self, app_id: str):
- icon_theme = Gtk.IconTheme.get_default()
- if icon_theme.has_icon(app_id):
- return app_id
- if icon_theme.has_icon(app_id + "-desktop"):
- return app_id + "-desktop"
- desktop_file = self._get_desktop_file(app_id)
- return (
- self._get_icon_from_desktop_file(desktop_file)
- if desktop_file
- else self.default_applicaiton_icon
- )
diff --git a/uv.lock b/uv.lock
new file mode 100644
index 00000000..2fb5a1b3
--- /dev/null
+++ b/uv.lock
@@ -0,0 +1,361 @@
+version = 1
+revision = 3
+requires-python = ">=3.13"
+
+[[package]]
+name = "anyio"
+version = "4.13.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "idna" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
+]
+
+[[package]]
+name = "certifi"
+version = "2026.4.22"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" },
+]
+
+[[package]]
+name = "cffi"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pycparser", marker = "implementation_name != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
+ { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
+ { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
+ { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
+ { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
+ { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
+ { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
+ { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
+ { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
+ { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
+]
+
+[[package]]
+name = "click"
+version = "8.3.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" },
+]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
+]
+
+[[package]]
+name = "fabric"
+version = "0.0.2"
+source = { git = "https://github.com/Fabric-Development/fabric.git#23cab85fc61f1cdbb5f60db0027dc57026ba05b7" }
+dependencies = [
+ { name = "click" },
+ { name = "loguru" },
+ { name = "pycairo" },
+ { name = "pygobject" },
+]
+
+[[package]]
+name = "h11"
+version = "0.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
+]
+
+[[package]]
+name = "httpcore"
+version = "1.0.9"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
+]
+
+[[package]]
+name = "httpx"
+version = "0.28.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "certifi" },
+ { name = "httpcore" },
+ { name = "idna" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
+]
+
+[[package]]
+name = "idna"
+version = "3.13"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" },
+]
+
+[[package]]
+name = "loguru"
+version = "0.7.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "win32-setctime", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" },
+]
+
+[[package]]
+name = "modus"
+version = "0.2.0"
+source = { editable = "." }
+dependencies = [
+ { name = "fabric" },
+ { name = "httpx" },
+ { name = "pam" },
+ { name = "psutil" },
+ { name = "pywayland" },
+ { name = "six" },
+]
+
+[package.dev-dependencies]
+dev = [
+ { name = "pyinstrument" },
+ { name = "ruff" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "fabric", git = "https://github.com/Fabric-Development/fabric.git" },
+ { name = "httpx", specifier = ">=0.28.1" },
+ { name = "pam", specifier = ">=0.2.0" },
+ { name = "psutil", specifier = ">=7.2.2" },
+ { name = "pywayland", specifier = ">=0.4.18" },
+ { name = "six", specifier = ">=1.17.0" },
+]
+
+[package.metadata.requires-dev]
+dev = [
+ { name = "pyinstrument", specifier = ">=5.1.2" },
+ { name = "ruff", specifier = ">=0.15.2" },
+]
+
+[[package]]
+name = "pam"
+version = "0.2.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "python-pam" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/65/25/74fcd42f6a76c7d06ca161c11a2455ddd4f68a76545241f7d3ea3354c6d6/pam-0.2.0-py3-none-any.whl", hash = "sha256:fcec42cee82e164b77fdf70c52117c668645c17d9756b3af66058816a5102401", size = 976, upload-time = "2020-11-11T23:11:01.769Z" },
+]
+
+[[package]]
+name = "psutil"
+version = "7.2.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" },
+ { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" },
+ { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" },
+ { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" },
+ { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" },
+ { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" },
+ { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" },
+ { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" },
+]
+
+[[package]]
+name = "pycairo"
+version = "1.29.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/22/d9/1728840a22a4ef8a8f479b9156aa2943cd98c3907accd3849fb0d5f82bfd/pycairo-1.29.0.tar.gz", hash = "sha256:f3f7fde97325cae80224c09f12564ef58d0d0f655da0e3b040f5807bd5bd3142", size = 665871, upload-time = "2025-11-11T19:13:01.584Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f0/21/3f477dc318dd4e84a5ae6301e67284199d7e5a2384f3063714041086b65d/pycairo-1.29.0-cp313-cp313-win32.whl", hash = "sha256:3eb382a4141591807073274522f7aecab9e8fa2f14feafd11ac03a13a58141d7", size = 750949, upload-time = "2025-11-11T19:12:12.198Z" },
+ { url = "https://files.pythonhosted.org/packages/43/34/7d27a333c558d6ac16dbc12a35061d389735e99e494ee4effa4ec6d99bed/pycairo-1.29.0-cp313-cp313-win_amd64.whl", hash = "sha256:91114e4b3fbf4287c2b0788f83e1f566ce031bda49cf1c3c3c19c3e986e95c38", size = 844149, upload-time = "2025-11-11T19:12:19.171Z" },
+ { url = "https://files.pythonhosted.org/packages/15/43/e782131e23df69e5c8e631a016ed84f94bbc4981bf6411079f57af730a23/pycairo-1.29.0-cp313-cp313-win_arm64.whl", hash = "sha256:09b7f69a5ff6881e151354ea092137b97b0b1f0b2ab4eb81c92a02cc4a08e335", size = 693595, upload-time = "2025-11-11T19:12:23.445Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/fa/87eaeeb9d53344c769839d7b2854db7ff2cd596211e00dd1b702eeb1838f/pycairo-1.29.0-cp314-cp314-win32.whl", hash = "sha256:69e2a7968a3fbb839736257bae153f547bca787113cc8d21e9e08ca4526e0b6b", size = 767198, upload-time = "2025-11-11T19:12:42.336Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/90/3564d0f64d0a00926ab863dc3c4a129b1065133128e96900772e1c4421f8/pycairo-1.29.0-cp314-cp314-win_amd64.whl", hash = "sha256:e91243437a21cc4c67c401eff4433eadc45745275fa3ade1a0d877e50ffb90da", size = 871579, upload-time = "2025-11-11T19:12:48.982Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/91/93632b6ba12ad69c61991e3208bde88486fdfc152be8cfdd13444e9bc650/pycairo-1.29.0-cp314-cp314-win_arm64.whl", hash = "sha256:b72200ea0e5f73ae4c788cd2028a750062221385eb0e6d8f1ecc714d0b4fdf82", size = 719537, upload-time = "2025-11-11T19:12:55.016Z" },
+ { url = "https://files.pythonhosted.org/packages/93/23/37053c039f8d3b9b5017af9bc64d27b680c48a898d48b72e6d6583cf0155/pycairo-1.29.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5e45fce6185f553e79e4ef1722b8e98e6cde9900dbc48cb2637a9ccba86f627a", size = 874015, upload-time = "2025-11-11T19:12:28.47Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/54/123f6239685f5f3f2edc123f1e38d2eefacebee18cf3c532d2f4bd51d0ef/pycairo-1.29.0-cp314-cp314t-win_arm64.whl", hash = "sha256:caba0837a4b40d47c8dfb0f24cccc12c7831e3dd450837f2a356c75f21ce5a15", size = 721404, upload-time = "2025-11-11T19:12:36.919Z" },
+]
+
+[[package]]
+name = "pycparser"
+version = "3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
+]
+
+[[package]]
+name = "pygobject"
+version = "3.50.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pycairo" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/2b/58/d34e67a79631177e3c08e7d02b5165147f590171f2cae6769502af5f7f7e/pygobject-3.50.0.tar.gz", hash = "sha256:4500ad3dbf331773d8dedf7212544c999a76fc96b63a91b3dcac1e5925a1d103", size = 1080367, upload-time = "2024-09-12T12:05:22.579Z" }
+
+[[package]]
+name = "pyinstrument"
+version = "5.1.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/32/7f/d3c4ef7c43f3294bd5a475dfa6f295a9fee5243c292d5c8122044fa83bcb/pyinstrument-5.1.2.tar.gz", hash = "sha256:af149d672da9493fa37334a1cc68f7b80c3e6cb9fd99b9e426c447db5c650bf0", size = 266889, upload-time = "2026-01-04T18:38:58.464Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b4/8e/b9aea969eec67c129652000446384d550a0df45c297adc9fd74da2f8482c/pyinstrument-5.1.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7b8bab2334bf1d4c9e92d61db574300b914b594588a6b6dd67c45450152dfc29", size = 131418, upload-time = "2026-01-04T18:37:58.642Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/62/76418eb29b5591f3e5500369a6777ce928135c3aa6ccdb0c861a9c6ca93b/pyinstrument-5.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:13dcc138a61298ef4994b7aebff509d2c06db89dfd6e2021f0b9cd96aaa44ec3", size = 124448, upload-time = "2026-01-04T18:37:59.95Z" },
+ { url = "https://files.pythonhosted.org/packages/07/73/874bccc04bcf6f4babc3de1a9568e209e7e40998563974f5030b0fb4d3e0/pyinstrument-5.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8abd4a7ffa2e7f9e00039a5e549e8eebc80d7ca8d43f0fb51a50ff2b117ce4a", size = 149853, upload-time = "2026-01-04T18:38:01.405Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/85/268446c4388d77ff4abdeaff202356e1527b3ff9576f5587443a24980bec/pyinstrument-5.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eb3a05108edebc30f31e2c69c904576042f1158b2513ab80adc08f7848a7a8f0", size = 148641, upload-time = "2026-01-04T18:38:03.086Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/15/4f8dea3381483e68d00582a9b823a21a088acfe77a847a7991a1a8feed76/pyinstrument-5.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f70d588b53f3f35829d1d1ddfa05e07fcebf1434b3b1509d542ca317d8e9a2a5", size = 148674, upload-time = "2026-01-04T18:38:04.805Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/61/72c180454b6511d5b90166f8828e1bab3b45d0489952a1fe48c5c585233d/pyinstrument-5.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b007327e0d6a6a01d5064883dd27c19996f044ce7488d507826fee7884e6a32e", size = 148315, upload-time = "2026-01-04T18:38:06.114Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/f0/4c27cebddf22a8840bd8b419366bb321ce41f921ca1893e309c932ab28bf/pyinstrument-5.1.2-cp313-cp313-win32.whl", hash = "sha256:9ba0e6b17a7e86c3dc02d208e4c25506e8f914d9964ae89449f1f37f0b70abc0", size = 125926, upload-time = "2026-01-04T18:38:07.507Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/20/6b1bee88ddef065b0df3a3ba4ba60ed8a9ca443d5cded7152a8a9750914f/pyinstrument-5.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:660d7fc486a839814db0b2f716bc13d8b99b9c780aaeb47f74a70a34adc02a7b", size = 126678, upload-time = "2026-01-04T18:38:08.826Z" },
+ { url = "https://files.pythonhosted.org/packages/66/0f/7d5154c92904bdf25be067a7fe4cad4ba48919f16ccbb51bb953d9ae1a20/pyinstrument-5.1.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0baed297beee2bb9897e737bbd89e3b9d45a2fbbea9f1ad4e809007d780a9b1e", size = 131388, upload-time = "2026-01-04T18:38:10.491Z" },
+ { url = "https://files.pythonhosted.org/packages/17/28/bf83231a3f951e11b4dfaf160e1eeba1ce29377eab30e3d2eb6ee22ff3ba/pyinstrument-5.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ebb910a32a45bde6c3fc30c578efc28a54517990e11e94b5e48a0d5479728568", size = 124456, upload-time = "2026-01-04T18:38:11.792Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/98/762cf10896d907268629e1db08a48f128984a53e8d92b99ea96f862597e5/pyinstrument-5.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bad403c157f9c6dba7f731a6fca5bfcd8ca2701a39bcc717dcc6e0b10055ffc4", size = 149594, upload-time = "2026-01-04T18:38:13.434Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/1b/48580e16e623d89af58b89c552c95a2ae65f70a1f4fab1d97879f34791db/pyinstrument-5.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f456cabdb95fd343c798a7f2a56688b028f981522e283c5f59bd59195b66df5", size = 148339, upload-time = "2026-01-04T18:38:14.767Z" },
+ { url = "https://files.pythonhosted.org/packages/62/7e/38157a8a6ec67789d8ee109fd09877ea3340df44e1a7add8f249e30a8ade/pyinstrument-5.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4e9c4dcc1f2c4a0cd6b576e3604abc37496a7868243c9a1443ad3b9db69d590f", size = 148485, upload-time = "2026-01-04T18:38:16.121Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/34/31ee72b19cfc48a82801024b5d653f07982154a11381a3ae65bbfdbf2c7b/pyinstrument-5.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:acf93b128328c6d80fdb85431068ac17508f0f7845e89505b0ea6130dead5ca6", size = 148106, upload-time = "2026-01-04T18:38:17.623Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/b4/7ab20243187262d66ab062778b1ccac4ca55090752f32a83f603f4e5e3a2/pyinstrument-5.1.2-cp314-cp314-win32.whl", hash = "sha256:9c7f0167903ecff8b1d744f7e37b2bd4918e05a69cca724cb112f5ed59d1e41b", size = 126593, upload-time = "2026-01-04T18:38:18.968Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/a0/db6a8ae3182546227f5a043b1be29b8d5f98bf973e20d922981ef206de85/pyinstrument-5.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:ce3f6b1f9a2b5d74819ecc07d631eadececf915f551474a75ad65ac580ec5a0e", size = 127358, upload-time = "2026-01-04T18:38:20.28Z" },
+ { url = "https://files.pythonhosted.org/packages/59/d2/719f439972b3f80e35fb5b1bcd888c3218d60dbc91957b99ffafd7ac9221/pyinstrument-5.1.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:af8651b239049accbeecd389d35823233f649446f76f47fd005316b05d08cef2", size = 132317, upload-time = "2026-01-04T18:38:21.669Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/1c/0ebfef69ae926665fae635424c5647411235c3689c9a9ad69fd68de6cae2/pyinstrument-5.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c6082f1c3e43e1d22834e91ba8975f0080186df4018a04b4dd29f9623c59df1d", size = 124917, upload-time = "2026-01-04T18:38:23.385Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/ee/5599f769f515a0f1c97443edc7394fe2b9829bf39f404c046499c1a62378/pyinstrument-5.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c031eb066ddc16425e1e2f56aad5c1ce1e27b2432a70329e5385b85e812decee", size = 157407, upload-time = "2026-01-04T18:38:24.774Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/40/32aa865252288caef301237488ee309bd6701125888bf453d23ab764e357/pyinstrument-5.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f447ec391cad30667ba412dce41607aaa20d4a2496a7ab867e0c199f0fe3ae3d", size = 155068, upload-time = "2026-01-04T18:38:26.112Z" },
+ { url = "https://files.pythonhosted.org/packages/91/68/0b56a1540fe1c357dfcda82d4f5b52c87fada5962cbf18703ea39ccbbe69/pyinstrument-5.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:50299bddfc1fe0039898f895b10ef12f9db08acffb4d85326fad589cda24d2ee", size = 155186, upload-time = "2026-01-04T18:38:27.914Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/48/7ef84abfc3e41148cf993095214f104e75ecff585e94c6e8be001e672573/pyinstrument-5.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a193ff08825ece115ececa136832acb14c491c77ab1e6b6a361905df8753d5c6", size = 153979, upload-time = "2026-01-04T18:38:29.236Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/cf/a28ad117d58b33c1d74bcdfbbcf1603b67346883800ac7d510cff8d3bcee/pyinstrument-5.1.2-cp314-cp314t-win32.whl", hash = "sha256:de887ba19e1057bd2d86e6584f17788516a890ae6fe1b7eed9927873f416b4d8", size = 127267, upload-time = "2026-01-04T18:38:30.619Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/97/03635143a12a5d941f545548b00f8ac39d35565321a2effb4154ed267338/pyinstrument-5.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:b6a71f5e7f53c86c9b476b30cf19509463a63581ef17ddbd8680fee37ae509db", size = 128164, upload-time = "2026-01-04T18:38:32.281Z" },
+]
+
+[[package]]
+name = "python-pam"
+version = "2.0.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6a/da/879f1c849e886b783239b8a4710daac73535ba2cfcf672ee4548543e3a74/python-pam-2.0.2.tar.gz", hash = "sha256:97235235ba9b82dbae8068d1099508455949b275f77273ca22fdbd8b1fb5d950", size = 11439, upload-time = "2022-03-18T00:32:09.856Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9b/2d/9fbb3bd686a474d76fbd0b79abdcc016f3da760b1d1c2048bf4c611a4939/python_pam-2.0.2-py3-none-any.whl", hash = "sha256:4ac51dd8953ac59aa45505882b565eef6a22e0423dcf25d63369902080416c20", size = 10658, upload-time = "2022-03-18T00:32:07.802Z" },
+]
+
+[[package]]
+name = "pywayland"
+version = "0.4.18"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/20/93/bc9b258dcb46aafc587438355a9170c3be05a6abb5cd55bf1d600d848fa0/pywayland-0.4.18.tar.gz", hash = "sha256:598ade02783aad05a328f663b51b694c5ab68bd5d1e0926c0da3c5212c566533", size = 247348, upload-time = "2024-07-27T19:24:38.008Z" }
+
+[[package]]
+name = "ruff"
+version = "0.15.12"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" },
+ { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" },
+ { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" },
+ { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" },
+ { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" },
+ { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" },
+ { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" },
+ { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" },
+ { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" },
+]
+
+[[package]]
+name = "six"
+version = "1.17.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
+]
+
+[[package]]
+name = "win32-setctime"
+version = "1.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" },
+]
diff --git a/widgets/customrevealer.py b/widgets/customrevealer.py
deleted file mode 100644
index bf47e52d..00000000
--- a/widgets/customrevealer.py
+++ /dev/null
@@ -1,320 +0,0 @@
-from gi.repository import GLib, Gtk
-import gi
-import math
-
-gi.require_version("Gtk", "3.0")
-
-# TODO: UsE BETTER APPROACH IF POSSIBLE
-
-
-class AnimationManager:
- _instance = None
- _animating_widgets = set()
- _timer_id = None
- _containers_to_redraw = set()
-
- @classmethod
- def get_instance(cls):
- if cls._instance is None:
- cls._instance = cls()
- return cls._instance
-
- def add_widget(self, widget):
- self._animating_widgets.add(widget)
- if self._timer_id is None:
- # Use 120 FPS for ultra-smooth animations like macOS
- self._timer_id = GLib.timeout_add(8, self._animate_all) # 120 FPS
-
-
- def remove_widget(self, widget):
- self._animating_widgets.discard(widget)
- if not self._animating_widgets and self._timer_id:
- # Stop timer when no widgets are animating
- GLib.source_remove(self._timer_id)
- self._timer_id = None
- # Clear any pending redraws
- self._containers_to_redraw.clear()
-
- def _animate_all(self):
- # Clear previous frame's redraw queue
- self._containers_to_redraw.clear()
-
- widgets_to_remove = []
-
- # Process all animations in a single frame
- for widget in list(self._animating_widgets):
- if not widget._calculate_position():
- widgets_to_remove.append(widget)
- else:
- container = widget._get_container_for_redraw()
- if container:
- self._containers_to_redraw.add(container)
-
- # Apply all position changes at once to prevent conflicts
- for widget in self._animating_widgets:
- widget._apply_position()
-
- # Batch redraw calls to minimize performance impact
- for container in self._containers_to_redraw:
- container.queue_draw()
-
- # Remove completed animations
- for widget in widgets_to_remove:
- self.remove_widget(widget)
-
- return len(self._animating_widgets) > 0 # Continue if widgets remain
-
- def get_active_widget_count(self):
- """Return the number of currently animating widgets"""
- return len(self._animating_widgets)
-
- def _get_optimal_interval(self):
- """Keep consistent 120 FPS for macOS-like smoothness"""
- return 8 # 120 FPS
-
-
- def _start_timer(self):
- interval = self._get_optimal_interval()
- self._timer_id = GLib.timeout_add(interval, self._animate_all)
-
- def _adjust_frame_rate(self):
- # No need to adjust frame rate anymore - keep it consistent
- pass
-
-
-class MacOSEasing:
- """macOS-style easing functions for natural motion"""
-
- @staticmethod
- def ease_out_expo(t):
- """Exponential ease out - fast start, slow end"""
- return 1 - math.pow(2, -10 * t) if t != 1 else 1
-
- @staticmethod
- def ease_in_out_quart(t):
- """Quartic ease in-out for smooth acceleration/deceleration"""
- return 8 * t * t * t * t if t < 0.5 else 1 - math.pow(-2 * t + 2, 4) / 2
-
- @staticmethod
- def ease_out_back(t):
- """Back ease out for slight overshoot effect"""
- c1 = 1.70158
- c3 = c1 + 1
- return 1 + c3 * math.pow(t - 1, 3) + c1 * math.pow(t - 1, 2)
-
- @staticmethod
- def ease_out_cubic_bezier(t):
- """Custom cubic bezier similar to macOS default (0.25, 0.1, 0.25, 1.0)"""
- # Approximation of cubic-bezier(0.25, 0.1, 0.25, 1.0)
- return t * t * t * (t * (6 * t - 15) + 10)
-
- @staticmethod
- def ease_in_cubic(t):
- """Cubic ease in for smooth acceleration"""
- return t * t * t
-
- @staticmethod
- def ease_out_quint(t):
- """Quintic ease out for very smooth deceleration"""
- return 1 - math.pow(1 - t, 5)
-
-
-class SlideRevealer(Gtk.Overlay):
- def __init__(self, child: Gtk.Widget, direction="right", duration=350, size=None):
- super().__init__()
-
- self.child = child
- self.direction = direction
- self.duration = duration # Slightly faster for snappier feel
- self.fixed_size = size
- self._revealed = False
- self._animating = False
- self._start_time = None
- self._show_animation = False
- self._pending_position = None
- self._current_position = (0.0, 0.0) # Use float for sub-pixel positioning
- self._animation_id = None # Track individual animation instances
-
- self._fixed = Gtk.Fixed()
- self._fixed.set_has_window(False)
- self._fixed.add(child)
- self.add_overlay(self._fixed)
-
- if self.fixed_size:
- self.set_size_request(self.fixed_size[0], self.fixed_size[1])
- child.hide()
- self.show_all()
- else:
- child.connect("size-allocate", self._on_size_allocate)
- child.hide()
- self.show_all()
-
- def _on_size_allocate(self, _widget, allocation):
- if not self.fixed_size:
- current_req = self.get_size_request()
- if (
- current_req[0] != allocation.width
- or current_req[1] != allocation.height
- ):
- self.set_size_request(allocation.width, allocation.height)
-
- def set_reveal_child(self, reveal: bool):
- if reveal:
- self.reveal()
- else:
- self.hide()
-
- def reveal(self):
- if self._revealed and not self._animating:
- return
- self._revealed = True
-
- # Ensure widget is properly laid out before starting animation
- if self.get_realized():
- self._start_animation(show=True)
- else:
- # Wait for widget to be realized
- def on_realize(*_):
- self._start_animation(show=True)
- self.disconnect_by_func(on_realize)
- self.connect("realize", on_realize)
-
- def hide(self):
- if not self._revealed and not self._animating:
- return
- self._revealed = False
- self._start_animation(show=False)
-
- def _start_animation(self, show: bool):
- # Stop any existing animation for this widget
- if self._animating:
- AnimationManager.get_instance().remove_widget(self)
-
- self._cached_dimensions = self._get_dimensions()
- if self._cached_dimensions[0] == 0 or self._cached_dimensions[1] == 0:
- self._animating = False
- return
-
- # Use high-precision monotonic time for smooth animations
- self._start_time = GLib.get_monotonic_time()
- self._animating = True
- self._show_animation = show
- self._animation_id = id(self) # Unique ID for this animation instance
-
- if show:
- self.child.show()
- pos = self._get_offscreen_pos_cached()
- self._current_position = (float(pos[0]), float(pos[1]))
- self._fixed.move(self.child, int(pos[0]), int(pos[1]))
-
- def start_with_dimensions():
- AnimationManager.get_instance().add_widget(self)
- return False
-
- # Use idle_add to ensure layout is complete
- GLib.idle_add(start_with_dimensions)
-
- def _calculate_position(self):
- if not self._animating:
- return False
-
- elapsed = (GLib.get_monotonic_time() - self._start_time) / 1000
- t = min(elapsed / self.duration, 1.0)
-
- # Use different easing functions for better smoothness
- if self._show_animation:
- # Use quintic ease out for very smooth revealing
- eased = MacOSEasing.ease_out_quint(t)
- else:
- # Use cubic ease in for smooth hiding
- eased = MacOSEasing.ease_in_cubic(t)
-
- self._pending_position = self._get_position_at_progress_cached(eased)
-
- if t >= 1.0:
- self._animating = False
- self._cached_dimensions = None
- self._animation_id = None
- if not self._show_animation:
- GLib.idle_add(lambda: self.child.hide())
- return False
- return True
-
- def _apply_position(self):
- if self._pending_position:
- x, y = self._pending_position
- # Use sub-pixel positioning for smoother motion
- self._current_position = (x, y)
- # Round to nearest pixel for actual positioning
- pixel_x, pixel_y = int(round(x)), int(round(y))
- self._fixed.move(self.child, pixel_x, pixel_y)
- self._pending_position = None
-
- def _get_container_for_redraw(self):
- return self
-
- def _get_dimensions(self):
- if self.fixed_size:
- return self.fixed_size
- else:
- alloc = self.child.get_allocation()
- return alloc.width, alloc.height
-
- def _get_offscreen_pos_cached(self):
- w, h = self._cached_dimensions
- if self.direction == "left":
- return -w, 0
- elif self.direction == "right":
- return w, 0
- elif self.direction == "top":
- return 0, -h
- elif self.direction == "bottom":
- return 0, h
- return 0, 0
-
- def _get_position_at_progress_cached(self, progress):
- w, h = self._cached_dimensions
- if self._show_animation:
- # Showing animation: slide from offscreen to onscreen (0,0)
- if self.direction == "left":
- return -w + w * progress, 0.0
- elif self.direction == "right":
- return w - w * progress, 0.0
- elif self.direction == "top":
- return 0.0, -h + h * progress
- elif self.direction == "bottom":
- return 0.0, h - h * progress
- else:
- # Hiding animation: slide from onscreen (0,0) to offscreen
- if self.direction == "left":
- return -w * progress, 0.0 # Slide left (negative x)
- elif self.direction == "right":
- return w * progress, 0.0 # Slide right (positive x)
- elif self.direction == "top":
- return 0.0, -h * progress # Slide up (negative y)
- elif self.direction == "bottom":
- return 0.0, h * progress # Slide down (positive y)
- return 0.0, 0.0
-
- def set_slide_direction(self, direction):
- self.direction = direction
-
- def is_revealed(self):
- return self._revealed
-
- def is_animating(self):
- return self._animating
-
- def get_child_revealed(self):
- return self._revealed
-
- def stop_animation(self):
- if self._animating:
- AnimationManager.get_instance().remove_widget(self)
- self._animating = False
- self._cached_dimensions = None
- self._animation_id = None
-
- def destroy(self):
- self.stop_animation()
- super().destroy()
\ No newline at end of file
diff --git a/widgets/mousecapture.py b/widgets/mousecapture.py
deleted file mode 100644
index af432fc1..00000000
--- a/widgets/mousecapture.py
+++ /dev/null
@@ -1,142 +0,0 @@
-from typing import Any
-
-import cairo
-from gi.repository import GLib, GtkLayerShell # type: ignore
-
-from fabric.widgets.eventbox import EventBox
-from fabric.widgets.widget import Widget
-from utils.roam import modus_service
-from widgets.wayland import WaylandWindow as Window
-
-
-class MouseCapture(Window):
- """A background overlay that captures outside clicks without blocking child window interactions"""
-
- def __init__(self, layer: str, child_window: Window, **kwargs):
- super().__init__(
- layer="top", # Use top layer to capture events
- anchor="top bottom left right",
- exclusivity="auto",
- title="modus",
- name="MouseCapture",
- keyboard_mode="none", # Don't steal keyboard
- all_visible=False,
- visible=False,
- **kwargs,
- )
-
- GtkLayerShell.set_exclusive_zone(self, -1)
-
- self.child_window = child_window
-
- # Ensure child window is on overlay layer to be above this capture
- if hasattr(self.child_window, "layer"):
- self.child_window.layer = "overlay"
-
- if hasattr(self.child_window, "_init_mousecapture"):
- self.child_window._init_mousecapture(self)
-
- # Create transparent event box that captures clicks
- self.event_box = EventBox(
- events=["button-press-event"],
- all_visible=True,
- )
- self.event_box.connect("button-press-event", self.on_overlay_click)
- self.children = [self.event_box]
-
- # Make the overlay transparent
- self.set_app_paintable(True)
- self.connect("draw", self.on_draw)
-
- # Add escape key binding to child window
- if hasattr(self.child_window, "add_keybinding"):
- self.child_window.add_keybinding("Escape", self.hide_child_window)
-
- def on_draw(self, _widget, cr):
- """Make overlay transparent"""
- cr.set_source_rgba(0, 0, 0, 0) # Fully transparent
- cr.set_operator(cairo.OPERATOR_SOURCE)
- cr.paint()
- return False
-
- def on_overlay_click(self, _widget, event):
- """Handle overlay clicks - check if click is outside child window"""
- if not self.child_window.is_visible():
- return False
-
- # Get click coordinates
- click_x = event.x_root
- click_y = event.y_root
-
- # Get child window bounds
- try:
- child_x, child_y = self.child_window.get_position()
- child_allocation = self.child_window.get_allocation()
-
- # Check if click is inside child window bounds
- inside_child = (
- child_x <= click_x <= child_x + child_allocation.width
- and child_y <= click_y <= child_y + child_allocation.height
- )
-
- if not inside_child:
- # Click is outside child window - hide it with delay
- GLib.timeout_add(
- 50, lambda: self.hide_child_window(None, None) or False
- )
- return True # Consume the event
-
- except Exception as e:
- print(f"Error checking click position: {e}")
- # If we can't determine position, hide child window to be safe
- GLib.timeout_add(50, lambda: self.hide_child_window(None, None) or False)
- return True
-
- # Click is inside child window - don't consume event
- return False
-
- def show_child_window(self, widget: Widget = None, event: Any = None) -> None:
- self.set_child_window_visible(True)
-
- def hide_child_window(self, widget: Widget = None, event: Any = None) -> None:
- self.set_child_window_visible(False)
-
- def set_child_window_visible(self, visible: bool) -> None:
- if visible:
- self.child_window.show()
- self.show()
- else:
- self.child_window.hide()
- self.hide()
-
- if hasattr(self.child_window, "_set_mousecapture"):
- self.child_window._set_mousecapture(visible)
-
- def toggle_mousecapture(self, *_) -> None:
- if self.is_visible():
- self.set_child_window_visible(False)
- else:
- self.set_child_window_visible(True)
-
-
-class DropDownMouseCapture(MouseCapture):
- """A specialized MouseCapture for dropdown menus with service integration"""
-
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
- modus_service.connect("dropdowns-hide-changed", self.dropdowns_hide_changed)
-
- def hide_child_window(self, widget: Widget = None, event: Any = None) -> None:
- """Hide child window and update dropdown service state"""
- # Update service state before hiding to prevent conflicts
- if hasattr(self.child_window, "id"):
- if str(modus_service.current_dropdown) == str(self.child_window.id):
- modus_service.current_dropdown = None
- super().hide_child_window(widget, event)
-
- def dropdowns_hide_changed(self, widget: Widget = None, event: Any = None) -> None:
- """Handle dropdown service hide changes"""
- if hasattr(self.child_window, "id"):
- if modus_service.current_dropdown == self.child_window.id:
- return
- return self.hide_child_window(widget, event)
diff --git a/widgets/wayland.py b/widgets/wayland.py
deleted file mode 100644
index b7c76aa9..00000000
--- a/widgets/wayland.py
+++ /dev/null
@@ -1,370 +0,0 @@
-import re
-from collections.abc import Iterable
-from enum import Enum
-from typing import Literal, cast
-
-import cairo
-import gi
-from gi.repository import Gdk, GObject, Gtk
-from loguru import logger
-
-from fabric.core.service import Property
-from fabric.utils.helpers import extract_css_values, get_enum_member
-from fabric.widgets.window import Window
-
-gi.require_version("Gtk", "3.0")
-
-try:
- gi.require_version("GtkLayerShell", "0.1")
- from gi.repository import GtkLayerShell
-except:
- raise ImportError(
- "looks like we don't have gtk-layer-shell installed, make sure to install it first (as well as using wayland)"
- )
-
-
-class WaylandWindowExclusivity(Enum):
- NONE = 1
- NORMAL = 2
- AUTO = 3
-
-
-class Layer(GObject.GEnum):
- BACKGROUND = 0
- BOTTOM = 1
- TOP = 2
- OVERLAY = 3
- ENTRY_NUMBER = 4
-
-
-class KeyboardMode(GObject.GEnum):
- NONE = 0
- EXCLUSIVE = 1
- ON_DEMAND = 2
- ENTRY_NUMBER = 3
-
-
-class Edge(GObject.GEnum):
- LEFT = 0
- RIGHT = 1
- TOP = 2
- BOTTOM = 3
- ENTRY_NUMBER = 4
-
-
-class WaylandWindow(Window):
- @Property(
- Layer,
- flags="read-write",
- default_value=Layer.TOP,
- )
- def layer(self) -> Layer: # type: ignore
- return self._layer # type: ignore
-
- @layer.setter
- def layer(
- self,
- value: Literal["background", "bottom", "top", "overlay"] | Layer,
- ) -> None:
- self._layer = get_enum_member(Layer, value, default=Layer.TOP)
- return GtkLayerShell.set_layer(self, self._layer)
-
- @Property(int, "read-write")
- def monitor(self) -> int:
- if not (monitor := cast(Gdk.Monitor, GtkLayerShell.get_monitor(self))):
- return -1
- display = monitor.get_display() or Gdk.Display.get_default()
- for i in range(0, display.get_n_monitors()):
- if display.get_monitor(i) is monitor:
- return i
- return -1
-
- @monitor.setter
- def monitor(self, monitor: int | Gdk.Monitor) -> bool:
- if isinstance(monitor, int):
- display = Gdk.Display().get_default()
- monitor = display.get_monitor(monitor)
- return (
- (GtkLayerShell.set_monitor(self, monitor), True)[1]
- if monitor is not None
- else False
- )
-
- @Property(WaylandWindowExclusivity, "read-write")
- def exclusivity(self) -> WaylandWindowExclusivity:
- return self._exclusivity
-
- @exclusivity.setter
- def exclusivity(
- self, value: Literal["none", "normal", "auto"] | WaylandWindowExclusivity
- ) -> None:
- value = get_enum_member(
- WaylandWindowExclusivity, value, default=WaylandWindowExclusivity.NONE
- )
- self._exclusivity = value
- match value:
- case WaylandWindowExclusivity.NORMAL:
- return GtkLayerShell.set_exclusive_zone(self, True)
- case WaylandWindowExclusivity.AUTO:
- return GtkLayerShell.auto_exclusive_zone_enable(self)
- case _:
- return GtkLayerShell.set_exclusive_zone(self, False)
-
- @Property(bool, "read-write", default_value=False)
- def pass_through(self) -> bool:
- return self._pass_through
-
- @pass_through.setter
- def pass_through(self, pass_through: bool = False):
- self._pass_through = pass_through
- region = cairo.Region() if pass_through is True else None
- self.input_shape_combine_region(region)
- del region
- return
-
- @Property(
- KeyboardMode,
- "read-write",
- default_value=KeyboardMode.NONE,
- )
- def keyboard_mode(self) -> KeyboardMode:
- return self._keyboard_mode
-
- @keyboard_mode.setter
- def keyboard_mode(
- self,
- value: (
- Literal[
- "none",
- "exclusive",
- "on-demand",
- "entry-number",
- ]
- | KeyboardMode
- ),
- ):
- self._keyboard_mode = get_enum_member(
- KeyboardMode, value, default=KeyboardMode.NONE
- )
- return GtkLayerShell.set_keyboard_mode(self, self._keyboard_mode)
-
- @Property(tuple[Edge, ...], "read-write")
- def anchor(self):
- return tuple(
- x
- for x in [
- Edge.TOP,
- Edge.RIGHT,
- Edge.BOTTOM,
- Edge.LEFT,
- ]
- if GtkLayerShell.get_anchor(self, x)
- )
-
- @anchor.setter
- def anchor(self, value: str | Iterable[Edge]) -> None:
- self._anchor = value
- if isinstance(value, (list, tuple)) and all(
- isinstance(edge, Edge) for edge in value
- ):
- for edge in [
- Edge.TOP,
- Edge.RIGHT,
- Edge.BOTTOM,
- Edge.LEFT,
- ]:
- if edge not in value:
- GtkLayerShell.set_anchor(self, edge, False)
- GtkLayerShell.set_anchor(self, edge, True)
- return
- elif isinstance(value, str):
- for edge, anchored in WaylandWindow.extract_edges_from_string(
- value
- ).items():
- GtkLayerShell.set_anchor(self, edge, anchored)
-
- return
-
- @Property(tuple[int, ...], flags="read-write")
- def margin(self) -> tuple[int, ...]:
- return tuple(
- GtkLayerShell.get_margin(self, x)
- for x in [
- Edge.TOP,
- Edge.RIGHT,
- Edge.BOTTOM,
- Edge.LEFT,
- ]
- )
-
- @margin.setter
- def margin(self, value: str | Iterable[int]) -> None:
- for edge, mrgv in WaylandWindow.extract_margin(value).items():
- GtkLayerShell.set_margin(self, edge, mrgv)
- return
-
- @Property(object, "read-write")
- def keyboard_mode(self):
- kb_mode = GtkLayerShell.get_keyboard_mode(self)
- if GtkLayerShell.get_keyboard_interactivity(self):
- kb_mode = KeyboardMode.EXCLUSIVE
- return kb_mode
-
- @keyboard_mode.setter
- def keyboard_mode(
- self,
- value: Literal["none", "exclusive", "on-demand"] | KeyboardMode,
- ):
- return GtkLayerShell.set_keyboard_mode(
- self,
- get_enum_member(
- KeyboardMode,
- value,
- default=KeyboardMode.NONE,
- ),
- )
-
- def __init__(
- self,
- layer: Literal["background", "bottom", "top", "overlay"] | Layer = Layer.TOP,
- anchor: str = "",
- margin: str | Iterable[int] = "0px 0px 0px 0px",
- exclusivity: (
- Literal["auto", "normal", "none"] | WaylandWindowExclusivity
- ) = WaylandWindowExclusivity.NONE,
- keyboard_mode: (
- Literal["none", "exclusive", "on-demand"] | KeyboardMode
- ) = KeyboardMode.NONE,
- pass_through: bool = False,
- monitor: int | Gdk.Monitor | None = None,
- title: str = "fabric",
- type: Literal["top-level", "popup"] | Gtk.WindowType = Gtk.WindowType.TOPLEVEL,
- child: Gtk.Widget | None = None,
- name: str | None = None,
- visible: bool = True,
- all_visible: bool = False,
- style: str | None = None,
- style_classes: Iterable[str] | str | None = None,
- tooltip_text: str | None = None,
- tooltip_markup: str | None = None,
- h_align: (
- Literal["fill", "start", "end", "center", "baseline"] | Gtk.Align | None
- ) = None,
- v_align: (
- Literal["fill", "start", "end", "center", "baseline"] | Gtk.Align | None
- ) = None,
- h_expand: bool = False,
- v_expand: bool = False,
- size: Iterable[int] | int | None = None,
- **kwargs,
- ):
- Window.__init__(
- self,
- title=title,
- type=type,
- child=child,
- name=name,
- visible=False,
- all_visible=False,
- style=style,
- style_classes=style_classes,
- tooltip_text=tooltip_text,
- tooltip_markup=tooltip_markup,
- h_align=h_align,
- v_align=v_align,
- h_expand=h_expand,
- v_expand=v_expand,
- size=size,
- **kwargs,
- )
- self._layer = Layer.ENTRY_NUMBER
- self._keyboard_mode = KeyboardMode.NONE
- self._anchor = anchor
- self._exclusivity = WaylandWindowExclusivity.NONE
- self._pass_through = pass_through
-
- GtkLayerShell.init_for_window(self)
- GtkLayerShell.set_namespace(self, title)
- self.connect(
- "notify::title",
- lambda *_: GtkLayerShell.set_namespace(self, self.get_title()),
- )
- if monitor is not None:
- self.monitor = monitor
- self.layer = layer
- self.anchor = anchor
- self.margin = margin
- self.keyboard_mode = keyboard_mode
- self.exclusivity = exclusivity
- self.pass_through = pass_through
- (
- self.show_all()
- if all_visible is True
- else self.show() if visible is True else None
- )
-
- def steal_input(self) -> None:
- return GtkLayerShell.set_keyboard_interactivity(self, True)
-
- def return_input(self) -> None:
- return GtkLayerShell.set_keyboard_interactivity(self, False)
-
- # custom overrides
- def show(self) -> None:
- super().show()
- return self.do_handle_post_show_request()
-
- def show_all(self) -> None:
- super().show_all()
- return self.do_handle_post_show_request()
-
- def do_handle_post_show_request(self) -> None:
- if not self.get_children():
- logger.warning(
- "[WaylandWindow] showing an empty window is not recommended, some compositors might freak out."
- )
- self.pass_through = self._pass_through
- return
-
- @staticmethod
- def extract_anchor_values(string: str) -> tuple[str, ...]:
- """
- extracts the geometry values from a given geometry string.
-
- :param string: the string containing the geometry values.
- :type string: str
- :return: a list of unique directions extracted from the geometry string.
- :rtype: list
- """
- direction_map = {"l": "left", "t": "top", "r": "right", "b": "bottom"}
- pattern = re.compile(r"\b(left|right|top|bottom)\b", re.IGNORECASE)
- matches = pattern.findall(string)
- return tuple(set(tuple(direction_map[match.lower()[0]] for match in matches)))
-
- @staticmethod
- def extract_edges_from_string(string: str) -> dict["Edge", bool]:
- anchor_values = WaylandWindow.extract_anchor_values(string.lower())
- return {
- Edge.TOP: "top" in anchor_values,
- Edge.RIGHT: "right" in anchor_values,
- Edge.BOTTOM: "bottom" in anchor_values,
- Edge.LEFT: "left" in anchor_values,
- }
-
- @staticmethod
- def extract_margin(input: str | Iterable[int]) -> dict["Edge", int]:
- margins = (
- extract_css_values(input.lower())
- if isinstance(input, str)
- else (
- input
- if isinstance(input, (tuple, list)) and len(input) == 4
- else (0, 0, 0, 0)
- )
- )
- return {
- Edge.TOP: margins[0],
- Edge.RIGHT: margins[1],
- Edge.BOTTOM: margins[2],
- Edge.LEFT: margins[3],
- }