.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..3a9df24
--- /dev/null
+++ b/README.md
@@ -0,0 +1,110 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+[](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
new file mode 100644
index 0000000..a98ab7c
Binary files /dev/null and b/assets/screenshots/home.png differ
diff --git a/assets/screenshots/lock.png b/assets/screenshots/lock.png
new file mode 100644
index 0000000..123f456
Binary files /dev/null and b/assets/screenshots/lock.png differ
diff --git a/config/assets/config.json b/config/assets/config.json
new file mode 100644
index 0000000..3750756
--- /dev/null
+++ b/config/assets/config.json
@@ -0,0 +1,16 @@
+{
+ "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
new file mode 100644
index 0000000..fa174ff
--- /dev/null
+++ b/config/assets/emoji.json
@@ -0,0 +1,15509 @@
+{
+ "๐": {
+ "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
new file mode 100644
index 0000000..6348bc5
--- /dev/null
+++ b/config/assets/icons/applets/bluetooth-clear.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
diff --git a/config/assets/icons/applets/bluetooth-off-clear.svg b/config/assets/icons/applets/bluetooth-off-clear.svg
new file mode 100644
index 0000000..cea2aa5
--- /dev/null
+++ b/config/assets/icons/applets/bluetooth-off-clear.svg
@@ -0,0 +1,25 @@
+
+
+
+
+
diff --git a/config/assets/icons/applets/bluetooth-off.svg b/config/assets/icons/applets/bluetooth-off.svg
new file mode 100644
index 0000000..308fba6
--- /dev/null
+++ b/config/assets/icons/applets/bluetooth-off.svg
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/config/assets/icons/applets/bluetooth-paired.svg b/config/assets/icons/applets/bluetooth-paired.svg
new file mode 100644
index 0000000..c400923
--- /dev/null
+++ b/config/assets/icons/applets/bluetooth-paired.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
diff --git a/config/assets/icons/applets/bluetooth.svg b/config/assets/icons/applets/bluetooth.svg
new file mode 100644
index 0000000..d3a0b61
--- /dev/null
+++ b/config/assets/icons/applets/bluetooth.svg
@@ -0,0 +1,80 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/config/assets/icons/applets/brightness.svg b/config/assets/icons/applets/brightness.svg
new file mode 100755
index 0000000..bcc61ef
--- /dev/null
+++ b/config/assets/icons/applets/brightness.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/config/assets/icons/applets/caffeine-off.svg b/config/assets/icons/applets/caffeine-off.svg
new file mode 100644
index 0000000..b59dfe0
--- /dev/null
+++ b/config/assets/icons/applets/caffeine-off.svg
@@ -0,0 +1,104 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/config/assets/icons/applets/caffeine-on.svg b/config/assets/icons/applets/caffeine-on.svg
new file mode 100644
index 0000000..2432b4a
--- /dev/null
+++ b/config/assets/icons/applets/caffeine-on.svg
@@ -0,0 +1,103 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/config/assets/icons/applets/dnd-clear.svg b/config/assets/icons/applets/dnd-clear.svg
new file mode 100644
index 0000000..19b053f
--- /dev/null
+++ b/config/assets/icons/applets/dnd-clear.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/config/assets/icons/applets/dnd-off.svg b/config/assets/icons/applets/dnd-off.svg
new file mode 100644
index 0000000..fe6a37c
--- /dev/null
+++ b/config/assets/icons/applets/dnd-off.svg
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
diff --git a/config/assets/icons/applets/dnd.svg b/config/assets/icons/applets/dnd.svg
new file mode 100644
index 0000000..4ecdd99
--- /dev/null
+++ b/config/assets/icons/applets/dnd.svg
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/config/assets/icons/applets/flight-off.svg b/config/assets/icons/applets/flight-off.svg
new file mode 100644
index 0000000..d59785c
--- /dev/null
+++ b/config/assets/icons/applets/flight-off.svg
@@ -0,0 +1,82 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/config/assets/icons/applets/flight-on.svg b/config/assets/icons/applets/flight-on.svg
new file mode 100644
index 0000000..5a492f1
--- /dev/null
+++ b/config/assets/icons/applets/flight-on.svg
@@ -0,0 +1,86 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/config/assets/icons/applets/network-wired-offline.svg b/config/assets/icons/applets/network-wired-offline.svg
new file mode 100644
index 0000000..c1734ca
--- /dev/null
+++ b/config/assets/icons/applets/network-wired-offline.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/config/assets/icons/applets/network-wired.svg b/config/assets/icons/applets/network-wired.svg
new file mode 100644
index 0000000..7b6881e
--- /dev/null
+++ b/config/assets/icons/applets/network-wired.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/config/assets/icons/applets/redshift-status-off.svg b/config/assets/icons/applets/redshift-status-off.svg
new file mode 100644
index 0000000..e81d366
--- /dev/null
+++ b/config/assets/icons/applets/redshift-status-off.svg
@@ -0,0 +1,94 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/config/assets/icons/applets/redshift-status-on.svg b/config/assets/icons/applets/redshift-status-on.svg
new file mode 100644
index 0000000..aaa2959
--- /dev/null
+++ b/config/assets/icons/applets/redshift-status-on.svg
@@ -0,0 +1,86 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/config/assets/icons/applets/wifi-clear.svg b/config/assets/icons/applets/wifi-clear.svg
new file mode 100644
index 0000000..9276ebe
--- /dev/null
+++ b/config/assets/icons/applets/wifi-clear.svg
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
diff --git a/config/assets/icons/applets/wifi-off-clear.svg b/config/assets/icons/applets/wifi-off-clear.svg
new file mode 100644
index 0000000..d19de29
--- /dev/null
+++ b/config/assets/icons/applets/wifi-off-clear.svg
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
diff --git a/config/assets/icons/applets/wifi-off.svg b/config/assets/icons/applets/wifi-off.svg
new file mode 100644
index 0000000..ba5c100
--- /dev/null
+++ b/config/assets/icons/applets/wifi-off.svg
@@ -0,0 +1,89 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/config/assets/icons/applets/wifi.svg b/config/assets/icons/applets/wifi.svg
new file mode 100644
index 0000000..6a0dd08
--- /dev/null
+++ b/config/assets/icons/applets/wifi.svg
@@ -0,0 +1,99 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/config/assets/icons/battery/battery-000-charging.svg b/config/assets/icons/battery/battery-000-charging.svg
new file mode 100644
index 0000000..ac8c28f
--- /dev/null
+++ b/config/assets/icons/battery/battery-000-charging.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
diff --git a/config/assets/icons/battery/battery-000.svg b/config/assets/icons/battery/battery-000.svg
new file mode 100644
index 0000000..c49fb45
--- /dev/null
+++ b/config/assets/icons/battery/battery-000.svg
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/config/assets/icons/battery/battery-010-charging.svg b/config/assets/icons/battery/battery-010-charging.svg
new file mode 100644
index 0000000..c9ea308
--- /dev/null
+++ b/config/assets/icons/battery/battery-010-charging.svg
@@ -0,0 +1,11 @@
+
+
+
+
+
+
diff --git a/config/assets/icons/battery/battery-010.svg b/config/assets/icons/battery/battery-010.svg
new file mode 100644
index 0000000..b88e8fb
--- /dev/null
+++ b/config/assets/icons/battery/battery-010.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/config/assets/icons/battery/battery-020-charging.svg b/config/assets/icons/battery/battery-020-charging.svg
new file mode 100644
index 0000000..5824ad4
--- /dev/null
+++ b/config/assets/icons/battery/battery-020-charging.svg
@@ -0,0 +1,11 @@
+
+
+
+
+
+
diff --git a/config/assets/icons/battery/battery-020.svg b/config/assets/icons/battery/battery-020.svg
new file mode 100644
index 0000000..87375ae
--- /dev/null
+++ b/config/assets/icons/battery/battery-020.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/config/assets/icons/battery/battery-030-charging.svg b/config/assets/icons/battery/battery-030-charging.svg
new file mode 100644
index 0000000..c0088ce
--- /dev/null
+++ b/config/assets/icons/battery/battery-030-charging.svg
@@ -0,0 +1,11 @@
+
+
+
+
+
+
diff --git a/config/assets/icons/battery/battery-030.svg b/config/assets/icons/battery/battery-030.svg
new file mode 100644
index 0000000..312ab1b
--- /dev/null
+++ b/config/assets/icons/battery/battery-030.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/config/assets/icons/battery/battery-040-charging.svg b/config/assets/icons/battery/battery-040-charging.svg
new file mode 100644
index 0000000..1327b10
--- /dev/null
+++ b/config/assets/icons/battery/battery-040-charging.svg
@@ -0,0 +1,11 @@
+
+
+
+
+
+
diff --git a/config/assets/icons/battery/battery-040.svg b/config/assets/icons/battery/battery-040.svg
new file mode 100644
index 0000000..ff8a28a
--- /dev/null
+++ b/config/assets/icons/battery/battery-040.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/config/assets/icons/battery/battery-050-charging.svg b/config/assets/icons/battery/battery-050-charging.svg
new file mode 100644
index 0000000..016985f
--- /dev/null
+++ b/config/assets/icons/battery/battery-050-charging.svg
@@ -0,0 +1,11 @@
+
+
+
+
+
+
diff --git a/config/assets/icons/battery/battery-050.svg b/config/assets/icons/battery/battery-050.svg
new file mode 100644
index 0000000..0e6af06
--- /dev/null
+++ b/config/assets/icons/battery/battery-050.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/config/assets/icons/battery/battery-060-charging.svg b/config/assets/icons/battery/battery-060-charging.svg
new file mode 100644
index 0000000..e23c733
--- /dev/null
+++ b/config/assets/icons/battery/battery-060-charging.svg
@@ -0,0 +1,11 @@
+
+
+
+
+
+
diff --git a/config/assets/icons/battery/battery-060.svg b/config/assets/icons/battery/battery-060.svg
new file mode 100644
index 0000000..f9c44b9
--- /dev/null
+++ b/config/assets/icons/battery/battery-060.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
diff --git a/config/assets/icons/battery/battery-070-charging.svg b/config/assets/icons/battery/battery-070-charging.svg
new file mode 100644
index 0000000..cf18c55
--- /dev/null
+++ b/config/assets/icons/battery/battery-070-charging.svg
@@ -0,0 +1,11 @@
+
+
+
+
+
+
diff --git a/config/assets/icons/battery/battery-070.svg b/config/assets/icons/battery/battery-070.svg
new file mode 100644
index 0000000..abd1a8e
--- /dev/null
+++ b/config/assets/icons/battery/battery-070.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/config/assets/icons/battery/battery-080-charging.svg b/config/assets/icons/battery/battery-080-charging.svg
new file mode 100644
index 0000000..672c08f
--- /dev/null
+++ b/config/assets/icons/battery/battery-080-charging.svg
@@ -0,0 +1,11 @@
+
+
+
+
+
+
diff --git a/config/assets/icons/battery/battery-080.svg b/config/assets/icons/battery/battery-080.svg
new file mode 100644
index 0000000..0a78582
--- /dev/null
+++ b/config/assets/icons/battery/battery-080.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/config/assets/icons/battery/battery-090-charging.svg b/config/assets/icons/battery/battery-090-charging.svg
new file mode 100644
index 0000000..2e81ab9
--- /dev/null
+++ b/config/assets/icons/battery/battery-090-charging.svg
@@ -0,0 +1,11 @@
+
+
+
+
+
+
diff --git a/config/assets/icons/battery/battery-090.svg b/config/assets/icons/battery/battery-090.svg
new file mode 100644
index 0000000..8cc1219
--- /dev/null
+++ b/config/assets/icons/battery/battery-090.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/config/assets/icons/battery/battery-100-charging.svg b/config/assets/icons/battery/battery-100-charging.svg
new file mode 100644
index 0000000..22471ae
--- /dev/null
+++ b/config/assets/icons/battery/battery-100-charging.svg
@@ -0,0 +1,11 @@
+
+
+
+
+
+
diff --git a/config/assets/icons/battery/battery-100.svg b/config/assets/icons/battery/battery-100.svg
new file mode 100644
index 0000000..9d9104b
--- /dev/null
+++ b/config/assets/icons/battery/battery-100.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/config/assets/icons/brightness/brightness-0.svg b/config/assets/icons/brightness/brightness-0.svg
new file mode 100755
index 0000000..d6065c7
--- /dev/null
+++ b/config/assets/icons/brightness/brightness-0.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/config/assets/icons/brightness/brightness-1.svg b/config/assets/icons/brightness/brightness-1.svg
new file mode 100755
index 0000000..e5b4101
--- /dev/null
+++ b/config/assets/icons/brightness/brightness-1.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/config/assets/icons/brightness/brightness-2.svg b/config/assets/icons/brightness/brightness-2.svg
new file mode 100755
index 0000000..df7cfba
--- /dev/null
+++ b/config/assets/icons/brightness/brightness-2.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/config/assets/icons/brightness/brightness-3.svg b/config/assets/icons/brightness/brightness-3.svg
new file mode 100755
index 0000000..c3b390b
--- /dev/null
+++ b/config/assets/icons/brightness/brightness-3.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/config/assets/icons/brightness/brightness.svg b/config/assets/icons/brightness/brightness.svg
new file mode 100755
index 0000000..bcc61ef
--- /dev/null
+++ b/config/assets/icons/brightness/brightness.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/config/assets/icons/mic/microphone-0.svg b/config/assets/icons/mic/microphone-0.svg
new file mode 100755
index 0000000..ef1f97a
--- /dev/null
+++ b/config/assets/icons/mic/microphone-0.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/config/assets/icons/mic/microphone-1.svg b/config/assets/icons/mic/microphone-1.svg
new file mode 100755
index 0000000..dfa3008
--- /dev/null
+++ b/config/assets/icons/mic/microphone-1.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
diff --git a/config/assets/icons/mic/microphone-2.svg b/config/assets/icons/mic/microphone-2.svg
new file mode 100755
index 0000000..0b47426
--- /dev/null
+++ b/config/assets/icons/mic/microphone-2.svg
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/config/assets/icons/mic/microphone-3.svg b/config/assets/icons/mic/microphone-3.svg
new file mode 100755
index 0000000..f48bd31
--- /dev/null
+++ b/config/assets/icons/mic/microphone-3.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
diff --git a/config/assets/icons/mic/microphone.svg b/config/assets/icons/mic/microphone.svg
new file mode 100755
index 0000000..dfa3008
--- /dev/null
+++ b/config/assets/icons/mic/microphone.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
diff --git a/config/assets/icons/misc/chevron-left.svg b/config/assets/icons/misc/chevron-left.svg
new file mode 100644
index 0000000..8cade29
--- /dev/null
+++ b/config/assets/icons/misc/chevron-left.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
diff --git a/config/assets/icons/misc/chevron-right.svg b/config/assets/icons/misc/chevron-right.svg
new file mode 100644
index 0000000..f81b1e6
--- /dev/null
+++ b/config/assets/icons/misc/chevron-right.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
diff --git a/config/assets/icons/misc/control-center.svg b/config/assets/icons/misc/control-center.svg
new file mode 100644
index 0000000..24f2195
--- /dev/null
+++ b/config/assets/icons/misc/control-center.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/config/assets/icons/misc/control.svg b/config/assets/icons/misc/control.svg
new file mode 100644
index 0000000..ed4e9d7
--- /dev/null
+++ b/config/assets/icons/misc/control.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/config/assets/icons/misc/imac.svg b/config/assets/icons/misc/imac.svg
new file mode 100644
index 0000000..47719db
--- /dev/null
+++ b/config/assets/icons/misc/imac.svg
@@ -0,0 +1,133 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/config/assets/icons/misc/logo.svg b/config/assets/icons/misc/logo.svg
new file mode 100644
index 0000000..9fa578d
--- /dev/null
+++ b/config/assets/icons/misc/logo.svg
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/config/assets/icons/misc/media-record.svg b/config/assets/icons/misc/media-record.svg
new file mode 100644
index 0000000..8f71f09
--- /dev/null
+++ b/config/assets/icons/misc/media-record.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
diff --git a/config/assets/icons/misc/search.svg b/config/assets/icons/misc/search.svg
new file mode 100644
index 0000000..ef84365
--- /dev/null
+++ b/config/assets/icons/misc/search.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/config/assets/icons/notification.png b/config/assets/icons/notification.png
new file mode 100644
index 0000000..41d66d6
Binary files /dev/null and b/config/assets/icons/notification.png differ
diff --git a/config/assets/icons/notifications/notification-active.svg b/config/assets/icons/notifications/notification-active.svg
new file mode 100644
index 0000000..63452f8
--- /dev/null
+++ b/config/assets/icons/notifications/notification-active.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
diff --git a/config/assets/icons/notifications/notification-disabled.svg b/config/assets/icons/notifications/notification-disabled.svg
new file mode 100644
index 0000000..d4cb163
--- /dev/null
+++ b/config/assets/icons/notifications/notification-disabled.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
diff --git a/config/assets/icons/notifications/notification-inactive.svg b/config/assets/icons/notifications/notification-inactive.svg
new file mode 100644
index 0000000..108e755
--- /dev/null
+++ b/config/assets/icons/notifications/notification-inactive.svg
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/config/assets/icons/player/Pause.svg b/config/assets/icons/player/Pause.svg
new file mode 100644
index 0000000..f95a5b8
--- /dev/null
+++ b/config/assets/icons/player/Pause.svg
@@ -0,0 +1,7 @@
+
+
+
+ Layer 1
+
+
+
\ No newline at end of file
diff --git a/config/assets/icons/player/Rewind.svg b/config/assets/icons/player/Rewind.svg
new file mode 100644
index 0000000..e52bc1d
--- /dev/null
+++ b/config/assets/icons/player/Rewind.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/config/assets/icons/player/audio-switcher.svg b/config/assets/icons/player/audio-switcher.svg
new file mode 100644
index 0000000..d50264f
--- /dev/null
+++ b/config/assets/icons/player/audio-switcher.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/config/assets/icons/player/fwd.svg b/config/assets/icons/player/fwd.svg
new file mode 100644
index 0000000..57f16c3
--- /dev/null
+++ b/config/assets/icons/player/fwd.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/config/assets/icons/player/play.svg b/config/assets/icons/player/play.svg
new file mode 100644
index 0000000..d3356bf
--- /dev/null
+++ b/config/assets/icons/player/play.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/config/assets/icons/power_modes/battery-balanced.svg b/config/assets/icons/power_modes/battery-balanced.svg
new file mode 100644
index 0000000..9d9104b
--- /dev/null
+++ b/config/assets/icons/power_modes/battery-balanced.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/config/assets/icons/power_modes/battery-performance.svg b/config/assets/icons/power_modes/battery-performance.svg
new file mode 100644
index 0000000..3bd7b42
--- /dev/null
+++ b/config/assets/icons/power_modes/battery-performance.svg
@@ -0,0 +1,116 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/config/assets/icons/power_modes/battery-power.svg b/config/assets/icons/power_modes/battery-power.svg
new file mode 100644
index 0000000..87375ae
--- /dev/null
+++ b/config/assets/icons/power_modes/battery-power.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/config/assets/icons/todo/checkbox-check.svg b/config/assets/icons/todo/checkbox-check.svg
new file mode 100644
index 0000000..198f1dd
--- /dev/null
+++ b/config/assets/icons/todo/checkbox-check.svg
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/config/assets/icons/todo/checkbox-uncheck.svg b/config/assets/icons/todo/checkbox-uncheck.svg
new file mode 100644
index 0000000..a0ee941
--- /dev/null
+++ b/config/assets/icons/todo/checkbox-uncheck.svg
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/config/assets/icons/todo/delete-symbolic.svg b/config/assets/icons/todo/delete-symbolic.svg
new file mode 100644
index 0000000..180f034
--- /dev/null
+++ b/config/assets/icons/todo/delete-symbolic.svg
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
diff --git a/config/assets/icons/todo/edit.svg b/config/assets/icons/todo/edit.svg
new file mode 100644
index 0000000..42b5282
--- /dev/null
+++ b/config/assets/icons/todo/edit.svg
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+
+
+
+
+
+
diff --git a/config/assets/icons/todo/plus-symbolic.svg b/config/assets/icons/todo/plus-symbolic.svg
new file mode 100644
index 0000000..af065bd
--- /dev/null
+++ b/config/assets/icons/todo/plus-symbolic.svg
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
diff --git a/config/assets/icons/todo/to-do-app-symbolic.svg b/config/assets/icons/todo/to-do-app-symbolic.svg
new file mode 100644
index 0000000..17cbcdf
--- /dev/null
+++ b/config/assets/icons/todo/to-do-app-symbolic.svg
@@ -0,0 +1 @@
+
\ 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
new file mode 100644
index 0000000..2728d98
--- /dev/null
+++ b/config/assets/icons/volume/audio-volume-0.svg
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/config/assets/icons/volume/audio-volume-1.svg b/config/assets/icons/volume/audio-volume-1.svg
new file mode 100644
index 0000000..8378bed
--- /dev/null
+++ b/config/assets/icons/volume/audio-volume-1.svg
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/config/assets/icons/volume/audio-volume-2.svg b/config/assets/icons/volume/audio-volume-2.svg
new file mode 100644
index 0000000..927462a
--- /dev/null
+++ b/config/assets/icons/volume/audio-volume-2.svg
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/config/assets/icons/volume/audio-volume-3.svg b/config/assets/icons/volume/audio-volume-3.svg
new file mode 100644
index 0000000..abd00cb
--- /dev/null
+++ b/config/assets/icons/volume/audio-volume-3.svg
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/config/assets/icons/volume/audio-volume.svg b/config/assets/icons/volume/audio-volume.svg
new file mode 100644
index 0000000..abd00cb
--- /dev/null
+++ b/config/assets/icons/volume/audio-volume.svg
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/config/assets/icons/wifi/network-wireless-0.svg b/config/assets/icons/wifi/network-wireless-0.svg
new file mode 100644
index 0000000..79a7d67
--- /dev/null
+++ b/config/assets/icons/wifi/network-wireless-0.svg
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
diff --git a/config/assets/icons/wifi/network-wireless-100.svg b/config/assets/icons/wifi/network-wireless-100.svg
new file mode 100644
index 0000000..9276ebe
--- /dev/null
+++ b/config/assets/icons/wifi/network-wireless-100.svg
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
diff --git a/config/assets/icons/wifi/network-wireless-20.svg b/config/assets/icons/wifi/network-wireless-20.svg
new file mode 100644
index 0000000..5dd4259
--- /dev/null
+++ b/config/assets/icons/wifi/network-wireless-20.svg
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
diff --git a/config/assets/icons/wifi/network-wireless-40.svg b/config/assets/icons/wifi/network-wireless-40.svg
new file mode 100644
index 0000000..d323c5c
--- /dev/null
+++ b/config/assets/icons/wifi/network-wireless-40.svg
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
diff --git a/config/assets/icons/wifi/network-wireless-60.svg b/config/assets/icons/wifi/network-wireless-60.svg
new file mode 100644
index 0000000..47c2a37
--- /dev/null
+++ b/config/assets/icons/wifi/network-wireless-60.svg
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
diff --git a/config/assets/icons/wifi/network-wireless-80.svg b/config/assets/icons/wifi/network-wireless-80.svg
new file mode 100644
index 0000000..6e65071
--- /dev/null
+++ b/config/assets/icons/wifi/network-wireless-80.svg
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
diff --git a/config/assets/icons/wifi/wifi-connecting.svg b/config/assets/icons/wifi/wifi-connecting.svg
new file mode 100644
index 0000000..6d95e92
--- /dev/null
+++ b/config/assets/icons/wifi/wifi-connecting.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/config/assets/launcher.json b/config/assets/launcher.json
new file mode 100644
index 0000000..9d411e0
--- /dev/null
+++ b/config/assets/launcher.json
@@ -0,0 +1,98 @@
+{
+ "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
new file mode 100644
index 0000000..e4aa977
--- /dev/null
+++ b/config/data.py
@@ -0,0 +1,118 @@
+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
new file mode 100644
index 0000000..cfa51cb
--- /dev/null
+++ b/config/hypr/modus.conf
@@ -0,0 +1,53 @@
+# 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
new file mode 100644
index 0000000..9aeb090
--- /dev/null
+++ b/config/matugen/templates/hyprland-colors.conf
@@ -0,0 +1,27 @@
+$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
new file mode 100644
index 0000000..7d8c607
--- /dev/null
+++ b/config/matugen/templates/macos.css
@@ -0,0 +1,32 @@
+: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
new file mode 100644
index 0000000..2e175e7
--- /dev/null
+++ b/debug_memory.py
@@ -0,0 +1,165 @@
+#!/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
new file mode 100755
index 0000000..e17294d
--- /dev/null
+++ b/install.sh
@@ -0,0 +1,187 @@
+#!/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
new file mode 100644
index 0000000..86a889c
--- /dev/null
+++ b/lock.py
@@ -0,0 +1,234 @@
+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
new file mode 100644
index 0000000..738870f
--- /dev/null
+++ b/main.css
@@ -0,0 +1,34 @@
+@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
new file mode 100644
index 0000000..1ba20cf
--- /dev/null
+++ b/main.py
@@ -0,0 +1,88 @@
+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
new file mode 100644
index 0000000..46c7f1c
--- /dev/null
+++ b/modules/about.py
@@ -0,0 +1,537 @@
+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
new file mode 100644
index 0000000..068de6e
--- /dev/null
+++ b/modules/controlcenter/battery.py
@@ -0,0 +1,393 @@
+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
new file mode 100644
index 0000000..08346aa
--- /dev/null
+++ b/modules/controlcenter/bluetooth.py
@@ -0,0 +1,613 @@
+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
new file mode 100644
index 0000000..99d0ad4
--- /dev/null
+++ b/modules/controlcenter/expanded_player.py
@@ -0,0 +1,1398 @@
+# 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
new file mode 100644
index 0000000..cfa00bc
--- /dev/null
+++ b/modules/controlcenter/main.py
@@ -0,0 +1,1078 @@
+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
new file mode 100644
index 0000000..c5414c2
--- /dev/null
+++ b/modules/controlcenter/nightlight.py
@@ -0,0 +1,146 @@
+# 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
new file mode 100644
index 0000000..08ba18a
--- /dev/null
+++ b/modules/controlcenter/per_app_volume.py
@@ -0,0 +1,336 @@
+# 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
new file mode 100644
index 0000000..d5a6fd4
--- /dev/null
+++ b/modules/controlcenter/player.py
@@ -0,0 +1,955 @@
+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
new file mode 100644
index 0000000..6cb612d
--- /dev/null
+++ b/modules/controlcenter/wifi.py
@@ -0,0 +1,715 @@
+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
new file mode 100644
index 0000000..cf3c79e
--- /dev/null
+++ b/modules/corners.py
@@ -0,0 +1,68 @@
+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
new file mode 100644
index 0000000..0c836f4
--- /dev/null
+++ b/modules/dock.py
@@ -0,0 +1,843 @@
+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
new file mode 100644
index 0000000..547fa73
--- /dev/null
+++ b/modules/launcher/__init__.py
@@ -0,0 +1,8 @@
+"""
+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
new file mode 100644
index 0000000..30ada34
--- /dev/null
+++ b/modules/launcher/main.py
@@ -0,0 +1,1380 @@
+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
new file mode 100644
index 0000000..13bd1fc
--- /dev/null
+++ b/modules/launcher/plugin_base.py
@@ -0,0 +1,172 @@
+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
new file mode 100644
index 0000000..caf002e
--- /dev/null
+++ b/modules/launcher/plugin_manager.py
@@ -0,0 +1,183 @@
+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
new file mode 100644
index 0000000..666274b
--- /dev/null
+++ b/modules/launcher/plugins/__init__.py
@@ -0,0 +1,3 @@
+"""
+Built-in plugins for the launcher.
+"""
diff --git a/modules/launcher/plugins/applications.py b/modules/launcher/plugins/applications.py
new file mode 100644
index 0000000..832d75b
--- /dev/null
+++ b/modules/launcher/plugins/applications.py
@@ -0,0 +1,185 @@
+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
new file mode 100644
index 0000000..44bb2e8
--- /dev/null
+++ b/modules/launcher/plugins/bash_scripts.py
@@ -0,0 +1,431 @@
+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
new file mode 100644
index 0000000..ee55139
--- /dev/null
+++ b/modules/launcher/plugins/bookmarks.py
@@ -0,0 +1,786 @@
+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
new file mode 100644
index 0000000..decde97
--- /dev/null
+++ b/modules/launcher/plugins/caffeine.py
@@ -0,0 +1,208 @@
+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
new file mode 100644
index 0000000..f97dddb
--- /dev/null
+++ b/modules/launcher/plugins/calculator.py
@@ -0,0 +1,163 @@
+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
new file mode 100644
index 0000000..e332d19
--- /dev/null
+++ b/modules/launcher/plugins/clipboard.py
@@ -0,0 +1,438 @@
+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
new file mode 100644
index 0000000..8842e80
--- /dev/null
+++ b/modules/launcher/plugins/emoji.py
@@ -0,0 +1,187 @@
+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
new file mode 100644
index 0000000..7ab0b6d
--- /dev/null
+++ b/modules/launcher/plugins/otp.py
@@ -0,0 +1,850 @@
+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
new file mode 100644
index 0000000..1551b20
--- /dev/null
+++ b/modules/launcher/plugins/password.py
@@ -0,0 +1,690 @@
+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
new file mode 100644
index 0000000..38b9cd8
--- /dev/null
+++ b/modules/launcher/plugins/power.py
@@ -0,0 +1,101 @@
+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
new file mode 100644
index 0000000..ded8c20
--- /dev/null
+++ b/modules/launcher/plugins/reminders.py
@@ -0,0 +1,448 @@
+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
new file mode 100644
index 0000000..05253b6
--- /dev/null
+++ b/modules/launcher/plugins/screencapture.py
@@ -0,0 +1,687 @@
+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
new file mode 100644
index 0000000..bc7dba0
--- /dev/null
+++ b/modules/launcher/plugins/system.py
@@ -0,0 +1,242 @@
+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
new file mode 100644
index 0000000..5c93fad
--- /dev/null
+++ b/modules/launcher/plugins/tmux.py
@@ -0,0 +1,334 @@
+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
new file mode 100644
index 0000000..3d4aa28
--- /dev/null
+++ b/modules/launcher/plugins/wallpaper.py
@@ -0,0 +1,976 @@
+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
new file mode 100644
index 0000000..80f992b
--- /dev/null
+++ b/modules/launcher/plugins/websearch.py
@@ -0,0 +1,259 @@
+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
new file mode 100644
index 0000000..fce3550
--- /dev/null
+++ b/modules/launcher/result.py
@@ -0,0 +1,52 @@
+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
new file mode 100644
index 0000000..baaaa43
--- /dev/null
+++ b/modules/launcher/result_item.py
@@ -0,0 +1,163 @@
+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
new file mode 100644
index 0000000..3a568c4
--- /dev/null
+++ b/modules/launcher/trigger_config.py
@@ -0,0 +1,57 @@
+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
new file mode 100644
index 0000000..77be746
--- /dev/null
+++ b/modules/notification/notification.py
@@ -0,0 +1,1281 @@
+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
new file mode 100644
index 0000000..3aa77b0
--- /dev/null
+++ b/modules/notification/notification_center.py
@@ -0,0 +1,923 @@
+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
new file mode 100644
index 0000000..0bd673b
--- /dev/null
+++ b/modules/notification/unified_cache.py
@@ -0,0 +1,197 @@
+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
new file mode 100644
index 0000000..6b27aaa
--- /dev/null
+++ b/modules/osd.py
@@ -0,0 +1,467 @@
+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
new file mode 100644
index 0000000..ac7e63b
--- /dev/null
+++ b/modules/panel/components/enhanced_system_tray.py
@@ -0,0 +1,165 @@
+"""
+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
new file mode 100644
index 0000000..7e8e277
--- /dev/null
+++ b/modules/panel/components/indicators.py
@@ -0,0 +1,477 @@
+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
new file mode 100644
index 0000000..61e7735
--- /dev/null
+++ b/modules/panel/components/menubar.py
@@ -0,0 +1,411 @@
+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
new file mode 100644
index 0000000..ab318e3
--- /dev/null
+++ b/modules/panel/components/recording_indicator.py
@@ -0,0 +1,236 @@
+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
new file mode 100644
index 0000000..c05dab0
--- /dev/null
+++ b/modules/panel/components/workspace.py
@@ -0,0 +1,44 @@
+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
new file mode 100644
index 0000000..5228e7c
--- /dev/null
+++ b/modules/panel/main.py
@@ -0,0 +1,243 @@
+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
new file mode 100644
index 0000000..564c54a
--- /dev/null
+++ b/modules/switcher.py
@@ -0,0 +1,405 @@
+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
new file mode 100644
index 0000000..30afe86
--- /dev/null
+++ b/modules/todo/__init__.py
@@ -0,0 +1 @@
+# Todo module
\ No newline at end of file
diff --git a/modules/todo/todo_widget.py b/modules/todo/todo_widget.py
new file mode 100644
index 0000000..a19cb96
--- /dev/null
+++ b/modules/todo/todo_widget.py
@@ -0,0 +1,456 @@
+# 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
new file mode 100644
index 0000000..21ce287
--- /dev/null
+++ b/modules/widget.py
@@ -0,0 +1,887 @@
+# 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
new file mode 100644
index 0000000..1d0d377
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,17 @@
+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
new file mode 100755
index 0000000..3652ccd
--- /dev/null
+++ b/scripts/gamemode.sh
@@ -0,0 +1,40 @@
+#!/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
new file mode 100755
index 0000000..703c7df
--- /dev/null
+++ b/scripts/hyprpicker.sh
@@ -0,0 +1,75 @@
+#!/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
new file mode 100755
index 0000000..a1fd899
--- /dev/null
+++ b/scripts/screen-capture.sh
@@ -0,0 +1,704 @@
+#!/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
new file mode 100644
index 0000000..3f03f12
--- /dev/null
+++ b/services/__init__.py
@@ -0,0 +1,4 @@
+"""
+Modus services package.
+Contains background services and utilities for the shell.
+"""
diff --git a/services/auth.py b/services/auth.py
new file mode 100644
index 0000000..3a7b070
--- /dev/null
+++ b/services/auth.py
@@ -0,0 +1,348 @@
+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
new file mode 100644
index 0000000..a28cceb
--- /dev/null
+++ b/services/battery.py
@@ -0,0 +1,251 @@
+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
new file mode 100644
index 0000000..1031444
--- /dev/null
+++ b/services/brightness.py
@@ -0,0 +1,96 @@
+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
new file mode 100644
index 0000000..7619887
--- /dev/null
+++ b/services/custom_notification.py
@@ -0,0 +1,462 @@
+# 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
new file mode 100644
index 0000000..f9e9444
--- /dev/null
+++ b/services/modus.py
@@ -0,0 +1,359 @@
+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
new file mode 100644
index 0000000..539a727
--- /dev/null
+++ b/services/mpris.py
@@ -0,0 +1,303 @@
+# 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
new file mode 100644
index 0000000..619e81d
--- /dev/null
+++ b/services/network.py
@@ -0,0 +1,667 @@
+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
new file mode 100644
index 0000000..876505e
--- /dev/null
+++ b/services/todo.py
@@ -0,0 +1,169 @@
+# 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
new file mode 100644
index 0000000..77ace66
--- /dev/null
+++ b/styles/about.css
@@ -0,0 +1,79 @@
+#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
new file mode 100644
index 0000000..763cfe4
--- /dev/null
+++ b/styles/battery-widget.css
@@ -0,0 +1,149 @@
+#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
new file mode 100644
index 0000000..1a391d3
--- /dev/null
+++ b/styles/colors.css
@@ -0,0 +1,32 @@
+: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
new file mode 100644
index 0000000..a60738d
--- /dev/null
+++ b/styles/controlcenter.css
@@ -0,0 +1,988 @@
+.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
new file mode 100644
index 0000000..24eb0f7
--- /dev/null
+++ b/styles/dock.css
@@ -0,0 +1,98 @@
+#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
new file mode 100644
index 0000000..2f036ad
--- /dev/null
+++ b/styles/dropdown.css
@@ -0,0 +1,61 @@
+#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
new file mode 100644
index 0000000..24382a4
--- /dev/null
+++ b/styles/launcher.css
@@ -0,0 +1,230 @@
+#launcher {
+ background-color: alpha(#000, 0.3);
+ padding: 0;
+ border-radius: 12px;
+ min-width: 640px;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+}
+
+/* #launcher-search { */
+/* color: #000; */
+/* } */
+#header_box {
+ padding: 0px 20px 0 20px;
+ color: #000;
+}
+
+#close-button,
+#config-button {
+ background-color: transparent;
+ border-radius: 6px;
+ padding: 6px;
+ transition: all 0.15s ease;
+}
+
+#close-button:hover,
+#close-button:focus,
+#config-button:hover,
+#config-button:focus {
+ background-color: rgba(255, 255, 255, 0.1);
+ border-radius: 6px;
+}
+
+#close-button.focused,
+#config-button.focused {
+ background-color: rgba(0, 122, 255, 0.2);
+ border-radius: 6px;
+ box-shadow: 0 0 0 2px rgba(0, 122, 255, 0.4);
+}
+
+#close-button.focused #close-label {
+ color: #007aff;
+}
+
+#config-button.focused #config-label {
+ color: #007aff;
+}
+
+#close-button:active {
+ background-color: rgba(255, 255, 255, 0.2);
+ border-radius: 6px;
+}
+
+#close-label {
+ color: rgba(255, 255, 255, 0.7);
+ font-size: 18px;
+}
+
+#close-button:active #close-label {
+ color: rgba(255, 255, 255, 0.9);
+}
+
+#config-button:active {
+ background-color: rgba(255, 255, 255, 0.2);
+ border-radius: 6px;
+}
+
+#config-label {
+ color: rgba(255, 255, 255, 0.7);
+ font-size: 18px;
+}
+
+#config-button:active #config-label {
+ color: rgba(255, 255, 255, 0.9);
+}
+
+#launcher-icon-label {
+ font-size: 20px;
+ padding: 6px;
+ color: rgba(255, 255, 255, 0.8);
+}
+
+#launcher-search {
+ font-weight: 400;
+ font-size: 36px;
+ background-color: transparent;
+ color: rgba(255, 255, 255, 0.95);
+ border: none;
+ border-radius: 0;
+ padding: 12px 0;
+ margin: 0;
+}
+
+#launcher-search:focus {
+ background-color: transparent;
+ box-shadow: none;
+ border: none;
+}
+
+#launcher-search selection {
+ color: white;
+ background-color: rgba(0, 122, 255, 0.8);
+}
+
+#launcher-results-scroll {
+ margin: 0;
+ border-radius: 0;
+ background: transparent;
+ padding: 0 20px 20px 20px;
+}
+
+#launcher-results-scroll scrollbar {
+ border-radius: 0;
+ background-color: transparent;
+ padding: 0;
+ margin: 0;
+ min-width: 0;
+}
+
+#launcher-results-scroll scrollbar slider {
+ border-radius: 2px;
+ min-width: 4px;
+ min-height: 20px;
+ background-color: rgba(255, 255, 255, 0.3);
+ margin: 0;
+}
+
+#launcher-results-scroll scrollbar:hover slider {
+ background-color: rgba(255, 255, 255, 0.5);
+}
+
+#launcher-results {
+ background: transparent;
+ margin-top: 8px;
+}
+
+#launcher-result-item {
+ border-radius: 8px;
+ padding: 12px 16px;
+ margin: 2px 0;
+ min-height: 64px;
+ transition: all 0.15s ease;
+ background: transparent;
+}
+
+@keyframes loadSlot {
+ 0% {
+ opacity: 0;
+ }
+
+ 100% {
+ opacity: 1;
+ }
+}
+
+#launcher-result-item:focus,
+#launcher-result-item:selected,
+#launcher-result-item:hover,
+#launcher-result-item.selected {
+ border-radius: 8px;
+ background-color: rgba(0, 122, 255, 0.8);
+ padding: 12px 16px;
+ margin: 2px 0;
+}
+
+#launcher-result-item.selected #result-item-title {
+ color: white;
+ font-weight: 600;
+}
+
+#launcher-result-item.selected #result-item-subtitle {
+ color: rgba(255, 255, 255, 0.8);
+}
+
+#launcher-result-item.selected #result-item-plugin {
+ color: rgba(255, 255, 255, 0.6);
+}
+
+#result-item-main {
+ min-height: 56px;
+}
+
+#launcher-result-item {
+ min-height: 56px;
+}
+
+#result-item-icon {
+ min-width: 56px;
+ min-height: 56px;
+ margin-right: 16px;
+ border-radius: 12px;
+}
+
+#result-item-title {
+ font-size: 18px;
+ font-weight: 500;
+ color: rgba(255, 255, 255, 0.95);
+ margin-top: 4px;
+ margin-bottom: 2px;
+}
+
+#result-item-subtitle {
+ font-size: 14px;
+ color: rgba(255, 255, 255, 0.6);
+ margin-bottom: 4px;
+}
+
+#result-item-plugin {
+ font-size: 12px;
+ color: rgba(255, 255, 255, 0.4);
+ font-style: normal;
+ margin-bottom: 4px;
+ opacity: 1;
+ font-weight: 400;
+}
+
+#network-password-entry {
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ background: rgba(255, 255, 255, 0.05);
+ color: rgba(255, 255, 255, 0.9);
+ padding: 12px;
+ border-radius: 8px;
+ margin-bottom: 8px;
+ font-size: 14px;
+}
+
+#network-password-entry:focus {
+ border: 1px solid rgba(0, 122, 255, 0.6);
+ background: rgba(255, 255, 255, 0.1);
+ box-shadow: 0 0 0 3px rgba(0, 122, 255, 0.2);
+}
diff --git a/styles/lock.css b/styles/lock.css
new file mode 100644
index 0000000..00fe279
--- /dev/null
+++ b/styles/lock.css
@@ -0,0 +1,65 @@
+#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
new file mode 100644
index 0000000..0c041d3
--- /dev/null
+++ b/styles/notification-center.css
@@ -0,0 +1,297 @@
+#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
new file mode 100644
index 0000000..35a7db0
--- /dev/null
+++ b/styles/notification.css
@@ -0,0 +1,84 @@
+#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
new file mode 100644
index 0000000..c4c2082
--- /dev/null
+++ b/styles/osd.css
@@ -0,0 +1,38 @@
+#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
new file mode 100644
index 0000000..218c358
--- /dev/null
+++ b/styles/panel.css
@@ -0,0 +1,147 @@
+#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
new file mode 100644
index 0000000..1a9b651
--- /dev/null
+++ b/styles/player.css
@@ -0,0 +1,178 @@
+#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
new file mode 100644
index 0000000..5302281
--- /dev/null
+++ b/styles/switcher.css
@@ -0,0 +1,38 @@
+#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
new file mode 100644
index 0000000..a65ddf4
--- /dev/null
+++ b/styles/todo.css
@@ -0,0 +1,258 @@
+/* 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
new file mode 100644
index 0000000..d10b0ef
--- /dev/null
+++ b/styles/tray.css
@@ -0,0 +1,50 @@
+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
new file mode 100644
index 0000000..8656200
--- /dev/null
+++ b/styles/widgets.css
@@ -0,0 +1,218 @@
+/* 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
new file mode 100644
index 0000000..3f03f12
--- /dev/null
+++ b/utils/__init__.py
@@ -0,0 +1,4 @@
+"""
+Modus services package.
+Contains background services and utilities for the shell.
+"""
diff --git a/utils/animator.py b/utils/animator.py
new file mode 100644
index 0000000..7dd063d
--- /dev/null
+++ b/utils/animator.py
@@ -0,0 +1,180 @@
+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
new file mode 100644
index 0000000..13b76df
--- /dev/null
+++ b/utils/app_name_resolver.py
@@ -0,0 +1,93 @@
+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
new file mode 100644
index 0000000..c39b4da
--- /dev/null
+++ b/utils/conversion.py
@@ -0,0 +1,620 @@
+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
new file mode 100644
index 0000000..edbf259
--- /dev/null
+++ b/utils/functions.py
@@ -0,0 +1,174 @@
+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
new file mode 100644
index 0000000..58ac1e4
--- /dev/null
+++ b/utils/icon_resolver.py
@@ -0,0 +1,106 @@
+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
new file mode 100644
index 0000000..6b5a106
--- /dev/null
+++ b/utils/inhibit.py
@@ -0,0 +1,186 @@
+# 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
new file mode 100644
index 0000000..01b706e
--- /dev/null
+++ b/utils/monitors.py
@@ -0,0 +1,60 @@
+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
new file mode 100644
index 0000000..b81ee75
--- /dev/null
+++ b/utils/occlusion.py
@@ -0,0 +1,140 @@
+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
new file mode 100644
index 0000000..4127d98
--- /dev/null
+++ b/utils/roam.py
@@ -0,0 +1,41 @@
+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
new file mode 100644
index 0000000..aefd7e0
--- /dev/null
+++ b/widgets/circle_image.py
@@ -0,0 +1,125 @@
+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
new file mode 100644
index 0000000..1aae7f6
--- /dev/null
+++ b/widgets/custom_image.py
@@ -0,0 +1,39 @@
+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
new file mode 100644
index 0000000..bf47e52
--- /dev/null
+++ b/widgets/customrevealer.py
@@ -0,0 +1,320 @@
+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
new file mode 100644
index 0000000..cb159c9
--- /dev/null
+++ b/widgets/dropdown.py
@@ -0,0 +1,91 @@
+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
new file mode 100644
index 0000000..af432fc
--- /dev/null
+++ b/widgets/mousecapture.py
@@ -0,0 +1,142 @@
+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
new file mode 100644
index 0000000..d6d600c
--- /dev/null
+++ b/widgets/popup_window.py
@@ -0,0 +1,191 @@
+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
new file mode 100644
index 0000000..b7c76aa
--- /dev/null
+++ b/widgets/wayland.py
@@ -0,0 +1,370 @@
+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
new file mode 100644
index 0000000..9d53586
--- /dev/null
+++ b/widgets/wifi_password_dialog.py
@@ -0,0 +1,291 @@
+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()