.
diff --git a/README.md b/README.md
deleted file mode 100644
index 3a9df24..0000000
--- a/README.md
+++ /dev/null
@@ -1,110 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-[](https://github.com/S4NKALP/Modus/stargazers)
-[](https://hyprland.org/)
-[]()
-[](https://discord.gg/tRFxkbQ3Zq)
-
-
-
-
-
-
- Home Screen:
-
-
- Lock Screen:
-
-
-
-
-
-## 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
-
-```bash
-git clone https://github.com/S4NKALP/Modus ~/.config/Modus
-cd ~/.config/Modus
-./install.sh
-```
-
-> [!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`
-
- Todo
-
-## Manual Installation (WIP)
-
-```bash
-paru -S glace-git gtk-session-lock python-pyotp python-pillow python-ijson python-setproctitle apple-fonts cinnamon-desktop --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
-```
-
-- [x] Launcher
-- [x] Lock Screen
-- [x] Dock
-- [x] Notification
-- [x] Control Center
-- [x] Music Player
-- [x] Desktop Widgets
-- [x] New Launcher (like Spotlight)
-- [ ] Settings
-- [x] ~~Magnifier hover effect on Dock~~
-- [x] ~~New Application Switcher~~
-- [x] Panel Widget
-- [x] MacOS like Widget
-- [x] Expandable Notification Centre
-- [ ] Installation Script
-- [ ] Proper Documentation
-- [ ] Pomodoro Timer Widget
-- [x] To-do List Widget
-
-## Bug Fixes (the bug found till now)
-
-- [x] WiFi
-- [x] wifi off button looks bigger
-- [x] Metadata Changes delay in Media Player
-- [x] Active Window Title showing `Unknown` when no active window
-- [x] Notification Escape Char Issue
-
-## Team
-
-- [SANKALP](https://github.com/S4NKALP/)
-- [tr1x_em](https://github.com/tr1xem)
-
-## Special Thanks
-
-A big thank you to the following people for their incredible help with code and creative ideas. Your help made a real difference!
-
-- [darsh](https://github.com/its-darsh): for creating Fabric, which made everything possible.
-- [gummy bear album](https://github.com/muhchaudhary): for sharing fantastic code snippets that saved me time and effort.
-- [axenide](https://github.com/Axenide): for the amazing config that not only inspired parts of mine but also provided some gems I couldnโt resist borrowing.
-- [E3nviction](https://github.com/E3nviction/): for code snippets and ideas that were incredibly helpful.
-
-I truly appreciate your support
diff --git a/assets/screenshots/home.png b/assets/screenshots/home.png
deleted file mode 100644
index a98ab7c..0000000
Binary files a/assets/screenshots/home.png and /dev/null differ
diff --git a/assets/screenshots/lock.png b/assets/screenshots/lock.png
deleted file mode 100644
index 123f456..0000000
Binary files a/assets/screenshots/lock.png and /dev/null differ
diff --git a/config/assets/config.json b/config/assets/config.json
deleted file mode 100644
index 3750756..0000000
--- 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 fa174ff..0000000
--- 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/assets/icons/applets/bluetooth-clear.svg b/config/assets/icons/applets/bluetooth-clear.svg
deleted file mode 100644
index 6348bc5..0000000
--- a/config/assets/icons/applets/bluetooth-clear.svg
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
-
-
diff --git a/config/assets/icons/applets/bluetooth-off-clear.svg b/config/assets/icons/applets/bluetooth-off-clear.svg
deleted file mode 100644
index cea2aa5..0000000
--- a/config/assets/icons/applets/bluetooth-off-clear.svg
+++ /dev/null
@@ -1,25 +0,0 @@
-
-
-
-
-
diff --git a/config/assets/icons/applets/bluetooth-off.svg b/config/assets/icons/applets/bluetooth-off.svg
deleted file mode 100644
index 308fba6..0000000
--- a/config/assets/icons/applets/bluetooth-off.svg
+++ /dev/null
@@ -1,76 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/config/assets/icons/applets/bluetooth-paired.svg b/config/assets/icons/applets/bluetooth-paired.svg
deleted file mode 100644
index c400923..0000000
--- a/config/assets/icons/applets/bluetooth-paired.svg
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
-
-
-
-
diff --git a/config/assets/icons/applets/bluetooth.svg b/config/assets/icons/applets/bluetooth.svg
deleted file mode 100644
index d3a0b61..0000000
--- a/config/assets/icons/applets/bluetooth.svg
+++ /dev/null
@@ -1,80 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/config/assets/icons/applets/brightness.svg b/config/assets/icons/applets/brightness.svg
deleted file mode 100755
index bcc61ef..0000000
--- a/config/assets/icons/applets/brightness.svg
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
diff --git a/config/assets/icons/applets/caffeine-off.svg b/config/assets/icons/applets/caffeine-off.svg
deleted file mode 100644
index b59dfe0..0000000
--- a/config/assets/icons/applets/caffeine-off.svg
+++ /dev/null
@@ -1,104 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/config/assets/icons/applets/caffeine-on.svg b/config/assets/icons/applets/caffeine-on.svg
deleted file mode 100644
index 2432b4a..0000000
--- a/config/assets/icons/applets/caffeine-on.svg
+++ /dev/null
@@ -1,103 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/config/assets/icons/applets/dnd-clear.svg b/config/assets/icons/applets/dnd-clear.svg
deleted file mode 100644
index 19b053f..0000000
--- a/config/assets/icons/applets/dnd-clear.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
-
diff --git a/config/assets/icons/applets/dnd-off.svg b/config/assets/icons/applets/dnd-off.svg
deleted file mode 100644
index fe6a37c..0000000
--- a/config/assets/icons/applets/dnd-off.svg
+++ /dev/null
@@ -1,53 +0,0 @@
-
-
-
-
-
-
-
diff --git a/config/assets/icons/applets/dnd.svg b/config/assets/icons/applets/dnd.svg
deleted file mode 100644
index 4ecdd99..0000000
--- a/config/assets/icons/applets/dnd.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
-
\ No newline at end of file
diff --git a/config/assets/icons/applets/flight-off.svg b/config/assets/icons/applets/flight-off.svg
deleted file mode 100644
index d59785c..0000000
--- a/config/assets/icons/applets/flight-off.svg
+++ /dev/null
@@ -1,82 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/config/assets/icons/applets/flight-on.svg b/config/assets/icons/applets/flight-on.svg
deleted file mode 100644
index 5a492f1..0000000
--- a/config/assets/icons/applets/flight-on.svg
+++ /dev/null
@@ -1,86 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/config/assets/icons/applets/network-wired-offline.svg b/config/assets/icons/applets/network-wired-offline.svg
deleted file mode 100644
index c1734ca..0000000
--- a/config/assets/icons/applets/network-wired-offline.svg
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
-
-
-
-
diff --git a/config/assets/icons/applets/network-wired.svg b/config/assets/icons/applets/network-wired.svg
deleted file mode 100644
index 7b6881e..0000000
--- a/config/assets/icons/applets/network-wired.svg
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
diff --git a/config/assets/icons/applets/redshift-status-off.svg b/config/assets/icons/applets/redshift-status-off.svg
deleted file mode 100644
index e81d366..0000000
--- a/config/assets/icons/applets/redshift-status-off.svg
+++ /dev/null
@@ -1,94 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/config/assets/icons/applets/redshift-status-on.svg b/config/assets/icons/applets/redshift-status-on.svg
deleted file mode 100644
index aaa2959..0000000
--- a/config/assets/icons/applets/redshift-status-on.svg
+++ /dev/null
@@ -1,86 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/config/assets/icons/applets/wifi-clear.svg b/config/assets/icons/applets/wifi-clear.svg
deleted file mode 100644
index 9276ebe..0000000
--- a/config/assets/icons/applets/wifi-clear.svg
+++ /dev/null
@@ -1,16 +0,0 @@
-
-
-
-
-
-
-
-
diff --git a/config/assets/icons/applets/wifi-off-clear.svg b/config/assets/icons/applets/wifi-off-clear.svg
deleted file mode 100644
index d19de29..0000000
--- a/config/assets/icons/applets/wifi-off-clear.svg
+++ /dev/null
@@ -1,44 +0,0 @@
-
-
-
-
-
-
-
-
-
diff --git a/config/assets/icons/applets/wifi-off.svg b/config/assets/icons/applets/wifi-off.svg
deleted file mode 100644
index ba5c100..0000000
--- a/config/assets/icons/applets/wifi-off.svg
+++ /dev/null
@@ -1,89 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/config/assets/icons/applets/wifi.svg b/config/assets/icons/applets/wifi.svg
deleted file mode 100644
index 6a0dd08..0000000
--- a/config/assets/icons/applets/wifi.svg
+++ /dev/null
@@ -1,99 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/config/assets/icons/battery/battery-000-charging.svg b/config/assets/icons/battery/battery-000-charging.svg
deleted file mode 100644
index ac8c28f..0000000
--- a/config/assets/icons/battery/battery-000-charging.svg
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
-
-
diff --git a/config/assets/icons/battery/battery-000.svg b/config/assets/icons/battery/battery-000.svg
deleted file mode 100644
index c49fb45..0000000
--- a/config/assets/icons/battery/battery-000.svg
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
diff --git a/config/assets/icons/battery/battery-010-charging.svg b/config/assets/icons/battery/battery-010-charging.svg
deleted file mode 100644
index c9ea308..0000000
--- a/config/assets/icons/battery/battery-010-charging.svg
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-
-
-
-
diff --git a/config/assets/icons/battery/battery-010.svg b/config/assets/icons/battery/battery-010.svg
deleted file mode 100644
index b88e8fb..0000000
--- a/config/assets/icons/battery/battery-010.svg
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
-
-
diff --git a/config/assets/icons/battery/battery-020-charging.svg b/config/assets/icons/battery/battery-020-charging.svg
deleted file mode 100644
index 5824ad4..0000000
--- a/config/assets/icons/battery/battery-020-charging.svg
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-
-
-
-
diff --git a/config/assets/icons/battery/battery-020.svg b/config/assets/icons/battery/battery-020.svg
deleted file mode 100644
index 87375ae..0000000
--- a/config/assets/icons/battery/battery-020.svg
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
-
-
diff --git a/config/assets/icons/battery/battery-030-charging.svg b/config/assets/icons/battery/battery-030-charging.svg
deleted file mode 100644
index c0088ce..0000000
--- a/config/assets/icons/battery/battery-030-charging.svg
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-
-
-
-
diff --git a/config/assets/icons/battery/battery-030.svg b/config/assets/icons/battery/battery-030.svg
deleted file mode 100644
index 312ab1b..0000000
--- a/config/assets/icons/battery/battery-030.svg
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
-
-
diff --git a/config/assets/icons/battery/battery-040-charging.svg b/config/assets/icons/battery/battery-040-charging.svg
deleted file mode 100644
index 1327b10..0000000
--- a/config/assets/icons/battery/battery-040-charging.svg
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-
-
-
-
diff --git a/config/assets/icons/battery/battery-040.svg b/config/assets/icons/battery/battery-040.svg
deleted file mode 100644
index ff8a28a..0000000
--- a/config/assets/icons/battery/battery-040.svg
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
-
-
diff --git a/config/assets/icons/battery/battery-050-charging.svg b/config/assets/icons/battery/battery-050-charging.svg
deleted file mode 100644
index 016985f..0000000
--- a/config/assets/icons/battery/battery-050-charging.svg
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-
-
-
-
diff --git a/config/assets/icons/battery/battery-050.svg b/config/assets/icons/battery/battery-050.svg
deleted file mode 100644
index 0e6af06..0000000
--- a/config/assets/icons/battery/battery-050.svg
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
-
-
diff --git a/config/assets/icons/battery/battery-060-charging.svg b/config/assets/icons/battery/battery-060-charging.svg
deleted file mode 100644
index e23c733..0000000
--- a/config/assets/icons/battery/battery-060-charging.svg
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-
-
-
-
diff --git a/config/assets/icons/battery/battery-060.svg b/config/assets/icons/battery/battery-060.svg
deleted file mode 100644
index f9c44b9..0000000
--- a/config/assets/icons/battery/battery-060.svg
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
-
-
diff --git a/config/assets/icons/battery/battery-070-charging.svg b/config/assets/icons/battery/battery-070-charging.svg
deleted file mode 100644
index cf18c55..0000000
--- a/config/assets/icons/battery/battery-070-charging.svg
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-
-
-
-
diff --git a/config/assets/icons/battery/battery-070.svg b/config/assets/icons/battery/battery-070.svg
deleted file mode 100644
index abd1a8e..0000000
--- a/config/assets/icons/battery/battery-070.svg
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
-
-
diff --git a/config/assets/icons/battery/battery-080-charging.svg b/config/assets/icons/battery/battery-080-charging.svg
deleted file mode 100644
index 672c08f..0000000
--- a/config/assets/icons/battery/battery-080-charging.svg
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-
-
-
-
diff --git a/config/assets/icons/battery/battery-080.svg b/config/assets/icons/battery/battery-080.svg
deleted file mode 100644
index 0a78582..0000000
--- a/config/assets/icons/battery/battery-080.svg
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
-
-
diff --git a/config/assets/icons/battery/battery-090-charging.svg b/config/assets/icons/battery/battery-090-charging.svg
deleted file mode 100644
index 2e81ab9..0000000
--- a/config/assets/icons/battery/battery-090-charging.svg
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-
-
-
-
diff --git a/config/assets/icons/battery/battery-090.svg b/config/assets/icons/battery/battery-090.svg
deleted file mode 100644
index 8cc1219..0000000
--- a/config/assets/icons/battery/battery-090.svg
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
-
-
diff --git a/config/assets/icons/battery/battery-100-charging.svg b/config/assets/icons/battery/battery-100-charging.svg
deleted file mode 100644
index 22471ae..0000000
--- a/config/assets/icons/battery/battery-100-charging.svg
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-
-
-
-
diff --git a/config/assets/icons/battery/battery-100.svg b/config/assets/icons/battery/battery-100.svg
deleted file mode 100644
index 9d9104b..0000000
--- a/config/assets/icons/battery/battery-100.svg
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
-
-
diff --git a/config/assets/icons/brightness/brightness-0.svg b/config/assets/icons/brightness/brightness-0.svg
deleted file mode 100755
index d6065c7..0000000
--- a/config/assets/icons/brightness/brightness-0.svg
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
diff --git a/config/assets/icons/brightness/brightness-1.svg b/config/assets/icons/brightness/brightness-1.svg
deleted file mode 100755
index e5b4101..0000000
--- a/config/assets/icons/brightness/brightness-1.svg
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
diff --git a/config/assets/icons/brightness/brightness-2.svg b/config/assets/icons/brightness/brightness-2.svg
deleted file mode 100755
index df7cfba..0000000
--- a/config/assets/icons/brightness/brightness-2.svg
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
diff --git a/config/assets/icons/brightness/brightness-3.svg b/config/assets/icons/brightness/brightness-3.svg
deleted file mode 100755
index c3b390b..0000000
--- a/config/assets/icons/brightness/brightness-3.svg
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
diff --git a/config/assets/icons/brightness/brightness.svg b/config/assets/icons/brightness/brightness.svg
deleted file mode 100755
index bcc61ef..0000000
--- a/config/assets/icons/brightness/brightness.svg
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
diff --git a/config/assets/icons/mic/microphone-0.svg b/config/assets/icons/mic/microphone-0.svg
deleted file mode 100755
index ef1f97a..0000000
--- a/config/assets/icons/mic/microphone-0.svg
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
-
-
-
-
-
-
diff --git a/config/assets/icons/mic/microphone-1.svg b/config/assets/icons/mic/microphone-1.svg
deleted file mode 100755
index dfa3008..0000000
--- a/config/assets/icons/mic/microphone-1.svg
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
-
-
-
-
diff --git a/config/assets/icons/mic/microphone-2.svg b/config/assets/icons/mic/microphone-2.svg
deleted file mode 100755
index 0b47426..0000000
--- a/config/assets/icons/mic/microphone-2.svg
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
-
-
-
-
-
-
-
diff --git a/config/assets/icons/mic/microphone-3.svg b/config/assets/icons/mic/microphone-3.svg
deleted file mode 100755
index f48bd31..0000000
--- a/config/assets/icons/mic/microphone-3.svg
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
-
-
-
-
diff --git a/config/assets/icons/mic/microphone.svg b/config/assets/icons/mic/microphone.svg
deleted file mode 100755
index dfa3008..0000000
--- a/config/assets/icons/mic/microphone.svg
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
-
-
-
-
diff --git a/config/assets/icons/misc/chevron-left.svg b/config/assets/icons/misc/chevron-left.svg
deleted file mode 100644
index 8cade29..0000000
--- a/config/assets/icons/misc/chevron-left.svg
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
-
-
diff --git a/config/assets/icons/misc/chevron-right.svg b/config/assets/icons/misc/chevron-right.svg
deleted file mode 100644
index f81b1e6..0000000
--- a/config/assets/icons/misc/chevron-right.svg
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
-
-
diff --git a/config/assets/icons/misc/control-center.svg b/config/assets/icons/misc/control-center.svg
deleted file mode 100644
index 24f2195..0000000
--- a/config/assets/icons/misc/control-center.svg
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
-
diff --git a/config/assets/icons/misc/control.svg b/config/assets/icons/misc/control.svg
deleted file mode 100644
index ed4e9d7..0000000
--- a/config/assets/icons/misc/control.svg
+++ /dev/null
@@ -1,3 +0,0 @@
-
-
-
diff --git a/config/assets/icons/misc/imac.svg b/config/assets/icons/misc/imac.svg
deleted file mode 100644
index 47719db..0000000
--- a/config/assets/icons/misc/imac.svg
+++ /dev/null
@@ -1,133 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/config/assets/icons/misc/logo.svg b/config/assets/icons/misc/logo.svg
deleted file mode 100644
index 9fa578d..0000000
--- a/config/assets/icons/misc/logo.svg
+++ /dev/null
@@ -1,46 +0,0 @@
-
-
-
-
-
-
-
-
-
-
diff --git a/config/assets/icons/misc/media-record.svg b/config/assets/icons/misc/media-record.svg
deleted file mode 100644
index 8f71f09..0000000
--- a/config/assets/icons/misc/media-record.svg
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
-
-
diff --git a/config/assets/icons/misc/search.svg b/config/assets/icons/misc/search.svg
deleted file mode 100644
index ef84365..0000000
--- a/config/assets/icons/misc/search.svg
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
diff --git a/config/assets/icons/notification.png b/config/assets/icons/notification.png
deleted file mode 100644
index 41d66d6..0000000
Binary files a/config/assets/icons/notification.png and /dev/null differ
diff --git a/config/assets/icons/notifications/notification-active.svg b/config/assets/icons/notifications/notification-active.svg
deleted file mode 100644
index 63452f8..0000000
--- a/config/assets/icons/notifications/notification-active.svg
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
-
diff --git a/config/assets/icons/notifications/notification-disabled.svg b/config/assets/icons/notifications/notification-disabled.svg
deleted file mode 100644
index d4cb163..0000000
--- a/config/assets/icons/notifications/notification-disabled.svg
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
-
diff --git a/config/assets/icons/notifications/notification-inactive.svg b/config/assets/icons/notifications/notification-inactive.svg
deleted file mode 100644
index 108e755..0000000
--- a/config/assets/icons/notifications/notification-inactive.svg
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
-
diff --git a/config/assets/icons/player/Pause.svg b/config/assets/icons/player/Pause.svg
deleted file mode 100644
index f95a5b8..0000000
--- a/config/assets/icons/player/Pause.svg
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
- Layer 1
-
-
-
\ No newline at end of file
diff --git a/config/assets/icons/player/Rewind.svg b/config/assets/icons/player/Rewind.svg
deleted file mode 100644
index e52bc1d..0000000
--- a/config/assets/icons/player/Rewind.svg
+++ /dev/null
@@ -1,3 +0,0 @@
-
-
-
diff --git a/config/assets/icons/player/audio-switcher.svg b/config/assets/icons/player/audio-switcher.svg
deleted file mode 100644
index d50264f..0000000
--- a/config/assets/icons/player/audio-switcher.svg
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
diff --git a/config/assets/icons/player/fwd.svg b/config/assets/icons/player/fwd.svg
deleted file mode 100644
index 57f16c3..0000000
--- a/config/assets/icons/player/fwd.svg
+++ /dev/null
@@ -1,3 +0,0 @@
-
-
-
diff --git a/config/assets/icons/player/play.svg b/config/assets/icons/player/play.svg
deleted file mode 100644
index d3356bf..0000000
--- a/config/assets/icons/player/play.svg
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
-
-
-
-
diff --git a/config/assets/icons/power_modes/battery-balanced.svg b/config/assets/icons/power_modes/battery-balanced.svg
deleted file mode 100644
index 9d9104b..0000000
--- a/config/assets/icons/power_modes/battery-balanced.svg
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
-
-
diff --git a/config/assets/icons/power_modes/battery-performance.svg b/config/assets/icons/power_modes/battery-performance.svg
deleted file mode 100644
index 3bd7b42..0000000
--- a/config/assets/icons/power_modes/battery-performance.svg
+++ /dev/null
@@ -1,116 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/config/assets/icons/power_modes/battery-power.svg b/config/assets/icons/power_modes/battery-power.svg
deleted file mode 100644
index 87375ae..0000000
--- a/config/assets/icons/power_modes/battery-power.svg
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
-
-
diff --git a/config/assets/icons/todo/checkbox-check.svg b/config/assets/icons/todo/checkbox-check.svg
deleted file mode 100644
index 198f1dd..0000000
--- a/config/assets/icons/todo/checkbox-check.svg
+++ /dev/null
@@ -1,58 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/config/assets/icons/todo/checkbox-uncheck.svg b/config/assets/icons/todo/checkbox-uncheck.svg
deleted file mode 100644
index a0ee941..0000000
--- a/config/assets/icons/todo/checkbox-uncheck.svg
+++ /dev/null
@@ -1,53 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
diff --git a/config/assets/icons/todo/delete-symbolic.svg b/config/assets/icons/todo/delete-symbolic.svg
deleted file mode 100644
index 180f034..0000000
--- a/config/assets/icons/todo/delete-symbolic.svg
+++ /dev/null
@@ -1,43 +0,0 @@
-
-
-
-
-
-
-
-
diff --git a/config/assets/icons/todo/edit.svg b/config/assets/icons/todo/edit.svg
deleted file mode 100644
index 42b5282..0000000
--- a/config/assets/icons/todo/edit.svg
+++ /dev/null
@@ -1,59 +0,0 @@
-
-
-
-
-
-
-
- image/svg+xml
-
-
-
-
-
-
-
-
diff --git a/config/assets/icons/todo/plus-symbolic.svg b/config/assets/icons/todo/plus-symbolic.svg
deleted file mode 100644
index af065bd..0000000
--- a/config/assets/icons/todo/plus-symbolic.svg
+++ /dev/null
@@ -1,51 +0,0 @@
-
-
-
-
-
-
-
diff --git a/config/assets/icons/todo/to-do-app-symbolic.svg b/config/assets/icons/todo/to-do-app-symbolic.svg
deleted file mode 100644
index 17cbcdf..0000000
--- a/config/assets/icons/todo/to-do-app-symbolic.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/config/assets/icons/volume/audio-volume-0.svg b/config/assets/icons/volume/audio-volume-0.svg
deleted file mode 100644
index 2728d98..0000000
--- a/config/assets/icons/volume/audio-volume-0.svg
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/config/assets/icons/volume/audio-volume-1.svg b/config/assets/icons/volume/audio-volume-1.svg
deleted file mode 100644
index 8378bed..0000000
--- a/config/assets/icons/volume/audio-volume-1.svg
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
diff --git a/config/assets/icons/volume/audio-volume-2.svg b/config/assets/icons/volume/audio-volume-2.svg
deleted file mode 100644
index 927462a..0000000
--- a/config/assets/icons/volume/audio-volume-2.svg
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
diff --git a/config/assets/icons/volume/audio-volume-3.svg b/config/assets/icons/volume/audio-volume-3.svg
deleted file mode 100644
index abd00cb..0000000
--- a/config/assets/icons/volume/audio-volume-3.svg
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
diff --git a/config/assets/icons/volume/audio-volume.svg b/config/assets/icons/volume/audio-volume.svg
deleted file mode 100644
index abd00cb..0000000
--- a/config/assets/icons/volume/audio-volume.svg
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
diff --git a/config/assets/icons/wifi/network-wireless-0.svg b/config/assets/icons/wifi/network-wireless-0.svg
deleted file mode 100644
index 79a7d67..0000000
--- a/config/assets/icons/wifi/network-wireless-0.svg
+++ /dev/null
@@ -1,16 +0,0 @@
-
-
-
-
-
-
-
-
diff --git a/config/assets/icons/wifi/network-wireless-100.svg b/config/assets/icons/wifi/network-wireless-100.svg
deleted file mode 100644
index 9276ebe..0000000
--- a/config/assets/icons/wifi/network-wireless-100.svg
+++ /dev/null
@@ -1,16 +0,0 @@
-
-
-
-
-
-
-
-
diff --git a/config/assets/icons/wifi/network-wireless-20.svg b/config/assets/icons/wifi/network-wireless-20.svg
deleted file mode 100644
index 5dd4259..0000000
--- a/config/assets/icons/wifi/network-wireless-20.svg
+++ /dev/null
@@ -1,16 +0,0 @@
-
-
-
-
-
-
-
-
diff --git a/config/assets/icons/wifi/network-wireless-40.svg b/config/assets/icons/wifi/network-wireless-40.svg
deleted file mode 100644
index d323c5c..0000000
--- a/config/assets/icons/wifi/network-wireless-40.svg
+++ /dev/null
@@ -1,16 +0,0 @@
-
-
-
-
-
-
-
-
diff --git a/config/assets/icons/wifi/network-wireless-60.svg b/config/assets/icons/wifi/network-wireless-60.svg
deleted file mode 100644
index 47c2a37..0000000
--- a/config/assets/icons/wifi/network-wireless-60.svg
+++ /dev/null
@@ -1,16 +0,0 @@
-
-
-
-
-
-
-
-
diff --git a/config/assets/icons/wifi/network-wireless-80.svg b/config/assets/icons/wifi/network-wireless-80.svg
deleted file mode 100644
index 6e65071..0000000
--- a/config/assets/icons/wifi/network-wireless-80.svg
+++ /dev/null
@@ -1,16 +0,0 @@
-
-
-
-
-
-
-
-
diff --git a/config/assets/icons/wifi/wifi-connecting.svg b/config/assets/icons/wifi/wifi-connecting.svg
deleted file mode 100644
index 6d95e92..0000000
--- a/config/assets/icons/wifi/wifi-connecting.svg
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
-
-
-
-
-
-
diff --git a/config/assets/launcher.json b/config/assets/launcher.json
deleted file mode 100644
index 9d411e0..0000000
--- a/config/assets/launcher.json
+++ /dev/null
@@ -1,98 +0,0 @@
-{
- "launcher_config": {
- "em": {
- "icon": "emoji-people-symbolic",
- "description": "Emoji - Search and copy emojis"
- },
- "clip": {
- "icon": "app.getclipboard.Clipboard",
- "description": "Clipboard - Search and manage clipboard history"
- },
- "=": {
- "examples": [
- "= 10*5",
- "= 2^8",
- "= sin(30)"
- ],
- "icon": "calculator",
- "description": "Quick Math - Fast mathematical expressions"
- },
- "app": {
- "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"
- },
- "caffeine": {
- "examples": [
- "caffeine 30m",
- "caffeine 1h",
- "caffeine 2h",
- "caffeine on"
- ],
- "icon": "caffeine",
- "description": "Caffeine - Prevent system from going idle"
- },
- "sc": {
- "icon": "cs-screen",
- "description": "Screencapture - Record screen and audio"
- },
- "wall": {
- "icon": "daily-wallpaper",
- "description": "Wallpapers - Set wallpapers, and tons of features"
- },
- "?": {
- "icon": "search-symbolic",
- "description": "Quick Web Search - Fast web search with question mark",
- "examples": [
- "? cats",
- "? 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": {
- "max_examples_shown": 2,
- "default_icon": "apps",
- "fallback_example_template": "{trigger} ",
- "config_version": "1.0"
- }
-}
diff --git a/config/data.py b/config/data.py
deleted file mode 100644
index e4aa977..0000000
--- a/config/data.py
+++ /dev/null
@@ -1,118 +0,0 @@
-import json
-import os
-
-import gi
-from fabric.utils.helpers import get_relative_path
-from gi.repository import Gdk, GLib
-
-gi.require_version("Gtk", "3.0")
-
-APP_NAME = "modus"
-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("~")
-
-CONFIG_DIR = os.path.expanduser(f"~/.config/{APP_NAME}")
-
-screen = Gdk.Screen.get_default()
-CURRENT_WIDTH = screen.get_width()
-CURRENT_HEIGHT = screen.get_height()
-
-
-WALLPAPERS_DIR_DEFAULT = get_relative_path("../assets/wallpapers_example/")
-CONFIG_FILE = get_relative_path("../config/assets/config.json")
-MATUGEN_STATE_FILE = os.path.join(CONFIG_DIR, "matugen")
-
-
-def load_config():
- """Load the configuration from config.json"""
- 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):
- with open(CONFIG_FILE, "r") as f:
- config = json.load(f)
- 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)
- DOCK_ICON_SIZE = config.get("dock_icon_size", 60)
- WINDOW_SWITCHER_ITEMS_PER_ROW = config.get("window_switcher_items_per_row", 10)
- HIDE_SPECIAL_WORKSPACE = config.get("hide_special_workspace", True)
- DOCK_HIDE_SPECIAL_WORKSPACE_APPS = config.get(
- "dock_hide_special_workspace_apps", True
- )
-
- NOTIFICATION_TIMEOUT_STR = config.get("notification_timeout", "5s")
- NOTIFICATION_TIMEOUT = parse_timeout_string(NOTIFICATION_TIMEOUT_STR)
- NOTIFICATION_IGNORED_APPS_HISTORY = config.get(
- "notification_ignored_apps_history", ["Hyprshot"]
- )
- NOTIFICATION_LIMITED_APPS_HISTORY = config.get(
- "notification_limited_apps_history", ["Spotify"]
- )
-
-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
- WINDOW_SWITCHER_ITEMS_PER_ROW = 10
- HIDE_SPECIAL_WORKSPACE = True
- DOCK_HIDE_SPECIAL_WORKSPACE_APPS = True
-
- NOTIFICATION_TIMEOUT_STR = "5s"
- NOTIFICATION_TIMEOUT = parse_timeout_string(NOTIFICATION_TIMEOUT_STR)
- NOTIFICATION_IGNORED_APPS_HISTORY = ["Hyprshot"]
- NOTIFICATION_LIMITED_APPS_HISTORY = ["Spotify"]
diff --git a/config/hypr/modus.conf b/config/hypr/modus.conf
deleted file mode 100644
index cfa51cb..0000000
--- a/config/hypr/modus.conf
+++ /dev/null
@@ -1,53 +0,0 @@
-# LAYER RULES FOR BLURS
-
-layerrule = blur, ^modus-notifications$
-layerrule = xray 0, ^modus-notifications$
-layerrule = blurpopups, ^modus-notifications$
-layerrule = ignorezero, ^modus-notifications$
-layerrule = noanim , ^modus-notifications$
-layerrule = animation popin, ^lockscreen$
-
-layerrule = blur, ^modus-launcher$
-layerrule = xray 0, ^modus-launcher$
-layerrule = blurpopups, ^modus-launcher$
-layerrule = ignorezero, ^modus-launcher$
-layerrule = animation popin, ^modus-launcher$
-
-layerrule = blur, ^fabric$
-layerrule = ignorezero, ^fabric$
-layerrule = xray 0, ^fabric$
-layerrule = blurpopups, ^fabric$
-layerrule = blur, ^modus$
-layerrule = xray 0, ^modus$
-layerrule = blurpopups, ^modus$
-layerrule = ignorezero, ^modus$
-layerrule = animation slide right, ^notification-center$
-
-
-# KEYBINDS
-
-exec-once = uwsm app -- python ~/.config/Modus/main.py
-$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()'
-# Kanban
-bind = SUPER, T, exec, $fabricSend "launcher.show_launcher('kanban')" # Kanban
-# 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/matugen/templates/hyprland-colors.conf b/config/matugen/templates/hyprland-colors.conf
deleted file mode 100644
index 9aeb090..0000000
--- a/config/matugen/templates/hyprland-colors.conf
+++ /dev/null
@@ -1,27 +0,0 @@
-$wallpaper = {{image}}
-$background = {{colors.background.default.hex_stripped}}
-$foreground = {{colors.on_background.default.hex_stripped}}
-
-$primary = {{colors.primary.default.hex_stripped}}
-$secondary = {{colors.secondary.default.hex_stripped}}
-$tertiary = {{colors.tertiary.default.hex_stripped}}
-$surface = {{colors.surface.default.hex_stripped}}
-$surface_bright = {{colors.surface_bright.default.hex_stripped}}
-$outline = {{colors.outline.default.hex_stripped}}
-$error = {{colors.error.default.hex_stripped | set_lightness: -20.0}}
-
-$shadow = {{colors.shadow.default.hex_stripped}}
-
-$red = {{colors.red.default.hex_stripped}}
-$green = {{colors.green.default.hex_stripped}}
-$yellow = {{colors.yellow.default.hex_stripped}}
-$blue = {{colors.blue.default.hex_stripped}}
-$magenta = {{colors.magenta.default.hex_stripped}}
-$cyan = {{colors.cyan.default.hex_stripped}}
-$white = {{colors.white.default.hex_stripped}}
-$red_dim = {{colors.red.default.hex_stripped | set_lightness: -20.0}}
-$green_dim = {{colors.green.default.hex_stripped | set_lightness: -20.0}}
-$yellow_dim = {{colors.yellow.default.hex_stripped | set_lightness: -20.0}}
-$blue_dim = {{colors.blue.default.hex_stripped | set_lightness: -20.0}}
-$magenta_dim = {{colors.magenta.default.hex_stripped | set_lightness: -20.0}}
-$cyan_dim = {{colors.cyan.default.hex_stripped | set_lightness: -20.0}}
diff --git a/config/matugen/templates/macos.css b/config/matugen/templates/macos.css
deleted file mode 100644
index 7d8c607..0000000
--- a/config/matugen/templates/macos.css
+++ /dev/null
@@ -1,32 +0,0 @@
-:vars {
- --foreground: {{colors.on_background.default.hex}};
- --background: {{colors.background.default.hex}};
- --cursor: {{colors.on_background.default.hex}};
- --primary: {{colors.primary.default.hex}};
- --on-primary: {{colors.on_primary.default.hex}};
- --secondary: {{colors.secondary.default.hex}};
- --on-secondary: {{colors.on_secondary.default.hex}};
- --tertiary: {{colors.tertiary.default.hex}};
- --on-tertiary: {{colors.on_tertiary.default.hex}};
- --surface: {{colors.surface.default.hex}};
- --surface-bright: {{colors.surface_bright.default.hex}};
- --error: {{colors.error.default.hex}};
- --error-dim: {{colors.error.default.hex | set_lightness: -10.0}};
- --on-error: {{colors.on_error.default.hex}};
- --error-container: {{colors.error_container.default.hex}};
- --outline: {{colors.outline.default.hex}};
- --shadow: {{colors.shadow.default.hex}};
- --red: {{colors.red.default.hex}};
- --red-dim: {{colors.red.default.hex | set_lightness: -10.0}};
- --green: {{colors.green.default.hex}};
- --green-dim: {{colors.green.default.hex | set_lightness: -10.0}};
- --yellow: {{colors.yellow.default.hex}};
- --yellow-dim: {{colors.yellow.default.hex | set_lightness: -10.0}};
- --blue: {{colors.blue.default.hex}};
- --blue-dim: {{colors.blue.default.hex | set_lightness: -10.0}};
- --magenta: {{colors.magenta.default.hex}};
- --magenta-dim: {{colors.magenta.default.hex | set_lightness: -10.0}};
- --cyan: {{colors.cyan.default.hex}};
- --cyan-dim: {{colors.cyan.default.hex | set_lightness: -10.0}};
- --white: {{colors.white.default.hex}};
-}
diff --git a/debug_memory.py b/debug_memory.py
deleted file mode 100644
index 2e175e7..0000000
--- 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
deleted file mode 100755
index e17294d..0000000
--- a/install.sh
+++ /dev/null
@@ -1,187 +0,0 @@
-#!/bin/bash
-
-# โโโโ โโโโ โโโโโโโ โโโโโโโ โโโ โโโโโโโโโโโ
-# โโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโ
-# โโโโโโโโโโโโโโ โโโโโโ โโโโโโ โโโโโโโโโโโ
-# โโโโโโโโโโโโโโ โโโโโโ โโโโโโ โโโโโโโโโโโ
-# โโโ โโโ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-# โโโ โโโ โโโโโโโ โโโโโโโ โโโโโโโ โโโโโโโโ
-#
-# A hackable shell for Hyprland
-# Installation Script for Arch Linux
-#
-# Repository: https://github.com/S4NKALP/Modus
-# License: GPLv3
-
-set -e # Exit immediately if a command exits with a non-zero status
-set -u # Treat unset variables as an error
-set -o pipefail # Prevent errors in a pipeline from being masked
-
-REPO_URL="https://github.com/S4NKALP/Modus.git"
-INSTALL_DIR="$HOME/.config/Modus1"
-
-PACKAGES=(
- python-fabric-git
- fabric-cli-git
- glace-git
- cliphist
- gnome-bluetooth-3.0
- gobject-introspection
- slurp
- ffmpeg
- hypridle
- hyprsunset
- hyprpicker
- imagemagick
- libnotify
- matugen-bin
- playerctl
- python-fabric-git
- python-gobject
- python-pillow
- python-setproctitle
- python-toml
- python-requests
- python-numpy
- python-pywayland
- python-pyxdg
- python-ijson
- python-watchdog
- python-pyotp
- pyzbar
- python-pywayland
- python-psutil
- python-pydbus
- python-thefuzz
- python-pam
- gtk-session-lock
- swww
- apple-fonts
- swappy
- wl-clipboard
- webp-pixbuf-loader
- wf-recorder
- acpi
- brightnessctl
- power-profiles-daemon
- uwsm
- cinnamon-desktop
-)
-
-# Colors
-GREEN=$(tput setaf 2)
-YELLOW=$(tput setaf 3)
-RED=$(tput setaf 1)
-CYAN=$(tput setaf 6)
-RESET=$(tput sgr0)
-
-# Function for status messages
-step() {
- echo -e "${CYAN}โ${RESET} $1"
-}
-success() {
- echo -e "${GREEN}โ${RESET} $1"
-}
-warn() {
- echo -e "${YELLOW}!${RESET} $1"
-}
-error() {
- echo -e "${RED}โ${RESET} $1"
-}
-
-echo -e "${GREEN}=================================="
-echo " Modus Installer for Arch Linux"
-echo -e "==================================${RESET}"
-
-# OS check
-if ! grep -qi "arch" /etc/os-release; then
- error "This script is designed for Arch Linux or Arch-based distro."
- exit 1
-fi
-
-# Root check
-if [ "$(id -u)" -eq 0 ]; then
- error "Please do not run as root."
- exit 1
-fi
-
-# Warn about sudo
-echo -e "\n${YELLOW}Some packages require root privileges (e.g., glace-git).${RESET}"
-echo "You will be prompted for your sudo password now so the installation runs smoothly."
-sudo -v || {
- error "Sudo authentication failed."
- exit 1
-}
-
-# Keep sudo alive until script finishes
-while true; do
- sudo -n true
- sleep 60
- kill -0 "$$" || exit
-done 2>/dev/null &
-
-# Confirm installation
-echo -e "\nThis will install Modus and the following packages:"
-printf "%s\n" "${PACKAGES[@]}"
-read -rp "$(echo -e "${YELLOW}""Proceed? (y/N): ""${RESET}")" confirm
-if [[ ! "$confirm" =~ ^[Yy]$ ]]; then
- warn "Installation cancelled."
- exit 0
-fi
-
-aur_helper="yay"
-
-# Check if paru exists, otherwise use yay
-if command -v paru &>/dev/null; then
- aur_helper="paru"
-elif ! command -v yay &>/dev/null; then
- echo "Installing yay-bin..."
- tmpdir=$(mktemp -d)
- git clone --depth=1 https://aur.archlinux.org/yay-bin.git "$tmpdir/yay-bin"
- (cd "$tmpdir/yay-bin" && makepkg -si --noconfirm)
- rm -rf "$tmpdir"
-fi
-
-# Clone or update repo
-if [ -d "$INSTALL_DIR" ]; then
- step "Updating Modus repository..."
- git -C "$INSTALL_DIR" pull
-else
- step "Cloning Modus repository..."
- git clone "$REPO_URL" "$INSTALL_DIR"
-fi
-success "Repository ready."
-
-# Install packages
-step "Installing required packages..."
-$aur_helper -Syy --needed --noconfirm "${PACKAGES[@]}" || warn "Some packages failed to install."
-
-# Update outdated packages
-step "Checking for outdated packages..."
-outdated=$($aur_helper -Qu | awk '{print $1}' || true)
-to_update=()
-for pkg in "${PACKAGES[@]}"; do
- if echo "$outdated" | grep -q "^$pkg\$"; then
- to_update+=("$pkg")
- fi
-done
-
-if [ ${#to_update[@]} -gt 0 ]; then
- step "Updating outdated packages..."
- $aur_helper -S --noconfirm "${to_update[@]}"
-else
- success "All packages are up-to-date."
-fi
-
-# Run config
-# step "Running configuration..."
-# python "$INSTALL_DIR/config/config.py"
-
-# Start Modus
-step "Starting Modus..."
-killall modus 2>/dev/null || true
-uwsm app -- python "$INSTALL_DIR/main.py" >/dev/null 2>&1 &
-disown
-success "Modus started successfully."
-
-echo -e "\n${GREEN}Installation complete!${RESET}"
diff --git a/lock.py b/lock.py
deleted file mode 100644
index 86a889c..0000000
--- a/lock.py
+++ /dev/null
@@ -1,234 +0,0 @@
-from widgets.circle_image import CircleImage as Image
-from modules.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
-
-
-class IndicatorBox(Box):
- def __init__(self, *args, **kwargs):
- super().__init__(
- h_align="end",
- name="indicator-box",
- spacing=5,
- h_expand=True,
- children=[
- BatteryIndicator(show_window=False),
- BluetoothIndicator(show_window=False),
- NetworkIndicator(show_window=False),
- ],
- )
-
-
-class ContentBox(CenterBox):
- def __init__(self, on_activate, *args, **kwargs):
- self.password_entry = Entry(
- placeholder="Enter Password",
- name="password-entry",
- h_align="center",
- v_align="center",
- visible=False,
- password=True,
- on_activate=on_activate,
- )
-
- face_icon = os.path.expanduser("~/.face.icon")
- if not os.path.exists(face_icon):
- face_icon = get_relative_path("./assets/default.png")
- self.password_entry.set_property("xalign", 0.5)
- self.username_label = Label(
- label=f"{getpass.getuser().title()}",
- name="username",
- visible=True,
- h_align="center",
- v_align="center",
- )
- self.unlock_text = Label(
- label="Touch ID or Enter Password",
- name="unlock-text",
- )
- super().__init__(
- name="content-box",
- h_expand=True,
- orientation="vertical",
- v_expand=True,
- start_children=[
- IndicatorBox(),
- DateTime(
- formatters=["%A,%B %-d"],
- interval=10000,
- h_expand=False,
- v_align="start",
- v_expand=False,
- name="lock-date",
- ),
- DateTime(formatters=["%I:%M"], name="lock-clock"),
- ],
- end_children=[
- Box(
- name="profile-box",
- h_align="center",
- h_expand=True,
- v_expand=True,
- v_align="end",
- children=[
- Image(
- name="face-icon",
- image_file=face_icon,
- size=64,
- ),
- ],
- ),
- Box(
- name="container-box",
- orientation="v",
- v_align="end",
- h_align="center",
- h_expand=True,
- v_expand=True,
- children=[
- self.username_label,
- self.password_entry,
- ],
- ),
- Box(
- name="unlock-box",
- v_align="end",
- h_align="center",
- h_expand=True,
- v_expand=True,
- children=[
- self.unlock_text,
- ],
- ),
- ],
- **kwargs,
- )
-
-
-class LockScreen(Window):
- def __init__(self, lock: GtkSessionLock.Lock):
- self._hide_timeout_id = None # prevent AttributeError
- self.lock = lock
- self.content = ContentBox(self.on_activate)
- super().__init__(
- title="lock",
- visible=False,
- all_visible=False,
- name="lockscreen-bg",
- anchor="center",
- child=self.content,
- )
-
- self.content.password_entry.set_visible(False)
- self.connect("key-press-event", self._on_keypress)
-
- bg = os.path.expanduser("~/.current.wall")
- if not os.path.exists(bg):
- bg = get_relative_path("./assets/wallpapers_example/example-1.png")
- self.set_style(f"background-image: url('{bg}');")
-
- def _on_keypress(self, widget, event):
- keyval = event.keyval
-
- # ESC pressed โ hide entry immediately
- if keyval == Gdk.KEY_Escape and self.content.password_entry.get_visible():
- self._hide_entry()
- return
-
- # Show entry if hidden
- if not self.content.password_entry.get_visible():
- self.content.username_label.set_visible(False)
- self.content.password_entry.set_visible(True)
- self.content.password_entry.grab_focus()
- self._start_hide_timer()
- else:
- # Reset timer if already visible
- self._restart_hide_timer()
-
- def _start_hide_timer(self):
- self._stop_hide_timer() # just in case
- self._hide_timeout_id = GLib.timeout_add_seconds(5, self._hide_entry)
- # 10 seconds of inactivity before hiding
-
- def _restart_hide_timer(self):
- self._start_hide_timer()
-
- def _stop_hide_timer(self):
- if self._hide_timeout_id:
- GLib.source_remove(self._hide_timeout_id)
- self._hide_timeout_id = None
-
- def _hide_entry(self):
- self._stop_hide_timer()
- self.content.password_entry.set_visible(False)
- self.content.username_label.set_visible(True)
- return False # stop timeout
-
- def on_activate(self, entry: Entry, *args):
- if not pam.authenticate(getpass.getuser(), (entry.get_text() or "").strip()):
- entry.set_text("")
- entry.set_placeholder_text("Wrong Password")
- return
- self.lock.unlock_and_destroy()
- self.destroy()
- GLib.idle_add(app.quit) # schedules quit after unlock
-
-
-def initialize():
- lock = GtkSessionLock.prepare_lock()
- lock.lock_lock()
- lockscreen = LockScreen(lock)
- lock.new_surface(
- lockscreen,
- Gdk.Display.get_default().get_monitor( # pyright: ignore[reportAttributeAccessIssue, reportOptionalMemberAccess]
- 0
- ),
- )
- lockscreen.show()
-
-
-if __name__ == "__main__":
- setproctitle.setproctitle("lockscreen")
- initialize()
- lockscreen = LockScreen(GtkSessionLock.Lock())
-
- app = Application("lock", lockscreen)
-
- def set_css():
- app.set_stylesheet_from_file(
- get_relative_path("main.css"),
- )
-
- app.set_css = set_css # pyright: ignore[reportAttributeAccessIssue]
-
- app.set_css() # pyright: ignore[reportAttributeAccessIssue]
- app.run()
diff --git a/main.css b/main.css
deleted file mode 100644
index 738870f..0000000
--- 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 1ba20cf..0000000
--- a/main.py
+++ /dev/null
@@ -1,88 +0,0 @@
-import gi
-import setproctitle
-from fabric import Application
-from fabric.core import widgets
-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
-
-gi.require_version("Gtk", "3.0")
-gi.require_version("Gdk", "3.0")
-
-# Initialize fontconfig early to prevent warnings
-try:
- import ctypes
-
- libfontconfig = ctypes.CDLL("libfontconfig.so.1")
- libfontconfig.FcInit()
-except:
- pass
-
-
-# from modules.corners import Corners
-
-for log in [
- "fabric.hyprland.widgets",
- "fabric.audio.service",
- "fabric.bluetooth.service",
- "services.network",
- "utils.wayland",
- # "modules.notification.notification",
- # "modules.notification.notification_center",
- # "modules.controlcenter.player",
- # "services.brightness",
- # "modules.controlcenter.expanded_player",
-]:
- 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/about.py b/modules/about.py
deleted file mode 100644
index 46c7f1c..0000000
--- a/modules/about.py
+++ /dev/null
@@ -1,537 +0,0 @@
-import re
-import subprocess
-import os
-
-import gi
-
-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.icon_resolver import IconResolver
-
-
-def read_dmi(field):
- try:
- with open(f"/sys/class/dmi/id/{field}") as f:
- return f.read().strip()
- except Exception:
- return "Unknown"
-
-
-def get_gpu_name():
- try:
- output = (
- subprocess.check_output(
- "lspci -nn | grep -i 'VGA compatible controller'", shell=True, text=True
- )
- .strip()
- .split("\n")
- )
-
- def clean(line):
- matches = re.findall(r"\[(.*?)\]", line)
- if len(matches) >= 2:
- return matches[1].strip()
- desc = line.split(":", 2)[-1]
- return desc.replace("Corporation", "").strip()
-
- for line in output:
- if any(vendor in line.lower() for vendor in ["nvidia", "amd", "radeon"]):
- return clean(line)
-
- if output:
- return clean(output[0])
-
- return "Unknown GPU"
- except Exception:
- return "Unknown GPU"
-
-
-def get_executable_path(exec_string):
- """Extract and find the actual executable path from Exec field"""
- if not exec_string:
- return None
-
- # Remove common exec modifiers and arguments
- exec_parts = exec_string.split()
- if not exec_parts:
- return None
-
- executable = exec_parts[0]
-
- # Remove common prefixes
- prefixes_to_remove = ["env", "bash", "sh", "/usr/bin/env"]
- while executable in prefixes_to_remove and len(exec_parts) > 1:
- exec_parts.pop(0)
- executable = exec_parts[0]
-
- # If it's already an absolute path, check if it exists
- if executable.startswith("/"):
- if os.path.exists(executable):
- return executable
- return None
-
- # Search in PATH
- try:
- result = subprocess.run(["which", executable], capture_output=True, text=True)
- if result.returncode == 0:
- return result.stdout.strip()
- except Exception:
- pass
-
- return None
-
-
-def get_app_info(wmclass):
- """Get comprehensive application information from .desktop file"""
- if not wmclass:
- return {
- "name": "Desktop",
- "version": "",
- "comment": "Desktop Environment",
- "icon": "desktop",
- "exec": "",
- "location": "",
- "categories": "",
- "desktop_file": "",
- }
-
- desktop_paths = [
- "/usr/share/applications",
- "/var/lib/flatpak/exports/share/applications",
- os.path.expanduser("~/.local/share/applications"),
- "/usr/local/share/applications",
- ]
-
- 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)
- 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
-
- key, value = line.split("=", 1)
- if key == "Name":
- name = value
- elif key == "Version":
- version = value
- elif key == "Comment":
- comment = value
- elif key == "GenericName" and not comment:
- # Use GenericName as fallback description
- comment = value
- elif key == "Icon":
- icon = value
- elif key == "Exec":
- exec_cmd = value
- 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
-
- # Fallback: try to find executable in PATH
- location = ""
- try:
- result = subprocess.run(
- ["which", wmclass.lower()], capture_output=True, text=True
- )
- if result.returncode == 0:
- location = result.stdout.strip()
- except Exception:
- pass
-
- return {
- "name": wmclass.title() if wmclass else "Unknown Application",
- "version": "",
- "comment": "",
- "icon": wmclass.lower() if wmclass else "application-x-executable",
- "exec": "",
- "location": location,
- "categories": "",
- "desktop_file": "",
- }
-
-
-class AboutApp(Gtk.Window):
- def __init__(self, app_name="Unknown Application", wmclass=""):
- super().__init__(title=f"About {app_name}")
- self.app_name = app_name
- self.wmclass = wmclass
- self.icon_resolver = IconResolver()
-
- self.set_default_size(300, 500)
- self.set_size_request(300, 480)
- self.set_resizable(False)
- self.set_title(f"About {app_name}")
- self.set_name("about-app")
- self.set_visible(False)
-
- self.setup_ui()
-
- def setup_ui(self):
- app_info = get_app_info(self.wmclass)
-
- main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
- main_box.set_margin_top(20)
- main_box.set_margin_bottom(20)
- main_box.set_margin_start(15)
- main_box.set_margin_end(15)
-
- # App Icon
- logo_box = Gtk.Box(halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER)
- try:
- # Use IconResolver's get_icon_pixbuf method like other parts of the project
- 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
- try:
- if app_info["icon"].startswith("/") and os.path.exists(
- app_info["icon"]
- ):
- pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
- app_info["icon"], 128, 128, preserve_aspect_ratio=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("๐ฑ ")
-
- logo_box.pack_start(logo, False, False, 0)
- logo_box.set_margin_bottom(15)
-
- # App Name
- app_name_label = Gtk.Label()
- app_name_label.set_markup(
- f"{app_info['name']} "
- )
- app_name_label.set_halign(Gtk.Align.CENTER)
- app_name_label.set_margin_bottom(5)
-
- # Version (if available)
- if app_info["version"]:
- version_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
- version_label = Gtk.Label(
- label=f"Version {app_info.get('version', 'Unknown')}"
- )
- version_label.set_name("version-label")
- version_label.set_halign(Gtk.Align.CENTER)
- version_box.pack_start(version_label, False, False, 0)
- version_box.set_margin_bottom(5)
-
- # Description/Comment - Make it more prominent
- description_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
- if app_info["comment"]:
- # Create a frame for the description to make it stand out
- desc_frame = Gtk.Frame()
- desc_frame.set_shadow_type(Gtk.ShadowType.IN)
-
- description_label = Gtk.Label()
- description_label.set_markup(f"{app_info['comment']} ")
- description_label.set_justify(Gtk.Justification.CENTER)
- description_label.set_halign(Gtk.Align.CENTER)
- description_label.set_line_wrap(True)
- description_label.set_max_width_chars(45)
- description_label.set_margin_top(8)
- description_label.set_margin_bottom(8)
- description_label.set_margin_start(12)
- description_label.set_margin_end(12)
-
- desc_frame.add(description_label)
- description_box.pack_start(desc_frame, False, False, 0)
- else:
- # Show a placeholder if no description is available
- placeholder_label = Gtk.Label()
- placeholder_label.set_markup(
- "No description available "
- )
- placeholder_label.set_halign(Gtk.Align.CENTER)
- description_box.pack_start(placeholder_label, False, False, 0)
-
- description_box.set_margin_bottom(15)
-
- # Information Grid
- # Create a label with markup
- info_frame = Gtk.Frame()
- label = Gtk.Label()
- label.set_markup("Application Information ")
- label.set_margin_bottom(5)
- info_frame.set_label_widget(label)
-
- info_frame.set_label_align(0.5, 0.5)
-
- info_grid = Gtk.Grid()
- info_grid.set_row_spacing(8)
- info_grid.set_column_spacing(15)
- info_grid.set_margin_top(10)
- info_grid.set_margin_bottom(10)
- info_grid.set_margin_start(15)
- info_grid.set_margin_end(15)
- info_grid.set_valign(Gtk.Align.CENTER)
- info_grid.set_halign(Gtk.Align.CENTER)
-
- def make_info_row(label_text, value_text, row):
- """Create a row in the info grid"""
- label = Gtk.Label(label=f"{label_text}:")
- label.set_halign(Gtk.Align.END)
- label.set_markup(f"{label_text}: ")
-
- value = Gtk.Label()
- value.set_markup(f'{value_text} ')
- value.set_halign(Gtk.Align.START)
- value.set_line_wrap(True)
- value.set_max_width_chars(30)
- value.set_name("info-value-label")
-
- info_grid.attach(label, 0, row, 1, 1)
- info_grid.attach(value, 1, row, 1, 1)
-
- row = 0
-
- # Application Name
- make_info_row("Name", app_info["name"], row)
- row += 1
-
- # Version
- if app_info["version"]:
- make_info_row("Version", app_info["version"], row)
- row += 1
-
- # Executable Location
- if app_info["location"]:
- make_info_row("Location", app_info["location"], row)
- row += 1
-
- # Window Class
- if self.wmclass:
- make_info_row("Window Class", self.wmclass, row)
- row += 1
-
- # Categories
- if app_info["categories"]:
- categories = app_info["categories"].replace(";", ", ").strip(", ")
- make_info_row("Categories", categories, row)
- row += 1
-
- # Desktop File
- if app_info["desktop_file"]:
- desktop_file_name = os.path.basename(app_info["desktop_file"])
- make_info_row("Desktop File", desktop_file_name, row)
- row += 1
-
- info_frame.add(info_grid)
-
- # Layout
- main_box.pack_start(logo_box, False, False, 0)
- main_box.pack_start(app_name_label, False, False, 0)
- if app_info["version"]:
- main_box.pack_start(version_box, False, False, 0)
- main_box.pack_start(description_box, False, False, 0)
- main_box.pack_start(info_frame, False, False, 0)
-
- self.add(main_box)
-
- def toggle(self, b):
- if self.get_visible():
- self.hide()
- else:
- self.show_all()
-
-
-class About(Gtk.Window):
- def __init__(self):
- super().__init__(title="About Menu")
- self.set_default_size(300, 550)
- self.set_size_request(300, 500)
- self.set_resizable(False)
- self.set_title("About PC")
- self.set_name("about-menu")
- self.set_visible(False)
-
- main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
- main_box.set_margin_top(10)
- main_box.set_margin_bottom(20)
- main_box.set_margin_start(20)
- main_box.set_margin_end(20)
-
- # 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"),
- 158,
- 108,
- preserve_aspect_ratio=True,
- )
- logo = Gtk.Image.new_from_pixbuf(pixbuf)
- logo_box.pack_start(logo, False, False, 0)
- logo_box.set_margin_top(60)
-
- # Product Name & Vendor
- name_label = Gtk.Label()
- name_label.set_margin_top(30)
- name_label.set_markup(
- f"{read_dmi('product_name')} "
- )
-
- vendor_label = Gtk.Label(label=read_dmi("sys_vendor"))
- vendor_label.set_name("vendor-label")
- vendor_label.set_halign(Gtk.Align.CENTER)
-
- # Info Grid
- info_grid = Gtk.Grid()
- info_grid.set_row_spacing(6)
- info_grid.set_column_spacing(10)
- info_grid.set_valign(Gtk.Align.CENTER)
- info_grid.set_halign(Gtk.Align.FILL)
-
- def make_label(text, align_end=False, name=None):
- label = Gtk.Label(label=text)
- label.set_halign(Gtk.Align.END if align_end else Gtk.Align.START)
- if name:
- label.set_name(name)
- return label
-
- # Info values
- labels = [
- (
- "Kernel",
- subprocess.run(
- "uname -r", shell=True, capture_output=True, text=True
- ).stdout.strip(),
- ),
- (
- "CPU",
- subprocess.run(
- "lscpu | grep 'Model name:' | cut -d ':' -f2-",
- shell=True,
- capture_output=True,
- text=True,
- ).stdout.strip(),
- ),
- (
- "Memory",
- subprocess.run(
- "free -h --giga | grep Mem | tr -s ' ' | cut -d ' ' -f 2",
- shell=True,
- capture_output=True,
- text=True,
- ).stdout.strip(),
- ),
- ("GPU", get_gpu_name()),
- (
- "Uptime",
- subprocess.run(
- "uptime -p", shell=True, capture_output=True, text=True
- ).stdout.strip(),
- ),
- ]
-
- for i, (title, value) in enumerate(labels):
- title_label = make_label(title, align_end=True)
- value_label = make_label(value, align_end=False, name="info-label")
- info_grid.attach(title_label, 0, i, 1, 1)
- info_grid.attach(value_label, 1, i, 1, 1)
-
- # Button
- 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")
- more_info_button.connect("clicked", self.open_more_info)
- button_box.pack_start(more_info_button, False, False, 0)
-
- # Info Footer
- info = Gtk.Label(
- label="โข and ยฉ 2025 Linux Inc.\nAll rights reserved.\n\n",
- justify=Gtk.Justification.CENTER,
- halign=Gtk.Align.CENTER,
- )
- info.set_name("info-label")
- info.set_margin_top(10)
-
- # Layout Order
- main_box.pack_start(logo_box, False, False, 0)
- main_box.pack_start(name_label, False, False, 0)
- main_box.pack_start(vendor_label, False, False, 0)
- main_box.pack_start(info_grid, False, False, 0)
- main_box.pack_start(button_box, False, False, 0)
- main_box.pack_start(info, False, False, 0)
-
- self.add(main_box)
-
- def open_more_info(self, button):
- # TODO: Implement the logic to open more information
- pass
-
- def toggle(self, b):
- if self.get_visible():
- self.hide()
- else:
- self.show_all()
diff --git a/modules/controlcenter/battery.py b/modules/controlcenter/battery.py
deleted file mode 100644
index 068de6e..0000000
--- a/modules/controlcenter/battery.py
+++ /dev/null
@@ -1,393 +0,0 @@
-import subprocess
-
-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.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.battery import Battery
-
-
-class EnergyModeButton(Box):
- def __init__(
- self,
- profile_name: str,
- display_name: str,
- icon_name: str,
- battery_service: Battery,
- parent,
- **kwargs,
- ):
- super().__init__(name="energy-mode-button", **kwargs)
- self.profile_name = profile_name
- 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 = Box(
- children=[self.mode_icon_svg],
- name="energy-mode-icon",
- style_classes="battery-profile-icon",
- )
-
- self.mode_label = Label(
- label=display_name,
- style_classes="battery-power-mode",
- h_align="start",
- h_expand=True,
- )
-
- start_box = Box(
- orientation="horizontal",
- spacing=4,
- children=[self.mode_icon, self.mode_label],
- )
-
- self.button = Button(
- child=start_box,
- h_expand=True,
- name="energy-mode-button-clickable",
- on_clicked=self.on_clicked,
- style_classes="battery-profile-button",
- )
-
- self.children = [self.button]
- self.update_state()
-
- def on_clicked(self, *args):
- success = self.battery_service.change_power_profile(self.profile_name)
- if success:
- # Update all profile buttons in parent
- self.parent.update_energy_mode_buttons()
-
- # Reset icon state after short delay
- GLib.timeout_add(300, lambda: self._reset_icon_state())
-
- def _reset_icon_state(self):
- return False # Remove timeout
-
- def update_state(self):
- is_active = self.battery_service.power_profile == self.profile_name
- if is_active:
- self.mode_icon.add_style_class("connected")
- else:
- self.mode_icon.remove_style_class("connected")
-
-
-class GameModeButton(Box):
- def __init__(self, parent, **kwargs):
- super().__init__(name="energy-mode-button", h_expand=True, **kwargs)
- self.parent = parent
-
- self.game_icon = Image(
- icon_name="applications-games-symbolic",
- size=16,
- name="game-mode-icon",
- style_classes="battery-gamemode-icon",
- )
-
- self.game_label = Label(
- label="Game Mode",
- style_classes="gamemode-button",
- h_align="start",
- h_expand=True,
- )
-
- start_box = Box(
- orientation="horizontal",
- spacing=3,
- children=[self.game_icon, self.game_label],
- )
-
- self.button = Button(
- child=start_box,
- name="game-mode-button-clickable",
- on_clicked=self.on_clicked,
- h_expand=True,
- style_classes="battery-gamemode-button",
- )
-
- self.children = [self.button]
- self.update_state()
-
- def on_clicked(self, *args):
- try:
- script_path = get_relative_path("../../scripts/gamemode.sh")
- subprocess.run([script_path], check=False)
-
- GLib.timeout_add(500, lambda: self.update_state())
- except Exception as e:
- print(f"Failed to toggle game mode: {e}")
-
- GLib.timeout_add(300, lambda: self._reset_icon_state())
-
- def _reset_icon_state(self):
- return False # Remove timeout
-
- 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"
-
- 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
-
-
-class BatteryControl(Box):
- def __init__(self, parent, **kwargs):
- super().__init__(
- spacing=12,
- orientation="vertical",
- name="control-center-widgets",
- **kwargs,
- )
- self.set_size_request(354, -1)
-
- self.parent = parent
- self.battery_service = Battery()
- self.energy_mode_buttons = []
-
- self.battery_widget = Box(
- name="battery-widget",
- orientation="vertical",
- style_classes="battery-status-section",
- h_expand=True,
- spacing=8,
- )
-
- self.battery_title = Label(
- label="Battery", style_classes="battery-main-title", h_align="start"
- )
-
- self.battery_percentage_label = Label(
- label="80%", style_classes="battery-percentage", h_align="end"
- )
-
- self.battery_header = CenterBox(
- start_children=self.battery_title,
- end_children=self.battery_percentage_label,
- name="battery-header",
- )
-
- self.power_source_label = Label(
- label="Power Source: Power Adapter",
- style_classes="battery-power-source",
- h_align="start",
- )
-
- self.charging_time_label = Label(
- label="1h 4m until fully charged",
- style_classes="battery-power-source",
- h_align="start",
- )
-
- self.energy_mode_section = Box(
- orientation="vertical", spacing=8, name="energy-mode-section"
- )
-
- self.energy_mode_title = Label(
- label="Energy Mode", style_classes="battery-section-title", h_align="start"
- )
-
- self.energy_modes_container = Box(
- orientation="vertical", spacing=4, name="energy-modes-container"
- )
-
- self.game_mode_section = Box(
- orientation="vertical", spacing=8, name="game-mode-section"
- )
-
- self.game_mode_title = Label(
- label="Game Mode", style_classes="battery-section-title", h_align="start"
- )
-
- self.game_mode_container = Box(
- orientation="vertical", spacing=4, name="game-mode-container"
- )
-
- self.battery_settings_button = Button(
- v_align="center",
- child=Label(
- label="Battery Settings",
- h_align="start",
- ),
- style_classes="battery-settings-button",
- on_clicked=self.open_battery_settings,
- )
-
- self.battery_widget.add(self.battery_header)
- self.battery_widget.add(self.power_source_label)
- self.battery_widget.add(self.charging_time_label)
-
- separator1 = Separator(orientation="h", name="separator")
- self.battery_widget.add(separator1)
-
- self.energy_mode_section.add(self.energy_mode_title)
- self.energy_mode_section.add(self.energy_modes_container)
- self.battery_widget.add(self.energy_mode_section)
-
- separator2 = Separator(orientation="h", name="separator")
- self.battery_widget.add(separator2)
-
- self.game_mode_section.add(self.game_mode_title)
- self.game_mode_section.add(self.game_mode_container)
- self.battery_widget.add(self.game_mode_section)
-
- separator3 = Separator(orientation="h", name="separator")
- self.battery_widget.add(separator3)
-
- self.battery_widget.add(self.battery_settings_button)
-
- self.add(self.battery_widget)
-
- self.battery_service.connect("changed", self.on_battery_changed)
- self.battery_service.connect("profile_changed", self.on_profile_changed)
-
- # Initialize display
- self.update_battery_info()
- self.create_energy_mode_buttons()
- self.create_game_mode_button()
-
- def open_battery_settings(self, *args):
- # TODO: Implement to open Battery Settings
- pass
-
- def create_energy_mode_buttons(self):
- # Clear existing buttons
- for button in self.energy_mode_buttons:
- button.destroy()
- self.energy_mode_buttons.clear()
-
- # Get available profiles
- available_profiles = self.battery_service.available_profiles
-
- if not available_profiles:
- no_profiles_label = Label(
- label="No energy modes available",
- style_classes="battery-no-profiles",
- h_align="start",
- )
- self.energy_modes_container.add(no_profiles_label)
- return
-
- # Define energy mode mappings with proper icon names
- energy_mode_config = {
- "balanced": {"display": "Automatic", "icon": "balanced"},
- "power-saver": {"display": "Low Power", "icon": "power"},
- "powersave": {"display": "Low Power", "icon": "power"},
- "performance": {"display": "High Power", "icon": "performance"},
- }
-
- # Define the desired order for energy modes
- desired_order = ["balanced", "power-saver", "powersave", "performance"]
-
- # Create ordered list of available profiles
- ordered_profiles = []
- for profile_name in desired_order:
- if profile_name in available_profiles:
- ordered_profiles.append(profile_name)
-
- # Add any remaining profiles not in the desired order
- for profile in available_profiles:
- if profile not in ordered_profiles:
- ordered_profiles.append(profile)
-
- # Create button for each available profile in the specified order
- for profile in ordered_profiles:
- config = energy_mode_config.get(
- profile, {"display": profile.title(), "icon": "good"}
- )
-
- button = EnergyModeButton(
- profile_name=profile,
- display_name=config["display"],
- icon_name=config["icon"],
- battery_service=self.battery_service,
- parent=self,
- )
- self.energy_mode_buttons.append(button)
- self.energy_modes_container.add(button)
-
- def update_energy_mode_buttons(self):
- for button in self.energy_mode_buttons:
- button.update_state()
-
- 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()
-
- # Create game mode button
- self.game_mode_button = GameModeButton(parent=self)
- self.game_mode_container.add(self.game_mode_button)
-
- def update_battery_info(self):
- if not self.battery_service.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
- self.battery_percentage_label.set_label(f"{percentage}%")
-
- # Update power source and charging info
- state = self.battery_service.state
-
- 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
- if time_to_full != "N/A" and time_to_full != "0m":
- self.charging_time_label.set_label(
- f"{time_to_full} until fully charged"
- )
- else:
- self.charging_time_label.set_label("Charging...")
- elif state == "FULLY_CHARGED":
- self.power_source_label.set_label("Power Source: Power Adapter")
- 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
- if time_to_empty != "N/A" and not time_to_empty.startswith(
- "4553h"
- ): # Filter out unrealistic times
- self.charging_time_label.set_label(f"{time_to_empty} remaining")
- else:
- self.charging_time_label.set_label("On Battery Power")
- elif state == "EMPTY":
- self.power_source_label.set_label("Power Source: Battery")
- self.charging_time_label.set_label("Battery Empty")
- else:
- self.power_source_label.set_label("Power Source: Unknown")
- self.charging_time_label.set_label("")
-
- def on_battery_changed(self, *args):
- self.update_battery_info()
-
- def on_profile_changed(self, service, new_profile):
- self.update_energy_mode_buttons()
diff --git a/modules/controlcenter/bluetooth.py b/modules/controlcenter/bluetooth.py
deleted file mode 100644
index 08346aa..0000000
--- a/modules/controlcenter/bluetooth.py
+++ /dev/null
@@ -1,613 +0,0 @@
-import subprocess
-
-import gi
-from fabric.bluetooth import BluetoothClient, BluetoothDevice
-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.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 fabric.widgets.svg import Svg
-from gi.repository import Gdk, GLib, Gtk
-from loguru import logger
-
-from services.battery import Battery
-
-gi.require_version("Gtk", "3.0")
-gi.require_version("Gdk", "3.0")
-
-
-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"]
-
- 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}")
-
-
-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.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(
- 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",
- )
-
- # 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 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, *_):
- 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(
- 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",
- )
-
- # 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.set_from_file(
- get_relative_path(
- Battery.get_battery_icon_file(
- self.device.battery_percentage,
- False, # Not charging for bluetooth devices
- "../../config/assets/icons/",
- )
- )
- )
-
- # 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 = 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
- self.client.connect(
- "notify::enabled",
- lambda *_: self.toggle_button.set_active(self.client.enabled),
- )
- 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)
-
- # Connect to additional signals for better real-time monitoring
- 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"""
- # 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
- ):
- 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
-
- 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
- self.stop_device_monitoring()
- # Make sure other devices revealer is collapsed when closing
- try:
- self.other_devices_revealer.child_revealed = False
- except:
- pass # Widget might already be destroyed
-
- 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/expanded_player.py b/modules/controlcenter/expanded_player.py
deleted file mode 100644
index 99d0ad4..0000000
--- a/modules/controlcenter/expanded_player.py
+++ /dev/null
@@ -1,1398 +0,0 @@
-# Standard library imports
-import os
-import re
-import tempfile
-import urllib.parse
-import urllib.request
-import threading
-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 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.image import Image
-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 loguru import logger
-
-# Local imports
-from services.mpris import MprisPlayer, MprisPlayerManager
-import config.data as data
-
-CACHE_DIR = f"{data.CACHE_DIR}/media"
-
-# Global memory management
-_shared_mpris_manager = None
-_widget_cache = weakref.WeakValueDictionary()
-_artwork_cache = {}
-_max_artwork_cache_size = 10 # Limit artwork cache to prevent memory bloat
-
-
-def get_shared_mpris_manager():
- """Get shared MPRIS manager instance to reduce memory usage."""
- global _shared_mpris_manager
- if _shared_mpris_manager is None:
- _shared_mpris_manager = MprisPlayerManager()
- return _shared_mpris_manager
-
-
-def cleanup_artwork_cache():
- """Clean up artwork cache to prevent memory leaks."""
- global _artwork_cache
- if len(_artwork_cache) > _max_artwork_cache_size:
- # Keep only the most recent items
- items = list(_artwork_cache.items())
- _artwork_cache = dict(items[-_max_artwork_cache_size:])
- # Force garbage collection
- gc.collect()
-
-
-def cleanup_old_cache_files():
- """Clean up old artwork cache files aggressively to save memory."""
- try:
- if not os.path.exists(CACHE_DIR):
- return
-
- import time
-
- current_time = time.time()
- # Clean files older than 2 hours (more aggressive)
- two_hours_ago = current_time - (2 * 60 * 60)
-
- for filename in os.listdir(CACHE_DIR):
- filepath = os.path.join(CACHE_DIR, filename)
- try:
- if os.path.isfile(filepath):
- file_mod_time = os.path.getmtime(filepath)
- if file_mod_time < two_hours_ago:
- os.remove(filepath)
- logger.debug(f"Cleaned up old cache file: {filename}")
- except Exception as e:
- logger.warning(f"Failed to clean up cache file {filename}: {e}")
-
- except Exception as e:
- logger.error(f"Error during cache cleanup: {e}")
-
-
-def get_artwork_cached(url: str) -> Optional[str]:
- """Get artwork with memory-efficient caching."""
- if not url:
- return None
-
- # Check memory cache first
- if url in _artwork_cache:
- return _artwork_cache[url]
-
- # Clean cache if too large
- cleanup_artwork_cache()
-
- try:
- # Create cache directory if it doesn't exist
- os.makedirs(CACHE_DIR, exist_ok=True)
-
- # Generate cache filename
- safe_filename = re.sub(
- r"[^\w\-_.]", "_", urllib.parse.urlparse(url).path.split("/")[-1]
- )
- if not safe_filename:
- safe_filename = f"artwork_{hash(url)}"
-
- cache_file = os.path.join(CACHE_DIR, safe_filename)
-
- # Check if cached file exists and is recent (within 2 hours)
- if os.path.exists(cache_file):
- import time
-
- if time.time() - os.path.getmtime(cache_file) < 7200: # 2 hours
- _artwork_cache[url] = cache_file
- return cache_file
-
- # Download and cache
- urllib.request.urlretrieve(url, cache_file)
- _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."""
-
- def __init__(self, control_center, **kwargs):
- super().__init__(
- orientation="vertical",
- h_expand=True,
- name="embedded-expanded-player",
- **kwargs,
- )
-
- self.control_center = control_center
- self.mpris_manager = get_shared_mpris_manager()
-
- # Create back button (hidden in header)
- self.back_button = Button(
- name="back-button",
- child=Label(label="โ Back"),
- on_clicked=self._on_back_clicked,
- )
-
- # 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",
- h_expand=True,
- style_classes="menu",
- visible=False, # Hide header to remove title and back button
- children=[
- self.back_button,
- Box(h_expand=True), # Spacer
- Label(label="Now Playing", style_classes="title"),
- Box(h_expand=True), # Spacer
- ],
- ),
- self.player_content,
- ]
-
- def _on_back_clicked(self, *_):
- """Handle back button click"""
- if self.control_center and hasattr(
- self.control_center, "close_expanded_player"
- ):
- self.control_center.close_expanded_player()
-
- def refresh(self):
- """Refresh the player content"""
- # This will automatically update as MPRIS players change
- pass
-
- def destroy(self):
- """Clean up resources and prevent memory leaks"""
- logger.debug("๐๏ธ EmbeddedExpandedPlayer cleanup starting")
-
- try:
- # Destroy player content (PlayerBoxStack)
- if hasattr(self, "player_content") and hasattr(
- self.player_content, "destroy"
- ):
- self.player_content.destroy()
-
- # Clean up back button
- if hasattr(self, "back_button") and hasattr(self.back_button, "destroy"):
- self.back_button.destroy()
-
- # Clean up any other widgets we might have
- for child in list(self.get_children()):
- try:
- child.destroy()
- 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()
- _artwork_cache.clear()
- logger.debug("๐๏ธ Cleared global widget and artwork caches")
-
- # Clear references
- if hasattr(self, "control_center"):
- self.control_center = None
- if hasattr(self, "mpris_manager"):
- self.mpris_manager = None
-
- # Force garbage collection
- gc.collect()
- logger.debug("๐๏ธ EmbeddedExpandedPlayer cleanup completed")
-
- except Exception as e:
- logger.error(f"Error during EmbeddedExpandedPlayer cleanup: {e}")
- 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
- cleanup_old_cache_files()
-
- # The player stack with memory-efficient settings
- self.player_stack = Stack(
- name="player-stack",
- # Disable transitions to reduce memory usage
- transition_type="none",
- )
- self.current_stack_pos = 0
-
- # Store player buttons - cleaned up via explicit removal
- self.player_buttons: list[Button] = []
- self._player_widgets: Dict[str, Box] = weakref.WeakValueDictionary()
-
- # Track signal connections for cleanup using weak references
- self._signal_connections = []
-
- # Create a lightweight "No media playing" placeholder
- self.no_media_box = self._create_no_media_box()
-
- super().__init__(orientation="v", name="media", children=[self.player_stack])
-
- # Show the no media box initially
- self.player_stack.children = [self.no_media_box]
- self.set_visible(True)
-
- self.mpris_manager = mpris_manager
-
- # Track connections for cleanup
- connections = bulk_connect(
- self.mpris_manager,
- {
- "player-appeared": self.on_new_player,
- "player-vanished": self.on_lost_player,
- },
- )
- for handler_id in connections:
- 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')}"
- )
- 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
-
- 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
-
- # Disconnect all signal connections
- for obj, handler_id in self._signal_connections:
- try:
- if obj and hasattr(obj, "disconnect"):
- obj.disconnect(handler_id)
- except Exception as e:
- logger.warning(f"Failed to disconnect signal: {e}")
- self._signal_connections.clear()
-
- # Clean up player widgets
- for widget in list(self._player_widgets.values()):
- try:
- if widget and hasattr(widget, "destroy"):
- widget.destroy()
- except Exception:
- pass
- self._player_widgets.clear()
-
- # Clean up player buttons explicitly
- for button in list(self.player_buttons):
- try:
- if button and hasattr(button, "destroy"):
- button.destroy()
- except Exception:
- pass
-
- # Clean up stack children
- for child in self.player_stack.get_children():
- if hasattr(child, "destroy") and child != self.no_media_box:
- try:
- child.destroy()
- except Exception:
- pass
-
- # Force final garbage collection
- gc.collect()
-
- except Exception as e:
- logger.error(f"Error during PlayerBoxStack cleanup: {e}")
- finally:
- super().destroy()
-
- 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"
-
- # Album cover with fallback image using Image widget
-
- album_cover = Box(
- name="macos-album-image-no",
- )
- album_cover.set_style(f"background-image:url('{fallback_cover_path}')")
-
- image_stack = Box(h_align="start", v_align="center", name="player-image-stack")
- image_stack.children = [album_cover]
-
- # Track info showing "No media playing"
- track_title = Label(
- label="No media playing",
- name="player-title-no",
- justification="left",
- max_chars_width=30,
- ellipsization="end",
- h_align="start",
- )
-
- track_artist = Label(
- label="",
- name="player-artist",
- justification="left",
- max_chars_width=12,
- ellipsization="end",
- h_align="start",
- visible=False, # Hide artist and album when no media
- )
-
- track_album = Label(
- label="",
- name="player-album",
- justification="left",
- max_chars_width=12,
- ellipsization="end",
- h_align="start",
- visible=False, # Hide artist and album when no media
- )
-
- track_info = Box(
- name="track-info",
- spacing=5,
- orientation="v",
- v_align="start",
- h_align="start",
- children=[track_title, track_artist, track_album],
- )
-
- # No control buttons for no media state - just an empty box
- controls_box = Box(
- name="player-controls",
- visible=False, # Hide controls when no media
- )
-
- player_info_box = Box(
- name="player-info-box",
- v_align="center",
- h_align="start",
- orientation="v",
- h_expand=True,
- children=[track_info, controls_box],
- )
-
- inner_box = Box(
- name="inner-player-box",
- v_align="center",
- h_align="start",
- )
-
- outer_box = Box(
- name="outer-player-box",
- h_align="start",
- )
-
- overlay_box = Overlay(
- child=outer_box,
- overlays=[
- inner_box,
- player_info_box,
- image_stack,
- ],
- )
-
- no_media_box = Box(
- h_align="center",
- name="player-box",
- h_expand=True,
- children=[overlay_box],
- )
-
- return no_media_box
-
- def on_player_clicked(self, type):
- # unset active from prev active button
- if self.player_buttons and self.current_stack_pos < len(self.player_buttons):
- self.player_buttons[self.current_stack_pos].remove_style_class("active")
-
- if type == "next":
- self.current_stack_pos = (
- self.current_stack_pos + 1
- if self.current_stack_pos != len(self.player_stack.get_children()) - 1
- else 0
- )
- elif type == "prev":
- self.current_stack_pos = (
- self.current_stack_pos - 1
- if self.current_stack_pos != 0
- else len(self.player_stack.get_children()) - 1
- )
-
- # set new active button
- if self.player_buttons and self.current_stack_pos < len(self.player_buttons):
- 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],
- )
-
- def on_player_clicked_by_index(self, index):
- """Switch to player at given index"""
- if 0 <= index < len(self.player_buttons):
- # unset active from prev active button
- if self.player_buttons and self.current_stack_pos < len(
- self.player_buttons
- ):
- self.player_buttons[self.current_stack_pos].remove_style_class("active")
- # set new position
- self.current_stack_pos = index
- # set new active button
- if self.player_buttons and self.current_stack_pos < len(
- self.player_buttons
- ):
- 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],
- )
- # Update all player boxes with new button state
- self._update_all_player_buttons()
-
- def on_new_player(self, mpris_manager, player):
- # if player_name in self.config.get("ignore", []):
- # return
-
- # Remove the no media box if it's the only child
- if (
- len(self.player_stack.get_children()) == 1
- and self.player_stack.get_children()[0] == self.no_media_box
- ):
- self.player_stack.children = []
- self.current_stack_pos = 0
-
- self.set_visible(True)
-
- new_player_box = PlayerBox(player=MprisPlayer(player), player_stack=self)
- self.player_stack.children = [
- *self.player_stack.children,
- new_player_box,
- ]
-
- 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"])
-
- # Update all player boxes with current button state
- self._update_all_player_buttons()
-
- def on_lost_player(self, mpris_manager, player_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
- player_box_to_remove = None
- for player_box in players:
- if (
- hasattr(player_box, "player")
- and player_box.player.player_name == player_name
- ):
- player_box_to_remove = player_box
- break
-
- if player_box_to_remove:
- try:
- player_box_to_remove.destroy()
- except Exception as e:
- logger.warning(f"Failed to destroy player box: {e}")
-
- # Check if this was the last player
- remaining_players = [
- p for p in self.player_stack.get_children() if p != player_box_to_remove
- ]
- if len(remaining_players) == 0:
- # Show the no media box instead of hiding
- self.player_stack.children = [self.no_media_box]
- self.current_stack_pos = 0
- self.player_buttons = [] # Clear player buttons
- return
-
- # Adjust current position if needed
- if self.current_stack_pos >= len(self.player_stack.get_children()):
- self.current_stack_pos = max(0, len(self.player_stack.get_children()) - 1)
-
- # Set active button if we have buttons and a valid position
- if self.player_buttons and self.current_stack_pos < len(self.player_buttons):
- self.player_buttons[self.current_stack_pos].set_style_classes(["active"])
- if self.player_stack.get_children():
- self.player_stack.set_visible_child(
- self.player_stack.get_children()[self.current_stack_pos],
- )
-
- # Update all player boxes with current button state
- self._update_all_player_buttons()
-
- def make_new_player_button(self, player_box):
- new_button = Button(name="player-stack-button")
-
- def on_player_button_click(button: Button):
- if self.player_buttons and self.current_stack_pos < len(
- self.player_buttons
- ):
- self.player_buttons[self.current_stack_pos].remove_style_class("active")
- if button in self.player_buttons:
- self.current_stack_pos = self.player_buttons.index(button)
- button.add_style_class("active")
- self.player_stack.set_visible_child(player_box)
-
- new_button.connect(
- "clicked",
- on_player_button_click,
- )
- self.player_buttons.append(new_button)
-
- # This will automatically destroy our used button
- def cleanup_button(*_):
- try:
- if new_button in self.player_buttons:
- self.player_buttons.remove(new_button)
- new_button.destroy()
- except Exception as e:
- logger.warning(f"Failed to cleanup button: {e}")
-
- player_box.connect("destroy", 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)
- else:
- logger.warning(
- "[PlayerBoxStack] PlayerBox missing update_buttons method"
- )
-
-
-class PlayerBox(Box):
- """A widget that displays the current player information."""
-
- def __init__(self, player: MprisPlayer, player_stack=None, **kwargs):
- super().__init__(
- h_align="center",
- name="player-box",
- **kwargs,
- h_expand=True,
- )
- # Setup
- self.player: MprisPlayer = player
- self.player_stack = player_stack
- self.fallback_cover_path = f"{data.HOME_DIR}/.current.wall"
-
- self.icon_size = 15
-
- # State
- self.exit = False
- self.skipped = False
- self._user_seeking = False # Flag to prevent choppy seeking
- self._seekbar_timer_id = None # Track timer ID for cleanup
-
- # 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
-
- # Use same CSS background approach as small player for consistency
- self.album_cover = Box(
- name="macos-album-image",
- )
- self.album_cover.set_style(
- f"background-image:url('{self.fallback_cover_path}')"
- )
- self.album_cover.set_size_request(70, 70)
-
- 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]
-
- # Track Info
- self.track_title = Label(
- label="No Title",
- name="macos-player-title",
- justification="left",
- max_chars_width=30,
- ellipsization="end",
- h_align="start",
- h_expand=True,
- )
-
- self.track_artist = Label(
- label="No Artist",
- name="macos-player-artist",
- justification="left",
- max_chars_width=25,
- ellipsization="end",
- h_align="start",
- h_expand=True,
- visible=True,
- )
- self.track_album = Label(
- label="No Album",
- name="macos-player-album",
- justification="left",
- max_chars_width=25,
- ellipsization="end",
- h_align="start",
- visible=True, # Hide artist and album when no media
- )
-
- self.app_icon = Box(
- children=Image(
- icon_name=self.player.player_name, name="player-app-icon", icon_size=20
- ),
- h_align="end",
- v_align="end",
- tooltip_text=self.player.player_name, # type: ignore
- )
- self.image = Overlay(
- child=self.image_stack,
- overlays=[
- self.app_icon,
- ],
- )
- # Seek bar should not update automatically during user interaction
- self._user_seeking = False
-
- self.seek_bar = Scale(
- value=0,
- min_value=0,
- max_value=100,
- increments=(1, 1),
- name="expanded-seek-bar",
- 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)
-
- # Position and length labels for seek bar
- self.position_label = Label(
- label="0:00",
- name="macos-position-label",
- justification="left",
- h_align="start",
- )
-
- self.length_label = Label(
- label="0:00",
- name="macos-length-label",
- justification="right",
- h_align="end",
- )
-
- # Labels box for position and length below seek bar
- self.labels_box = Box(
- name="macos-labels-box",
- orientation="h",
- children=[
- self.position_label,
- Box(h_expand=True), # Spacer to push labels to ends
- self.length_label,
- ],
- )
-
- # Seek bar with position labels below
- self.seek_box = Box(
- name="macos-seek-box",
- orientation="v",
- spacing=2,
- children=[
- self.seek_bar,
- self.labels_box,
- ],
- )
-
- # Define buttons first
- self.button_box = Box(
- name="macos-button-box",
- h_align="center",
- h_expand=True,
- spacing=10, # Reduced spacing for macOS look
- )
-
- # Define controls_box BEFORE using it in track_info
- self.controls_box = Box(
- name="macos-player-controls",
- orientation="v",
- h_expand=True,
- spacing=6,
- h_align="center",
- children=[self.button_box],
- )
-
- # Track info with inline controls - expands to fill available space
- self.track_info = Box(
- name="macos-track-info",
- spacing=4, # Reduced spacing
- orientation="v",
- v_align="start",
- h_align="fill", # Fill all available horizontal space
- h_expand=True, # Expand horizontally to take maximum space
- v_expand=True, # Also expand vertically
- children=[
- self.track_title,
- self.track_artist,
- self.track_album,
- ],
- )
-
- # 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
- )
- 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.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)
- self.stack_buttons_box = Box(
- h_expand=False, # Fixed width, don't expand
- v_expand=True,
- name="macos-stack-buttons-box",
- spacing=4, # Reduced spacing
- orientation="h", # Vertical layout for compactness
- h_align="center",
- v_align="end",
- )
- 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.play_pause_button = Button(
- style_classes=["control-buttons"],
- name="macos-play-button",
- child=self.play_pause_icon,
- on_clicked=self.player.play_pause,
- )
-
- self.player.bind_property("can_pause", self.play_pause_button, "sensitive")
-
- self.next_button = Button(
- style_classes=["control-buttons"],
- name="macos-control-button",
- child=self.skip_next_icon,
- on_clicked=self._on_player_next,
- )
- self.player.bind_property("can_go_next", self.next_button, "sensitive")
-
- self.prev_button = Button(
- name="macos-control-button",
- child=self.skip_prev_icon,
- style_classes=["control-buttons"],
- on_clicked=self._on_player_prev,
- )
- self.button_box.children = (
- self.prev_button,
- self.play_pause_button,
- self.next_button,
- )
-
- self.box = Box(
- orientation="horizontal",
- children=[
- self.image, # Album art on left (fixed width)
- # Contains title, artist, seek bar AND controls (expands)
- self.track_info,
- ],
- )
- self.inner_box = Box(
- orientation="v",
- h_expand=True,
- h_align="fill", # Fill available space
- children=[
- self.box, # Track info and album art
- self.seek_box, # Seek bar with position labels
- self.controls_box, # Controls now inline with track info
- ],
- )
- # Compact macOS layout: album art on left, expanded track info+controls, minimal switcher
- self.outer_box = Box(
- name="macos-outer-player-box",
- orientation="v",
- spacing=10, # Reduced spacing between elements
- h_expand=True,
- v_expand=True,
- v_align="center",
- h_align="fill", # Fill available space
- children=[
- self.inner_box, # Track info and controls
- # Compact switcher in corner (fixed width)
- self.stack_buttons_box,
- ],
- )
-
- self.children = [*self.children, self.outer_box]
-
- # Track signal connections for cleanup - store (object, handler_id) tuples
- connections = bulk_connect(
- self.player,
- {
- "exit": self._on_player_exit,
- "notify::playback-status": self._on_playback_change,
- "notify::metadata": self._on_metadata,
- },
- )
- # Store as (object, handler_id) tuples
- for handler_id in connections:
- self._signal_connections.append((self.player, handler_id))
-
- def destroy(self):
- """Clean up all resources when the widget is destroyed."""
- # Set exit flag FIRST to stop any running timers
- self.exit = True
-
- # Cancel any ongoing downloads immediately
- self._download_cancelled = True
-
- # Cancel seek bar timer
- if self._seekbar_timer_id:
- try:
- from gi.repository import GLib
-
- GLib.source_remove(self._seekbar_timer_id)
- except:
- 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
- except Exception:
- pass
-
- # 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 signal: {e}")
- self._signal_connections.clear()
-
- # Clean up temp files aggressively
- self._cleanup_temp_files()
-
- # Clear image references
- if hasattr(self, "album_cover_image"):
- try:
- self.album_cover_image.set_from_pixbuf(None)
- except Exception:
- pass
-
- super().destroy()
-
- def __del__(self):
- """Ensure cleanup happens even if player exits unexpectedly."""
- try:
- self._cleanup_temp_files()
- except Exception:
- pass # Ignore errors during cleanup in destructor
-
- def update_buttons(self, player_buttons, show_buttons):
- """Update the stack switcher buttons in this player box"""
- logger.info(
- f"[PlayerBox] update_buttons called: show_buttons={
- show_buttons
- }, num_buttons={len(player_buttons)}"
- )
-
- # Clear existing buttons
- for child in self.stack_buttons_box.get_children():
- try:
- child.destroy()
- except Exception:
- pass
-
- if show_buttons and len(player_buttons) > 1:
- logger.info(f"[PlayerBox] Creating {len(player_buttons)} stack buttons")
- # Create macOS-style dot indicators for each player
- for i, button in enumerate(player_buttons):
- # Create a macOS-style dot button
- dot_button = Button(
- name="macos-player-switcher-dot",
- style_classes=["macos-switcher-dot"],
- )
-
- # Set active state based on original button
- if button.get_style_context().has_class("active"):
- dot_button.add_style_class("active")
-
- # Connect click handler to switch to this player
- def make_click_handler(index):
- return lambda *_: self.player_stack.on_player_clicked_by_index(
- index
- )
-
- dot_button.connect("clicked", make_click_handler(i))
- self.stack_buttons_box.children = [
- *self.stack_buttons_box.children,
- dot_button,
- ]
- logger.info(f"[PlayerBox] Added dot button {i}")
-
- self.stack_buttons_box.show_all()
- logger.info("[PlayerBox] Stack buttons box shown")
- else:
- self.stack_buttons_box.hide()
- logger.info("[PlayerBox] Stack buttons box hidden")
-
- def length_str(self, length):
- """Convert length in microseconds to MM:SS or H:MM:SS format like real media players."""
- if length is None or length <= 0:
- return "0:00"
-
- # Convert microseconds to seconds
- length_seconds = length / 1000000
-
- hours = int(length_seconds // 3600)
- minutes = int((length_seconds % 3600) // 60)
- seconds = int(length_seconds % 60)
-
- if hours > 0:
- return f"{hours}:{minutes:02d}:{seconds:02d}"
- else:
- return f"{minutes}:{seconds:02d}"
-
- def _on_metadata(self, *_):
- self._set_image()
- duration = self.player.length
-
- if duration:
- 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
- if self._seekbar_timer_id:
- try:
- from gi.repository import GLib
-
- GLib.source_remove(self._seekbar_timer_id)
- except:
- pass
- self._seekbar_timer_id = None
-
- # Start new timer and store its ID
- self._seekbar_timer_id = invoke_repeater(1000, self._move_seekbar)
-
- def _cleanup_temp_files(self):
- """Clean up temporary artwork files."""
- for temp_file in self.temp_artwork_files:
- try:
- if os.path.exists(temp_file):
- os.unlink(temp_file)
- except Exception as e:
- logger.warning(f"Failed to cleanup temp file {temp_file}: {e}")
- self.temp_artwork_files.clear()
-
- def _on_player_exit(self, _, value):
- self.exit = value
- self._cleanup_temp_files() # Clean up temp files before destroying
- self.destroy()
-
- def _on_player_next(self, *_):
- self.player.next()
-
- def _on_player_prev(self, *_):
- self.player.previous()
-
- def _on_playback_change(self, player, status):
- status = player.get_property("playback-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")
- )
-
- 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 no art URL or empty/None, use fallback
- if not art_url:
- self._update_image(None)
- return
-
- 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:
- print(art_url)
- 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
-
- try:
- # Check if download was cancelled
- if self._download_cancelled:
- return
-
- # 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
-
- # Download artwork
- 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()
-
- # Check one more time if cancelled
- if self._download_cancelled:
- return
-
- # 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
-
- # Track temp file for cleanup
- if temp_file_path and not self._download_cancelled:
- self.temp_artwork_files.append(temp_file_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
- return
-
- # Only update UI if not cancelled
- if not self._download_cancelled:
- GLib.idle_add(self._update_image, local_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
-
- # Additional safety checks to prevent GTK errors
- if not hasattr(self, "seek_bar") or self.seek_bar is None:
- return False # Stop the timer
-
- try:
- position = self.player.position
- 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
- 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}")
- return False
-
- return True
-
- def _on_seek_start(self, widget, event):
- """User started seeking - disable automatic updates"""
- self._user_seeking = True
- return False
-
- def _on_seek_end(self, widget, event):
- """User finished seeking - re-enable automatic updates"""
- self._user_seeking = False
- return False
-
- 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:
- try:
- new_position = int(scale.get_value())
- # Clamp to 32-bit signed integer range to avoid overflow
- max_int32 = 2147483647 # 2^31 - 1
- min_int32 = -2147483648 # -2^31
- new_position = max(min_int32, min(max_int32, new_position))
- self.player.position = new_position
- self.position_label.set_label(self.length_str(new_position))
- except Exception as e:
- # If setting position fails, just update the label
- try:
- self.position_label.set_label(self.length_str(new_position))
- except Exception:
- logger.warning(f"Failed to update position label: {e}")
-
- @cooldown(0.1)
- def _on_scale_move(self, scale: Scale, event, pos: int):
- try:
- if not self.exit and self.player:
- self.player.position = pos
- self.position_label.set_label(self.length_str(pos))
- self.seek_bar.set_value(pos)
- except Exception as e:
- logger.warning(f"Failed to update seek position: {e}")
-
-
-class Thing(Box):
- def __init__(self, **kwargs):
- super().__init__(
- name="thing",
- size=(480, 160),
- orientation="vertical",
- spacing=0,
- children=[
- Label(
- name="thing-label",
- label="This is a thing",
- style="font-size: 16px; padding: 10px;",
- ),
- ],
- **kwargs,
- )
-
-
-class ExpandedPlayer(Window):
- def __init__(self, **kwargs):
- super().__init__(
- name="expanded-player",
- title="modus",
- anchor="top right",
- layer="top",
- exclusivity="auto",
- child=PlayerBoxStack(get_shared_mpris_manager()),
- visible=False,
- )
- self.add_keybinding("Escape", self.set_child_visible(False))
-
- def destroy(self):
- """Clean up resources when the window is destroyed."""
- # Clean up the child PlayerBoxStack
- if hasattr(self, "child") and hasattr(self.child, "destroy"):
- try:
- self.child.destroy()
- except Exception as e:
- logger.warning(f"Failed to destroy child PlayerBoxStack: {e}")
-
- super().destroy()
-
- def _init_mousecapture(self, mousecapture):
- self._mousecapture_parent = mousecapture
-
- def hide_controlcenter(self, *_):
- # self._mousecapture_parent.toggle_mousecapture()
- self.set_visible(False)
diff --git a/modules/controlcenter/main.py b/modules/controlcenter/main.py
deleted file mode 100644
index cfa00bc..0000000
--- a/modules/controlcenter/main.py
+++ /dev/null
@@ -1,1078 +0,0 @@
-import subprocess
-
-from fabric.utils import idle_add
-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.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 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
-
-brightness_service = Brightness.get_initial()
-
-
-class ModusControlCenter(Window):
- def __init__(self, **kwargs):
- super().__init__(
- layer="top",
- title="modus",
- anchor="top right",
- margin="2px 10px 0px 0px",
- exclusivity="auto",
- keyboard_mode="on-demand",
- name="control-center-menu",
- visible=False,
- **kwargs,
- )
- self.focus_mode = modus_service.dont_disturb
- self._updating_brightness = False
- self._updating_volume = False
-
- # Flight mode and caffeine states
- self.flight_mode = False
- self.caffeine_mode = False
- self._caffeine_process = None
-
- # Lazy loading flags
- self._music_initialized = False
- self._per_app_volume_initialized = False
- self._expanded_player_initialized = False
-
- # Store references for cleanup - initialize all as None
- self._signal_connections = []
- self._music_widget_content = None
- self._per_app_volume_widget = None
- self._expanded_player_widget = None
- self._mpris_manager = None # Shared MPRIS manager instance
-
- # Initialize network service for WiFi toggle
- self.network_service = NetworkClient()
- self.wifi_service = None
-
- # Initialize flight mode and caffeine states
- self._check_initial_states()
-
- # Wait for network service to be ready
-
- 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),
- ]
- )
-
- print(wlan)
- self.wlan_label = Label(
- label=wlan,
- name="wifi-widget-label",
- max_chars_width=15,
- h_align="start",
- ellipsization="end",
- )
- if bluetooth != "disabled":
- if bluetooth.startswith("connected:"):
- parts = bluetooth.split(":")
- bluetooth_display = parts[1] if len(parts) >= 2 else "Connected"
- else:
- bluetooth_display = "On"
- else:
- bluetooth_display = "Off"
-
- self.bluetooth_label = Label(
- label=bluetooth_display,
- name="bluetooth-widget-label",
- max_chars_width=15,
- ellipsization="end",
- h_align="start",
- )
- self.volume_scale = Scale(
- value=volume,
- min_value=0,
- max_value=100,
- increments=(5, 5),
- name="volume-widget-slider",
- 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 = (
- int((current_brightness / brightness_service.max_screen) * 100)
- if brightness_service.max_screen > 0
- else 50
- )
-
- self.brightness_scale = Scale(
- value=brightness_percentage,
- min_value=0,
- max_value=100,
- increments=(5, 5),
- name="brightness-widget-slider",
- size=30,
- h_expand=True,
- )
-
- # 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)
- )
- 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.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"
- if bluetooth != "disabled"
- else "../../config/assets/icons/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"
- ),
- size=42,
- )
-
- self.bluetooth_widget = Box(
- name="bluetooth-widget",
- orientation="h",
- children=[
- Button(
- name="bluetooth-icon-button",
- child=self.bluetooth_svg,
- on_clicked=self.toggle_bluetooth,
- ),
- Button(
- name="bluetooth-info-button",
- child=Box(
- name="bluetooth-widget-info",
- orientation="vertical",
- children=[
- Label(
- name="bluetooth-widget-name",
- label="Bluetooth",
- style_classes="ct",
- h_align="start",
- ),
- self.bluetooth_label,
- ],
- ),
- on_clicked=self.open_bluetooth,
- ),
- ],
- )
-
- self.wlan_widget = Box(
- name="wifi-widget",
- orientation="h",
- children=[
- Button(
- name="wifi-icon-button",
- child=self.wifi_svg,
- on_clicked=self.toggle_wifi,
- ),
- Button(
- name="wifi-info-button",
- child=Box(
- name="wifi-widget-info",
- orientation="vertical",
- children=[
- Label(
- name="wifi-widget-name",
- label="Wi-Fi",
- style_classes="ct",
- h_align="start",
- ),
- self.wlan_label,
- ],
- ),
- on_clicked=self.open_wifi,
- ),
- ],
- )
-
- 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"
- ),
- size=42,
- )
-
- self.focus_status_label = Label(
- label="On" if self.focus_mode else "Off",
- style_classes="status-label",
- h_align="start",
- )
-
- self.focus_widget = Button(
- name="focus-widget",
- child=Box(
- spacing=4,
- orientation="h",
- children=[
- self.focus_icon,
- Box(
- v_expand=True,
- h_expand=True,
- v_align="center",
- orientation="v",
- h_align="start",
- children=[
- Label(
- label="Focus",
- style_classes="title-widget",
- h_align="start",
- ),
- self.focus_status_label,
- ],
- ),
- ],
- ),
- 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"
- ),
- size=42,
- )
-
- self.flight_widget = Button(
- name="flight-widget",
- child=Box(
- orientation="v",
- h_expand=True,
- v_expand=True,
- spacing=8,
- h_align="center",
- v_align="center",
- children=[
- self.flight_icon,
- Label(
- label="Flight",
- style_classes="title-widget",
- h_align="center",
- ),
- ],
- ),
- 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"
- if self.caffeine_mode
- else "../../config/assets/icons/applets/caffeine-off.svg"
- ),
- size=42,
- )
-
- self.caffeine_status_label = Label(
- label="On" if self.caffeine_mode else "Off",
- style_classes="status-label",
- h_align="start",
- )
-
- self.caffeine_widget = Button(
- name="caffeine-widget",
- child=Box(
- orientation="v",
- spacing=8,
- h_expand=True,
- v_expand=True,
- h_align="center",
- v_align="center",
- children=[
- self.caffeine_icon,
- Label(
- label="Caffeine",
- 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,
- )
-
- # Create night light widget
- self.night_light_widget = create_night_light_widget(self)
-
- # Create main widgets directly without XML
- self.widgets = Box(
- orientation="vertical",
- h_expand=True,
- name="control-center-widgets",
- children=[
- # Top section: Wi-Fi + Bluetooth on left, DND + Flight Mode + Caffeine on right
- Box(
- orientation="horizontal",
- h_expand=True,
- name="top-widget",
- children=[
- # Left side: Wi-Fi and Bluetooth
- Box(
- orientation="vertical",
- name="wb-widget",
- style_classes="menu",
- spacing=5,
- children=[
- self.wlan_widget,
- self.bluetooth_widget,
- self.night_light_widget,
- ],
- ),
- # Right side: DND, Flight Mode, and Caffeine
- Box(
- orientation="vertical",
- h_expand=True,
- name="right-side-widget",
- children=[
- # DND widget
- Box(
- orientation="vertical",
- name="dnd-widget",
- style_classes="menu",
- children=[
- self.focus_widget,
- ],
- ),
- # Flight Mode and Caffeine row
- Box(
- orientation="horizontal",
- name="flight-caffeine-row",
- children=[
- Box(
- orientation="vertical",
- name="flight-widget-container",
- style_classes="menu",
- h_expand=True,
- v_expand=True,
- children=[
- self.flight_widget,
- ],
- ),
- Box(
- orientation="vertical",
- name="caffeine-widget-container",
- h_expand=True,
- v_expand=True,
- style_classes="menu",
- children=[
- self.caffeine_widget,
- ],
- ),
- ],
- ),
- ],
- ),
- ],
- ),
- Box(
- orientation="vertical",
- name="brightness-widget",
- style_classes="menu",
- h_expand=True,
- children=[
- Label(label="Display", style_classes="title", h_align="start"),
- self.brightness_scale,
- Label(
- label="๓ฐจ ", name="brightness-widget-icon", h_align="start"
- ),
- ],
- ),
- Box(
- orientation="vertical",
- name="volume-widget",
- style_classes="menu",
- h_expand=True,
- children=[
- Label(label="Sound", style_classes="title", h_align="start"),
- Box(
- name="vol-box",
- orientation="horizontal",
- spacing=12,
- v_expand=True,
- children=[
- Box(
- name="vol-slider-box",
- h_expand=True,
- v_align="center",
- v_expand=False,
- children=[
- self.volume_scale,
- ],
- ),
- Button(
- name="per-app-volume-button",
- size=(36, 36),
- child=Svg(
- svg_file=get_relative_path(
- "../../config/assets/icons/player/audio-switcher.svg"
- ),
- name="per-app-volume-icon",
- sidze=32,
- ),
- on_clicked=self.open_per_app_volume,
- ),
- ],
- ),
- Label(label="๏จ ", name="volume-widget-icon", h_align="start"),
- ],
- ),
- self.music_widget,
- ],
- )
-
- # Initialize managers directly like working version
- self.wifi_man = WifiConnections(self)
- self.bluetooth_man = BluetoothConnections(self)
-
- self.has_bluetooth_open = False
- self.has_wifi_open = False
-
- # Lazy-loaded widgets - create placeholders
- 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 revealers for crossfade transitions
-
- self.widgets.set_size_request(300, -1)
-
- self.children = self.center_box
-
- # Track current state for smooth transitions
- self.current_view = "main" # main, expanded_player
-
- # Connect to visibility changes for cleanup
- self.connect("notify::visible", self._on_visibility_changed)
-
- def _on_visibility_changed(self, widget, param):
- """Handle visibility changes for memory management"""
- if not self.get_visible():
- self._cleanup_when_hidden()
-
- def _cleanup_when_hidden(self):
- """Aggressively clean up resources when widget is hidden to reduce memory usage"""
- 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
- try:
- self._music_widget_content.destroy()
- except Exception as e:
- logger.warning(f"Failed to destroy music widget content: {e}")
- self._music_widget_content = None
-
- # Clean up shared MPRIS manager when hidden to free memory
- if self._mpris_manager:
- try:
- self._mpris_manager.destroy()
- except Exception as e:
- logger.warning(
- f"Failed to destroy MPRIS manager during cleanup: {e}"
- )
- self._mpris_manager = None
-
- # Reset initialization flags to force recreation next time
- self._music_initialized = False
-
- # Force garbage collection
- import gc
-
- gc.collect()
-
- logger.debug("Control center aggressive cleanup completed")
-
- 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
-
- def _ensure_bluetooth_widgets(self):
- """Lazy load bluetooth widgets"""
- if self.bluetooth_widgets is None:
- self.bluetooth_widgets = Box(
- 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=[
- # ],
- # ),
- # ],
- # ),
- ],
- )
- self.bluetooth_center_box = Box(
- h_expand=True, v_expand=True, children=[self.bluetooth_widgets]
- )
- self.bluetooth_center_box.set_size_request(350, -1)
-
- def _ensure_wifi_widgets(self):
- """Lazy load wifi widgets"""
- if self.wifi_widgets is None:
- self.wifi_widgets = Box(
- orientation="vertical",
- h_expand=True,
- v_expand=True,
- children=[
- self.wifi_man,
- ],
- )
- self.wifi_center_box = Box(
- h_expand=True, v_expand=True, children=[self.wifi_widgets]
- )
- self.wifi_center_box.set_size_request(350, -1)
-
- def _ensure_per_app_volume_widgets(self):
- """Lazy load per-app volume widgets"""
- if self.per_app_volume_widgets is None:
- if self._per_app_volume_widget is None:
- self._per_app_volume_widget = PerAppVolumeControl(self)
-
- self.per_app_volume_widgets = Box(
- orientation="vertical",
- h_expand=True,
- name="control-center-widgets",
- children=[
- self._per_app_volume_widget,
- ],
- )
- self.per_app_volume_center_box = CenterBox(
- start_children=[self.per_app_volume_widgets]
- )
- 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
- try:
- result = subprocess.run(
- ["pgrep", "-f", "modus-inhibit"], capture_output=True, text=True
- )
- self.caffeine_mode = bool(result.stdout.strip())
- except Exception:
- self.caffeine_mode = False
-
- # Flight mode starts as False (normal mode)
- self.flight_mode = False
-
- # Update initial labels (will be set after widgets are created)
- GLib.timeout_add(100, self._update_initial_labels)
-
- def _update_initial_labels(self):
- return False
-
- 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_status_label.set_label("On" if self.focus_mode else "Off")
-
- def toggle_flight_mode(self, *_):
- try:
- self.flight_mode = not self.flight_mode
-
- if self.flight_mode:
- # Turn off WiFi and Bluetooth
- if self.wifi_service:
- self.wifi_service.wireless_enabled = False
- if hasattr(self, "bluetooth_man") and hasattr(
- self.bluetooth_man, "client"
- ):
- self.bluetooth_man.client.set_enabled(False)
- else:
- # Turn on WiFi and Bluetooth
- if self.wifi_service:
- self.wifi_service.wireless_enabled = True
- if hasattr(self, "bluetooth_man") and hasattr(
- self.bluetooth_man, "client"
- ):
- 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"
- )
- )
-
- except Exception as e:
- logger.warning(f"Failed to toggle flight mode: {e}")
-
- 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:
- 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_status_label.set_label("On" if self.caffeine_mode else "Off")
-
- except Exception as e:
- logger.warning(f"Failed to toggle caffeine: {e}")
-
- def set_volume(self, _, __, volume):
- self._updating_volume = True
- audio_service.speaker.volume = round(volume)
- self._updating_volume = False
-
- def set_brightness(self, _, __, brightness):
- 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:
- return
-
- if brightness_service.max_screen > 0:
- brightness_percentage = int(
- (brightness_value / brightness_service.max_screen) * 100
- )
-
- GLib.idle_add(
- lambda: self.brightness_scale.set_value(brightness_percentage)
- )
-
- def on_volume_scroll(self, widget, event):
- current_value = self.volume_scale.get_value()
- scroll_step = 5
- if event.direction == Gdk.ScrollDirection.UP:
- new_value = min(100, current_value + scroll_step)
- elif event.direction == Gdk.ScrollDirection.DOWN:
- new_value = max(0, current_value - scroll_step)
- else:
- return False
-
- self.volume_scale.set_value(new_value)
- return True
-
- def on_brightness_scroll(self, widget, event):
- current_value = self.brightness_scale.get_value()
- scroll_step = 5
- if event.direction == Gdk.ScrollDirection.UP:
- new_value = min(100, current_value + scroll_step)
- elif event.direction == Gdk.ScrollDirection.DOWN:
- new_value = max(0, current_value - scroll_step)
- else:
- return False
-
- self.brightness_scale.set_value(new_value)
- return True
-
- def toggle_bluetooth(self, *_):
- 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(
- self.bluetooth_man.client, not current_state
- )
- else:
- logger.warning("Bluetooth client not available for toggling")
- except Exception as e:
- 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.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"""
- 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")
- except Exception as e:
- logger.warning(f"Failed to toggle wifi: {e}")
-
- def set_children(self, children):
- self.children = children
-
- def open_bluetooth(self, *_):
- self._ensure_bluetooth_widgets()
- idle_add(lambda *_: self.set_children(self.bluetooth_center_box))
- self.has_bluetooth_open = True
-
- def open_wifi(self, *_):
- self._ensure_wifi_widgets()
- idle_add(lambda *_: self.set_children(self.wifi_center_box))
- self.has_wifi_open = True
-
- def close_bluetooth(self, *_):
- if self.current_view == "expanded_player":
- self._crossfade_to_view("main")
- else:
- idle_add(lambda *_: self.set_children(self.center_box))
- self.has_bluetooth_open = False
-
- def close_wifi(self, *_):
- if self.current_view == "expanded_player":
- self._crossfade_to_view("main")
- else:
- idle_add(lambda *_: self.set_children(self.center_box))
- self.has_wifi_open = False
-
- def open_per_app_volume(self, *_):
- self._ensure_per_app_volume_widgets()
- if self.current_view == "expanded_player":
- # If coming from expanded player, use crossfade
- self._crossfade_to_view("main")
- GLib.timeout_add(
- 250,
- lambda: idle_add(
- lambda *_: self.set_children(self.per_app_volume_center_box)
- ),
- )
- else:
- idle_add(lambda *_: self.set_children(self.per_app_volume_center_box))
- self.has_per_app_volume_open = True
- # Refresh the app list when opening
- if self._per_app_volume_widget:
- self._per_app_volume_widget.refresh()
-
- def close_per_app_volume(self, *_):
- if self.current_view == "expanded_player":
- self._crossfade_to_view("main")
- else:
- idle_add(lambda *_: self.set_children(self.center_box))
- self.has_per_app_volume_open = False
-
- def open_expanded_player(self, *_):
- self._ensure_expanded_player_widgets()
- 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, *_):
- 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()
-
- self.set_visible(visible)
- if not visible:
- self.close_bluetooth()
- self.close_wifi()
- self.close_per_app_volume()
- self.close_expanded_player()
-
- def volume_changed(
- self,
- _,
- ):
- if self._updating_volume:
- return
-
- GLib.idle_add(
- lambda: self.volume_scale.set_value(int(audio_service.speaker.volume))
- )
-
- 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 wlan != "No Connection":
- if wlan.startswith("connected:"):
- parts = wlan.split(":")
- if len(parts) >= 2:
- wifi_name = parts[1]
- GLib.idle_add(
- lambda: self.wlan_label.set_property("label", wifi_name)
- )
- else:
- GLib.idle_add(
- lambda: self.wlan_label.set_property("label", "Connected")
- )
- else:
- GLib.idle_add(lambda: self.wlan_label.set_property("label", wlan))
- else:
- 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 bluetooth != "disabled":
- if bluetooth.startswith("connected:"):
- parts = bluetooth.split(":")
- if len(parts) >= 2:
- device_name = parts[1]
- GLib.idle_add(lambda: self.bluetooth_label.set_label(device_name))
- else:
- GLib.idle_add(lambda: self.bluetooth_label.set_label("Connected"))
- elif bluetooth == "enabled":
- GLib.idle_add(lambda: self.bluetooth_label.set_label("On"))
- else:
- GLib.idle_add(lambda: self.bluetooth_label.set_label("On"))
- else:
- GLib.idle_add(lambda: self.bluetooth_label.set_label("Off"))
-
- def audio_changed(self, *_):
- pass
-
- def dnd_changed(self, _, dnd_state):
- 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_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 destroy(self):
- """Clean up resources when widget is destroyed"""
- # Disconnect all signal connections
- for connection in self._signal_connections:
- try:
- connection.disconnect()
- except:
- pass
-
- # Clean up caffeine process
- if self._caffeine_process:
- try:
- self._caffeine_process.terminate()
- except:
- 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
-
- super().destroy()
diff --git a/modules/controlcenter/nightlight.py b/modules/controlcenter/nightlight.py
deleted file mode 100644
index c5414c2..0000000
--- a/modules/controlcenter/nightlight.py
+++ /dev/null
@@ -1,146 +0,0 @@
-# Standard library imports
-import subprocess
-
-# Fabric imports
-from fabric.utils.helpers 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
-
-# Local imports
-from loguru import logger
-
-
-class NightLightControl:
- """Control night light using hyprsunset"""
-
- def __init__(self):
- self.is_active = False
- self._check_initial_state()
-
- def _check_initial_state(self):
- """Check if hyprsunset is currently running"""
- try:
- result = subprocess.run(
- ["pgrep", "-f", "hyprsunset"], capture_output=True, text=True
- )
- self.is_active = bool(result.stdout.strip())
- except Exception as e:
- logger.warning(f"Failed to check hyprsunset status: {e}")
- self.is_active = False
-
- def toggle(self):
- """Toggle night light on/off"""
- try:
- if self.is_active:
- # Turn off night light by killing hyprsunset
- subprocess.run(["pkill", "hyprsunset"], check=False)
- self.is_active = False
- logger.debug("Night light turned off")
- else:
- # Turn on night light with default temperature (3000K)
- subprocess.Popen(["hyprsunset", "-t", "4500"], start_new_session=True)
- self.is_active = True
- logger.debug("Night light turned on")
- return True
- except Exception as e:
- logger.warning(f"Failed to toggle night light: {e}")
- return False
-
- def set_temperature(self, temperature: int):
- """Set night light temperature (1000-6500K)"""
- try:
- # Kill existing process
- subprocess.run(["pkill", "hyprsunset"], check=False)
-
- # Start with new temperature
- subprocess.Popen(
- ["hyprsunset", "-t", str(temperature)], start_new_session=True
- )
- self.is_active = True
- logger.debug(f"Night light temperature set to {temperature}K")
- return True
- except Exception as e:
- logger.warning(f"Failed to set night light temperature: {e}")
- return False
-
-
-def create_night_light_widget(control_center):
- """Create night light widget for control center"""
-
- # Initialize night light control
- 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"
- if night_light.is_active
- else "../../config/assets/icons/applets/redshift-status-off.svg"
- ),
- size=42,
- )
-
- # Create status label
- night_light_status_label = Label(
- label="On" if night_light.is_active else "Off",
- name="nightlight-widget-label",
- style_classes="status-label",
- max_chars_width=15,
- ellipsization="end",
- h_align="start",
- )
-
- 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"
- )
- )
- # Update status label
- night_light_status_label.set_label("On" if night_light.is_active else "Off")
-
- # Create widget box (similar to bluetooth_widget structure)
- night_light_widget = Box(
- name="nightlight-widget",
- orientation="h",
- h_expand=True,
- children=[
- Button(
- name="nightlight-icon-button",
- child=night_light_icon,
- on_clicked=toggle_night_light,
- ),
- Button(
- name="nightlight-info-button",
- child=Box(
- name="nightlight-widget-info",
- h_expand=True,
- v_expand=True,
- v_align="center",
- h_align="start",
- orientation="vertical",
- children=[
- Label(
- name="nightlight-widget-name",
- label="Night Light",
- style_classes="ct",
- h_align="start",
- ),
- night_light_status_label,
- ],
- ),
- on_clicked=toggle_night_light,
- ),
- ],
- )
-
- return night_light_widget
-
diff --git a/modules/controlcenter/per_app_volume.py b/modules/controlcenter/per_app_volume.py
deleted file mode 100644
index 08ba18a..0000000
--- a/modules/controlcenter/per_app_volume.py
+++ /dev/null
@@ -1,336 +0,0 @@
-# Standard library imports
-from gi.repository import GLib
-
-# Fabric imports
-from fabric.widgets.box import Box
-from fabric.widgets.button import Button
-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
-from utils.roam import audio_service
-
-
-class PerAppVolumeControl(Box):
- """Per-application volume control widget"""
-
- def __init__(self, control_center, **kwargs):
- super().__init__(
- orientation="vertical",
- name="per-app-volume-control",
- spacing=5,
- **kwargs,
- )
-
- self.control_center = control_center
- self._updating_volumes = set()
- self._app_widgets = {}
- self._signal_connections = []
-
- # Header with back button
- self.header = Box(
- orientation="horizontal",
- name="per-app-volume-header",
- style_classes="menu-header",
- children=[
- Button(
- image=Image(icon_name="back", size=10),
- on_clicked=lambda *_: self.control_center.close_per_app_volume(),
- ),
- Label("App Volume", name="app-volume-header"),
- ],
- )
-
- # Scrollable container for app volume controls
- self.apps_container = Box(
- orientation="vertical",
- name="apps-scrolled-container",
- spacing=2,
- )
-
- self.scrolled_window = ScrolledWindow(
- name="apps-scrolled-window",
- child=self.apps_container,
- size=(300, 500),
- )
-
- # Add escape key binding for navigation back
- try:
- if hasattr(self.control_center, "add_keybinding"):
- self.control_center.add_keybinding("Escape", self._go_back)
- except Exception:
- pass # Ignore if keybinding fails
-
- self.children = [self.header, self.scrolled_window]
-
- # Connect to audio service changes
- if audio_service:
- self._signal_connections.append(
- audio_service.connect("stream-added", self._on_stream_changed)
- )
- self._signal_connections.append(
- audio_service.connect("stream-removed", self._on_stream_changed)
- )
-
- # Initial population
- self._populate_apps()
-
- # Set up auto-refresh timer for audio streams
- self._refresh_timer = GLib.timeout_add_seconds(2, self._auto_refresh)
-
- def _auto_refresh(self):
- """Auto-refresh the application list every 2 seconds"""
- self._populate_apps()
- return True # Continue the timer
-
- def _go_back(self, *_):
- """Return to main control center view"""
- self.control_center.close_per_app_volume()
-
- def _get_app_icon(self, app):
- """Get icon for application"""
- # Don't use fabric's generic audio icon, use description/name for better detection
- fabric_icon = getattr(app, "icon_name", "")
- if fabric_icon and fabric_icon != "audio":
- return fabric_icon
-
- # Use description first (more accurate), then name
- app_description = getattr(app, "description", "").lower()
- app_name = app.name.lower()
-
- # Check description first as it's more reliable
- search_text = app_description if app_description else app_name
-
- icon_mapping = {
- "spotify": "spotify",
- "firefox": "firefox",
- "chromium": "chromium-browser",
- "chrome": "google-chrome",
- "vlc": "vlc",
- "discord": "discord",
- "steam": "steam",
- "zen": "zen-browser",
- "code": "vscode",
- "visual studio code": "vscode",
- "telegram": "telegram-desktop",
- "pulse": "audio-card",
- "zen": "zen-browser",
- "pipewire": "audio-card",
- "alsa": "audio-card",
- "sink": "audio-speakers",
- "source": "audio-input-microphone",
- "youtube": "youtube",
- "music": "rhythmbox",
- "media": "multimedia-player",
- }
-
- # Check if any key in mapping is contained in search text
- for key, icon in icon_mapping.items():
- if key in search_text:
- return icon
-
- # Also check app name as fallback
- if search_text != app_name:
- for key, icon in icon_mapping.items():
- if key in app_name:
- return icon
-
- # Default audio icon for unknown apps
- return "audio-volume-high"
-
- def _format_app_name(self, name):
- """Format application name with proper capitalization"""
- if not name:
- return "Unknown"
-
- # Handle common app names specially
- special_names = {
- "spotify": "Spotify",
- "firefox": "Firefox",
- "chromium": "Chromium",
- "chrome": "Chrome",
- "vlc": "VLC",
- "discord": "Discord",
- "steam": "Steam",
- "zen": "Zen Browser",
- "code": "VS Code",
- "telegram": "Telegram",
- }
-
- name_lower = name.lower()
- if name_lower in special_names:
- return special_names[name_lower]
-
- # Default: capitalize first letter
- return name.capitalize()
-
- def _populate_apps(self):
- """Populate the widget with current audio applications"""
- # Clear existing widgets
- self.apps_container.children = []
- self._app_widgets.clear()
-
- # Use fabric audio service for applications
- if not audio_service:
- self._show_no_apps_message()
- return
-
- applications = getattr(audio_service, "applications", [])
-
- if applications:
- for i, app in enumerate(applications):
- if hasattr(app, "name") and hasattr(app, "volume"):
- app_widget = self._create_app_control(app)
- self.apps_container.children = list(
- self.apps_container.children
- ) + [app_widget]
-
- # Add separator between apps (except for last one)
- if i < len(applications) - 1:
- separator = Separator(
- orientation="horizontal",
- style_classes="app-volume-separator",
- )
- self.apps_container.children = list(
- self.apps_container.children
- ) + [separator]
-
- self._app_widgets[app.name] = (app_widget, app)
- else:
- self._show_no_apps_message()
-
- def _show_no_apps_message(self):
- """Show message when no apps are playing audio"""
- message = Label(
- label="No applications currently using audio",
- style_classes="subtitle",
- h_align="center",
- v_align="center",
- )
- self.apps_container.children = [message]
-
- def _create_app_control(self, app):
- """Create compact volume control for a single application"""
- # Format app name (shorter for compact layout)
- app_name = self._format_app_name(app.name)
- # Get current volume from fabric audio service
- current_volume = getattr(app, "volume", 0.0)
- max_vol = getattr(audio_service, "max_volume", 100) if audio_service else 100
-
- # Ensure volume is in valid range
- volume_percent = max(0, min(current_volume, max_vol))
-
- # App icon - use the same approach as expanded player
- icon_name = self._get_app_icon(app)
- app_icon = Image(
- icon_name=icon_name,
- name="app-icon",
- icon_size=16,
- style_classes="app-icon",
- )
-
- # App name label
- name_label = Label(
- label=app_name,
- style_classes="app-name-compact",
- justification="left",
- h_align="start",
- max_chars_width=10,
- ellipsization="end",
- )
-
- # Volume scale (smaller and horizontal)
- volume_scale = Scale(
- value=volume_percent,
- min_value=0,
- max_value=max_vol,
- increments=(1, 5),
- name="compact-volume-slider",
- size=20,
- h_expand=True,
- )
-
- # Connect volume change handler
- volume_scale.connect(
- "change-value",
- lambda scale, scroll_type, value, app=app: self._set_app_volume(app, value),
- )
-
- # Create horizontal compact layout
- app_control = Box(
- orientation="horizontal",
- spacing=5,
- h_expand=True,
- style_classes="compact-app-volume-item",
- children=[
- Box(
- orientation="h",
- spacing=3,
- v_expand=True,
- v_align="center",
- name="app-control-box",
- children=[
- app_icon,
- name_label,
- ],
- ),
- volume_scale,
- ],
- )
- # Add separator after the control
-
- return app_control
-
- def _set_app_volume(self, app, volume_value):
- """Set volume for a specific application using fabric audio service"""
- if app.name in self._updating_volumes:
- return
-
- self._updating_volumes.add(app.name)
-
- try:
- # Get max volume from audio service
- max_vol = (
- getattr(audio_service, "max_volume", 100) if audio_service else 100
- )
-
- # Ensure volume is within bounds
- volume_value = max(0, min(volume_value, max_vol))
-
- # Set volume directly - fabric expects the actual volume value, not percentage
- app.volume = volume_value
-
- except Exception as e:
- print(f"Error setting volume for {app.name}: {e}")
- finally:
- GLib.timeout_add(100, lambda: self._updating_volumes.discard(app.name))
-
- def _on_stream_changed(self, *_):
- """Handle when audio streams are added or removed"""
- GLib.idle_add(self._populate_apps)
-
- def refresh(self):
- """Manually refresh the application list"""
- self._populate_apps()
-
- def destroy(self):
- """Clean up resources"""
- if hasattr(self, "_refresh_timer"):
- GLib.source_remove(self._refresh_timer)
-
- # Disconnect fabric audio service signals
- if audio_service:
- for connection in self._signal_connections:
- try:
- audio_service.disconnect(connection)
- except:
- pass
-
- self._signal_connections.clear()
- self._app_widgets.clear()
- self._updating_volumes.clear()
-
- super().destroy()
diff --git a/modules/controlcenter/player.py b/modules/controlcenter/player.py
deleted file mode 100644
index d5a6fd4..0000000
--- a/modules/controlcenter/player.py
+++ /dev/null
@@ -1,955 +0,0 @@
-import os
-import re
-import tempfile
-import urllib.parse
-import urllib.request
-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.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 services.mpris import MprisPlayer, MprisPlayerManager
-import config.data as data
-
-CACHE_DIR = f"{data.CACHE_DIR}/media"
-
-
-def cleanup_old_cache_files():
- """Clean up old artwork cache files (older than 1 day) and limit total cache size."""
- try:
- 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 = []
-
- # Collect all cache files with their modification times
- 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)
- file_size = os.path.getsize(filepath)
- cache_files.append((filepath, file_mtime, file_size))
- except Exception:
- pass # Ignore individual file errors
-
- # Remove files older than 1 day
- total_size = 0
- recent_files = []
- for filepath, file_mtime, file_size in cache_files:
- if file_mtime < one_day_ago:
- try:
- os.unlink(filepath)
- except Exception:
- pass
- else:
- recent_files.append((filepath, file_mtime, file_size))
- total_size += file_size
-
- # If cache is still too large (>50MB), remove oldest files
- MAX_CACHE_SIZE = 50 * 1024 * 1024 # 50MB
- if total_size > MAX_CACHE_SIZE:
- # Sort by modification time (oldest first)
- recent_files.sort(key=lambda x: x[1])
- for filepath, _, file_size in recent_files:
- if total_size <= MAX_CACHE_SIZE:
- break
- try:
- os.unlink(filepath)
- total_size -= file_size
- except Exception:
- pass
-
- except Exception:
- pass # Ignore all errors in cleanup
-
-
-class PlayerBoxStack(Box):
- """A widget that displays the current player information."""
-
- def __init__(
- self, mpris_manager: MprisPlayerManager, control_center=None, **kwargs
- ):
- # Clean up old cache files on startup
- cleanup_old_cache_files()
-
- # The player stack
- self.player_stack = Stack(
- # transition_type="slide-left-right",
- # transition_duration=500,
- name="player-stack",
- )
- self.current_stack_pos = 0
- self.control_center = control_center
-
- # List to store player buttons
- self.player_buttons: list[Button] = []
-
- # Track signal connections for cleanup
- self._signal_connections = []
-
- # Create a "No media playing" placeholder
- self.no_media_box = self._create_no_media_box()
-
- super().__init__(orientation="v", name="media", children=[self.player_stack])
-
- # Show the no media box initially
- self.player_stack.children = [self.no_media_box]
- self.set_visible(True)
-
- 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))
-
- for player in self.mpris_manager.players: # type: ignore
- logger.info(
- f"[PLAYER MANAGER] player found: {player.get_property('player-name')}",
- )
- self.on_new_player(self.mpris_manager, player)
-
- def destroy(self):
- """Clean up resources when the widget 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 signal: {e}")
- self._signal_connections.clear()
-
- # Clean up player buttons
- for button in self.player_buttons:
- try:
- button.destroy()
- except Exception:
- pass
- self.player_buttons.clear()
-
- # Clean up player boxes
- for child in self.player_stack.get_children():
- if hasattr(child, "destroy") and child != self.no_media_box:
- try:
- child.destroy()
- except Exception:
- pass
-
- super().destroy()
-
- def _periodic_cleanup(self):
- """Enhanced cleanup for reuse - clean internal state and free memory"""
- try:
- # Destroy all player boxes properly to free their resources
- current_children = list(self.player_stack.get_children())
- for child in current_children:
- if hasattr(child, "destroy") and child != self.no_media_box:
- try:
- child.destroy()
- except Exception as e:
- logger.warning(f"Failed to destroy player child: {e}")
-
- # Reset to no media state
- self.player_stack.children = [self.no_media_box]
-
- # Clear player buttons
- for button in self.player_buttons:
- try:
- button.destroy()
- except Exception:
- pass
- self.player_buttons.clear()
-
- # Reset stack position
- self.current_stack_pos = 0
-
- # 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"
-
- # Album cover with fallback image
- album_cover = Box(style_classes="album-image-c")
- album_cover.set_style(f"background-image:url('{fallback_cover_path}')")
-
- image_stack = Box(h_align="start", v_align="center", name="player-image-stack")
- image_stack.children = [album_cover]
-
- # Track info showing "No media playing"
- track_title = Label(
- label="No media playing",
- name="player-title-c",
- justification="left",
- max_chars_width=25,
- ellipsization="end",
- h_align="start",
- )
-
- track_artist = Label(
- label="",
- name="player-artist-c",
- justification="left",
- max_chars_width=15,
- ellipsization="end",
- h_align="start",
- visible=False, # Hide artist and album when no media
- )
-
- track_info = Box(
- name="track-info",
- # spacing=5,
- h_expand=True,
- orientation="v",
- v_align="start",
- h_align="start",
- children=[track_title, track_artist],
- )
-
- # No control buttons for no media state - just an empty box
- controls_box = Box(
- name="player-controls",
- visible=False, # Hide controls when no media
- )
-
- player_info_box = Box(
- name="player-info-box-c",
- h_expand=True,
- v_align="center",
- h_align="center",
- orientation="v",
- children=[track_info, controls_box],
- )
-
- inner_box = CenterBox(
- name="inner-player-box",
- start_children=[
- image_stack,
- ],
- center_children=[
- player_info_box,
- ],
- )
- # resize the inner box
- outer_box = Box(
- spacing=5,
- name="outer-no-player-box-c",
- h_expand=True,
- h_align="fill",
- # children=[
- v_expand=True,
- children=inner_box,
- # inner_box,
- # player_info_box,
- # image,
- # ],
- )
-
- box = Box(
- name="box-c",
- orientation="h",
- v_expand=True,
- h_align="fill",
- h_expand=True,
- children=[
- outer_box,
- ],
- )
- no_media_box = Box(
- h_align="center",
- name="player-box",
- h_expand=True,
- children=[box],
- )
-
- return no_media_box
-
- def _find_playing_player_index(self):
- """Find the index of the currently playing player."""
- players: List[PlayerBox] = self.player_stack.get_children()
- for i, player_box in enumerate(players):
- if (
- hasattr(player_box, "player")
- and player_box.player.playback_status == "playing"
- ):
- return i
- return None
-
- 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 on_player_playback_changed(self, player_box, status):
- """Called when a player's playback status changes."""
- 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
-
- def on_player_clicked(self, type):
- # unset active from prev active button
- if self.player_buttons and self.current_stack_pos < len(self.player_buttons):
- self.player_buttons[self.current_stack_pos].remove_style_class("active")
-
- if type == "next":
- self.current_stack_pos = (
- self.current_stack_pos + 1
- if self.current_stack_pos != len(self.player_stack.get_children()) - 1
- else 0
- )
- elif type == "prev":
- self.current_stack_pos = (
- self.current_stack_pos - 1
- if self.current_stack_pos != 0
- else len(self.player_stack.get_children()) - 1
- )
-
- # 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],
- )
-
- def on_player_clicked_by_index(self, index):
- """Switch to player at given index"""
- if 0 <= index < len(self.player_buttons):
- # unset active from prev active button
- if self.player_buttons and self.current_stack_pos < len(
- self.player_buttons
- ):
- self.player_buttons[self.current_stack_pos].remove_style_class("active")
- # set new position
- self.current_stack_pos = index
- # set new active button
- if self.player_buttons and self.current_stack_pos < len(
- self.player_buttons
- ):
- 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],
- )
- # Update all player boxes with new button state
- 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
-
- # Remove the no media box if it's the only child
- if (
- len(self.player_stack.get_children()) == 1
- and self.player_stack.get_children()[0] == self.no_media_box
- ):
- self.player_stack.children = []
- self.current_stack_pos = 0
-
- self.set_visible(True)
-
- new_player_box = PlayerBox(
- player=MprisPlayer(player),
- player_stack=self,
- control_center=self.control_center,
- )
- self.player_stack.children = [
- *self.player_stack.children,
- new_player_box,
- ]
-
- 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"])
-
- # Update all player boxes with current button state
- self._update_all_player_buttons()
-
- # 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):
- # 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
- player_box_to_remove = None
- for player_box in players:
- if (
- hasattr(player_box, "player")
- and player_box.player.player_name == player_name
- ):
- player_box_to_remove = player_box
- break
-
- if player_box_to_remove:
- try:
- player_box_to_remove.destroy()
- except Exception as e:
- logger.warning(f"Failed to destroy player box: {e}")
-
- # Check if this was the last player
- remaining_players = [
- p for p in self.player_stack.get_children() if p != player_box_to_remove
- ]
- if len(remaining_players) == 0:
- # Show the no media box instead of hiding
- self.player_stack.children = [self.no_media_box]
- self.current_stack_pos = 0
- self.player_buttons = [] # Clear player buttons
- return
-
- # Adjust current position if needed
- if self.current_stack_pos >= len(self.player_stack.get_children()):
- self.current_stack_pos = max(0, len(self.player_stack.get_children()) - 1)
-
- # Set active button if we have buttons and a valid position
- if self.player_buttons and self.current_stack_pos < len(self.player_buttons):
- self.player_buttons[self.current_stack_pos].set_style_classes(["active"])
- if self.player_stack.get_children():
- self.player_stack.set_visible_child(
- self.player_stack.get_children()[self.current_stack_pos],
- )
-
- # Update all player boxes with current button state
- self._update_all_player_buttons()
-
- # After a player is removed, check if we should switch to a playing player
- self._switch_to_playing_player()
-
- def make_new_player_button(self, player_box):
- new_button = Button(name="player-stack-button")
-
- def on_player_button_click(button: Button):
- if self.player_buttons and self.current_stack_pos < len(
- self.player_buttons
- ):
- self.player_buttons[self.current_stack_pos].remove_style_class("active")
- if button in self.player_buttons:
- self.current_stack_pos = self.player_buttons.index(button)
- button.add_style_class("active")
- self.player_stack.set_visible_child(player_box)
-
- new_button.connect(
- "clicked",
- on_player_button_click,
- )
- self.player_buttons.append(new_button)
-
- # This will automatically destroy our used button
- def cleanup_button(*_):
- try:
- if new_button in self.player_buttons:
- self.player_buttons.remove(new_button)
- new_button.destroy()
- except Exception as e:
- logger.warning(f"Failed to cleanup button: {e}")
-
- player_box.connect("destroy", 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)
- else:
- logger.warning(
- "[PlayerBoxStack] PlayerBox missing update_buttons method"
- )
-
-
-class PlayerBox(Box):
- """A widget that displays the current player information."""
-
- def __init__(
- self, player: MprisPlayer, player_stack=None, control_center=None, **kwargs
- ):
- super().__init__(
- h_align="center",
- name="player-box",
- **kwargs,
- h_expand=True,
- )
- # Setup
- self.player: MprisPlayer = player
- self.player_stack = player_stack
- self.control_center = control_center
- self.fallback_cover_path = f"{data.HOME_DIR}/.current.wall"
-
- # Add controls_box attribute early for compatibility
- # Temporary placeholder
- self.controls_box = Box(name="temp-controls-box")
-
- self.image_size = 50
- self.icon_size = 15
-
- # State
- self.exit = False
- self.skipped = False
-
- # 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
-
- self.album_cover = Box(style_classes="album-image-c")
- self.album_cover.set_style(
- f"background-image:url('{self.fallback_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.app_icon = Box(
- children=Image(
- icon_name=self.player.player_name, name="player-app-icon", icon_size=20
- ),
- h_align="end",
- v_align="end",
- tooltip_text=self.player.player_name, # type: ignore
- )
- self.image = Overlay(
- child=self.image_stack,
- overlays=[
- self.app_icon,
- ],
- )
- # Track Info
-
- self.track_title = Label(
- label="No Title",
- name="player-title-c",
- justification="left",
- max_chars_width=25,
- ellipsization="end",
- h_align="start",
- )
-
- self.track_artist = Label(
- label="No Artist",
- name="player-artist-c",
- justification="left",
- max_chars_width=23,
- ellipsization="end",
- h_align="start",
- 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.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.track_info = Box(
- name="track-info",
- spacing=5,
- orientation="v",
- v_align="start",
- h_align="start",
- children=[
- self.track_title,
- self.track_artist,
- ],
- )
-
- # Buttons with fixed sizing for layout stability
- self.button_box = Box(
- name="button-box-c",
- h_expand=False,
- spacing=2,
- )
-
- # 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"),
- )
-
- # Fixed size buttons to prevent layout shifts
- self.play_pause_button = Button(
- name="player-button",
- child=self.play_pause_icon,
- on_clicked=self.player.play_pause,
- )
- # Set consistent button size
-
- 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.button_box.children = (
- self.play_pause_button,
- self.next_button,
- )
-
- # Assign button_box to controls_box for compatibility
- self.controls_box = self.button_box
-
- self.player_info_box = Box(
- name="player-info-box-c",
- v_align="center",
- h_expand=True,
- h_align="start",
- orientation="v",
- children=[
- self.track_info,
- ],
- )
-
- self.inner_box = Box(
- name="inner-player-box",
- h_expand=True,
- v_align="center",
- h_align="start",
- children=[
- self.image,
- self.player_info_box,
- ],
- )
- # resize the inner box
- self.outer_box = Button(
- 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(
- name="box-c",
- orientation="h",
- h_align="center",
- start_children=[
- self.outer_box,
- # self.stack_buttons_box,
- ],
- end_children=[
- self.button_box,
- ],
- )
-
- self.children = [
- *self.children,
- self.box,
- ]
-
- # Track signal connections for cleanup - store (object, handler_id) tuples
- connections = bulk_connect(
- self.player,
- {
- "exit": self._on_player_exit,
- "notify::playback-status": self._on_playback_change,
- "notify::metadata": self._on_metadata,
- },
- )
- # Store as (object, handler_id) tuples
- for handler_id in connections:
- self._signal_connections.append((self.player, handler_id))
-
- def destroy(self):
- """Clean up all resources when the widget is destroyed."""
- # Cancel any ongoing downloads
- self._download_cancelled = True
-
- # 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 signal: {e}")
- self._signal_connections.clear()
-
- # Clean up temp files
- self._cleanup_temp_files()
-
- super().destroy()
-
- def __del__(self):
- """Ensure cleanup happens even if player exits unexpectedly."""
- try:
- self._cleanup_temp_files()
- except Exception:
- pass # Ignore errors during cleanup in destructor
-
- def _on_prev_button_click(self, *_):
- """Handle prev button click: open expanded player in control center"""
- try:
- # Open expanded player in control center instead of new window
- if self.control_center and hasattr(
- self.control_center, "open_expanded_player"
- ):
- self.control_center.open_expanded_player()
- except Exception as e:
- logger.warning(f"Failed to handle prev button click: {e}")
-
- def _on_outer_box_clicked(self, *_):
- """Handle outer box click with proper error handling."""
- try:
- # Open expanded player in control center instead of new window
- if self.control_center and hasattr(
- self.control_center, "open_expanded_player"
- ):
- 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()
-
- def _cleanup_temp_files(self):
- """Clean up temporary artwork files."""
- for temp_file in self.temp_artwork_files:
- try:
- if os.path.exists(temp_file):
- os.unlink(temp_file)
- except Exception as e:
- logger.warning(f"Failed to cleanup temp file {temp_file}: {e}")
- self.temp_artwork_files.clear()
-
- def _on_player_exit(self, _, value):
- self.exit = value
- self._cleanup_temp_files() # Clean up temp files before destroying
- self.destroy()
-
- def _on_player_next(self, *_):
- self.player.next()
-
- def _on_player_prev(self, *_):
- self.player.previous()
-
- def _on_playback_change(self, player, status):
- status = player.get_property("playback-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)
-
- 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
-
- 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
-
- try:
- # Check if download was cancelled
- if self._download_cancelled:
- return
-
- # 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
-
- # Download artwork
- 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()
-
- # Check one more time if cancelled
- if self._download_cancelled:
- return
-
- # 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
-
- # Track temp file for cleanup
- if temp_file_path and not self._download_cancelled:
- self.temp_artwork_files.append(temp_file_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
- return
-
- # Only update UI if not cancelled
- if not self._download_cancelled:
- GLib.idle_add(self._update_image, local_arturl)
-
- def close_bluetooth(self, *args):
- """Placeholder method for compatibility"""
- pass
diff --git a/modules/controlcenter/wifi.py b/modules/controlcenter/wifi.py
deleted file mode 100644
index 6cb612d..0000000
--- a/modules/controlcenter/wifi.py
+++ /dev/null
@@ -1,715 +0,0 @@
-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
-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.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")
-
-
-class WifiNetworkSlot(Box):
- def __init__(self, access_point, wifi_service, parent=None, **kwargs):
- super().__init__(name="wifi-network-slot", **kwargs)
- self.access_point = access_point
- self.wifi_service = wifi_service
- self.parent = parent # Reference to control center
-
- # Get network info from AccessPoint object
- self.ssid = access_point.ssid
- self.bssid = access_point.bssid
- self.strength = access_point.strength
- self.icon_name = access_point.icon
-
- # Check if this network is currently connected
- self.is_connected = access_point.is_active
-
- # Initialize styles based on connection state
- self.styles = [
- "connected" if self.is_connected else "",
- ]
-
- # 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.wifi_icon_box = Box(
- children=[self.dimage],
- style_classes=["wifi-icon-box"],
- )
- # if self.is_connected:
- # self.wifi_icon_box.remove_style_class("wifi-icon-box")
- # self.wifi_icon_box.add_style_class("wifi-icon-box-connected")
- #
- # Set initial style classes
- if self.is_connected:
- self.dimage.add_style_class("connected")
-
- self.network_label = Label(
- label=self.ssid, name="wifi-network-name", h_align="start", h_expand=True
- )
-
- # Create lock icon for secured networks
- self.lock_icon = None
- if self.access_point.requires_password:
- self.lock_icon = Image(
- icon_name="changes-prevent-symbolic",
- size=12,
- name="wifi-lock-icon",
- )
-
- # Initialize password dialog
- self.password_dialog = None
-
- # Create the start section with WiFi icon and network name
- start_box = Box(
- orientation="h",
- spacing=8,
- )
- start_box.children = [self.wifi_icon_box, self.network_label]
- if self.lock_icon:
- start_box.children.append(self.lock_icon)
- # Create end section with lock icon if needed
-
- self.children = [
- Button(
- child=start_box,
- h_expand=True,
- name="wifi-network-button",
- on_clicked=lambda *_: self.toggle_connecting(),
- )
- ]
-
- # Emit initial change to update display
- self.on_changed()
-
- def toggle_connecting(self):
- # Check if this network is currently connected
- is_currently_connected = self.access_point.is_active
-
- if is_currently_connected:
- # Show disconnecting state using connecting icon
- connecting_icon = get_wifi_connecting_icon()
- self.dimage.set_from_file(connecting_icon)
- self.dimage.add_style_class("disconnecting")
-
- # Disconnect from network
- self.wifi_service.disconnect_wifi()
- self.is_connected = False
- # Remove disconnecting state after a short delay to show feedback
- GLib.timeout_add(500, lambda: self._reset_disconnect_state())
- else:
- # Try to connect - check if password is required
- if self.access_point.requires_password:
- # Show password dialog immediately
- self._show_password_dialog()
- else:
- # Try to connect without password (for open networks)
- connecting_icon = get_wifi_connecting_icon()
- self.dimage.set_from_file(connecting_icon)
- self.dimage.add_style_class("connecting")
-
- def on_open_connection_result(success, message):
- """Handle the connection result for open networks"""
- if success:
- self.is_connected = True
- # Remove connecting state after a short delay
- GLib.timeout_add(500, lambda: self._reset_connect_state())
- else:
- # Connection failed
- self._reset_connect_state()
-
- # Update display after connection attempt
- self.on_changed()
-
- try:
- self.wifi_service.connect_to_wifi(
- self.access_point, callback=on_open_connection_result
- )
- except Exception:
- # Handle any connection errors gracefully
- self._reset_connect_state()
- self.on_changed()
-
- # Update display after connection attempt
- self.on_changed()
-
- def _reset_disconnect_state(self):
- """Reset visual state after disconnect operation"""
- self.dimage.remove_style_class("disconnecting")
- wifi_icon_path = get_wifi_icon_for_strength(self.strength)
- self.dimage.set_from_file(wifi_icon_path)
- self.on_changed()
- return False # Remove timeout
-
- def _reset_connect_state(self):
- """Reset visual state after connect operation"""
- self.dimage.remove_style_class("connecting")
- wifi_icon_path = get_wifi_icon_for_strength(self.strength)
- self.dimage.set_from_file(wifi_icon_path)
- self.on_changed()
- return False # Remove timeout
-
- def on_changed(self, *_):
- # Check if this network is currently connected using the access point's is_active property
- self.is_connected = self.access_point.is_active
- self.styles = [
- "connected" if self.is_connected else "",
- ]
- # Update style classes for SVG widget
- if self.is_connected:
- self.wifi_icon_box.add_style_class("wifi-icon-box-connected")
- else:
- self.wifi_icon_box.remove_style_class("wifi-icon-box-connected")
- return
-
- def _show_password_dialog(self):
- """Show the WiFi password dialog"""
- # Close the control center first
- if self.parent and hasattr(self.parent, "hide_controlcenter"):
- self.parent.hide_controlcenter()
-
- # Create a new dialog each time to ensure clean state
- if self.password_dialog:
- self.password_dialog.destroy_dialog()
-
- self.password_dialog = WiFiPasswordDialog(
- ssid=self.ssid,
- on_connect_callback=self._on_password_connect,
- on_cancel_callback=self._on_password_cancel,
- )
-
- self.password_dialog.show_dialog()
-
- def _on_password_connect(self, ssid, password):
- """Handle password dialog connect action"""
- if password.strip():
- # Show connecting state using connecting icon
- connecting_icon = get_wifi_connecting_icon()
- self.dimage.set_from_file(connecting_icon)
- self.dimage.add_style_class("connecting")
-
- # Try to connect with password using callback
- def on_connection_result(success, message):
- """Handle the connection result"""
- 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())
-
- # Clear any timeout in the password dialog
- if (
- self.password_dialog
- and self.password_dialog.connection_timeout_id
- ):
- GLib.source_remove(self.password_dialog.connection_timeout_id)
- self.password_dialog.connection_timeout_id = None
- self.password_dialog.is_connecting = False
- else:
- # Connection failed - show error in dialog
- self._reset_connect_state()
- if self.password_dialog:
- self._show_connection_error(message)
-
- # Update display after connection attempt
- self.on_changed()
-
- try:
- self.wifi_service.connect_to_wifi(
- self.access_point, password, callback=on_connection_result
- )
- except Exception:
- # Handle any connection errors gracefully
- self._reset_connect_state()
- if self.password_dialog:
- self._show_connection_error("Connection failed. Please try again.")
- self.on_changed()
-
- def _show_connection_error(self, message="Incorrect password. Please try again."):
- """Show connection error in a separate thread to prevent UI blocking"""
- if self.password_dialog:
- self.password_dialog.show_error(message)
- return False # Don't repeat if called from GLib.timeout_add
-
- def _on_password_cancel(self):
- """Handle password dialog cancel action"""
- # Reset any connecting state
- self._reset_connect_state()
-
-
-class WifiConnections(Box):
- def __init__(self, parent, show_back_button=True, **kwargs):
- super().__init__(
- spacing=8,
- orientation="vertical",
- name="wifi-connections",
- **kwargs,
- )
-
- self.parent = parent
- self.network_service = NetworkClient()
- self.wifi_service = None
- self.is_scanning = False # Track scanning state
- 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)
-
- # Create pull-to-refresh indicator
- self.refresh_indicator = Label(
- name="wifi-refresh-indicator",
- label="โ Pull to scan for networks",
- 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_wifi(),
- )
- )
- title_children.append(Label("Wi-Fi", name="wifi-title"))
-
- self.title = Box(
- orientation="h",
- children=title_children,
- )
-
- self.toggle_button = Gtk.Switch(visible=True, name="toggle-button")
-
- # Create Known Network section
- self.known_networks_label = Label(
- label="Known Network", h_align="start", name="networks-title"
- )
- self.known_networks = Box(
- spacing=4, orientation="vertical", name="known-networks"
- )
-
- # Create "No networks available" message
- self.no_networks_label = Label(
- label="No networks available",
- h_align="center",
- name="no-networks-label",
- visible=False,
- )
-
- # Create Other Networks section with clickable title
- self.other_networks_button = Button(
- child=Label("Other Networks", h_align="start"),
- name="wifi-other-button",
- on_clicked=self.toggle_other_networks,
- )
- self.other_networks = Box(spacing=4, orientation="vertical")
-
- # Create scrolled window for other networks
- self.other_networks_scrolled = ScrolledWindow(
- min_content_size=(303, 150),
- child=self.other_networks,
- overlay_scroll=True,
- )
-
- # Add pull-to-refresh functionality to scrolled window
- self.setup_pull_to_refresh()
-
- # Create revealer for Other Networks section
- self.other_networks_revealer = Revealer(
- child=self.other_networks_scrolled,
- transition_type="slide-down",
- transition_duration=100,
- child_revealed=False,
- )
-
- # Create More Settings button (same style as Other Networks button)
- self.more_settings_button = Button(
- child=Label("More Settings", h_align="start"),
- name="wifi-other-button",
- on_clicked=self.open_network_settings,
- )
-
- self.children = [
- CenterBox(
- start_children=self.title,
- end_children=self.toggle_button,
- name="wifi-widget-top",
- ),
- self.refresh_indicator,
- Separator(orientation="h", name="separator"),
- self.known_networks_label,
- self.known_networks,
- self.no_networks_label,
- Separator(orientation="h", name="separator"),
- self.other_networks_button,
- self.other_networks_revealer,
- Separator(orientation="h", name="separator"),
- self.more_settings_button,
- ]
-
- # Connect cleanup on destroy
- self.connect("destroy", self.on_destroy)
-
- # Start periodic network monitoring for real-time updates
- self.start_network_monitoring()
-
- def toggle_other_networks(self, *_):
- """Toggle the visibility of other networks section"""
- current_state = self.other_networks_revealer.child_revealed
- self.other_networks_revealer.child_revealed = not current_state
-
- # Update button text based on state
- if self.other_networks_revealer.child_revealed:
- # Trigger a scan when revealing other networks and force refresh
- if self.wifi_service:
- self.wifi_service.scan()
- # Also force an immediate network refresh to catch any missed connections
- self.force_network_refresh()
-
- def on_network_ready(self, *_):
- """Called when network service is ready"""
- self.wifi_service = self.network_service.wifi_device
- if self.wifi_service:
- # Set up WiFi toggle
- 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
- )
- 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()
-
- def on_toggle_changed(self, toggle_button, *_):
- """Handle WiFi toggle button changes"""
- if self.wifi_service:
- new_state = toggle_button.get_active()
- self.wifi_service.wireless_enabled = new_state
-
- def on_wifi_enabled_changed(self, *_):
- """Handle WiFi enabled state changes"""
- if self.wifi_service:
- self.toggle_button.set_active(self.wifi_service.wireless_enabled)
-
- def open_network_settings(self, *_):
- """Open NetworkManager connection editor"""
- try:
- subprocess.Popen(["nm-connection-editor"], 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_networks(self, *_):
- """Update the list of available networks"""
- # Prevent concurrent updates and check if destroyed
- if self._update_in_progress or self._destroyed or not self.wifi_service:
- return
-
- self._update_in_progress = True
-
- try:
- # Store current network SSIDs to detect changes
- current_known_ssids = {
- child.ssid
- for child in self.known_networks.get_children()
- if hasattr(child, "ssid")
- }
- current_other_ssids = {
- child.ssid
- for child in self.other_networks.get_children()
- if hasattr(child, "ssid")
- }
-
- # Get current networks
- access_points = self.wifi_service.access_points
- known_networks = []
- other_networks = []
- new_known_ssids = set()
- new_other_ssids = set()
-
- for access_point in access_points:
- try:
- if access_point.ssid and access_point.ssid != "Unknown":
- # Categorize networks: connected or saved networks go to "Known Network"
- # All others go to "Other Networks"
- if access_point.is_active or self._is_saved_network(
- access_point
- ):
- known_networks.append(access_point)
- new_known_ssids.add(access_point.ssid)
- else:
- other_networks.append(access_point)
- new_other_ssids.add(access_point.ssid)
- except Exception:
- continue
-
- # Check if we need to update (networks added/removed)
- known_changed = current_known_ssids != new_known_ssids
- other_changed = current_other_ssids != new_other_ssids
-
- # Only rebuild if something actually changed
- if known_changed or other_changed:
- # Clear existing networks safely
- for child in list(self.known_networks.get_children()):
- if not self._destroyed:
- child.destroy()
- for child in list(self.other_networks.get_children()):
- if not self._destroyed:
- child.destroy()
-
- # Add known networks
- for access_point in known_networks:
- if not self._destroyed:
- network_slot = WifiNetworkSlot(
- access_point, self.wifi_service, parent=self.parent
- )
- self.known_networks.add(network_slot)
-
- # Add other networks
- for access_point in other_networks:
- if not self._destroyed:
- network_slot = WifiNetworkSlot(
- access_point, self.wifi_service, parent=self.parent
- )
- self.other_networks.add(network_slot)
-
- # Show/hide sections based on available networks
- if not self._destroyed:
- has_known_networks = len(known_networks) > 0
- has_other_networks = len(other_networks) > 0
- has_any_networks = has_known_networks or has_other_networks
-
- # Show known networks section only if there are known networks
- self.known_networks_label.set_visible(has_known_networks)
- self.known_networks.set_visible(has_known_networks)
-
- # Show "No networks available" message if no networks at all
- self.no_networks_label.set_visible(not has_any_networks)
-
- # Always show the other networks button, regardless of available networks
- self.other_networks_button.set_visible(True) # Always visible
-
- # Update all network connection states
- self.refresh_network_states()
-
- except Exception:
- pass
- finally:
- self._update_in_progress = False
-
- def _is_saved_network(self, access_point):
- """Check if a network is saved/known using NetworkManager connections"""
- if not self.network_service or not self.network_service._client:
- return False
-
- try:
- ssid = access_point.ssid
- if not ssid or ssid == "Unknown":
- return False
-
- # Get all saved connections from NetworkManager
- connections = self.network_service._client.get_connections()
-
- for connection in connections:
- # Check if this is a WiFi connection
- if connection.get_connection_type() != "802-11-wireless":
- continue
-
- # Get the wireless setting
- wifi_setting = connection.get_setting_wireless()
- if not wifi_setting:
- continue
-
- # 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()
- )
- if connection_ssid == ssid:
- return True
-
- except Exception:
- pass
-
- return False
-
- def refresh_network_states(self, *_):
- """Refresh connection states for all network slots"""
- # Refresh known networks
- for child in self.known_networks.get_children():
- if hasattr(child, "on_changed"):
- child.on_changed()
-
- # Refresh other networks
- for child in self.other_networks.get_children():
- if hasattr(child, "on_changed"):
- child.on_changed()
-
- def start_network_monitoring(self):
- """Start periodic monitoring for network changes"""
- # Monitor for network changes every 5 seconds
- # This helps catch networks that connect from external sources
- self.refresh_timer = GLib.timeout_add_seconds(5, self.periodic_network_refresh)
-
- def stop_network_monitoring(self):
- """Stop periodic monitoring"""
- if self.refresh_timer:
- GLib.source_remove(self.refresh_timer)
- self.refresh_timer = None
-
- 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 (
- 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
-
- return True # Continue monitoring
-
- def force_network_refresh(self):
- """Force an immediate refresh of the network list"""
- if self._update_in_progress or self._destroyed:
- return
-
- try:
- # Simply trigger update_networks which has its own safety checks
- self.update_networks()
- except Exception:
- pass
-
- 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_networks_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_networks_scrolled.connect("scroll-event", self.on_scroll_event)
- self.other_networks_scrolled.connect("button-press-event", self.on_button_press)
- self.other_networks_scrolled.connect(
- "button-release-event", self.on_button_release
- )
- self.other_networks_scrolled.connect(
- "motion-notify-event", self.on_motion_notify
- )
-
- # Enable events
- self.other_networks_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 - trigger scan and force refresh
- if self.wifi_service:
- self.wifi_service.scan()
- self.force_network_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:
- # Trigger scan and force refresh
- if self.wifi_service:
- self.wifi_service.scan()
- self.force_network_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:
- self.refresh_indicator.set_label("โ Release to scan")
- self.refresh_indicator.add_style_class("ready-to-refresh")
- else:
- self.refresh_indicator.set_label("โ Pull to scan for networks")
- self.refresh_indicator.remove_style_class("ready-to-refresh")
- else:
- self.refresh_indicator.set_visible(False)
- return False
-
- def on_destroy(self, widget):
- """Cleanup when widget is destroyed"""
- # Mark as destroyed to prevent further updates
- self._destroyed = True
- # Stop monitoring
- self.stop_network_monitoring()
- # Make sure other networks revealer is collapsed when closing
- try:
- self.other_networks_revealer.child_revealed = False
- except:
- pass # Widget might already be destroyed
-
- def close_wifi(self):
- """Called when WiFi panel is being closed"""
- # Collapse the other networks section when closing
- self.other_networks_revealer.child_revealed = False
diff --git a/modules/corners.py b/modules/corners.py
deleted file mode 100644
index cf3c79e..0000000
--- 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/dock.py b/modules/dock.py
deleted file mode 100644
index 0c836f4..0000000
--- a/modules/dock.py
+++ /dev/null
@@ -1,843 +0,0 @@
-import json
-import os
-import re
-import subprocess
-
-from fabric.utils.helpers import get_desktop_applications, get_relative_path
-from fabric.widgets.box import Box
-from fabric.widgets.button import Button
-from fabric.widgets.eventbox import EventBox
-from fabric.widgets.image import Image
-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
-
-import config.data as data
-from services.modus import modus_service
-from utils.functions import read_json_file, write_json_file, is_special_workspace_id
-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")
-
-
-class AppBar(Box):
- def __init__(self, parent: Window):
- self.client_buttons = {} # For running app instances
- self.pinned_buttons = {} # For pinned apps
- # Position tracking for hover effects
- self.running_items_pos = []
- 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,
- children=[],
- )
- self.icon_resolver = IconResolver()
- self._hyprland_connection = modus_service._hyprland_connection
-
- # Initialize GTK menu
- self.menu = Gtk.Menu()
-
- self.pinned_apps = read_json_file(PINNED_APPS_FILE) or []
- self.pinned_apps_container = Box()
- self.add(self.pinned_apps_container)
-
- self.separator = Box(
- v_align="center", style_classes=["hidden", "dock_separator"]
- )
- self.add(self.separator)
-
- self.running_apps_container = Box(name="dock_container")
- self.add(self.running_apps_container)
-
- self._populate_pinned_apps()
- self.setup_app_monitoring()
-
- def setup_app_monitoring(self):
- def update_running_apps():
- try:
- self.update_dock_apps()
- except Exception as e:
- logger.error(f"[AppBar] Error updating apps: {e}")
- return True
-
- GLib.timeout_add(250, update_running_apps)
- GLib.idle_add(self.update_dock_apps)
-
- def _populate_pinned_apps(self):
- for child in self.pinned_apps_container.get_children():
- self.pinned_apps_container.remove(child)
-
- self.pinned_buttons = {}
- self.pinned_items_pos = []
-
- try:
- desktop_apps = get_desktop_applications(include_hidden=False)
- except Exception:
- desktop_apps = []
-
- for app_data in self.pinned_apps:
- self._create_pinned_button(app_data, desktop_apps)
-
- # Add trash icon at the end of pinned apps
- 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", ""
- )
- 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)
-
- main_container = Box(
- name="dock_item_main_container",
- orientation="v",
- children=[pinned_image],
- )
-
- pinned_button = Button(
- name="dock_item",
- child=main_container,
- tooltip_text=display_name,
- 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_leave_notify_event=lambda *_: self._handle_item_unhovered(
- pinned_button, True
- ),
- )
-
- pinned_button.add_style_class("shown")
-
- 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
- )
-
- trash_image = Image(name="dock_item_icon")
- trash_image.set_from_pixbuf(trash_icon_pixbuf)
-
- main_container = Box(
- name="dock_item_main_container",
- orientation="v",
- children=[trash_image],
- )
-
- trash_button = Button(
- name="dock_item",
- 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_leave_notify_event=lambda *_: self._handle_item_unhovered(
- trash_button, True
- ),
- )
-
- trash_button.add_style_class("shown")
- trash_button.is_trash = True
-
- 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):
- 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()
- )
- )
- ):
- return app
- return None
-
- def _find_desktop_app_by_id(self, app_id: str, desktop_apps):
- 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()
- )
- )
- ):
- return app
- return None
-
- def show_menu(self, app_id: str, client=None, instance_address=None):
- for item in self.menu.get_children():
- self.menu.remove(item)
- item.destroy()
-
- if client or instance_address:
- close_item = Gtk.MenuItem(label="Close")
- if instance_address:
- close_item.connect(
- "activate", lambda *_: self._close_running_app(instance_address)
- )
- self.menu.add(close_item)
-
- if app_id:
- separator = Gtk.SeparatorMenuItem()
- self.menu.add(separator)
-
- if app_id:
- is_pinned = self._is_app_pinned(app_id)
- pin_item = Gtk.MenuItem(label="Unpin" if is_pinned else "Pin")
-
- if is_pinned:
- pin_item.connect("activate", lambda *_: self._unpin_app(app_id))
- else:
- pin_item.connect("activate", lambda *_: self._pin_app(app_id))
-
- self.menu.add(pin_item)
-
- self.menu.show_all()
-
- def _close_running_app(self, instance_address):
- try:
- self._hyprland_connection.send_command(
- f"dispatch closewindow address:{instance_address}"
- )
- except Exception as e:
- logger.error(f"[AppBar] Error closing window: {e}")
-
- def _handle_pinned_app_click(self, event, app_data):
- if event.button == 1: # Left click - launch app
- self._launch_app_data(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)
-
- def _handle_trash_click(self, event):
- """Handle trash button click to open trash in file manager"""
- if event.button == 1: # Left click
- try:
- trash_path = os.path.expanduser("~/.local/share/Trash/files")
- file_managers = [
- "nautilus",
- "dolphin",
- "thunar",
- "nemo",
- "caja",
- "pcmanfm",
- ]
-
- for fm in file_managers:
- try:
- result = subprocess.run(
- ["which", fm], capture_output=True, text=True
- )
- if result.returncode == 0:
- subprocess.Popen([fm, trash_path])
- return
- except Exception:
- continue
- 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 _launch_app_data(self, app_data):
- try:
- desktop_apps = get_desktop_applications(include_hidden=False)
-
- 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 app:
- self._launch_app(app)
- except Exception as e:
- logger.error(f"[AppBar] Failed to launch app: {e}")
-
- 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)
- else:
- logger.error(
- f"[AppBar] No command or executable found for app: {app_data}"
- )
- except Exception as e:
- logger.error(f"[AppBar] Failed to launch app from data: {e}")
-
- def _pin_app(self, app_class: str):
- if self._is_app_pinned(app_class):
- return False
-
- 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,
- }
-
- self.pinned_apps.append(app_data)
- except Exception:
- self.pinned_apps.append(app_class)
-
- write_json_file(self.pinned_apps, PINNED_APPS_FILE)
- self._populate_pinned_apps()
- return True
-
- def _unpin_app(self, app_identifier: str):
- apps_to_remove = []
-
- for i, pinned_app in enumerate(self.pinned_apps):
- if self._matches_app_identifier(pinned_app, app_identifier):
- apps_to_remove.append(i)
-
- for i in reversed(apps_to_remove):
- self.pinned_apps.pop(i)
-
- if apps_to_remove:
- write_json_file(self.pinned_apps, PINNED_APPS_FILE)
- self._populate_pinned_apps()
- return True
- return False
-
- def _matches_app_identifier(self, pinned_app, app_identifier):
- if not app_identifier:
- return False
-
- if isinstance(pinned_app, dict):
- window_class = pinned_app.get("window_class") or ""
- name = pinned_app.get("name") or ""
- return (
- window_class.lower() == app_identifier.lower()
- or name.lower() == app_identifier.lower()
- )
- return (
- isinstance(pinned_app, str) and pinned_app.lower() == app_identifier.lower()
- )
-
- def get_clients(self):
- try:
- clients_data = self._hyprland_connection.send_command("j/clients").reply
- if not clients_data:
- return []
- return json.loads(clients_data.decode("utf-8"))
- except Exception as e:
- logger.error(f"[AppBar] Error getting clients: {e}")
- return []
-
- def get_focused_window(self):
- try:
- active_data = self._hyprland_connection.send_command("j/activewindow").reply
- if not active_data:
- return None
- return json.loads(active_data.decode("utf-8"))
- except Exception as e:
- logger.error(f"[AppBar] Error getting focused window: {e}")
- return None
-
- 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 ""
-
- current_instance_ids = set()
-
- for client in clients:
- if client.get("hidden", False) or not self._should_show_app_instance(client):
- continue
-
- instance_address = client.get("address", "")
- app_class = client.get("class", "") or client.get("title", "")
- if not instance_address or not app_class:
- continue
-
- current_instance_ids.add(instance_address)
-
- if instance_address not in self.client_buttons:
- self.create_instance_button(instance_address, client, app_class)
- else:
- self.update_instance_button(instance_address, client, app_class)
-
- button = self.client_buttons[instance_address]
- if instance_address == focused_address:
- button.add_style_class("activated")
- else:
- button.remove_style_class("activated")
-
- self._update_pinned_apps_state(clients)
- 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}")
-
- def _update_pinned_apps_state(self, clients):
- running_app_classes = {
- client.get("class", "").lower() or client.get("title", "").lower()
- for client in clients
- if not client.get("hidden", False)
- and (client.get("class") or client.get("title"))
- and self._should_show_app_instance(client)
- }
-
- for app_identifier, button in self.pinned_buttons.items():
- # Skip trash button as it's not a regular app
- if app_identifier == "trash" or hasattr(button, "is_trash"):
- continue
-
- if app_identifier.lower() in running_app_classes:
- button.add_style_class("instance")
- else:
- button.remove_style_class("instance")
-
- def _cleanup_removed_instances(self, current_instance_ids):
- buttons_to_remove = [
- instance_id
- for instance_id in self.client_buttons.keys()
- if instance_id not in 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()]:
- if instance_id in self.client_buttons:
- button = self.client_buttons.pop(instance_id)
- try:
- if button in self.running_items_pos:
- self.running_items_pos.remove(button)
- button.remove_style_class("shown")
- button.remove_style_class("activated")
- if button.get_parent():
- button.get_parent().remove(button)
- button.destroy()
- except Exception as e:
- logger.warning(f"[AppBar] Error during cleanup: {e}")
-
- def create_instance_button(self, instance_address, client, app_class):
- try:
- client_image = Image(name="dock_item_icon")
-
- 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
- )
-
- client_image.set_from_pixbuf(pixbuf)
- except Exception as e:
- logger.warning(f"[AppBar] Could not load icon for {app_class}: {e}")
-
- workspace_id = self._get_workspace_id(client)
- workspace_label = None
- if workspace_id is not None:
- workspace_label = Label(
- label=str(workspace_id),
- name="workspace-indicator",
- h_align="end",
- v_align="end",
- )
-
- image_overlay = Overlay(name="dock-image-overlay", child=client_image)
- if workspace_label:
- image_overlay.add_overlay(workspace_label)
-
- indicator = Box(name="dock_item_indicator", h_align="center")
- main_container = Box(
- name="dock_item_main_container",
- orientation="v",
- children=[image_overlay, indicator],
- )
-
- tooltip_text = client.get("title", app_class)
- if tooltip_text != app_class:
- tooltip_text = f"{app_class}: {tooltip_text}"
-
- client_button = Button(
- name="dock_item",
- child=main_container,
- tooltip_text=tooltip_text,
- 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_leave_notify_event=lambda *_: self._handle_item_unhovered(
- client_button, False
- ),
- )
-
- client_button.instance_address = instance_address
- client_button.client_data = client
- client_button.app_class = app_class
- client_button.workspace_label = workspace_label
- 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}")
-
- def _get_workspace_id(self, client):
- workspace_data = client.get("workspace", {})
- if isinstance(workspace_data, dict):
- return workspace_data.get("id")
- elif isinstance(workspace_data, (int, str)):
- 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:
- 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)
-
- def update_instance_button(self, instance_address, client, app_class):
- if instance_address not in self.client_buttons:
- return
-
- button = self.client_buttons[instance_address]
- button.client_data = client
- button.app_class = app_class
-
- tooltip_text = client.get("title", app_class)
- if tooltip_text != app_class:
- tooltip_text = f"{app_class}: {tooltip_text}"
- button.set_tooltip_text(tooltip_text)
-
- workspace_id = self._get_workspace_id(client)
- 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
-
- def handle_instance_click(self, button_widget, event):
- instance_address = getattr(button_widget, "instance_address", None)
- app_class = getattr(button_widget, "app_class", None)
-
- if event.button == 1: # Left click - focus window
- if instance_address:
- try:
- self._hyprland_connection.send_command(
- f"dispatch focuswindow address:{instance_address}"
- )
- except Exception as e:
- logger.error(f"[AppBar] Error focusing window: {e}")
-
- elif event.button == 2: # Middle click - pin/unpin app
- if app_class and not self._is_app_pinned(app_class):
- self._pin_app(app_class)
-
- elif event.button == 3: # Right click - context menu
- if app_class:
- self.show_menu(app_class, instance_address=instance_address)
- self.menu.popup_at_pointer(event)
-
- def _is_app_pinned(self, app_class: str) -> bool:
- return any(
- self._matches_app_identifier(pinned_app, app_class)
- for pinned_app in self.pinned_apps
- )
-
- def _update_separator_visibility(self):
- has_pinned_apps = len(self.pinned_items_pos) > 0
- has_running_apps = len(self.running_items_pos) > 0
- if has_pinned_apps and has_running_apps:
- self.separator.remove_style_class("hidden")
- else:
- self.separator.add_style_class("hidden")
-
-
-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)
-
- 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,
- )
-
- self.children = EventBox(
- events=["enter-notify", "leave-notify"],
- child=Box(style="min-height: 1px", children=self.revealer),
- on_enter_notify_event=lambda *_: self.on_hover_enter(),
- on_leave_notify_event=lambda *_: self.on_hover_leave(),
- )
-
- self.revealer.set_reveal_child(True)
- self.app_bar.add_style_class("shown")
-
- self.dock_height = 100
- self.is_hovered = False
- self.hide_timeout_id = None
-
- # Only setup occlusion monitoring if auto-hide is enabled
- if data.DOCK_AUTO_HIDE:
- self.setup_occlusion_monitoring()
-
- 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.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)
-
- def setup_occlusion_monitoring(self):
- def check_dock_occlusion():
- try:
- if data.DOCK_ALWAYS_OCCLUDED:
- is_occluded = True
- else:
- occlusion_position = self._get_occlusion_position()
- is_occluded = check_occlusion(occlusion_position)
-
- if (
- is_occluded
- and not self.is_hovered
- and self.revealer.get_reveal_child()
- ):
- self.revealer.set_reveal_child(False)
- self.app_bar.remove_style_class("shown")
- elif not is_occluded and not self.revealer.get_reveal_child():
- self.revealer.set_reveal_child(True)
- self.app_bar.add_style_class("shown")
- elif is_occluded and self.is_hovered:
- if not self.revealer.get_reveal_child():
- self.revealer.set_reveal_child(True)
- self.app_bar.add_style_class("shown")
- except Exception as e:
- logger.error(f"[Dock] Occlusion check error: {e}")
-
- return True
-
- GLib.timeout_add(300, check_dock_occlusion)
diff --git a/modules/launcher/__init__.py b/modules/launcher/__init__.py
deleted file mode 100644
index 547fa73..0000000
--- a/modules/launcher/__init__.py
+++ /dev/null
@@ -1,8 +0,0 @@
-"""
-Plugin-based launcher module for Fabric.
-Similar to Albert Launcher with extensible plugin system.
-"""
-
-from .main import Launcher
-
-__all__ = ["Launcher"]
diff --git a/modules/launcher/main.py b/modules/launcher/main.py
deleted file mode 100644
index 30ada34..0000000
--- a/modules/launcher/main.py
+++ /dev/null
@@ -1,1380 +0,0 @@
-from typing import List, Optional, Tuple
-
-from gi.repository import Gdk, GLib
-
-from fabric.core.service import Property
-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
-
-# Constants
-SEARCH_DEBOUNCE_MS = 50
-TRIGGER_SEARCH_DEBOUNCE_MS = 50
-CURSOR_POSITION_DELAY_MS = 10
-SCROLL_PADDING = 10
-DEFAULT_ITEM_HEIGHT = 68
-PAGE_NAVIGATION_STEP = 5
-LAUNCHER_WIDTH = 640
-LAUNCHER_HEIGHT = 400
-
-
-class Launcher(Window):
- """
- Main launcher window with search functionality and plugin system.
- Spotlight-style interface.
- """
-
- # Properties
- query = Property(str, flags="read-write", default_value="")
- visible = Property(bool, flags="read-write", default_value=False)
- active_trigger = Property(str, flags="read-write", default_value="")
-
- def __init__(self, **kwargs):
- super().__init__(
- name="launcher-window",
- title="modus-launcher",
- layer="top",
- anchor="center",
- exclusivity="none",
- keyboard_mode="exclusive",
- visible=False, # Start hidden until explicitly shown
- **kwargs,
- )
- self._initializing = True
- self._auto_adding_space = False
- self._processing_backspace = False
- self.plugin_manager = PluginManager()
- self.trigger_config = TriggerConfig()
-
- self.results: List[Result] = []
- self.selected_index = 0
- self.max_results = 5 # Show 4 applications by default instead of list
-
- # Trigger system
- self.triggered_plugin = None # Currently active triggered plugin
- self.active_trigger = "" # Currently active trigger keyword
- self.query = "" # Current search query
- self.visible = False # Launcher visibility state
- self.opened_with_trigger = False # Whether launcher was opened with a trigger
-
- # Focus management
- self.focus_mode = "search" # "search", "results"
-
- # Mouse activity tracking
- self._mouse_active = False
- self._last_mouse_activity = 0
- self._mouse_activity_timeout = 1000 # 1 second timeout
- self._keyboard_used_recently = False
- self._last_keyboard_activity = 0
- self._keyboard_activity_timeout = 2000 # 2 second timeout
- self._launcher_just_opened = False
- self._mouse_interaction_delay = 500 # 500ms delay after opening
-
- # Setup UI
- main_box = Box(
- name="launcher",
- orientation="v",
- spacing=0,
- h_align="center",
- v_align="center",
- )
- self.add(main_box)
-
- # Search entry with Spotlight-style large text
- self.search_entry = Entry(
- name="launcher-search",
- placeholder="Spotlight Search",
- h_expand=True,
- h_align="fill",
- notify_text=lambda entry, *_: self._on_search_changed(entry),
- )
- self.search_entry.connect("changed", self._on_search_changed)
- self.search_entry.connect("activate", self._on_entry_activate)
-
- self.header_box = Box(
- name="header_box",
- spacing=10,
- orientation="h",
- children=[
- self.search_entry,
- ],
- )
-
- main_box.add(self.header_box)
-
- self.results_scroll = ScrolledWindow(
- name="launcher-results-scroll",
- h_scrollbar_policy="never",
- min_content_size=(LAUNCHER_WIDTH, LAUNCHER_HEIGHT),
- max_content_size=(LAUNCHER_WIDTH, LAUNCHER_HEIGHT),
- propagate_width=False,
- propagate_height=False,
- )
-
- self.results_box = Box(
- name="launcher-results",
- orientation="v",
- spacing=0,
- )
- self.results_scroll.add(self.results_box)
- main_box.add(self.results_scroll)
-
- # Keep the results container visible initially
-
- self.connect("key-press-event", self._on_key_press)
-
- # Connect mouse events to track activity
- self.results_scroll.connect("button-press-event", self._on_mouse_activity)
- self.results_scroll.connect("motion-notify-event", self._on_mouse_activity)
- self.results_scroll.connect("scroll-event", self._on_mouse_activity)
-
- # Also track mouse activity on the main window for better coverage
- self.connect("button-press-event", self._on_mouse_activity)
- self.connect("motion-notify-event", self._on_mouse_activity)
-
- self.hide()
-
- # Mark initialization as complete
- self._initializing = False
-
- # Hide trigger suggestions at startup
- self._clear_results()
-
- def show_launcher(self, trigger_keyword: str = None, external: bool = False):
- """Show the launcher and focus the search entry, or execute command externally.
-
- Args:
- trigger_keyword: Optional trigger keyword to activate immediately (e.g., "google", "calc", "app")
- external: If True, execute the command without showing the launcher UI
- """
- # Reset mouse activity tracking when launcher is shown
- self._mouse_active = False
- self._last_mouse_activity = 0
- self._keyboard_used_recently = False
- self._last_keyboard_activity = 0
- self._launcher_just_opened = True
-
- # Schedule to allow mouse interactions after a delay
- def enable_mouse_interactions():
- self._launcher_just_opened = False
- return False
-
- GLib.timeout_add(self._mouse_interaction_delay, enable_mouse_interactions)
-
- if external and trigger_keyword:
- # Execute command externally without showing launcher
- return self._execute_external_command(trigger_keyword)
-
- self.show_all()
-
- if trigger_keyword:
- # Set flag to track that launcher was opened with a trigger keyword
- self.opened_with_trigger = True
-
- # Set the trigger keyword with a space and activate trigger mode
- trigger_text = f"{trigger_keyword} "
- self.search_entry.set_text(trigger_text)
-
- # Detect and activate the trigger
- triggered_plugin, detected_trigger = self._detect_trigger(trigger_text)
- if triggered_plugin:
- self.triggered_plugin = triggered_plugin
- self.active_trigger = detected_trigger
-
- # Query the plugin with empty string to show default options
- try:
- results = triggered_plugin.query("")
- self.results = results
- self.selected_index = 0
- self._update_results_display()
- except Exception as e:
- print(
- f"Error querying triggered plugin {triggered_plugin.name}: {e}"
- )
- self._clear_results()
- else:
- # Trigger not found, clear and show error or fallback
- self.search_entry.set_text("")
- self._clear_results()
- else:
- # Normal launcher opening - show applications
- self.opened_with_trigger = False
- self.search_entry.set_text("")
- # Trigger initial search to show applications immediately
- self._perform_search("")
-
- # Reset focus mode to search
- self.focus_mode = "search"
-
- # Focus search entry without selecting text
- if trigger_keyword:
- # For trigger keywords, we want the cursor at the end
- self.search_entry.grab_focus()
-
- def position_cursor():
- if hasattr(self.search_entry, "set_position"):
- self.search_entry.set_position(-1) # Move caret to end
- return False # Only run once
-
- GLib.idle_add(position_cursor)
- else:
- # For normal opening, use our method that prevents text selection
- self._focus_search_entry_without_selection()
-
- self.visible = True
-
- def _position_cursor_at_end(self, text_length: Optional[int] = None) -> None:
- """Position cursor at the end of search entry text."""
- if text_length is None:
- text_length = len(self.search_entry.get_text())
-
- def position_cursor():
- if hasattr(self.search_entry, "set_position"):
- self.search_entry.set_position(-1) # Move caret to end
- if hasattr(self.search_entry, "select_region"):
- self.search_entry.select_region(
- text_length, text_length
- ) # No selection
- return False # Only run once
-
- GLib.idle_add(position_cursor)
-
- def _add_space_to_trigger(self, trigger_word: str) -> None:
- """Add space after trigger keyword and position cursor."""
- trigger_text_with_space = f"{trigger_word} "
-
- # Temporarily disable search change handling to prevent recursion
- self._auto_adding_space = True
- self.search_entry.set_text(trigger_text_with_space)
-
- # Position cursor at the end
- def position_cursor():
- if hasattr(self.search_entry, "set_position"):
- self.search_entry.set_position(-1) # Move caret to end
- if hasattr(self.search_entry, "select_region"):
- self.search_entry.select_region(
- len(trigger_text_with_space), len(trigger_text_with_space)
- ) # No selection
- self._auto_adding_space = False
- return False # Only run once
-
- GLib.idle_add(position_cursor)
-
- # Update query
- self.query = trigger_text_with_space
- return trigger_text_with_space
-
- def close_launcher(self):
- """Hide the launcher and clear search."""
- self.hide()
- self.search_entry.set_text("")
- self._clear_results()
- self.triggered_plugin = None
- self.active_trigger = ""
- self.visible = False
- self.opened_with_trigger = False
-
- def _on_search_changed(self, entry):
- """Handle search text changes."""
- # Skip if still initializing
- if getattr(self, "_initializing", True):
- return
-
- # Skip if we're automatically adding a space to prevent recursion
- if getattr(self, "_auto_adding_space", False):
- return
-
- # Skip if we're processing a backspace to prevent interference
- if getattr(self, "_processing_backspace", False):
- return
-
- # Reset focus to search when user types
- if self.focus_mode != "search":
- self.focus_mode = "search"
-
- query = entry.get_text().strip()
- self.query = query
-
- # If query is exactly ':', show all triggers
- if query == ":":
- self._show_available_triggers()
- # If query matches a trigger exactly (with or without space), handle trigger activation
- elif any(
- query == trig or query == f"{trig} "
- for trig in [
- t.strip()
- for p in self.plugin_manager.get_active_plugins()
- for t in p.get_triggers()
- ]
- ):
- # Check if we need to add space immediately for exact trigger matches
- if not query.endswith(" "):
- # This is an exact trigger match without space - add space immediately
- trigger_text_with_space = self._add_space_to_trigger(query)
- GLib.timeout_add(
- TRIGGER_SEARCH_DEBOUNCE_MS,
- self._perform_search,
- trigger_text_with_space,
- )
- else:
- # Already has space, proceed with normal search
- GLib.timeout_add(SEARCH_DEBOUNCE_MS, self._perform_search, query)
- elif query:
- # Debounce search to avoid too many queries
- GLib.timeout_add(SEARCH_DEBOUNCE_MS, self._perform_search, query)
- else:
- # Show applications when query is empty
- self._perform_search("")
-
- def _perform_search(self, query: str) -> bool:
- """Perform search across all plugins."""
- # Only search if query hasn't changed
- if query != self.query:
- return False
-
- if not query:
- # Empty query - show popular applications
- self.triggered_plugin = None
- self.active_trigger = ""
-
- # Get applications plugin and show popular apps
- applications_plugin = self._get_applications_plugin()
- if applications_plugin:
- try:
- # Query with empty string to get popular/frequently used applications
- all_results = applications_plugin.query("")
- # Limit to max_results for empty query
- self.results = all_results[: self.max_results]
- self.selected_index = 0
- self._update_results_display()
- except Exception as e:
- print(f"Error getting applications: {e}")
- self._clear_results()
- else:
- self._clear_results()
- return False
-
- # Check if we're already in trigger mode
- if self.triggered_plugin and self.active_trigger:
- # We're in trigger mode - search within the triggered plugin
- try:
- # Extract the search query after the trigger
- remaining_query = self._extract_query_after_trigger(
- query, self.active_trigger
- )
- all_results = self.triggered_plugin.query(remaining_query)
- except Exception as e:
- print(f"Error in triggered plugin {self.triggered_plugin.name}: {e}")
- all_results = []
- else:
- # Check for trigger activation
- triggered_plugin, trigger = self._detect_trigger(query)
-
- if triggered_plugin:
- # New trigger detected - check if we need to add space automatically
- trigger_word = trigger.strip()
- current_text = self.search_entry.get_text()
-
- # If the current text is exactly the trigger word (no space), add space automatically
- if (
- current_text.strip() == trigger_word
- and not current_text.endswith(" ")
- and not getattr(self, "_auto_adding_space", False)
- ):
- query = self._add_space_to_trigger(trigger_word)
-
- # Enter trigger mode
- self.triggered_plugin = triggered_plugin
- self.active_trigger = trigger
-
- # Extract search query after trigger
- remaining_query = self._extract_query_after_trigger(query, trigger)
-
- # Always call the plugin's query method, even with empty remaining query
- # This allows plugins to show default options when just the trigger is typed
- try:
- all_results = triggered_plugin.query(remaining_query)
- except Exception as e:
- print(f"Error in triggered plugin {triggered_plugin.name}: {e}")
- all_results = []
- else:
- # No trigger detected - search applications and show trigger suggestions
- self.triggered_plugin = None
- self.active_trigger = ""
-
- all_results = []
-
- # Search applications directly without trigger
- applications_plugin = self._get_applications_plugin()
- if applications_plugin:
- try:
- app_results = applications_plugin.query(query)
- all_results.extend(app_results)
- except Exception as e:
- print(f"Error searching applications: {e}")
-
- # Also show trigger suggestions if query matches trigger prefixes
- trigger_suggestions = self._get_trigger_suggestions(query)
- all_results.extend(trigger_suggestions)
-
- # Sort results by relevance score
- all_results.sort(key=lambda r: r.relevance, reverse=True)
-
- # Check if any results have bypass_max_results flag
- has_bypass = any(
- hasattr(r, "data") and r.data and r.data.get("bypass_max_results")
- for r in all_results
- )
-
- # Don't limit results for triggered plugin queries, only for global searches and trigger suggestions
- if self.triggered_plugin and self.active_trigger:
- # In trigger mode - show all results from the triggered plugin
- self.results = all_results
- elif not has_bypass:
- # Global search - check if it's an application search
- applications_plugin = self._get_applications_plugin()
- is_application_search = applications_plugin and any(
- hasattr(r, "plugin_name") and r.plugin_name == "Applications"
- for r in all_results
- )
-
- if is_application_search:
- # Don't limit application search results
- self.results = all_results
- else:
- # Apply max_results limit for other global searches and trigger suggestions
- self.results = all_results[: self.max_results]
- else:
- # Has bypass flag - show all results
- self.results = all_results
- self.selected_index = 0
-
- # Update UI
- self._update_results_display()
-
- return False # Don't repeat timeout
-
- def _extract_query_after_trigger(self, query: str, trigger: str) -> str:
- """
- Extract the search query after removing the trigger.
-
- Args:
- query: The full query string
- trigger: The trigger keyword
-
- Returns:
- The remaining query after the trigger
- """
- if not query or not trigger:
- return ""
-
- query_lower = query.lower()
- trigger_lower = trigger.lower()
-
- # Handle trigger with space (e.g., "app ")
- if trigger_lower.endswith(" ") and query_lower.startswith(trigger_lower):
- return query[len(trigger) :].strip()
-
- # Handle trigger without space (e.g., "app")
- trigger_word = trigger.strip().lower()
- if query_lower.startswith(trigger_word):
- # Check if it's followed by space or end of string
- if len(query) == len(trigger_word):
- return ""
- elif len(query) > len(trigger_word) and query[len(trigger_word)] == " ":
- return query[len(trigger_word) + 1 :].strip()
- elif len(query) > len(trigger_word):
- # No space after trigger word, extract rest
- return query[len(trigger_word) :].strip()
-
- return ""
-
- def _show_available_triggers(self):
- """Show available triggers when launcher is first opened."""
- trigger_suggestions = self._get_trigger_suggestions("")
- self.results = trigger_suggestions # Show all available triggers without limit
- self.selected_index = 0
- self._update_results_display()
-
- def _detect_trigger(self, query: str) -> Tuple[Optional[object], str]:
- """
- Detect if query starts with a trigger keyword.
-
- Args:
- query: The search query
-
- Returns:
- Tuple of (plugin, trigger) if triggered, (None, "") otherwise
- """
- if not query.strip():
- return None, ""
-
- # Check all plugins for triggers
- for plugin in self.plugin_manager.get_active_plugins():
- trigger = plugin.get_active_trigger(query)
- if trigger:
- return plugin, trigger
-
- return None, ""
-
- def _get_applications_plugin(self):
- """Get the applications plugin instance."""
- for plugin in self.plugin_manager.get_active_plugins():
- if (
- hasattr(plugin, "display_name")
- and plugin.display_name == "Applications"
- ):
- return plugin
- return None
-
- def _get_trigger_suggestions(self, query: str) -> List[Result]:
- """
- Get trigger suggestions based on the current query.
-
- Args:
- query: The search query
-
- Returns:
- List of Result objects showing available triggers
- """
- suggestions = []
- query_lower = query.lower().strip()
-
- # Get all available triggers from plugins
- all_triggers = {}
- for plugin in self.plugin_manager.get_active_plugins():
- triggers = plugin.get_triggers()
- for trigger in triggers:
- trigger_clean = trigger.strip()
- if trigger_clean not in all_triggers:
- all_triggers[trigger_clean] = {
- "plugin": plugin,
- "trigger": trigger,
- "examples": [],
- }
-
- # Get max examples to show from configuration
- max_examples = self.trigger_config.settings.get("max_examples_shown", 2)
-
- # Show trigger suggestions based on query
- if query_lower:
- # Show triggers that match the query
- for trigger_clean, _ in all_triggers.items():
- if trigger_clean.lower().startswith(query_lower):
- result = self._create_trigger_result(trigger_clean, max_examples)
- suggestions.append(result)
- else:
- # Empty query - show all available triggers
- for trigger_clean, _ in all_triggers.items():
- result = self._create_trigger_result(trigger_clean, max_examples)
- suggestions.append(result)
-
- return suggestions # Return all trigger suggestions without limit
-
- def _create_trigger_result(self, trigger_clean: str, max_examples: int) -> Result:
- """Create a Result object for a trigger suggestion."""
- examples = self.trigger_config.get_trigger_examples(trigger_clean)
- icon_name = self.trigger_config.get_trigger_icon(trigger_clean)
- description = self.trigger_config.get_trigger_description(trigger_clean)
-
- return Result(
- title=f"{trigger_clean}",
- subtitle=f"{description} - {', '.join(examples[:max_examples])}",
- icon_name=icon_name,
- action=lambda t=trigger_clean: self._activate_trigger(t),
- # Shorter triggers get higher relevance
- relevance=100 - len(trigger_clean),
- data={"type": "trigger_suggestion", "trigger": trigger_clean},
- )
-
- def _activate_trigger(self, trigger: str):
- """
- Activate a trigger by setting it in the search entry.
-
- Args:
- trigger: The trigger keyword to activate
- """
- # Set the trigger text in the search entry
- trigger_text = f"{trigger} "
- self.search_entry.set_text(trigger_text)
- self.search_entry.grab_focus()
-
- def clear_selection():
- if hasattr(self.search_entry, "set_position"):
- self.search_entry.set_position(-1) # Move caret to end
- if hasattr(self.search_entry, "select_region"):
- self.search_entry.select_region(
- len(trigger_text), len(trigger_text)
- ) # No selection
- return False # Only run once
-
- GLib.idle_add(clear_selection)
-
- # Manually set the trigger mode to avoid search processing issues
- triggered_plugin, detected_trigger = self._detect_trigger(trigger_text)
- if triggered_plugin:
- self.triggered_plugin = triggered_plugin
- self.active_trigger = detected_trigger
-
- # Clear results and show trigger ready state
- self.results = []
- self.selected_index = 0
- self._update_results_display()
-
- # Focus back to the search entry for immediate typing
- self.search_entry.grab_focus()
-
- # Don't hide the launcher - user should be able to continue typing
-
- def _execute_external_command(self, command_string: str):
- """Execute a command externally without showing the launcher UI.
-
- Args:
- command_string: Full command string (e.g., "wall random", "calc 2+2")
-
- Returns:
- Result of the command execution or None if failed
- """
- try:
- # Parse the command to extract trigger and query
- parts = command_string.strip().split(" ", 1)
- if not parts:
- return None
-
- trigger_part = parts[0]
- query_part = parts[1] if len(parts) > 1 else ""
-
- # Find the plugin that handles this trigger
- triggered_plugin = None
-
- for plugin in self.plugin_manager.get_active_plugins():
- trigger = plugin.get_active_trigger(f"{trigger_part} ")
- if trigger:
- triggered_plugin = plugin
- break
-
- if not triggered_plugin:
- print(f"No plugin found for trigger: {trigger_part}")
- return None
-
- # Query the plugin with the remaining query
- try:
- results = triggered_plugin.query(query_part)
- if not results:
- print(f"No results found for query: {query_part}")
- return None
-
- # Find the first result that matches the query exactly or has highest relevance
- best_result = None
- for result in results:
- # For exact matches like "random", execute immediately
- if (
- hasattr(result, "data")
- and result.data
- and result.data.get("action") == query_part.strip()
- ):
- best_result = result
- break
- # For partial matches, take the first high-relevance result
- elif not best_result and result.relevance >= 0.9:
- best_result = result
-
- # If no exact match, take the first result
- if not best_result and results:
- best_result = results[0]
-
- if best_result:
- # Execute the result action
- try:
- result_value = best_result.activate()
- print(f"External command executed: {command_string}")
- return result_value
- except Exception as e:
- print(f"Error executing result action: {e}")
- return None
- else:
- print(f"No suitable result found for: {command_string}")
- return None
-
- except Exception as e:
- print(f"Error querying plugin {triggered_plugin.name}: {e}")
- return None
-
- except Exception as e:
- print(f"Error executing external command '{command_string}': {e}")
- return None
-
- def _update_results_display(self):
- """Update the results display."""
- # Skip if still initializing or results_box not ready
- if getattr(self, "_initializing", True) or not hasattr(self, "results_box"):
- return
-
- # Update input field with trigger indication (Spotlight-style)
- self._update_input_action_text()
-
- # Clear existing results
- for child in self.results_box.get_children():
- self.results_box.remove(child)
-
- # Add new results
- for i, result in enumerate(self.results):
- # Check if this result has a custom widget
- if result.custom_widget:
- # Ensure the widget is not already parented
- parent = result.custom_widget.get_parent()
- if parent:
- parent.remove(result.custom_widget)
-
- result.custom_widget.show_all() # Ensure widget is visible
- self.results_box.add(result.custom_widget)
- else:
- # Create normal result item
- result_item = ResultItem(
- result=result, selected=(i == self.selected_index), index=i
- )
- result_item.clicked.connect(
- lambda _, idx=i: self._on_result_clicked(result_item, idx)
- )
- result_item.hovered.connect(
- lambda _, idx=i: self._on_result_hovered(idx)
- )
- self.results_box.add(result_item)
-
- self.results_box.show_all()
-
- self.results_scroll.show()
-
- def _update_input_action_text(self):
- """Update the input field with action text (Spotlight-style)."""
- # Check if search_entry is initialized
- if not hasattr(self, "search_entry") or self.search_entry is None:
- return
-
- # Get the current input text
- current_text = self.search_entry.get_text()
-
- # Spotlight-style placeholder text
- if self.triggered_plugin:
- if self.results:
- # Show more minimal placeholder for triggered mode
- self.search_entry.set_placeholder_text(
- f"Search {self.active_trigger.strip()}..."
- )
- else:
- # In trigger mode but no results yet
- if current_text == self.active_trigger.strip():
- # Just the trigger keyword
- self.search_entry.set_placeholder_text(
- f"Search {self.active_trigger.strip()}..."
- )
- else:
- # Searching within trigger
- self.search_entry.set_placeholder_text(
- f"Searching {self.active_trigger.strip()}..."
- )
- else:
- # Not in trigger mode - show Spotlight-style help
- if current_text == ":":
- self.search_entry.set_placeholder_text("Available search triggers")
- else:
- self.search_entry.set_placeholder_text("Spotlight Search")
-
- def _clear_results(self):
- """Clear all results."""
- self.results = []
- self.selected_index = 0
- for child in self.results_box.get_children():
- self.results_box.remove(child)
- # Keep the results scroll visible even when empty
-
- def _handle_escape_key(self) -> bool:
- """Handle escape key press."""
- # First check if there's a password entry widget that should handle Escape
- password_entry_widget = self._find_password_entry_widget()
- if password_entry_widget:
- # Cancel the password entry
- password_entry_widget.cancel_password_entry()
- return True
- elif self.opened_with_trigger:
- # If launcher was opened with a trigger keyword, close directly
- self.close_launcher()
- return True
- else:
- # Hide launcher
- self.close_launcher()
- return True
-
- def _handle_backspace_key(self) -> bool:
- """Handle backspace key press in trigger mode."""
- if self.triggered_plugin:
- trigger_text = self.active_trigger.strip()
- current_text = self.search_entry.get_text()
-
- # Set flag to prevent search change handler from interfering
- self._processing_backspace = True
-
- # Allow normal backspace behavior first, then check if we need to exit trigger mode
- # Don't intercept the backspace - let GTK handle it normally
-
- # Schedule a check after the backspace is processed
- def check_trigger_after_backspace():
- try:
- # Get the text after backspace has been processed
- new_text = self.search_entry.get_text()
-
- # Check if we should exit trigger mode
- # We need to check if the text still matches the trigger pattern
- should_exit_trigger = False
-
- # If the active trigger ends with a space (like "calc "),
- # we should exit if the text doesn't contain that space anymore
- if self.active_trigger.endswith(" "):
- # For triggers like "calc ", exit if text is just "calc" or doesn't start with "calc "
- trigger_with_space = self.active_trigger.lower()
- if not new_text.lower().startswith(trigger_with_space):
- should_exit_trigger = True
- else:
- # For triggers without space, exit if text doesn't start with trigger
- if not new_text.lower().startswith(trigger_text.lower()):
- should_exit_trigger = True
-
- 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
-
- # If we're still in trigger mode but the text changed, update the search
- elif self.triggered_plugin and new_text != current_text:
- # Trigger a search with the new text
- self.query = new_text
- GLib.timeout_add(50, self._perform_search, new_text)
-
- finally:
- # Clear the backspace processing flag
- self._processing_backspace = False
-
- return False # Don't repeat
-
- # Use idle_add to check after the backspace is processed
- GLib.idle_add(check_trigger_after_backspace)
-
- # Allow the normal backspace to proceed
- return False
-
- # Let normal backspace behavior continue for other cases
- return False
-
- def _on_key_press(self, _widget, event):
- """Handle key press events."""
- # Track keyboard activity
- import time
-
- current_time = int(time.time() * 1000)
- self._last_keyboard_activity = current_time
- self._keyboard_used_recently = True
-
- keyval = event.keyval
-
- # Escape - handle password entry, exit trigger mode, or hide launcher
- if keyval == Gdk.KEY_Escape:
- return self._handle_escape_key()
-
- # Backspace - handle trigger mode backspace behavior
- if keyval == Gdk.KEY_BackSpace:
- return self._handle_backspace_key()
-
- # Up/Down - navigate results (alternative to Tab)
- if keyval == Gdk.KEY_Up:
- if self.focus_mode == "results" and self.results:
- if self.selected_index > 0:
- # Move to previous result
- self.selected_index -= 1
- self._update_selection()
- else:
- # At first result, go back to search entry
- self.focus_mode = "search"
- self._focus_search_entry_without_selection()
- elif self.results:
- # If not in results mode but have results, enter results mode at last item
- self.focus_mode = "results"
- self.selected_index = len(self.results) - 1
- self._update_selection()
- return True
-
- if keyval == Gdk.KEY_Down:
- if self.focus_mode == "results" and self.results:
- if self.selected_index < len(self.results) - 1:
- # Move to next result
- self.selected_index += 1
- self._update_selection()
- else:
- # At last result, wrap around to first result
- self.selected_index = 0
- self._update_selection()
- elif self.results:
- # If not in results mode but have results, enter results mode at first item
- self.focus_mode = "results"
- self.selected_index = 0
- self._update_selection()
- return True
-
- # Enter - activate selected result
- if keyval == Gdk.KEY_Return:
- # Check for Shift+Enter for alternative actions
- if event.state & Gdk.ModifierType.SHIFT_MASK:
- if self.results and 0 <= self.selected_index < len(self.results):
- result = self.results[self.selected_index]
- if result.data:
- # Check for generic alternative action first
- if result.data.get("alt_action"):
- result.data["alt_action"]()
- return True
- # Fallback to pin_action for backward compatibility
- elif result.data.get("pin_action"):
- result.data["pin_action"]()
- return True
-
- # Check if the selected result has a custom widget with Entry fields
- if self.results and 0 <= self.selected_index < len(self.results):
- result = self.results[self.selected_index]
- if result.custom_widget:
- # Check if the custom widget contains Entry widgets that should handle Enter
- if self._custom_widget_has_entry(result.custom_widget):
- # Let the custom widget handle the Enter key
- # Find the focused Entry widget and trigger its activate signal
- focused_entry = self._find_focused_entry_in_widget(
- result.custom_widget
- )
- if focused_entry:
- focused_entry.emit("activate")
- return True
-
- # Normal Enter behavior
- self._activate_selected()
- return True
-
- # Tab - cycle through focus areas and results
- if keyval == Gdk.KEY_Tab:
- if event.state & Gdk.ModifierType.SHIFT_MASK:
- # Shift+Tab - reverse direction
- if self.focus_mode == "results":
- # Navigate through results in reverse
- self._navigate_results_backward()
- else:
- self._cycle_focus_backward()
- else:
- # Tab - forward direction
- if self.focus_mode == "results":
- # Navigate through results forward
- self._navigate_results_forward()
- else:
- self._cycle_focus_forward()
- return True
-
- # Page Up/Page Down - navigate results faster
- if keyval == Gdk.KEY_Page_Up:
- if self.results:
- self.selected_index = max(0, self.selected_index - PAGE_NAVIGATION_STEP)
- self._update_selection()
- return True
-
- if keyval == Gdk.KEY_Page_Down:
- if self.results:
- self.selected_index = min(
- len(self.results) - 1, self.selected_index + PAGE_NAVIGATION_STEP
- )
- self._update_selection()
- return True
-
- # Home/End - go to first/last result
- if keyval == Gdk.KEY_Home:
- if self.results:
- self.selected_index = 0
- self._update_selection()
- return True
-
- if keyval == Gdk.KEY_End:
- if self.results:
- self.selected_index = len(self.results) - 1
- self._update_selection()
- return True
-
- # Forward other keys to custom widgets if they can handle them
- if self.results and 0 <= self.selected_index < len(self.results):
- result = self.results[self.selected_index]
- if result.custom_widget and hasattr(result.custom_widget, "on_key_press"):
- # Try to forward the key event to the custom widget
- if result.custom_widget.on_key_press(result.custom_widget, event):
- return True
-
- return False
-
- def _custom_widget_has_entry(self, widget):
- """Check if a custom widget contains Entry widgets."""
-
- if isinstance(widget, Entry):
- return True
-
- # Check children recursively
- if hasattr(widget, "get_children"):
- for child in widget.get_children():
- if self._custom_widget_has_entry(child):
- return True
-
- return False
-
- def _find_focused_entry_in_widget(self, widget):
- """Find the focused Entry widget within a custom widget."""
-
- if isinstance(widget, Entry):
- # Try multiple ways to check if this Entry is focused
- try:
- if widget.has_focus() or widget.is_focus():
- return widget
- # Also check if this is the only Entry in the widget (likely to be the target)
- return widget
- except:
- # If focus checking fails, assume this Entry should handle the event
- return widget
-
- # Check children recursively
- if hasattr(widget, "get_children"):
- for child in widget.get_children():
- focused_entry = self._find_focused_entry_in_widget(child)
- if focused_entry:
- return focused_entry
-
- return None
-
- def _find_password_entry_widget(self):
- """Find a NetworkPasswordEntry widget in the current results."""
- for result in self.results:
- if result.custom_widget:
- # Check if this is a NetworkPasswordEntry widget
- if (
- hasattr(result.custom_widget, "__class__")
- and result.custom_widget.__class__.__name__
- == "NetworkPasswordEntry"
- ):
- return result.custom_widget
- # Also check if it has the cancel_password_entry method (duck typing)
- elif hasattr(result.custom_widget, "cancel_password_entry"):
- return result.custom_widget
- return None
-
- def _on_entry_activate(self, _entry):
- """Handle entry activation (Enter key)."""
- self._activate_selected()
-
- def _on_result_clicked(self, _result_item, index):
- """Handle result item click."""
- # Only allow clicks when mouse is active or keyboard hasn't been used recently
- if self._should_allow_mouse_interaction():
- self.selected_index = index
- self._activate_selected()
-
- def _on_result_hovered(self, index):
- """Handle result item hover."""
- # Only allow hover selection when mouse is active and keyboard hasn't been used recently
- if self._should_allow_mouse_interaction():
- if 0 <= index < len(self.results):
- self.selected_index = index
- self.focus_mode = "results" # Switch to results focus mode
- self._update_selection_visual_only()
-
- def _on_mouse_activity(self, widget, event):
- """Track mouse activity to determine when mouse interactions should be enabled."""
- import time
-
- current_time = int(time.time() * 1000)
- self._last_mouse_activity = current_time
- self._mouse_active = True
-
- # Schedule a check to disable mouse activity after timeout
- def check_mouse_timeout():
- if current_time - self._last_mouse_activity >= self._mouse_activity_timeout:
- self._mouse_active = False
- return False
-
- GLib.timeout_add(self._mouse_activity_timeout, check_mouse_timeout)
- return False
-
- def _should_allow_mouse_interaction(self):
- """Determine if mouse interactions should be allowed."""
- import time
-
- current_time = int(time.time() * 1000)
-
- # Don't allow mouse interactions immediately after launcher opens
- if self._launcher_just_opened:
- return False
-
- # Allow mouse interaction if:
- # 1. Mouse is currently active (recent activity)
- # 2. OR keyboard hasn't been used recently (2 seconds)
- mouse_recently_active = (
- current_time - self._last_mouse_activity
- ) < self._mouse_activity_timeout
- keyboard_not_recently_used = (
- current_time - self._last_keyboard_activity
- ) > self._keyboard_activity_timeout
-
- return mouse_recently_active or keyboard_not_recently_used
-
- def _is_mouse_over_results(self):
- """Check if mouse is over the results area."""
- # Simple check - if we have results visible, assume user might be interacting
- return len(self.results) > 0 and self.results_scroll.get_visible()
-
- def _update_selection(self):
- """Update the visual selection of results with scrolling (for keyboard navigation)."""
- children = self.results_box.get_children()
- selected_widget = None
-
- for i, child in enumerate(children):
- if isinstance(child, ResultItem):
- is_selected = i == self.selected_index
- child.set_selected(is_selected)
- if is_selected:
- selected_widget = child
- # For custom widgets, we don't need to handle selection visually
- # since they manage their own interaction
-
- # Focus custom widgets when selected for keyboard interaction
- if self.results and 0 <= self.selected_index < len(self.results):
- result = self.results[self.selected_index]
- if result.custom_widget and result.custom_widget.get_can_focus():
- # Give focus to the custom widget for keyboard interaction
- result.custom_widget.grab_focus()
-
- # Scroll to make the selected item visible
- if selected_widget and self.results_scroll.get_visible():
- # Use immediate scrolling for better responsiveness
- self._scroll_to_widget(selected_widget)
- # Also schedule a more accurate scroll after layout is complete
- GLib.idle_add(self._ensure_selected_visible)
-
- def _update_selection_visual_only(self):
- """Update the visual selection of results without scrolling (for mouse hover)."""
- children = self.results_box.get_children()
-
- for i, child in enumerate(children):
- if isinstance(child, ResultItem):
- is_selected = i == self.selected_index
- child.set_selected(is_selected)
- # For custom widgets, we don't need to handle selection visually
- # since they manage their own interaction
-
- # Focus custom widgets when selected for keyboard interaction
- if self.results and 0 <= self.selected_index < len(self.results):
- result = self.results[self.selected_index]
- if result.custom_widget and result.custom_widget.get_can_focus():
- # Give focus to the custom widget for keyboard interaction
- result.custom_widget.grab_focus()
-
- def _scroll_to_widget(self, widget):
- """Scroll the results container to make the widget visible."""
- if not widget or not self.results_scroll.get_visible():
- return
-
- # Debug output (can be removed in production)
- # print(f"Scrolling to selected index: {self.selected_index}")
-
- # Get the scrolled window's vertical adjustment
- vadjustment = self.results_scroll.get_vadjustment()
- if not vadjustment:
- return
-
- # Use a simpler approach: scroll to the selected item index
- if self.results and 0 <= self.selected_index < len(self.results):
- # Get all children to work with actual widgets
- children = self.results_box.get_children()
- if not children or self.selected_index >= len(children):
- return
-
- # Get the selected child widget
- selected_child = children[self.selected_index]
-
- # Try to get actual allocation, fallback to estimation
- try:
- allocation = selected_child.get_allocation()
- item_height = allocation.height if allocation.height > 0 else 68
- item_y = allocation.y
- except:
- # Fallback to estimation
- item_height = DEFAULT_ITEM_HEIGHT
- item_y = self.selected_index * item_height
-
- # Get current scroll info
- current_scroll = vadjustment.get_value()
- page_size = vadjustment.get_page_size()
- max_scroll = vadjustment.get_upper() - page_size
-
- # Calculate visible area
- visible_top = current_scroll
- visible_bottom = current_scroll + page_size
-
- # Check if selected item is visible
- item_top = item_y
- item_bottom = item_y + item_height
-
- # Add some padding for better visibility
- # Scroll if needed
- if item_top < visible_top + SCROLL_PADDING:
- # Item is above visible area - scroll up
- new_scroll = max(0, item_top - SCROLL_PADDING)
- vadjustment.set_value(new_scroll)
- elif item_bottom > visible_bottom - SCROLL_PADDING:
- # Item is below visible area - scroll down
- new_scroll = min(max_scroll, item_bottom - page_size + SCROLL_PADDING)
- vadjustment.set_value(new_scroll)
-
- def _ensure_selected_visible(self):
- """Alternative method to ensure selected item is visible using GTK methods."""
- if (
- not self.results
- or self.selected_index < 0
- or self.selected_index >= len(self.results)
- ):
- return False
-
- children = self.results_box.get_children()
- if self.selected_index < len(children):
- selected_child = children[self.selected_index]
-
- # Try to use widget's allocation for more accurate scrolling
- try:
- allocation = selected_child.get_allocation()
- if allocation.height > 0:
- vadjustment = self.results_scroll.get_vadjustment()
- if vadjustment:
- # Calculate the position to center the selected item
- page_size = vadjustment.get_page_size()
- target_pos = (
- allocation.y - (page_size / 2) + (allocation.height / 2)
- )
- target_pos = max(
- 0, min(target_pos, vadjustment.get_upper() - page_size)
- )
- vadjustment.set_value(target_pos)
- except Exception as e:
- print(f"Error in _ensure_selected_visible: {e}")
-
- return False # Don't repeat the idle callback
-
- def _cycle_focus_forward(self):
- """Cycle focus forward: search -> results"""
- if self.focus_mode == "search":
- if self.results:
- self.focus_mode = "results"
- self._update_selection()
-
- def _cycle_focus_backward(self):
- """Cycle focus backward: results -> search"""
- if self.focus_mode == "results":
- self.focus_mode = "search"
- self._focus_search_entry_without_selection()
-
- def _focus_search_entry_without_selection(self):
- """Focus search entry and position cursor at end without selecting text."""
- # First grab focus
- self.search_entry.grab_focus()
-
- # Use multiple approaches to prevent text selection
- def clear_selection():
- try:
- text_length = len(self.search_entry.get_text())
-
- # Method 1: Set position and clear selection
- if hasattr(self.search_entry, "set_position"):
- self.search_entry.set_position(text_length)
- if hasattr(self.search_entry, "select_region"):
- self.search_entry.select_region(text_length, text_length)
-
- # Method 2: Try to access underlying GTK widget
- try:
- # For fabric Entry widgets, try to get the actual GTK Entry
- if hasattr(self.search_entry, "_entry"):
- gtk_entry = self.search_entry._entry
- elif (
- hasattr(self.search_entry, "get_children")
- and self.search_entry.get_children()
- ):
- gtk_entry = self.search_entry.get_children()[0]
- else:
- gtk_entry = self.search_entry
-
- if hasattr(gtk_entry, "set_position"):
- gtk_entry.set_position(text_length)
- if hasattr(gtk_entry, "select_region"):
- gtk_entry.select_region(text_length, text_length)
-
- except Exception:
- pass
-
- except Exception as e:
- print(f"Could not clear selection: {e}")
- return False
-
- # Schedule clearing selection after focus is established
- GLib.idle_add(clear_selection)
- # Also try with a small delay as backup
- GLib.timeout_add(CURSOR_POSITION_DELAY_MS, clear_selection)
-
- def _navigate_results_forward(self):
- """Navigate to next result or wrap around to first result."""
- if self.results and self.selected_index < len(self.results) - 1:
- # Move to next result
- self.selected_index += 1
- self._update_selection()
- else:
- # At last result, wrap around to first result
- self.selected_index = 0
- self._update_selection()
-
- def _navigate_results_backward(self):
- """Navigate to previous result or exit results mode."""
- if self.results and self.selected_index > 0:
- # Move to previous result
- self.selected_index -= 1
- self._update_selection()
- else:
- # Exit results mode and go to search
- self.focus_mode = "search"
- self._focus_search_entry_without_selection()
-
- def _activate_selected(self):
- """Activate the currently selected result."""
- if self.results and 0 <= self.selected_index < len(self.results):
- result = self.results[self.selected_index]
- try:
- # Check if this result has a custom widget
- if result.custom_widget:
- # For custom widgets, we don't activate them since they're already displayed
- # The widget handles its own interactions
- return
-
- # Check if this is a trigger suggestion or should keep launcher open
- is_trigger_suggestion = (
- result.data and result.data.get("type") == "trigger_suggestion"
- )
- keep_launcher_open = result.data and result.data.get(
- "keep_launcher_open", False
- )
-
- # Activate the result
- result.activate()
-
- # Only hide launcher if it's not a trigger suggestion and doesn't have keep_launcher_open flag
- if not is_trigger_suggestion and not keep_launcher_open:
- self.close_launcher()
- # For trigger suggestions and keep_launcher_open actions, the launcher stays open
- except Exception as e:
- print(f"Error activating result: {e}")
diff --git a/modules/launcher/plugin_base.py b/modules/launcher/plugin_base.py
deleted file mode 100644
index 13bd1fc..0000000
--- a/modules/launcher/plugin_base.py
+++ /dev/null
@@ -1,172 +0,0 @@
-from abc import ABC, abstractmethod
-from typing import List
-
-from modules.launcher.result import Result
-
-
-class PluginBase(ABC):
- """
- Abstract base class for launcher plugins.
- All plugins must inherit from this class.
- """
-
- def __init__(self):
- self.name = self.__class__.__name__.lower()
- self.display_name = self.__class__.__name__
- self.description = "A launcher plugin"
- self.version = "1.0.0"
- self.enabled = True
- self._triggers = [] # List of trigger keywords
-
- @abstractmethod
- def initialize(self):
- """
- Initialize the plugin.
- Called when the plugin is activated.
- """
- pass
-
- @abstractmethod
- def cleanup(self):
- """
- Cleanup the plugin.
- Called when the plugin is deactivated.
- """
- pass
-
- @abstractmethod
- def query(self, query_string: str) -> List[Result]:
- """
- Process a search query and return results.
-
- Args:
- query_string: The search query from the user
-
- Returns:
- List of Result objects
- """
- pass
-
- def get_triggers(self) -> List[str]:
- """
- Get list of trigger keywords for this plugin.
- If the query starts with any of these, this plugin gets priority.
-
- Returns:
- List of trigger strings (e.g., ["calc", "=", "math"])
- """
- return self._triggers
-
- def set_triggers(self, triggers: List[str]):
- """
- Set the trigger keywords for this plugin.
-
- Args:
- triggers: List of trigger keywords
- """
- self._triggers = triggers
-
- def handles_query(self, query_string: str) -> bool:
- """
- Check if this plugin should handle the given query.
-
- Args:
- query_string: The search query
-
- Returns:
- True if this plugin should process the query
- """
- if not self.enabled:
- return False
-
- # Check triggers
- triggers = self.get_triggers()
- if triggers:
- query_lower = query_string.lower().strip()
- return any(query_lower.startswith(trigger.lower()) for trigger in triggers)
-
- # Default: handle all queries
- return True
-
- def get_active_trigger(self, query_string: str) -> str:
- """
- Get the active trigger for the given query.
-
- Args:
- query_string: The search query
-
- Returns:
- The trigger keyword if found, empty string otherwise
- """
- if not self.enabled:
- return ""
-
- triggers = self.get_triggers()
- if triggers:
- query_lower = query_string.lower().strip()
-
- # Sort triggers by length (longest first) to match more specific triggers first
- sorted_triggers = sorted(triggers, key=len, reverse=True)
-
- for trigger in sorted_triggers:
- trigger_lower = trigger.lower()
-
- # Exact match with trigger (including space if present)
- if query_lower.startswith(trigger_lower):
- return trigger
-
- # Match trigger word followed by space
- trigger_word = trigger.strip().lower()
- if (
- query_lower.startswith(trigger_word + " ")
- or query_lower == trigger_word
- ):
- return trigger
-
- return ""
-
- def query_triggered(self, query_string: str, trigger: str) -> List[Result]:
- """
- Process a triggered query (when plugin is in sticky mode).
- Default implementation removes trigger and calls query().
-
- Args:
- query_string: The full query string including trigger
- trigger: The trigger that activated this plugin
-
- Returns:
- List of Result objects
- """
- # Remove trigger from query and process remaining text
- remaining_query = query_string[len(trigger) :].strip()
- return self.query(remaining_query)
-
- def get_config(self) -> dict:
- """
- Get plugin configuration.
-
- Returns:
- Dictionary of configuration options
- """
- return {
- "name": self.name,
- "display_name": self.display_name,
- "description": self.description,
- "version": self.version,
- "enabled": self.enabled,
- }
-
- def set_config(self, config: dict):
- """
- Set plugin configuration.
-
- Args:
- config: Dictionary of configuration options
- """
- self.enabled = config.get("enabled", self.enabled)
-
- def __str__(self):
- return f"Plugin({self.name})"
-
- def __repr__(self):
- return self.__str__()
diff --git a/modules/launcher/plugin_manager.py b/modules/launcher/plugin_manager.py
deleted file mode 100644
index caf002e..0000000
--- a/modules/launcher/plugin_manager.py
+++ /dev/null
@@ -1,183 +0,0 @@
-import importlib
-import importlib.util
-import os
-from typing import Dict, List, Type
-
-from modules.launcher.plugin_base import PluginBase
-
-
-class PluginManager:
- """
- Manages launcher plugins.
- """
-
- def __init__(self):
- self.plugins: Dict[str, PluginBase] = {}
- self.plugin_classes: Dict[str, Type[PluginBase]] = {}
- self.active_plugins: List[str] = []
-
- # Load built-in plugins
- self._load_builtin_plugins()
-
- # Load external plugins
- self._load_external_plugins()
-
- # Activate default plugins
- self._activate_default_plugins()
-
- def _load_builtin_plugins(self):
- """Load built-in plugins from the plugins directory."""
- plugins_dir = os.path.join(os.path.dirname(__file__), "plugins")
-
- if not os.path.exists(plugins_dir):
- return
-
- for filename in os.listdir(plugins_dir):
- if filename.endswith(".py") and not filename.startswith("_"):
- plugin_name = filename[:-3] # Remove .py extension
- self._load_plugin_from_file(plugins_dir, plugin_name)
-
- def _load_external_plugins(self):
- """Load external plugins from user directory."""
- # Could be implemented to load from ~/.config/launcher/plugins/
- pass
-
- def _load_plugin_from_file(self, plugins_dir: str, plugin_name: str):
- """Load a plugin from a Python file."""
- 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
- )
-
- if spec and spec.loader:
- module = importlib.util.module_from_spec(spec)
-
- # Add the module to sys.modules to support relative imports
- import sys
-
- sys.modules[f"modules.launcher.plugins.{plugin_name}"] = module
-
- spec.loader.exec_module(module)
-
- # Look for plugin class
- for attr_name in dir(module):
- attr = getattr(module, attr_name)
- if (
- isinstance(attr, type)
- and issubclass(attr, PluginBase)
- and attr != PluginBase
- ):
- self.plugin_classes[plugin_name] = attr
- break
-
- except Exception as e:
- print(f"Failed to load plugin {plugin_name}: {e}")
-
- def _activate_default_plugins(self):
- """Activate default plugins."""
- default_plugins = [
- "applications",
- "calculator",
- "system",
- "clipboard",
- "power",
- "caffeine",
- "screencapture",
- "emoji",
- "wallpaper",
- "websearch",
- "reminders",
- "otp",
- "password",
- "bookmarks",
- "bash_scripts",
- "tmux",
- ]
-
- for plugin_name in default_plugins:
- self.activate_plugin(plugin_name)
-
- def activate_plugin(self, plugin_name: str) -> bool:
- """Activate a plugin by name."""
- if plugin_name in self.plugins:
- # Already activated
- return True
-
- if plugin_name not in self.plugin_classes:
- return False
-
- try:
- # Instantiate plugin
- plugin_class = self.plugin_classes[plugin_name]
- plugin_instance = plugin_class()
-
- # Initialize plugin
- plugin_instance.initialize()
-
- # Store plugin
- self.plugins[plugin_name] = plugin_instance
- self.active_plugins.append(plugin_name)
-
- return True
-
- except Exception as e:
- print(f"Failed to activate plugin {plugin_name}: {e}")
- return False
-
- def deactivate_plugin(self, plugin_name: str) -> bool:
- """Deactivate a plugin by name."""
- if plugin_name not in self.plugins:
- return False
-
- try:
- # Cleanup plugin
- plugin = self.plugins[plugin_name]
- plugin.cleanup()
-
- # Remove from active plugins
- del self.plugins[plugin_name]
- if plugin_name in self.active_plugins:
- self.active_plugins.remove(plugin_name)
-
- return True
-
- except Exception as e:
- print(f"Failed to deactivate plugin {plugin_name}: {e}")
- return False
-
- def get_active_plugins(self) -> List[PluginBase]:
- """Get list of active plugin instances."""
- return [
- self.plugins[name] for name in self.active_plugins if name in self.plugins
- ]
-
- def get_plugin_names(self) -> List[str]:
- """Get list of available plugin names."""
- return list(self.plugin_classes.keys())
-
- def get_active_plugin_names(self) -> List[str]:
- """Get list of active plugin names."""
- return self.active_plugins.copy()
-
- def reload_plugin(self, plugin_name: str) -> bool:
- """Reload a plugin."""
- was_active = plugin_name in self.active_plugins
-
- # Deactivate if active
- if was_active:
- self.deactivate_plugin(plugin_name)
-
- # Remove from classes
- if plugin_name in self.plugin_classes:
- del self.plugin_classes[plugin_name]
-
- # Reload from file
- plugins_dir = os.path.join(os.path.dirname(__file__), "plugins")
- self._load_plugin_from_file(plugins_dir, plugin_name)
-
- # Reactivate if it was active
- if was_active and plugin_name in self.plugin_classes:
- return self.activate_plugin(plugin_name)
-
- return plugin_name in self.plugin_classes
diff --git a/modules/launcher/plugins/__init__.py b/modules/launcher/plugins/__init__.py
deleted file mode 100644
index 666274b..0000000
--- a/modules/launcher/plugins/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-"""
-Built-in plugins for the launcher.
-"""
diff --git a/modules/launcher/plugins/applications.py b/modules/launcher/plugins/applications.py
deleted file mode 100644
index 832d75b..0000000
--- a/modules/launcher/plugins/applications.py
+++ /dev/null
@@ -1,185 +0,0 @@
-import json
-import re
-from typing import List
-import subprocess
-
-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
-
-
-class ApplicationsPlugin(PluginBase):
- def __init__(self):
- super().__init__()
- self.display_name = "Applications"
- self.description = "Search and launch desktop applications"
-
- def initialize(self):
- pass
-
- def cleanup(self):
- pass
-
- def _pin_application(self, app):
- """Pin an application to the dock."""
- config_path = get_relative_path("../../../config/assets/dock.json")
- try:
- with open(config_path, "r") as file:
- pinned_apps = json.load(file)
- except (FileNotFoundError, json.JSONDecodeError):
- pinned_apps = []
-
- # Handle legacy format (dict with "pinned_apps" key) and convert to new format (simple list)
- if isinstance(pinned_apps, dict):
- pinned_apps = pinned_apps.get("pinned_apps", [])
-
- # Check if app is already pinned (by name/app_id)
- app_id = app.name # Use app.name as the identifier
- if app_id not in pinned_apps:
- pinned_apps.append(app_id)
-
- # Save the updated list
- with open(config_path, "w") as file:
- json.dump(pinned_apps, file, indent=4)
-
- # Notify dock about the change via modus_service
- if modus_service:
- try:
- dock_apps_json = json.dumps(pinned_apps)
- modus_service.dock_apps = dock_apps_json
- except Exception as e:
- print(f"Failed to notify dock about pinned app change: {e}")
-
- def query(self, query_string: str) -> List[Result]:
- """Search applications based on query."""
- if not query_string.strip():
- return self._get_all_applications()
-
- try:
- applications = get_desktop_applications(include_hidden=False)
- except Exception as e:
- print(f"Failed to load applications: {e}")
- applications = []
-
- query = query_string.lower().strip()
- results = []
-
- for app in applications:
- relevance = self._calculate_relevance(app, query)
- if relevance > 0:
- description = app.description or app.generic_name or ""
- if len(description) > 80:
- description = description[:70] + "..."
-
- result = Result(
- title=app.display_name or app.name,
- subtitle=description,
- icon=app.get_icon_pixbuf(size=48),
- action=lambda a=app: self._launch_application(a),
- relevance=relevance,
- plugin_name=self.display_name,
- data={
- "app": app,
- "pin_action": lambda a=app: self._pin_application(a),
- },
- )
- results.append(result)
-
- return results
-
- def _calculate_relevance(self, app, query: str) -> float:
- """Calculate relevance score for an application."""
- if not query:
- return 0.0
-
- # Get searchable text
- name = (app.name or "").lower()
- display_name = (app.display_name or "").lower()
- description = (app.description or "").lower()
- generic_name = (app.generic_name or "").lower()
- executable = (app.executable or "").lower()
-
- # Exact matches get highest score
- if query == name or query == display_name:
- return 1.0
-
- # Starts with matches get high score
- if (
- name.startswith(query)
- or display_name.startswith(query)
- or generic_name.startswith(query)
- ):
- return 0.9
-
- # Contains matches get medium score
- if (
- query in name
- or query in display_name
- or query in description
- or query in generic_name
- ):
- return 0.7
-
- # Fuzzy matching for partial matches
- if self._fuzzy_match(query, name) or self._fuzzy_match(query, display_name):
- return 0.5
-
- # Executable name matching
- if executable and query in executable:
- return 0.4
-
- return 0.0
-
- def _fuzzy_match(self, query: str, text: str) -> bool:
- """Simple fuzzy matching algorithm."""
- if not query or not text:
- return False
-
- # Create regex pattern for fuzzy matching
- pattern = ".*".join(re.escape(char) for char in query)
- return bool(re.search(pattern, text, re.IGNORECASE))
-
- def _launch_application(self, app: DesktopApp):
-
- # Remove ALL % codes (e.g., %u, %U, %f, %F, %i, %c, etc.)
- cleaned_command = re.sub(r"%\w+", "", app.command_line).strip()
-
- # Final command with hyprctl dispatch
- final_command = f"hyprctl dispatch exec 'uwsm app -- {cleaned_command}'"
- subprocess.Popen(final_command, shell=True)
-
- # app.launch()
-
- def _get_all_applications(self) -> List[Result]:
- """Get a list of all available applications."""
- try:
- applications = get_desktop_applications(include_hidden=False)
- except Exception as e:
- print(f"Failed to load applications: {e}")
- return []
-
- results = []
-
- for app in applications:
- # Truncate description
- description = app.description or app.generic_name or ""
- if len(description) > 80:
- description = description[:70] + "..."
-
- result = Result(
- title=app.display_name or app.name,
- subtitle=description,
- icon=app.get_icon_pixbuf(size=48),
- action=lambda a=app: self._launch_application(a),
- relevance=0.5, # Default relevance for all apps
- plugin_name=self.display_name,
- data={
- "app": app,
- "pin_action": lambda a=app: self._pin_application(a),
- },
- )
- results.append(result)
-
- return results
diff --git a/modules/launcher/plugins/bash_scripts.py b/modules/launcher/plugins/bash_scripts.py
deleted file mode 100644
index 44bb2e8..0000000
--- 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 ee55139..0000000
--- 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/caffeine.py b/modules/launcher/plugins/caffeine.py
deleted file mode 100644
index decde97..0000000
--- a/modules/launcher/plugins/caffeine.py
+++ /dev/null
@@ -1,208 +0,0 @@
-import subprocess
-from threading import Timer
-from typing import List
-
-import gi
-
-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")
-
-
-class CaffeinePlugin(PluginBase):
- """
- Plugin for preventing system idle using the inhibit script.
- """
-
- def __init__(self):
- super().__init__()
- self.display_name = "Caffeine"
- self.description = "Prevent system from going idle"
- self.inhibit_script = get_relative_path("../../../utils/inhibit.py")
-
- # Predefined durations
- self.durations = {
- "30m": "30 minutes",
- "1h": "1 hour",
- "2h": "2 hours",
- "4h": "4 hours",
- "8h": "8 hours",
- "on": "On",
- "off": "Off",
- }
-
- def initialize(self):
- """Initialize the caffeine plugin."""
- self.set_triggers(["caffeine"])
-
- def cleanup(self):
- """Cleanup the caffeine plugin."""
- pass
-
- def _create_inhibit_action(self, duration: str):
- """Create an inhibit action that properly captures the duration."""
-
- def action():
- try:
- # Run the script in the background without waiting
- subprocess.Popen(
- ["python3", self.inhibit_script, duration],
- stdout=subprocess.DEVNULL,
- stderr=subprocess.DEVNULL,
- start_new_session=True, # Run in a new session to prevent hanging
- )
-
- # Send notification based on duration
- if duration.lower() == "off":
- # Deactivation notification
- exec_shell_command_async(
- f"notify-send 'โ Caffeine' 'Deactivated' -a '{
- data.APP_NAME_CAP
- }' -e"
- )
- else:
- # Activation notification with duration
- duration_text = self.durations.get(duration, duration)
- exec_shell_command_async(
- f"notify-send 'โ Caffeine' 'Activated for {
- duration_text
- }' -a '{data.APP_NAME_CAP}' -e"
- )
-
- # Schedule expiration notification for timed durations
- if duration.lower() not in ["on", "off"]:
- self._schedule_expiration_notification(duration, duration_text)
-
- except Exception as e:
- print(f"Error starting inhibit script: {e}")
-
- return action
-
- def _parse_duration_to_seconds(self, duration_str: str) -> int:
- """Parse duration string into seconds. Same logic as inhibit.py"""
- try:
- if duration_str.lower() in ["on", "off"]:
- return 0
- elif duration_str.endswith("h"):
- return int(float(duration_str[:-1]) * 3600)
- elif duration_str.endswith("m"):
- return int(float(duration_str[:-1]) * 60)
- elif duration_str.endswith("s"):
- return int(float(duration_str[:-1]))
- else:
- return int(duration_str)
- except ValueError:
- return 0
-
- def _schedule_expiration_notification(self, duration: str, duration_text: str):
- """Schedule a notification for when the caffeine effect expires."""
- seconds = self._parse_duration_to_seconds(duration)
- if seconds > 0:
-
- def send_expiration_notification():
- exec_shell_command_async(
- f"notify-send 'โ Caffeine' 'Expired after {duration_text}' -a '{
- data.APP_NAME_CAP
- }' -e"
- )
-
- # Schedule the notification
- timer = Timer(seconds, send_expiration_notification)
- timer.daemon = True # Don't prevent program exit
- timer.start()
-
- def _is_valid_duration(self, query: str) -> bool:
- """Check if the query is a valid duration format."""
- if query in self.durations:
- return True
- if query.isdigit():
- return True
- if query.endswith(("h", "m", "s")) and query[:-1].replace(".", "").isdigit():
- return True
- return False
-
- def _get_default_action(self, query: str):
- """Get the default action for a direct duration input."""
- if self._is_valid_duration(query):
- return self._create_inhibit_action(query)
- return None
-
- def query(self, query_string: str) -> List[Result]:
- """Search caffeine durations based on query."""
- # For empty queries, show all available durations
- if not query_string.strip():
- query_string = "" # Will match all durations in the loop below
-
- query = query_string.lower().strip()
- results = []
-
- # Handle direct search entry (e.g., "caffeine 30m" or just "30m")
- if query.startswith("caffeine "):
- query = query[9:].strip() # Remove "caffeine " prefix
- # If query becomes empty after removing prefix, show all durations
- if not query:
- query = "" # Will match all durations in the loop below
- elif query == "caffeine":
- # Handle just "caffeine" without space - show all durations
- query = ""
- elif not query:
- # Handle empty query - show all durations
- pass
- elif (
- query in self.durations
- or query.isdigit()
- or (
- query.endswith(("h", "m", "s"))
- and query[:-1].replace(".", "").isdigit()
- )
- ):
- # If query is a valid duration without prefix, use it directly
- pass
- else:
- return []
-
- # Add custom duration option
- if query.isdigit() or (
- query.endswith(("h", "m", "s")) and query[:-1].replace(".", "").isdigit()
- ):
- result = Result(
- title=f"Custom: {query}",
- subtitle="Set custom duration",
- icon_name="caffeine",
- action=self._create_inhibit_action(query),
- relevance=1.0,
- plugin_name=self.display_name,
- data={"duration": query},
- )
- results.append(result)
-
- # Add predefined durations
- for duration, description in self.durations.items():
- # If query is empty, show all durations; otherwise filter by query
- if not query or query in duration or query in description.lower():
- # Special handling for "off" - it should stop inhibition
- if duration.lower() == "off":
- subtitle = "Stop idle inhibition"
- else:
- subtitle = f"Prevent idle for {description.lower()}"
-
- result = Result(
- title=description,
- subtitle=subtitle,
- icon_name="caffeine",
- action=self._create_inhibit_action(duration),
- relevance=0.9 if query == duration else 0.7,
- plugin_name=self.display_name,
- data={"duration": duration},
- )
- results.append(result)
-
- # Set default action for direct duration input
- if results:
- results[0].default_action = self._get_default_action(query)
-
- return results
diff --git a/modules/launcher/plugins/calculator.py b/modules/launcher/plugins/calculator.py
deleted file mode 100644
index f97dddb..0000000
--- a/modules/launcher/plugins/calculator.py
+++ /dev/null
@@ -1,163 +0,0 @@
-import math
-import re
-import subprocess
-import time
-from typing import List
-
-from modules.launcher.plugin_base import PluginBase
-from modules.launcher.result import Result
-from utils.conversion import Conversion
-
-
-class CalculatorPlugin(PluginBase):
- """
- Plugin for calculating mathematical expressions and converting units.
- """
-
- def __init__(self):
- super().__init__()
- self.display_name = "Calculator"
- self.description = "Evaluate mathematical expressions and convert units"
-
- # Safe functions for evaluation
- self.safe_functions = {
- "abs": abs,
- "round": round,
- "min": min,
- "max": max,
- "sum": sum,
- "pow": pow,
- "sqrt": math.sqrt,
- "sin": math.sin,
- "cos": math.cos,
- "tan": math.tan,
- "asin": math.asin,
- "acos": math.acos,
- "atan": math.atan,
- "log": math.log,
- "log10": math.log10,
- "exp": math.exp,
- "pi": math.pi,
- "e": math.e,
- }
-
- # Initialize conversion utility
- self.converter = Conversion()
-
- # Pre-compiled regex patterns
- 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]+)"
- )
-
- # Cache for conversion results
- self._conversion_cache = {}
- self._last_cache_cleanup = time.time()
- self._cache_cleanup_interval = 300 # 5 minutes
-
- def initialize(self):
- """Initialize the files plugin."""
- self.set_triggers(["="])
-
- def _cleanup_cache(self):
- """Clean up old cache entries."""
- current_time = time.time()
- if current_time - self._last_cache_cleanup > self._cache_cleanup_interval:
- self._conversion_cache.clear()
- self._last_cache_cleanup = current_time
-
- def query(self, query: str) -> List[Result]:
- """Process a query and return results."""
- if not query:
- return []
-
- # Clean up cache periodically
- self._cleanup_cache()
-
- # Check if it's a conversion query
- conversion_match = self.conversion_pattern.match(query)
- if conversion_match:
- try:
- value, from_unit, to_unit = conversion_match.groups()
- value = float(value)
-
- # Check cache first
- cache_key = f"{value}_{from_unit}_{to_unit}"
- if cache_key in self._conversion_cache:
- result = self._conversion_cache[cache_key]
- subtitle = f"{value} {from_unit} = {result:.6g} {to_unit}"
- else:
- # Use the conversion utility
- result = self.converter.convert(value, from_unit, to_unit)
- # Cache the result
- self._conversion_cache[cache_key] = result
- subtitle = f"{value} {from_unit} = {result:.6g} {to_unit}"
-
- return [
- Result(
- title=f"{result:.6g} {to_unit}",
- subtitle=subtitle,
- icon_name="calculator-symbolic",
- action=lambda r=f"{result:.6g}": self._copy_to_clipboard(r),
- relevance=1.0,
- plugin_name=self.display_name,
- data={"from": (value, from_unit), "to": (result, to_unit)},
- )
- ]
- except ValueError as e:
- return [
- Result(
- title="Invalid conversion",
- subtitle=str(e),
- icon_name="calculator-symbolic",
- relevance=0.0,
- plugin_name=self.display_name,
- )
- ]
-
- # Check if it's a math expression
- if self.expression_pattern.search(query):
- try:
- # Evaluate the expression
- result = eval(query, {"__builtins__": {}}, self.safe_functions)
- if isinstance(result, (int, float)):
- return [
- Result(
- title=f"{result:.6g}",
- subtitle=f"{query} = {result:.6g}",
- icon_name="calculator-symbolic",
- action=lambda r=f"{result:.6g}": self._copy_to_clipboard(r),
- relevance=1.0,
- plugin_name=self.display_name,
- )
- ]
- except Exception:
- pass
-
- return []
-
- def _format_cache_age(self, age_seconds: float) -> str:
- """Format cache age for display."""
- if age_seconds < 60:
- return f"{int(age_seconds)}s ago"
- elif age_seconds < 3600:
- return f"{int(age_seconds // 60)}m ago"
- else:
- return f"{int(age_seconds // 3600)}h ago"
-
- def _copy_to_clipboard(self, text: str):
- """Copy text to clipboard using cliphist."""
- try:
- # First copy to clipboard
- subprocess.run(["wl-copy"], input=text.encode(), check=True)
- # Then store in cliphist
- subprocess.run(["cliphist", "store"], input=text.encode(), check=True)
- except subprocess.CalledProcessError:
- # If cliphist fails, at least we have the text in clipboard
- pass
-
- def cleanup(self):
- """Clean up resources."""
- self._conversion_cache.clear()
- self.converter.cleanup()
diff --git a/modules/launcher/plugins/clipboard.py b/modules/launcher/plugins/clipboard.py
deleted file mode 100644
index e332d19..0000000
--- a/modules/launcher/plugins/clipboard.py
+++ /dev/null
@@ -1,438 +0,0 @@
-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 modules.launcher.plugin_base import PluginBase
-from modules.launcher.result import Result
-
-
-class ClipboardPlugin(PluginBase):
- def __init__(self):
- super().__init__()
- self.name = "clipboard"
- self.display_name = "Clipboard History"
- self.description = "Search and manage clipboard history using cliphist"
-
- # Performance settings - show more history like example_cliphist.py
- self.max_results = 50
- # Cache clipboard items for 5 seconds (more responsive)
- self.cache_ttl = 5
-
- # Initialize cache and temp directory
- self.tmp_dir = tempfile.mkdtemp(prefix="cliphist-")
- # Cache images forever like example_cliphist.py
- self.image_cache: Dict[str, GdkPixbuf.Pixbuf] = {}
- self.clipboard_items_cache: List[str] = []
- self.cache_timestamp = 0
-
- # Threading
- self.executor = ThreadPoolExecutor(
- max_workers=2, thread_name_prefix="clipboard"
- )
- self.cache_lock = threading.Lock()
-
- # State tracking
- self._loading = False
- self._pending_updates = False
-
- def initialize(self):
- """Initialize the plugin."""
- self.set_triggers(["clip"])
- try:
- subprocess.run(
- ["cliphist", "list"], capture_output=True, check=True, timeout=5
- )
- except (subprocess.SubprocessError, FileNotFoundError):
- raise RuntimeError("cliphist is not installed or not working properly")
-
- # Pre-warm cache in background
- self.executor.submit(self._load_clipboard_items_cached)
-
- def cleanup(self):
- """Cleanup the plugin."""
- try:
- # Shutdown executor
- if hasattr(self, "executor"):
- self.executor.shutdown(wait=False)
-
- # Clean up temp files
- if os.path.exists(self.tmp_dir):
- import shutil
-
- shutil.rmtree(self.tmp_dir)
-
- # Clear caches
- with self.cache_lock:
- self.image_cache.clear()
- self.clipboard_items_cache.clear()
- except Exception as e:
- print(f"Error cleaning up temporary files: {e}", file=sys.stderr)
-
- def invalidate_cache(self):
- """Force invalidation of the clipboard cache."""
- with self.cache_lock:
- self.clipboard_items_cache.clear()
- self.cache_timestamp = 0
- # Keep image cache forever like example_cliphist.py
-
- def _force_launcher_refresh(self):
- """Force the launcher to refresh its results."""
- try:
- from gi.repository import GLib
-
- 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")
- and hasattr(launcher, "_perform_search")
- ):
- # Get current search text to preserve the query
- current_text = launcher.search_entry.get_text()
- # Trigger the search to refresh results
- launcher._perform_search(current_text)
- 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"
- ):
- current_text = obj.search_entry.get_text()
- obj._perform_search(current_text)
- return False
-
- except Exception as e:
- print(f"Error forcing launcher refresh: {e}")
-
- return False # Don't repeat
-
- # Use immediate refresh
- GLib.timeout_add(10, trigger_refresh)
-
- except Exception as e:
- print(f"Could not trigger refresh: {e}")
-
- def _load_clipboard_items_cached(self) -> List[str]:
- """Load clipboard items from cliphist with caching and change detection."""
- current_time = time.time()
-
- # Always load fresh data to check for changes
- try:
- result = subprocess.run(
- ["cliphist", "list"], capture_output=True, check=True, timeout=5
- )
- stdout_str = result.stdout.decode("utf-8", errors="replace")
- if stdout_str.strip():
- lines = stdout_str.strip().split("\n")
- items = [
- line for line in lines if line and " List[str]:
- """Load clipboard items (legacy method for compatibility)."""
- return self._load_clipboard_items_cached()
-
- def _create_pixbuf_from_bytes(
- self, image_data: bytes, max_size: int = 100
- ) -> GdkPixbuf.Pixbuf:
- """Create a GdkPixbuf from image bytes with size limit."""
- try:
- loader = GdkPixbuf.PixbufLoader()
- loader.write(image_data)
- loader.close()
- pixbuf = loader.get_pixbuf()
-
- # Scale image if needed
- width, height = pixbuf.get_width(), pixbuf.get_height()
- if width > height:
- new_width = max_size
- new_height = int(height * (max_size / width))
- else:
- new_height = max_size
- new_width = int(width * (max_size / height))
-
- return pixbuf.scale_simple(
- new_width, new_height, GdkPixbuf.InterpType.BILINEAR
- )
- except GLib.Error:
- return None
-
- def _is_image_data(self, content: str) -> bool:
- """Determine if clipboard content is likely an image (like example_cliphist.py)."""
- return (
- content.startswith("data:image/")
- or content.startswith("\x89PNG")
- or content.startswith("GIF8")
- or content.startswith("\xff\xd8\xff")
- or "binary" in content.lower()
- and any(
- ext in content.lower() for ext in ["jpg", "jpeg", "png", "bmp", "gif"]
- )
- )
-
- def _get_text_preview(self, content: str) -> str:
- """Get a text preview of the content."""
- if len(content) > 50:
- return content[:37] + "..."
- return content
-
- def _load_image_preview_cached(self, item_id: str) -> Optional[GdkPixbuf.Pixbuf]:
- """Load image preview with forever caching (like example_cliphist.py)."""
- # Check cache first - cache forever like example_cliphist.py
- with self.cache_lock:
- if item_id in self.image_cache:
- return self.image_cache[item_id]
-
- try:
- result = subprocess.run(
- ["cliphist", "decode", item_id],
- capture_output=True,
- check=True,
- timeout=3,
- )
- pixbuf = self._create_pixbuf_from_bytes(result.stdout)
- if pixbuf:
- with self.cache_lock:
- self.image_cache[item_id] = pixbuf
-
- return pixbuf
- except Exception as e:
- print(f"Error loading image preview: {e}", file=sys.stderr)
- return None
-
- def _load_image_preview_async(self, item_id: str) -> Optional[GdkPixbuf.Pixbuf]:
- """Load image preview (legacy method for compatibility)."""
- return self._load_image_preview_cached(item_id)
-
- def query(self, query_string: str) -> List[Result]:
- """Search clipboard history using cliphist with optimized performance."""
- results = []
-
- # Handle query string
- if query_string.lower() == "clip":
- query_string = "" # Show all items
-
- try:
- # Load clipboard items from cache
- clipboard_items = self._load_clipboard_items_cached()
-
- # Early exit if no items
- if not clipboard_items:
- return results
-
- # Filter items based on query - search through ALL items first
- filtered_items = []
- query_lower = query_string.lower() if query_string else ""
-
- for item in clipboard_items:
- parts = item.split("\t", 1)
- content = parts[1] if len(parts) > 1 else item
- content_lower = content.lower()
-
- # Fast filtering - search through all items, don't limit here
- if not query_lower or query_lower in content_lower:
- # Calculate relevance score for better sorting
- relevance = 1.0
- if query_lower:
- if content_lower.startswith(query_lower):
- relevance = 1.0 # Exact start match
- elif query_lower in content_lower:
- # Position-based scoring: earlier matches get higher scores
- position = content_lower.find(query_lower)
- relevance = max(
- 0.5, 1.0 - (position / len(content_lower)) * 0.4
- )
-
- filtered_items.append((item, relevance))
-
- # Sort by relevance (highest first) and then limit results
- filtered_items.sort(key=lambda x: x[1], reverse=True)
- filtered_items = filtered_items[: self.max_results]
-
- # Process items with lazy image loading
- for i, (item, relevance) in enumerate(filtered_items):
- parts = item.split("\t", 1)
- item_id = parts[0] if len(parts) > 1 else str(i)
- content = parts[1] if len(parts) > 1 else item
-
- # Handle image content like example_cliphist.py
- if self._is_image_data(content):
- # Check if image is already cached (forever cache like example_cliphist.py)
- cached_pixbuf = None
- with self.cache_lock:
- cached_pixbuf = self.image_cache.get(item_id)
-
- if cached_pixbuf:
- # Use cached image immediately
- result = Result(
- title="Image from clipboard",
- subtitle="Click to copy image to clipboard",
- description="Image content",
- icon=cached_pixbuf,
- relevance=relevance,
- plugin_name=self.name,
- action=lambda id=item_id: self._copy_to_clipboard(id),
- data={"bypass_max_results": True},
- )
- else:
- # Try to load image immediately like example_cliphist.py
- try:
- immediate_pixbuf = self._load_image_preview_cached(item_id)
- if immediate_pixbuf:
- # Successfully loaded immediately
- result = Result(
- title="Image from clipboard",
- subtitle="Click to copy image to clipboard",
- description="Image content",
- icon=immediate_pixbuf,
- relevance=relevance,
- plugin_name=self.name,
- action=lambda id=item_id: self._copy_to_clipboard(
- id
- ),
- data={"bypass_max_results": True},
- )
- else:
- # Show placeholder if loading failed
- result = Result(
- title="Image from clipboard",
- subtitle="Click to copy image to clipboard",
- description="Image content",
- icon_name="image-x-generic",
- relevance=relevance,
- plugin_name=self.name,
- action=lambda id=item_id: self._copy_to_clipboard(
- id
- ),
- data={"bypass_max_results": True},
- )
- except Exception:
- # Fallback to placeholder
- result = Result(
- title="Image from clipboard",
- subtitle="Click to copy image to clipboard",
- description="Image content",
- icon_name="image-x-generic",
- relevance=relevance,
- plugin_name=self.name,
- action=lambda id=item_id: self._copy_to_clipboard(id),
- data={"bypass_max_results": True},
- )
-
- results.append(result)
- continue
-
- # Handle text content
- display_text = self._get_text_preview(content)
- result = Result(
- title=display_text,
- subtitle="Text from clipboard",
- description=(
- content if len(content) <= 100 else content[:97] + "..."
- ),
- icon_name="edit-paste",
- relevance=relevance,
- plugin_name=self.name,
- action=lambda id=item_id: self._copy_to_clipboard(id),
- data={"bypass_max_results": True},
- )
- results.append(result)
-
- except Exception as e:
- # Handle errors gracefully
- results.append(
- Result(
- title="Error accessing clipboard history",
- subtitle=str(e),
- icon_name="dialog-error",
- relevance=0.0,
- plugin_name=self.name,
- data={"bypass_max_results": True},
- )
- )
-
- return results
-
- def _copy_to_clipboard(self, entry_id: str):
- """Copy entry to clipboard using cliphist with timeout."""
- try:
- result = subprocess.run(
- ["cliphist", "decode", entry_id],
- capture_output=True,
- check=True,
- timeout=5,
- )
- # Use wl-copy for Wayland or xclip for X11
- try:
- subprocess.run(["wl-copy"], input=result.stdout, check=True, timeout=3)
- except subprocess.SubprocessError:
- subprocess.run(
- ["xclip", "-selection", "clipboard"],
- input=result.stdout,
- check=True,
- timeout=3,
- )
-
- # Don't invalidate image cache - keep images cached forever like example_cliphist.py
- # Only invalidate clipboard items cache
- with self.cache_lock:
- self.clipboard_items_cache.clear()
- self.cache_timestamp = 0
-
- except subprocess.SubprocessError as e:
- print(f"Error copying to clipboard: {e}", file=sys.stderr)
- except subprocess.TimeoutExpired as e:
- print(f"Timeout copying to clipboard: {e}", file=sys.stderr)
diff --git a/modules/launcher/plugins/emoji.py b/modules/launcher/plugins/emoji.py
deleted file mode 100644
index 8842e80..0000000
--- 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 7ab0b6d..0000000
--- 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 1551b20..0000000
--- 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/power.py b/modules/launcher/plugins/power.py
deleted file mode 100644
index 38b9cd8..0000000
--- a/modules/launcher/plugins/power.py
+++ /dev/null
@@ -1,101 +0,0 @@
-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
-
-
-class PowerPlugin(PluginBase):
- """
- Plugin for system power management operations.
- """
-
- def __init__(self):
- super().__init__()
- self.display_name = "Power"
- self.description = "System power management and control"
- self.commands = {
- "shutdown": {
- "description": "Shutdown the system",
- "icon": "system-shutdown-symbolic",
- "action": self.shutdown,
- },
- "restart": {
- "description": "Restart the system",
- "icon": "system-reboot-symbolic",
- "action": self.restart,
- },
- "lock": {
- "description": "Lock the screen",
- "icon": "system-lock-screen-symbolic",
- "action": self.lock,
- },
- "suspend": {
- "description": "Suspend the system",
- "icon": "system-suspend-symbolic",
- "action": self.suspend,
- },
- "logout": {
- "description": "Logout from current session",
- "icon": "system-log-out-symbolic",
- "action": self.logout,
- },
- }
-
- def initialize(self):
- """Initialize the power plugin."""
- self.set_triggers(["power"])
-
- def cleanup(self):
- """Cleanup the power plugin."""
- pass
-
- def query(self, query_string: str) -> List[Result]:
- """Search power commands based on query."""
- query = query_string.lower().strip()
- results = []
-
- # If no query, show all power commands
- if not query:
- for cmd, info in self.commands.items():
- result = Result(
- title=cmd.capitalize(),
- subtitle=info["description"],
- icon_name=info["icon"],
- action=info["action"],
- relevance=1.0,
- plugin_name=self.display_name,
- data={"command": cmd},
- )
- results.append(result)
- else:
- # Filter commands based on query
- for cmd, info in self.commands.items():
- if query in cmd.lower() or query in info["description"].lower():
- result = Result(
- title=cmd.capitalize(),
- subtitle=info["description"],
- icon_name=info["icon"],
- action=info["action"],
- relevance=1.0 if query == cmd else 0.7,
- plugin_name=self.display_name,
- data={"command": cmd},
- )
- results.append(result)
-
- return results
-
- def shutdown(self, *args) -> None:
- exec_shell_command_async("systemctl poweroff")
-
- def restart(self, *args) -> None:
- exec_shell_command_async("systemctl reboot")
-
- def lock(self, *args) -> None:
- exec_shell_command_async("loginctl lock-session")
-
- def suspend(self, *args) -> None:
- exec_shell_command_async("systemctl suspend")
-
- def logout(self, *args) -> None:
- exec_shell_command_async("hyprctl dispatch exit")
diff --git a/modules/launcher/plugins/reminders.py b/modules/launcher/plugins/reminders.py
deleted file mode 100644
index ded8c20..0000000
--- 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 05253b6..0000000
--- 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 bc7dba0..0000000
--- 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 5c93fad..0000000
--- 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/launcher/plugins/wallpaper.py b/modules/launcher/plugins/wallpaper.py
deleted file mode 100644
index 3d4aa28..0000000
--- a/modules/launcher/plugins/wallpaper.py
+++ /dev/null
@@ -1,976 +0,0 @@
-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
-
-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
-
-
-class WallpaperPlugin(PluginBase):
- """
- Plugin for wallpaper management with search, random selection, and matugen integration.
- """
-
- def __init__(self):
- super().__init__()
- self.display_name = "Wallpaper"
- self.wallpapers = []
- self.cache_dir = f"{data.CACHE_DIR}/thumbs"
- self.thumbnail_cache: Dict[str, Optional[GdkPixbuf.Pixbuf]] = {}
- self.thumbnail_loading = set() # Track which thumbnails are being loaded
- self.schemes = {
- "scheme-tonal-spot": "Tonal Spot",
- "scheme-content": "Content",
- "scheme-expressive": "Expressive",
- "scheme-fidelity": "Fidelity",
- "scheme-fruit-salad": "Fruit Salad",
- "scheme-monochrome": "Monochrome",
- "scheme-neutral": "Neutral",
- "scheme-rainbow": "Rainbow",
- }
-
- def initialize(self):
- """Initialize the wallpaper plugin."""
- self.set_triggers(["wall"])
- self._load_wallpapers()
- os.makedirs(self.cache_dir, exist_ok=True)
- # Start background thumbnail creation
- self._start_background_thumbnail_creation()
-
- def cleanup(self):
- """Cleanup the wallpaper plugin."""
- pass
-
- def _load_wallpapers(self):
- """Load available wallpapers from the wallpapers directory."""
- try:
- if os.path.exists(data.WALLPAPERS_DIR):
- self.wallpapers = sorted(
- [f for f in os.listdir(data.WALLPAPERS_DIR) if self._is_image(f)]
- )
- else:
- logger.error(f"Wallpapers directory not found: {data.WALLPAPERS_DIR}")
- except Exception as e:
- logger.error(f"Error loading wallpapers: {e}")
-
- def _is_image(self, filename: str) -> bool:
- """Check if file is a supported image format."""
- return filename.lower().endswith(
- (".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp")
- )
-
- def _get_matugen_state(self) -> bool:
- """Get current matugen state from config.json."""
- self.matugen_enabled = True # Default to True
- try:
- with open(data.CONFIG_FILE, "r") as f:
- config = json.load(f)
- self.matugen_enabled = config.get("matugen_enabled", True)
- except FileNotFoundError:
- # File doesn't exist, keep default True
- pass
- except Exception as e:
- logger.error(f"Error reading config file: {e}")
- # Keep default True on error
-
- return self.matugen_enabled
-
- def _set_matugen_state(self, enabled: bool):
- """Set matugen state and save to config.json."""
- self.matugen_enabled = enabled
-
- # Save the state to config.json
- try:
- # Read current config
- config = {}
- if os.path.exists(data.CONFIG_FILE):
- with open(data.CONFIG_FILE, "r") as f:
- config = json.load(f)
-
- # Update matugen state
- config["matugen_enabled"] = enabled
-
- # Write back to config file
- with open(data.CONFIG_FILE, "w") as f:
- json.dump(config, f, indent=4)
-
- except Exception as e:
- logger.error(f"Error writing matugen state to config: {e}")
-
- # Clear the search query after toggling matugen
- self._clear_launcher_query()
-
- # # Send notification
- # status = "enabled" if enabled else "disabled"
- # exec_shell_command_async(
- # f"notify-send '๐จ Matugen' 'Dynamic colors {status}' -a '{
- # data.APP_NAME_CAP
- # }' -e"
- # )
-
- 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
-
- app = Application.get_default()
-
- if app and hasattr(app, "launcher"):
- launcher = app.launcher
- if launcher and hasattr(launcher, "search_entry"):
-
- def clear_and_refresh():
- # Clear the search entry to just the trigger
- launcher.search_entry.set_text("wall ")
- # Position cursor at the end
- launcher.search_entry.set_position(-1)
- # Trigger the search to show default wallpaper view
- if hasattr(launcher, "_perform_search"):
- launcher._perform_search("wall ")
- return False
-
- # Use a small delay to ensure the action completes first
- GLib.timeout_add(50, clear_and_refresh)
- return
-
- # 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"):
-
- def clear_and_refresh():
- obj.search_entry.set_text("wall ")
- obj.search_entry.set_position(-1)
- obj._perform_search("wall ")
- return False
-
- GLib.timeout_add(50, clear_and_refresh)
- return
-
- except Exception as e:
- logger.error(f"Could not clear launcher query: {e}")
-
- def _get_cache_path(self, filename: str) -> str:
- """Get cache path for wallpaper thumbnail."""
- file_hash = hashlib.md5(filename.encode("utf-8")).hexdigest()
- return os.path.join(self.cache_dir, f"{file_hash}.png")
-
- def _create_thumbnail(self, filename: str) -> str:
- """Create thumbnail for wallpaper if it doesn't exist."""
- full_path = os.path.join(data.WALLPAPERS_DIR, filename)
- cache_path = self._get_cache_path(filename)
-
- 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)
- except Exception as e:
- logger.error(f"Error creating thumbnail for {filename}: {e}")
- return None
-
- return cache_path
-
- def _start_background_thumbnail_creation(self):
- """Start background thread to create thumbnails for all wallpapers."""
-
- def create_thumbnails():
- for wallpaper in self.wallpapers:
- if wallpaper not in self.thumbnail_loading:
- self.thumbnail_loading.add(wallpaper)
- try:
- cache_path = self._create_thumbnail(wallpaper)
- if cache_path and os.path.exists(cache_path):
- # Load thumbnail into memory cache
- try:
- pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
- cache_path, 32, 32, True
- )
- self.thumbnail_cache[wallpaper] = pixbuf
- except Exception as e:
- logger.error(
- f"Error loading thumbnail for {wallpaper}: {e}"
- )
- self.thumbnail_cache[wallpaper] = None
- else:
- self.thumbnail_cache[wallpaper] = None
- except Exception as e:
- logger.error(f"Error processing thumbnail for {wallpaper}: {e}")
- self.thumbnail_cache[wallpaper] = None
- finally:
- self.thumbnail_loading.discard(wallpaper)
-
- # Small delay to prevent overwhelming the system
- time.sleep(0.01)
-
- # Start background thread
- thread = threading.Thread(target=create_thumbnails, daemon=True)
- thread.start()
-
- def _get_thumbnail_fast(self, filename: str) -> Optional[GdkPixbuf.Pixbuf]:
- """Get thumbnail quickly from cache or return None if not ready."""
- # Return cached thumbnail if available
- if filename in self.thumbnail_cache:
- return self.thumbnail_cache[filename]
-
- # Check if thumbnail file exists and load it immediately
- cache_path = self._get_cache_path(filename)
- if os.path.exists(cache_path):
- try:
- pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
- cache_path, 32, 32, True
- )
- self.thumbnail_cache[filename] = pixbuf
- return pixbuf
- except Exception as e:
- logger.error(f"Error loading thumbnail for {filename}: {e}")
- self.thumbnail_cache[filename] = None
- return None
-
- # If not in cache and file doesn't exist, trigger background creation
- if filename not in self.thumbnail_loading:
- self.thumbnail_loading.add(filename)
-
- def create_async():
- try:
- cache_path = self._create_thumbnail(filename)
- if cache_path and os.path.exists(cache_path):
- try:
- pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
- cache_path, 32, 32, True
- )
- self.thumbnail_cache[filename] = pixbuf
- except Exception as e:
- print(f"Error loading thumbnail for {filename}: {e}")
- self.thumbnail_cache[filename] = None
- else:
- self.thumbnail_cache[filename] = None
- except Exception as e:
- print(f"Error creating thumbnail for {filename}: {e}")
- self.thumbnail_cache[filename] = None
- finally:
- self.thumbnail_loading.discard(filename)
-
- thread = threading.Thread(target=create_async, daemon=True)
- thread.start()
-
- return None
-
- def _set_wallpaper(self, filename: str, scheme: str = None):
- """Set wallpaper and apply matugen color scheme if enabled."""
- full_path = os.path.join(data.WALLPAPERS_DIR, filename)
- current_wall = os.path.expanduser("~/.current.wall")
-
- if scheme is None:
- scheme = self._get_current_scheme()
-
- # Update current wallpaper symlink
- if os.path.isfile(current_wall) or os.path.islink(current_wall):
- os.remove(current_wall)
- os.symlink(full_path, current_wall)
-
- # Always set the wallpaper image
- exec_shell_command_async(
- f'swww img "{
- full_path
- }" -t outer --transition-duration 1.5 --transition-step 255 --transition-fps 60 -f Nearest'
- )
-
- # 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}')
-
- def _set_random_wallpaper(self):
- """Set a random wallpaper."""
- if not self.wallpapers:
- return
-
- filename = random.choice(self.wallpapers)
- self._set_wallpaper(filename)
- # Show notification for immediate feedback
- exec_shell_command_async(
- f"notify-send '๐ฒ Random Wallpaper' 'Applied: {filename}' -a '{
- data.APP_NAME_CAP
- }' -e"
- )
- return filename
-
- def _hsl_to_rgb_hex(self, h: float, s: float = 1.0, l: 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_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}"
-
- def _is_valid_hex_color(self, hex_color: str) -> bool:
- """Check if string is a valid hex color."""
- if not hex_color.startswith("#"):
- hex_color = "#" + hex_color
- return bool(re.match(r"^#[0-9A-Fa-f]{6}$", hex_color))
-
- def _get_current_scheme(self) -> str:
- """Get current color scheme from config (default to tonal-spot)."""
- try:
- with open(data.CONFIG_FILE, "r") as f:
- config = json.load(f)
- return config.get("current_scheme", "scheme-tonal-spot")
- except FileNotFoundError:
- # File doesn't exist, return default
- return "scheme-tonal-spot"
- except Exception as e:
- print(f"Error reading current scheme from config: {e}")
- return "scheme-tonal-spot"
-
- def _set_current_scheme(self, scheme: str):
- """Set current color scheme and apply it."""
- scheme_name = self.schemes.get(scheme, scheme)
- matugen_enabled = self._get_matugen_state()
-
- # Save the scheme to config
- try:
- # Read current config
- config = {}
- if os.path.exists(data.CONFIG_FILE):
- with open(data.CONFIG_FILE, "r") as f:
- config = json.load(f)
-
- # Update current scheme
- config["current_scheme"] = scheme
-
- # Write back to config file
- with open(data.CONFIG_FILE, "w") as f:
- json.dump(config, f, indent=4)
-
- except Exception as e:
- print(f"Error saving current scheme to config: {e}")
- return
-
- # Apply the scheme to current wallpaper if matugen is enabled
- if matugen_enabled:
- current_wall = os.path.expanduser("~/.current.wall")
- if os.path.exists(current_wall) and os.path.islink(current_wall):
- # Get the current wallpaper path
- wallpaper_path = os.readlink(current_wall)
- if os.path.exists(wallpaper_path):
- # Apply the new scheme to current wallpaper
- exec_shell_command_async(
- f'matugen image "{wallpaper_path}" -t {scheme}'
- )
-
- # Send notification
- exec_shell_command_async(
- f"notify-send '๐จ Color Scheme' 'Applied {
- scheme_name
- } scheme' -a '{data.APP_NAME_CAP}' -e"
- )
- else:
- # No current wallpaper, just show scheme change notification
- exec_shell_command_async(
- f"notify-send '๐จ Color Scheme' 'Set to {
- scheme_name
- } (will apply to next wallpaper)' -a '{data.APP_NAME_CAP}' -e"
- )
- else:
- # No current wallpaper, just show scheme change notification
- exec_shell_command_async(
- f"notify-send '๐จ Color Scheme' 'Set to {
- scheme_name
- } (will apply to next wallpaper)' -a '{data.APP_NAME_CAP}' -e"
- )
- else:
- # Matugen is disabled, just save the setting
- exec_shell_command_async(
- f"notify-send '๐จ Color Scheme' 'Set to {
- scheme_name
- } (matugen disabled)' -a '{data.APP_NAME_CAP}' -e"
- )
-
- def _apply_hex_color(self, hex_color: str, scheme: str = None):
- """Apply hex color using matugen. Assumes matugen is enabled."""
- if not hex_color.startswith("#"):
- hex_color = "#" + hex_color
-
- if scheme is None:
- scheme = self._get_current_scheme()
-
- exec_shell_command_async(f'matugen color hex "{hex_color}" -t {scheme}')
-
- def _apply_hex_color_direct(self, hex_color: str, scheme: str = None):
- """Apply hex color using matugen (following example_wallpapers.py pattern)."""
- if not hex_color.startswith("#"):
- hex_color = "#" + hex_color
-
- if scheme is None:
- scheme = self._get_current_scheme()
-
- exec_shell_command_async(f'matugen color hex "{hex_color}" -t {scheme}')
-
- # Send notification
- exec_shell_command_async(
- f"notify-send '๐จ Hex Color Applied' 'Applied color: {hex_color}' -a '{
- data.APP_NAME_CAP
- }' -e"
- )
-
- def _generate_random_hex_color(self) -> str:
- """Generate a random hex color."""
- hue = random.randint(0, 360)
- return self._hsl_to_rgb_hex(hue)
-
- def _get_status_indicators(self) -> tuple:
- """Get current status indicators for display."""
- current_scheme = self._get_current_scheme()
- matugen_enabled = self._get_matugen_state()
- scheme_name = self.schemes.get(current_scheme, current_scheme)
-
- indicators = []
- if not matugen_enabled:
- indicators.append("Matugen Off")
-
- indicator_text = " โข " + " โข ".join(indicators) if indicators else ""
- status_text = f"Matugen: {
- 'Enabled' if matugen_enabled else 'Disabled'
- } โข Scheme: {scheme_name}"
-
- return indicator_text, status_text, current_scheme, matugen_enabled
-
- def query(self, query_string: str) -> List[Result]:
- """Search wallpapers and provide management options."""
- results = []
- query = query_string.lower().strip()
-
- # Get status indicators for consistent display
- indicator_text, status_text, current_scheme, matugen_enabled = (
- self._get_status_indicators()
- )
-
- # Special commands
- if query.strip() == "random":
- # Show result for random wallpaper (execute on Enter)
- results.append(
- Result(
- title=f"Random Wallpaper{indicator_text}",
- subtitle=f"Set a random wallpaper โข {status_text}",
- icon_name="media-playlist-shuffle-symbolic",
- action=lambda: self._set_random_wallpaper(),
- relevance=1.0,
- plugin_name=self.display_name,
- data={
- "action": "random",
- "bypass_max_results": True,
- "keep_launcher_open": True,
- },
- )
- )
- elif query.startswith("random") and query.strip() != "random":
- # Show suggestion for random wallpaper (partial match)
- results.append(
- Result(
- title=f"Random Wallpaper{indicator_text}",
- subtitle=f"Set a random wallpaper โข {status_text}",
- icon_name="media-playlist-shuffle-symbolic",
- action=lambda: self._set_random_wallpaper(),
- relevance=0.9,
- plugin_name=self.display_name,
- data={
- "action": "random_suggestion",
- "bypass_max_results": True,
- "keep_launcher_open": True,
- },
- )
- )
-
- # Hex color commands
- if "color" in query or "hex" in query or query.startswith("#"):
- # Check for scheme specification in the query
- scheme = self._get_current_scheme()
- for scheme_id, scheme_name in self.schemes.items():
- if (
- scheme_name.lower() in query.lower()
- or scheme_id.lower() in query.lower()
- ):
- scheme = scheme_id
- break
-
- # Check for hex color patterns
- hex_match = re.search(r"#?([0-9A-Fa-f]{6})", query)
- if hex_match:
- hex_color = "#" + hex_match.group(1)
- scheme_name = self.schemes.get(scheme, scheme)
-
- # Check if this is a complete hex color input (6 digits)
- # Execute immediately when we have a complete 6-digit hex color
- if len(hex_match.group(1)) == 6:
- # Check if there's additional text after the hex color
- hex_end_pos = hex_match.end()
- remaining_text = query[hex_end_pos:].strip()
-
- # Only show result if no additional text after hex color (exact match)
- if not remaining_text:
- # Hex colors work when matugen is OFF (following example_wallpapers.py pattern)
- if not matugen_enabled:
- # Show result for hex color (execute on Enter)
- results.append(
- Result(
- title=f"Apply Hex Color: {hex_color}{
- indicator_text
- }",
- subtitle=f"Apply with {scheme_name} scheme โข {
- status_text
- }",
- icon_name="color-picker-symbolic",
- action=lambda c=hex_color, s=scheme: self._apply_hex_color_direct(
- c, s
- ),
- relevance=1.0,
- plugin_name=self.display_name,
- data={
- "action": "hex_color",
- "color": hex_color,
- "scheme": scheme,
- "bypass_max_results": True,
- "keep_launcher_open": True,
- },
- )
- )
- else:
- # Show error result when matugen is enabled
- results.append(
- Result(
- title=f"Cannot Apply Hex Color: {hex_color}{
- indicator_text
- }",
- subtitle="Matugen is enabled โข Disable matugen to use hex colors",
- icon_name="color-picker-symbolic",
- action=lambda: None,
- relevance=1.0,
- plugin_name=self.display_name,
- data={
- "action": "hex_color_failed",
- "color": hex_color,
- "bypass_max_results": True,
- },
- )
- )
- else:
- # Show suggestion for hex color with additional text (partial match)
- results.append(
- Result(
- title=f"Apply Hex Color: {hex_color}{indicator_text}",
- subtitle=f"Apply with {scheme_name} scheme โข {
- status_text
- }",
- icon_name="color-picker-symbolic",
- action=lambda c=hex_color, s=scheme: (
- self._apply_hex_color_direct(c, s)
- if not matugen_enabled
- else None
- ),
- relevance=0.9,
- plugin_name=self.display_name,
- data={
- "action": "hex_color_suggestion",
- "color": hex_color,
- "scheme": scheme,
- "bypass_max_results": True,
- "keep_launcher_open": True,
- },
- )
- )
- else:
- # Incomplete hex color, show as suggestion
- results.append(
- Result(
- title=f"Hex Color (incomplete): {hex_color}",
- subtitle="Complete the 6-digit hex color to apply",
- icon_name="color-picker-symbolic",
- action=lambda: None,
- relevance=0.7,
- plugin_name=self.display_name,
- data={
- "action": "hex_color_incomplete",
- "color": hex_color,
- "bypass_max_results": True,
- },
- )
- )
- elif "random" in query and ("color" in query or "hex" in query):
- # Check for exact matches for random color commands
- if (
- query.strip() == "color random"
- or query.strip() == "hex random"
- or query.strip() == "random color"
- or query.strip() == "random hex"
- ):
- # Random hex color - show result (execute on Enter)
- scheme_name = self.schemes.get(scheme, scheme)
-
- # Check if matugen is disabled (hex colors work when matugen is OFF)
- if not matugen_enabled:
- results.append(
- Result(
- title=f"Random Hex Color{indicator_text}",
- subtitle=f"Generate and apply random color with {
- scheme_name
- } scheme โข {status_text}",
- icon_name="color-picker-symbolic",
- action=lambda s=scheme: self._apply_hex_color_direct(
- self._generate_random_hex_color(), s
- ),
- relevance=1.0,
- plugin_name=self.display_name,
- data={
- "action": "random_hex",
- "scheme": scheme,
- "bypass_max_results": True,
- "keep_launcher_open": True,
- },
- )
- )
- else:
- # Show error result when matugen is enabled
- results.append(
- Result(
- title=f"Cannot Apply Random Color{indicator_text}",
- subtitle="Matugen is enabled โข Disable matugen to use hex colors",
- icon_name="color-picker-symbolic",
- action=lambda: None,
- relevance=1.0,
- plugin_name=self.display_name,
- data={
- "action": "random_hex_failed",
- "bypass_max_results": True,
- },
- )
- )
- else:
- # Show suggestion for random hex color (partial match)
- results.append(
- Result(
- title=f"Random Hex Color{indicator_text}",
- subtitle=f"Generate and apply random color โข {status_text}",
- icon_name="color-picker-symbolic",
- action=lambda s=scheme: (
- self._apply_hex_color_direct(
- self._generate_random_hex_color(), s
- )
- if not matugen_enabled
- else None
- ),
- relevance=0.8,
- plugin_name=self.display_name,
- data={
- "action": "random_hex_suggestion",
- "scheme": scheme,
- "bypass_max_results": True,
- "keep_launcher_open": True,
- },
- )
- )
- else:
- # Show hex color help
- results.append(
- Result(
- title="Hex Color Commands",
- subtitle="Use: color #FF5733, hex #00FF00, color random",
- icon_name="color-picker-symbolic",
- action=lambda: None,
- relevance=0.8,
- plugin_name=self.display_name,
- data={"action": "hex_help", "bypass_max_results": True},
- )
- )
-
- # Color scheme commands
- if "scheme" in query:
- current_scheme = self._get_current_scheme()
- matugen_enabled = self._get_matugen_state()
-
- # Show all available schemes
- for scheme_id, scheme_name in self.schemes.items():
- # Check if this scheme matches the query (for filtering)
- if query.strip() == "scheme":
- # Show all schemes when just "scheme" is typed
- scheme_matches = True
- else:
- # Extract search terms from query (remove "scheme" keyword)
- search_terms = query.replace("scheme", "").strip().split()
- scheme_matches = False
-
- # Check if any search term matches the scheme name or ID
- for term in search_terms:
- if (
- term.lower() in scheme_name.lower()
- or term.lower() in scheme_id.lower()
- ):
- scheme_matches = True
- break
-
- if scheme_matches:
- # Create indicators
- indicators = []
- if scheme_id == current_scheme:
- indicators.append("Current")
- if not matugen_enabled:
- indicators.append("Matugen Off")
-
- indicator_text = (
- " โข " + " โข ".join(indicators) if indicators else ""
- )
-
- results.append(
- Result(
- title=f"{scheme_name}{indicator_text}",
- subtitle=f"Set color scheme to {scheme_name}"
- + (
- " (requires matugen enabled)"
- if not matugen_enabled
- else ""
- ),
- icon_name="color-management-symbolic",
- action=lambda s=scheme_id: self._set_current_scheme(s),
- relevance=1.0 if scheme_id == current_scheme else 0.8,
- plugin_name=self.display_name,
- data={
- "action": "scheme_select",
- "scheme": scheme_id,
- "bypass_max_results": True,
- "keep_launcher_open": True,
- },
- )
- )
-
- # Matugen controls
- if "matugen" in query:
- current_state = self._get_matugen_state()
-
- # Check for exact command matches
- if query.strip() == "matugen on" or query.strip() == "matugen enable":
- # Show result for enabling matugen (execute on Enter)
- results.append(
- Result(
- title=f"Enable Matugen{indicator_text}",
- subtitle=f"Turn on dynamic color generation โข {status_text}",
- icon_name="color-management-symbolic",
- action=lambda: self._set_matugen_state(True),
- relevance=0.9,
- plugin_name=self.display_name,
- data={
- "action": "matugen_on",
- "bypass_max_results": True,
- "keep_launcher_open": True,
- },
- )
- )
- elif query.strip() == "matugen off" or query.strip() == "matugen disable":
- # Show result for disabling matugen (execute on Enter)
- results.append(
- Result(
- title=f"Disable Matugen{indicator_text}",
- subtitle=f"Turn off dynamic color generation โข {status_text}",
- icon_name="color-management-symbolic",
- action=lambda: self._set_matugen_state(False),
- relevance=0.9,
- plugin_name=self.display_name,
- data={
- "action": "matugen_off",
- "bypass_max_results": True,
- "keep_launcher_open": True,
- },
- )
- )
- elif query.strip() == "matugen toggle":
- # Show result for toggling matugen (execute on Enter)
- new_state = not current_state
- results.append(
- Result(
- title=f"Toggle Matugen to {
- 'Enabled' if new_state else 'Disabled'
- }{indicator_text}",
- subtitle=f"Switch matugen to {
- 'enabled' if new_state else 'disabled'
- } โข {status_text}",
- icon_name="color-management-symbolic",
- action=lambda: self._set_matugen_state(new_state),
- relevance=0.9,
- plugin_name=self.display_name,
- data={
- "action": "matugen_toggle",
- "bypass_max_results": True,
- "keep_launcher_open": True,
- },
- )
- )
- elif ("on" in query or "enable" in query) and not query.strip().endswith(
- ("on", "enable")
- ):
- # Show suggestion for enabling (partial match)
- results.append(
- Result(
- title=f"Enable Matugen{indicator_text}",
- subtitle=f"Turn on dynamic color generation โข {status_text}",
- icon_name="color-management-symbolic",
- action=lambda: self._set_matugen_state(True),
- relevance=0.8,
- plugin_name=self.display_name,
- data={
- "action": "matugen_on_suggestion",
- "bypass_max_results": True,
- "keep_launcher_open": True,
- },
- )
- )
- elif ("off" in query or "disable" in query) and not query.strip().endswith(
- ("off", "disable")
- ):
- # Show suggestion for disabling (partial match)
- results.append(
- Result(
- title=f"Disable Matugen{indicator_text}",
- subtitle=f"Turn off dynamic color generation โข {status_text}",
- icon_name="color-management-symbolic",
- action=lambda: self._set_matugen_state(False),
- relevance=0.8,
- plugin_name=self.display_name,
- data={
- "action": "matugen_off_suggestion",
- "bypass_max_results": True,
- "keep_launcher_open": True,
- },
- )
- )
- elif "toggle" in query and not query.strip().endswith("toggle"):
- # Show suggestion for toggling (partial match)
- results.append(
- Result(
- title=f"Toggle Matugen{indicator_text}",
- subtitle=f"Switch matugen state โข {status_text}",
- icon_name="color-management-symbolic",
- action=lambda: self._set_matugen_state(not current_state),
- relevance=0.8,
- plugin_name=self.display_name,
- data={
- "action": "matugen_toggle_suggestion",
- "bypass_max_results": True,
- "keep_launcher_open": True,
- },
- )
- )
- else:
- # Show current state with scheme info
- results.append(
- Result(
- title=f"Matugen: {'Enabled' if current_state else 'Disabled'}{
- indicator_text
- }",
- subtitle=f"Dynamic colors โข {status_text}",
- icon_name="color-management-symbolic",
- action=lambda: None,
- relevance=0.8,
- plugin_name=self.display_name,
- data={"action": "matugen_status", "bypass_max_results": True},
- )
- )
-
- # Status command
- if query == "status" or query == "info":
- results.append(
- Result(
- title=f"Wallpaper System Status{indicator_text}",
- subtitle=status_text,
- icon_name="color-management-symbolic",
- action=lambda: None,
- relevance=1.0,
- plugin_name=self.display_name,
- data={"action": "status", "bypass_max_results": True},
- )
- )
-
- # Show quick actions
- results.append(
- Result(
- title=f"Random Wallpaper{indicator_text}",
- subtitle=f"Set a random wallpaper โข {status_text}",
- icon_name="media-playlist-shuffle-symbolic",
- action=lambda: self._set_random_wallpaper(),
- relevance=0.9,
- plugin_name=self.display_name,
- data={
- "action": "random_quick",
- "bypass_max_results": True,
- "keep_launcher_open": True,
- },
- )
- )
-
- # Search wallpapers by filename - Show ALL wallpapers like example_wallpapers.py
- if not query or (
- query
- and "matugen" not in query
- and "random" not in query
- and "scheme" not in query
- and "status" not in query
- and "info" not in query
- and "color" not in query
- and "hex" not in query
- ):
- matching_wallpapers = []
- for wallpaper in self.wallpapers:
- if not query or query in wallpaper.lower():
- # Calculate relevance
- relevance = 1.0 if query == wallpaper.lower() else 0.7
- if query and query in wallpaper.lower():
- relevance = 0.8
- matching_wallpapers.append((wallpaper, relevance))
-
- # Sort by relevance and show ALL wallpapers (like example_wallpapers.py)
- matching_wallpapers.sort(key=lambda x: x[1], reverse=True)
-
- # Show ALL wallpapers instead of limiting (following example_wallpapers.py pattern)
- for wallpaper, relevance in matching_wallpapers:
- # Use fast thumbnail loading
- icon = self._get_thumbnail_fast(wallpaper)
-
- results.append(
- Result(
- title=f"{wallpaper}{indicator_text if not query else ''}",
- subtitle=f"Set as wallpaper{
- ' โข ' + status_text if not query else ''
- }",
- icon=icon,
- icon_name="image-x-generic-symbolic" if not icon else None,
- action=lambda w=wallpaper: self._set_wallpaper(w),
- relevance=relevance,
- plugin_name=self.display_name,
- data={
- "wallpaper": wallpaper,
- "action": "set",
- "bypass_max_results": True,
- "keep_launcher_open": True,
- },
- )
- )
-
- # Sort by relevance - no limit since we use bypass_max_results
- results.sort(key=lambda x: x.relevance, reverse=True)
- return results
diff --git a/modules/launcher/plugins/websearch.py b/modules/launcher/plugins/websearch.py
deleted file mode 100644
index 80f992b..0000000
--- a/modules/launcher/plugins/websearch.py
+++ /dev/null
@@ -1,259 +0,0 @@
-import subprocess
-import urllib.parse
-from typing import List
-
-from modules.launcher.plugin_base import PluginBase
-from modules.launcher.result import Result
-
-
-class WebSearchPlugin(PluginBase):
- """
- Web search plugin that supports multiple search engines.
- """
-
- def __init__(self):
- super().__init__()
- self.display_name = "Web Search"
- self.description = "Search the web using various search engines"
- self.current_trigger = "" # Track the current active trigger
-
- # Define search engines with their URLs and icons
- self.search_engines = {
- "google": {
- "name": "Google",
- "url": "https://www.google.com/search?q={}",
- "icon": "google-symbolic",
- "description": "Search with Google",
- },
- "duckduckgo": {
- "name": "DuckDuckGo",
- "url": "https://duckduckgo.com/?q={}",
- "icon": "preferences-desktop-search",
- "description": "Search with DuckDuckGo (privacy-focused)",
- },
- "youtube": {
- "name": "YouTube",
- "url": "https://www.youtube.com/results?search_query={}",
- "icon": "youtube-symbolic",
- "description": "Search videos on YouTube",
- },
- "github": {
- "name": "GitHub",
- "url": "https://github.com/search?q={}",
- "icon": "github-desktop-symbolic",
- "description": "Search repositories on GitHub",
- },
- "stackoverflow": {
- "name": "Stack Overflow",
- "url": "https://stackoverflow.com/search?q={}",
- "icon": "stack",
- "description": "Search programming questions on Stack Overflow",
- },
- "wikipedia": {
- "name": "Wikipedia",
- "url": "https://en.wikipedia.org/wiki/Special:Search?search={}",
- "icon": "search-menus-symbolic",
- "description": "Search articles on Wikipedia",
- },
- "reddit": {
- "name": "Reddit",
- "url": "https://www.reddit.com/search/?q={}",
- "icon": "reddit-symbolic",
- "description": "Search discussions on Reddit",
- },
- "linkedin": {
- "name": "LinkedIn",
- "url": "https://www.linkedin.com/search/results/all/?keywords={}",
- "icon": "link",
- "description": "Search professionals and jobs on LinkedIn",
- },
- }
-
- # Default search engine
- self.default_engine = "google"
-
- def initialize(self):
- """Initialize the web search plugin."""
- # Set up triggers for web search - only main triggers, not individual search engines
- triggers = ["?"]
-
- # Don't add individual search engine triggers to avoid cluttering trigger keywords
- # Search engines can still be used within web search context (e.g., "web google cats")
-
- self.set_triggers(triggers)
-
- def get_active_trigger(self, query_string: str) -> str:
- """Override to track which trigger was activated."""
- trigger = super().get_active_trigger(query_string)
- if trigger:
- self.current_trigger = trigger.strip()
- return trigger
-
- def cleanup(self):
- """Cleanup the web search plugin."""
- pass
-
- def query(self, query_string: str) -> List[Result]:
- """Process web search queries."""
- results = []
-
- if not query_string.strip():
- # Show available search engines when no query
- return self._get_search_engine_list()
-
- # Check if the query is a URL
- if self._is_url(query_string):
- results.append(self._create_url_result(query_string))
- return results
-
- # Parse query to check if it starts with a search engine name
- engine_name, search_query = self._parse_engine_query(query_string)
-
- if engine_name and engine_name in self.search_engines:
- # Specific search engine specified in query (e.g., "google cats")
- if search_query:
- # Search with specific engine
- results.append(self._create_search_result(engine_name, search_query))
- else:
- # Show specific engine info
- results.append(self._create_engine_info_result(engine_name))
- else:
- # General search - offer multiple engines
- search_query = query_string.strip()
-
- # Add default search engine first
- results.append(
- self._create_search_result(self.default_engine, search_query)
- )
-
- # Add other popular search engines
- popular_engines = ["duckduckgo", "youtube", "github"]
- for engine in popular_engines:
- if engine != self.default_engine:
- results.append(self._create_search_result(engine, search_query))
-
- return results
-
- def _parse_engine_query(self, query: str) -> tuple[str, str]:
- """Parse query to extract search engine and search terms."""
- query = query.strip().lower()
-
- for engine in self.search_engines.keys():
- if query.startswith(f"{engine} "):
- return engine, query[len(engine) :].strip()
- elif query == engine:
- return engine, ""
-
- return "", query
-
- def _is_url(self, text: str) -> bool:
- """Check if the text is a URL."""
- text = text.strip().lower()
- return text.startswith(("http://", "https://", "www.")) or (
- "." in text and " " not in text and len(text) > 3
- )
-
- def _create_url_result(self, url: str) -> Result:
- """Create a result for opening a URL directly."""
- # Add protocol if missing
- if not url.startswith(("http://", "https://")):
- if url.startswith("www."):
- url = "https://" + url
- else:
- url = "https://" + url
-
- return Result(
- title=f"Open {url}",
- subtitle="Open this URL in your default browser",
- icon_name="link",
- action=lambda u=url: self._open_url(u),
- relevance=1.0,
- plugin_name=self.display_name,
- data={"type": "url", "url": url},
- )
-
- def _open_url(self, url: str):
- """Open a URL directly in the default browser."""
- try:
- subprocess.Popen(
- ["xdg-open", url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
- )
- except Exception as e:
- print(f"Failed to open URL: {e}")
-
- def _get_search_engine_list(self) -> List[Result]:
- """Get list of available search engines."""
- results = []
-
- for engine_id, engine_info in self.search_engines.items():
- result = Result(
- title=engine_info["name"],
- subtitle=engine_info["description"],
- icon_name=engine_info["icon"],
- action=lambda e=engine_id: self._show_engine_help(e),
- relevance=1.0 if engine_id == self.default_engine else 0.8,
- plugin_name=self.display_name,
- data={"type": "engine_info", "engine": engine_id},
- )
- results.append(result)
-
- return results
-
- def _create_search_result(self, engine_id: str, query: str) -> Result:
- """Create a search result for a specific engine and query."""
- engine_info = self.search_engines[engine_id]
-
- return Result(
- title=f"Search '{query}' on {engine_info['name']}",
- subtitle=f"{engine_info['description']} - {query}",
- icon_name=engine_info["icon"],
- action=lambda e=engine_id, q=query: self._perform_search(e, q),
- relevance=1.0 if engine_id == self.default_engine else 0.9,
- plugin_name=self.display_name,
- data={"type": "search", "engine": engine_id, "query": query},
- )
-
- def _create_engine_info_result(self, engine_id: str) -> Result:
- """Create an info result for a specific search engine."""
- engine_info = self.search_engines[engine_id]
-
- return Result(
- title=f"{engine_info['name']} Search",
- subtitle=f"{engine_info['description']} - Type your search query",
- icon_name=engine_info["icon"],
- action=lambda: None, # No action for info result
- relevance=1.0,
- plugin_name=self.display_name,
- data={"type": "engine_ready", "engine": engine_id},
- )
-
- def _perform_search(self, engine_id: str, query: str):
- """Perform a web search using the specified engine."""
- if not query.strip():
- return
-
- engine_info = self.search_engines.get(engine_id)
- if not engine_info:
- return
-
- # URL encode the search query
- encoded_query = urllib.parse.quote_plus(query)
- search_url = engine_info["url"].format(encoded_query)
-
- try:
- # Open the search URL in the default browser
- subprocess.Popen(
- ["xdg-open", search_url],
- stdout=subprocess.DEVNULL,
- stderr=subprocess.DEVNULL,
- )
- except Exception as e:
- print(f"Failed to open search URL: {e}")
-
- def _show_engine_help(self, engine_id: str):
- """Show help for a specific search engine."""
- engine_info = self.search_engines.get(engine_id)
- if engine_info:
- print(
- f"Search engine: {engine_info['name']} - {engine_info['description']}"
- )
diff --git a/modules/launcher/result.py b/modules/launcher/result.py
deleted file mode 100644
index fce3550..0000000
--- a/modules/launcher/result.py
+++ /dev/null
@@ -1,52 +0,0 @@
-from dataclasses import dataclass
-from typing import Any, Callable, Optional
-
-from gi.repository import GdkPixbuf, Gtk
-
-
-@dataclass
-class Result:
- """
- Represents a search result that can be displayed and activated.
- """
-
- # Display information
- title: str
- subtitle: str = ""
- subtitle_markup: Optional[str] = None # Pango markup for subtitle
- description: str = ""
- icon: Optional[GdkPixbuf.Pixbuf] = None
- icon_name: Optional[str] = None
- icon_markup: Optional[str] = None
- # Behavior
- action: Optional[Callable[[], Any]] = None
- relevance: float = 0.0
-
- # Custom widget support
- custom_widget: Optional[Gtk.Widget] = None
-
- # Metadata
- plugin_name: str = ""
- data: Optional[dict] = None
-
- def activate(self):
- """Activate this result (execute its action)."""
- if self.action:
- return self.action()
- else:
- raise NotImplementedError("No action defined for this result")
-
- def __post_init__(self):
- """Post-initialization processing."""
- # Ensure relevance is within valid range
- self.relevance = max(0.0, min(1.0, self.relevance))
-
- # Set default data if None
- if self.data is None:
- self.data = {}
-
- def __str__(self):
- return f"Result(title='{self.title}', relevance={self.relevance})"
-
- def __repr__(self):
- return self.__str__()
diff --git a/modules/launcher/result_item.py b/modules/launcher/result_item.py
deleted file mode 100644
index baaaa43..0000000
--- a/modules/launcher/result_item.py
+++ /dev/null
@@ -1,163 +0,0 @@
-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")
-
-
-class ResultItem(EventBox):
- """
- Widget for displaying a single search result.
- """
-
- # Signals
- @Signal
- def clicked(self, index: int) -> None:
- """Emitted when result is clicked."""
- pass
-
- @Signal
- def hovered(self, index: int) -> None:
- """Emitted when result is hovered."""
- pass
-
- def __init__(
- self, result: Result, selected: bool = False, index: int = 0, **kwargs
- ):
- super().__init__(name="launcher-result-item", **kwargs)
-
- self.result = result
- self._selected = selected
- self.index = index
-
- # Setup UI
- self._setup_ui()
-
- # Connect signals
- self.connect("button-press-event", self._on_button_press)
- self.connect("enter-notify-event", self._on_enter)
- self.connect("leave-notify-event", self._on_leave)
-
- # Set initial selection state
- self.set_selected(selected)
-
- def _setup_ui(self):
- """Setup the result item UI."""
- # Main container
- main_box = Box(
- name="result-item-main",
- orientation="h",
- spacing=12,
- h_align="fill",
- v_align="center",
- )
- self.add(main_box)
-
- # Icon
- if self.result.icon:
- icon_widget = Image(pixbuf=self.result.icon, name="result-item-icon")
- elif self.result.icon_name:
- icon_widget = Image(
- icon_name=self.result.icon_name, icon_size=48, name="result-item-icon"
- )
- elif self.result.icon_markup:
- icon_widget = Label(
- markup=self.result.icon_markup, name="launcher-icon-label"
- )
- else:
- # Default icon
- icon_widget = Image(
- icon_name="application-default-icon",
- icon_size=48,
- name="result-item-icon",
- )
-
- main_box.add(icon_widget)
-
- # Text container
- text_box = Box(
- name="result-item-text",
- orientation="v",
- spacing=2,
- h_expand=True,
- v_align="center",
- )
- main_box.add(text_box)
-
- # Title
- title_label = Label(
- label=self.result.title,
- name="result-item-title",
- h_align="start",
- v_align="center",
- ellipsize="end",
- )
- text_box.add(title_label)
-
- # Subtitle (if present)
- if self.result.subtitle or self.result.subtitle_markup:
- if self.result.subtitle_markup:
- # Use markup for subtitle (supports Pango markup)
- subtitle_label = Label(
- markup=self.result.subtitle_markup,
- name="result-item-subtitle",
- h_align="start",
- v_align="center",
- ellipsize="end",
- )
- else:
- # Use plain text for subtitle
- subtitle_label = Label(
- label=self.result.subtitle,
- name="result-item-subtitle",
- h_align="start",
- v_align="center",
- ellipsize="end",
- )
- text_box.add(subtitle_label)
-
- # Plugin name (small text)
- if self.result.plugin_name:
- plugin_label = Label(
- label=f"via {self.result.plugin_name}",
- name="result-item-plugin",
- h_align="start",
- v_align="center",
- ellipsize="end",
- )
- text_box.add(plugin_label)
-
- def set_selected(self, selected: bool):
- """Set the selection state of this result item."""
- self._selected = selected
-
- if selected:
- self.add_style_class("selected")
- else:
- self.remove_style_class("selected")
-
- def get_selected(self) -> bool:
- """Get the selection state of this result item."""
- return self._selected
-
- def _on_button_press(self, widget, event):
- """Handle button press events."""
- if event.button == 1: # Left click
- self.clicked.emit(self.index)
- return True
- return False
-
- def _on_enter(self, widget, event):
- """Handle mouse enter events."""
- # Emit hover signal to update selection
- self.hovered.emit(self.index)
- return False
-
- def _on_leave(self, widget, event):
- """Handle mouse leave events."""
- # Could be used for hover effects cleanup
- return False
diff --git a/modules/launcher/trigger_config.py b/modules/launcher/trigger_config.py
deleted file mode 100644
index 3a568c4..0000000
--- a/modules/launcher/trigger_config.py
+++ /dev/null
@@ -1,57 +0,0 @@
-import json
-import os
-from typing import Any, Dict, List
-
-from fabric.utils import get_relative_path
-
-
-class TriggerConfig:
- def __init__(self, config_path: str = None):
- if config_path is None:
- config_path = get_relative_path("../../config/assets/launcher.json")
-
- self.config_path = config_path
-
- # Load configuration from JSON file
- config = {"launcher_config": {}, "settings": {}}
- if os.path.exists(config_path):
- try:
- with open(config_path, "r", encoding="utf-8") as f:
- config = json.load(f)
- except Exception as e:
- print(f"Error loading trigger config: {e}")
-
- self.config = config
- self.launcher_config = self.config.get("launcher_config", {})
-
- # Initialize settings with defaults
- default_settings = {
- "max_examples_shown": 2,
- "default_icon": "application-default-icon",
- "fallback_example_template": "{trigger} ",
- "config_version": "1.0",
- }
- self.settings = {**default_settings, **self.config.get("settings", {})}
-
- def get_trigger_examples(self, trigger: str) -> List[str]:
- examples = self.launcher_config.get(trigger, {}).get("examples", [])
- if not examples:
- template = self.settings.get(
- "fallback_example_template", "{trigger} "
- )
- examples = [template.format(trigger=trigger)]
- return examples
-
- def get_trigger_icon(self, trigger: str) -> str:
- icon = self.launcher_config.get(trigger, {}).get(
- "icon", self.settings.get("default_icon", "application-default-icon")
- )
- return icon
-
- def get_trigger_description(self, trigger: str) -> str:
- return self.launcher_config.get(trigger, {}).get(
- "description", f"{trigger} - No description available"
- )
-
- def get_all_triggers(self) -> Dict[str, Dict[str, Any]]:
- return self.launcher_config.copy()
diff --git a/modules/notification/notification.py b/modules/notification/notification.py
deleted file mode 100644
index 77be746..0000000
--- a/modules/notification/notification.py
+++ /dev/null
@@ -1,1281 +0,0 @@
-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.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 services.modus import notification_service
-
-NOTIFICATION_WIDTH = 360
-NOTIFICATION_IMAGE_SIZE = 48
-
-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
-
-
-def ensure_notification_cache_dirs():
- """Ensure unified notification cache directory exists"""
- os.makedirs(UNIFIED_NOTIFICATION_CACHE_DIR, exist_ok=True)
-
-
-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}")
- 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
-
-
-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
- except Exception as e:
- logger.warning(f"Failed to cache notification icon: {e}")
- return None
-
-
-def get_cached_pixbuf(cache_key, fallback_size=(48, 48), cache_dir=None):
- """Get a cached pixbuf or return None if not found"""
- if cache_dir is 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
- )
- except Exception as e:
- logger.warning(f"Failed to load cached notification icon: {e}")
- return None
-
-
-def cache_notification_icon(source, size=(48, 48), app_name=None):
- """Optimized notification icon caching with immediate pixbuf generation and caching"""
- try:
- ensure_notification_cache_dirs()
-
- # Handle different source types with optimized caching
- if isinstance(source, str):
- cache_key = get_unified_cache_key(source, size, app_name)
-
- # Check cache first for immediate return
- cached_pixbuf = get_cached_pixbuf(
- cache_key, fallback_size=size, cache_dir=NOTIFICATION_ICON_CACHE_DIR
- )
- if cached_pixbuf:
- return cached_pixbuf
-
- # Load, cache, and return icon in one optimized flow
- if source.startswith("file://"):
- # Local file URL - process and cache immediately
- file_path = source[7:]
- pixbuf = load_and_cache_local_icon(file_path, cache_key, size)
- elif os.path.exists(source):
- # Direct file path - process and cache immediately
- pixbuf = load_and_cache_local_icon(source, cache_key, size)
- else:
- # Icon name - resolve from theme and cache immediately
- pixbuf = load_and_cache_theme_icon(source, cache_key, size)
-
- return pixbuf
-
- elif hasattr(source, "scale_simple"):
- # Already a pixbuf - cache it directly with optimized flow
- cache_key = get_unified_cache_key(source, size, app_name)
-
- # Check cache first
- cached_pixbuf = get_cached_pixbuf(
- cache_key, fallback_size=size, cache_dir=NOTIFICATION_ICON_CACHE_DIR
- )
- if cached_pixbuf:
- return cached_pixbuf
-
- # Scale once and cache immediately
- scaled_pixbuf = source.scale_simple(
- size[0], size[1], GdkPixbuf.InterpType.BILINEAR
- )
- save_pixbuf_to_cache(scaled_pixbuf, cache_key, NOTIFICATION_ICON_CACHE_DIR)
- return scaled_pixbuf
-
- except Exception as e:
- logger.warning(f"Failed to cache notification icon: {e}")
-
- # Return fallback with caching
- return get_fallback_notification_icon(size)
-
-
-def load_and_cache_local_icon(file_path, cache_key, size):
- """Load a local icon file and cache it"""
- try:
- if os.path.exists(file_path):
- pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
- file_path, size[0], size[1], True
- )
- save_pixbuf_to_cache(pixbuf, cache_key, NOTIFICATION_ICON_CACHE_DIR)
- return pixbuf
- except Exception as e:
- logger.warning(f"Failed to load local notification icon {file_path}: {e}")
-
- return get_fallback_notification_icon(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
-
-
-def get_notification_image_cache_key(notification_id, image_pixbuf):
- """Generate a deterministic cache key based on image content to prevent duplicate caching"""
- try:
- # Use image content hash for deterministic caching
- if image_pixbuf and hasattr(image_pixbuf, "get_pixels"):
- try:
- pixel_data = image_pixbuf.get_pixels()
- image_hash = hashlib.md5(pixel_data).hexdigest()[:8]
- return image_hash
- except Exception:
- # If pixel data fails, use image dimensions + timestamp
- try:
- width = image_pixbuf.get_width()
- height = image_pixbuf.get_height()
- 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:
- # Ultimate fallback
- return str(int(time.time()))[:8]
-
-
-def cache_notification_image(notification_id, image_pixbuf, size=(64, 64)):
- """Smart notification image caching that avoids duplicate caching"""
- try:
- ensure_notification_cache_dirs()
-
- # 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
- except Exception as scale_error:
- logger.debug(f"Failed to cache image (temp file likely gone): {scale_error}")
- return None, None
-
- except Exception as e:
- logger.warning(f"Failed to cache notification image: {e}")
- return None, None
-
-
-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)
- except Exception as e:
- logger.warning(f"Failed to load cached notification image: {e}")
- return None
-
-
-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}")
- except Exception as e:
- logger.warning(f"Failed to cleanup notification image cache: {e}")
-
-
-def cleanup_notification_specific_caches(
- app_icon_source=None, notification_image_cache_key=None
-):
- """Clean up caches specific to a notification (both app icon and notification image) - SINGLE ICON SIZE"""
- try:
- # Clean up notification image cache
- if notification_image_cache_key:
- cleanup_notification_image_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}")
-
- except Exception as e:
- logger.warning(f"Failed to cleanup notification specific caches: {e}")
-
-
-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")
- except Exception as e:
- logger.warning(f"Failed to cleanup all notification caches: {e}")
-
-
-def verify_cache_persistence():
- """Verify that cached icons persist and can be loaded after restart"""
- try:
- icon_cache_files = []
- image_cache_files = []
-
- if os.path.exists(NOTIFICATION_ICON_CACHE_DIR):
- icon_cache_files = [
- f for f in os.listdir(NOTIFICATION_ICON_CACHE_DIR) if f.endswith(".png")
- ]
-
- if os.path.exists(NOTIFICATION_IMAGE_CACHE_DIR):
- image_cache_files = [
- f
- for f in os.listdir(NOTIFICATION_IMAGE_CACHE_DIR)
- 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}")
- 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}")
- except Exception as e:
- logger.warning(f"Failed to load cached image {cache_file}: {e}")
-
- return len(icon_cache_files) + len(image_cache_files) > 0
-
- except Exception as e:
- logger.error(f"Failed to verify cache persistence: {e}")
- return False
-
-
-def migrate_persistent_notifications():
- """Migrate persistent notifications to use cached assets when temp files are gone"""
- try:
- from services.modus import notification_service
-
- migrated_count = 0
- for cached_notification in notification_service.cached_notifications:
- notification = cached_notification._notification
-
- # Check if notification has image_pixbuf but temp file might be gone
- if hasattr(notification, "image_pixbuf") and notification.image_pixbuf:
- try:
- # Try to access pixel data to test if temp file still exists
- notification.image_pixbuf.get_pixels()
- except Exception:
- # Temp file is gone, ensure app icon is cached as fallback
- try:
- 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}: {
- cache_error
- }"
- )
-
- 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}")
-
-
-# Initialize cache and verify persistence on module load
-ensure_notification_cache_dirs()
-cleanup_old_cache_files()
-verify_cache_persistence()
-
-# Run migration for persistent notifications on startup
-try:
- migrate_persistent_notifications()
-except Exception as e:
- logger.debug(f"Migration skipped (service not ready): {e}")
-
-
-def preload_notification_assets(notification):
- """Preload and cache notification assets with robust error handling for persistent notifications - SINGLE ICON SIZE"""
- try:
- # Cache app icon only at content size (35x35) - scale down for headers at runtime
- if hasattr(notification, "app_icon") and notification.app_icon:
- try:
- # Only cache at 35x35 to reduce disk usage - headers will scale this down
- cache_notification_icon(notification.app_icon, (35, 35))
- except Exception as icon_error:
- logger.debug(
- f"Failed to preload app icon for {notification.app_name}: {
- icon_error
- }"
- )
-
- # Cache notification image if available
- if hasattr(notification, "image_pixbuf") and notification.image_pixbuf:
- cache_notification_image(notification.id, notification.image_pixbuf, (35, 35))
-
- except Exception as e:
- logger.warning(f"Failed to preload notification assets: {e}")
-
-
-def smooth_revealer_animation(revealer: SlideRevealer, duration: int = 280):
- """Configure revealer for ultra-smooth animation"""
- revealer.duration = duration
-
-
-class ActionButton(Button):
- def __init__(
- self, action: NotificationAction, index: int, total: int, notification_box
- ):
- super().__init__(
- name="action-button",
- h_expand=True,
- on_clicked=self.on_clicked,
- child=Label(name="button-label", label=action.label),
- )
- self.action = action
- self.notification_box = notification_box
- style_class = (
- "start-action"
- if index == 0
- else "end-action"
- if index == total - 1
- else "middle-action"
- )
- self.add_style_class(style_class)
- self.connect(
- "enter-notify-event", lambda *_: notification_box.hover_button(self)
- )
- self.connect(
- "leave-notify-event", lambda *_: notification_box.unhover_button(self)
- )
-
- def on_clicked(self, *_):
- # Mark for cache cleanup when action button is clicked
- self.notification_box._should_cleanup_cache = True
- self.action.invoke()
- self.action.parent.close("dismissed-by-user")
-
-
-class NotificationWidget(Box):
- def __init__(
- self,
- notification: Notification,
- timeout_ms=data.NOTIFICATION_TIMEOUT,
- show_close_button=True,
- name="notification",
- **kwargs,
- ):
- self.show_close_button = show_close_button
- self.close_button = None
- self._is_hovered = False
- self.notification_image_cache_key = None # Track cached image for cleanup
- self.app_icon_source = (
- notification.app_icon
- ) # Track app icon source for cleanup
- self._should_cleanup_cache = False # Only cleanup cache on manual dismissal
-
- super().__init__(
- size=(NOTIFICATION_WIDTH, -1),
- name=name,
- orientation="v",
- h_align="fill",
- h_expand=True,
- children=[
- self.create_content(notification),
- self.create_action_buttons(notification),
- ],
- )
-
- self.notification = notification
- self.timeout_ms = timeout_ms
- self._timeout_id = None
-
- # Add hover events to the main notification widget
- self.connect("enter-notify-event", self._on_enter_notify)
- self.connect("leave-notify-event", self._on_leave_notify)
-
- self.start_timeout()
-
- def create_header(self, notification):
- """Create notification header with optimized cached app icon - SINGLE CACHE SIZE"""
- try:
- # Get 35x35 cached icon and scale down to 24x24 for header
- cached_app_icon_pixbuf = cache_notification_icon(
- notification.app_icon, (35, 35)
- )
-
- if cached_app_icon_pixbuf:
- # Scale down the 35x35 cached icon to 24x24 for header display
- 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")
- else:
- # Fallback to theme icon if caching fails completely
- app_icon = Image(
- name="notification-icon",
- 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(
- name="notification-icon",
- icon_name="notifications",
- icon_size=24,
- )
-
- return CenterBox(
- name="notification-title",
- start_children=[
- Box(
- spacing=4,
- children=[
- app_icon,
- Label(
- notification.app_name,
- name="notification-app-name",
- h_align="start",
- ),
- ],
- )
- ],
- end_children=[
- self.create_close_button() if self.show_close_button else Box()
- ],
- )
-
- def create_content(self, notification):
- return Box(
- name="notification-content",
- spacing=8,
- children=[
- Box(
- name="notification-image",
- children=CustomImage(
- pixbuf=self._get_notification_pixbuf(notification)
- ),
- ),
- Box(
- name="notification-text",
- orientation="v",
- v_align="center",
- children=[
- Box(
- name="notification-summary-box",
- orientation="h",
- children=[
- Label(
- name="notification-summary",
- markup=escape_markup_text(notification.summary.replace("\n", " ")),
- h_align="start",
- max_chars_width=40,
- ellipsization="end",
- ),
- # Label(
- # name="notification-app-name",
- # markup=" | " + notification.app_name,
- # h_align="start",
- # ellipsization="end",
- # ),
- ],
- ),
- (
- Label(
- markup=escape_markup_text(notification.body.replace("\n", " ")),
- h_align="start",
- max_chars_width=45,
- ellipsization="end",
- )
- if notification.body
- else Label(
- markup="",
- h_align="start",
- ellipsization="end",
- )
- ),
- ],
- ),
- Box(h_expand=True),
- Box(
- orientation="v",
- children=[
- Button(
- name="notification-close-button",
- image=CustomImage(icon_name="close-symbolic", icon_size=18),
- visible=True, # Initially hidden
- on_clicked=lambda *_: self._manual_close(),
- ),
- Box(v_expand=True),
- ],
- ),
- ],
- )
-
- def _on_enter_notify(self, widget, event):
- self._is_hovered = True
- if self.close_button:
- self.close_button.set_visible(True)
- self.pause_timeout()
- return False
-
- def _on_leave_notify(self, widget, event):
- self._is_hovered = False
- if self.close_button:
- self.close_button.set_visible(False)
- self.resume_timeout()
- return False
-
- def get_pixbuf(self, icon_path, width, height):
- """Get pixbuf with caching support"""
- try:
- # Use the icon caching system
- cached_pixbuf = cache_notification_icon(icon_path, (width, height))
- if cached_pixbuf:
- return cached_pixbuf
- except Exception as e:
- logger.warning(f"Failed to get cached pixbuf for {icon_path}: {e}")
-
- # Fallback to original method if caching fails
- if icon_path.startswith("file://"):
- 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:
- pixbuf = GdkPixbuf.Pixbuf.new_from_file(icon_path)
- if pixbuf:
- return pixbuf.scale_simple(width, height, GdkPixbuf.InterpType.BILINEAR)
- else:
- return get_fallback_notification_icon((width, height))
- except Exception as e:
- logger.error(f"Failed to load or scale icon: {e}")
- return get_fallback_notification_icon((width, height))
-
- def _get_notification_pixbuf(self, notification):
- """Simplified notification pixbuf with NO CACHING for image-pixmap to prevent disk bloat"""
- notification_id = getattr(notification, "id", int(time.time()))
-
- try:
- # 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)
- cached_image = get_cached_notification_image(cache_key)
- if cached_image:
- return cached_image
- except Exception as image_error:
- logger.debug(f"Notification image processing failed: {image_error}")
- # Continue to app icon fallback
-
- except Exception as e:
- logger.debug(f"Failed to process notification image: {e}")
-
- # Use cached app icon as fallback
- try:
- app_icon_source = getattr(notification, "app_icon", None)
- if app_icon_source:
- cached_app_icon = cache_notification_icon(app_icon_source, (35, 35))
- if cached_app_icon:
- return cached_app_icon
- except Exception as e:
- logger.debug(f"Failed to get cached app icon: {e}")
-
- # Ultimate fallback
- return get_fallback_notification_icon((35, 35))
-
- def create_action_buttons(self, notification):
- return Box(
- name="notification-action-buttons",
- spacing=4,
- h_expand=True,
- children=[
- ActionButton(action, i, len(notification.actions), self)
- for i, action in enumerate(notification.actions)
- ],
- )
-
- def start_timeout(self):
- self.stop_timeout()
- self._timeout_id = GLib.timeout_add(self.timeout_ms, self.close_notification)
-
- def stop_timeout(self):
- if self._timeout_id is not None:
- GLib.source_remove(self._timeout_id)
- self._timeout_id = None
-
- def close_notification(self):
- self.notification.close("expired")
- self.stop_timeout()
- return False
-
- def pause_timeout(self):
- self.stop_timeout()
-
- def resume_timeout(self):
- if not self._is_hovered: # Only resume if not hovered
- self.start_timeout()
-
- def _manual_close(self):
- """Handle manual close button click - just close notification"""
- self.notification.close("dismissed-by-user")
-
- def destroy(self):
- self.stop_timeout()
- # Only clean up caches if this was a manual dismissal
- if self._should_cleanup_cache:
- cleanup_notification_specific_caches(
- app_icon_source=getattr(self, "app_icon_source", None),
- notification_image_cache_key=getattr(
- 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")
- super().destroy()
-
- # @staticmethod
- def set_pointer_cursor(self, widget, cursor_name):
- window = widget.get_window()
- if window:
- cursor = Gdk.Cursor.new_from_name(widget.get_display(), cursor_name)
- window.set_cursor(cursor)
-
- def hover_button(self, button):
- self.pause_timeout()
- self.set_pointer_cursor(button, "hand2")
-
- def unhover_button(self, button):
- # Don't resume timeout here since the notification itself might still be hovered
- self.set_pointer_cursor(button, "arrow")
-
-
-class NotificationRevealer(SlideRevealer):
- def __init__(
- self,
- notification: Notification,
- on_transition_end=None,
- parent_window=None,
- **kwargs,
- ):
- self.notif_box = NotificationWidget(notification, show_close_button=False)
- self.notification = notification
- self.on_transition_end = on_transition_end
- # Reference to NotificationCenter window for queue clearing
- self.parent_window = parent_window
- self._is_closing = False
-
- # Enhanced swipe detection variables for Android-style animation
- self._drag_start_y = 0
- self._drag_start_x = 0
- self._is_dragging = False
- self._swipe_threshold = 80 # Distance to trigger auto-dismiss
- self._swipe_velocity_threshold = (
- 150 # Velocity to trigger dismiss even on shorter swipes
- )
- self._swipe_in_progress = False
- self._current_offset = 0
- self._last_drag_time = 0
- self._drag_velocity = 0
- self._spring_back_duration = 200 # Duration for spring-back animation
- self._dismiss_threshold = 0.3 # Dismiss if swiped 30% of width
-
- # Animation state
- self._animation_in_progress = False
- self._spring_timer_id = None
- self._css_provider = None
-
- # Wrap notification in EventBox for swipe detection
- self.event_box = EventBox(
- events=[
- "button-press-event",
- "button-release-event",
- "motion-notify-event",
- ],
- child=self.notif_box,
- )
-
- super().__init__(
- child=self.event_box,
- direction="right",
- duration=280, # Faster, smoother duration
- )
-
- smooth_revealer_animation(self)
-
- # Connect our own handler that manages the slide animation
- self.notification.connect("closed", self.on_resolved)
-
- self._animation_in_progress = True
-
- def _ease_out_cubic(self, t):
- """Smoother easing function for better animation quality"""
- return 1 - pow(1 - t, 3)
-
- def _ease_out_quart(self, t):
- """Even smoother easing for ultra-smooth animations"""
- return 1 - pow(1 - t, 4)
-
- def _apply_transform(self, offset_x, opacity, scale):
- """Apply smooth CSS transforms for animation"""
- try:
- # Create CSS transformation
- transform_css = f"""
- opacity: {opacity};
- transform: translateX({offset_x}px) scale({scale});
- transition: none;
- """
-
- # Apply to the notification box
- if hasattr(self.notif_box, "get_style_context"):
- style_context = self.notif_box.get_style_context()
- 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
- )
-
- css_data = f"* {{ {transform_css} }}"
- self._css_provider.load_from_data(css_data.encode())
-
- except Exception as e:
- logger.debug(f"Transform apply failed (non-critical): {e}")
-
- def _animate_dismiss(self, start_offset):
- """Animate the notification sliding out with smooth 60fps animation"""
- target_offset = NOTIFICATION_WIDTH + 50
- duration = 200 # Slightly longer for smoother feel
-
- if self._spring_timer_id:
- GLib.source_remove(self._spring_timer_id)
-
- start_time = GLib.get_monotonic_time() / 1000
- offset_diff = target_offset - start_offset
-
- def animate_step():
- current_time = GLib.get_monotonic_time() / 1000
- elapsed = current_time - start_time
- progress = min(1.0, elapsed / duration)
-
- # Use smoother easing for premium feel
- eased_progress = self._ease_out_quart(progress)
- current_offset = start_offset + (offset_diff * eased_progress)
-
- # Smoother fade and scale transitions
- opacity = max(0.0, 1.0 - (progress * 0.9)) # Gentler fade
- scale = max(0.9, 1.0 - (progress * 0.1)) # Subtle scale
-
- self._apply_transform(current_offset, opacity, scale)
-
- if progress >= 1.0:
- # Mark notification for cache cleanup on swipe dismissal
- self.notif_box._should_cleanup_cache = True
- try:
- self.notification.close("dismissed-by-user")
- except:
- pass
- return False
-
- return True
-
- # Use consistent 60fps timing
- self._animation_in_progress = True
- self._spring_timer_id = GLib.timeout_add(16, animate_step) # ~60 FPS
-
- def _calculate_drag_velocity(self, current_x):
- """Calculate the velocity of the drag gesture"""
- current_time = GLib.get_monotonic_time() / 1000
-
- if self._last_drag_time > 0:
- time_diff = current_time - self._last_drag_time
- if time_diff > 0:
- distance_diff = current_x - self._drag_start_x - self._current_offset
- self._drag_velocity = abs(distance_diff / time_diff)
-
- self._last_drag_time = current_time
-
- def _on_animation_complete(self, is_hiding=False):
- if is_hiding:
- # Manually destroy the notification widget since we disconnected its handler
- self.notif_box.destroy()
-
- if self.on_transition_end:
- self.on_transition_end()
- self.destroy()
-
- def on_resolved(
- self,
- _notification: Notification,
- reason: NotificationCloseReason,
- ):
- if self._is_closing:
- return
-
- self._is_closing = True
-
- # Clean up any ongoing animations
- if self._spring_timer_id:
- GLib.source_remove(self._spring_timer_id)
-
- # Use different slide directions based on dismiss reason
- if reason == "expired":
- # Gentle fade-out for auto-dismiss
- self.set_slide_direction("left")
- self.duration = 250 # Slightly slower for natural feel
- elif self._swipe_in_progress:
- # Quick slide for swipe dismissals
- self.duration = 150
- self.set_slide_direction("right")
- else:
- # Smooth slide for manual close
- self.set_slide_direction("right")
- self.duration = 200
-
- self.hide()
- # Consistent timing for smooth transitions
- timeout_duration = self.duration + 50
- 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)
- super().destroy()
-
-
-class NotificationState:
- IDLE = 0
- SHOWING = 1
- HIDING = 2
- TRANSITIONING = 3 # New state for smooth transitions
-
-
-class ModusNoti(Window):
- def __init__(self):
- self._server = notification_service
-
- self.notifications = Box(
- v_expand=True,
- h_expand=True,
- style="margin: 1px 0px 1px 1px;",
- orientation="v",
- spacing=5,
- )
-
- # Enhanced queue system for ultra-smooth transitions
- self.notification_queue = []
- self.current_notification = None
- self.notification_state = NotificationState.IDLE
- self._transition_timer_id = None
- self._debounce_timer_id = None
- self._last_notification_time = 0
-
- # Queue management settings for smooth behavior
- self.MAX_QUEUE_SIZE = 3 # Limit queue to prevent overwhelming
- self.TRANSITION_DELAY = 100 # Smoother transition timing
- self.DEBOUNCE_DELAY = 50 # Prevent rapid fire notifications
-
- self._server.connect("notification-added", self.on_new_notification)
- super().__init__(
- anchor="top right",
- child=self.notifications,
- layer="overlay",
- title="modus-notifications", # More specific title for debugging
- all_visible=True,
- visible=False, # Start hidden, show only when we have content
- exclusive=False,
- )
-
- 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
-
- if self._server.dont_disturb or modus_service.dont_disturb:
- # Notification is already cached by the service, just don't show popup
- 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
-
- # If queue is getting full, remove oldest notifications smoothly
- if len(self.notification_queue) >= self.MAX_QUEUE_SIZE:
- # Remove oldest notification from queue (not current showing one)
- if self.notification_queue:
- oldest = self.notification_queue.pop(0)
- try:
- oldest.close("dismissed-by-user")
- except:
- pass
-
- # Add new notification to queue
- self.notification_queue.append(notification)
-
- # Debounce rapid notifications for smoother experience
- if self._debounce_timer_id:
- GLib.source_remove(self._debounce_timer_id)
-
- self._debounce_timer_id = GLib.timeout_add(
- self.DEBOUNCE_DELAY,
- lambda: self._process_notification_queue_debounced() or False,
- )
-
- def _process_notification_queue_debounced(self):
- """Process queue after debounce delay for smooth transitions"""
- self._debounce_timer_id = None
- self._process_notification_queue()
- return False
-
- def _process_notification_queue(self):
- # If we're currently showing a notification and there's a new one in queue
- if (
- self.notification_state == NotificationState.SHOWING
- and self.current_notification
- and self.notification_queue
- ):
- # Smooth transition: start hiding current notification
- self.notification_state = NotificationState.TRANSITIONING
- self._start_smooth_transition()
-
- elif (
- self.notification_state == NotificationState.IDLE
- and self.notification_queue
- ):
- # If we're idle and have notifications in queue, show the next one
- self._show_next_notification()
-
- def _start_smooth_transition(self):
- """Start smooth transition between notifications"""
- if self.current_notification and not self.current_notification._is_closing:
- # Don't mark for cache cleanup during smooth transitions
- # to maintain performance
-
- # Use shorter timeout for smooth transitions
- self.current_notification.notif_box.timeout_ms = 100
-
- # Force close current notification with smooth animation
- try:
- self.current_notification.notification.close("expired")
- except:
- pass
-
- def _show_next_notification(self):
- if (
- not self.notification_queue
- or self.notification_state != NotificationState.IDLE
- ):
- 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'):
- # Skip invalid notifications and try next one
- if self.notification_queue:
- self._show_next_notification()
- return
-
- self.notification_state = NotificationState.SHOWING
-
- new_box = NotificationRevealer(
- notification,
- on_transition_end=lambda: self._on_notification_finished(new_box),
- parent_window=self,
- )
-
- self.current_notification = new_box
-
- # Clear any existing children
- for child in list(self.notifications.children):
- try:
- self.notifications.remove(child)
- except:
- pass
-
- self.notifications.children = [new_box]
-
- # Show the window now that we have content to display
- self.set_visible(True)
-
- new_box.show()
- self.notifications.queue_resize()
-
- def start_animation():
- if new_box.get_parent() and new_box.get_realized():
- new_box.reveal()
- return False
- return True
-
- GLib.idle_add(start_animation)
-
- def _on_notification_finished(self, notification_box):
- if notification_box != self.current_notification:
- return
-
- # Cancel any pending transition timer
- if self._transition_timer_id:
- GLib.source_remove(self._transition_timer_id)
- self._transition_timer_id = None
-
- # Safely remove notification box
- try:
- if notification_box in self.notifications.children:
- self.notifications.remove(notification_box)
- except:
- pass
-
- # Reset state
- self.current_notification = None
- self.notification_state = NotificationState.IDLE
-
- # Process next notification with optimized delay for ultra-smooth transitions
- if self.notification_queue:
- self._transition_timer_id = GLib.timeout_add(
- self.TRANSITION_DELAY, # Consistent smooth timing
- lambda: self._show_next_notification() or False,
- )
- else:
- # Hide window when no more notifications to show
- self.set_visible(False)
-
- def show_next_notification(self):
- # Legacy method for compatibility - redirect to new implementation
- self._show_next_notification()
-
- def on_notification_finished(self, notification_box):
- # Legacy method for compatibility - redirect to new implementation
- self._on_notification_finished(notification_box)
-
- def clear_notification_queue(self):
- """Clear queue with smooth cleanup"""
- queue_length = len(self.notification_queue)
- if queue_length > 0:
- # Smooth dismissal of queued notifications
- for notification in list(self.notification_queue):
- try:
- notification.close("dismissed-by-user")
- except:
- pass
- self.notification_queue.clear()
-
- # Also clean current notification if showing
- if self.current_notification:
- # Mark for cache cleanup when clearing queue
- self.current_notification.notif_box._should_cleanup_cache = True
-
- # Clear animation timers
- if self._transition_timer_id:
- GLib.source_remove(self._transition_timer_id)
- self._transition_timer_id = None
-
- if self._debounce_timer_id:
- GLib.source_remove(self._debounce_timer_id)
- self._debounce_timer_id = None
-
- def get_queue_length(self):
- return len(self.notification_queue)
diff --git a/modules/notification/notification_center.py b/modules/notification/notification_center.py
deleted file mode 100644
index 3aa77b0..0000000
--- a/modules/notification/notification_center.py
+++ /dev/null
@@ -1,923 +0,0 @@
-from collections import defaultdict
-import time
-
-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.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 modules.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,
-)
-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):
- def __init__(self, app_name, notifications, **kwargs):
- super().__init__(
- name="notification-group", orientation="v", spacing=0, **kwargs
- )
-
- self.app_name = app_name
- self.notifications = notifications
- self.is_expanded = False # Always start collapsed
-
- # Create collapsed state first (shows only latest notification)
- self.create_collapsed_state()
-
- # Create expanded state (hidden initially)
- self.create_expanded_state()
-
- # Ensure we start in collapsed state
- self.collapsed_eventbox.set_visible(True)
- self.expanded_container.set_visible(False)
-
- def create_collapsed_state(self):
- latest_notification = self.notifications[0] # Most recent notification
-
- # Create clickable event box
- self.collapsed_eventbox = EventBox(
- events=["button-press-event"],
- )
- self.collapsed_eventbox.connect("button-press-event", self.on_clicked)
-
- # Only create stacked effect if we have multiple notifications
- num_notifications = len(self.notifications)
-
- if num_notifications == 1:
- # Single notification - no stacking needed
- notification_widget = NotificationCenterWidget(
- notification=latest_notification
- )
- self.collapsed_eventbox.add(notification_widget)
- else:
- # Multiple notifications - create stacked effect
- # Create container for the entire stack
- stack_container = Box(
- name="notification-stack-container",
- orientation="v",
- spacing=0,
- )
-
- # Add bottom shadow layer first (deepest)
- if num_notifications >= 3:
- bottom_shadow = Box(
- name="stack-shadow-bottom",
- )
- stack_container.add(bottom_shadow)
-
- # Add middle shadow layer
- if num_notifications >= 2:
- middle_shadow = Box(
- name="stack-shadow-middle",
- )
- stack_container.add(middle_shadow)
-
- # Add the main notification content on top
- main_notification = Box(
- name="stack-main-notification",
- spacing=8,
- children=[
- Box(
- name="notification-image",
- children=CustomImage(
- pixbuf=self._get_notification_pixbuf_for_group(
- latest_notification
- )
- ),
- ),
- Box(
- name="notification-text",
- orientation="v",
- v_align="center",
- h_expand=True,
- children=[
- Box(
- name="notification-summary-box",
- orientation="h",
- children=[
- Label(
- name="notification-summary",
- markup=f"{self.app_name} ",
- h_align="start",
- ellipsization="end",
- ),
- ],
- ),
- Label(
- name="notification-body",
- markup=escape_markup_text(latest_notification._notification.summary.replace(
- "\n", " "
- )),
- max_chars_width=25,
- h_align="start",
- ellipsization="end",
- ),
- ],
- ),
- Box(
- name="notification-count",
- orientation="v",
- children=[
- Button(
- name="notification-close",
- image=CustomImage(
- icon_name="close-symbolic", icon_size=18
- ),
- visible=True,
- on_clicked=lambda *_: self._close_single_notification_and_stop_propagation(
- latest_notification
- ),
- ),
- Label(
- name="notification-count-label",
- label=f"{len(self.notifications)}",
- h_align="end",
- ),
- ],
- ),
- ],
- )
- stack_container.add(main_notification)
- self.collapsed_eventbox.add(stack_container)
-
- self.add(self.collapsed_eventbox)
-
- # Create expanded state (hidden initially)
- self.create_expanded_state()
-
- 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)
-
- # First try to get cached notification image using stored cache key
- if (
- hasattr(cached_notification, "cache_metadata")
- and cached_notification.cache_metadata
- ):
- notification_image_cache_key = cached_notification.cache_metadata.get(
- "notification_image_cache_key"
- )
- if notification_image_cache_key:
- try:
- cached_image = get_cached_notification_image(
- 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}")
-
- # Fallback to app icon using cached key
- if (
- hasattr(cached_notification, "cache_metadata")
- and cached_notification.cache_metadata
- ):
- app_icon_cache_key = cached_notification.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}")
-
- # Final fallback - try to cache app icon directly if available
- try:
- app_icon_source = getattr(notification, "app_icon", None)
- 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):
- # Create main expanded container
- self.expanded_container = Box(
- name="notification-group-expanded-container",
- orientation="v",
- spacing=0,
- )
-
- # Header with app name and controls
- self.header_content = Box(
- orientation="h",
- h_expand=True,
- children=[
- Label(
- name="notification-group-title",
- markup=f"{self.app_name} ",
- h_align="start",
- h_expand=True,
- ),
- Button(
- name="notification-show-less",
- label="Show less",
- on_clicked=self.collapse,
- h_align="end",
- ),
- Button(
- name="notification-close-summery",
- h_expand=False,
- v_expand=False,
- on_clicked=self.close_all,
- image=CustomImage(
- icon_name="close-symbolic",
- name="notification-close-header",
- icon_size=18,
- h_align="end",
- ),
- visible=True,
- ),
- ],
- )
-
- # Wrap header in revealer for slide-up animation during collapse
- self.header_revealer = Revealer(
- child=self.header_content,
- transition_type="slide-up",
- transition_duration=300,
- child_revealed=False,
- )
-
- # Box for individual notifications
- self.notifications_list = Box(
- name="notification-group-notifications",
- orientation="v",
- spacing=5,
- )
-
- # Add individual notifications to the list
- for notification in self.notifications:
- notification_widget = NotificationCenterWidget(notification=notification)
- self.notifications_list.add(notification_widget)
-
- # Wrap notifications list in revealer for slide-down animation
- self.notifications_revealer = Revealer(
- child=self.notifications_list,
- transition_type="slide-down",
- transition_duration=300,
- child_revealed=False,
- )
-
- # Wrap notifications revealer in crossfade revealer for closing animation
- self.notifications_crossfade = Revealer(
- child=self.notifications_revealer,
- transition_type="crossfade",
- transition_duration=250,
- child_revealed=True, # Start revealed so crossfade works on close
- )
-
- # Add header revealer and notifications crossfade to container
- self.expanded_container.add(self.header_revealer)
- self.expanded_container.add(self.notifications_crossfade)
-
- # Add the container to the main group
- self.add(self.expanded_container)
-
- # Hide the entire expanded container initially
- self.expanded_container.set_visible(False)
-
- def on_clicked(self, widget, event):
- if event.button == 1: # Left click
- # Always allow expansion, even for single notifications
- # This ensures single notifications can show their expanded entry view
- self.expand()
- return True
-
- def expand(self, *args):
- """Expand to show all notifications in this group with slide-down animation"""
- self.is_expanded = True
- self.collapsed_eventbox.set_visible(False)
- self.expanded_container.set_visible(True)
-
- # Show header immediately (no animation on expand)
- self.header_revealer.set_reveal_child(True)
-
- # Ensure crossfade is revealed for expand
- self.notifications_crossfade.set_reveal_child(True)
-
- # 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"""
- self.is_expanded = False
-
- # Start header slide-up and notifications crossfade simultaneously
- self.header_revealer.set_reveal_child(False)
- self.notifications_crossfade.set_reveal_child(False)
-
- # Show collapsed state and hide expanded container halfway through crossfade
- GLib.timeout_add(125, self._show_collapsed_midway)
-
- # After crossfade completes, start slide-up animation (just for cleanup)
- GLib.timeout_add(
- 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)
- self.expanded_container.set_visible(False)
- return False # Don't repeat timeout
-
- def _complete_collapse(self):
- """Complete the collapse animation - no longer needed but kept for compatibility"""
- return False # Don't repeat timeout
-
- def close_all(self, *args):
- """Close all notifications in this group with proper cache cleanup"""
- # Close all notifications in this group
- for notification in self.notifications:
- try:
- # Get notification cache metadata for cleanup
- 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(
- "notification_image_cache_key"
- ),
- )
-
- 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(
- f"Error removing notification {notification.cache_id}: {e}"
- )
-
- def _close_single_notification(self, notification):
- """Close a single notification from this group with proper cache cleanup"""
- try:
- # Get notification cache metadata for cleanup
- 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(
- "notification_image_cache_key"
- ),
- )
-
- 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}"
- )
-
- def _close_single_notification_and_stop_propagation(self, notification):
- """Close notification and prevent click from expanding the group"""
- self._close_single_notification(notification)
-
- # If this was the last notification in the group, the group will be removed
- # by the notification_removed signal handler. If there are still notifications,
- # we need to check if this group should be removed from view.
- remaining_notifications = [
- n for n in self.notifications if n.cache_id != notification.cache_id
- ]
-
- if not remaining_notifications:
- # This was the last notification, the group will be destroyed by signal handler
- pass
- else:
- # Update the notifications list and refresh the view immediately
- self.notifications = remaining_notifications
- # Force immediate UI update by destroying and recreating collapsed state
- if hasattr(self, "collapsed_eventbox"):
- self.collapsed_eventbox.destroy()
- self.create_collapsed_state()
- self.show_all()
-
- return True # Stop event propagation
-
-
-class NotificationCenterWidget(NotificationWidget):
- def __init__(self, notification, **kwargs):
- self.notification_id = notification.cache_id
- self.cache_metadata = getattr(notification, "cache_metadata", {})
-
- super().__init__(
- notification._notification,
- timeout_ms=0,
- show_close_button=True,
- name="notification-centre-notifs",
- **kwargs,
- )
-
- def _get_notification_pixbuf(self, notification):
- """Get notification pixbuf using cached image key - fallback to app icon"""
- notification_id = getattr(notification, "id", None)
-
- # First try to get cached notification image using stored cache key
- if self.cache_metadata:
- notification_image_cache_key = self.cache_metadata.get(
- "notification_image_cache_key"
- )
- if notification_image_cache_key:
- try:
- cached_image = get_cached_notification_image(
- 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}")
-
- # Fallback to app icon using cached key
- if self.cache_metadata:
- 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}")
-
- # Final fallback - try to cache app icon directly if available
- try:
- app_icon_source = getattr(notification, "app_icon", None)
- 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):
- # Create our custom close button for notification center
-
- self.close_button = Button(
- name="notif-close-button",
- image=CustomImage(
- icon_name="close-symbolic", name="notification-close", icon_size=18
- ),
- visible=True, # Always visible in notification center
- on_clicked=self._on_close_clicked,
- )
- self.close_button.connect(
- "enter-notify-event", lambda *_: self.hover_button(self.close_button)
- )
- self.close_button.connect(
- "leave-notify-event", lambda *_: self.unhover_button(self.close_button)
- )
-
- # Create the content box manually with our custom close button
- return Box(
- name="notification-content",
- spacing=8,
- children=[
- Box(
- name="notification-image",
- children=CustomImage(
- pixbuf=self._get_notification_pixbuf(notification)
- ),
- ),
- Box(
- name="notification-text",
- orientation="v",
- v_align="center",
- children=[
- Box(
- name="notification-summary-box",
- orientation="h",
- children=[
- Label(
- name="notification-summary",
- markup=escape_markup_text(notification.summary.replace("\n", " ")),
- h_align="start",
- max_chars_width=25,
- ellipsization="end",
- ),
- ],
- ),
- (
- Label(
- markup=escape_markup_text(notification.body.replace("\n", " ")),
- h_align="start",
- max_chars_width=35,
- ellipsization="end",
- )
- if notification.body
- else Label(
- markup="",
- h_align="start",
- ellipsization="end",
- )
- ),
- ],
- ),
- Box(h_expand=True),
- Box(
- orientation="v",
- children=[
- self.close_button, # Use our custom close button
- Box(v_expand=True),
- ],
- ),
- ],
- )
-
- # Override to disable the action buttons
- def create_action_buttons(self, notification):
- return Box(name="notification-action-buttons")
-
- def _on_close_clicked(self, *args):
- try:
- # Use instance cache metadata instead of notification attribute
- 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(
- "notification_image_cache_key"
- ),
- )
-
- 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}")
-
- # Override to disable timeout functionality
- def start_timeout(self):
- pass
-
- # Override to disable timeout functionality
- def stop_timeout(self):
- pass
-
- # Override to disable auto-close functionality
- def close_notification(self):
- return False
-
-
-class NotificationCenter(Window):
- def __init__(self):
- super().__init__(
- layer="overlay",
- anchor="top right",
- visible=False,
- keyboard_mode="on-demand",
- title="modus",
- )
-
- NOTIFICATION_CENTER_WIDTH = 410
- self.set_size_request(NOTIFICATION_CENTER_WIDTH, 600)
-
- # Group notifications by app name
- self.notification_groups = defaultdict(list)
- self.group_widgets = {}
-
- notification_service.connect(
- "cached-notification-added", self.on_notification_added
- )
- notification_service.connect(
- "cached-notification-removed", self.on_notification_removed
- )
- notification_service.connect("clear-all", self.on_clear_all)
- notification_service.connect("notify::count", self.on_count_changed)
-
- main_box = Box(
- orientation="v",
- spacing=5,
- name="noti-center-box",
- )
-
- self.scrolled = ScrolledWindow(h_expand=False, v_expand=False)
- self.notifications_box = Box(
- v_expand=False,
- h_expand=False,
- style="margin: 1px 0px 1px 1px;",
- orientation="v",
- spacing=5,
- )
- self.scrolled.add(self.notifications_box)
- main_box.add(self.scrolled)
-
- # No notifications label - REMOVED
-
- self.clear_all_button = Button(
- name="noti-clear-button",
- label="Clear",
- on_clicked=self.clear_all_notifications,
- visible=(notification_service.count > 0),
- )
- self.button_centre_box = CenterBox(
- center_children=[self.clear_all_button],
- )
- main_box.add(self.button_centre_box)
-
- # Wrap main content in revealer for slide-left animation
- self.main_revealer = Revealer(
- child=main_box,
- transition_type="slide-left",
- transition_duration=400,
- child_revealed=False,
- )
-
- self.children = self.main_revealer
-
- # Load existing notifications and group them
- 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"""
- # Clear existing groups
- self.notification_groups.clear()
- self.group_widgets.clear()
-
- # Clear notifications box
- for child in self.notifications_box.get_children():
- child.destroy()
-
- # Group notifications by app name and preload assets with debugging
- 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)
-
- # Skip ignored apps during rebuild
- if app_name in data.NOTIFICATION_IGNORED_APPS_HISTORY:
- continue
-
- # Preload assets for each cached notification to ensure display consistency
- preload_notification_assets(cached_notification._notification)
-
- 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)
- # This ensures the latest notifications appear at the top of each group
- notifications.sort(
- key=lambda n: getattr(n._notification, "id", 0), reverse=True
- )
-
- # Handle limited apps history - only keep 5 notifications during rebuild
- if app_name in data.NOTIFICATION_LIMITED_APPS_HISTORY:
- if len(notifications) > 5:
- # Keep only the 5 most recent notifications
- self.notification_groups[app_name] = notifications[:5]
-
- group_widget = ExpandableNotificationGroup(
- app_name, self.notification_groups[app_name]
- )
- self.group_widgets[app_name] = group_widget
- self.notifications_box.add(group_widget)
-
- def on_notification_added(self, service, cached_notification):
- try:
- app_name = cached_notification._notification.app_name
-
- # Check if this app should be ignored for history (don't add to notification center)
- if app_name in data.NOTIFICATION_IGNORED_APPS_HISTORY:
- return
-
- # Preload assets for notification center display (ensure caching consistency)
- preload_notification_assets(cached_notification._notification)
-
- # Add to groups in sorted order (highest ID first - maintains newest-first ordering)
- notifications_list = self.notification_groups[app_name]
- new_notification_id = getattr(cached_notification._notification, "id", 0)
-
- # Find the correct position to insert based on ID (highest first)
- insert_position = 0
- for i, existing_notification in enumerate(notifications_list):
- existing_id = getattr(existing_notification._notification, "id", 0)
- if new_notification_id > existing_id:
- insert_position = i
- break
- insert_position = i + 1
-
- notifications_list.insert(insert_position, cached_notification)
-
- # Handle limited apps history - only keep 5 notifications
- if app_name in data.NOTIFICATION_LIMITED_APPS_HISTORY:
- notifications_for_app = self.notification_groups[app_name]
- if len(notifications_for_app) > 5:
- # Remove oldest notifications beyond the limit
- excess_notifications = notifications_for_app[5:]
- self.notification_groups[app_name] = notifications_for_app[:5]
-
- # Remove excess notifications from the service cache
- for excess_notification in excess_notifications:
- try:
- notification_service.remove_cached_notification(
- excess_notification.cache_id
- )
- except Exception as e:
- logger.error(f"Error removing excess notification: {e}")
-
- # Update or create group widget
- if app_name in self.group_widgets:
- # Update existing group
- group_widget = self.group_widgets[app_name]
- group_widget.notifications = self.notification_groups[app_name]
- # Refresh the group widget
- self._refresh_group_widget(group_widget)
- else:
- # Create new group widget
- group_widget = ExpandableNotificationGroup(
- app_name, self.notification_groups[app_name]
- )
- self.group_widgets[app_name] = group_widget
- 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}")
-
- def _refresh_group_widget(self, group_widget):
- """Refresh a group widget's content"""
- try:
- # Remove existing children
- for child in group_widget.get_children():
- group_widget.remove(child)
-
- # Recreate content
- group_widget.create_collapsed_state()
- group_widget.show_all()
-
- except Exception as e:
- logger.error(f"Error refreshing group widget: {e}")
-
- def on_notification_removed(self, service, cached_notification):
- try:
- app_name = cached_notification._notification.app_name
-
- # Remove from groups
- if app_name in self.notification_groups:
- self.notification_groups[app_name] = [
- n
- for n in self.notification_groups[app_name]
- if n.cache_id != cached_notification.cache_id
- ]
-
- # If no more notifications for this app, remove group widget
- if not self.notification_groups[app_name]:
- if app_name in self.group_widgets:
- group_widget = self.group_widgets[app_name]
- group_widget.destroy()
- del self.group_widgets[app_name]
- del self.notification_groups[app_name]
- else:
- # Update existing group widget
- group_widget = self.group_widgets[app_name]
- group_widget.notifications = self.notification_groups[app_name]
- self._refresh_group_widget(group_widget)
-
- # Clean up caches
- cleanup_notification_specific_caches(
- app_icon_source=getattr(cached_notification, "app_icon_source", None),
- notification_image_cache_key=getattr(
- cached_notification, "notification_image_cache_key", None
- ),
- )
-
- logger.debug(f"Removed notification from group {app_name}")
- except Exception as e:
- logger.error(f"Error removing notification from group: {e}")
-
- def on_clear_all(self, service):
- try:
- # Clear all groups
- self.notification_groups.clear()
- self.group_widgets.clear()
-
- # Clear all remaining cached notification images AND icons
- 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}")
-
- def on_count_changed(self, service, count=None):
- current_count = notification_service.count
- # No notifications label removed - only update clear button and scrolled visibility
- self.clear_all_button.set_visible(current_count > 0)
- self.scrolled.set_visible(current_count > 0)
-
- # Auto-close notification center when no notifications remain
- if current_count == 0 and hasattr(self, "mousecapture"):
- self.mousecapture.hide_child_window()
-
- def clear_all_notifications(self, *_):
- # Clear all groups
- self.notification_groups.clear()
- self.group_widgets.clear()
-
- # Clear all remaining cached notification images AND icons when clear all is clicked
- cleanup_all_notification_caches() # Clear ALL caches (icons + images)
- notification_service.clear_all_cached_notifications()
- if hasattr(self, "mousecapture"):
- self.mousecapture.hide_child_window()
-
- def _on_escape_pressed(self, *_):
- if hasattr(self, "mousecapture"):
- self.mousecapture.hide_child_window()
-
- def _init_mousecapture(self, mousecapture):
- self.mousecapture = mousecapture
-
- def _set_mousecapture(self, visible):
- """Control notification center visibility with slide-left animation"""
- if 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/modules/notification/unified_cache.py
deleted file mode 100644
index 0bd673b..0000000
--- a/modules/notification/unified_cache.py
+++ /dev/null
@@ -1,197 +0,0 @@
-import os
-import hashlib
-import time
-import uuid
-
-from fabric.utils import get_relative_path
-from gi.repository import GdkPixbuf
-from loguru import logger
-
-import config.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")
-
-
-def ensure_cache_dir():
- """Ensure unified notification cache directory exists"""
- os.makedirs(UNIFIED_NOTIFICATION_CACHE_DIR, exist_ok=True)
-
-
-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 random UUID if pixel data fails
- return str(uuid.uuid4())[: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 random UUID
- return str(uuid.uuid4())[:8]
- except Exception:
- # Ultimate fallback
- return str(uuid.uuid4())[:8]
-
-
-def save_to_cache(pixbuf, cache_key, size=None):
- """Save a pixbuf to the unified cache directory"""
- try:
- ensure_cache_dir()
- cache_path = os.path.join(UNIFIED_NOTIFICATION_CACHE_DIR, f"{cache_key}.png")
-
- # Don't overwrite existing cache
- if os.path.exists(cache_path):
- logger.debug(f"Cache hit - already exists: {cache_key}")
- return cache_path, cache_key
-
- # Scale if size is specified
- if size and (pixbuf.get_width() != size[0] or pixbuf.get_height() != size[1]):
- pixbuf = pixbuf.scale_simple(
- size[0], size[1], GdkPixbuf.InterpType.BILINEAR
- )
-
- 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}")
- return None, None
-
-
-def get_from_cache(cache_key, size=None):
- """Get a cached asset or return None if not found"""
- try:
- cache_path = os.path.join(UNIFIED_NOTIFICATION_CACHE_DIR, f"{cache_key}.png")
- if os.path.exists(cache_path):
- # logger.debug(f"Using cached asset: {cache_key}")
- if size:
- return GdkPixbuf.Pixbuf.new_from_file_at_scale(
- cache_path, size[0], size[1], True
- )
- else:
- return GdkPixbuf.Pixbuf.new_from_file(cache_path)
- except Exception as e:
- logger.warning(f"Failed to load cached asset: {e}")
- return None
-
-
-def cleanup_cache(cache_key=None):
- """Clean up unified cache - specific key or all"""
- try:
- ensure_cache_dir()
-
- if cache_key:
- # Remove specific cached asset
- cache_path = os.path.join(
- UNIFIED_NOTIFICATION_CACHE_DIR, f"{cache_key}.png"
- )
- 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):
- if filename.endswith(".png"):
- 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:
- logger.warning(f"Failed to cleanup cache: {e}")
-
-
-def cleanup_old_cache_files():
- """Clean up old 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 cache: {filename}")
- except Exception as e:
- logger.warning(f"Failed to cleanup cache file {filename}: {e}")
- except Exception as e:
- logger.warning(f"Failed to cleanup cache: {e}")
-
-
-def verify_cache_persistence():
- """Verify that cached assets persist and can be loaded after restart"""
- try:
- cache_files = []
-
- if os.path.exists(UNIFIED_NOTIFICATION_CACHE_DIR):
- cache_files = [
- f
- for f in os.listdir(UNIFIED_NOTIFICATION_CACHE_DIR)
- 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}")
- except Exception as e:
- logger.warning(f"Failed to load cached asset {cache_file}: {e}")
-
- return len(cache_files) > 0
-
- except Exception as e:
- logger.error(f"Failed to verify cache persistence: {e}")
- return False
-
-
-def get_fallback_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 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
-
-
-# Initialize cache on module load
-ensure_cache_dir()
-cleanup_old_cache_files()
-verify_cache_persistence()
-
diff --git a/modules/osd.py b/modules/osd.py
deleted file mode 100644
index 6b27aaa..0000000
--- 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 ac7e63b..0000000
--- 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/indicators.py b/modules/panel/components/indicators.py
deleted file mode 100644
index 7e8e277..0000000
--- a/modules/panel/components/indicators.py
+++ /dev/null
@@ -1,477 +0,0 @@
-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 modules.controlcenter.battery import BatteryControl
-from modules.controlcenter.bluetooth import BluetoothConnections
-from modules.controlcenter.wifi import WifiConnections
-from services.battery import Battery
-from services.network import NetworkClient
-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
-
-
-class BluetoothIndicator(Box):
- def __init__(self, show_window=True, **kwargs):
- super().__init__(name="bluetooth-indicator", orientation="h", **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_button = Button(
- name="bt-button", child=self.bt_icon, on_clicked=self.on_bluetooth_clicked
- )
-
- 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
-
- modus_service.connect("bluetooth-changed", self.on_bluetooth_changed)
- self.bluetooth.connect("changed", self.on_bluetooth_direct_changed)
- self.bluetooth.connect("device-added", self.on_device_added)
- self.bluetooth.connect("device-removed", self.on_device_removed)
-
- self.update_modus_service_bluetooth_state()
- self.update_state()
-
- 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"
- )
- )
- 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"
- )
- )
- if len(connected_devices) >= 1:
- self.bt_icon.set_from_file(
- get_relative_path(
- "../../../config/assets/icons/applets/bluetooth-paired.svg"
- )
- )
- device = connected_devices[0]
- tooltip = f"Connected to {device.alias}"
- if device.battery_percentage > 0:
- tooltip += f" ({device.battery_percentage:.0f}%)"
- 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"
- )
- )
- tooltip = "No devices connected"
-
- self.bt_button.set_tooltip_text(tooltip)
-
- def on_bluetooth_changed(self, service, new_bluetooth_state):
- self.update_state()
-
- def on_bluetooth_direct_changed(self, *args):
- self.update_modus_service_bluetooth_state()
- self.update_state()
-
- def on_device_added(self, _, address):
- self.update_modus_service_bluetooth_state()
- self.update_state()
-
- def on_device_removed(self, _, address):
- self.update_modus_service_bluetooth_state()
- self.update_state()
-
- def update_modus_service_bluetooth_state(self):
- if not self.bluetooth.enabled:
- bluetooth_state = "disabled"
- else:
- connected_devices = self.bluetooth.connected_devices
- if connected_devices:
- if len(connected_devices) == 1:
- device = connected_devices[0]
- bluetooth_state = f"connected:{device.alias}"
- if (
- hasattr(device, "battery_percentage")
- and device.battery_percentage > 0
- ):
- bluetooth_state += f":{device.battery_percentage:.0f}%"
- else:
- bluetooth_state = f"connected:{len(connected_devices)}_devices"
- else:
- bluetooth_state = "enabled"
-
- 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()
-
- def close_bluetooth(self, *args):
- """Close Bluetooth control center"""
- if self.show_window and self.bluetooth_mousecapture:
- self.bluetooth_mousecapture.hide_child_window()
-
- def hide_controlcenter(self, *args):
- """Hide Bluetooth control center"""
- if self.show_window and self.bluetooth_mousecapture:
- self.bluetooth_mousecapture.hide_child_window()
-
-
-class NetworkIndicator(Box):
- def __init__(self, show_window=True, **kwargs):
- super().__init__(name="network-indicator", orientation="h", **kwargs)
- self.show_window = show_window
-
- 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_button = Button(
- name="network-button",
- child=self.network_icon,
- on_clicked=self.on_wifi_clicked,
- )
-
- 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
-
- modus_service.connect("wlan-changed", self.on_wlan_changed)
- self.network_service.connect("wifi-device-added", self.on_wifi_device_added)
- self.network_service.connect(
- "ethernet-device-added", self.on_ethernet_device_added
- )
- self.network_service.connect("changed", self.on_network_changed)
-
- self.update_modus_service_wlan_state()
- self.update_state()
-
- def on_wlan_changed(self, service, new_wlan_state):
- self.update_state()
-
- def on_wifi_device_added(self, *args):
- """Called when WiFi device is added"""
- if self.network_service.wifi_device:
- self.network_service.wifi_device.connect(
- "changed", self.on_network_direct_changed
- )
- self.update_modus_service_wlan_state()
- 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
- )
- self.update_modus_service_wlan_state()
- self.update_state()
-
- def on_network_direct_changed(self, *args):
- self.update_modus_service_wlan_state()
- self.update_state()
-
- def on_network_changed(self, *args):
- self.update_modus_service_wlan_state()
- self.update_state()
-
- 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:
- wlan_state = "disabled"
- elif wifi.active_access_point:
- ap = wifi.active_access_point
- wlan_state = f"connected:{ap.ssid}"
- if ap.strength >= 0:
- wlan_state += f":{ap.strength}%"
- else:
- wlan_state = "enabled"
-
- # Check Ethernet if WiFi is not connected
- elif self.network_service.ethernet_device:
- ethernet = self.network_service.ethernet_device
- if ethernet.internet == "activated":
- wlan_state = "ethernet:connected"
- if hasattr(ethernet, "speed") and ethernet.speed:
- wlan_state += f":{ethernet.speed}"
- elif ethernet.internet == "activating":
- wlan_state = "ethernet:connecting"
- else:
- wlan_state = "ethernet:disconnected"
-
- modus_service.wlan = wlan_state
-
- def update_state(self):
- tooltip = "No network connection"
- icon_file = "wifi-off-clear.svg"
-
- # Check WiFi first (prioritize WiFi over Ethernet)
- if self.network_service.wifi_device:
- wifi = self.network_service.wifi_device
- if not wifi.wireless_enabled:
- icon_file = "wifi-off-clear.svg"
- tooltip = "WiFi disabled"
- elif wifi.active_access_point:
- ap = wifi.active_access_point
- # Use dynamic WiFi icon based on signal strength
- wifi_icon_path = get_wifi_icon_for_strength(ap.strength)
- self.network_icon.set_from_file(wifi_icon_path)
- tooltip = f"Connected to {ap.ssid}"
- if ap.strength >= 0:
- tooltip += f" ({ap.strength}%)"
- self.network_button.set_tooltip_text(tooltip)
- return # Early return to avoid setting icon again
- else:
- icon_file = "wifi-off-clear.svg"
- tooltip = "WiFi disconnected"
-
- # Check Ethernet if WiFi is not connected
- elif self.network_service.ethernet_device:
- ethernet = self.network_service.ethernet_device
- if ethernet.internet == "activated":
- icon_file = "network-wired.svg"
- tooltip = "Ethernet connected"
- if hasattr(ethernet, "speed") and ethernet.speed:
- tooltip += f" ({ethernet.speed})"
- elif ethernet.internet == "activating":
- icon_file = "network-wired.svg"
- tooltip = "Ethernet connecting..."
- else:
- 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_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()
-
- def close_wifi(self, *args):
- """Close WiFi control center"""
- if self.show_window and self.wifi_mousecapture:
- self.wifi_mousecapture.hide_child_window()
-
- def hide_controlcenter(self, *args):
- """Hide WiFi control center"""
- if self.show_window and self.wifi_mousecapture:
- self.wifi_mousecapture.hide_child_window()
-
-
-class BatteryIndicator(Box):
- 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_icon = Svg(
- name="battery-icon",
- size=23,
- svg_file=get_relative_path(
- "../../../config/assets/icons/battery/battery-100.svg"
- ),
- )
-
- self.battery_button = Button(
- name="battery-button",
- child=self.battery_icon,
- on_clicked=self.on_battery_clicked,
- )
-
- self.battery_label = Label(name="battery-label", label="--- %")
-
- 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
-
- modus_service.connect("battery-changed", self.on_battery_changed)
- self.battery_service.connect("changed", self.on_battery_direct_changed)
-
- self.update_modus_service_battery_state()
- self.update_state()
-
- def on_battery_changed(self, service, new_battery_state):
- self.update_state()
-
- def on_battery_direct_changed(self, *args):
- self.update_modus_service_battery_state()
- self.update_state()
-
- def update_modus_service_battery_state(self):
- if not self.battery_service.is_present:
- battery_state = "not_present"
- else:
- percentage = self.battery_service.percentage
- state = self.battery_service.state.lower()
-
- battery_state = f"{state}:{percentage}%"
-
- if state == "discharging":
- time_to_empty = self.battery_service.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
- if time_to_full != "N/A":
- battery_state += f":{time_to_full}"
-
- modus_service.battery = battery_state
-
- def get_battery_tooltip(self, percentage, state):
- tooltip = f"Battery: {percentage}%"
-
- if state == "CHARGING":
- tooltip += " (Charging)"
- time_to_full = self.battery_service.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
- if time_to_empty != "N/A":
- tooltip += f" - {time_to_empty} remaining"
- elif state == "FULLY_CHARGED":
- tooltip += " (Fully charged)"
-
- return tooltip
-
- def update_state(self):
- if not self.battery_service.is_present:
- # Hide the entire battery component when no battery is present
- 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
- is_charging = state in ["CHARGING", "FULLY_CHARGED"]
-
- icon_file = Battery.get_battery_icon_file(
- percentage, is_charging, base_path="../../../config/assets/icons/"
- )
- 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_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()
-
- def close_battery(self, *args):
- """Close Battery control center"""
- if self.show_window and self.battery_mousecapture:
- self.battery_mousecapture.hide_child_window()
-
- def hide_controlcenter(self, *args):
- """Hide Battery control center"""
- if self.show_window and self.battery_mousecapture:
- self.battery_mousecapture.hide_child_window()
diff --git a/modules/panel/components/menubar.py b/modules/panel/components/menubar.py
deleted file mode 100644
index 61e7735..0000000
--- 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/components/recording_indicator.py b/modules/panel/components/recording_indicator.py
deleted file mode 100644
index ab318e3..0000000
--- a/modules/panel/components/recording_indicator.py
+++ /dev/null
@@ -1,236 +0,0 @@
-import os
-import subprocess
-import time
-
-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 gi.repository import GLib
-
-
-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
- self.timer_update_interval = 1000
- self.status_check_interval = 2000
-
- 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.time_label = Label(
- name="recording-time-label",
- markup="00:00",
- max_width_chars=5,
- ellipsize="none",
- )
-
- self.recording_box = Box(
- orientation="h",
- spacing=2,
- children=[self.recording_icon, self.time_label],
- size=(80, -1),
- )
-
- self.add(self.recording_box)
-
- self.connect("clicked", self.on_stop_recording)
- self.connect("button-press-event", self.on_button_press)
- self.hide()
-
- GLib.timeout_add(100, self._delayed_init)
-
- def on_button_press(self, *args):
- GLib.timeout_add(100, lambda: self.remove_style_class("pressed") or False)
- 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
-
- def check_recording_status(self):
- current_time = time.time()
- self.last_process_check = current_time
-
- try:
- is_recording = self.is_recorder_running()
-
- if is_recording:
- if not self.get_visible():
- self.set_visible(True)
- if self.timer_timeout_id is None:
- self.timer_timeout_id = GLib.timeout_add(
- self.timer_update_interval, self.update_timer_display
- )
-
- if self.recording_start_time is None:
- self.recording_start_time = self.get_recording_start_time()
-
- self.update_timer_display()
- else:
- if self.get_visible():
- self.set_visible(False)
- self.cleanup_recording_state()
-
- except Exception as e:
- print(f"[DEBUG] Error checking recording status: {e}")
- if self.get_visible():
- self.set_visible(False)
- self.cleanup_recording_state()
-
- return True
-
- def update_timer_display(self):
- if not self.get_visible() or self.recording_start_time is None:
- return False
-
- try:
- elapsed_seconds = int(time.time() - self.recording_start_time)
- minutes = elapsed_seconds // 60
- seconds = elapsed_seconds % 60
- time_text = f"{minutes:02d}:{seconds:02d}"
-
- self.time_label.set_markup(time_text)
- self.set_tooltip_text(
- f"Recording in progress ({time_text}) - Click to stop"
- )
-
- return True
- except Exception as e:
- print(f"[DEBUG] Error updating timer display: {e}")
- return False
-
- def cleanup_recording_state(self):
- self.recording_start_time = None
-
- if self.timer_timeout_id:
- GLib.source_remove(self.timer_timeout_id)
- self.timer_timeout_id = None
-
- def get_recording_start_time(self):
- wf_file = "/tmp/recording_start_time.txt"
- gpu_file = "/tmp/gpu_recording_start_time.txt"
-
- def read_timestamp(path):
- try:
- with open(path, "r") as f:
- content = f.read().strip()
- if content:
- t = float(content)
- if abs(t - time.time()) <= 3600:
- return t
- return os.path.getmtime(path)
- except (OSError, ValueError):
- return None
-
- if os.path.exists(wf_file):
- t = read_timestamp(wf_file)
- if t:
- print("[DEBUG] Using wf-recorder start time")
- return t
-
- if os.path.exists(gpu_file):
- t = read_timestamp(gpu_file)
- if t:
- print("[DEBUG] Using gpu-screen-recorder start time")
- return t
-
- print("[DEBUG] No start time file found, using current time")
- return time.time()
-
- def on_stop_recording(self, *args):
- try:
- self.set_visible(False)
- self.cleanup_recording_state()
-
- 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)
-
- except Exception:
- self.set_visible(False)
- self.cleanup_recording_state()
-
- def _verify_recording_stopped(self):
- try:
- if self.is_recorder_running():
- if self.recording_start_time is None:
- self.recording_start_time = self.get_recording_start_time()
-
- if self.recording_start_time:
- self.set_visible(True)
- self.update_timer_display()
-
- if self.timer_timeout_id is None:
- self.timer_timeout_id = GLib.timeout_add(
- self.timer_update_interval, self.update_timer_display
- )
- else:
- self.set_visible(False)
- self.cleanup_recording_state()
-
- except Exception:
- self.set_visible(False)
- self.cleanup_recording_state()
-
- return False
-
- def _delayed_init(self):
- try:
- self.check_recording_status()
- self.status_timeout_id = GLib.timeout_add(
- self.status_check_interval, self.check_recording_status
- )
-
- except Exception as e:
- print(f"[DEBUG] Error in delayed recording indicator init: {e}")
- return False
-
- def destroy(self):
- if self.timer_timeout_id:
- GLib.source_remove(self.timer_timeout_id)
- self.timer_timeout_id = None
-
- if self.status_timeout_id:
- GLib.source_remove(self.status_timeout_id)
- self.status_timeout_id = None
-
- super().destroy()
diff --git a/modules/panel/components/workspace.py b/modules/panel/components/workspace.py
deleted file mode 100644
index c05dab0..0000000
--- a/modules/panel/components/workspace.py
+++ /dev/null
@@ -1,44 +0,0 @@
-from fabric.hyprland.widgets import HyprlandWorkspaces, WorkspaceButton
-from fabric.widgets.box import Box
-import config.data as data
-from utils.functions import is_special_workspace_id
-
-# TODO: Support Multi Monitor
-
-
-class WorkspaceIndicator(Box):
- def __init__(self, **kwargs):
- super().__init__(
- name="workspace-indicator", orientation="h", spacing=4, **kwargs
- )
-
- # Create workspace widget with custom button factory if filtering is enabled
- if data.HIDE_SPECIAL_WORKSPACE:
- self.workspaces = HyprlandWorkspaces(
- name="workspaces",
- spacing=4,
- buttons_factory=self._create_filtered_button,
- )
- else:
- self.workspaces = HyprlandWorkspaces(
- name="workspaces",
- spacing=4,
- buttons_factory=lambda ws_id: WorkspaceButton(
- id=ws_id, label=str(ws_id)
- ),
- )
-
- self.add(self.workspaces)
- self.show_all()
-
- def _is_special_workspace_id(self, ws_id):
- return is_special_workspace_id(ws_id)
-
- def _create_filtered_button(self, ws_id):
- if data.HIDE_SPECIAL_WORKSPACE and self._is_special_workspace_id(ws_id):
- # Return None or an empty widget to hide special workspaces
- return None
-
- return WorkspaceButton(id=ws_id, label=str(ws_id))
-
-
diff --git a/modules/panel/main.py b/modules/panel/main.py
deleted file mode 100644
index 5228e7c..0000000
--- a/modules/panel/main.py
+++ /dev/null
@@ -1,243 +0,0 @@
-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
-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.panel.components.workspace import WorkspaceIndicator
-from modules.todo.todo_widget import TodoListCapture
-from utils.roam import modus_service
-from widgets.mousecapture import MouseCapture
-from widgets.wayland import WaylandWindow as Window
-from services.modus import notification_service
-
-# 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.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),
- )
-
- # DND indicator - REMOVED
-
- 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()
-
- # Workspace indicator
- self.workspace_indicator = WorkspaceIndicator()
-
- 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/switcher.py b/modules/switcher.py
deleted file mode 100644
index 564c54a..0000000
--- a/modules/switcher.py
+++ /dev/null
@@ -1,405 +0,0 @@
-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.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
-
-gi.require_version("Glace", "0.1")
-
-
-class ApplicationSwitcher(Window):
- def __init__(self, **kwargs):
- super().__init__(
- name="application-switcher",
- title="modus-switcher",
- layer="top",
- anchor="center",
- exclusivity="auto",
- keyboard_mode="exclusive",
- visible=False, # Start hidden until explicitly shown
- **kwargs,
- )
-
- self.conn = get_hyprland_connection()
- self.icon_resolver = IconResolver()
- self.windows = []
- self.current_index = 0
- self.tab_pressed = False
- self.items_per_row = data.WINDOW_SWITCHER_ITEMS_PER_ROW
- self.icon_size = 64
-
- # 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
-
- container = Box(
- name="application-switcher-container",
- orientation="v",
- h_align="center",
- v_align="center",
- expand=True,
- )
- self.add(container)
-
- self.view = Box(
- name="application-switcher-view",
- orientation="v",
- spacing=12,
- h_align="center",
- v_align="center",
- )
- container.add(self.view)
- 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()
-
- def show_switcher(self) -> None:
- self.update_windows()
- if not self.windows:
- return
-
- self.show()
- self.grab_keyboard()
- self.tab_pressed = False
-
- 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"""
- 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)
-
- 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)
-
- try:
- clients_data = self.conn.send_command("j/clients").reply
- if not clients_data:
- return
- clients = json.loads(clients_data.decode("utf-8"))
-
- # Filter out hidden windows and optionally special workspace windows
- filtered_windows = []
- 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)
- ):
- continue
- filtered_windows.append(c)
-
- self.windows = filtered_windows
-
- active_data = self.conn.send_command("j/activewindow").reply
- active_window = (
- json.loads(active_data.decode("utf-8")) if active_data else None
- )
-
- self.current_index = 0
- if active_window:
- for i, window in enumerate(self.windows):
- if window.get("address") == active_window.get("address"):
- 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)
-
- for i, window in enumerate(self.windows):
- title = window.get("title", "")
-
- # Create preview image for this window
- preview_image = self.create_preview_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",
- 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,
- h_align="center",
- v_align="center",
- max_width_chars=15,
- ellipsize="end",
- ),
- ],
- )
-
- event_box = EventBox(
- name="window-button",
- style_classes=["active"] if i == self.current_index else None,
- child=button_content,
- )
- 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:
- print(f"Failed to update windows: {e}")
-
- def on_key_press(self, _, event):
- keyval = event.keyval
- state = event.state
- alt_pressed = bool(state & Gdk.ModifierType.MOD1_MASK)
-
- if not self.windows:
- return False
-
- if keyval == Gdk.KEY_Escape:
- self.hide_switcher()
- return True
-
- if keyval == Gdk.KEY_Tab:
- if not self.tab_pressed or alt_pressed:
- self.current_index = (self.current_index + 1) % len(self.windows)
- self.update_selection()
- self.tab_pressed = True
- return True
-
- if keyval == Gdk.KEY_ISO_Left_Tab or (
- keyval == Gdk.KEY_Tab and (state & Gdk.ModifierType.SHIFT_MASK)
- ):
- self.current_index = (self.current_index - 1) % len(self.windows)
- self.update_selection()
- return True
-
- if keyval == Gdk.KEY_Return:
- self.activate_selected()
- self.hide_switcher()
- return True
-
- if keyval == Gdk.KEY_Right or keyval == Gdk.KEY_l:
- self.current_index = (self.current_index + 1) % len(self.windows)
- self.update_selection()
- return True
-
- if keyval == Gdk.KEY_Left or keyval == Gdk.KEY_h:
- self.current_index = (self.current_index - 1) % len(self.windows)
- self.update_selection()
- return True
-
- if keyval == Gdk.KEY_Down:
- next_index = self.current_index + self.items_per_row
- if next_index < len(self.windows):
- self.current_index = next_index
- self.update_selection()
- return True
-
- if keyval == Gdk.KEY_Up:
- next_index = self.current_index - self.items_per_row
- if next_index >= 0:
- self.current_index = next_index
- self.update_selection()
- return True
-
- return False
-
- def on_key_release(self, _, event):
- keyval = event.keyval
-
- if keyval in (Gdk.KEY_Alt_L, Gdk.KEY_Alt_R):
- self.activate_selected()
- self.hide_switcher()
- return True
-
- if keyval == Gdk.KEY_Tab:
- self.tab_pressed = False
- return True
-
- 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")
-
- def activate_selected(self):
- if not self.windows or self.current_index >= len(self.windows):
- return
-
- window = self.windows[self.current_index]
- address = window.get("address")
- if address:
- try:
- command = f"/dispatch focuswindow address:{address}"
- self.conn.send_command(command)
- except Exception as e:
- print(f"Failed to focus window: {e}")
-
- def grab_keyboard(self):
- try:
- display = Gdk.Display.get_default()
- seat = display.get_default_seat()
- window = self.get_window()
- seat.grab(window, Gdk.SeatCapabilities.KEYBOARD, False, None, None, None)
- except Exception as e:
- print(f"Failed to grab keyboard: {e}")
-
- def ungrab_keyboard(self):
- try:
- display = Gdk.Display.get_default()
- seat = display.get_default_seat()
- seat.ungrab()
- except Exception as e:
- print(f"Failed to ungrab keyboard: {e}")
diff --git a/modules/todo/__init__.py b/modules/todo/__init__.py
deleted file mode 100644
index 30afe86..0000000
--- a/modules/todo/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-# Todo module
\ No newline at end of file
diff --git a/modules/todo/todo_widget.py b/modules/todo/todo_widget.py
deleted file mode 100644
index a19cb96..0000000
--- a/modules/todo/todo_widget.py
+++ /dev/null
@@ -1,456 +0,0 @@
-# Standard library imports
-from datetime import datetime
-
-# Fabric imports
-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.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
-
-# Local imports
-from services.todo import todo_service
-from widgets.mousecapture import MouseCapture
-from widgets.wayland import WaylandWindow as Window
-
-
-class TodoItem(Box):
- """Individual todo item widget"""
-
- def __init__(self, todo_data, todo_list_widget, **kwargs):
- self.todo_data = todo_data
- self.todo_list_widget = todo_list_widget
- self.editing = False
-
- super().__init__(
- name="todo-item",
- orientation="h",
- spacing=8,
- style_classes=["menu"],
- **kwargs,
- )
-
- self._build_ui()
-
- def _build_ui(self):
- """Build the todo item UI"""
- # Checkbox for completion - using SVG icons
- checkbox_icon = (
- "checkbox-check.svg"
- if self.todo_data["completed"]
- else "checkbox-uncheck.svg"
- )
- self.checkbox_icon = Svg(
- name="todo-checkbox-icon",
- size=24,
- svg_file=get_relative_path(
- "../../config/assets/icons/todo/" + checkbox_icon
- ),
- )
- self.checkbox = Button(
- name="todo-checkbox",
- child=self.checkbox_icon,
- on_clicked=self._toggle_completion,
- )
-
- # Todo text (can be converted to entry for editing)
- text_content = self.todo_data["text"]
- if self.todo_data["completed"]:
- text_content = f"{text_content} "
-
- self.text_label = Label(
- markup=text_content,
- name="todo-text",
- h_align="start",
- h_expand=True,
- line_wrap="word-char",
- style_classes=(
- ["title-widget"]
- if not self.todo_data["completed"]
- else ["status-label"]
- ),
- )
-
- # Date/time label
- created_at = datetime.fromisoformat(self.todo_data["created_at"])
- date_text = created_at.strftime("%b %d, %Y at %I:%M %p")
-
- self.date_label = Label(
- label=date_text,
- name="todo-date",
- h_align="start",
- style_classes=["todo-date-text"],
- )
-
- self.text_entry = Entry(
- name="todo-text-entry",
- text=self.todo_data["text"],
- h_expand=True,
- visible=False,
- )
- self.text_entry.connect("activate", self._save_edit)
-
- # Priority indicator
- # priority_symbols = {"high": "๐ด", "medium": "๐ก", "low": "๐ข"}
- #
- # self.priority_label = Label(
- # label=priority_symbols.get(self.todo_data["priority"], "๐ก"),
- # name="todo-priority-label",
- # )
- # self.priority_button = Button(
- # name="todo-priority",
- # size=(20, 20),
- # child=self.priority_label,
- # on_clicked=self._cycle_priority,
- # )
- #
- # Edit button - using SVG icon
- self.edit_icon = 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",
- child=self.edit_icon,
- on_clicked=self._start_edit,
- )
-
- # Delete button - using SVG icon
- self.delete_icon = 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",
- child=self.delete_icon,
- on_clicked=self._delete_todo,
- )
-
- # Text container that switches between label and entry
- self.text_container = Box(
- orientation="v",
- h_expand=True,
- children=[
- Box(orientation="h", children=[self.text_label]),
- self.date_label,
- ],
- )
-
- self.children = [
- self.checkbox,
- self.text_container,
- # self.priority_button,
- self.edit_button,
- self.delete_button,
- ]
-
- def _toggle_completion(self, *_):
- """Toggle todo completion status"""
- 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)
-
- def _start_edit(self, *_):
- """Start editing the todo text"""
- if self.editing:
- return
-
- self.editing = True
- self.text_container.children = [
- Box(orientation="h", children=[self.text_entry]),
- self.date_label,
- ]
- self.text_entry.set_visible(True)
- self.text_entry.grab_focus()
- self.text_entry.set_position(-1) # Move cursor to end
-
- def _save_edit(self, *_):
- """Save the edited todo text"""
- if not self.editing:
- return
-
- new_text = self.text_entry.get_text().strip()
- if new_text:
- todo_service.edit_todo(self.todo_data["id"], new_text)
-
- self._cancel_edit()
-
- def _cancel_edit(self):
- """Cancel editing and revert to label"""
- self.editing = False
- self.text_container.children = [
- Box(orientation="h", children=[self.text_label]),
- self.date_label,
- ]
- self.text_entry.set_visible(False)
-
- def _delete_todo(self, *_):
- """Delete this todo"""
- todo_service.delete_todo(self.todo_data["id"])
-
- def update_from_data(self, todo_data):
- """Update the widget from new todo data"""
- self.todo_data = todo_data
-
- # Update checkbox icon by recreating it
- checkbox_icon = (
- "checkbox-check.svg" if todo_data["completed"] else "checkbox-uncheck.svg"
- )
- new_checkbox_icon = Svg(
- 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
-
- # Update text and styling with markup
- text_content = todo_data["text"]
- if todo_data["completed"]:
- text_content = f"{text_content} "
-
- self.text_label.set_markup(text_content)
- self.text_label.style_classes = (
- ["title-widget"] if not todo_data["completed"] else ["status-label"]
- )
-
- # Update date/time
- created_at = datetime.fromisoformat(todo_data["created_at"])
- date_text = created_at.strftime("%b %d, %Y at %I:%M %p")
- self.date_label.set_label(date_text)
-
-
-class TodoListWidget(Window):
- """Main todo list widget window"""
-
- def __init__(self, **kwargs):
- super().__init__(
- title="modus-todo",
- anchor="top right",
- margin="2px 10px 0px 0px",
- exclusivity="auto",
- keyboard_mode="on-demand",
- name="todo-list-window",
- visible=False, # Back to hidden by default
- **kwargs,
- )
-
- self.todo_items = {} # Maps todo IDs to TodoItem widgets
-
- # Register callback with todo service
- todo_service.add_callback(self._on_todo_event)
-
- self._build_ui()
- self._refresh_todos()
-
- # Add keybinding for escape
- self.add_keybinding("Escape", self.hide_todo_list)
-
- def _build_ui(self):
- """Build the main UI"""
- # Header with title and stats
- self.stats_label = Label(
- label="",
- name="todo-stats",
- style_classes=["status-label"],
- h_align="start",
- )
-
- self.header = Box(
- name="todo-header",
- orientation="v",
- children=[
- Label(
- label="Todo List",
- name="todo-title",
- style_classes=["title"],
- h_align="start",
- ),
- self.stats_label,
- ],
- )
-
- # Add new todo section
- self.new_todo_entry = Entry(
- name="new-todo-entry",
- placeholder_text="Add a new task...",
- h_expand=True,
- )
- self.new_todo_entry.connect("activate", self._add_todo)
-
- # Add button - using SVG icon
- self.add_icon = 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",
- child=self.add_icon,
- on_clicked=self._add_todo,
- )
-
- self.add_section = Box(
- name="todo-add-section",
- orientation="h",
- spacing=8,
- style_classes=["menu"],
- children=[
- self.new_todo_entry,
- self.add_button,
- ],
- )
-
- # Todo items container
- self.todos_container = Box(
- name="todos-container",
- orientation="v",
- spacing=4,
- )
-
- # Scrolled window for todos
- self.scrolled = ScrolledWindow(
- name="todos-scrolled",
- min_content_height=300,
- max_content_height=500,
- min_content_width=400,
- child=self.todos_container,
- policy="automatic",
- v_expand=True, # Allow vertical expansion
- )
-
- # Clear completed button
- self.clear_button = Button(
- name="clear-completed-button",
- label="Clear Completed",
- style_classes=["status-label"],
- on_clicked=self._clear_completed,
- )
-
- # Main container
- self.main_container = Box(
- name="todo-main-container",
- orientation="v",
- spacing=8,
- style_classes=["menu"],
- children=[
- self.header,
- self.add_section,
- self.scrolled,
- self.clear_button,
- ],
- )
-
- self.children = [self.main_container]
-
- def _add_todo(self, *_):
- """Add a new todo"""
- text = self.new_todo_entry.get_text().strip()
- if text:
- todo_service.add_todo(text)
- self.new_todo_entry.set_text("")
-
- def _clear_completed(self, *_):
- """Clear all completed todos"""
- todo_service.clear_completed()
-
- def _on_todo_event(self, event_type, data=None):
- """Handle todo service events via callback"""
- if event_type == "todos-changed":
- GLib.idle_add(self._refresh_todos)
- elif event_type == "todo-added":
- GLib.idle_add(self._refresh_todos)
- elif event_type == "todo-deleted":
- GLib.idle_add(self._refresh_todos)
- elif event_type in ["todo-toggled", "todo-edited", "todo-priority-changed"]:
- if data and data["id"] in self.todo_items:
- GLib.idle_add(
- lambda: self.todo_items[data["id"]].update_from_data(data)
- )
- GLib.idle_add(self._update_stats)
-
- def _refresh_todos(self, *_):
- """Refresh the entire todo list"""
- # Clear existing items
- self.todo_items.clear()
- self.todos_container.children = []
-
- # Get all todos
- todos = todo_service.todos
-
- # Sort todos: incomplete first, then by priority, then by creation date
- def sort_key(todo):
- priority_order = {"high": 0, "medium": 1, "low": 2}
- return (
- todo["completed"], # False (incomplete) comes before True (completed)
- priority_order.get(todo["priority"], 1),
- todo["created_at"],
- )
-
- sorted_todos = sorted(todos, key=sort_key)
-
- # Create todo item widgets
- for todo in sorted_todos:
- todo_item = TodoItem(todo, self)
- self.todo_items[todo["id"]] = todo_item
-
- # Update container children
- self.todos_container.children = list(self.todo_items.values())
-
- # Update stats
- self._update_stats()
-
- def _update_stats(self):
- """Update the statistics display"""
- stats = todo_service.get_stats()
- stats_text = f"{stats['pending']} pending, {stats['completed']} completed"
- self.stats_label.set_label(stats_text)
-
- def set_visible(self, visible):
- """Override set_visible for debugging"""
- super().set_visible(visible)
-
- def hide_todo_list(self, *_):
- """Hide the todo list"""
- if hasattr(self, "_mousecapture_parent"):
- self._mousecapture_parent.toggle_mousecapture()
- self.set_visible(False)
-
- def _init_mousecapture(self, mousecapture):
- """Initialize mousecapture parent reference"""
- self._mousecapture_parent = mousecapture
-
- def destroy(self):
- """Clean up when destroyed"""
- # Remove callback from todo service
- todo_service.remove_callback(self._on_todo_event)
-
- super().destroy()
-
-
-class TodoListCapture(MouseCapture):
- """MouseCapture wrapper for the todo list"""
-
- def __init__(self, **kwargs):
- super().__init__(
- layer="top",
- child_window=TodoListWidget(),
- **kwargs,
- )
diff --git a/modules/widget.py b/modules/widget.py
deleted file mode 100644
index 21ce287..0000000
--- 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/requirements.txt b/requirements.txt
deleted file mode 100644
index 1d0d377..0000000
--- a/requirements.txt
+++ /dev/null
@@ -1,17 +0,0 @@
-certifi==2025.8.3
-charset-normalizer==3.4.2
-click==8.2.1
-idna==3.10
-loguru==0.7.3
-pillow==11.3.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.32.4
-setproctitle==1.3.6
-thefuzz==0.22.1
-urllib3==2.5.0
diff --git a/scripts/gamemode.sh b/scripts/gamemode.sh
deleted file mode 100755
index 3652ccd..0000000
--- 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 703c7df..0000000
--- 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 a1fd899..0000000
--- 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/__init__.py b/services/__init__.py
deleted file mode 100644
index 3f03f12..0000000
--- a/services/__init__.py
+++ /dev/null
@@ -1,4 +0,0 @@
-"""
-Modus services package.
-Contains background services and utilities for the shell.
-"""
diff --git a/services/auth.py b/services/auth.py
deleted file mode 100644
index 3a7b070..0000000
--- 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 a28cceb..0000000
--- 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/brightness.py b/services/brightness.py
deleted file mode 100644
index 1031444..0000000
--- a/services/brightness.py
+++ /dev/null
@@ -1,96 +0,0 @@
-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
-
-
-def exec_brightnessctl_async(args: str):
- exec_shell_command_async(f"brightnessctl {args}", lambda _: None)
-
-
-# Discover screen backlight device
-try:
- screen_device = os.listdir("/sys/class/backlight")
- screen_device = screen_device[0] if screen_device else ""
-except FileNotFoundError:
- logger.error("No backlight devices found, brightness control disabled")
- screen_device = ""
-
-
-class Brightness(Service):
- """Service to manage screen brightness levels."""
-
- instance = None
-
- @staticmethod
- def get_initial():
- if Brightness.instance is None:
- Brightness.instance = Brightness()
-
- return Brightness.instance
-
- @Signal
- def screen(self, value: int) -> None:
- """Signal emitted when screen brightness changes."""
- # Implement as needed for your application
-
- def __init__(self, **kwargs):
- super().__init__(**kwargs)
-
- # Path for screen backlight control
- self.screen_backlight_path = f"/sys/class/backlight/{screen_device}"
-
- # Initialize maximum brightness level
- self.max_screen = self.do_read_max_brightness(self.screen_backlight_path)
-
- if screen_device == "":
- return
-
- # Monitor screen brightness file
- self.screen_monitor = monitor_file(f"{self.screen_backlight_path}/brightness")
-
- self.screen_monitor.connect(
- "changed",
- lambda _, file, *args: self.emit(
- "screen",
- round(int(file.load_bytes()[0].get_data())),
- ),
- )
-
- # Log the initialization of the service
- logger.info(f"Brightness service initialized for device: {screen_device}")
-
- def do_read_max_brightness(self, path: str) -> int:
- # Reads the maximum brightness value from the specified path.
- max_brightness_path = os.path.join(path, "max_brightness")
- if os.path.exists(max_brightness_path):
- with open(max_brightness_path) as f:
- return int(f.readline())
- return -1 # Return -1 if file doesn't exist, indicating an error.
-
- @Property(int, "read-write")
- def screen_brightness(self) -> int:
- # Property to get or set the screen brightness.
- brightness_path = os.path.join(self.screen_backlight_path, "brightness")
- if os.path.exists(brightness_path):
- with open(brightness_path) as f:
- return int(f.readline())
- logger.warning(f"Brightness file does not exist: {brightness_path}")
- return -1 # Return -1 if file doesn't exist, indicating error.
-
- @screen_brightness.setter
- def screen_brightness(self, value: int):
- # Setter for screen brightness property.
- if not (0 <= value <= self.max_screen):
- value = max(0, min(value, self.max_screen))
-
- try:
- exec_brightnessctl_async(f"--device '{screen_device}' set {value}")
- self.emit("screen", int((value / self.max_screen) * 100))
- except GLib.Error as e:
- logger.error(f"Error setting screen brightness: {e.message}")
- except Exception as e:
- logger.exception(f"Unexpected error setting screen brightness: {e}")
diff --git a/services/custom_notification.py b/services/custom_notification.py
deleted file mode 100644
index 7619887..0000000
--- a/services/custom_notification.py
+++ /dev/null
@@ -1,462 +0,0 @@
-# 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,
- NotificationAction,
- NotificationImagePixmap,
- Notifications,
-)
-
-gi.require_version("Gtk", "3.0")
-gi.require_version("GdkPixbuf", "2.0")
-
-NOTIFICATION_CACHE_FILE = f"{data.CACHE_DIR}/notification_history.json"
-
-
-class CachedNotification(Service):
- @classmethod
- def create_from_dict(cls, data, **kwargs):
- """Create CachedNotification from enhanced JSON data"""
- data["timeout"] = 0
- self = cls.__new__(cls)
- 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
- def removed_from_cache(self) -> None: ...
-
- @Property(int, "readable")
- def cache_id(self) -> int:
- return self._cache_id
-
- @Property(str, "readable")
- def app_name(self) -> str:
- return self._notification.app_name
-
- @Property(str, "readable")
- def app_icon(self) -> str:
- return self._notification.app_icon
-
- @Property(str, "readable")
- def summary(self) -> str:
- return self._notification.summary
-
- @Property(str, "readable")
- def body(self) -> str:
- return self._notification.body
-
- @Property(int, "readable")
- def id(self) -> int:
- return self._notification.id
-
- @Property(int, "readable")
- def replaces_id(self) -> int:
- return self._notification.replaces_id
-
- @Property(int, "readable")
- def urgency(self) -> int:
- return self._notification.urgency
-
- @Property(list[NotificationAction], "readable")
- def actions(self) -> list[NotificationAction]:
- return self._notification.actions
-
- @Property(NotificationImagePixmap, "readable")
- def image_pixmap(self) -> NotificationImagePixmap:
- return self._notification.image_pixmap # type: ignore
-
- @Property(str, "readable")
- def image_file(self) -> str:
- return self._notification.image_file # type: ignore
-
- @Property(object, "readable")
- def image_pixbuf(self) -> GdkPixbuf.Pixbuf | None:
- try:
- if self.image_pixmap:
- return self.image_pixmap.as_pixbuf()
- if self.image_file and os.path.exists(self.image_file):
- try:
- return GdkPixbuf.Pixbuf.new_from_file(self.image_file)
- except Exception:
- # If file can't be loaded, return None
- pass
- except Exception:
- # If any error occurs (including temp file gone), return None safely
- pass
- return None
-
- @Property(dict, "readable")
- def serialized(self) -> dict:
- """Enhanced serialization with cache metadata - stores only cache keys"""
- from modules.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 not, try to generate it safely
- 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)
- if 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
- pass
- except Exception:
- # If any error occurs during cache key generation, skip it
- pass
-
- return {
- "cached-id": self.cache_id,
- "id": self.id,
- "replaces-id": self.replaces_id,
- "app-name": self.app_name,
- "app-icon": self.app_icon,
- "summary": self.summary,
- "body": self.body,
- "urgency": self.urgency,
- "actions": [(action.identifier, action.label) for action in self.actions],
- "image-file": self.image_file,
- # Only store image-pixmap if no cache key is available (fallback)
- "image-pixmap": None, # Don't store image data, only cache key
- "timestamp": int(time.time()),
- "group": self.app_name, # Group notifications by app name
- # Enhanced cache metadata - store only cache keys
- "cache_metadata": {
- "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())
- }
- }
-
- def __init__(self, notification: Notification, cache_id: int, **kwargs):
- super().__init__()
- self._notification: Notification = notification
- self._cache_id = cache_id
- self.cache_metadata = {}
- self.timestamp = int(time.time())
-
- def remove_from_cache(self):
- self.removed_from_cache.emit()
-
-
-class CachedNotifications(Notifications):
- """A service to manage the cached notifications."""
-
- @Signal
- def clear_all(self) -> None:
- """Signal emitted when notifications are emptied."""
- pass
-
- @Signal
- def cached_notification_added(self, notification: CachedNotification) -> None:
- """Signal emitted when a notification is cached."""
- pass
-
- @Signal
- def cached_notification_removed(self, notification: CachedNotification) -> None:
- """Signal emitted when a notification is removed from cache."""
- pass
-
- @Property(List[CachedNotification], "readable")
- def cached_notifications(self) -> List[CachedNotification]:
- """Return the cached notifications."""
- return list(self._cached_notifications.values())
-
- @Property(int, "readable")
- def count(self) -> int:
- """Return the count of notifications."""
- return self._count
-
- @Property(bool, "read-write", default_value=False)
- 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
- self.notify("dont-disturb")
-
- def __init__(self, **kwargs):
- super().__init__()
- self._cached_notifications: dict[int, CachedNotification] = {}
- self._signal_handlers = {} # Store signal handlers by notification_id
- 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.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)
-
- def load_cached_notifications(self) -> dict[int, CachedNotification]:
- """Load cached notifications from a JSON file (deserialization)."""
- try:
- with open(NOTIFICATION_CACHE_FILE, "r") as file:
- data = json.load(file) # Load list of serialized notifications
- except (FileNotFoundError, json.JSONDecodeError):
- # If file doesn't exist or is corrupted, start with empty list
- data = []
-
- max_cache_id = 0
- for notification in data:
- 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
- ),
- )
- self._signal_handlers[cache_id] = handler_id
- self._cached_notifications[cache_id] = cached_notification
- self._count += 1
-
- # Set next cache ID to be higher than any existing ID
- self._next_cache_id = max_cache_id + 1
- self.notify("count")
- return self._cached_notifications
-
- def cache_notifications(self) -> None:
- """Save cached notifications to a JSON file."""
- # Ensure cache directory exists
- os.makedirs(os.path.dirname(NOTIFICATION_CACHE_FILE), exist_ok=True)
-
- serialized_data = [
- notif.serialized for notif in self._cached_notifications.values()
- ] # Convert to serializable format
- with open(NOTIFICATION_CACHE_FILE, "w") as file:
- json.dump(serialized_data, file, indent=4)
-
- 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
-
- 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
- self._next_cache_id = 1 # Reset cache ID counter
- self.notify("count")
- self.clear_all.emit()
-
- 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}")
- return
-
- # Import here to avoid circular imports
- from config import data
- from modules.notification.notification import (
- preload_notification_assets,
- cache_notification_icon,
- cache_notification_image,
- get_cache_key,
- get_notification_image_cache_key
- )
-
- # Check if this app should be ignored for history (don't cache)
- if notification.app_name in data.NOTIFICATION_IGNORED_APPS_HISTORY:
- # Don't cache notifications from ignored apps, but still allow popup display
- 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
- # 3. Notification was cached recently (within last 5 minutes)
- 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):
- existing_notification = cached_notif
- break
-
- if existing_notification:
- 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]}...")
-
- # GUARANTEED STORAGE: Always create and store notification to history first
- cache_id = self._next_cache_id
- self._next_cache_id += 1
- self._count += 1
-
- cached_notification = CachedNotification(
- notification=notification, cache_id=cache_id
- )
- # 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())
- }
-
- # IMMEDIATELY store to history before attempting any caching operations
- handler_id = cached_notification.connect(
- "removed-from-cache",
- lambda *args: self.remove_cached_notification(notification_id=cache_id),
- )
- 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}")
-
- # 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
- 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'):
- try:
- # Safely try to access image_pixbuf
- 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
- cached_notification.cache_metadata["has_cached_image"] = True
- 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}")
-
- # 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}")
-
- # 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)}")
-
- 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'):
- cache_metadata = cached_notification.cache_metadata
-
- # Clean up specific cached files using stored keys
- from modules.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')
- )
-
- 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:
- cached_notification.disconnect(handler_id)
-
- # Emit signal to notify UI that notification was removed
- self.emit("cached-notification-removed", cached_notification)
-
- def toggle_dnd(self):
- self.set_dont_disturb(not self.dont_disturb)
diff --git a/services/modus.py b/services/modus.py
deleted file mode 100644
index f9e9444..0000000
--- a/services/modus.py
+++ /dev/null
@@ -1,359 +0,0 @@
-import json
-
-from fabric.core.service import Property, Service, Signal
-from fabric.hyprland.service import Hyprland
-from loguru import logger
-
-from services.custom_notification import CachedNotifications
-
-notification_service = CachedNotifications()
-
-
-class ModusService(Service):
- @Signal
- def bluetooth_changed(self, new_bluetooth: str) -> None: ...
-
- @Signal
- def volume_changed(self, new_volume: int) -> None: ...
-
- @Signal
- def wlan_changed(self, new_wlan: str) -> None: ...
-
- @Signal
- def battery_changed(self, new_battery: str) -> None: ...
-
- @Signal
- def dock_apps_changed(self, new_dock_apps: str) -> None: ...
-
- @Signal
- def dont_disturb_changed(self, value: bool) -> None: ...
-
- @Signal
- def current_active_app_name_changed(self, value: str) -> None: ...
-
- @Signal
- def current_workspace_changed(self, value: str) -> None: ...
-
- @Signal
- def music_changed(self, value: str) -> None: ...
-
- @Signal
- def current_dropdown_changed(self, value: str) -> None: ...
-
- @Signal
- def dropdowns_hide_changed(self, value: bool) -> None: ...
-
- @Signal
- def dock_width_changed(self, value: int) -> None: ...
-
- @Signal
- def dock_height_changed(self, value: int) -> None: ...
-
- @Signal
- def dock_hidden_changed(self, value: bool) -> None: ...
-
- @Signal
- def show_notificationcenter_changed(self, value: bool) -> None: ...
-
- @Signal
- def notification_count_changed(self, value: int) -> None: ...
-
- @Property(str, flags="read-write")
- def current_active_app_name(self) -> str:
- return self._current_active_app_name
-
- @Property(str, flags="read-write")
- def current_workspace(self) -> str:
- return self._current_workspace
-
- @Property(str, flags="read-write")
- def bluetooth(self) -> str:
- return self._bluetooth
-
- @Property(str, flags="read-write")
- def wlan(self) -> str:
- return self._wlan
-
- @Property(str, flags="read-write")
- def battery(self) -> str:
- return self._battery
-
- @Property(int, flags="read-write")
- def volume(self) -> int:
- return self._volume
-
- @Property(str, flags="read-write")
- def dock_apps(self) -> str:
- return self._dock_apps
-
- @Property(bool, flags="read-write", default_value=False)
- def dont_disturb(self) -> bool:
- return self._dont_disturb
-
- @Property(str, flags="read-write")
- def music(self) -> str:
- return self._music
-
- @Property(str, flags="read-write")
- def current_dropdown(self) -> str:
- return self._current_dropdown
-
- @Property(bool, flags="read-write", default_value=False)
- def dropdowns_hide(self) -> bool:
- return self._dropdowns_hide
-
- @Property(int, flags="read-write")
- def dock_width(self) -> int:
- return self._dock_width
-
- @Property(int, flags="read-write")
- def dock_height(self) -> int:
- return self._dock_height
-
- @Property(bool, flags="read-write", default_value=False)
- def dock_hidden(self) -> bool:
- return self._dock_hidden
-
- @Property(bool, flags="read-write", default_value=False)
- def show_notificationcenter(self) -> bool:
- return self._show_notificationcenter
-
- @current_active_app_name.setter
- def current_active_app_name(self, value: str):
- if value != self._current_active_app_name:
- self._current_active_app_name = value
- self.current_active_app_name_changed(value)
-
- @current_workspace.setter
- def current_workspace(self, value: str):
- if value != self._current_workspace:
- self._current_workspace = value
- self.current_workspace_changed(value)
-
- @volume.setter
- def volume(self, value: int):
- if value != self._volume:
- self._name = value
- self.volume_changed(value)
-
- @wlan.setter
- def wlan(self, value: str):
- if value != self._wlan:
- self._wlan = value
- self.wlan_changed(value)
-
- @battery.setter
- def battery(self, value: str):
- if value != self._battery:
- self._battery = value
- self.battery_changed(value)
-
- @bluetooth.setter
- def bluetooth(self, value: str):
- if value != self._bluetooth:
- self._bluetooth = value
- self.bluetooth_changed(value)
-
- @dock_apps.setter
- def dock_apps(self, value: str):
- if value != self._dock_apps:
- self._dock_apps = value
- self.dock_apps_changed(value)
-
- @dont_disturb.setter
- def dont_disturb(self, value: bool):
- if value != self._dont_disturb:
- self._dont_disturb = value
- self.dont_disturb_changed(value)
-
- @music.setter
- def music(self, value: str):
- if value != self._music:
- self._music = value
- self.music_changed(value)
-
- @current_dropdown.setter
- def current_dropdown(self, value: str):
- if value != self._current_dropdown:
- self._current_dropdown = value
- self.current_dropdown_changed(value)
-
- @dropdowns_hide.setter
- def dropdowns_hide(self, value: bool):
- if value != self._dropdowns_hide:
- self._dropdowns_hide = value
- self.dropdowns_hide_changed(value)
-
- @dock_width.setter
- def dock_width(self, value: int):
- if value != self._dock_width:
- self._dock_width = value
- self.dock_width_changed(value)
-
- @dock_height.setter
- def dock_height(self, value: int):
- if value != self._dock_height:
- self._dock_height = value
- self.dock_height_changed(value)
-
- @dock_hidden.setter
- def dock_hidden(self, value: bool):
- if value != self._dock_hidden:
- self._dock_hidden = value
- self.dock_hidden_changed(value)
-
- @show_notificationcenter.setter
- def show_notificationcenter(self, value: bool):
- if value != self._show_notificationcenter:
- self._show_notificationcenter = value
- self.show_notificationcenter_changed(value)
-
- def sc(self, signal_name: str, callback: callable, def_value="..."):
- self.connect(signal_name, callback)
- # Return current property value instead of default
- if signal_name == "bluetooth-changed":
- return self.bluetooth if self.bluetooth else "Off"
- elif signal_name == "wlan-changed":
- return self.wlan if self.wlan else "No Connection"
- elif signal_name == "battery-changed":
- return self.battery if self.battery else "Unknown"
- elif signal_name == "music-changed":
- return self.music if self.music else ""
- else:
- return def_value
-
- def __init__(self):
- super().__init__()
- self._volume = 0
- self._wlan = ""
- self._battery = ""
- self._bluetooth = ""
- self._dock_apps = ""
- self._dont_disturb = False
- self._current_active_app_name = "Finder" # Changed from "Hyprland" to "Finder"
- self._current_workspace = "1"
- self._music = ""
- self._current_dropdown = None
- self._dropdowns_hide = False
- self._dock_hidden = False
- self._show_notificationcenter = False
-
- self._dock_width = 0
- self._dock_height = 0
-
- # Initialize Hyprland connection for workspace and window monitoring
- self._setup_workspace_monitoring()
- self._setup_active_window_monitoring()
-
- def _setup_workspace_monitoring(self):
- """Setup Hyprland connection and workspace monitoring"""
- try:
- self._hyprland_connection = Hyprland()
-
- # Get initial workspace
- workspace_data = self._hyprland_connection.send_command(
- "j/activeworkspace"
- ).reply
- active_workspace = json.loads(workspace_data.decode("utf-8"))["name"]
- self._current_workspace = str(active_workspace)
-
- # Connect to workspace change events
- self._hyprland_connection.connect(
- "event::workspace", self._on_workspace_changed
- )
-
- except Exception as e:
- logger.error(f"[ModusService] Failed to setup workspace monitoring: {e}")
- self._current_workspace = "1"
-
- def _setup_active_window_monitoring(self):
- """Setup active window monitoring"""
- try:
- 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}")
-
- 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:
- return
-
- window_data = self._hyprland_connection.send_command("j/activewindow").reply
- if not window_data:
- self.current_active_app_name = "Finder"
- return
-
- window_info = json.loads(window_data.decode("utf-8"))
- 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]
- else:
- name = "Finder"
-
- self.current_active_app_name = name
-
- 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)
- except Exception as e:
- logger.error(f"[ModusService] Error processing workspace change: {e}")
-
- def remove_notification(self, id: int):
- notification_service.remove_cached_notification(id)
- self.notification_count_changed(self.notification_count)
-
- def clear_all_notifications(self):
- notification_service.clear_all_cached_notifications()
- self.notification_count_changed(self.notification_count)
-
- def get_cached_notifications(self):
- return notification_service.cached_notifications
-
- def get_deserialized_with_ids(self):
- return [
- (notif._notification, notif.cache_id)
- for notif in notification_service.cached_notifications
- ]
-
- @property
- def notification_count(self) -> int:
- return notification_service.count
-
- def toggle_dnd(self):
- notification_service.toggle_dnd()
- self.dont_disturb_changed(notification_service.dont_disturb)
-
-
-global modus_service
-try:
- modus_service = ModusService()
-except Exception as e:
- logger.error("[Main] Failed to create ModusShellService:", e)
diff --git a/services/mpris.py b/services/mpris.py
deleted file mode 100644
index 539a727..0000000
--- 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/services/network.py b/services/network.py
deleted file mode 100644
index 619e81d..0000000
--- a/services/network.py
+++ /dev/null
@@ -1,667 +0,0 @@
-from gi.repository import NM, GLib
-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
-
-gi.require_version("NM", "1.0") # Ensure the correct version is loaded
-
-
-class NetworkClient(Service):
- """A service to manage network devices"""
-
- @Signal
- def wifi_device_added(self) -> None: ...
-
- @Signal
- def ethernet_device_added(self) -> None: ...
-
- @Signal
- def wifi_device_removed(self) -> None: ...
-
- @Signal
- def ethernet_device_removed(self) -> None: ...
-
- @Signal
- def changed(self) -> None: ...
-
- @Property(list, "readable")
- def connections(self) -> Optional[list]:
- """Returns the active connections, if available."""
- return self._client.get_property("connections") if self._client else None
-
- @Property(object, "readable")
- def wifi_device(self) -> Optional[object]:
- """Returns the WiFi device if available."""
- return self._wifi_device
-
- @Property(object, "readable")
- def ethernet_device(self) -> Optional[object]:
- """Returns the Ethernet device if available."""
- return self._ethernet_device
-
- @Property(str, "readable")
- def primary_connection(self) -> Optional[str]:
- """Returns the primary connection if available."""
- return self._client.get_property("primary_connection") if self._client else None
-
- @Property(str, "readable")
- def active_connection(self) -> Optional[str]:
- """Returns the active connection if available."""
- return self._client.get_property("active_connection") if self._client else None
-
- @Property(str, "readable")
- def state(self) -> str:
- """Returns the current network state."""
- if not self._client:
- return "disconnected"
- return snake_case_to_kebab_case(
- get_enum_member_name(
- self._client.get_property("state"), default="disconnected"
- )
- )
-
- @Property(str, "readable")
- def connectivity(self) -> str:
- """Returns the connectivity state."""
- if not self._client:
- return "disconnected"
- return snake_case_to_kebab_case(
- get_enum_member_name(
- self._client.get_property("connectivity"), default="disconnected"
- )
- )
-
- @Property(list, "readable")
- def devices(self) -> Optional[list]:
- """Returns the list of network devices if available."""
- return self._client.get_property("devices") if self._client else None
-
- @Property(str, "readable")
- def hostname(self) -> Optional[str]:
- """Returns the hostname if available."""
- return self._client.get_property("hostname") if self._client else None
-
- @Property(bool, "read-write", default_value=False)
- def networking_enabled(self) -> bool:
- """Checks if networking is enabled."""
- return (
- self._client.get_property("networking_enabled") if self._client else False
- )
-
- @networking_enabled.setter
- def networking_enabled(self, value: bool):
- """Sets the networking state if the client is available."""
- if self._client:
- self._client.set_property("networking_enabled", value)
-
- @Property(bool, "read-write", default_value=False)
- def wireless_enabled(self) -> bool:
- """Checks if wireless networking is enabled."""
- return self._client.get_property("wireless_enabled") if self._client else False
-
- @wireless_enabled.setter
- def wireless_enabled(self, value: bool):
- """Sets the wireless networking state if the client is available."""
- if self._client:
- self._client.set_property("wireless_enabled", value)
-
- def __init__(self, **kwargs):
- super().__init__(**kwargs)
-
- self._client: NM.Client = None
- self._wifi_device: Wifi | None = None
- self._ethernet_device: Ethernet | None = None
-
- logger.info("[Network] Initializing client asynchronously...")
-
- # Start async NM client initialization
- NM.Client.new_async(None, self.on_client_ready)
-
- def on_client_ready(self, source, result):
- """Callback when NM.Client is ready."""
- try:
- self._client = NM.Client.new_finish(result) # Retrieve client instance
- logger.info("[Network] NM.Client initialized successfully!")
-
- # Connect signals
- bulk_connect(
- self._client,
- {
- "device-added": lambda _, device: self.on_device_added(
- device=device
- ),
- "device-removed": lambda _, device: self.on_device_removed(
- device=device
- ),
- "notify::state": lambda *args: self.notifier("state"),
- "notify::networking-enabled": lambda *args: self.notifier(
- "networking-enabled"
- ),
- "notify::wireless-enabled": lambda *args: self.notifier(
- "wireless-enabled"
- ),
- # "notify::primary-connection": lambda *args: self.notifier('primary-connection'),
- # "notify::active-connection": lambda *args: self.notifier('active-connection'),
- # "active-connection-added": lambda *args: self.emit("changed"),
- # "active-connection-removed": lambda *args: self.emit("changed")
- },
- )
-
- # Process devices AFTER client is ready
- for device in self.do_get_raw_devices():
- self.on_device_added(device=device)
-
- self.notify("state")
- self.notify("networking-enabled")
- self.notify("wireless-enabled")
-
- except Exception as e:
- logger.error(f"[Network] Error initializing NM.Client: {e}")
-
- def do_get_raw_devices(self) -> list[NM.Device]:
- return [
- dev
- for dev in self.devices
- if dev.get_device_type() in (NM.DeviceType.WIFI, NM.DeviceType.ETHERNET)
- ]
-
- def on_device_added(self, device):
- device_type = device.get_device_type()
- if device_type == NM.DeviceType.WIFI and not self._wifi_device:
- logger.info("[Network] WiFi device detected, initializing...")
- self._wifi_device = Wifi(client=self, device=device)
- self.wifi_device_added.emit()
-
- elif device_type == NM.DeviceType.ETHERNET and not self._ethernet_device:
- logger.info("[Network] Ethernet device detected, initializing...")
- self._ethernet_device = Ethernet(client=self, device=device)
- self.ethernet_device_added.emit()
-
- def on_device_removed(self, device):
- if device == self._wifi_device:
- logger.info("[Network] WiFi device removed.")
- self._wifi_device = None
- self.wifi_device_removed.emit()
-
- elif device == self._ethernet_device:
- logger.info("[Network] Ethernet device removed.")
- self._ethernet_device = None
- self.ethernet_device_removed.emit()
-
- def toggle_network(self):
- """Enable or disable Network"""
- self.networking_enabled = not self.networking_enabled
-
- def deactivate_connection(self, connection):
- """Disconnect"""
- self._client.deactivate_connection_async(connection, None, None)
-
- def notifier(self, name):
- self.notify(name)
- self.emit("changed")
-
-
-class AccessPoint(Service):
- """A service to manage access points"""
-
- @Signal
- def changed(self) -> None: ...
-
- @Property(object, "readable")
- def device(self) -> object:
- return self._device
-
- @Property(int, "readable")
- def strength(self) -> int:
- return self._ap.get_property("strength")
-
- @Property(int, "readable")
- def frequency(self) -> int:
- return self._ap.get_property("frequency")
-
- @Property(str, "readable")
- def bssid(self) -> str:
- return self._ap.get_property("bssid") if self._ap else None
-
- @Property(str, "readable")
- def hw_address(self) -> str:
- return self._ap.get_property("hw_address")
-
- @Property(str, "readable")
- def ssid(self) -> str:
- ssid = self._ap.get_ssid()
- return NM.utils_ssid_to_utf8(ssid.get_data()) if ssid else "Unknown"
-
- @Property(str, "readable")
- def icon(self) -> str:
- return {
- 80: "network-wireless-signal-excellent-symbolic",
- 60: "network-wireless-signal-good-symbolic",
- 40: "network-wireless-signal-ok-symbolic",
- 20: "network-wireless-signal-weak-symbolic",
- 00: "network-wireless-signal-none-symbolic",
- }.get(
- min(80, 20 * round(self.strength / 20)),
- "network-wireless-no-route-symbolic",
- )
-
- @Property(bool, "readable", default_value=False)
- def requires_password(self) -> bool:
- ssid = self.ssid
- settings = self._client.connections
- connection = None
- for setting in settings:
- wifi_setting = setting.get_setting_wireless()
- if (
- wifi_setting
- and NM.utils_ssid_to_utf8(wifi_setting.get_ssid().get_data()) == ssid
- ):
- connection = setting
- break
- if not connection:
- return bool(self._ap.get_wpa_flags() or self._ap.get_rsn_flags())
- return False
-
- @Property(bool, "readable", default_value=False)
- def is_active(self) -> bool:
- if self._device.active_access_point:
- return self.bssid == self._device.active_access_point.get_bssid()
- return False
-
- def __init__(self, device: "Wifi", ap: NM.AccessPoint, **kwargs):
- super().__init__(**kwargs)
- self._client: NetworkClient = device.client
- self._device: Wifi = device
- self._ap: NM.AccessPoint = ap
-
- self._ap.connect("notify::strength", lambda *args: self.notifier("strength"))
- self._device.connect(
- "notify::active-access-point", lambda *args: self.notifier("is-active")
- )
-
- def notifier(self, name: str, *args):
- self.notify(name)
- self.emit("changed")
- return
-
-
-class Wifi(Service):
- """A service to manage wifi devices"""
-
- @Signal
- def changed(self) -> None: ...
-
- @Signal
- def ap_added(self, ap: AccessPoint) -> None: ...
-
- @Signal
- def ap_removed(self, ap: AccessPoint) -> None: ...
-
- @Property(NetworkClient, "readable")
- def client(self) -> NetworkClient:
- """Returns the client"""
- return self._client
-
- @Property(bool, "read-write", default_value=False)
- def wireless_enabled(self) -> bool:
- """Returns if the wifi is enabled"""
- return self._client.get_property("wireless_enabled")
-
- @wireless_enabled.setter
- def wireless_enabled(self, value: bool):
- return self._client.set_property("wireless_enabled", value)
-
- @Property(list[AccessPoint], "readable")
- def access_points(self) -> list[AccessPoint]:
- return sorted(
- self._access_points.values(), key=lambda x: x.is_active, reverse=True
- )
-
- @Property(AccessPoint, "readable")
- def active_access_point(self) -> Optional[AccessPoint]:
- return self._active_access_point
-
- def __init__(self, client: NetworkClient, device: NM.DeviceWifi, **kwargs):
- super().__init__(**kwargs)
- self._client: NetworkClient = client
- self._device: NM.DeviceWifi = device
- self._active_access_point: NM.AccessPoint | None = None
- self._access_points: dict[str, AccessPoint] = {}
-
- bulk_connect(
- self._device,
- {
- "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
- ),
- # "state-changed": lambda device, new, old, reason: self.on_state_changed(new),
- },
- )
-
- self._client.connect(
- "notify::wireless-enabled", lambda *args: self.notifier("wireless-enabled")
- )
-
- for ap in self.do_get_access_points():
- self.on_access_point_added(ap=ap)
-
- self.on_access_point_activated()
-
- def on_state_changed(self, state):
- self.emit("changed")
-
- def do_get_access_points(self):
- return self._device.get_access_points()
-
- def on_access_point_added(self, ap):
- ssid = ap.get_ssid()
- ssid = NM.utils_ssid_to_utf8(ssid.get_data()) if ssid else "Unknown"
-
- access_point: AccessPoint = AccessPoint(ap=ap, device=self)
-
- self._access_points[ssid] = access_point
-
- self.ap_added.emit(access_point)
-
- # self.notifier("access-points")
-
- logger.info(f"[Wifi] New access point added with ssid: {ssid}")
-
- def on_access_point_removed(self, ap):
- ssid = ap.get_ssid()
- ssid = NM.utils_ssid_to_utf8(ssid.get_data()) if ssid else "Unknown"
-
- if not (access_point := self._access_points.pop(ssid, None)):
- return logger.warning(
- f"[Network] tried to remove a unknwon access point with ssid {ssid}"
- )
-
- self.ap_removed.emit(access_point)
-
- logger.info(f"[Wifi] Access point with ssid: {ssid} removed.")
-
- def on_access_point_activated(self):
- if self._device.get_active_access_point():
- self._active_access_point: AccessPoint = AccessPoint(
- ap=self._device.get_active_access_point(), device=self
- )
-
- else:
- self._active_access_point = None
-
- self.notifier("active-access-point")
-
- logger.info("[Wifi] New active connection")
-
- def disconnect_wifi(self):
- """Disconnect from the current WiFi network."""
- active_connection = self._device.get_active_connection()
- self._client.deactivate_connection(active_connection)
- logger.info("[Wifi] Wifi network disconnected")
-
- def scan(self):
- self._device.request_scan_async(
- None,
- lambda device, result: [
- device.request_scan_finish(result),
- self.notifier("access-points"),
- ],
- )
- logger.info("[Wifi] Scan started")
-
- def toggle_wifi(self):
- """Enable or disable WiFi"""
- self.wireless_enabled = not self.wireless_enabled
-
- def connect_to_wifi(self, ap: AccessPoint, password: str = None, callback=None):
- """Connect to a WiFi network."""
-
- ssid = ap.ssid
-
- if ssid == "Unknown":
- logger.error("Invalid access point data")
- if callback:
- callback(False, "Invalid access point data")
- return False
-
- logger.info(f"Connecting to WiFi SSID: {ssid}")
-
- # Check for existing connections
- settings = self._client.connections
- connection = None
-
- for setting in settings:
- wifi_setting = setting.get_setting_wireless()
- if (
- wifi_setting
- and NM.utils_ssid_to_utf8(wifi_setting.get_ssid().get_data()) == ssid
- ):
- connection = setting
- break
-
- def on_activation_result(client, result):
- """Handle the result of connection activation"""
- try:
- active_connection = client.activate_connection_finish(result)
- if active_connection:
- logger.info(f"Successfully connected to '{ssid}'")
- if callback:
- callback(True, "Connected successfully")
- else:
- logger.error(f"Failed to connect to '{ssid}'")
- if callback:
- callback(False, "Failed to connect. Please try again.")
- except Exception as e:
- error_msg = str(e).lower()
- logger.error(f"Connection to '{ssid}' failed: {e}")
-
- # Parse NetworkManager error messages and provide user-friendly responses
- if "802-11-wireless-security" in error_msg:
- if "property is invalid" in error_msg or "psk" in error_msg:
- user_msg = "Incorrect password. Please try again."
- else:
- user_msg = "Security configuration error. Please try again."
- elif "secrets were required" in error_msg or "no secrets" in error_msg:
- user_msg = "Incorrect password. Please try again."
- elif "timeout" in error_msg:
- user_msg = "Connection timeout. Please try again."
- elif "not found" in error_msg or "no such device" in error_msg:
- user_msg = "Network not available. Please try again."
- elif "already connected" in error_msg:
- user_msg = "Already connected to this network."
- else:
- user_msg = "Failed to connect. Please try again."
-
- if callback:
- callback(False, user_msg)
-
- if not connection:
- # Create a new connection profile
- logger.info(f"Creating new WiFi connection for SSID '{ssid}'")
- connection = NM.SimpleConnection.new()
-
- # Required connection settings
- s_con = NM.SettingConnection.new()
- s_con.set_property(NM.SETTING_CONNECTION_ID, ssid)
- s_con.set_property(NM.SETTING_CONNECTION_TYPE, "802-11-wireless")
- s_con.set_property(
- NM.SETTING_CONNECTION_INTERFACE_NAME, self._device.get_iface()
- ) # Set interface name
- connection.add_setting(s_con)
-
- # Wireless settings
- s_wifi = NM.SettingWireless.new()
- s_wifi.set_property(NM.SETTING_WIRELESS_SSID, GLib.Bytes.new(ssid.encode()))
- s_wifi.set_property(
- NM.SETTING_WIRELESS_MODE, "infrastructure"
- ) # Ensure mode is correct
- connection.add_setting(s_wifi)
-
- # Security settings (only if password is required and provided)
- if ap.requires_password:
- if not password:
- logger.error("Password required but not provided")
- if callback:
- callback(False, "Password required but not provided")
- return False
-
- s_sec = NM.SettingWirelessSecurity.new()
- s_sec.set_property(NM.SETTING_WIRELESS_SECURITY_KEY_MGMT, "wpa-psk")
- s_sec.set_property(NM.SETTING_WIRELESS_SECURITY_PSK, password)
- connection.add_setting(s_sec)
-
- # IPv4 settings
- s_ipv4 = NM.SettingIP4Config.new()
- s_ipv4.set_property("method", "auto")
- connection.add_setting(s_ipv4)
-
- # IPv6 settings
- s_ipv6 = NM.SettingIP6Config.new()
- s_ipv6.set_property("method", "auto")
- connection.add_setting(s_ipv6)
-
- # Callback for async connection
- def on_connection_added(client, result):
- try:
- new_connection = client.add_connection_finish(result)
- if not new_connection:
- logger.error(f"Failed to create connection for '{ssid}'")
- if callback:
- callback(
- False, "Failed to create connection. Please try again."
- )
- return
-
- logger.info(f"Connection for '{ssid}' added successfully")
-
- # Now activate the newly created connection
- client.activate_connection_async(
- new_connection, self._device, None, None, on_activation_result
- )
- except Exception as e:
- error_msg = str(e).lower()
- logger.error(f"Failed to add connection: {e}")
-
- # Parse connection creation errors and provide user-friendly responses
- if "802-11-wireless-security" in error_msg:
- if "property is invalid" in error_msg or "psk" in error_msg:
- user_msg = "Incorrect password. Please try again."
- else:
- user_msg = "Security configuration error. Please try again."
- elif "invalid" in error_msg or "property" in error_msg:
- user_msg = "Invalid network configuration. Please try again."
- else:
- user_msg = "Failed to connect. Please try again."
-
- if callback:
- callback(False, user_msg)
-
- # Save the new connection
- self._client._client.add_connection_async(
- connection, True, None, on_connection_added
- )
- else:
- # Activate existing connection
- self._client._client.activate_connection_async(
- connection, self._device, None, None, on_activation_result
- )
-
- return True
-
- def notifier(self, name: str, *args):
- self.notify(name)
- self.emit("changed")
- return
-
-
-class Ethernet(Service):
- """A service to manage ethernet devices"""
-
- @Signal
- def changed(self) -> None: ...
-
- @Signal
- def enabled(self) -> bool: ...
-
- @Property(int, "readable")
- def speed(self) -> str:
- speed_mbps = self._device.get_speed()
- return f"{speed_mbps} Mb/s"
-
- @Property(str, "readable")
- def state(self) -> str:
- return snake_case_to_kebab_case(
- get_enum_member_name(self._device.get_state(), default="disconnected")
- )
-
- @Property(str, "readable")
- def internet(self) -> str:
- if self._active_connection:
- return snake_case_to_kebab_case(
- get_enum_member_name(
- self._active_connection.get_state(), default="disconnected"
- )
- )
- return "disconnected"
-
- @Property(str, "readable")
- def iface(self) -> str:
- return self._device.get_iface() if self._device else None
-
- @Property(str, "readable")
- def icon_name(self) -> str:
- network = self.internet
- if network == "activated":
- return "network-wired-symbolic"
- elif network == "activating":
- return "network-wired-acquiring-symbolic"
- return "network-wired-disconnected-symbolic"
-
- def __init__(self, client: NM.Client, device: NM.DeviceEthernet, **kwargs) -> None:
- super().__init__(**kwargs)
- self._client: NM.Client = client
- self._device: NM.DeviceEthernet = device
- self._active_connection = None
-
- self._device.connect(
- "state-changed", lambda *args: self.on_network_state_changed()
- )
-
- self.update_active_connection()
-
- def on_network_state_changed(self):
- """Called when networking is toggled on/off."""
- if self.state != "unmanaged":
- # Re-initialize device when networking is re-enabled
- self.update_active_connection()
- else:
- self._active_connection = None
-
- def update_active_connection(self):
- """Updates the active connection and connects to its state change signal."""
- active_connection = self._device.get_active_connection()
- if active_connection:
- self._active_connection = active_connection
- self._active_connection.connect(
- "state-changed", lambda *args: self.emit("changed")
- )
-
- def get_network_stats(self):
- """Fetch received and transmitted bytes from the system files"""
- try:
- # Read data from /sys/class/net for accurate speed
- with open(
- f"/sys/class/net/{self.iface}/statistics/rx_bytes", "r"
- ) as rx_file:
- rx_bytes = int(rx_file.read().strip())
- with open(
- f"/sys/class/net/{self.iface}/statistics/tx_bytes", "r"
- ) as tx_file:
- tx_bytes = int(tx_file.read().strip())
- return rx_bytes, tx_bytes
- except FileNotFoundError:
- return None, None
diff --git a/services/todo.py b/services/todo.py
deleted file mode 100644
index 876505e..0000000
--- a/services/todo.py
+++ /dev/null
@@ -1,169 +0,0 @@
-# Standard library imports
-import json
-import uuid
-from datetime import datetime
-from pathlib import Path
-
-# Fabric imports
-from fabric.core.service import Property, Service
-
-# Local imports
-import config.data as data
-
-
-class TodoService(Service):
- """Service for managing persistent todo list with JSON storage"""
-
- def __init__(self):
- super().__init__()
- self._todos = []
- self._file_path = self._get_todos_file_path()
- self._load_todos()
- self._callbacks = []
-
- def add_callback(self, callback):
- """Add a callback function to be notified of changes"""
- self._callbacks.append(callback)
-
- def remove_callback(self, callback):
- """Remove a callback function"""
- if callback in self._callbacks:
- self._callbacks.remove(callback)
-
- def _notify_callbacks(self, event_type, data=None):
- """Notify all registered callbacks of changes"""
- for callback in self._callbacks:
- try:
- callback(event_type, data)
- except Exception as e:
- print(f"Error in todo callback: {e}")
-
- def _get_todos_file_path(self):
- """Returns the path to the todos JSON file"""
- cache_dir = Path(data.CACHE_DIR) / "todos"
- cache_dir.mkdir(parents=True, exist_ok=True)
- return cache_dir / "todos.json"
-
- def _load_todos(self):
- """Load todos from JSON file"""
- try:
- if self._file_path.exists():
- with open(self._file_path, "r", encoding="utf-8") as f:
- self._todos = json.load(f)
- else:
- self._todos = []
- except Exception as e:
- print(f"Error loading todos: {e}")
- self._todos = []
-
- def _save_todos(self):
- """Save todos to JSON file"""
- try:
- with open(self._file_path, "w", encoding="utf-8") as f:
- json.dump(self._todos, f, indent=2, ensure_ascii=False)
- except Exception as e:
- print(f"Error saving todos: {e}")
-
- @Property(list, "readable")
- def todos(self):
- """Get all todos"""
- return self._todos.copy()
-
- def add_todo(self, text: str, priority: str = "medium") -> dict:
- """Add a new todo item"""
- todo = {
- "id": str(uuid.uuid4()),
- "text": text,
- "completed": False,
- "priority": priority, # low, medium, high
- "created_at": datetime.now().isoformat(),
- "updated_at": datetime.now().isoformat(),
- }
- self._todos.append(todo)
- self._save_todos()
- self._notify_callbacks("todo-added", todo)
- self._notify_callbacks("todos-changed")
- return todo
-
- def delete_todo(self, todo_id: str) -> bool:
- """Delete a todo item by ID"""
- for i, todo in enumerate(self._todos):
- if todo["id"] == todo_id:
- deleted_todo = self._todos.pop(i)
- self._save_todos()
- self._notify_callbacks("todo-deleted", deleted_todo)
- self._notify_callbacks("todos-changed")
- return True
- return False
-
- def toggle_todo(self, todo_id: str) -> bool:
- """Toggle completion status of a todo item"""
- for todo in self._todos:
- if todo["id"] == todo_id:
- todo["completed"] = not todo["completed"]
- todo["updated_at"] = datetime.now().isoformat()
- self._save_todos()
- self._notify_callbacks("todo-toggled", todo)
- self._notify_callbacks("todos-changed")
- return True
- return False
-
- def edit_todo(self, todo_id: str, new_text: str) -> bool:
- """Edit the text of a todo item"""
- for todo in self._todos:
- if todo["id"] == todo_id:
- todo["text"] = new_text
- todo["updated_at"] = datetime.now().isoformat()
- self._save_todos()
- self._notify_callbacks("todo-edited", todo)
- self._notify_callbacks("todos-changed")
- return True
- return False
-
- def set_priority(self, todo_id: str, priority: str) -> bool:
- """Set the priority of a todo item"""
- if priority not in ["low", "medium", "high"]:
- return False
-
- for todo in self._todos:
- if todo["id"] == todo_id:
- todo["priority"] = priority
- todo["updated_at"] = datetime.now().isoformat()
- self._save_todos()
- self._notify_callbacks("todo-priority-changed", todo)
- self._notify_callbacks("todos-changed")
- return True
- return False
-
- def get_todo(self, todo_id: str) -> dict | None:
- """Get a specific todo by ID"""
- for todo in self._todos:
- if todo["id"] == todo_id:
- return todo.copy()
- return None
-
- def clear_completed(self):
- """Remove all completed todos"""
- initial_count = len(self._todos)
- self._todos = [todo for todo in self._todos if not todo["completed"]]
- if len(self._todos) < initial_count:
- self._save_todos()
- self._notify_callbacks("todos-changed")
-
- def get_stats(self) -> dict:
- """Get todo statistics"""
- total = len(self._todos)
- completed = sum(1 for todo in self._todos if todo["completed"])
- pending = total - completed
-
- return {
- "total": total,
- "completed": completed,
- "pending": pending,
- "completion_rate": (completed / total * 100) if total > 0 else 0,
- }
-
-
-# Global service instance
-todo_service = TodoService()
-
diff --git a/styles/about.css b/styles/about.css
deleted file mode 100644
index 77ace66..0000000
--- a/styles/about.css
+++ /dev/null
@@ -1,79 +0,0 @@
-#about-menu {
- background-color: alpha(#000, 0.34);
-
- box-shadow:
- inset 0 -0.5px 0 0.5px alpha(#555, 0.7),
- inset 0 0.5px 0 0.5px alpha(#777, 0.7);
- border: 1px solid alpha(#111, 0.3);
- border-top: none;
- border-radius: 1.25rem;
- padding: 2rem;
-}
-
-#about-options {
- background-color: alpha(#333, 0.9);
- border-radius: 15px;
- padding: 2rem;
-}
-
-#about-logo-box {
- margin-top: 2rem;
- margin-bottom: 2rem;
-}
-
-#about-button-box {
- padding: 1rem;
-}
-#vendor-label {
- color: #999999;
- margin-top: -1rem;
-}
-
-#info-label {
- color: #888888;
-}
-#about-info-title-box {
- padding-left: 4rem;
-}
-
-#about-name-label {
- font-size: 24px;
- margin: 0px;
- font-weight: 600;
-}
-
-#about-date-label {
- font-size: 11px;
- margin: 0px;
- color: #ddd;
- font-weight: 400;
- margin-bottom: 2rem;
-}
-
-#about-chip-title-label,
-#about-so-title-label,
-#about-mem-title-label {
- font-size: 12px;
- margin: 0px;
- margin-right: 10px;
- font-weight: 500;
-}
-
-#more-info-button {
- background-color: #888888;
- border-radius: 5px;
- padding: 0px 10px;
- margin: 1px 2px;
- transition: all 200ms ease-in-out;
-}
-#more-info-button:hover {
- background-color: rgb(72, 130, 255);
- background-color: #2369ff;
-}
-
-#more-info-button label {
- font-size: 12px;
- margin: 0px;
- font-weight: 400;
- color: #fff;
-}
diff --git a/styles/battery-widget.css b/styles/battery-widget.css
deleted file mode 100644
index 763cfe4..0000000
--- a/styles/battery-widget.css
+++ /dev/null
@@ -1,149 +0,0 @@
-#battery-widget {
- background: rgba(0, 0, 0, 0);
- padding-left: 5px;
- padding-right: 5px;
-}
-.battery-main-title {
- font-size: 16px;
- font-weight: bold;
-
- color: rgba(255, 255, 255, 0.95);
- margin: 6px 10px;
-}
-
-.battery-power-source {
- font-size: 0.9em;
- font-weight: 500;
- color: rgba(255, 255, 255, 0.8);
- margin: 0px 12px;
-}
-
-.battery-section-title {
- font-size: 0.9em;
- font-weight: 500;
- color: rgba(255, 255, 255, 0.9);
- margin: 8px 12px 4px 12px;
-}
-
-.battery-energy-item {
- margin: 4px 12px;
- padding: 4px 0;
-}
-
-.battery-status-section {
- /* background: rgba(255, 255, 255, 0.06); */
- /* border-radius: 12px; */
- padding: 2px;
- /* margin: 8px 0; */
- /* border: 1px solid rgba(255, 255, 255, 0.08); */
-}
-
-.battery-percentage {
- font-size: 16px;
- margin-right: 8px;
- font-weight: 500;
- color: rgba(255, 255, 255, 0.95);
-}
-
-#energy-mode-button-clickable {
- background: transparent;
- border: none;
- border-radius: 8px;
- padding: 6px 12px;
- margin: 1px 0;
-}
-
-#energy-mode-button-clickable:hover {
- background: rgba(255, 255, 255, 0.1);
-}
-
-#energy-mode-icon {
- border-radius: 50%;
- padding: 4px;
- margin-top: -2px;
- margin-bottom: -2px;
- margin-right: 6px;
- margin-left: -6px;
- background-color: rgba(255, 255, 255, 0.1);
- color: rgba(255, 255, 255, 0.7);
-}
-
-#energy-mode-icon.connected {
- background-color: #007aff;
- color: #ffffff;
-}
-.battery-power-source {
- font-size: 12px;
- margin-bottom: -4px;
- color: alpha(#ffffff, 0.5);
-}
-.battery-section-title {
- font-size: 14px;
- color: alpha(#ffffff, 0.7);
- margin: 2px 0 0 8px;
-}
-.battery-power-mode {
- font-size: 13px;
-}
-
-.battery-settings-button:hover {
- margin-top: -5px;
- background: alpha(#ffffff, 0.1);
- min-height: 30px;
- border-radius: 8px;
-}
-.battery-settings-button {
- margin-top: -5px;
- min-height: 30px;
- border-radius: 8px;
-}
-.battery-settings-button label {
- font-weight: 500;
- color: alpha(#ffffff, 1);
-}
-#energy-mode-button:first-child {
- margin-top: 0;
-}
-#energy-mode-button {
- margin-top: -8px;
- margin-bottom: 4px;
- color: rgba(255, 255, 255, 0.85);
-}
-.gamemode-button {
- font-size: 12px;
- margin-left: -6px;
- color: rgba(255, 255, 255, 0.85);
-}
-
-#game-mode-button {
- margin: 2px 0;
-}
-#game-mode-icon {
- margin-left: -6px;
-}
-
-#game-mode-button-clickable {
- background: transparent;
- border: none;
- border-radius: 8px;
- padding: 6px 12px;
- margin: 1px 0;
- /* min-height: 36px; */
-}
-
-#game-mode-button-clickable:hover {
- background: rgba(255, 255, 255, 0.1);
-}
-
-#game-mode-icon {
- border-radius: 50%;
- padding: 4px;
- margin-right: 8px;
- background-color: rgba(255, 255, 255, 0.1);
- color: rgba(255, 255, 255, 0.7);
-}
-
-#game-mode-icon.connected {
- background-color: #007aff;
- color: #ffffff;
-}
diff --git a/styles/colors.css b/styles/colors.css
deleted file mode 100644
index 1a391d3..0000000
--- 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/controlcenter.css b/styles/controlcenter.css
deleted file mode 100644
index a60738d..0000000
--- a/styles/controlcenter.css
+++ /dev/null
@@ -1,988 +0,0 @@
-.title {
- font-size: 16px;
- font-family: "SF Pro Rounded";
- font-weight: bold;
-}
-#control-center-menu {
- background-color: transparent;
- border-radius: 12px;
- box-shadow: none;
- margin: 0;
-}
-
-#separator {
- min-height: 0.09rem;
- margin: 3px 0;
- background-color: alpha(#fff, 0.2);
- border-radius: 8px;
-}
-
-/* #bluetooth-control-window { */
-/* #wifi-control-window, */
-#battery-control-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: 15px;
-}
-
-#bluetooth-title,
-#wifi-title {
- 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);
- /* border: 1px solid alpha(#111, 0.4); */
- border-radius: 12px;
- padding: 0.5rem;
-}
-#focus-widget {
-}
-#wb-widget,
-#brightness-menu {
- background-color: alpha(#000, 0.08);
- box-shadow: inset 0 0 0 0.5px alpha(#aaa, 0.4);
- border: 1px solid alpha(#111, 0.4);
- border-radius: 12px;
-}
-/* Widgets */
-#wifi-widget,
-#bluetooth-widget,
-#nightlight-widget {
- min-width: 140px;
- padding: 5px 0 0px 0;
-}
-
-.icon {
- background-color: #2369ff;
- font-size: 15px;
- padding: 20px 20px;
-}
-
-#bluetooth-widget-label,
-#wifi-widget-label,
-#nightlight-widget-label {
- font-size: 12px;
- margin-left: 5px;
- font-weight: 500;
- color: #999;
-}
-
-/* WiFi Password Dialog */
-#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;
-}
-
-#bluetooth-widget-title {
- font-size: 12px;
- font-weight: 500;
- color: #fff;
-}
-
-/* #bluetooth-widget-top { */
-/* padding-bottom: 10px; */
-/* border-bottom: 1px solid alpha(#aaa, 0.3); */
-/* } */
-
-#device-icon {
- border-radius: 50%;
- padding: 5px;
- margin-bottom: 2.5px;
- margin-top: 2.5px;
- margin-right: 10px;
- background-color: #555;
-}
-
-#devices-title {
- color: alpha(#fff, 0.6);
-}
-
-#device-icon.paired {
- background-color: #888;
-}
-
-#device-icon.connected {
- background-color: #2369ff;
-}
-
-#toggle-button {
- min-width: 40px;
- min-height: 20px;
- background-color: #bababa;
- border-radius: 15px;
- padding: 2px;
- transition: background-color 0.3s ease;
-}
-
-#toggle-button slider {
- background-color: #fff;
- border-radius: 50%;
- min-width: 16px;
- min-height: 8px;
- transition: background-color 0.1s cubic-bezier(0.5, 0.25, 0, 1.25);
-}
-
-#toggle-button:checked {
- background-color: #4487f6;
-}
-
-/* #toggle-button:checked slider { */
-/* background-color: #fff; */
-/* } */
-/**/
-/* #toggle-button:checked image { */
-/* opacity: 0; */
-/* } */
-
-.menu {
- background-color: alpha(#000, 0.08);
- box-shadow: inset 0 0 0 0.5px alpha(#aaa, 0.4);
- border: 1px solid alpha(#111, 0.4);
- border-radius: 12px;
- margin: 5px;
- /* background-color: alpha(#999, 0.1); */
- /* border: 0.5px solid alpha(#000, 0.4); */
- /* border-radius: 8px; */
- padding: 1rem;
-}
-
-.title {
- padding: 0;
- font-weight: 500;
- font-size: 12px;
-}
-
-#bluetooth-widget-name,
-#wifi-widget-name,
-#nightlight-widget-name {
- margin-top: 6px;
- font-size: 14px;
- font-weight: bold;
-}
-
-.ct {
- margin-left: 5px;
-}
-
-#vol-slider-box {
- /* margin-bottom: 2px; */
- margin-top: -10px;
- /* border: 1px solid alpha(#fff, 1); */
-}
-#volume-widget-slider {
- /* margin-bottom: 2px; */
-}
-#volume-widget-icon {
- font-size: 22px;
- margin-top: -35px;
- margin-right: -36px;
- color: #000;
-}
-
-#brightness-widget-icon {
- font-size: 15px;
- margin-top: -27px;
- margin-right: -29px;
- color: #000;
-}
-
-#control-center-menu slider {
- background: linear-gradient(135deg, #ffffff 0%, #f0f1f2 100%);
- border-color: #000;
- min-width: 28px;
- min-height: 28px;
- margin: -2px;
- border-radius: 50%;
- /* background-image: none; */
- /* background-color: transparent; */
- /* min-width: 4px; */
- /* min-height: 40px; */
- /* margin: -9px; */
-}
-
-#control-center-menu slider:hover {
- border-color: #999;
- min-width: 30px;
- min-height: 30px;
- margin: -3px;
- border-radius: 50%;
- /* background-image: none; */
- /* background-color: transparent; */
- /* min-width: 4px; */
- /* min-height: 40px; */
- /* margin: -9px; */
-}
-#control-center-menu scale {
- background-color: transparent;
- margin-top: 10px;
- border-radius: 20px;
-}
-
-#control-center-menu trough {
- min-width: 25px;
- border-radius: 99px;
- background-color: alpha(#666, 0.5);
- border: 1px solid alpha(#444, 0.3);
-}
-
-#control-center-menu highlight {
- background: alpha(#fff, 0.8);
- border-radius: 99px;
-}
-
-#control-center-menu mark indicator {
- background: none;
- background-image: none;
- color: alpha(#fff, 0.2);
-}
-
-#control-center-menu mark label {
- background: none;
- background-image: none;
- color: alpha(#fff, 0.2);
-}
-
-/* Per-app volume control styles */
-#per-app-volume-control {
- min-height: 140px;
- min-width: 350px;
- /* background-color: alpha(#000, 0.3); */
- /* background-color: alpha(#999, 0.1); */
- /* box-shadow: inset 0 0 200px 0 alpha(#111, 0.3); */
- /* border: 0.5px solid alpha(#000, 0.4); */
- border-radius: 8px;
- padding: 1rem;
-
- /* padding: 1rem; */
-}
-
-#apps-scrolled-container {
- min-height: 120px;
- min-width: 120px;
-}
-#back-button {
- padding: 8px 12px;
- background-color: alpha(#fff, 0.1);
- border-radius: 8px;
- border: none;
- color: #fff;
- font-size: 13px;
- font-weight: 500;
- transition: background-color 0.2s ease;
-}
-#back-button:hover {
- background-color: alpha(#fff, 0.18);
-}
-
-/* Apple-style app volume items */
-.apple-app-volume-item {
- margin: 3px 0;
- padding: 16px;
- background-color: alpha(#fff, 0.08);
- border-radius: 12px;
- border: 1px solid alpha(#fff, 0.12);
- transition: background-color 0.2s ease;
-}
-
-.apple-app-volume-item:hover {
- background-color: alpha(#fff, 0.12);
-}
-
-.apple-app-name {
- font-size: 14px;
- font-weight: 600;
- color: #fff;
- margin-bottom: 4px;
- letter-spacing: -0.01em;
-}
-
-/* Apple-style volume slider */
-#apple-volume-slider {
- background-color: transparent;
- margin-top: 4px;
- margin-bottom: 4px;
- min-height: 32px;
-}
-
-#apple-volume-slider trough {
- min-width: 28px;
- min-height: 6px;
- border-radius: 3px;
- background-color: alpha(#fff, 0.25);
- border: none;
- box-shadow: inset 0 1px 2px alpha(#000, 0.2);
-}
-
-#apple-volume-slider highlight {
- background: linear-gradient(90deg, #007aff 0%, #0051d0 100%);
- border-radius: 3px;
- border: none;
- box-shadow: 0 1px 3px alpha(#007aff, 0.3);
-}
-
-#apple-volume-slider slider {
- background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
- /* border: 2px solid alpha(#007aff, 0.8); */
- border-radius: 50%;
- min-width: 20px;
- min-height: 20px;
- margin: -10px;
- box-shadow:
- 0 2px 8px alpha(#000, 0.15),
- 0 1px 3px alpha(#000, 0.2);
- transition: all 0.15s ease;
-}
-
-#apple-volume-slider slider:hover {
- background: linear-gradient(135deg, #ffffff 0%, #f0f1f2 100%);
- border-color: #007aff;
- box-shadow:
- 0 4px 12px alpha(#000, 0.2),
- 0 2px 6px alpha(#007aff, 0.3);
- min-width: 22px;
- min-height: 22px;
- margin: -9px;
-}
-
-#apple-volume-slider slider:active {
- background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
- min-width: 21px;
- min-height: 21px;
- margin: -9px;
- box-shadow:
- 0 2px 6px alpha(#000, 0.25),
- 0 1px 3px alpha(#007aff, 0.4);
-}
-
-/* Legacy app volume styles (fallback) */
-.app-volume-item {
- margin: 8px 0;
- padding: 8px;
- background-color: alpha(#fff, 0.05);
- border-radius: 6px;
- border: 1px solid alpha(#fff, 0.1);
-}
-
-.app-name {
- font-size: 13px;
- font-weight: 500;
- color: #fff;
- margin-bottom: 5px;
-}
-
-#app-volume-slider {
- background-color: transparent;
- margin-top: 5px;
-}
-
-#app-volume-slider trough {
- min-width: 20px;
- border-radius: 99px;
- background-color: alpha(#666, 0.5);
- border: 1px solid alpha(#444, 0.3);
-}
-
-#app-volume-slider highlight {
- background: alpha(#0078d4, 0.8);
- border-radius: 99px;
-}
-
-#app-volume-slider slider {
- background-color: #fff;
- padding: 2px;
- min-width: 4px;
- min-height: 18px;
- border-radius: 20px;
-}
-
-/* Circle button for per-app volume */
-#per-app-volume-button {
- background-color: alpha(#fff, 0.15);
- border: 1px solid alpha(#999, 0.6);
- /* min-width: 33px; */
- /* min-height: 30px; */
- border-radius: 50%;
-}
-
-#per-app-volume-button:hover {
- background-color: alpha(#fff, 0.25);
- /* border-color: #007aff; */
- box-shadow: 0 4px 12px alpha(#000, 0.2);
-}
-
-#per-app-volume-button:active {
- background-color: alpha(#fff, 0.2);
- box-shadow: 0 1px 3px alpha(#000, 0.3);
-}
-
-#per-app-volume-icon {
- background-color: alpha(#fff, 0.15);
-
- border: 2px solid alpha(#999, 0.6);
-
- border-radius: 50%;
-
- min-width: 30px;
-}
-
-/* Apple-style seek bar for expanded player */
-#apple-seek-bar {
- background-color: transparent;
- margin-top: 8px;
- margin-bottom: 4px;
- min-height: 10px;
-}
-
-#apple-seek-bar trough {
- min-width: 28px;
- min-height: 3px;
- border-radius: 1.5px;
- background-color: alpha(#fff, 0.3);
- border: none;
- box-shadow: inset 0 1px 1px alpha(#000, 0.1);
-}
-
-#apple-seek-bar highlight {
- background: #007aff;
- border-radius: 1.5px;
- border: none;
- box-shadow: 0 0 2px alpha(#007aff, 0.4);
-}
-
-#apple-seek-bar slider {
- background: #ffffff;
- border: 1px solid alpha(#007aff, 0.8);
- border-radius: 50%;
- min-width: 12px;
- min-height: 12px;
- margin: -4.5px;
- box-shadow:
- 0 1px 3px alpha(#000, 0.2),
- 0 0 0 1px alpha(#fff, 0.8);
- transition: all 0.1s ease;
-}
-
-#apple-seek-bar slider:hover {
- background: #ffffff;
- border-color: #007aff;
- min-width: 14px;
- min-height: 14px;
- margin: -5.5px;
- box-shadow:
- 0 2px 6px alpha(#000, 0.25),
- 0 0 0 2px alpha(#007aff, 0.3);
-}
-
-#apple-seek-bar slider:active {
- background: #f8f9fa;
- min-width: 13px;
- min-height: 13px;
- margin: -5px;
- box-shadow:
- 0 1px 3px alpha(#000, 0.3),
- 0 0 0 2px alpha(#007aff, 0.5);
-}
-
-/* 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 */
- /* margin: 10px; */
- padding: 10px;
- /* background-color: alpha(#000, 0.4); */
-}
-
-#macos-main-section {
- margin-left: 5px;
-}
-
-#macos-album-cover-image image {
- min-width: 70px;
- min-height: 70px;
- border-radius: 9px;
-}
-#macos-album-image {
- min-width: 70px;
- min-height: 70px;
- border-radius: 9px;
- background-position: center;
- background-size: cover;
- box-shadow: 0 2px 8px alpha(#000, 0.3);
-}
-
-#macos-album-image-no {
- min-width: 90px;
- min-height: 90px;
- border-radius: 9px;
- margin-left: 12px;
- background-position: center;
- background-size: cover;
- box-shadow: 0 2px 8px alpha(#000, 0.3);
-}
-#macos-album-image image {
- min-width: 70px;
- min-height: 70px;
- border-radius: 9px;
- box-shadow: 0 2px 8px alpha(#000, 0.3);
-}
-
-/* Track info container that expands to fill available space */
-#macos-track-info {
- margin-top: 5px;
- margin-left: 15px;
- margin-right: 15px;
-}
-
-#macos-player-title {
- font-weight: 600;
- font-size: 16px;
- color: #ffffff;
- margin-bottom: 2px;
-}
-
-#macos-player-artist {
- font-weight: 400;
- margin-top: -2px;
- font-size: 13px;
- color: alpha(#999, 0.8);
-}
-
-#macos-player-album {
- font-weight: 400;
- margin-top: -4px;
- font-size: 13px;
- color: alpha(#999, 0.8);
- margin-bottom: 8px;
-}
-/* macOS seek bar styling */
-#macos-seek-bar {
- background-color: transparent;
- margin: 8px 0 4px 0;
- min-height: 10px;
-}
-
-#macos-seek-bar trough {
- min-width: 200px;
-
- min-height: 2px;
- border-radius: 2px;
- background-color: alpha(#fff, 0.25);
- border: none;
-}
-
-#macos-seek-bar highlight {
- background: #ffffff;
- border-radius: 2px;
- border: none;
-}
-
-#macos-seek-bar slider {
- background: #ffffff;
- border: none;
- border-radius: 20%;
- min-width: 1px;
- min-height: 8px;
- margin: -3px;
- box-shadow: 0 1px 3px alpha(#000, 0.3);
- transition: all 0.1s ease;
-}
-
-#macos-seek-bar slider:hover {
- min-width: 4px;
- min-height: 14px;
- margin: -5px;
- box-shadow: 0 2px 6px alpha(#000, 0.4);
-}
-
-#macos-position-label,
-#macos-length-label {
- font-size: 11px;
- color: alpha(#999, 0.9);
- font-weight: 400;
-}
-
-/* macOS control buttons - more compact */
-#macos-button-box {
- margin-top: -1px; /* Reduced top margin */
-}
-
-#macos-control-button {
- background: transparent;
- border: none;
- border-radius: 50%;
- min-width: 28px; /* Slightly smaller */
- opacity: 0.7;
- min-height: 28px;
- padding: 5px; /* Reduced padding */
- color: #ffffff;
- transition: all 0.15s ease;
-}
-
-#macos-control-button:hover {
- opacity: 1;
-}
-
-#macos-play-button {
- background: transparent;
- border: none;
- border-radius: 50%;
- min-width: 36px; /* Slightly smaller */
- opacity: 0.7;
- min-height: 36px;
- padding: 7px; /* Reduced padding */
- color: #ffffff;
- transition: all 0.15s ease;
-}
-
-#macos-play-button:hover {
- opacity: 1;
-}
-
-/* macOS player switcher dots - compact horizontal layout */
-#macos-stack-buttons-box {
- margin: 2px 5px; /* Minimal margins */
-}
-
-.macos-switcher-dot {
- background: alpha(#fff, 0.3);
- /* border: none; */
- border-radius: 50%;
- min-width: 12px; /* Smaller dots */
- min-height: 12px;
- margin: 2px; /* Reduced margins */
- /* transition: all 0.2s ease; */
-}
-
-.macos-switcher-dot:hover {
- background: alpha(#fff, 0.5);
-}
-
-.macos-switcher-dot.active {
- background: #ffffff;
- box-shadow: 0 0 0 1px alpha(#fff, 0.3); /* Smaller glow */
-}
-
-/* Per app-volume-item */
-
-.compact-app-volume-item {
- padding-bottom: 5px;
- 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;
- margin-top: 8px;
-}
-
-#compact-app-volume-slider scale {
- margin-right: 8px;
- padding-right: 8px;
-}
-
-#expanded-seek-bar {
- background-color: transparent;
- margin: 8px 0 4px 0;
- min-height: 10px;
-}
-
-#expanded-seek-bar trough {
- min-width: 200px;
-
- min-height: 2px;
- border-radius: 2px;
- background-color: alpha(#fff, 0.25);
- border: none;
-}
-
-#expanded-seek-bar highlight {
- background: #ffffff;
- border-radius: 2px;
- border: none;
-}
-
-#expanded-seek-bar slider {
- background: #ffffff;
- border: none;
- border-radius: 20%;
- min-width: 1px;
- min-height: 10px;
- margin: -3px;
- box-shadow: 0 1px 3px alpha(#000, 0.3);
- transition: all 0.1s ease;
-}
-
-#expanded-seek-bar slider:hover {
- min-width: 4px;
- min-height: 14px;
- margin: -5px;
- box-shadow: 0 2px 6px alpha(#000, 0.4);
-}
-
-/* macOS-style WiFi interface */
-#wifi-connections,
-#bluetooth-connections {
- /* background-color: rgba(255, 255, 255, 0.08); */
- background-color: alpha(#fff, 0.05);
- box-shadow: inset 0 0 0 1px alpha(#aaa, 0.4);
- border: 1px solid alpha(#111, 0.4);
- border-radius: 8px;
- /* padding: 0.5rem; */
- /* border-radius: 12px; */
- padding: 10px;
-}
-
-/* WiFi network slots - macOS style */
-#wifi-network-slot button {
- border-radius: 8px;
- padding: 4px 4px;
- /* min-width: 280px; */
- margin: 2px 0;
-}
-
-#wifi-network-slot button:hover {
- background-color: rgba(255, 255, 255, 0.1);
-}
-
-/* WiFi-specific device icon styles */
-#wifi-connections #device-icon {
- background-color: rgba(255, 255, 255, 0.1);
-}
-
-#wifi-connections #device-icon.connected {
- background-color: #007aff;
- color: #ffffff;
-}
-
-#wifi-network-name {
- color: #ffffff;
- font-size: 14px;
- font-weight: 500;
-}
-
-#wifi-lock-icon {
- color: rgba(255, 255, 255, 0.6);
- margin-left: 8px;
-}
-
-/* Other Networks and Settings buttons */
-#wifi-other-button {
- background: transparent;
- border: none;
- padding: 8px 6px;
- margin-bottom: -12px;
- margin-top: -6px;
- border-radius: 8px;
- /* transition: background-color 0.2s ease; */
-}
-
-#wifi-other-button:hover {
- background-color: rgba(255, 255, 255, 0.1);
-}
-#wifi-other-button:last-child {
- margin-bottom: 3px;
-}
-
-#device-button {
- /* background-color: #000; */
- border-radius: 8px;
-}
-.device-slot:hover {
- background-color: rgba(255, 255, 255, 0.05);
-}
-
-.button-hovered {
- background-color: rgba(255, 255, 255, 0.1);
-}
-
-#wifi-other-device label,
-#wifi-other-button label {
- font-size: 12px;
- font-weight: bold;
-}
-#app-volume-header {
- font-size: 16px;
- padding-left: 5px;
- margin-top: -2px;
- font-weight: bold;
- color: #ffffff;
-}
-
-.wifi-icon-box {
- background-color: #aaaaaa;
- border-radius: 50%;
- padding: 2px;
- /* margin-right: 8px; */
-}
-.wifi-icon-box-connected {
- background-color: #007aff;
- border-radius: 50%;
- padding: 2px;
- /* margin-right: 8px; */
-}
-
-/* #flight-icon { */
-/* margin-left: -10px; */
-/* } */
-
-.title-widget {
- /* margin-top: -14px; */
- /* margin-left: 5px; */
- font-size: 13px;
- font-family: "SF Pro Rounded";
- font-weight: bold;
-}
-.status-label {
- font-size: 11px;
- font-weight: 500;
- color: #aaa;
- /* margin-top: -28px; */
- /* margin-left: 5px; */
- font-family: "SF Pro Rounded";
- /* font-weight: bold; */
- /* margin-bottom: 2px; */
-}
-
-#app-control-box {
- min-width: 100px;
-}
-#caffeine-widget,
-#flight-widget {
- padding: 0px 0 5px 0;
- min-width: 50px;
-}
diff --git a/styles/dock.css b/styles/dock.css
deleted file mode 100644
index 24eb0f7..0000000
--- a/styles/dock.css
+++ /dev/null
@@ -1,98 +0,0 @@
-#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);
-}
-
-#dock.shown {
- 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);
-}
-
-#dock_item.shown:hover #dock_item_main_container {
- margin-top: -12px;
-}
-
-#dock_item.shown.semi_hovered #dock_item_main_container {
- margin-top: -7px;
-}
-/**/
-/* #dock_item.shown.semi_hovered #dock_item_indicator { */
-/* margin-top: 11px; */
-/* } */
-
-/* #dock_item.shown.activated:hover #dock_item_main_container { */
-/* margin-top: -12px; */
-/* } */
-/**/
-/* #dock_item.shown.activated:hover #dock_item_indicator { */
-/* margin-top: 16px; */
-/* } */
-/**/
-/* #dock_item.shown.activated #dock_item_main_container { */
-/* margin-top: -4px; */
-/* } */
-
-#dock_item.shown.activated #dock_item_icon {
- opacity: 1;
- /* background-color: #01458e; */
-}
-
-/* #dock_item.shown.activated #dock_item_indicator { */
-/* min-width: 4px; */
-/* margin-top: 8px; */
-/* background-color: #aac7ff; */
-/* } */
-
-#dock_item_icon {
- border-radius: 13px;
- opacity: 0.7;
- transition: all 0.2s cubic-bezier(0.25, 0.1, 0.25, 1);
-}
-
-/* #dock_item_indicator { */
-/* transition: all 0.15s ease-out; */
-/* min-height: 4px; */
-/* border-radius: 4px; */
-/* } */
-
-#dock_item {
- transition: margin 0.25s cubic-bezier(0.25, 0.1, 0.25, 1);
-}
-
-#dock_item.shown {
- padding-left: 4px;
- opacity: 1;
-}
-
-#dock_item.shown:first-child {
- 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;
-}
-
-.dock_separator.hidden {
- min-width: 0px;
- background-color: transparent;
- margin: 0px;
-}
-
-/* #dock_item.shown:not(.activated) { */
-/* min-width: 0px; */
-/* min-height: 0px; */
-/* margin-top: 0px; */
-/* background-color: transparent; */
-/* } */
diff --git a/styles/dropdown.css b/styles/dropdown.css
deleted file mode 100644
index 2f036ad..0000000
--- a/styles/dropdown.css
+++ /dev/null
@@ -1,61 +0,0 @@
-#dropdown-menu {
- background-color: transparent;
-
- border-radius: 0.75rem;
- margin: 0;
-}
-
-#dropdown-options {
- background-color: alpha(#000, 0.3);
- box-shadow: inset 0 0 0 1px alpha(#aaa, 0.4);
- border: 1px solid alpha(#111, 0.4);
- border-radius: 0.75rem;
- padding: 0.5rem;
-}
-
-#dropdown-option {
- background-color: transparent;
- border-radius: 5px;
- padding: 0px 10px;
- margin: 1px 2px;
- transition: all 0ms ease-in-out;
-}
-
-#dropdown-option:hover {
- transition: all 20ms ease-in-out;
- color: #222;
- background-color: alpha(#2369ff, 1);
-}
-
-#dropdown-option-label {
- font-size: 13px;
- margin: 0px;
- color: #fff;
- font-weight: 400;
-}
-
-#dropdown-option-label:first-child {
- padding-right: 5rem;
-}
-
-#dropdown-option:hover #dropdown-option-keybind {
- color: #fff;
-}
-
-#dropdown-option-keybind {
- font-weight: 500;
- color: #aaa;
-}
-
-/* Divider */
-
-#dropdown-divider-box {
- background-color: transparent;
- border-radius: 5px;
- padding: 0px 10px;
- margin: 2px;
-}
-#dropdown-divider {
- border-bottom: 1px solid alpha(#aaa, 0.3);
- margin: 2px 0;
-}
diff --git a/styles/launcher.css b/styles/launcher.css
deleted file mode 100644
index 24382a4..0000000
--- 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/lock.css b/styles/lock.css
deleted file mode 100644
index 00fe279..0000000
--- a/styles/lock.css
+++ /dev/null
@@ -1,65 +0,0 @@
-#lockscreen-bg {
- background-size: cover;
-}
-#indicator-box {
- margin-right: 10px;
- margin-top: 4px;
-}
-#container-box {
- min-height: 50px;
- min-width: 250px;
- transition: all 0.7s ease-in-out;
-}
-#password-entry {
- background-color: alpha(#fff, 0.4);
- border-radius: 20px;
- color: white;
- min-width: 220px;
- min-height: 40px;
- padding-right: 5px;
- padding-left: 5px;
- font-size: 14px;
- font-weight: 500;
- transition: all 0.7s ease-in-out;
-}
-#profile-box {
- margin-bottom: 10px;
-}
-#username {
- font-size: 21px;
- font-weight: bold;
- margin-top: 5px;
- transition: all 0.7s ease-in-out;
-}
-#lock-date label {
- background-color: transparent;
- color: alpha(#fff, 0.6);
- border: none;
- font-size: 30px;
- margin-top: 80px;
- font-weight: bold;
-}
-#lock-clock label {
- background-color: transparent;
- color: alpha(#fff, 0.6);
- border: none;
- font-size: 120px;
- margin-top: -20px;
- font-weight: bold;
-}
-
-#face-icon image {
- margin-bottom: 10px;
-}
-#face-icon {
- padding-bottom: 10px;
-}
-
-#unlock-box {
- margin-bottom: 40px;
-}
-#unlock-text {
- font-size: 15px;
- color: alpha(#fff, 0.6);
- font-weight: bold;
-}
diff --git a/styles/notification-center.css b/styles/notification-center.css
deleted file mode 100644
index 0c041d3..0000000
--- a/styles/notification-center.css
+++ /dev/null
@@ -1,297 +0,0 @@
-#noti-center-box {
- background-color: transparent;
-
- /* border: 1px solid alpha(#111, 0.3); */
- border-radius: 20px;
- margin-right: 10px;
- /* padding: 20px; */
- /* margin: 6px; */
-}
-
-#noti-clear-button {
- /* min-width: 10px; */
- background-color: alpha(#1c2328, 0.05);
- min-height: 25px;
- min-width: 70px;
- border: 1px solid #525155;
- border-radius: 15px;
- margin-top: 10px;
-}
-
-#notif-clear-button label {
- font-size: 15px;
-}
-
-#noti-clear-button:hover {
- background-color: alpha(#fff, 0.09);
-}
-
-/* Expandable notification group styles */
-#notification-group {
- margin: 4px 0;
-}
-
-/* Single notification (no stacking) */
-#single-notification-content {
- background-color: alpha(#1c2328, 0.05);
- border: 1.5px solid #525155;
- border-radius: 12px;
- padding: 12px;
- transition: all 0.2s ease;
-}
-
-#single-notification-content:hover {
- background-color: alpha(#999, 0.9);
- border: 1.5px solid #525155;
-}
-
-/* Stacked notification container */
-#notification-stack-container {
- background-color: transparent;
- margin: 8px 0;
-}
-
-/* Bottom shadow layer (deepest) - offset to show stacking from below */
-#stack-shadow-bottom {
- background-color: alpha(#1c2328, 0.05);
- border-top: 1px solid #525155;
- border-radius: 12px;
- opacity: 1;
- min-height: 16px;
- margin-left: 20px;
- margin-right: -4px;
- margin-bottom: -10px;
-}
-
-/* Middle shadow layer */
-#stack-shadow-middle {
- background-color: alpha(#1c2328, 0.05);
-
- border-top: 1px solid #525155;
- border-radius: 12px;
- opacity: 1;
- min-height: 20px;
- margin-left: 10px;
- margin-right: -2px;
- margin-bottom: -14px;
-}
-
-/* Main notification content (top layer) */
-#stack-main-notification {
- background-color: alpha(#1c2328, 0.05);
-
- border: 1.2px solid #525155;
- border-radius: 12px;
- padding: 12px;
- transition: all 0.2s ease;
-}
-
-/* Hover effects for stacked notifications */
-#notification-stack-container:hover #stack-main-notification {
- background-color: alpha(#525155, 0.1);
- border: 1px solid #525155;
-}
-
-#notification-stack-container:hover #stack-shadow-middle {
- opacity: 0.7;
-}
-
-#notification-stack-container:hover #stack-shadow-bottom {
- opacity: 0.5;
-}
-
-/* Collapsed notification state */
-#notification-content-collapsed {
- background-color: alpha(#000, 0.05);
- border: 1px solid #525155;
- border-radius: 12px;
- padding: 12px;
- margin: 2px 0;
- transition: all 0.2s ease;
-}
-
-#notification-content-collapsed:hover {
- background-color: alpha(#525155, 0.1);
- border: 1px solid #525155;
-}
-
-/* Notification count badge */
-#notification-count-label {
- background-color: alpha(#fff, 0.2);
- color: rgba(255, 255, 255, 0.8);
- border-radius: 10px;
- padding: 2px 6px;
- font-size: 10px;
- font-weight: 500;
- min-width: 16px;
-}
-
-/* Expanded notification group header */
-#notification-group-header {
- background-color: alpha(#000, 0.05);
- border: 1px solid #525155;
- border-radius: 12px 12px 0 0;
- padding: 8px 12px;
- margin: 2px 0 0 0;
-}
-
-#notification-group-title {
- color: #ffffff;
- font-weight: bold;
- font-size: 14px;
-}
-
-#notification-show-less {
- background-color: transparent;
- color: rgba(255, 255, 255, 0.7);
- border: 1px solid alpha(#fff, 0.2);
- border-radius: 6px;
- padding: 2px 8px;
- font-size: 11px;
- margin-right: 8px;
-}
-
-#notification-show-less:hover {
- background-color: alpha(#999, 0.7);
- color: rgba(255, 255, 255, 0.9);
-}
-
-#notification-close-all {
- background-color: transparent;
- color: rgba(255, 255, 255, 0.7);
- border: 1px solid alpha(#fff, 0.2);
- border-radius: 6px;
- padding: 2px 8px;
- font-size: 12px;
- min-width: 24px;
-}
-
-#notification-close-all:hover {
- background-color: alpha(#ff5555, 0.2);
- color: rgba(255, 255, 255, 0.9);
- border-color: alpha(#ff5555, 0.4);
-}
-
-/* Expanded notification list */
-#notification-group-expanded {
- border: 1px solid #525155;
- border-top: none;
- border-radius: 0 0 12px 12px;
- /* background-color: alpha(#000, 0.02); */
-}
-
-/* Stacked notification group styles */
-.stacked-notification-group {
- margin: 4px 0;
-}
-
-.stacked-notification-container {
-}
-
-.stacked-notification-item {
- background-color: alpha(#000, 0.05);
- border: 1px solid #525155;
- border-radius: 12px;
- transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
-}
-
-#notif-clear-btn {
- border-radius: 10px;
- font-size: 12px;
- padding: 2px;
-}
-
-#notif-clear-btn:hover {
- background-color: var(--surface-bright);
-}
-
-#stacked-notification-item:hover {
- background-color: alpha(#525155, 0.1);
- border: 1px solid #525155;
-}
-
-/* Stack count indicator */
-#stack-count-indicator {
- font-size: 10px;
- color: rgba(255, 255, 255, 0.7);
- margin: 2px 8px;
- font-weight: 500;
-}
-
-/* Expanded view styles - notifications use their default styling */
-#expanded-notification-list {
- margin-top: 8px;
-}
-
-/* Make expanded notifications clickable with subtle hover effect */
-#expanded-notification-list:hover {
- background-color: alpha(#111, 0.9);
- border-radius: 8px;
- transition: background-color 0.2s ease;
-}
-
-#notification-group-title {
- font-size: 19px;
- margin: 10px;
- font-weight: bold;
-}
-#notification-show-less {
- background-color: alpha(#1c2328, 0.05);
- font-size: 12px;
- margin: 10px;
- border-radius: 15px;
-}
-
-#notification-close-all {
- margin: 10px 10px 10px 0;
- background-color: alpha(#1c2328, 0.05);
- border-radius: 15px;
-}
-
-#notif-close-button {
- background-color: transparent;
-}
-
-#notification-group-expanded {
- background-color: transparent;
- border: none;
-}
-
-#notification-content {
- background-color: transparent;
-}
-#notification-count-label {
- background-color: transparent;
- color: white;
- font-size: 13px;
-}
-#notification-centre-notifs {
- background-color: alpha(#1c2328, 0.05);
- min-height: 40px;
- padding: 10px;
- border: 1px solid #525155;
- border-radius: 12px;
-}
-
-#notification-centre-notifs:last-child {
- margin-bottom: 10px;
-}
-
-#notification-close-header {
- margin-right: 10px;
-}
-#notification-close,
-#notification-close-header {
- min-width: 23px;
- min-height: 23px;
- border-radius: 50%;
-}
-#notification-close:hover {
- background-color: alpha(#666, 0.8);
- min-width: 23px;
- min-height: 23px;
- border-radius: 50%;
-}
-#notification-close-summery:hover .notification-close-header button {
- background-color: transparent;
-}
diff --git a/styles/notification.css b/styles/notification.css
deleted file mode 100644
index 35a7db0..0000000
--- a/styles/notification.css
+++ /dev/null
@@ -1,84 +0,0 @@
-#notification {
- padding: 10px;
- background-color: alpha(#fff, 0.03);
- border-radius: 8px;
- margin-top: 10px;
- margin-right: 10px;
- /* margin: 5px; */
-}
-
-#notification-close-button {
- background-color: alpha(#999, 0.3);
- border-radius: 8px;
-}
-#notification-close-button:hover {
- background-color: alpha(#2369ff, 0.9);
-}
-
-#notification-action-buttons button {
- background-color: alpha(#999, 0.3);
- padding: 4px;
- border-radius: 7px;
- transition: background-color 0.1s ease;
-}
-
-#notification-action-buttons button:hover {
- background-color: alpha(#2369ff, 0.9);
-}
-
-#notification-action-buttons button:active {
- background-color: var(--primary);
-}
-
-#notification-action-buttons button:active #button-label {
- color: var(--shadow);
-}
-
-#notification-image image {
- border-radius: 16px;
- color: var(--on-surface);
-}
-
-#notification-summary,
-#button-label {
-}
-
-#notification-summary {
- font-weight: bold;
- font-size: 16px;
- /* color: var(--primary); */
-}
-
-#notification-app-name {
- color: var(--outline);
- font-weight: bold;
-}
-
-#action-button {
- margin-top: 8px;
-}
-
-#notif-close-button {
- background-color: var(--surface);
- border-radius: 16px;
- padding: 4px;
- transition: background-color 0.1s ease;
-}
-
-#notif-close-button:hover,
-#notif-close-button:focus {
- background-color: var(--surface-bright);
-}
-
-#notif-close-button:active {
- background-color: var(--red-dim);
-}
-
-#notif-close-label {
- color: var(--red-dim);
- font-size: 20px;
-}
-
-#notif-close-button:active #notif-close-label {
- color: var(--shadow);
-}
diff --git a/styles/osd.css b/styles/osd.css
deleted file mode 100644
index c4c2082..0000000
--- 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 218c358..0000000
--- 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/player.css b/styles/player.css
deleted file mode 100644
index 1a9b651..0000000
--- a/styles/player.css
+++ /dev/null
@@ -1,178 +0,0 @@
-#player-stack-button {
- border-radius: 5px;
- min-width: 7px;
- min-height: 7px;
- margin: 10px 3px;
-}
-
-#button-box-c {
- /* margin-left: -10px; */
-
- padding-right: 15px;
-}
-
-#player-stack-button:hover {
- background-color: #646464;
- box-shadow: inset rgba(255, 255, 255, 0.5) 0 0 10px;
-}
-
-#player-stack-button.active {
- border-radius: 5px;
- background-color: rgb(221.25, 221.25, 223.65);
-}
-
-#outer-player-box {
- min-height: 110px;
- min-width: 320px;
- border-radius: 10px;
- margin: 0 6px;
- background-color: alpha(#000, 0.3);
- box-shadow: inset 0 0 0 1px alpha(#aaa, 0.4);
-}
-
-#outer-player-box-c {
- min-height: 55px;
- min-width: 245px;
- border-radius: 8px;
- /* margin: 0 6px; */
- /* background-color: alpha(#000, 0.3); */
- /* box-shadow: inset 0 0 0 1px alpha(#aaa, 0.4); */
-}
-#outer-no-player-box-c {
- min-height: 55px;
- min-width: 310px;
- border-radius: 8px;
- /* margin: 0 6px; */
- /* background-color: alpha(#000, 0.3); */
- /* box-shadow: inset 0 0 0 1px alpha(#aaa, 0.4); */
-}
-#box-c {
- background-color: alpha(#000, 0.08);
- box-shadow: inset 0 0 0 0.5px alpha(#aaa, 0.4);
- border: 1px solid alpha(#111, 0.4);
- border-radius: 12px;
- margin-top: 0.5rem;
- margin-left: 4px;
- margin-right: 4px;
- margin-bottom: 0.5rem;
-
- min-height: 50px;
- min-width: 330px;
- /* border-radius: 8px; */
- /* margin: 0 6px 10px; */
- /* border: 0.5px solid alpha(#000, 0.4); */
- /* background-color: alpha(#999, 0.1); */
- /* box-shadow: inset 0 0 0 1px alpha(#aaa, 0.4); */
-}
-#player-box:disabled highlight,
-#player-box:disabled progress {
- background-color: rgba(205, 214, 244, 0.6);
- background-image: none;
-}
-
-#player-info-box-c {
- margin-left: 10px;
-}
-#player-info-box {
- margin-left: 10px;
-}
-
-#player-title-c {
- /* margin-top: 18px; */
- font-weight: bold;
- font-size: 14px;
-}
-#player-title {
- margin-top: 18px;
- font-weight: 700;
- font-size: 16px;
-}
-#player-title-no {
- font-weight: 700;
- margin-left: 120px;
- font-size: 16px;
-}
-#player-app-icon {
- margin-bottom: 10px;
-}
-
-#player-artist-c {
- margin-top: -5px;
- color: #999;
- font-size: 12px;
-}
-#player-artist,
-#player-album {
- font-weight: 500;
- font-size: 12px;
-}
-
-#player-controls {
- margin-top: 5px;
- margin-bottom: 15px;
-}
-
-.player-icon {
- font-size: 16px;
-}
-
-.player-button {
- padding: 1px;
-}
-
-#player-box #player-controls #button-box .player-button:disabled {
- color: #5f5f5f;
-}
-
-#player-button {
- opacity: 0.5;
-}
-#player-button:hover {
- opacity: 0.9;
-}
-
-#btn svg {
- margin: 0 2px;
-}
-.album-image-c {
- min-width: 50px;
- min-height: 50px;
- background-position: center;
- margin-top: 10px;
- margin-bottom: 10px;
- background-size: cover;
- margin-left: 10px;
- border-radius: 5px;
-}
-.album-image {
- min-width: 70px;
- min-height: 70px;
- background-position: center;
- background-size: cover;
- margin-left: 12px;
- border-radius: 9px;
-}
-
-#seek-bar slider {
- background-image: none;
- background-color: transparent;
- padding: 0px;
-}
-
-#seek-bar scale {
- background-color: transparent;
- margin-top: 10px;
- border-radius: 10px;
-}
-
-#seek-bar trough {
- min-width: 10px;
- border-radius: 99px;
- background-color: alpha(#666, 0.5);
- border: 1px solid alpha(#444, 0.3);
-}
-
-#seek-bar highlight {
- background: alpha(#fff, 0.8);
- border-radius: 99px;
-}
diff --git a/styles/switcher.css b/styles/switcher.css
deleted file mode 100644
index 5302281..0000000
--- 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 a65ddf4..0000000
--- 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/styles/tray.css b/styles/tray.css
deleted file mode 100644
index d10b0ef..0000000
--- a/styles/tray.css
+++ /dev/null
@@ -1,50 +0,0 @@
-tooltip {
- border: #474747 solid 1px;
- border-radius: 0.75rem;
- background-color: alpha(#fff, 0.05);
-}
-
-@keyframes tooltipShow {
- from {
- opacity: 0;
- }
-
- to {
- opacity: 1;
- }
-}
-
-tooltip > * {
- padding: 3px 10px;
- border-radius: 0.75rem;
-}
-
-menu {
- border: #474747 solid 1px;
- border-radius: 0.75rem;
- transition: all 100ms ease-in-out;
- background-color: alpha(#fff, 0.05);
-}
-
-menu > menuitem {
- padding: 5px 10px;
- margin: 1px 2px;
- font-size: 13px;
- border-bottom: #474747 solid 1px;
-}
-
-menu > menuitem:last-child {
- border-bottom: none;
-}
-menu > menuitem:hover {
- transition: all 20ms ease-in-out;
- font-size: 13px;
- border-radius: 4px;
- color: #222;
- background-color: alpha(#2369ff, 1);
-}
-
-menu > menuitem:hover > label {
- color: #fff;
- font-weight: 400;
-}
diff --git a/styles/widgets.css b/styles/widgets.css
deleted file mode 100644
index 8656200..0000000
--- a/styles/widgets.css
+++ /dev/null
@@ -1,218 +0,0 @@
-/* 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;
-}
-
-#weather-widget label {
- margin-left: 12px;
-}
-
-#city {
- margin-top: 10px;
- color: var(--blue-dim);
- font-weight: 600;
- font-size: 20px;
-}
-#temperature {
- font-size: 50px;
- font-weight: 400;
-}
-
-#condition-emoji {
- margin-top: 5px;
- font-size: 16px;
-}
-
-#condition {
- font-size: 15px;
- font-weight: 600;
-}
-#feels-like {
- font-weight: 600;
- font-size: 15px;
-}
-/* # */
-
-/* Calendar Widget */
-#calendar-box-widget {
- background: linear-gradient(to bottom, #202020, #141414);
- margin: 5px 20px 20px 10px;
- min-width: 170px;
- min-height: 170px;
- border-radius: 16px;
-}
-
-#calendar-widget {
- margin: 5px 10px 0px 10px;
-}
-
-#calendar-month {
- font-size: 16px;
- font-weight: 600;
- color: var(--blue-dim);
- margin-bottom: 6px;
-}
-
-#calendar-days-header {
- margin-bottom: 4px;
-}
-
-#calendar-day-header {
- font-size: 12px;
- font-weight: 600;
- color: #ffffff;
- min-width: 18px;
- min-height: 10px;
-}
-
-#calendar-day-header-weekend {
- font-size: 12px;
- font-weight: 600;
- color: #8e8d93;
- min-width: 18px;
- min-height: 10px;
-}
-
-#calendar-day {
- font-size: 12px;
- margin: 1.4px 0px 1.4px 0px;
- font-weight: 500;
- color: #ffffff;
- min-width: 18px;
- min-height: 10px;
-}
-
-#calendar-day-weekend {
- font-size: 12px;
- font-weight: 500;
- color: #8e8d93;
- min-width: 18px;
- min-height: 16px;
-}
-
-#calendar-day-today {
- font-size: 12px;
- font-weight: 700;
- color: #191919;
- background-color: var(--blue-dim);
- border-radius: 50%;
- min-width: 18px;
- min-height: 16px;
- padding: 2px;
-}
-
-#calendar-day-empty {
- min-width: 18px;
- min-height: 16px;
-}
-
-#calendar-grid {
- margin-top: 2px;
-}
-
-/* Date Widget CSS */
-#date-widget label {
- margin-top: 10px;
-}
-#day label,
-#month label {
- color: var(--foreground);
- padding: 5px;
- margin-top: 10px;
- font-size: 30px;
- color: alpha(#fff, 0.9);
- font-weight: 600;
-}
-
-#date label {
- color: alpha(#fff, 0.9);
-
- margin-top: -10px;
- font-size: 90px;
- font-weight: 600;
-}
-#day label {
- color: alpha(var(--blue-dim), 0.9);
-}
-
-#date-container {
- background: linear-gradient(to bottom, #202020, #141414);
- margin: 5px 10px 20px 5px;
- min-width: 170px;
- min-height: 170px;
- border-radius: 16px;
-}
-
-/* System Info Widget CSS */
-#info-box-widget {
- background: linear-gradient(to bottom, #202020, #141414);
- margin: 5px 5px 5px 5px;
- min-width: 170px;
- 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);
-}
-
-/* Color indicators for memory usage */
-#used-color-indicator {
- color: var(--blue-dim);
- font-size: 10px;
- font-weight: 900;
-}
-
-#free-color-indicator {
- color: #7c7c7c;
- font-size: 10px;
- font-weight: 900;
-}
-
-#temp-color-indicator {
- color: var(--blue-dim);
- font-size: 10px;
- font-weight: 900;
-}
-
-/* Info text styling */
-#info-text {
- font-size: 13px;
- font-weight: 500;
- color: #8e8e8e;
- min-width: 32px;
-}
-
-#info-value {
- font-size: 12px;
- font-weight: 600;
- color: #ffffff;
-}
-
-#info-container {
- margin-left: -8px;
- margin-top: 5px;
-}
-
-#info {
- margin-left: -13px;
- margin-top: 5px;
- font-size: 14px;
- font-weight: 600;
-}
-#component-name {
- font-size: 10px;
-}
diff --git a/utils/__init__.py b/utils/__init__.py
deleted file mode 100644
index 3f03f12..0000000
--- 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 7dd063d..0000000
--- 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/app_name_resolver.py b/utils/app_name_resolver.py
deleted file mode 100644
index 13b76df..0000000
--- a/utils/app_name_resolver.py
+++ /dev/null
@@ -1,93 +0,0 @@
-import os
-from utils.roam import modus_service
-
-
-class AppName:
- def __init__(self, path="/usr/share/applications"):
- self.files = os.listdir(path)
- self.path = path
-
- def get_app_name(self, wmclass, format_=False):
- desktop_file = ""
- for f in self.files:
- if f.startswith(wmclass + ".desktop"):
- desktop_file = f
-
- desktop_app_name = wmclass
-
- if desktop_file == "":
- return wmclass
- with open(os.path.join(self.path, desktop_file), "r") as f:
- lines = f.readlines()
- for line in lines:
- if line.startswith("Name="):
- desktop_app_name = line.split("=")[1].strip()
- break
- return desktop_app_name
-
- def get_app_exec(self, wmclass, format_=False):
- desktop_file = ""
- for f in self.files:
- if f.startswith(wmclass + ".desktop"):
- desktop_file = f
-
- desktop_app_name = wmclass
-
- if desktop_file == "":
- return wmclass
- with open(os.path.join(self.path, desktop_file), "r") as f:
- lines = f.readlines()
- for line in lines:
- if line.startswith("Exec="):
- desktop_app_name = line.split("=")[1].strip()
- break
- return desktop_app_name
-
- def get_desktop_file(self, wmclass):
- desktop_file = ""
- for f in self.files:
- if f.startswith(wmclass + ".desktop"):
- desktop_file = f
- return desktop_file
-
- def format_app_name(self, title, wmclass, update=False):
- # Handle case when both title and wmclass are empty (no active window)
- if not title and not wmclass:
- name = "Finder"
- else:
- name = wmclass
- if name == "":
- name = title
-
- # Try to get the proper app name from desktop file only if wmclass is not empty
- if wmclass:
- name = self.get_app_name(wmclass=wmclass)
-
- # Smart title formatting (capitalize first letter)
- name = str(name).title()
- if "." in name:
- name = name.split(".")[-1]
-
- if update:
- modus_service.current_active_app_name = name
- return name
-
-
-# Create a global instance for use across modules
-app_name_resolver = AppName()
-
-
-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
- if title == "unknown":
- title = ""
- if wmclass == "unknown":
- wmclass = ""
-
- name = app_name_resolver.format_app_name(title, wmclass, True)
- return name
-
diff --git a/utils/conversion.py b/utils/conversion.py
deleted file mode 100644
index c39b4da..0000000
--- a/utils/conversion.py
+++ /dev/null
@@ -1,620 +0,0 @@
-import threading
-import time
-from concurrent.futures import ThreadPoolExecutor
-from typing import Dict, Optional, Tuple
-
-import requests
-
-
-class CurrencyCache:
- """Thread-safe currency exchange rate cache with background updates."""
-
- def __init__(self):
- # {base_currency: {rates_data, timestamp}}
- self._cache: Dict[str, Dict] = {}
- self._cache_lock = threading.Lock()
- self._cache_ttl = 3600 # 1 hour in seconds
- self._request_timeout = 2 # 2 seconds for faster response
- self._executor = ThreadPoolExecutor(
- max_workers=2, thread_name_prefix="currency"
- )
- self._pending_requests: Dict[str, threading.Event] = {}
-
- def get_rate(self, from_code: str, to_code: str) -> Optional[Tuple[float, float]]:
- """
- Get exchange rate from cache or fetch if needed.
- Returns (rate, cache_age_seconds) or None if unavailable.
- """
- from_lower = from_code.lower()
- to_lower = to_code.lower()
-
- if from_lower == to_lower:
- return 1.0, 0.0
-
- current_time = time.time()
-
- with self._cache_lock:
- # Check if we have fresh cached data
- if from_lower in self._cache:
- cache_entry = self._cache[from_lower]
- cache_age = current_time - cache_entry["timestamp"]
-
- if cache_age < self._cache_ttl and to_lower in cache_entry["rates"]:
- rate = cache_entry["rates"][to_lower]["rate"]
- return rate, cache_age
-
- # Check if we're already fetching this currency
- if from_lower in self._pending_requests:
- # Don't wait, return cached data if available (even if stale)
- if (
- from_lower in self._cache
- and to_lower in self._cache[from_lower]["rates"]
- ):
- cache_entry = self._cache[from_lower]
- cache_age = current_time - cache_entry["timestamp"]
- rate = cache_entry["rates"][to_lower]["rate"]
- return rate, cache_age
- return None
-
- # Start background fetch
- self._pending_requests[from_lower] = threading.Event()
-
- # Submit background task to fetch rates
- self._executor.submit(self._fetch_rates_background, from_lower)
-
- # Return stale cached data if available
- with self._cache_lock:
- if (
- from_lower in self._cache
- and to_lower in self._cache[from_lower]["rates"]
- ):
- cache_entry = self._cache[from_lower]
- cache_age = current_time - cache_entry["timestamp"]
- rate = cache_entry["rates"][to_lower]["rate"]
- return rate, cache_age
-
- return None
-
- 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 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,
- }
-
- except Exception as e:
- print(f"Background currency fetch failed for {from_code}: {e}")
- finally:
- # Mark request as complete
- with self._cache_lock:
- if from_code in self._pending_requests:
- self._pending_requests[from_code].set()
- del self._pending_requests[from_code]
-
- def cleanup(self):
- """Cleanup resources."""
- self._executor.shutdown(wait=False)
- with self._cache_lock:
- self._cache.clear()
- self._pending_requests.clear()
-
-
-# Global currency cache instance
-_currency_cache = CurrencyCache()
-
-
-class Units:
- def __init__(self):
- self.WEIGHT_CHART: dict[str, tuple[float, float]] = {
- "kilogram": (1, 1),
- "kg": (1, 1),
- "tonne": (1000, 0.001),
- "ton": (1000, 0.001),
- "gram": (1e-3, 1e3),
- "g": (1e-3, 1e3),
- "milligram": (1e-6, 1e6),
- "mg": (1e-6, 1e6),
- "metric-ton": (1000, 0.001),
- "metric-tonne": (1000, 0.001),
- "long-ton": (1016.04608, 0.0009842073),
- "short-ton": (907.184, 0.0011023122),
- "pound": (0.453592, 2.2046244202),
- "lb": (0.453592, 2.2046244202),
- "stone": (6.35029, 0.1574731728),
- "st": (6.35029, 0.1574731728),
- "ounce": (0.0283495, 35.273990723),
- "oz": (0.0283495, 35.273990723),
- "carrat": (0.0002, 5000),
- "ct": (0.0002, 5000),
- "atomic-mass-unit": (1.660540199e-27, 6.022136652e26),
- }
-
- self.LENGTH_CHART: dict[str, float] = {
- # meter
- "m": 1,
- "M": 1,
- "meter": 1,
- # kilometer
- "km": 1e3,
- "KM": 1e3,
- "kilometer": 1e3,
- # centimeter
- "cm": 1e-2,
- "CM": 1e-2,
- "centimeter": 1e-2,
- # millimeter
- "mm": 1e-3,
- "MM": 1e-3,
- "millimeter": 1e-3,
- # micrometer
- "um": 1e-6,
- "UM": 1e-6,
- "micrometer": 1e-6,
- # nanometer
- "nm": 1e-9,
- "NM": 1e-9,
- "nanometer": 1e-9,
- # mile
- "mi": 1609.344,
- "MI": 1609.344,
- "mile": 1609.344,
- # yard
- "yd": 0.9144,
- "YD": 0.9144,
- "yard": 0.9144,
- # foot
- "ft": 0.3048,
- "FT": 0.3048,
- "foot": 0.3048,
- "feet": 0.3048,
- # inch
- "in": 0.0254,
- "IN": 0.0254,
- "inch": 0.0254,
- "inches": 0.0254,
- # nautical mile
- "nmi": 1852,
- "NMI": 1852,
- "nautical-mile": 1852,
- }
-
- self.STORAGE_TYPE_CHART: dict[str, float] = {
- "bit": 1,
- "byte": 8,
- "B": 8,
- "kilobyte": 8192,
- "KB": 8192,
- "megabyte": 8388608,
- "MB": 8388608,
- "gigabyte": 8589934592,
- "GB": 8589934592,
- "terabyte": 8796093022208,
- "TB": 8796093022208,
- "petabyte": 9007199254740992,
- "PB": 9007199254740992,
- "exabyte": 9223372036854775808,
- "EB": 9223372036854775808,
- }
-
- self.TEMPERATURE_CHART = {
- "celsius": (lambda v: v + 273.15, lambda v: v - 273.15),
- "c": (lambda v: v + 273.15, lambda v: v - 273.15),
- "fahrenheit": (
- lambda v: (v - 32) * 5 / 9 + 273.15,
- lambda v: (v - 273.15) * 9 / 5 + 32,
- ),
- "f": (
- lambda v: (v - 32) * 5 / 9 + 273.15,
- lambda v: (v - 273.15) * 9 / 5 + 32,
- ),
- "kelvin": (lambda v: v, lambda v: v),
- "k": (lambda v: v, lambda v: v),
- "rankine": (lambda v: v * 5 / 9, lambda v: v * 9 / 5),
- "reaumur": (lambda v: v * 5 / 4 + 273.15, lambda v: (v - 273.15) * 4 / 5),
- }
-
- self.TIME_CHART: dict[str, float] = {
- "second": 1,
- "s": 1,
- "minute": 60,
- "min": 60,
- "m": 60,
- "hour": 3600,
- "h": 3600,
- "milisecond": 1e-3,
- "ms": 1e-3,
- "day": 86400,
- "d": 86400,
- "week": 604800,
- "w": 604800,
- "fortnight": 1209600,
- "month": 2628000, # Approximation (30.44 days)
- "mo": 2628000, # Approximation (30.44 days)
- "year": 31536000, # Approximation (365 days)
- "yr": 31536000, # Approximation (365 days)
- "decade": 315360000, # Approximation (10 years)
- "dec": 315360000, # Approximation (10 years)
- "century": 3153600000, # Approximation (100 years)
- "cent": 3153600000, # Approximation (100 years)
- "millennium": 31536000000, # Approximation (1000 years)
- "millenia": 31536000000, # Approximation (1000 years)
- }
-
- self.LIQUID_VOLUME_CHART: dict[str, float] = {
- "liter": 1,
- "l": 1,
- "milliliter": 1e-3,
- "ml": 1e-3,
- "gallon": 3.78541,
- "quart": 0.946353,
- "pint": 0.473176,
- "fluid-ounce": 0.0295735,
- "fl-oz": 0.0295735,
- "oz": 0.0295735,
- "ounce": 0.0295735,
- "cup": 0.236588,
- "tablespoon": 0.0147868,
- "tbsp": 0.0147868,
- "teaspoon": 0.00492892,
- "tsp": 0.00492892,
- }
-
- self.ANGLE_CHART: dict[str, float] = {
- "degree": 1,
- "deg": 1,
- "radian": 57.2958,
- "rad": 57.2958,
- "gradian": 0.9,
- "gon": 0.9,
- }
-
- self.ENERGY_CHART: dict[str, float] = {
- "joule": 1,
- "j": 1,
- "kilojoule": 1000,
- "kj": 1000,
- "calorie": 4.184,
- "cal": 4.184,
- "kilocalorie": 4184,
- "kcal": 4184,
- "watt-hour": 3600,
- "wh": 3600,
- "kilowatt-hour": 3.6e6,
- "kwh": 3.6e6,
- }
-
- self.SPEED_CHART: dict[str, float] = {
- "mps": 1,
- "kmph": 0.277778,
- "mph": 0.44704,
- "fps": 0.3048,
- "knot": 0.514444,
- }
-
- self.PRESSURE_CHART: dict[str, float] = {
- "pascal": 1,
- "Pa": 1,
- "bar": 100000,
- "atm": 101325,
- "torr": 133.322,
- "mmHg": 133.322,
- "psi": 6894.76,
- }
-
- self.FORCE_CHART: dict[str, float] = {
- "newton": 1,
- "N": 1,
- "kilonewton": 1000,
- "kN": 1000,
- "pound-force": 4.44822,
- "lbf": 4.44822,
- "dyne": 1e-5,
- }
-
- self.POWER_CHART: dict[str, float] = {
- "watt": 1,
- "W": 1,
- "kilowatt": 1000,
- "kW": 1000,
- "horsepower": 745.7,
- "hp": 745.7,
- "megawatt": 1e6,
- "MW": 1e6,
- }
-
- self.VOLTAGE_CHART: dict[str, float] = {
- "volt": 1,
- "V": 1,
- "millivolt": 1e-3,
- "mV": 1e-3,
- "kilovolt": 1000,
- "kV": 1000,
- "megavolt": 1e6,
- "MV": 1e6,
- }
-
- self.CURRENT_CHART: dict[str, float] = {
- "ampere": 1,
- "A": 1,
- "milliampere": 1e-3,
- "mA": 1e-3,
- "microampere": 1e-6,
- "ฮผA": 1e-6,
- }
-
- self.RESISTANCE_CHART: dict[str, float] = {
- "ohm": 1,
- "ฮฉ": 1,
- "kilohm": 1000,
- "kฮฉ": 1000,
- "megohm": 1e6,
- "Mฮฉ": 1e6,
- }
-
- self.CAPACITANCE_CHART: dict[str, float] = {
- "farad": 1,
- "F": 1,
- "millifarad": 1e-3,
- "mF": 1e-3,
- "microfarad": 1e-6,
- "ฮผF": 1e-6,
- "nanofarad": 1e-9,
- "nF": 1e-9,
- }
-
- self.INDUCTANCE_CHART: dict[str, float] = {
- "henry": 1,
- "H": 1,
- "millihenry": 1e-3,
- "mH": 1e-3,
- "microhenry": 1e-6,
- "ฮผH": 1e-6,
- "nanohenry": 1e-9,
- "nH": 1e-9,
- }
-
- self.FREQUENCY_CHART: dict[str, float] = {
- "hertz": 1,
- "Hz": 1,
- "kilohertz": 1e3,
- "kHz": 1e3,
- "megahertz": 1e6,
- "MHz": 1e6,
- "gigahertz": 1e9,
- "GHz": 1e9,
- }
-
- self.LUMINANCE_CHART: dict[str, float] = {
- "candela": 1,
- "cd": 1,
- "lumen": 1,
- "lm": 1,
- "lux": 1,
- "lx": 1,
- }
-
- self.AREA_CHART: dict[str, float] = {
- "square-meter": 1,
- "m2": 1,
- "square-kilometer": 1e6,
- "km2": 1e6,
- "hectare": 1e4,
- "ha": 1e4,
- "are": 1e2,
- "a": 1e2,
- "square-centimeter": 1e-4,
- "cm2": 1e-4,
- "square-millimeter": 1e-6,
- "mm2": 1e-6,
- }
-
- # We no longer use currency_converter here.
-
-
-class Conversion:
- def __init__(self):
- self.units = Units()
- self.currency_cache = _currency_cache
-
- def convert(self, value: float, from_type: str, to_type: str):
- """
- Generalized conversion function that works with all categories,
- including currency via floatrates.com.
- """
- # Collection of all non-currency charts
- charts = {
- "WEIGHT_CHART": self.units.WEIGHT_CHART,
- "LENGTH_CHART": self.units.LENGTH_CHART,
- "TEMPERATURE_CHART": self.units.TEMPERATURE_CHART,
- "TIME_CHART": self.units.TIME_CHART,
- "LIQUID_VOLUME_CHART": self.units.LIQUID_VOLUME_CHART,
- "STORAGE_TYPE_CHART": self.units.STORAGE_TYPE_CHART,
- "ANGLE_CHART": self.units.ANGLE_CHART,
- "ENERGY_CHART": self.units.ENERGY_CHART,
- "SPEED_CHART": self.units.SPEED_CHART,
- "PRESSURE_CHART": self.units.PRESSURE_CHART,
- "FORCE_CHART": self.units.FORCE_CHART,
- "POWER_CHART": self.units.POWER_CHART,
- "VOLTAGE_CHART": self.units.VOLTAGE_CHART,
- "CURRENT_CHART": self.units.CURRENT_CHART,
- "RESISTANCE_CHART": self.units.RESISTANCE_CHART,
- "CAPACITANCE_CHART": self.units.CAPACITANCE_CHART,
- "INDUCTANCE_CHART": self.units.INDUCTANCE_CHART,
- "FREQUENCY_CHART": self.units.FREQUENCY_CHART,
- "LUMINANCE_CHART": self.units.LUMINANCE_CHART,
- "AREA_CHART": self.units.AREA_CHART,
- }
-
- # 1) Check if it's in any of the charts (non-currency)
- for chart_name, chart in charts.items():
- if from_type in chart and to_type in chart:
- # Temperatures use lambdas
- if chart_name == "TEMPERATURE_CHART":
- if from_type == to_type:
- return value
- to_kelvin = chart[from_type][0]
- from_kelvin = chart[to_type][1]
- return from_kelvin(to_kelvin(value))
-
- # Handle WEIGHT_CHART separately (tuple values)
- if chart_name == "WEIGHT_CHART":
- if from_type == to_type:
- return value
- to_kg = chart[from_type][0]
- from_kg = chart[to_type][1]
- return value * to_kg * from_kg
-
- # Any other numeric chart
- if from_type == to_type:
- return value
- return value * (chart[from_type] / chart[to_type])
-
- # 2) If both are currency codes (e.g. โUSDโ, โARSโ)
- # we assume they are uppercase and have 3 letters.
- if (
- len(from_type) == 3
- and len(to_type) == 3
- and from_type.isalpha()
- and to_type.isalpha()
- ):
- result = self._convert_currency_fast(value, from_type, to_type)
- if result is not None:
- return result
- # Fallback to slow method if fast method fails
- return self._convert_currency_via_floatrates(value, from_type, to_type)
-
- # 3) If it doesn't fall into any case, error.
- raise ValueError(f"Unsupported conversion: {from_type} to {to_type}")
-
- def _convert_currency_fast(
- self, value: float, from_code: str, to_code: str
- ) -> Optional[float]:
- """
- Fast currency conversion using cached exchange rates.
- Returns None if rate is not available in cache.
- """
- rate_info = self.currency_cache.get_rate(from_code, to_code)
- if rate_info is not None:
- rate, _ = rate_info # cache_age not needed here
- return value * rate
- return None
-
- def _convert_currency_via_floatrates(
- self, value: float, from_code: str, to_code: str
- ) -> float:
- """
- Converts using the JSON from floatrates.com:
- - Makes GET to https://www.floatrates.com/daily/{from_lower}.json
- - Takes the rate from the to_lower key and multiplies.
- """
- from_lower = from_code.lower()
- to_lower = to_code.lower()
-
- if from_lower == to_lower:
- 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()
- if to_lower not in data:
- raise ValueError(
- f"Target currency '{to_code}' not found in floatrates response for '{
- from_code
- }'"
- )
-
- rate = data[to_lower]["rate"]
- return value * rate
-
- def parse_input_and_convert(self, input: str):
- parts = input.split()
- addition = "s" if parts[-1].endswith("s") else ""
-
- if "and" in parts: # value unit1 and value2 unit2 _ to target_unit
- parts.remove("and")
- if len(parts) != 6:
- raise ValueError(
- "Invalid format. Expected: 'value from_type and value2 from_type2 _ to_type'"
- )
-
- value1, from_type1, value2, from_type2, _, to_type = parts
- value1, value2 = float(value1), float(value2)
- from_type1 = self.clean_type(from_type1)
- from_type2 = self.clean_type(from_type2)
- to_type = self.clean_type(to_type)
-
- if from_type1 == from_type2:
- return (
- self.convert(value1 + value2, from_type1, to_type),
- to_type + addition,
- )
- else:
- res = 0
- res += self.convert(value1, from_type1, to_type)
- res += self.convert(value2, from_type2, to_type)
- return res, to_type + addition
- else:
- if len(parts) != 4:
- raise ValueError(
- "Invalid format. Expected: 'value from_type _ to_type'"
- )
- value, from_type, _, to_type = parts
- value = float(value)
- from_type = self.clean_type(from_type)
- to_type = self.clean_type(to_type)
- return self.convert(value, from_type, to_type), to_type + addition
-
- def clean_type(self, type: str) -> str:
- """
- 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 len(type) == 3 and type.isalpha():
- return type.upper()
- if type.endswith("s") and type.lower() != "celsius":
- # For tables that have singular/plural
- singular = type[:-1].lower()
- # If it exists in STORAGE_TYPE_CHART, we use it;
- # if not, we return singular in lowercase for other charts.
- if singular in self.units.STORAGE_TYPE_CHART:
- return singular
- return singular.lower()
- return type
-
- def cleanup(self):
- """Cleanup resources."""
- self.currency_cache.cleanup()
-
- def get_currency_cache_info(
- self, from_code: str, to_code: str
- ) -> Optional[Tuple[bool, float]]:
- """
- Get currency cache information for UI display.
- Returns (is_fresh, cache_age_seconds) or None if not cached.
- """
- rate_info = self.currency_cache.get_rate(from_code, to_code)
- if rate_info is not None:
- _, cache_age = rate_info # rate not needed here
- is_fresh = cache_age < 300 # Consider fresh if less than 5 minutes old
- return is_fresh, cache_age
- return None
-
-
-# Quick usage example:
-if __name__ == "__main__":
- conv = Conversion()
- # Convert 10 USD to ARS:
- result, suffix = conv.parse_input_and_convert("10 USD _ ARS")
- print(f"{result:.2f} {suffix}") # Ex: "10 USD _ ARS" -> "38754.23 ARS"
diff --git a/utils/functions.py b/utils/functions.py
deleted file mode 100644
index edbf259..0000000
--- a/utils/functions.py
+++ /dev/null
@@ -1,174 +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")
-
-
-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:
- """
- 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 58ac1e4..0000000
--- 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/utils/inhibit.py b/utils/inhibit.py
deleted file mode 100644
index 6b5a106..0000000
--- a/utils/inhibit.py
+++ /dev/null
@@ -1,186 +0,0 @@
-# From https://github.com/stwa/wayland-idle-inhibitor
-# License: WTFPL Version 2
-
-import argparse
-import os
-import subprocess
-import sys
-from dataclasses import dataclass
-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,
-)
-from pywayland.protocol.wayland.wl_compositor import WlCompositor
-from pywayland.protocol.wayland.wl_registry import WlRegistryProxy
-from pywayland.protocol.wayland.wl_surface import WlSurface
-
-
-@dataclass
-class GlobalRegistry:
- surface: WlSurface | None = None
- inhibit_manager: ZwpIdleInhibitManagerV1 | None = None
-
-
-def parse_duration(duration_str: str) -> int:
- """Parse duration string into seconds.
- Examples: '1h', '30m', '45s', '1.5h', '1.5m', '30.5s', 'on', 'off'
- """
- try:
- if duration_str.lower() in ["on", "off"]:
- return 0
- elif duration_str.endswith("h"):
- return int(float(duration_str[:-1]) * 3600)
- elif duration_str.endswith("m"):
- return int(float(duration_str[:-1]) * 60)
- elif duration_str.endswith("s"):
- return int(float(duration_str[:-1]))
- else:
- return int(duration_str)
- except ValueError:
- raise ValueError(
- "Invalid duration format. Use '1h', '30m', '45s', 'on', 'off', etc."
- )
-
-
-def kill_existing_inhibit_processes():
- """Kill any existing modus-inhibit processes."""
- try:
- # Find all modus-inhibit processes except the current one
- output = subprocess.check_output(["pgrep", "-f", "modus-inhibit"], text=True)
- pids = output.strip().split("\n")
- current_pid = str(os.getpid())
-
- killed_count = 0
- for pid in pids:
- pid = pid.strip()
- if pid and pid != current_pid:
- try:
- subprocess.run(["kill", pid], check=True)
- killed_count += 1
- except subprocess.CalledProcessError:
- pass # Process might have already exited
-
- if killed_count > 0:
- print(f"Stopped {killed_count} existing inhibit process(es)")
- else:
- print("No existing inhibit processes were running")
- return killed_count
-
- except subprocess.CalledProcessError:
- # No processes found
- print("No existing inhibit processes were running")
- return 0
- except Exception as e:
- print(f"Error stopping existing processes: {e}")
- return 0
-
-
-def handle_registry_global(
- wl_registry: WlRegistryProxy, id_num: int, iface_name: str, version: int
-) -> None:
- global_registry: GlobalRegistry = wl_registry.user_data or GlobalRegistry()
-
- if iface_name == "wl_compositor":
- compositor = wl_registry.bind(id_num, WlCompositor, version)
- global_registry.surface = compositor.create_surface() # type: ignore
- elif iface_name == "zwp_idle_inhibit_manager_v1":
- global_registry.inhibit_manager = wl_registry.bind(
- id_num, ZwpIdleInhibitManagerV1, version
- )
-
-
-def main() -> None:
- parser = argparse.ArgumentParser(
- description="Inhibit system idle for a specified duration"
- )
- parser.add_argument(
- "duration",
- nargs="?",
- default="0",
- help='Duration to inhibit (e.g., "1h", "30m", "45s", "on", "off"). Use "on" for indefinite, "off" to stop.',
- )
- args = parser.parse_args()
-
- # Handle "off" argument - kill existing processes and exit
- if args.duration.lower() == "off":
- kill_existing_inhibit_processes()
- sys.exit(0)
-
- done = Event()
- signal(SIGINT, lambda _, __: done.set())
- signal(SIGTERM, lambda _, __: done.set())
-
- global_registry = GlobalRegistry()
-
- try:
- display = Display()
- display.connect()
-
- registry = display.get_registry() # type: ignore
- registry.user_data = global_registry
- registry.dispatcher["global"] = handle_registry_global
-
- def shutdown() -> None:
- display.dispatch()
- display.roundtrip()
- display.disconnect()
-
- display.dispatch()
- display.roundtrip()
-
- if global_registry.surface is None:
- print("Error: Failed to create Wayland surface.")
- shutdown()
- sys.exit(1)
-
- if global_registry.inhibit_manager is None:
- print("Error: Your Wayland compositor does not support idle inhibition.")
- print("This usually means either:")
- print(
- "1. Your compositor (like Hyprland) doesn't support the idle-inhibit protocol"
- )
- print("2. The protocol is not enabled in your compositor")
- print("\nFor Hyprland, you can enable it by adding to your config:")
- print("misc:disable_autoreload = true")
- print("misc:enable_wayland_protocols = true")
- shutdown()
- sys.exit(1)
-
- inhibitor = global_registry.inhibit_manager.create_inhibitor( # type: ignore
- global_registry.surface
- )
-
- display.dispatch()
- display.roundtrip()
-
- duration = parse_duration(args.duration)
- if duration > 0:
- print(f"Inhibiting idle for {args.duration}...")
- # Set up timer to release inhibition
- timer = Timer(duration, lambda: done.set())
- timer.start()
- else:
- print("Inhibiting idle indefinitely...")
-
- done.wait()
- print("Shutting down...")
-
- inhibitor.destroy()
- if duration > 0:
- timer.cancel()
-
- shutdown()
-
- except Exception as e:
- print(f"Error: {str(e)}")
- print("Make sure you're running this under a Wayland session.")
- sys.exit(1)
-
-
-if __name__ == "__main__":
- setproctitle.setproctitle("modus-inhibit")
- main()
diff --git a/utils/monitors.py b/utils/monitors.py
deleted file mode 100644
index 01b706e..0000000
--- a/utils/monitors.py
+++ /dev/null
@@ -1,60 +0,0 @@
-import json
-import warnings
-from typing import Dict
-import time
-from fabric.hyprland import Hyprland
-from gi.repository import Gdk
-from functools import lru_cache
-
-warnings.filterwarnings("ignore", category=DeprecationWarning)
-
-
-def ttl_lru_cache(seconds_to_live: int, maxsize: int = 128):
- def wrapper(func):
- @lru_cache(maxsize)
- def inner(__ttl, *args, **kwargs):
- return func(*args, **kwargs)
-
- return lambda *args, **kwargs: inner(
- time.time() // seconds_to_live, *args, **kwargs
- )
-
- return wrapper
-
-
-class HyprlandWithMonitors(Hyprland):
- """A Hyprland class with additional monitor common."""
-
- instance = None
-
- @staticmethod
- def get_default():
- if HyprlandWithMonitors.instance is None:
- HyprlandWithMonitors.instance = HyprlandWithMonitors()
-
- return HyprlandWithMonitors.instance
-
- def __init__(self, commands_only: bool = False, **kwargs):
- self.display: Gdk.Display = Gdk.Display.get_default()
- super().__init__(commands_only, **kwargs)
-
- @ttl_lru_cache(100, 5)
- def get_all_monitors(self) -> Dict:
- monitors = json.loads(self.send_command("j/monitors").reply)
- return {monitor["id"]: monitor["name"] for monitor in monitors}
-
- def get_gdk_monitor_id_from_name(self, plug_name: str) -> int | None:
- for i in range(self.display.get_n_monitors()):
- if self.display.get_default_screen().get_monitor_plug_name(i) == plug_name:
- return i
- return None
-
- def get_gdk_monitor_id(self, hyprland_id: int) -> int | None:
- monitors = self.get_all_monitors()
- if hyprland_id in monitors:
- return self.get_gdk_monitor_id_from_name(monitors[hyprland_id])
- return None
-
- def get_current_gdk_monitor_id(self) -> int | None:
- active_workspace = json.loads(self.send_command("j/activeworkspace").reply)
- return self.get_gdk_monitor_id_from_name(active_workspace["monitor"])
diff --git a/utils/occlusion.py b/utils/occlusion.py
deleted file mode 100644
index b81ee75..0000000
--- a/utils/occlusion.py
+++ /dev/null
@@ -1,140 +0,0 @@
-import json
-import subprocess
-
-import config.data as data
-
-
-def get_current_workspace():
- """
- Get the current workspace ID using hyprctl.
- """
- try:
- result = subprocess.run(
- ["hyprctl", "activeworkspace"], capture_output=True, text=True
- )
- # Assume the output similar to: "ID "
- # Extracting the number from the output
- parts = result.stdout.split()
- for i, part in enumerate(parts):
- if part == "ID" and i + 1 < len(parts):
- return int(parts[i + 1])
- except Exception as e:
- print(f"Error getting current workspace: {e}")
- return -1
-
-
-def get_screen_dimensions():
- """
- Get screen dimensions from hyprctl.
-
- Returns:
- tuple: (width, height) of the monitor containing the current workspace
- """
- try:
- # Get current workspace
- workspace_id = get_current_workspace()
-
- # Get monitor information
- result = subprocess.run(
- ["hyprctl", "-j", "monitors"], capture_output=True, text=True
- )
- monitors = json.loads(result.stdout)
-
- # Find the monitor containing our workspace
- for monitor in monitors:
- if monitor.get("activeWorkspace", {}).get("id") == workspace_id:
- return monitor.get("width", data.CURRENT_WIDTH), monitor.get(
- "height", data.CURRENT_HEIGHT
- )
-
- # Fallback to first monitor
- if monitors:
- return monitors[0].get("width", data.CURRENT_WIDTH), monitors[0].get(
- "height", data.CURRENT_HEIGHT
- )
- except Exception as e:
- print(f"Error getting screen dimensions: {e}")
-
- # Default fallback values
- return data.CURRENT_WIDTH, data.CURRENT_HEIGHT
-
-
-def check_occlusion(occlusion_region, workspace=None):
- """
- Check if a region is occupied by any window on a given workspace.
-
- Parameters:
- occlusion_region: Can be one of:
- - tuple (side, size): where side is "top", "bottom", "left", or "right"
- and size is the pixel width of the region
- - tuple (x, y, width, height): The full region coordinates (legacy format)
- workspace (int, optional): The workspace ID to check. If None, the current workspace is used.
-
- Returns:
- bool: True if any window overlaps with the occlusion region, False otherwise.
- """
- if workspace is None:
- workspace = get_current_workspace()
-
- # Handle simplified side-based format
- if isinstance(occlusion_region, tuple) and len(occlusion_region) == 2:
- side, size = occlusion_region
- if isinstance(side, str):
- # Convert side-based format to coordinates
- screen_width, screen_height = get_screen_dimensions()
-
- if side.lower() == "bottom":
- occlusion_region = (0, screen_height - size, screen_width, size)
- elif side.lower() == "top":
- occlusion_region = (0, 0, screen_width, size)
- elif side.lower() == "left":
- occlusion_region = (0, 0, size, screen_height)
- elif side.lower() == "right":
- occlusion_region = (screen_width - size, 0, size, screen_height)
-
- # Ensure occlusion_region is in the correct format (x, y, width, height)
- if not isinstance(occlusion_region, tuple) or len(occlusion_region) != 4:
- print(f"Invalid occlusion region format: {occlusion_region}")
- return False
-
- try:
- result = subprocess.run(
- ["hyprctl", "-j", "clients"], capture_output=True, text=True
- )
- clients = json.loads(result.stdout)
- except Exception as e:
- print(f"Error retrieving client windows: {e}")
- return False
-
- occ_x, occ_y, occ_width, occ_height = occlusion_region
- occ_x2 = occ_x + occ_width
- occ_y2 = occ_y + occ_height
-
- for client in clients:
- # Check if client is mapped
- if not client.get("mapped", False):
- continue
-
- # Ensure client has proper workspace information and matches the workspace
- client_workspace = client.get("workspace", {})
- if client_workspace.get("id") != workspace:
- continue
-
- # Ensure client has position and size info
- position = client.get("at")
- size = client.get("size")
- if not position or not size:
- continue
-
- x, y = position
- width, height = size
- win_x1, win_y1 = x, y
- win_x2, win_y2 = x + width, y + height
-
- # Check for intersection between the window and occlusion region
- if not (
- win_x2 <= occ_x or win_x1 >= occ_x2 or win_y2 <= occ_y or win_y1 >= occ_y2
- ):
- return True # Occlusion region is occupied
-
- return False # No window overlaps the occlusion region
diff --git a/utils/roam.py b/utils/roam.py
deleted file mode 100644
index 4127d98..0000000
--- a/utils/roam.py
+++ /dev/null
@@ -1,41 +0,0 @@
-from loguru import logger
-
-from fabric.audio import Audio
-
-from services.modus import ModusService, notification_service as notification_service_instance
-
-global modus_service
-try:
- modus_service = ModusService()
-except Exception as e:
- logger.error(f"[Main] Failed to create ModusService: {e}")
- modus_service = None
-
-if modus_service is None:
- logger.warning(
- "[Main] ModusService was not initialized. Functionality may be limited."
- )
-
-global notification_service
-try:
- notification_service = notification_service_instance
-except Exception as e:
- logger.error(f"[Main] Failed to create NotificationService: {e}")
- notification_service = None
-
-if notification_service is None:
- logger.warning(
- "[Main] NotificationService was not initialized. Notifications may not work."
- )
-
-global audio_service
-try:
- audio_service = Audio()
-except Exception as e:
- logger.error(f"[Main] Failed to create AudioService: {e}")
- audio_service = None
-
-if audio_service is None:
- logger.warning(
- "[Main] AudioService was not initialized. Audio features may not work."
- )
diff --git a/widgets/circle_image.py b/widgets/circle_image.py
deleted file mode 100644
index aefd7e0..0000000
--- a/widgets/circle_image.py
+++ /dev/null
@@ -1,125 +0,0 @@
-import math
-from typing import Literal
-
-import cairo
-import gi
-from fabric.core.service import Property
-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."""
-
- @Property(int, "read-write")
- def angle(self) -> int:
- return self._angle
-
- @angle.setter
- def angle(self, value: int):
- self._angle = value % 360
- self.queue_draw()
-
- def __init__(
- self,
- image_file: str | None = None,
- pixbuf: GdkPixbuf.Pixbuf | None = None,
- name: str | None = None,
- visible: bool = True,
- all_visible: bool = False,
- style: 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: int | None = None,
- **kwargs,
- ):
- Gtk.DrawingArea.__init__(self)
- Widget.__init__(
- self,
- name=name,
- visible=visible,
- all_visible=all_visible,
- style=style,
- 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.size = size if size is not None else 100 # Default size if not provided
- self._angle = 0
- # Original image for reprocessing
- self._orig_image: GdkPixbuf.Pixbuf | None = None
- self._image: GdkPixbuf.Pixbuf | None = None
- if image_file:
- pix = GdkPixbuf.Pixbuf.new_from_file(image_file)
- self._orig_image = pix
- self._image = self._process_image(pix)
- elif pixbuf:
- self._orig_image = pixbuf
- self._image = self._process_image(pixbuf)
- self.connect("draw", self.on_draw)
-
- def _process_image(self, pixbuf: GdkPixbuf.Pixbuf) -> GdkPixbuf.Pixbuf:
- """Crop the image to a centered square and scale it to the widgetโs size."""
- width, height = pixbuf.get_width(), pixbuf.get_height()
- if width != height:
- square_size = min(width, height)
- x_offset = (width - square_size) // 2
- y_offset = (height - square_size) // 2
- pixbuf = pixbuf.new_subpixbuf(x_offset, y_offset, square_size, square_size)
- else:
- square_size = width
- if square_size != self.size:
- pixbuf = pixbuf.scale_simple(
- self.size, self.size, GdkPixbuf.InterpType.BILINEAR
- )
- return pixbuf
-
- def on_draw(self, widget: "CircleImage", ctx: cairo.Context):
- if self._image:
- ctx.save()
- # Create a circular clipping path
- ctx.arc(self.size / 2, self.size / 2, self.size / 2, 0, 2 * math.pi)
- ctx.clip()
- # Rotate around the center of the square image
- ctx.translate(self.size / 2, self.size / 2)
- ctx.rotate(self._angle * math.pi / 180.0)
- ctx.translate(-self.size / 2, -self.size / 2)
- Gdk.cairo_set_source_pixbuf(ctx, self._image, 0, 0)
- ctx.paint()
- ctx.restore()
-
- def set_image_from_file(self, new_image_file: str):
- if not new_image_file:
- return
- pixbuf = GdkPixbuf.Pixbuf.new_from_file(new_image_file)
- self._orig_image = pixbuf
- self._image = self._process_image(pixbuf)
- self.queue_draw()
-
- def set_image_from_pixbuf(self, pixbuf: GdkPixbuf.Pixbuf):
- if not pixbuf:
- return
- self._orig_image = pixbuf
- self._image = self._process_image(pixbuf)
- self.queue_draw()
-
- def set_image_size(self, size: int):
- self.size = size
- if self._orig_image:
- self._image = self._process_image(self._orig_image)
- self.queue_draw()
diff --git a/widgets/custom_image.py b/widgets/custom_image.py
deleted file mode 100644
index 1aae7f6..0000000
--- a/widgets/custom_image.py
+++ /dev/null
@@ -1,39 +0,0 @@
-import math
-from typing import cast
-
-import cairo
-from gi.repository import Gtk
-
-from fabric.widgets.image import Image
-
-
-class CustomImage(Image):
- def do_render_rectangle(
- self, 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)))
- cr.close_path()
-
- def do_draw(self, cr: cairo.Context):
- context = self.get_style_context()
- width, height = self.get_allocated_width(), self.get_allocated_height()
- cr.save()
-
- self.do_render_rectangle(
- cr,
- width,
- height,
- cast(int, context.get_property("border-radius", Gtk.StateFlags.NORMAL)),
- )
- cr.clip()
- Image.do_draw(self, cr)
-
- cr.restore()
diff --git a/widgets/customrevealer.py b/widgets/customrevealer.py
deleted file mode 100644
index bf47e52..0000000
--- 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/dropdown.py b/widgets/dropdown.py
deleted file mode 100644
index cb159c9..0000000
--- a/widgets/dropdown.py
+++ /dev/null
@@ -1,91 +0,0 @@
-from fabric.widgets.box import Box
-from fabric.widgets.centerbox import CenterBox
-from fabric.widgets.eventbox import EventBox
-from utils.roam import modus_service
-from widgets.popup_window import PopupWindow
-
-dropdowns = []
-
-
-def dropdown_divider(comment):
- return Box(
- children=[Box(name="dropdown-divider", h_expand=True)],
- name="dropdown-divider-box",
- h_align="fill",
- h_expand=True,
- v_expand=True,
- )
-
-
-class ModusDropdown(PopupWindow):
- def __init__(self, dropdown_children=None, dropdown_id=None, **kwargs):
- super().__init__(
- layer="top",
- exclusivity="auto",
- name="dropdown-menu",
- title="modus",
- keyboard_mode="none",
- visible=False,
- **kwargs,
- )
-
- self.id = dropdown_id or str(len(dropdowns))
- dropdowns.append(self)
- self._mousecapture_parent = None # Will be set by mousecapture
-
- modus_service.connect("dropdowns-hide-changed", self.hide_dropdown)
-
- self.dropdown = Box(
- children=dropdown_children or [],
- h_expand=True,
- name="dropdown-options",
- orientation="vertical",
- )
-
- self.child_box = CenterBox(start_children=[self.dropdown])
-
- self.event_box = EventBox(
- events=["enter-notify-event", "leave-notify-event"],
- child=self.child_box,
- all_visible=True,
- )
-
- 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):
- self.set_visible(not self.is_visible())
- modus_service.current_dropdown = self.id if self.is_visible() else None
-
- def _init_mousecapture(self, mousecapture):
- """Store reference to mousecapture parent for hiding"""
- self._mousecapture_parent = mousecapture
-
- def hide_dropdown(self, widget, event):
- if self.is_visible():
- self.hide()
- if str(modus_service.current_dropdown) == str(self.id):
- modus_service.current_dropdown = None
-
- def hide_via_mousecapture(self):
- """Hide dropdown via mousecapture parent"""
- if self._mousecapture_parent:
- self._mousecapture_parent.hide_child_window()
-
- def _set_mousecapture(self, visible: bool) -> None:
- self.set_visible(visible)
- if visible:
- modus_service.current_dropdown = self.id
- else:
- if str(modus_service.current_dropdown) == str(self.id):
- modus_service.current_dropdown = None
-
- def on_cursor_enter(self, *_):
- self.set_visible(True)
-
- def on_cursor_leave(self, *_):
- if self.is_hovered():
- return
- self.set_visible(False)
- modus_service.dropdowns_hide = not modus_service.dropdowns_hide
diff --git a/widgets/mousecapture.py b/widgets/mousecapture.py
deleted file mode 100644
index af432fc..0000000
--- 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/popup_window.py b/widgets/popup_window.py
deleted file mode 100644
index d6d600c..0000000
--- a/widgets/popup_window.py
+++ /dev/null
@@ -1,191 +0,0 @@
-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
-
-gi.require_version("GtkLayerShell", "0.1")
-
-
-class PopupWindow(WaylandWindow):
- """A simple popover window that can point to a widget."""
-
- def __init__(
- self,
- parent: WaylandWindow,
- pointing_to: Gtk.Widget | None = None,
- margin: tuple[int, ...] | str = "0px 0px 0px 0px",
- enable_boundary_checking: bool = True,
- **kwargs,
- ):
- super().__init__(**kwargs)
- self.exclusivity = "none"
- self._is_centered = False
- self._parent = parent
- self._pointing_widget = pointing_to
- self._hyprland = HyprlandWithMonitors()
- self._base_margin = self.extract_margin(margin)
- self.margin = self._base_margin.values()
- self._enable_boundary_checking = enable_boundary_checking
-
- self.connect("notify::visible", self.do_update_handlers)
-
- def get_coords_for_widget(self, widget: Gtk.Widget) -> tuple[int, int]:
- if not ((toplevel := widget.get_toplevel()) and toplevel.is_toplevel()): # type: ignore
- return 0, 0
- allocation = widget.get_allocation()
- x, y = widget.translate_coordinates(toplevel, allocation.x, allocation.y) or (
- 0,
- 0,
- )
- return round(x / 2), round(y / 2)
-
- def set_pointing_to(self, widget: Gtk.Widget | None):
- if self._pointing_widget:
- with contextlib.suppress(Exception):
- self._pointing_widget.disconnect_by_func(self.do_handle_size_allocate)
- self._pointing_widget = widget
- return self.do_update_handlers()
-
- def do_update_handlers(self, *_):
- if not self._pointing_widget:
- return
-
- if not self.get_visible():
- try:
- self._pointing_widget.disconnect_by_func(self.do_handle_size_allocate)
- self.disconnect_by_func(self.do_handle_size_allocate)
- except Exception:
- pass
- return
-
- self._pointing_widget.connect("size-allocate", self.do_handle_size_allocate)
- self.connect("size-allocate", self.do_handle_size_allocate)
-
- return self.do_handle_size_allocate()
-
- def do_handle_size_allocate(self, *_):
- return self.do_reposition(self.do_calculate_edges())
-
- def do_calculate_edges(self):
- move_axe = "x"
- parent_anchor = self._parent.anchor
-
- if len(parent_anchor) != 3:
- self.anchor = "left bottom"
- self._is_centered = True
- return move_axe
-
- if (
- GtkLayerShell.Edge.LEFT in parent_anchor
- and GtkLayerShell.Edge.RIGHT in parent_anchor
- ):
- # horizontal -> move on x-axies
- move_axe = "x"
- if GtkLayerShell.Edge.TOP in parent_anchor:
- self.anchor = "left top"
- else:
- self.anchor = "left bottom"
- elif (
- GtkLayerShell.Edge.TOP in parent_anchor
- and GtkLayerShell.Edge.BOTTOM in parent_anchor
- ):
- # vertical -> move on y-axies
- move_axe = "y"
- if GtkLayerShell.Edge.RIGHT in parent_anchor:
- self.anchor = "top right"
- else:
- self.anchor = "top left"
-
- self._is_centered = False
- return move_axe
-
- def do_reposition(self, move_axe: str):
- parent_margin = self._parent.margin
- parent_x_margin, parent_y_margin = parent_margin[0], parent_margin[3]
-
- height = self.get_allocated_height()
- width = self.get_allocated_width()
-
- # Get monitor geometry for boundary checking
- current_monitor_id = self._hyprland.get_current_gdk_monitor_id()
- if current_monitor_id is not None:
- 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
- else:
- # Fallback to default screen
- screen = Gdk.Screen.get_default()
- monitor_x, monitor_y = 0, 0
- monitor_width, monitor_height = screen.get_width(), screen.get_height()
-
- if self._pointing_widget:
- coords = self.get_coords_for_widget(self._pointing_widget)
- coords_centered = (
- round(coords[0] + self._pointing_widget.get_allocated_width() / 2),
- round(coords[1] + self._pointing_widget.get_allocated_height() / 2),
- )
- else:
- coords_centered = (
- round(self._parent.get_allocated_width() / 2),
- round(self._parent.get_allocated_height() / 2),
- )
-
- if self._is_centered:
- # Calculate centered position with boundary checking
- centered_x = (
- (monitor_width / 2 - self._parent.get_allocated_width() / 2)
- - width / 2
- ) + coords_centered[0]
-
- # Apply boundary checking only if enabled
- if self._enable_boundary_checking:
- if centered_x < monitor_x:
- centered_x = monitor_x
- elif centered_x + width > monitor_x + monitor_width:
- centered_x = monitor_x + monitor_width - width
-
- self.margin = tuple(
- a + b
- for a, b in zip(
- (0, 0, 0, centered_x),
- self._base_margin.values(),
- )
- )
- return
-
- # Calculate position with boundary checking
- if move_axe == "x":
- # Horizontal positioning
- calculated_x = round((parent_x_margin + coords_centered[0]) - (width / 2))
-
- # Apply boundary checking only if enabled
- if self._enable_boundary_checking:
- if calculated_x < monitor_x:
- calculated_x = monitor_x
- elif calculated_x + width > monitor_x + monitor_width:
- calculated_x = monitor_x + monitor_width - width
-
- margin_values = (0, 0, 0, calculated_x)
- else:
- # Vertical positioning
- calculated_y = round((parent_y_margin + coords_centered[1]) - (height / 2))
-
- # Apply boundary checking only if enabled
- if self._enable_boundary_checking:
- if calculated_y < monitor_y:
- calculated_y = monitor_y
- elif calculated_y + height > monitor_y + monitor_height:
- calculated_y = monitor_y + monitor_height - height
-
- margin_values = (calculated_y, 0, 0, 0)
-
- self.margin = tuple(
- a + b
- for a, b in zip(
- margin_values,
- self._base_margin.values(),
- )
- )
diff --git a/widgets/wayland.py b/widgets/wayland.py
deleted file mode 100644
index b7c76aa..0000000
--- 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],
- }
diff --git a/widgets/wifi_password_dialog.py b/widgets/wifi_password_dialog.py
deleted file mode 100644
index 9d53586..0000000
--- a/widgets/wifi_password_dialog.py
+++ /dev/null
@@ -1,291 +0,0 @@
-import gi
-from gi.repository 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__(
- self,
- ssid: str,
- on_connect_callback=None,
- on_cancel_callback=None,
- on_dialog_closed=None,
- **kwargs,
- ):
- super().__init__(
- title="modus",
- layer="overlay",
- anchor="center",
- keyboard_mode="on-demand",
- visible=False,
- name="wifi-password-dialog",
- **kwargs,
- )
-
- self.ssid = ssid
- self.on_connect_callback = on_connect_callback
- self.on_cancel_callback = on_cancel_callback
- # self.set_size_request(400, 300)
- self.set_resizable(False)
- self.on_dialog_closed = on_dialog_closed
- self.is_connecting = False
- self.connection_timeout_id = None
-
- self._create_dialog_content()
- self.connect("key-press-event", self._on_key_press)
- self.connect("notify::visible", self._on_visibility_changed)
-
- def _create_dialog_content(self):
- self.wifi_icon = Image(
- icon_name="network-wireless-symbolic", size=20, name="wifi-dialog-icon"
- )
-
- self.title_label = Label(
- label=f'The Wi-Fi network "{self.ssid}" requires a WPA2 password.',
- name="wifi-dialog-title",
- h_align="start",
- wrap=True,
- max_width_chars=40,
- )
-
- self.title_container = Box(
- orientation="h",
- spacing=8,
- children=[self.wifi_icon, self.title_label],
- name="wifi-dialog-title-container",
- h_align="start",
- )
-
- self.error_label = Label(
- label="Incorrect password. Please try again.",
- name="wifi-dialog-error",
- h_align="center",
- visible=False,
- )
-
- self.password_label = Label(
- label="Password:", name="wifi-dialog-password-label", h_align="start"
- )
-
- self.password_entry = Entry(
- placeholder_text="Enter password",
- name="wifi-dialog-password-entry",
- visibility=False,
- h_expand=True,
- h_align="fill",
- )
-
- self.password_entry.connect("activate", lambda *_: self._on_join_clicked())
- self.password_entry.connect("changed", self._on_password_changed)
-
- self.password_visible = False
- self.show_password_button = Button(
- image=Image(icon_name="view-conceal-symbolic", size=16),
- name="wifi-dialog-show-password-button",
- on_clicked=self._on_show_password_clicked,
- )
-
- self.show_password_label = Label(
- label="Show password", name="wifi-dialog-show-password-label"
- )
-
- self.show_password_box = Box(
- orientation="h",
- spacing=8,
- children=[self.show_password_button, self.show_password_label],
- name="wifi-dialog-show-password-box",
- )
-
- self.cancel_button = Button(
- label="Cancel",
- name="wifi-dialog-cancel-button",
- on_clicked=self._on_cancel_clicked,
- )
-
- self.join_button = Button(
- label="Join",
- name="wifi-dialog-join-button",
- on_clicked=self._on_join_clicked,
- )
-
- self.button_box = Box(
- orientation="h",
- spacing=12,
- h_expand=True,
- children=[self.cancel_button, self.join_button],
- name="wifi-dialog-button-box",
- h_align="end",
- )
-
- self.password_container = Box(
- orientation="v",
- h_expand=True,
- spacing=6,
- children=[self.password_label, self.password_entry, self.show_password_box],
- name="wifi-dialog-password-container",
- )
-
- self.content_box = Box(
- orientation="v",
- h_expand=True,
- spacing=12,
- children=[
- self.title_container,
- self.error_label,
- self.password_container,
- self.button_box,
- ],
- name="wifi-dialog-content",
- h_align="fill",
- v_align="center",
- )
-
- self.dialog_background = Box(
- children=[self.content_box],
- name="wifi-dialog-background",
- h_align="center",
- v_align="center",
- )
-
- self.children = self.dialog_background
-
- self._update_join_button_state()
-
- def _on_password_changed(self, entry):
- self._update_join_button_state()
-
- def _update_join_button_state(self):
- password = self.password_entry.get_text().strip()
- has_password = len(password) > 0
-
- if has_password:
- self.join_button.set_opacity(1.0)
- self.join_button.remove_style_class("disabled")
- else:
- self.join_button.set_opacity(0.5)
- self.join_button.add_style_class("disabled")
-
- def _on_show_password_clicked(self, *args):
- self.password_visible = not self.password_visible
- self.password_entry.set_visibility(self.password_visible)
-
- icon_name = (
- "view-reveal-symbolic" if self.password_visible else "view-conceal-symbolic"
- )
- self.show_password_button.get_image().set_property("icon-name", icon_name)
-
- def _on_key_press(self, widget, event):
- keyval = event.keyval
-
- if keyval == Gdk.KEY_Return or keyval == Gdk.KEY_KP_Enter:
- self._on_join_clicked()
- return True
- elif keyval == Gdk.KEY_Escape:
- self._on_cancel_clicked()
- return True
-
- return False
-
- def _on_visibility_changed(self, widget, *args):
- """Handle visibility changes"""
- if self.get_visible():
- GLib.timeout_add(100, lambda: self.password_entry.grab_focus())
-
- def _on_cancel_clicked(self, *args):
- self.hide()
- if self.on_cancel_callback:
- self.on_cancel_callback()
- if self.on_dialog_closed:
- self.on_dialog_closed()
-
- def _on_join_clicked(self, *args):
- if self.is_connecting:
- return
-
- password = self.password_entry.get_text().strip()
- if not password:
- self.password_entry.grab_focus()
- return
-
- self.is_connecting = True
- self.join_button.set_sensitive(False)
-
- self.connection_timeout_id = GLib.timeout_add(5000, self._connection_timeout)
- self.error_label.set_visible(False)
-
- self.hide()
- if self.on_connect_callback:
- self.on_connect_callback(self.ssid, password)
- if self.on_dialog_closed:
- self.on_dialog_closed()
-
- def _connection_timeout(self):
- if self.is_connecting:
- self.is_connecting = False
- self.join_button.set_sensitive(True)
- self.show_error("Connection timeout. Please try again.")
- return False
-
- def show_dialog(self):
- if self.connection_timeout_id:
- GLib.source_remove(self.connection_timeout_id)
- self.connection_timeout_id = None
-
- self.show_all()
- self.password_entry.set_text("")
- self.error_label.set_visible(False)
-
- self.password_visible = False
- self.password_entry.set_visibility(False)
- self.show_password_button.get_image().set_property(
- "icon-name", "view-conceal-symbolic"
- )
-
- self.is_connecting = False
- self.join_button.set_sensitive(True)
-
- self._update_join_button_state()
-
- def show_error(self, message="Incorrect password. Please try again."):
- if self.connection_timeout_id:
- GLib.source_remove(self.connection_timeout_id)
- self.connection_timeout_id = None
-
- self.is_connecting = False
- self.join_button.set_sensitive(True)
-
- if not self.get_visible():
- self.error_label.set_text(message)
- self.error_label.set_visible(True)
- self.show_all()
- GLib.timeout_add(10, lambda: self._focus_and_select_password())
- else:
- self.error_label.set_text(message)
- self.error_label.set_visible(True)
- self._focus_and_select_password()
-
- def _focus_and_select_password(self):
- try:
- self.password_entry.grab_focus()
- self.password_entry.select_region(0, -1)
- return False
- except:
- return False
-
- def get_password(self):
- return self.password_entry.get_text()
-
- def destroy_dialog(self):
- self.hide()
- self.destroy()