diff --git a/.gitignore b/.gitignore
index db0e77f..b8a5f21 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,13 @@
-__pycache__
+
+**/__pycache__/
+*.pyc
+*.pyo
+*.pyd
config/assets/config.json
+config/assets/accounts.json
+config/assets/passwords.json
+config/assets/bookmarks.json
+config/assets/screentime.json
config/hypr/modus.conf
+config/hypr/colors.conf
+# styles/colors.css
diff --git a/README.md b/README.md
index a64fcac..7536909 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,3 @@
-
-
It may not work on your machine
-
-
@@ -33,93 +29,14 @@
-> [!WARNING]
-> This configuration is tailored to my preferences with little regard for others. It's not designed to be "good"โjust comfortable for me.
-
- Installation
-
-> [!NOTE]
-> You need a functioning Hyprland installation.
-
-### Arch Linux
-
-> [!NOTE]
-> You need to clone it into the Home directory.
-
-> [!TIP]
-> This command also works for updating an existing installation!
-
-```bash
-curl -fsSL https://raw.githubusercontent.com/S4NKALP/Modus/main/install.sh | bash
-```
-
-### Manual Installation
-
-1. Dependencies:
-
-```sh
-python-fabric-git fabric-cli gnome-bluetooth-3.0 wf-recorder cliphist wl-clipboard grimblast power-profile-daemon adw-gtk-theme brightnessctl swww hypridle hyprlock imagemagick libnotify swappy libadwaita wlinhibit
-```
-
-```sh
-python-materialyoucolor-git python-pillow python-toml python-ijson python-setproctitle python-requests python-numpy python-pywayland python-pyxdg
-```
-
-```sh
-ttf-font-awesome otf-font-awesome ttf-material-symbols-variable-git ttf-google-sans ttf-opensans ttf-robot
-```
-
-- Icon:
-
-```sh
-git clone https://github.com/vinceliuice/Tela-icon-theme
-cd Tela-icon-theme
-./install.sh nord
-```
-
- Screenshots
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Todo
-- [x] App Launcher
-- [x] Power Menu
-- [x] Wallpaper Picker
-- [x] Todo
-- [x] Bluetooth Manager
-- [x] Clipboard Manager
-- [x] OSD
-- [x] Sh Runner
-- [x] Emoji Picker
-- [x] Update Screenshot
-- [x] Installation script
-- [x] Network Manager
-- [x] Calendar
-- [x] Power Manager
+- [x] Launcher
- [x] Dock
-- [x] Web search in Launcher
-- [ ] Notification Panel
-- [ ] Improve CSS Styling
-- [ ] Update Screenshot
+- [ ] Application Switcher
+- [ ] Notification
+- [ ] Control Center
+- [ ] Panel Widget
## Special Thanks
@@ -128,6 +45,5 @@ A big thank you to the following people for their incredible help with code and
- [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.
-- [koeqaife](https://github.com/koeqaife): thanks for material-colors scripts
I truly appreciate your support
diff --git a/assets/screenshots/bluetooth.png b/assets/screenshots/bluetooth.png
deleted file mode 100644
index 7033d48..0000000
Binary files a/assets/screenshots/bluetooth.png and /dev/null differ
diff --git a/assets/screenshots/cliphist.png b/assets/screenshots/cliphist.png
deleted file mode 100644
index 6e21db0..0000000
Binary files a/assets/screenshots/cliphist.png and /dev/null differ
diff --git a/assets/screenshots/emoji.png b/assets/screenshots/emoji.png
deleted file mode 100644
index 3202bf9..0000000
Binary files a/assets/screenshots/emoji.png and /dev/null differ
diff --git a/assets/screenshots/home.png b/assets/screenshots/home.png
index c7ba7c3..134dbae 100644
Binary files a/assets/screenshots/home.png and b/assets/screenshots/home.png differ
diff --git a/assets/screenshots/notification.png b/assets/screenshots/notification.png
deleted file mode 100644
index c8c9f30..0000000
Binary files a/assets/screenshots/notification.png and /dev/null differ
diff --git a/assets/screenshots/osd.png b/assets/screenshots/osd.png
deleted file mode 100644
index a02dec6..0000000
Binary files a/assets/screenshots/osd.png and /dev/null differ
diff --git a/assets/screenshots/powermenu.png b/assets/screenshots/powermenu.png
deleted file mode 100644
index 165fc0e..0000000
Binary files a/assets/screenshots/powermenu.png and /dev/null differ
diff --git a/assets/screenshots/shrunner.png b/assets/screenshots/shrunner.png
deleted file mode 100644
index 40f184b..0000000
Binary files a/assets/screenshots/shrunner.png and /dev/null differ
diff --git a/assets/screenshots/todo.png b/assets/screenshots/todo.png
deleted file mode 100644
index 0718afb..0000000
Binary files a/assets/screenshots/todo.png and /dev/null differ
diff --git a/assets/screenshots/wallpaper.png b/assets/screenshots/wallpaper.png
deleted file mode 100644
index 4e6789b..0000000
Binary files a/assets/screenshots/wallpaper.png and /dev/null differ
diff --git a/config/assets/dock.json b/config/assets/dock.json
new file mode 100644
index 0000000..41c04ec
--- /dev/null
+++ b/config/assets/dock.json
@@ -0,0 +1,27 @@
+{
+ "pinned_apps": [
+ {
+ "name": "kitty",
+ "display_name": "kitty",
+ "executable": "kitty",
+ "command_line": "kitty",
+ "icon_name": "kitty"
+ },
+ {
+ "name": "Zen Browser",
+ "display_name": "Zen Browser",
+ "window_class": "zen",
+ "executable": "/opt/zen-browser-bin/zen-bin",
+ "command_line": "/opt/zen-browser-bin/zen-bin %u",
+ "icon_name": "zen-browser"
+ },
+ {
+ "name": "Visual Studio Code",
+ "display_name": "Visual Studio Code",
+ "window_class": "Code",
+ "executable": "/usr/bin/code",
+ "command_line": "/usr/bin/code %F",
+ "icon_name": "visual-studio-code"
+ }
+ ]
+}
\ No newline at end of file
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/1bluetooth-off.svg b/config/assets/icons/1bluetooth-off.svg
new file mode 100644
index 0000000..b8f4651
--- /dev/null
+++ b/config/assets/icons/1bluetooth-off.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/config/assets/icons/1bluetooth.svg b/config/assets/icons/1bluetooth.svg
new file mode 100644
index 0000000..c98d365
--- /dev/null
+++ b/config/assets/icons/1bluetooth.svg
@@ -0,0 +1,3 @@
+
+
+
\ No newline at end of file
diff --git a/config/assets/icons/1search.svg b/config/assets/icons/1search.svg
new file mode 100644
index 0000000..9b396bb
--- /dev/null
+++ b/config/assets/icons/1search.svg
@@ -0,0 +1,5 @@
+
+
+
+
\ No newline at end of file
diff --git a/config/assets/icons/audio-volume.svg b/config/assets/icons/audio-volume.svg
new file mode 100644
index 0000000..e4df579
--- /dev/null
+++ b/config/assets/icons/audio-volume.svg
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/config/assets/icons/battery.svg b/config/assets/icons/battery.svg
new file mode 100644
index 0000000..8c4ef65
--- /dev/null
+++ b/config/assets/icons/battery.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
\ No newline at end of file
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/bluetooth-activated.svg b/config/assets/icons/bluetooth-activated.svg
new file mode 100644
index 0000000..e3ef1e1
--- /dev/null
+++ b/config/assets/icons/bluetooth-activated.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
diff --git a/config/assets/icons/bluetooth-off.svg b/config/assets/icons/bluetooth-off.svg
new file mode 100644
index 0000000..b90cd7a
--- /dev/null
+++ b/config/assets/icons/bluetooth-off.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/config/assets/icons/bluetooth.svg b/config/assets/icons/bluetooth.svg
new file mode 100644
index 0000000..f982699
--- /dev/null
+++ b/config/assets/icons/bluetooth.svg
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/config/assets/icons/chevron-left.svg b/config/assets/icons/chevron-left.svg
new file mode 100644
index 0000000..8cade29
--- /dev/null
+++ b/config/assets/icons/chevron-left.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
diff --git a/config/assets/icons/chevron-right.svg b/config/assets/icons/chevron-right.svg
new file mode 100644
index 0000000..f81b1e6
--- /dev/null
+++ b/config/assets/icons/chevron-right.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
diff --git a/config/assets/icons/control-center.svg b/config/assets/icons/control-center.svg
new file mode 100644
index 0000000..24f2195
--- /dev/null
+++ b/config/assets/icons/control-center.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/config/assets/icons/dnd-off.svg b/config/assets/icons/dnd-off.svg
new file mode 100644
index 0000000..28c496d
--- /dev/null
+++ b/config/assets/icons/dnd-off.svg
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/config/assets/icons/dnd.svg b/config/assets/icons/dnd.svg
new file mode 100644
index 0000000..4ecdd99
--- /dev/null
+++ b/config/assets/icons/dnd.svg
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/config/assets/icons/imac.png b/config/assets/icons/imac.png
new file mode 100644
index 0000000..c344ad2
Binary files /dev/null and b/config/assets/icons/imac.png differ
diff --git a/config/assets/icons/imac.svg b/config/assets/icons/imac.svg
new file mode 100644
index 0000000..47719db
--- /dev/null
+++ b/config/assets/icons/imac.svg
@@ -0,0 +1,133 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/config/assets/icons/indicator.svg b/config/assets/icons/indicator.svg
new file mode 100644
index 0000000..db3bede
--- /dev/null
+++ b/config/assets/icons/indicator.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/config/assets/icons/logo.svg b/config/assets/icons/logo.svg
new file mode 100644
index 0000000..3710b28
--- /dev/null
+++ b/config/assets/icons/logo.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/config/assets/icons/media-record.svg b/config/assets/icons/media-record.svg
new file mode 100644
index 0000000..8f71f09
--- /dev/null
+++ b/config/assets/icons/media-record.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
diff --git a/config/assets/icons/search.svg b/config/assets/icons/search.svg
new file mode 100644
index 0000000..0ea0f28
--- /dev/null
+++ b/config/assets/icons/search.svg
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
diff --git a/config/assets/icons/volume/audio-volume-high.svg b/config/assets/icons/volume/audio-volume-high.svg
new file mode 100644
index 0000000..97cc0b7
--- /dev/null
+++ b/config/assets/icons/volume/audio-volume-high.svg
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/config/assets/icons/volume/audio-volume-low.svg b/config/assets/icons/volume/audio-volume-low.svg
new file mode 100644
index 0000000..80e548b
--- /dev/null
+++ b/config/assets/icons/volume/audio-volume-low.svg
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/config/assets/icons/volume/audio-volume-medium.svg b/config/assets/icons/volume/audio-volume-medium.svg
new file mode 100644
index 0000000..6f2aaa2
--- /dev/null
+++ b/config/assets/icons/volume/audio-volume-medium.svg
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/config/assets/icons/volume/audio-volume-muted.svg b/config/assets/icons/volume/audio-volume-muted.svg
new file mode 100644
index 0000000..7ced706
--- /dev/null
+++ b/config/assets/icons/volume/audio-volume-muted.svg
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/config/assets/icons/wifi-off.svg b/config/assets/icons/wifi-off.svg
new file mode 100644
index 0000000..ea4a074
--- /dev/null
+++ b/config/assets/icons/wifi-off.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/config/assets/icons/wifi.svg b/config/assets/icons/wifi.svg
new file mode 100644
index 0000000..f041926
--- /dev/null
+++ b/config/assets/icons/wifi.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/config/assets/launcher.json b/config/assets/launcher.json
new file mode 100644
index 0000000..2890dc5
--- /dev/null
+++ b/config/assets/launcher.json
@@ -0,0 +1,97 @@
+{
+ "launcher_config": {
+ "em": {
+ "icon": "emoji",
+ "description": "Emoji - Search and copy emojis"
+ },
+ "clip": {
+ "icon": "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": "coffee",
+ "description": "Caffeine - Prevent system from going idle"
+ },
+ "sc": {
+ "icon": "toolbox",
+ "description": "Screencapture - Record screen and audio"
+ },
+ "wall": {
+ "icon": "wallpapers",
+ "description": "Wallpapers - Set wallpapers, and tons of features"
+ },
+ "?": {
+ "icon": "world",
+ "description": "Quick Web Search - Fast web search with question mark",
+ "examples": ["? cats", "? google cats", "? youtube music"]
+ },
+ "kanban": {
+ "icon": "kanban",
+ "description": "Kanban - Task management with kanban-style columns"
+ },
+ "cal": {
+ "icon": "calendar",
+ "description": "Calendar - View calendar, navigate dates, and get date/time info"
+ },
+ "remind": {
+ "icon": "timer_on",
+ "description": "Reminders - Set time-based reminders with notifications"
+ },
+ "otp": {
+ "icon": "auth",
+ "description": "Manage TOTP codes and 2FA authentication"
+ },
+ "pass": {
+ "icon": "key",
+ "description": "Password Manager - Search and manage passwords"
+ },
+ "bm": {
+ "icon": "bookmark",
+ "description": "Bookmarks - Search and manage bookmarks"
+ },
+ "ps": {
+ "icon": "process",
+ "description": "Process Manager - View and manage running processes"
+ },
+ "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/assets/todos.json b/config/assets/todos.json
new file mode 100644
index 0000000..dd6113e
--- /dev/null
+++ b/config/assets/todos.json
@@ -0,0 +1,171 @@
+{
+ "todos": [
+ {
+ "id": 14,
+ "title": "yo",
+ "description": "",
+ "priority": "low",
+ "category": "",
+ "due_date": null,
+ "completed": false,
+ "created": "2025-06-26T16:51:06.643095",
+ "completed_at": null
+ },
+ {
+ "id": 16,
+ "title": "Medium priority task",
+ "description": "",
+ "priority": "medium",
+ "category": "normal",
+ "due_date": null,
+ "completed": false,
+ "created": "2025-06-26T16:52:59.579365",
+ "completed_at": null
+ },
+ {
+ "id": 17,
+ "title": "Low priority task",
+ "description": "",
+ "priority": "low",
+ "category": "later",
+ "due_date": null,
+ "completed": false,
+ "created": "2025-06-26T16:52:59.583278",
+ "completed_at": null
+ },
+ {
+ "id": 18,
+ "title": "๐ฅ URGENT: Fix critical bug",
+ "description": "",
+ "priority": "high",
+ "category": "work",
+ "due_date": null,
+ "completed": true,
+ "created": "2025-06-26T16:53:51.412081",
+ "completed_at": "2025-06-26T16:53:51.418575"
+ },
+ {
+ "id": 19,
+ "title": "๐ Review documentation",
+ "description": "",
+ "priority": "medium",
+ "category": "work",
+ "due_date": null,
+ "completed": false,
+ "created": "2025-06-26T16:53:51.413594",
+ "completed_at": null
+ },
+ {
+ "id": 20,
+ "title": "๐งน Clean desk",
+ "description": "",
+ "priority": "low",
+ "category": "personal",
+ "due_date": null,
+ "completed": false,
+ "created": "2025-06-26T16:53:51.415181",
+ "completed_at": null
+ },
+ {
+ "id": 21,
+ "title": "Test high priority",
+ "description": "",
+ "priority": "high",
+ "category": "test",
+ "due_date": null,
+ "completed": true,
+ "created": "2025-06-26T16:55:12.629834",
+ "completed_at": "2025-06-26T17:00:48.989919"
+ },
+ {
+ "id": 22,
+ "title": "Test medium priority",
+ "description": "",
+ "priority": "medium",
+ "category": "test",
+ "due_date": null,
+ "completed": false,
+ "created": "2025-06-26T16:55:12.631496",
+ "completed_at": null
+ },
+ {
+ "id": 23,
+ "title": "Test low priority",
+ "description": "",
+ "priority": "low",
+ "category": "test",
+ "due_date": null,
+ "completed": false,
+ "created": "2025-06-26T16:55:12.632521",
+ "completed_at": null
+ },
+ {
+ "id": 24,
+ "title": "High priority task",
+ "description": "",
+ "priority": "high",
+ "category": "test",
+ "due_date": null,
+ "completed": true,
+ "created": "2025-06-26T16:57:15.780304",
+ "completed_at": "2025-06-26T16:57:15.800393"
+ },
+ {
+ "id": 25,
+ "title": "Medium priority task",
+ "description": "",
+ "priority": "medium",
+ "category": "test",
+ "due_date": null,
+ "completed": false,
+ "created": "2025-06-26T16:57:15.783358",
+ "completed_at": null
+ },
+ {
+ "id": 26,
+ "title": "Low priority task",
+ "description": "",
+ "priority": "low",
+ "category": "test",
+ "due_date": null,
+ "completed": false,
+ "created": "2025-06-26T16:57:15.792488",
+ "completed_at": null
+ },
+ {
+ "id": 27,
+ "title": "๐ฅ High priority",
+ "description": "",
+ "priority": "high",
+ "category": "test",
+ "due_date": null,
+ "completed": true,
+ "created": "2025-06-26T16:57:50.943800",
+ "completed_at": "2025-06-26T17:00:50.482300"
+ },
+ {
+ "id": 28,
+ "title": "๐ Medium priority",
+ "description": "",
+ "priority": "medium",
+ "category": "test",
+ "due_date": null,
+ "completed": false,
+ "created": "2025-06-26T16:57:50.952343",
+ "completed_at": null
+ },
+ {
+ "id": 29,
+ "title": "๐งน Low priority",
+ "description": "",
+ "priority": "low",
+ "category": "test",
+ "due_date": null,
+ "completed": false,
+ "created": "2025-06-26T16:57:50.968381",
+ "completed_at": null
+ }
+ ],
+ "next_id": 30,
+ "last_modified": "2025-06-26T17:00:50.482384"
+}
\ No newline at end of file
diff --git a/config/data.py b/config/data.py
new file mode 100644
index 0000000..d432d4c
--- /dev/null
+++ b/config/data.py
@@ -0,0 +1,134 @@
+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"
+
+ALLOWED_PLAYERS = ["vlc", "cmus", "firefox", "spotify", "chromium", "vivaldi", "brave"]
+
+NOTIF_POS_KEY = "notif_pos"
+NOTIF_POS_DEFAULT = "Top"
+
+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")
+WALLPAPERS_DIR_DEFAULT = os.path.join(HOME_DIR, "Pictures", "wallpapers")
+
+CONFIG_FILE = get_relative_path("../config/assets/config.json")
+MATUGEN_STATE_FILE = os.path.join(CONFIG_DIR, "matugen")
+
+WORKSPACE_NUMS = False
+WORKSPACE_USE_CHINESE_NUMERAL = False
+WORKSPACE_DOTS = False
+
+DOCK_THEME = "Pills"
+
+
+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 = config.get("wallpapers_dir", WALLPAPERS_DIR_DEFAULT)
+ DOCK_POSITION = config.get("dock_position", "Bottom")
+ VERTICAL = DOCK_POSITION in ["Left", "Right"]
+ 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_THEME = config.get("dock_theme", "Pills")
+ DOCK_ICON_SIZE = config.get("dock_icon_size", 20)
+ WORKSPACE_NUMS = config.get("workspace_nums", False)
+ WORKSPACE_USE_CHINESE_NUMERAL = config.get("workspace_use_chinese_numerals", False)
+ WORKSPACE_DOTS = config.get("workspace_dots", False)
+ WINDOW_SWITCHER_ITEMS_PER_ROW = config.get("window_switcher_items_per_row", 13)
+ DOCK_HIDE_SPECIAL_WORKSPACE = config.get("dock_hide_special_workspace", True)
+ DOCK_HIDE_SPECIAL_WORKSPACE_APPS = config.get(
+ "dock_hide_special_workspace_apps", True
+ )
+
+ NOTIF_POS = config.get(NOTIF_POS_KEY, NOTIF_POS_DEFAULT)
+
+ DOCK_COMPONENTS_VISIBILITY = {
+ "workspace": config.get("dock_workspace_visible", True),
+ "metrics": config.get("dock_metrics_visible", True),
+ "battery": config.get("dock_battery_visible", True),
+ "date_time": config.get("dock_date_time_visible", True),
+ "controls": config.get("dock_controls_visible", True),
+ "indicators": config.get("dock_indicators_visible", True),
+ "notifications": config.get("dock_notifications_visible", True),
+ "systray": config.get("dock_tray_visible", True),
+ "applications": config.get("dock_applications_visible", True),
+ "language": config.get("dock_language_visible", True),
+ }
+
+ METRICS_DISKS = config.get("metrics_disks", ["/"])
+ METRICS_VISIBLE = config.get(
+ "metrics_visible",
+ {"cpu": True, "ram": True, "disk": True, "swap": True, "gpu": False},
+ )
+else:
+ WALLPAPERS_DIR = WALLPAPERS_DIR_DEFAULT
+ DOCK_POSITION = "Bottom"
+ VERTICAL = False
+ DOCK_ENABLED = True
+ DOCK_ALWAYS_OCCLUDED = False
+ DOCK_AUTO_HIDE = True
+ TERMINAL_COMMAND = "kitty -e"
+ DOCK_THEME = "Pills"
+ DOCK_ICON_SIZE = 20
+ WINDOW_SWITCHER_ITEMS_PER_ROW = 13
+ NOTIF_POS = NOTIF_POS_DEFAULT
+ DOCK_HIDE_SPECIAL_WORKSPACE = True
+ DOCK_HIDE_SPECIAL_WORKSPACE_APPS = True
+
+ DOCK_COMPONENTS_VISIBILITY = {
+ "workspace": True,
+ "metrics": True,
+ "battery": True,
+ "date_time": True,
+ "controls": True,
+ "indicators": True,
+ "notifications": True,
+ "systray": True,
+ "applications": True,
+ "language": True,
+ }
+
+ METRICS_DISKS = ["/"]
+ METRICS_VISIBLE = {
+ "cpu": True,
+ "ram": True,
+ "disk": True,
+ "swap": True,
+ "gpu": False,
+ }
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/install.sh b/install.sh
deleted file mode 100644
index dc640c4..0000000
--- a/install.sh
+++ /dev/null
@@ -1,136 +0,0 @@
-#!/bin/bash
-
-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"
-INSTALL_DIR="$HOME/Modus"
-
-PACKAGES=(
- fabric-cli-git
- gnome-bluetooth-3.0
- grimblast
- hypridle
- hyprlock
- hyprpicker
- imagemagick
- libnotify
- python-fabric-git
- python-materialyoucolor-git
- python-pillow
- python-setproctitle
- python-toml
- python-requests
- python-numpy
- python-pywayland
- python-pyxdg
- python-ijson
- sddm-theme-corners-git
- swww
- swappy
- wl-clipboard
- wf-recorder
- libadwaita
- acpi
- adw-gtk-theme
- brightnessctl
- wlinhibit
- power-profile-daemon
- ttf-chakra-petch
- ttf-tabler-icons
-)
-
-# Ensure running on Arch Linux
-if ! grep -q "arch" /etc/os-release; then
- echo "This script is designed to run on Arch Linux."
- exit 1
-fi
-
-# Prevent running as root
-if [ "$(id -u)" -eq 0 ]; then
- echo "Please do not run this script as root."
- exit 1
-fi
-
-# Check for AUR helper (yay or paru)
-aur_helper=""
-if command -v yay &>/dev/null; then
- aur_helper="yay"
-elif command -v paru &>/dev/null; then
- aur_helper="paru"
-else
- echo "Installing paru..."
- tmpdir=$(mktemp -d)
- git clone https://aur.archlinux.org/paru.git "$tmpdir/paru"
- cd "$tmpdir/paru"
- makepkg -si --noconfirm
- cd - >/dev/null
- rm -rf "$tmpdir"
- aur_helper="paru"
-fi
-
-# Clone or update the Modus repository
-if [ -d "$INSTALL_DIR" ]; then
- echo "Updating Modus..."
- git -C "$INSTALL_DIR" pull
-else
- echo "Cloning Modus..."
- git clone "$REPO_URL" "$INSTALL_DIR"
-fi
-
-# Install required packages using the AUR helper
-echo "Installing required packages..."
-$aur_helper -Syy --needed --noconfirm "${PACKAGES[@]}" || true
-
-# Update outdated packages
-echo "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
- echo "Updating outdated packages..."
- $aur_helper -S --noconfirm "${to_update[@]}" || true
-else
- echo "All required packages are up-to-date."
-fi
-
-# Backup and replace GTK configurations
-echo "Updating GTK configurations..."
-mkdir -p "$HOME/.config"
-[[ -d "$HOME/.config/gtk-3.0" ]] && mv "$HOME/.config/gtk-3.0" "$HOME/.config/gtk-3.0-bk-$(date +%s)"
-[[ -d "$HOME/.config/gtk-4.0" ]] && mv "$HOME/.config/gtk-4.0" "$HOME/.config/gtk-4.0-bk-$(date +%s)"
-
-cp -r "$INSTALL_DIR/assets/gtk-3.0" "$HOME/.config/" || true
-cp -r "$INSTALL_DIR/assets/gtk-4.0" "$HOME/.config/" || true
-
-# Install Icon Theme
-echo "Installing Tela icon theme..."
-tmpdir=$(mktemp -d)
-git clone https://github.com/vinceliuice/Tela-icon-theme "$tmpdir/Tela-icon-theme"
-cd "$tmpdir/Tela-icon-theme"
-./install.sh nord
-rm -rf "$tmpdir"
-
-# Install Vencord
-echo "Installing Vencord..."
-if command -v curl &>/dev/null; then
- sh -c "$(curl -sS https://raw.githubusercontent.com/Vendicated/VencordInstaller/main/install.sh)" || true
- mkdir -p "$HOME/.config/Vencord/settings/"
- ln -sf "$HOME/.cache/material/material-discord.css" "$HOME/.config/Vencord/settings/quickCss.css"
-else
- echo "Skipping Vencord installation (curl not found)."
-fi
-
-# Launch Modus
-echo "Starting Modus..."
-killall modus 2>/dev/null || true
-python "$INSTALL_DIR/main.py" >/dev/null 2>&1 &
-disown
-
-echo "Installation complete."
diff --git a/main.css b/main.css
new file mode 100644
index 0000000..f70444d
--- /dev/null
+++ b/main.css
@@ -0,0 +1,67 @@
+@import url("./styles/colors.css");
+@import url("./styles/panel.css");
+@import url("./styles/menubar.css");
+@import url("./styles/dock.css");
+@import url("./styles/switcher.css");
+@import url("./styles/launcher.css");
+
+* {
+ all: unset;
+ color: var(--foreground);
+ font-size: 12px;
+ font-family: unset;
+}
+
+#corner {
+ background-color: var(--shadow);
+ border-radius: 0;
+}
+
+#corner-container {
+ min-width: 20px;
+ min-height: 20px;
+}
+
+tooltip {
+ border: solid 1px;
+ border-radius: 16px;
+ border-color: var(--surface);
+ background-color: var(--shadow);
+ animation: tooltipShow 0.25s cubic-bezier(0.5, 0.25, 0, 1);
+}
+
+@keyframes tooltipShow {
+ from {
+ opacity: 0;
+ }
+
+ to {
+ opacity: 1;
+ }
+}
+
+tooltip > * {
+ padding: 6px 10px;
+ border-radius: 10px;
+}
+
+menu {
+ border: solid 1px;
+ border-radius: 16px;
+ border-color: var(--surface);
+ background-color: var(--shadow);
+ padding: 6px;
+}
+
+menu > menuitem {
+ border-radius: 10px;
+ padding: 6px 10px;
+}
+
+menu > menuitem:hover {
+ background-color: var(--primary);
+}
+
+menu > menuitem:hover > label {
+ color: var(--shadow);
+}
diff --git a/main.py b/main.py
new file mode 100644
index 0000000..d85f3d1
--- /dev/null
+++ b/main.py
@@ -0,0 +1,66 @@
+import setproctitle
+from fabric import Application
+from fabric.utils import get_relative_path, monitor_file
+from loguru import logger
+
+from config.data import APP_NAME
+
+# from modules.corners import Corners
+from modules.panel.main import Panel
+from modules.dock import Dock
+from modules.switcher import ApplicationSwitcher
+from modules.launcher.main import Launcher
+
+
+for log in [
+ "fabric.hyprland.widgets",
+ "fabric.audio.service",
+ "fabric.bluetooth.service",
+ "services.network",
+ "utils.wayland"
+]:
+ logger.disable(log)
+
+
+if __name__ == "__main__":
+ setproctitle.setproctitle(APP_NAME)
+
+ # Load configuration
+ from config.data import load_config
+
+ config = load_config()
+
+ panel = Panel()
+ # corners = Corners()
+ dock = Dock()
+ switcher = ApplicationSwitcher()
+ launcher = Launcher()
+ panel.launcher = launcher
+
+ # 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())
+
+ color_css_main_file = monitor_file(get_relative_path("main.css"))
+ _ = color_css_main_file.connect("changed", lambda *_: set_css())
+
+ color_css_file = monitor_file(get_relative_path("./styles/colors.css"))
+ _ = color_css_file.connect("changed", lambda *_: set_css())
+
+ # Make sure corners is added to the app
+ app = Application(f"{APP_NAME}", panel, dock, switcher, launcher)
+
+ def set_css():
+ app.set_stylesheet_from_file(
+ get_relative_path("main.css"),
+ )
+
+ app.set_css = set_css
+
+ app.set_css()
+
+ app.run()
diff --git a/modules/corners.py b/modules/corners.py
new file mode 100644
index 0000000..9ec8e6e
--- /dev/null
+++ b/modules/corners.py
@@ -0,0 +1,69 @@
+from fabric.widgets.box import Box
+from fabric.widgets.shapes import Corner
+
+from utils.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..4ffbce8
--- /dev/null
+++ b/modules/dock.py
@@ -0,0 +1,912 @@
+import json
+import logging
+
+import cairo
+from fabric.hyprland.widgets import get_hyprland_connection
+from fabric.utils import (
+ exec_shell_command,
+ exec_shell_command_async,
+ get_relative_path,
+ idle_add,
+ remove_handler,
+)
+from fabric.utils.helpers import get_desktop_applications
+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.revealer import Revealer
+from gi.repository import Gdk, GLib, Gtk
+
+import config.data as data
+from modules.corners import MyCorner
+from utils.icon_resolver import IconResolver
+from utils.occlusion import check_occlusion
+from utils.wayland import WaylandWindow as Window
+
+
+def read_config():
+ """Read and return the full configuration from the JSON file, handling missing file."""
+ config_path = get_relative_path("../config/assets/dock.json")
+ try:
+ with open(config_path, "r") as file:
+ config_data = json.load(file)
+
+ if (
+ "pinned_apps" in config_data
+ and config_data["pinned_apps"]
+ and isinstance(config_data["pinned_apps"][0], str)
+ ):
+ all_apps = get_desktop_applications()
+ app_map = {app.name: app for app in all_apps if app.name}
+
+ old_pinned = config_data["pinned_apps"]
+ config_data["pinned_apps"] = []
+
+ for app_id in old_pinned:
+ app = app_map.get(app_id)
+ if app:
+ app_data_obj = {
+ "name": app.name,
+ "display_name": app.display_name,
+ "window_class": app.window_class,
+ "executable": app.executable,
+ "command_line": app.command_line,
+ }
+ config_data["pinned_apps"].append(app_data_obj)
+ else:
+ config_data["pinned_apps"].append({"name": app_id})
+
+ except (FileNotFoundError, json.JSONDecodeError):
+ config_data = {"pinned_apps": []}
+ return config_data
+
+
+def createSurfaceFromWidget(widget: Gtk.Widget) -> cairo.ImageSurface:
+ alloc = widget.get_allocation()
+ surface = cairo.ImageSurface(
+ cairo.Format.ARGB32,
+ alloc.width,
+ alloc.height,
+ )
+ cr = cairo.Context(surface)
+ cr.set_source_rgba(255, 255, 255, 0)
+ cr.rectangle(0, 0, alloc.width, alloc.height)
+ cr.fill()
+ widget.draw(cr)
+ return surface
+
+
+class Dock(Window):
+ _instances = []
+
+ def __init__(self, **kwargs):
+ self.icon_size = data.DOCK_ICON_SIZE
+ self.effective_occlusion_size = 36 + self.icon_size
+
+ # Dock is always positioned at the bottom
+ anchor_to_set = "bottom"
+ revealer_transition_type = "slide-up"
+ main_box_orientation_val = Gtk.Orientation.VERTICAL
+ main_box_h_align_val = "center"
+ dock_wrapper_orientation_val = Gtk.Orientation.HORIZONTAL
+
+ self.actual_dock_is_horizontal = True
+
+ super().__init__(
+ name="dock-window",
+ layer="top",
+ anchor=anchor_to_set,
+ margin="0px 0px 0px 0px",
+ exclusivity="none",
+ **kwargs,
+ )
+ Dock._instances.append(self)
+
+ self.config = read_config()
+ self.conn = get_hyprland_connection()
+ self.icon_resolver = IconResolver()
+ self.pinned = self.config.get("pinned_apps", [])
+ self.config_path = get_relative_path("../config/assets/dock.json")
+ self.app_map = {}
+ self._all_apps = get_desktop_applications()
+ self.app_identifiers = self._build_app_identifiers_map()
+
+ self.hide_id = None
+ self._arranger_handler = None
+ self._drag_in_progress = False
+ self.always_occluded = data.DOCK_ALWAYS_OCCLUDED
+ self.is_mouse_over_dock_area = False
+ self._prevent_occlusion = False
+
+ self.view = Box(name="viewport", spacing=4)
+ self.wrapper = Box(
+ name="dock",
+ children=[self.view],
+ )
+
+ self.wrapper.set_orientation(dock_wrapper_orientation_val)
+ self.view.set_orientation(dock_wrapper_orientation_val)
+
+ # Dock is always horizontal, so remove vertical style class
+ self.wrapper.remove_style_class("vertical")
+
+ # Always use Dense theme
+ self.wrapper.add_style_class("dense")
+
+ self.dock_eventbox = EventBox()
+ self.dock_eventbox.add(self.wrapper)
+ self.dock_eventbox.connect("enter-notify-event", self._on_dock_enter)
+ self.dock_eventbox.connect("leave-notify-event", self._on_dock_leave)
+
+ # Create corners for horizontal dock at bottom
+ self.corner_left = Box(
+ name="dock-corner-left",
+ orientation=Gtk.Orientation.VERTICAL,
+ h_align="start",
+ children=[
+ Box(v_expand=True, v_align="fill"),
+ MyCorner("bottom-right"),
+ ],
+ )
+ self.corner_right = Box(
+ name="dock-corner-right",
+ orientation=Gtk.Orientation.VERTICAL,
+ h_align="end",
+ children=[
+ Box(v_expand=True, v_align="fill"),
+ MyCorner("bottom-left"),
+ ],
+ )
+ self.corner_top = Box()
+ self.corner_bottom = Box()
+
+ self.dock_full = Box(
+ name="dock-full",
+ orientation=Gtk.Orientation.HORIZONTAL,
+ h_expand=True,
+ h_align="fill",
+ children=[self.corner_left, self.dock_eventbox, self.corner_right],
+ )
+
+ self.dock_revealer = Revealer(
+ name="dock-revealer",
+ transition_type=revealer_transition_type,
+ transition_duration=250,
+ child_revealed=False,
+ child=self.dock_full,
+ )
+
+ self.hover_activator = EventBox()
+
+ self.hover_activator.set_size_request(-1, 1)
+ self.hover_activator.connect("enter-notify-event", self._on_hover_enter)
+ self.hover_activator.connect("leave-notify-event", self._on_hover_leave)
+
+ self.main_box = Box(
+ orientation=main_box_orientation_val,
+ children=[self.hover_activator, self.dock_revealer],
+ h_align=main_box_h_align_val,
+ )
+ self.add(self.main_box)
+
+ # Dense theme - hide corners
+ for corner in [
+ self.corner_left,
+ self.corner_right,
+ self.corner_top,
+ self.corner_bottom,
+ ]:
+ corner.set_visible(False)
+
+ if not data.DOCK_ENABLED:
+ self.set_visible(False)
+
+ if self.always_occluded:
+ self.dock_full.add_style_class("occluded")
+
+ self.view.drag_source_set(
+ Gdk.ModifierType.BUTTON1_MASK,
+ [Gtk.TargetEntry.new("text/plain", Gtk.TargetFlags.SAME_APP, 0)],
+ Gdk.DragAction.MOVE,
+ )
+ self.view.drag_dest_set(
+ Gtk.DestDefaults.ALL,
+ [Gtk.TargetEntry.new("text/plain", Gtk.TargetFlags.SAME_APP, 0)],
+ Gdk.DragAction.MOVE,
+ )
+ self.view.connect("drag-data-get", self.on_drag_data_get)
+ self.view.connect("drag-data-received", self.on_drag_data_received)
+ self.view.connect("drag-begin", self.on_drag_begin)
+ self.view.connect("drag-end", self.on_drag_end)
+
+ if self.conn.ready:
+ self.update_dock()
+ GLib.timeout_add(250, self.check_occlusion_state)
+ else:
+ self.conn.connect("event::ready", self.update_dock)
+ self.conn.connect(
+ "event::ready",
+ lambda *args: GLib.timeout_add(250, self.check_occlusion_state),
+ )
+
+ for ev in ("activewindow", "openwindow", "closewindow", "changefloatingmode"):
+ self.conn.connect(f"event::{ev}", self.update_dock)
+
+ self.conn.connect("event::workspace", self.check_hide)
+
+ GLib.timeout_add_seconds(1, self.check_config_change)
+
+ def _build_app_identifiers_map(self):
+ identifiers = {}
+ for app in self._all_apps:
+ if app.name:
+ identifiers[app.name.lower()] = app
+ if app.display_name:
+ identifiers[app.display_name.lower()] = app
+ if app.window_class:
+ identifiers[app.window_class.lower()] = app
+ if app.executable:
+ identifiers[app.executable.split("/")[-1].lower()] = app
+ if app.command_line:
+ identifiers[app.command_line.split()[0].split("/")[-1].lower()] = app
+ return identifiers
+
+ def _normalize_window_class(self, class_name):
+ if not class_name:
+ return ""
+ normalized = class_name.lower()
+ suffixes = [".bin", ".exe", ".so", "-bin", "-gtk"]
+ for suffix in suffixes:
+ if normalized.endswith(suffix):
+ normalized = normalized[: -len(suffix)]
+ return normalized
+
+ def _classes_match(self, class1, class2):
+ if not class1 or not class2:
+ return False
+ norm1 = self._normalize_window_class(class1)
+ norm2 = self._normalize_window_class(class2)
+ return norm1 == norm2
+
+ def on_drag_begin(self, widget, drag_context):
+ self._drag_in_progress = True
+ Gtk.drag_set_icon_surface(drag_context, createSurfaceFromWidget(widget))
+
+ def _on_hover_enter(self, *args):
+ self.is_mouse_over_dock_area = True
+ if self.hide_id:
+ GLib.source_remove(self.hide_id)
+ self.hide_id = None
+ self.dock_revealer.set_reveal_child(True)
+ if not self.always_occluded:
+ self.dock_full.remove_style_class("occluded")
+
+ def _on_hover_leave(self, *args):
+ self.is_mouse_over_dock_area = False
+ self.delay_hide()
+
+ def _on_dock_enter(self, widget, event):
+ self.is_mouse_over_dock_area = True
+ if self.hide_id:
+ GLib.source_remove(self.hide_id)
+ self.hide_id = None
+ self.dock_revealer.set_reveal_child(True)
+ if not self.always_occluded:
+ self.dock_full.remove_style_class("occluded")
+ return True
+
+ def _on_dock_leave(self, widget, event):
+ if event.detail == Gdk.NotifyType.INFERIOR:
+ return False
+
+ self.is_mouse_over_dock_area = False
+ self.delay_hide()
+
+ if self.always_occluded:
+ self.dock_full.add_style_class("occluded")
+ return True
+
+ def find_app(self, app_identifier):
+ if not app_identifier:
+ return None
+ if isinstance(app_identifier, dict):
+ for key in [
+ "window_class",
+ "executable",
+ "command_line",
+ "name",
+ "display_name",
+ ]:
+ if key in app_identifier and app_identifier[key]:
+ app = self.find_app_by_key(app_identifier[key])
+ if app:
+ return app
+ return None
+ return self.find_app_by_key(app_identifier)
+
+ def find_app_by_key(self, key_value):
+ if not key_value:
+ return None
+ normalized_id = str(key_value).lower()
+ if normalized_id in self.app_identifiers:
+ return self.app_identifiers[normalized_id]
+ for app in self._all_apps:
+ if app.name and normalized_id in app.name.lower():
+ return app
+ if app.display_name and normalized_id in app.display_name.lower():
+ return app
+ if app.window_class and normalized_id in app.window_class.lower():
+ return app
+ if app.executable and normalized_id in app.executable.lower():
+ return app
+ if app.command_line and normalized_id in app.command_line.lower():
+ return app
+ return None
+
+ def update_app_map(self):
+ self._all_apps = get_desktop_applications()
+ self.app_map = {app.name: app for app in self._all_apps if app.name}
+ self.app_identifiers = self._build_app_identifiers_map()
+
+ def create_button(self, app_identifier, instances):
+ desktop_app = self.find_app(app_identifier)
+ icon_img = None
+ display_name = None
+
+ if desktop_app:
+ icon_img = desktop_app.get_icon_pixbuf(size=self.icon_size)
+ display_name = desktop_app.display_name or desktop_app.name
+
+ id_value = (
+ app_identifier["name"]
+ if isinstance(app_identifier, dict)
+ else app_identifier
+ )
+
+ if not icon_img:
+ icon_img = self.icon_resolver.get_icon_pixbuf(id_value, self.icon_size)
+
+ if not icon_img:
+ icon_img = self.icon_resolver.get_icon_pixbuf(
+ "application-x-executable-symbolic", self.icon_size
+ )
+ if not icon_img:
+ icon_img = self.icon_resolver.get_icon_pixbuf(
+ "image-missing", self.icon_size
+ )
+
+ items = [Image(pixbuf=icon_img)]
+
+ # Add dot indicator for open apps
+ if instances:
+ dot = Box(
+ name="dock-app-dot",
+ h_align="center",
+ v_align="center",
+ )
+ dot.set_size_request(4, 4)
+ items.append(dot)
+
+ tooltip = display_name or (id_value if isinstance(id_value, str) else "Unknown")
+ if not display_name and instances and instances[0].get("title"):
+ tooltip = instances[0]["title"]
+
+ button = Button(
+ child=Box(
+ name="dock-icon", orientation="v", h_align="center", children=items
+ ),
+ on_clicked=lambda *a: self.handle_app(
+ app_identifier, instances, desktop_app
+ ),
+ tooltip_text=tooltip,
+ name="dock-app-button",
+ )
+ button.app_identifier = app_identifier
+ button.desktop_app = desktop_app
+ button.instances = instances
+ if instances:
+ button.add_style_class("instance")
+
+ button.drag_source_set(
+ Gdk.ModifierType.BUTTON1_MASK,
+ [Gtk.TargetEntry.new("text/plain", Gtk.TargetFlags.SAME_APP, 0)],
+ Gdk.DragAction.MOVE,
+ )
+ button.connect("drag-begin", self.on_drag_begin)
+ button.connect("drag-end", self.on_drag_end)
+ button.drag_dest_set(
+ Gtk.DestDefaults.ALL,
+ [Gtk.TargetEntry.new("text/plain", Gtk.TargetFlags.SAME_APP, 0)],
+ Gdk.DragAction.MOVE,
+ )
+ button.connect("drag-data-get", self.on_drag_data_get)
+ button.connect("drag-data-received", self.on_drag_data_received)
+ button.connect("enter-notify-event", self._on_child_enter)
+ return button
+
+ def handle_app(self, app_identifier, instances, desktop_app=None):
+ if not instances:
+ if not desktop_app:
+ desktop_app = self.find_app(app_identifier)
+ if desktop_app:
+ launch_success = desktop_app.launch()
+ if not launch_success:
+ if desktop_app.command_line:
+ exec_shell_command_async(f"nohup {desktop_app.command_line} &")
+ elif desktop_app.executable:
+ exec_shell_command_async(f"nohup {desktop_app.executable} &")
+ else:
+ cmd_to_run = None
+ if isinstance(app_identifier, dict):
+ if (
+ "command_line" in app_identifier
+ and app_identifier["command_line"]
+ ):
+ cmd_to_run = app_identifier["command_line"]
+ elif (
+ "executable" in app_identifier and app_identifier["executable"]
+ ):
+ cmd_to_run = app_identifier["executable"]
+ elif "name" in app_identifier and app_identifier["name"]:
+ cmd_to_run = app_identifier["name"]
+ elif isinstance(app_identifier, str):
+ cmd_to_run = app_identifier
+ if cmd_to_run:
+ exec_shell_command_async(f"nohup {cmd_to_run} &")
+ else:
+ focused = self.get_focused()
+ idx = next(
+ (i for i, inst in enumerate(instances) if inst["address"] == focused),
+ -1,
+ )
+ next_inst = instances[(idx + 1) % len(instances)]
+ exec_shell_command(
+ f"hyprctl dispatch focuswindow address:{next_inst['address']}"
+ )
+
+ def _on_child_enter(self, widget, event):
+ self.is_mouse_over_dock_area = True
+ if self.hide_id:
+ GLib.source_remove(self.hide_id)
+ self.hide_id = None
+ return False
+
+ def delay_hide(self):
+ if self.hide_id:
+ GLib.source_remove(self.hide_id)
+ self.hide_id = GLib.timeout_add(250, self.hide_dock_if_not_hovered)
+
+ def hide_dock_if_not_hovered(self):
+ self.hide_id = None
+ if (
+ not self.is_mouse_over_dock_area
+ and not self._drag_in_progress
+ and not self._prevent_occlusion
+ ):
+ if self.always_occluded:
+ self.dock_revealer.set_reveal_child(False)
+ else:
+ occlusion_region = ("bottom", self.effective_occlusion_size)
+ if check_occlusion(occlusion_region) or not self.view.get_children():
+ self.dock_revealer.set_reveal_child(False)
+ return False
+
+ def check_hide(self, *args):
+ if (
+ self.is_mouse_over_dock_area
+ or self._drag_in_progress
+ or self._prevent_occlusion
+ ):
+ return
+
+ clients = self.get_clients()
+ current_ws = self.get_workspace()
+ ws_clients = [w for w in clients if w["workspace"]["id"] == current_ws]
+
+ if not self.always_occluded:
+ if not ws_clients:
+ if not self.dock_revealer.get_reveal_child():
+ self.dock_revealer.set_reveal_child(True)
+ self.dock_full.remove_style_class("occluded")
+ elif any(
+ not w.get("floating") and not w.get("fullscreen") for w in ws_clients
+ ):
+ self.check_occlusion_state()
+ else:
+ if not self.dock_revealer.get_reveal_child():
+ self.dock_revealer.set_reveal_child(True)
+ self.dock_full.remove_style_class("occluded")
+ else:
+ if self.dock_revealer.get_reveal_child():
+ self.dock_revealer.set_reveal_child(False)
+ self.dock_full.add_style_class("occluded")
+
+ def update_dock(self, *args):
+ self.update_app_map()
+ arranger_handler = getattr(self, "_arranger_handler", None)
+ if arranger_handler:
+ remove_handler(arranger_handler)
+ clients = self.get_clients()
+
+ running_windows = {}
+ for c in clients:
+ window_id = None
+ if class_name := c.get("initialClass", "").lower():
+ window_id = class_name
+ elif class_name := c.get("class", "").lower():
+ window_id = class_name
+ elif title := c.get("title", "").lower():
+ possible_name = title.split(" - ")[0].strip()
+ if possible_name and len(possible_name) > 1:
+ window_id = possible_name
+ else:
+ window_id = title
+ if not window_id:
+ window_id = "unknown-app"
+ running_windows.setdefault(window_id, []).append(c)
+ normalized_id = self._normalize_window_class(window_id)
+ if normalized_id != window_id:
+ running_windows.setdefault(normalized_id, []).extend(
+ running_windows[window_id]
+ )
+
+ pinned_buttons = []
+ used_window_classes = set()
+
+ for app_data_item in self.pinned:
+ app = self.find_app(app_data_item)
+ instances = []
+ matched_class = None
+ possible_identifiers = []
+
+ if isinstance(app_data_item, dict):
+ for key in [
+ "window_class",
+ "executable",
+ "command_line",
+ "name",
+ "display_name",
+ ]:
+ if key in app_data_item and app_data_item[key]:
+ possible_identifiers.append(app_data_item[key].lower())
+ elif isinstance(app_data_item, str):
+ possible_identifiers.append(app_data_item.lower())
+
+ if app:
+ if app.window_class:
+ possible_identifiers.append(app.window_class.lower())
+ if app.executable:
+ possible_identifiers.append(app.executable.split("/")[-1].lower())
+ if app.command_line:
+ cmd_parts = app.command_line.split()
+ if cmd_parts:
+ possible_identifiers.append(cmd_parts[0].split("/")[-1].lower())
+ if app.name:
+ possible_identifiers.append(app.name.lower())
+ if app.display_name:
+ possible_identifiers.append(app.display_name.lower())
+
+ possible_identifiers = list(set(possible_identifiers))
+
+ for identifier in possible_identifiers:
+ if identifier in running_windows:
+ instances = running_windows[identifier]
+ matched_class = identifier
+ break
+ normalized = self._normalize_window_class(identifier)
+ if normalized in running_windows:
+ instances = running_windows[normalized]
+ matched_class = normalized
+ break
+ for window_class_key in running_windows:
+ if len(identifier) >= 3 and identifier in window_class_key:
+ instances = running_windows[window_class_key]
+ matched_class = window_class_key
+ break
+ if matched_class:
+ break
+
+ if matched_class:
+ used_window_classes.add(matched_class)
+ used_window_classes.add(self._normalize_window_class(matched_class))
+
+ pinned_buttons.append(self.create_button(app_data_item, instances))
+
+ open_buttons = []
+ for class_name, instances in running_windows.items():
+ if class_name not in used_window_classes:
+ app = None
+ app = self.app_identifiers.get(class_name)
+ if not app:
+ norm_class = self._normalize_window_class(class_name)
+ app = self.app_identifiers.get(norm_class)
+ if not app:
+ app = self.find_app_by_key(class_name)
+ if not app and instances and instances[0].get("title"):
+ title = instances[0].get("title", "")
+ potential_name = title.split(" - ")[0].strip()
+ if len(potential_name) > 2:
+ app = self.find_app_by_key(potential_name)
+
+ if app:
+ app_data_obj = {
+ "name": app.name,
+ "display_name": app.display_name,
+ "window_class": app.window_class,
+ "executable": app.executable,
+ "command_line": app.command_line,
+ }
+ identifier = app_data_obj
+ else:
+ identifier = class_name
+ open_buttons.append(self.create_button(identifier, instances))
+
+ children = pinned_buttons
+ separator_orientation = (
+ Gtk.Orientation.VERTICAL
+ if self.view.get_orientation() == Gtk.Orientation.HORIZONTAL
+ else Gtk.Orientation.HORIZONTAL
+ )
+ if pinned_buttons and open_buttons:
+ children += [
+ Box(
+ orientation=separator_orientation,
+ v_expand=False,
+ h_expand=False,
+ h_align="center",
+ v_align="center",
+ name="dock-separator",
+ )
+ ]
+ children += open_buttons
+
+ self.view.children = children
+ idle_add(self._update_size)
+ self._drag_in_progress = False
+ self.check_occlusion_state()
+
+ def _update_size(self):
+ width, _ = self.view.get_preferred_width()
+ self.set_size_request(width, -1)
+ return False
+
+ def get_clients(self):
+ try:
+ return json.loads(self.conn.send_command("j/clients").reply.decode())
+ except json.JSONDecodeError:
+ return []
+
+ def get_focused(self):
+ try:
+ return json.loads(
+ self.conn.send_command("j/activewindow").reply.decode()
+ ).get("address", "")
+ except json.JSONDecodeError:
+ return ""
+
+ def get_workspace(self):
+ try:
+ return json.loads(
+ self.conn.send_command("j/activeworkspace").reply.decode()
+ ).get("id", 0)
+ except json.JSONDecodeError:
+ return 0
+
+ def check_occlusion_state(self):
+ if (
+ self.is_mouse_over_dock_area
+ or self._drag_in_progress
+ or self._prevent_occlusion
+ ):
+ if not self.dock_revealer.get_reveal_child():
+ self.dock_revealer.set_reveal_child(True)
+ if not self.always_occluded:
+ self.dock_full.remove_style_class("occluded")
+ return True
+
+ if self.always_occluded:
+ if self.dock_revealer.get_reveal_child():
+ self.dock_revealer.set_reveal_child(False)
+ self.dock_full.add_style_class("occluded")
+ return True
+
+ occlusion_region = ("bottom", self.effective_occlusion_size)
+ is_occluded_by_window = check_occlusion(occlusion_region)
+ is_empty = not self.view.get_children()
+
+ if is_occluded_by_window or is_empty:
+ if self.dock_revealer.get_reveal_child():
+ self.dock_revealer.set_reveal_child(False)
+ self.dock_full.add_style_class("occluded")
+ else:
+ if not self.dock_revealer.get_reveal_child():
+ self.dock_revealer.set_reveal_child(True)
+ self.dock_full.remove_style_class("occluded")
+
+ return True
+
+ def _find_drag_target(self, widget):
+ children = self.view.get_children()
+ while widget is not None and widget not in children:
+ widget = widget.get_parent() if hasattr(widget, "get_parent") else None
+ return widget
+
+ def on_drag_data_get(self, widget, drag_context, data_obj, info, time):
+ target = self._find_drag_target(
+ widget.get_parent() if isinstance(widget, Box) else widget
+ )
+ if target is not None:
+ index = self.view.get_children().index(target)
+ data_obj.set_text(str(index), -1)
+
+ def on_drag_data_received(self, widget, drag_context, x, y, data_obj, info, time):
+ target = self._find_drag_target(
+ widget.get_parent() if isinstance(widget, Box) else widget
+ )
+ if target is None:
+ return
+ try:
+ source_index = int(data_obj.get_text())
+ except (TypeError, ValueError):
+ return
+
+ children = self.view.get_children()
+ try:
+ target_index = children.index(target)
+ except ValueError:
+ return
+
+ if source_index != target_index:
+ separator_index = -1
+ for i, child_item_loop in enumerate(children):
+ if child_item_loop.get_name() == "dock-separator":
+ separator_index = i
+ break
+ cross_section_drag = separator_index != -1 and (
+ (source_index < separator_index and target_index > separator_index)
+ or (source_index > separator_index and target_index < separator_index)
+ )
+
+ child_item_to_move = children.pop(source_index)
+ children.insert(target_index, child_item_to_move)
+ self.view.children = children
+ self.update_pinned_apps(skip_update=not cross_section_drag)
+ if cross_section_drag:
+ GLib.idle_add(self.update_dock)
+
+ def on_drag_end(self, widget, drag_context):
+ if not self._drag_in_progress:
+ return
+
+ def process_drag_end():
+ display = Gdk.Display.get_default()
+ _, x, y, _ = display.get_pointer()
+
+ # Get the widget's allocation to check if drag ended outside
+ alloc = self.view.get_allocation()
+ widget_x = alloc.x
+ widget_y = alloc.y
+ widget_width = alloc.width
+ widget_height = alloc.height
+
+ # Check if pointer is outside the dock area
+ if not (
+ widget_x <= x <= widget_x + widget_width
+ and widget_y <= y <= widget_y + widget_height
+ ):
+ app_id_dragged = widget.app_identifier
+ instances_dragged = widget.instances
+
+ # Remove pinned app
+ app_index_dragged = -1
+ for i, pinned_app_item in enumerate(self.pinned):
+ if isinstance(app_id_dragged, dict) and isinstance(
+ pinned_app_item, dict
+ ):
+ if app_id_dragged.get("name") == pinned_app_item.get("name"):
+ app_index_dragged = i
+ break
+ elif app_id_dragged == pinned_app_item:
+ app_index_dragged = i
+ break
+
+ if app_index_dragged >= 0:
+ self.pinned.pop(app_index_dragged)
+ self.config["pinned_apps"] = self.pinned
+ self.update_pinned_apps_file()
+ self.update_dock()
+ elif instances_dragged:
+ address = instances_dragged[0].get("address")
+ if address:
+ exec_shell_command(
+ f"hyprctl dispatch focuswindow address:{address}"
+ )
+
+ self._drag_in_progress = False
+ self.check_occlusion_state()
+
+ GLib.idle_add(process_drag_end)
+
+ def check_config_change(self):
+ new_config = read_config()
+ new_always_occluded = data.DOCK_ALWAYS_OCCLUDED
+ if self.always_occluded != new_always_occluded:
+ self.always_occluded = new_always_occluded
+ self.check_occlusion_state()
+
+ if new_config.get("pinned_apps", []) != self.config.get("pinned_apps", []):
+ self.config = new_config
+ self.pinned = self.config.get("pinned_apps", [])
+ self.update_app_map()
+ self.update_dock()
+ return True
+
+ def update_pinned_apps_file(self):
+ config_path = get_relative_path("../config/assets/dock.json")
+ try:
+ with open(config_path, "w") as file:
+ json.dump(self.config, file, indent=4)
+ return True
+ except Exception as e:
+ logging.error(f"Failed to write dock config: {e}")
+ return False
+
+ def update_pinned_apps(self, skip_update=False):
+ pinned_children_data = []
+ for child_widget in self.view.get_children():
+ if child_widget.get_name() == "dock-separator":
+ break
+ if hasattr(child_widget, "app_identifier"):
+ if hasattr(child_widget, "desktop_app") and child_widget.desktop_app:
+ app = child_widget.desktop_app
+ app_data_obj = {
+ "name": app.name,
+ "display_name": app.display_name,
+ "window_class": app.window_class,
+ "executable": app.executable,
+ "command_line": app.command_line,
+ }
+ pinned_children_data.append(app_data_obj)
+ else:
+ pinned_children_data.append(child_widget.app_identifier)
+
+ self.config["pinned_apps"] = pinned_children_data
+ self.pinned = pinned_children_data
+ file_updated = self.update_pinned_apps_file()
+ if file_updated and not skip_update:
+ self.update_dock()
+
+ @staticmethod
+ def notify_config_change():
+ for dock_instance in Dock._instances:
+ GLib.idle_add(dock_instance.check_config_change_immediate)
+
+ def check_config_change_immediate(self):
+ new_config = read_config()
+
+ previous_always_occluded = self.always_occluded
+ self.always_occluded = data.DOCK_ALWAYS_OCCLUDED
+
+ if previous_always_occluded != self.always_occluded:
+ self.check_occlusion_state()
+
+ if new_config.get("pinned_apps", []) != self.config.get("pinned_apps", []):
+ self.config = new_config
+ self.pinned = self.config.get("pinned_apps", [])
+ self.update_app_map()
+ self.update_dock()
+ return False
+
+ @staticmethod
+ def update_visibility(visible):
+ for dock in Dock._instances:
+ dock.set_visible(visible)
+ if visible:
+ GLib.idle_add(dock.check_occlusion_state)
+ else:
+ if (
+ hasattr(dock, "dock_revealer")
+ and dock.dock_revealer.get_reveal_child()
+ ):
+ dock.dock_revealer.set_reveal_child(False)
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..46a9dca
--- /dev/null
+++ b/modules/launcher/main.py
@@ -0,0 +1,1299 @@
+from typing import List, Optional, Tuple
+
+import utils.icons as icons
+from fabric.core.service import Property
+from fabric.utils import exec_shell_command_async, get_relative_path
+from fabric.widgets.box import Box
+from fabric.widgets.button import Button
+from fabric.widgets.entry import Entry
+from fabric.widgets.label import Label
+from fabric.widgets.scrolledwindow import ScrolledWindow
+from gi.repository import Gdk, GLib
+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 utils.wayland import WaylandWindow as Window
+
+# Constants
+SEARCH_DEBOUNCE_MS = 150
+TRIGGER_SEARCH_DEBOUNCE_MS = 50
+CURSOR_POSITION_DELAY_MS = 10
+SCROLL_PADDING = 10
+DEFAULT_ITEM_HEIGHT = 60
+PAGE_NAVIGATION_STEP = 5
+LAUNCHER_WIDTH = 550
+LAUNCHER_HEIGHT = 260
+
+
+class Launcher(Window):
+ """
+ Main launcher window with search functionality and plugin system.
+ Similar to Albert Launcher 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",
+ layer="top",
+ anchor="center",
+ exclusivity="none",
+ keyboard_mode="exclusive",
+ **kwargs,
+ )
+
+ # Initialization flag to prevent callbacks during setup
+ self._initializing = True
+
+ # Flag to prevent recursion when automatically adding spaces
+ self._auto_adding_space = False
+
+ # Flag to prevent search change handler interference during backspace processing
+ self._processing_backspace = False
+
+ # Initialize plugin manager
+ self.plugin_manager = PluginManager()
+
+ # Initialize trigger configuration
+ self.trigger_config = TriggerConfig()
+
+ # Current results and selection
+ self.results: List[Result] = []
+ self.selected_index = 0
+ self.max_results = 10
+
+ # 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"
+
+ # Setup UI
+ main_box = Box(
+ name="launcher",
+ orientation="v",
+ spacing=0,
+ h_align="center",
+ v_align="center",
+ )
+ self.add(main_box)
+
+ # Search entry with overlay for trigger indication
+ self.search_entry = Entry(
+ name="launcher-search",
+ placeholder="Type to 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)
+
+ # Results container
+ 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)
+
+ # Initially hide the results container
+ self.results_scroll.hide()
+
+ self.connect("key-press-event", self._on_key_press)
+ 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
+ """
+ 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 - clear everything
+ self.opened_with_trigger = False
+ self.search_entry.set_text("")
+ self._clear_results()
+
+ # 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:
+ # Hide results when query is empty
+ self._clear_results()
+
+ 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 - reset trigger mode and hide results
+ self.triggered_plugin = None
+ self.active_trigger = ""
+ 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 - only show trigger suggestions
+ self.triggered_plugin = None
+ self.active_trigger = ""
+
+ # Show trigger suggestions if query matches trigger prefixes
+ trigger_suggestions = self._get_trigger_suggestions(query)
+ all_results = 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 or trigger suggestions - apply max_results limit
+ 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_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_markup=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 (Albert-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()
+
+ # Show/hide scroll container based on results
+ if self.results:
+ self.results_scroll.show()
+ else:
+ self.results_scroll.hide()
+
+ def _update_input_action_text(self):
+ """Update the input field with action text (Albert-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()
+
+ if self.triggered_plugin:
+ if self.results:
+ # Show the first result as action text
+ first_result = self.results[0]
+ action_text = first_result.title
+
+ # For calculator results, show the evaluation
+ if hasattr(first_result, "data") and "result" in first_result.data:
+ action_text = str(first_result.data["result"])
+
+ # Set placeholder to show the action text
+ if action_text and action_text != current_text:
+ self.search_entry.set_placeholder_text(
+ f"{current_text} โ {action_text}"
+ )
+ else:
+ self.search_entry.set_placeholder_text(
+ f"[{self.active_trigger.strip()}] searching..."
+ )
+ 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"[{self.active_trigger.strip()}] ready - type to search"
+ )
+ else:
+ # Searching within trigger
+ self.search_entry.set_placeholder_text(
+ f"[{self.active_trigger.strip()}] searching..."
+ )
+ else:
+ # Not in trigger mode - show trigger help
+ if current_text == ":":
+ self.search_entry.set_placeholder_text(
+ "Showing all available triggers."
+ )
+ elif current_text:
+ self.search_entry.set_placeholder_text(
+ "Type trigger keyword (calc, app, file, system...)"
+ )
+ else:
+ self.search_entry.set_placeholder_text(
+ "Type trigger keyword: calc, app, file, system..."
+ )
+
+ 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)
+ self.results_scroll.hide()
+
+ 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
+ elif self.triggered_plugin:
+ # Exit trigger mode
+ self.triggered_plugin = None
+ self.active_trigger = ""
+ self.search_entry.set_text("")
+ self._clear_results()
+ 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."""
+ 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."""
+ self.selected_index = index
+ self._activate_selected()
+
+ def _on_result_hovered(self, index):
+ """Handle result item hover."""
+ # Update selection when mouse hovers over a result (without scrolling)
+ 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 _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 60
+ 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..f137a17
--- /dev/null
+++ b/modules/launcher/plugin_manager.py
@@ -0,0 +1,184 @@
+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",
+ "process",
+ "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..1e904f9
--- /dev/null
+++ b/modules/launcher/plugins/applications.py
@@ -0,0 +1,163 @@
+import json
+import re
+from typing import List
+
+from fabric.utils import DesktopApp
+from fabric.utils.helpers import get_desktop_applications, get_relative_path
+
+from modules.dock import Dock
+from modules.launcher.plugin_base import PluginBase
+from modules.launcher.result import Result
+
+
+class ApplicationsPlugin(PluginBase):
+ def __init__(self):
+ super().__init__()
+ self.display_name = "Applications"
+ self.description = "Search and launch desktop applications"
+
+ def initialize(self):
+ # Set up triggers for applications - both with and without spaces
+ self.set_triggers(["app"])
+
+ def cleanup(self):
+ pass
+
+ def _pin_application(self, app):
+ """Pin an application to the dock."""
+ app_data = {
+ k: v
+ for k, v in {
+ "name": app.name,
+ "display_name": app.display_name,
+ "window_class": app.window_class,
+ "executable": app.executable,
+ "command_line": app.command_line,
+ "icon_name": app.icon_name,
+ }.items()
+ if v is not None
+ }
+
+ config_path = get_relative_path("../../../config/assets/dock.json")
+ try:
+ with open(config_path, "r") as file:
+ data = json.load(file)
+ except (FileNotFoundError, json.JSONDecodeError):
+ data = {"pinned_apps": []}
+
+ already_pinned = False
+ for pinned_app in data.get("pinned_apps", []):
+ if (
+ isinstance(pinned_app, dict)
+ and pinned_app.get("name") == app_data["name"]
+ ):
+ already_pinned = True
+ pinned_app.update(app_data)
+ break
+ elif isinstance(pinned_app, str) and pinned_app == app_data["name"]:
+ already_pinned = True
+ data["pinned_apps"].remove(pinned_app)
+ data["pinned_apps"].append(app_data)
+ break
+
+ if not already_pinned:
+ data.setdefault("pinned_apps", []).append(app_data)
+
+ with open(config_path, "w") as file:
+ json.dump(data, file, indent=4)
+
+ Dock.notify_config_change()
+
+ def query(self, query_string: str) -> List[Result]:
+ """Search applications based on query."""
+ if not query_string.strip():
+ return []
+
+ # Get fresh applications list each time (like examples/app-launcher)
+ 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:
+ # Truncate description to prevent overflow beyond 550px window
+ description = app.description or app.generic_name or ""
+ if len(description) > 80: # Limit to ~80 characters to fit in 550px
+ 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):
+ app.launch()
diff --git a/modules/launcher/plugins/bash_scripts.py b/modules/launcher/plugins/bash_scripts.py
new file mode 100644
index 0000000..7a242a6
--- /dev/null
+++ b/modules/launcher/plugins/bash_scripts.py
@@ -0,0 +1,552 @@
+import json
+import os
+import threading
+import time
+from typing import Dict, List
+
+from fabric.utils import exec_shell_command_async
+
+import config.data as data
+import utils.icons as icons
+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
+ }
+
+ # Built-in power manager commands
+ self.power_manager_commands = {
+ "pm balanced": {
+ "description": "Switch to balanced power profile",
+ "profile": "balanced",
+ "icon": icons.power_balanced,
+ },
+ "pm performance": {
+ "description": "Switch to performance power profile",
+ "profile": "performance",
+ "icon": icons.power_performance,
+ },
+ "pm saver": {
+ "description": "Switch to power saver profile",
+ "profile": "power-saver",
+ "icon": icons.power_saving,
+ },
+ }
+
+ # 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
+
+ # Battery service reference
+ self._battery_service = None
+
+ def initialize(self):
+ """Initialize the bash scripts plugin."""
+ self.set_triggers(["sh"])
+ self._load_scripts_cache()
+ self._start_background_cache_update()
+ self._get_battery_service()
+
+ 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 _get_battery_service(self):
+ """Get reference to the battery service."""
+ try:
+ from services.battery import Battery
+ self._battery_service = Battery()
+ except Exception as e:
+ print(f"BashScriptsPlugin: Error getting battery service: {e}")
+ self._battery_service = None
+
+ def _set_power_profile(self, profile: str) -> bool:
+ """Set the power profile using the battery service."""
+ if not self._battery_service:
+ print("BashScriptsPlugin: Battery service not available")
+ return False
+
+ try:
+ # Check if profile proxy is available
+ if not hasattr(self._battery_service, '_profile_proxy') or not self._battery_service._profile_proxy:
+ print("BashScriptsPlugin: Power profile proxy not available")
+ return False
+
+ # Get available profiles
+ profiles = self._battery_service._profile_proxy.Profiles
+ available_profiles = []
+ for p in profiles:
+ if isinstance(p, dict) and "Profile" in p:
+ available_profiles.append(p["Profile"])
+ elif hasattr(p, "Profile"):
+ available_profiles.append(p.Profile)
+ elif isinstance(p, str):
+ available_profiles.append(p)
+
+ # Map profile types to actual profile names
+ profile_mapping = {
+ "power-saver": ["power-saver", "powersave", "power_saver"],
+ "balanced": ["balanced", "balance"],
+ "performance": ["performance", "performance-mode"]
+ }
+
+ # Find the actual profile name
+ actual_profile = None
+ if profile in profile_mapping:
+ for candidate in profile_mapping[profile]:
+ if candidate in available_profiles:
+ actual_profile = candidate
+ break
+ elif profile in available_profiles:
+ actual_profile = profile
+
+ if actual_profile:
+ self._battery_service._profile_proxy.ActiveProfile = actual_profile
+ print(f"BashScriptsPlugin: Successfully set power profile to {actual_profile}")
+ return True
+ else:
+ print(f"BashScriptsPlugin: No matching profile found for '{profile}' in available profiles: {available_profiles}")
+ return False
+
+ except Exception as e:
+ print(f"BashScriptsPlugin: Error setting power profile: {e}")
+ return False
+
+
+
+ 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 = []
+
+ # Check for power manager commands first
+ power_results = self._search_power_commands(query)
+ if power_results:
+ results.extend(power_results)
+
+ # Handle special commands
+ if not query:
+ # Show all scripts when no query
+ results.extend(self._list_all_scripts())
+ # Also show power manager commands when no query
+ if not power_results:
+ results.extend(self._list_all_power_commands())
+ 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 _search_power_commands(self, query: str) -> List[Result]:
+ """Search for power manager commands matching the query."""
+ if not query:
+ return []
+
+ results = []
+ query_lower = query.lower()
+
+ for cmd, info in self.power_manager_commands.items():
+ cmd_lower = cmd.lower()
+ description_lower = info["description"].lower()
+
+ # Check for exact match or partial match
+ if (query_lower == cmd_lower or
+ cmd_lower.startswith(query_lower) or
+ query_lower in cmd_lower or
+ query_lower in description_lower):
+
+ relevance = 1.0 if query_lower == cmd_lower else 0.9
+
+ result = Result(
+ title=cmd,
+ subtitle=info["description"],
+ icon_markup=info["icon"],
+ action=self._create_power_action(info["profile"]),
+ relevance=relevance,
+ plugin_name=self.display_name,
+ data={"command": cmd, "profile": info["profile"], "type": "power_manager"}
+ )
+ results.append(result)
+
+ return results
+
+ def _list_all_power_commands(self) -> List[Result]:
+ """List all power manager commands."""
+ results = []
+
+ for cmd, info in self.power_manager_commands.items():
+ result = Result(
+ title=cmd,
+ subtitle=info["description"],
+ icon_markup=info["icon"],
+ action=self._create_power_action(info["profile"]),
+ relevance=0.8,
+ plugin_name=self.display_name,
+ data={"command": cmd, "profile": info["profile"], "type": "power_manager"}
+ )
+ results.append(result)
+
+ return results
+
+ def _create_power_action(self, profile: str):
+ """Create an action function for setting power profile."""
+ def action():
+ success = self._set_power_profile(profile)
+ if success:
+ # Clear the search query after successful execution
+ try:
+ from fabric import Application
+ from gi.repository import GLib
+
+ app = Application.get_default()
+ if app and hasattr(app, "launcher"):
+ launcher = app.launcher
+ if launcher and hasattr(launcher, "search_entry"):
+ def clear_search():
+ launcher.search_entry.set_text("")
+ return False
+ # Use a small delay to ensure the action completes first
+ GLib.timeout_add(50, clear_search)
+ except Exception as e:
+ print(f"BashScriptsPlugin: Could not clear search query: {e}")
+ return action
+
+ 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_markup = icons.file
+ elif script_type == "custom":
+ icon_markup = icons.star
+ else:
+ icon_markup = icons.terminal
+
+ return Result(
+ title=script_name,
+ subtitle=subtitle,
+ icon_markup=icon_markup,
+ 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_markup=icons.terminal,
+ 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..424a80e
--- /dev/null
+++ b/modules/launcher/plugins/bookmarks.py
@@ -0,0 +1,735 @@
+import json
+import subprocess
+import threading
+import time
+from pathlib import Path
+from typing import Dict, List, Optional
+from urllib.parse import urlparse
+
+from fabric.utils.helpers import get_relative_path
+from thefuzz import fuzz
+
+import utils.icons as icons
+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 your first bookmark",
+ icon_markup=icons.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 your first bookmark",
+ icon_markup=icons.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_markup=icons.bulb,
+ 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_markup=icons.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_markup=icons.bulb,
+ 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_markup=icons.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:
+ return [
+ Result(
+ title="Bookmark Already Exists",
+ subtitle=f"A bookmark with URL '{normalized_url}' already exists",
+ icon_markup=icons.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)
+ subtitle = f"Click to add bookmark: {domain}"
+ if description:
+ subtitle += f" โข {description}"
+
+ return [
+ Result(
+ title=f"Add bookmark for '{title}'",
+ subtitle=subtitle,
+ icon_markup=icons.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_markup=icons.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", ""))
+
+ return [
+ Result(
+ title=f"Remove bookmark '{title}'?",
+ subtitle=f"Click to confirm deletion: {domain}",
+ icon_markup=icons.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_markup=icons.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_markup=icons.bookmark,
+ 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", ""))
+ results.append(
+ Result(
+ title=f"remove {title}",
+ subtitle=f"Click to remove: {title} ({domain})",
+ icon_markup=icons.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
+ if len(title) > 60:
+ title = title[:57] + "..."
+
+ # Create subtitle with domain and description
+ domain = self._extract_domain(url)
+ if description:
+ subtitle = f"{domain} โข {description}"
+ else:
+ subtitle = domain
+
+ return Result(
+ title=title,
+ subtitle=subtitle,
+ icon_markup=icons.bookmark,
+ 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..433d0c1
--- /dev/null
+++ b/modules/launcher/plugins/caffeine.py
@@ -0,0 +1,209 @@
+import subprocess
+from threading import Timer
+from typing import List
+
+import gi
+from fabric.utils import get_relative_path
+from fabric.utils.helpers import exec_shell_command_async
+
+import config.data as data
+import utils.icons as icons
+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_markup=icons.coffee,
+ 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_markup=icons.coffee,
+ 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..1468154
--- /dev/null
+++ b/modules/launcher/plugins/calculator.py
@@ -0,0 +1,166 @@
+import math
+import re
+import subprocess
+import time
+from typing import List
+
+import utils.icons as icons
+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_markup=icons.calculator,
+ 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_markup=icons.calculator,
+ 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_markup=icons.calculator,
+ 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..d86bd5a
--- /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..834d673
--- /dev/null
+++ b/modules/launcher/plugins/emoji.py
@@ -0,0 +1,188 @@
+import json
+import os
+import subprocess
+import time
+from collections import OrderedDict
+from typing import Dict, List
+
+from fabric.utils import get_relative_path
+
+import config.data as data
+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..d9f95c4
--- /dev/null
+++ b/modules/launcher/plugins/otp.py
@@ -0,0 +1,851 @@
+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
+
+import utils.icons as icons
+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(1):
+ current_time = time.time()
+ if current_time - self.last_update >= 1:
+ self.last_update = current_time
+ 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_markup=icons.question,
+ 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_markup=icons.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_markup=icons.key,
+ 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_markup=icons.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_markup=icons.key,
+ 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_markup=icons.cancel,
+ 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_markup=icons.key,
+ 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_markup=icons.magnifier,
+ 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_markup=icons.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_markup=icons.question,
+ 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_markup=icons.bulb,
+ 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_markup=icons.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_markup=icons.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_markup=icons.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_markup=icons.connect,
+ 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_markup=icons.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_markup=icons.connect,
+ 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_markup=icons.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_markup=icons.trash,
+ 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_markup=icons.trash,
+ 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_markup=icons.trash,
+ 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_markup=icons.cancel,
+ 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_markup=icons.trash,
+ 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_markup=icons.cancel,
+ 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_markup=icons.check,
+ 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_markup=icons.cancel,
+ 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_markup=icons.check,
+ 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..18f1f65
--- /dev/null
+++ b/modules/launcher/plugins/password.py
@@ -0,0 +1,692 @@
+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
+
+import utils.icons as icons
+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_markup=icons.key,
+ 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_markup=icons.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_markup=icons.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_markup=icons.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_markup=icons.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_markup=icons.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_markup=icons.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_markup=icons.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_markup=icons.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_markup=icons.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_markup=icons.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..ffdc919
--- /dev/null
+++ b/modules/launcher/plugins/power.py
@@ -0,0 +1,103 @@
+from typing import List
+
+from fabric.utils import exec_shell_command_async
+
+import utils.icons as icons
+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": icons.shutdown,
+ "action": self.shutdown,
+ },
+ "restart": {
+ "description": "Restart the system",
+ "icon": icons.reboot,
+ "action": self.restart,
+ },
+ "lock": {
+ "description": "Lock the screen",
+ "icon": icons.lock,
+ "action": self.lock,
+ },
+ "suspend": {
+ "description": "Suspend the system",
+ "icon": icons.suspend,
+ "action": self.suspend,
+ },
+ "logout": {
+ "description": "Logout from current session",
+ "icon": icons.logout,
+ "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_markup=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_markup=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/process.py b/modules/launcher/plugins/process.py
new file mode 100644
index 0000000..d94bfbe
--- /dev/null
+++ b/modules/launcher/plugins/process.py
@@ -0,0 +1,524 @@
+import threading
+import time
+from typing import List
+
+import psutil
+
+import utils.icons as icons
+from modules.launcher.plugin_base import PluginBase
+from modules.launcher.result import Result
+
+
+class ProcessPlugin(PluginBase):
+ """
+ Plugin for viewing and managing running processes with live CPU/RAM updates.
+ """
+
+ def __init__(self):
+ super().__init__()
+ self.display_name = "Process Manager"
+ self.description = "View and manage running processes"
+
+ # Threading for auto-refresh
+ self.refresh_thread = None
+ self.stop_refresh = threading.Event()
+ self.last_update = 0
+
+ # CPU monitoring cache to get meaningful readings
+ self._cpu_cache = {}
+ self._last_cpu_update = 0
+
+ def initialize(self):
+ """Initialize the process plugin."""
+ self.set_triggers(["ps"])
+ # Pre-populate CPU cache for immediate meaningful readings
+ self._initialize_cpu_monitoring()
+ self._start_refresh_thread()
+
+ def _initialize_cpu_monitoring(self):
+ """Initialize CPU monitoring for all processes to get meaningful readings immediately."""
+ try:
+ for proc in psutil.process_iter(["pid"]):
+ try:
+ pid = proc.info["pid"]
+ # Initialize CPU monitoring - this establishes baseline for future readings
+ proc.cpu_percent()
+ self._cpu_cache[pid] = proc
+ except (
+ psutil.NoSuchProcess,
+ psutil.AccessDenied,
+ psutil.ZombieProcess,
+ ):
+ continue
+ self._last_cpu_update = time.time()
+ except Exception as e:
+ print(f"ProcessPlugin: Error initializing CPU monitoring: {e}")
+
+ def cleanup(self):
+ """Cleanup the process plugin."""
+ self.stop_refresh.set()
+ if self.refresh_thread:
+ self.refresh_thread.join(timeout=1)
+ # Clear CPU cache
+ self._cpu_cache.clear()
+
+ def _get_live_process_data(self):
+ """Get fresh live process data with CPU/RAM information, grouped by application."""
+ try:
+ current_time = time.time()
+
+ # Initialize CPU monitoring if this is the first call or enough time has passed
+ if current_time - self._last_cpu_update > 0.1: # Update every 100ms
+ # Pre-populate CPU cache for all processes
+ for proc in psutil.process_iter(["pid"]):
+ try:
+ pid = proc.info["pid"]
+ # Initialize CPU monitoring - this call establishes baseline
+ proc.cpu_percent()
+ self._cpu_cache[pid] = proc
+ except (
+ psutil.NoSuchProcess,
+ psutil.AccessDenied,
+ psutil.ZombieProcess,
+ ):
+ continue
+ self._last_cpu_update = current_time
+
+ # Group processes by application name
+ app_groups = {}
+
+ for proc in psutil.process_iter(["pid", "name", "status", "ppid"]):
+ try:
+ info = proc.info
+ if info["status"] == psutil.STATUS_ZOMBIE:
+ continue
+
+ pid = info["pid"]
+ name = info["name"] or "Unknown"
+
+ # Get CPU percentage - use cached process if available for better readings
+ if pid in self._cpu_cache:
+ try:
+ cpu_percent = self._cpu_cache[pid].cpu_percent()
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
+ cpu_percent = proc.cpu_percent()
+ self._cpu_cache[pid] = proc
+ else:
+ cpu_percent = proc.cpu_percent()
+ self._cpu_cache[pid] = proc
+
+ # Normalize CPU to 0-100% range (divide by number of cores)
+ cpu_cores = psutil.cpu_count()
+ if cpu_cores and cpu_percent > 0:
+ cpu_percent = min(100.0, cpu_percent / cpu_cores)
+
+ memory_info = proc.memory_info()
+ memory_percent = proc.memory_percent()
+ memory_mb = memory_info.rss / (1024 * 1024) if memory_info else 0
+
+ process_data = {
+ "pid": pid,
+ "ppid": info["ppid"],
+ "name": name,
+ "cpu_percent": cpu_percent,
+ "memory_percent": memory_percent,
+ "memory_mb": memory_mb,
+ "status": info["status"],
+ }
+
+ # Group processes by application name
+ app_name = self._get_app_name(name)
+ if app_name not in app_groups:
+ app_groups[app_name] = []
+ app_groups[app_name].append(process_data)
+
+ except (
+ psutil.NoSuchProcess,
+ psutil.AccessDenied,
+ psutil.ZombieProcess,
+ ):
+ continue
+
+ # Create grouped results
+ grouped_processes = []
+ for app_name, app_processes in app_groups.items():
+ if len(app_processes) == 1:
+ # Single process - show as individual
+ proc = app_processes[0]
+ proc["process_count"] = 1
+ grouped_processes.append(proc)
+ else:
+ # Multiple processes - group them and sum resources
+ total_cpu = sum(p["cpu_percent"] for p in app_processes)
+ total_memory_mb = sum(p["memory_mb"] for p in app_processes)
+ total_memory_percent = sum(
+ p["memory_percent"] for p in app_processes
+ )
+
+ # Find the main process (usually the one with lowest PID)
+ main_process = min(app_processes, key=lambda x: x["pid"])
+
+ grouped_process = {
+ # Use main process PID for killing
+ "pid": main_process["pid"],
+ "name": app_name,
+ "cpu_percent": total_cpu,
+ "memory_percent": total_memory_percent,
+ "memory_mb": total_memory_mb,
+ "status": main_process["status"],
+ "process_count": len(app_processes),
+ "child_pids": [p["pid"] for p in app_processes],
+ }
+ grouped_processes.append(grouped_process)
+
+ # Clean up old entries from cache
+ all_pids = set()
+ for app_processes in app_groups.values():
+ all_pids.update(p["pid"] for p in app_processes)
+ self._cpu_cache = {
+ pid: proc for pid, proc in self._cpu_cache.items() if pid in all_pids
+ }
+
+ # Sort by combined CPU and memory usage for better display
+ grouped_processes.sort(
+ key=lambda x: (x["cpu_percent"] + x["memory_percent"]), reverse=True
+ )
+ return grouped_processes
+
+ except Exception as e:
+ print(f"ProcessPlugin: Error getting process data: {e}")
+ return []
+
+ def _get_app_name(self, process_name: str) -> str:
+ """Extract application name from process name, handling common patterns."""
+ # Remove common suffixes and normalize
+ name = process_name.lower()
+
+ # Handle common browser patterns
+ if "firefox" in name:
+ return "Firefox"
+ elif "chrome" in name or "chromium" in name:
+ return "Chrome"
+ elif "zen" in name or name in [
+ "socket process",
+ "privileged cont",
+ "rdd process",
+ "isolated web co",
+ "web content",
+ "webextensions",
+ "utility process",
+ "isolated servic",
+ ]:
+ return "Zen Browser"
+ elif "code" in name and ("helper" in name or "oss" in name or name == "code"):
+ return "VS Code"
+ elif name.startswith("python") and len(name) > 6:
+ return "Python"
+ elif name.startswith("node") and len(name) > 4:
+ return "Node.js"
+ elif "electron" in name:
+ return "Electron"
+ elif "java" in name:
+ return "Java"
+ elif "gnome" in name:
+ return "GNOME"
+ elif "gtk" in name:
+ return "GTK App"
+
+ # Remove common suffixes
+ suffixes = [
+ "-bin",
+ "-real",
+ "-wrapped",
+ ".bin",
+ ".exe",
+ "-helper",
+ "-gpu",
+ "-renderer",
+ ]
+ for suffix in suffixes:
+ if name.endswith(suffix):
+ name = name[: -len(suffix)]
+ break
+
+ # Capitalize first letter
+ return name.capitalize()
+
+ def query(self, query_string: str) -> List[Result]:
+ """Search for processes matching the query with live updates - ALWAYS FRESH DATA."""
+ # ALWAYS get completely fresh data - NO CACHING EVER
+ processes = self._get_live_process_data()
+
+ query = query_string.strip().lower()
+ results = []
+
+ # Filter processes based on query
+ filtered_processes = processes
+ if query:
+ filtered_processes = [
+ proc
+ for proc in processes
+ if query in proc["name"].lower() or query in str(proc["pid"])
+ ]
+
+ # Limit to top 20 processes to avoid overwhelming the UI
+ for proc in filtered_processes[:20]:
+ result = self._create_process_result(proc)
+ if result:
+ results.append(result)
+
+ return results
+
+ def _create_process_result(self, proc_data: dict) -> Result:
+ """Create a Result object for a process or grouped application."""
+ try:
+ pid = proc_data["pid"]
+ name = proc_data["name"]
+ cpu_percent = proc_data["cpu_percent"]
+ memory_percent = proc_data["memory_percent"]
+ memory_mb = proc_data["memory_mb"]
+ process_count = proc_data.get("process_count", 1)
+
+ # Format the title with process count if grouped
+ if process_count > 1:
+ title = f"{name} ({process_count} processes)"
+ else:
+ title = f"{name} (PID: {pid})"
+
+ # Add visual indicators for high usage
+ cpu_indicator = (
+ "๐ฅ" if cpu_percent > 80 else "โก" if cpu_percent > 50 else ""
+ )
+ mem_indicator = "๐พ" if memory_percent > 80 else ""
+
+ subtitle = f"CPU: {cpu_percent:.1f}%{cpu_indicator} | Memory: {memory_mb:.1f}MB ({memory_percent:.1f}%){mem_indicator}"
+
+ # Choose icon based on CPU usage
+ if cpu_percent > 50:
+ icon_markup = icons.cpu
+ elif memory_percent > 50:
+ icon_markup = icons.memory
+ else:
+ icon_markup = icons.terminal
+
+ return Result(
+ title=title,
+ subtitle=subtitle,
+ icon_markup=icon_markup,
+ action=lambda: None, # No action on Enter - just show process info
+ relevance=min(1.0, (cpu_percent + memory_percent) / 100.0),
+ plugin_name=self.display_name,
+ data={
+ "type": "process",
+ "pid": pid,
+ "name": name,
+ "cpu_percent": cpu_percent,
+ "memory_percent": memory_percent,
+ "memory_mb": memory_mb,
+ "process_count": process_count,
+ "child_pids": proc_data.get("child_pids", [pid]),
+ "keep_launcher_open": True, # Keep launcher open to see live updates
+ "alt_action": lambda data=proc_data: self._kill_process_group(data),
+ },
+ )
+ except Exception as e:
+ print(f"ProcessPlugin: Error creating process result: {e}")
+ return None
+
+ def _kill_process_group(self, proc_data: dict):
+ """Kill a process group (application with all its subprocesses)."""
+ try:
+ process_count = proc_data.get("process_count", 1)
+ child_pids = proc_data.get("child_pids", [proc_data["pid"]])
+ app_name = proc_data["name"]
+
+ if process_count == 1:
+ # Single process
+ self._kill_process(proc_data["pid"])
+ else:
+ # Multiple processes - kill all
+ killed_count = 0
+ for pid in child_pids:
+ if self._kill_process(pid, silent=True):
+ killed_count += 1
+
+
+ except Exception as e:
+ print(f"โ Error killing process group: {e}")
+
+ def _kill_process(self, pid: int, silent: bool = False) -> bool:
+ """Kill a process by PID. Returns True if successful."""
+ try:
+ proc = psutil.Process(pid)
+ proc_name = proc.name()
+
+ # Try graceful termination first
+ proc.terminate()
+
+ # Wait a bit for graceful termination
+ try:
+ proc.wait(timeout=3)
+ return True
+ except psutil.TimeoutExpired:
+ # Force kill if graceful termination failed
+ proc.kill()
+ if not silent:
+ print(f"โ Force killed process '{proc_name}' (PID: {pid})")
+ return True
+
+ except psutil.NoSuchProcess:
+ if not silent:
+ print(f"โ Process {pid} not found - may have already been terminated")
+ return False
+ except psutil.AccessDenied:
+ if not silent:
+ print(
+ f"โ Access denied - cannot kill process {pid} (insufficient permissions)"
+ )
+ return False
+ except Exception as e:
+ if not silent:
+ print(f"โ Error killing process {pid}: {e}")
+ return False
+
+ def _start_refresh_thread(self):
+ """Start background thread for auto-refreshing process data in milliseconds."""
+
+ def refresh_loop():
+ # Check every 500ms for real-time updates
+ while not self.stop_refresh.wait(0.5):
+ current_time = time.time()
+ if current_time - self.last_update >= 0.5: # Update every 500ms
+ self.last_update = current_time
+ # Update existing process labels without full refresh
+ 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 process data in existing result items (like OTP plugin)."""
+ 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_process_results = any(
+ result.data and result.data.get("type") == "process"
+ for result in obj.results
+ if hasattr(result, "data") and result.data
+ )
+
+ if has_process_results:
+ self._update_existing_process_labels(obj.results_box)
+ return False
+ except Exception:
+ pass
+ return False
+
+ GLib.idle_add(do_update)
+ except Exception:
+ pass
+
+ def _update_existing_process_labels(self, results_box):
+ """Update subtitle labels in existing ResultItem widgets with FRESH data (optimized)."""
+ try:
+ # Get fresh process data in background thread - optimized
+ def get_data_async():
+ return self._get_live_process_data()
+
+ # Run data collection in background to avoid blocking UI
+ import concurrent.futures
+
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
+ future = executor.submit(get_data_async)
+ try:
+ current_processes = future.result(timeout=1.0) # 1 second timeout
+ process_dict = {proc["pid"]: proc for proc in current_processes}
+
+ 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") == "process"
+ ):
+ self._update_process_result_item(child, process_dict)
+ except concurrent.futures.TimeoutError:
+ # Skip update if it takes too long
+ pass
+ except Exception as e:
+ print(f"Error updating process labels: {e}")
+
+ def _update_process_result_item(self, result_item, process_dict):
+ """Update both the title and subtitle of a specific process ResultItem."""
+ try:
+ pid = result_item.result.data.get("pid")
+ if not pid or pid not in process_dict:
+ return
+
+ proc_data = process_dict[pid]
+ cpu_percent = proc_data["cpu_percent"]
+ memory_percent = proc_data["memory_percent"]
+ memory_mb = proc_data["memory_mb"]
+
+ # CPU is already normalized in _get_live_process_data, so no need to normalize again
+
+ # Update the subtitle with live data
+ cpu_indicator = (
+ "๐ฅ" if cpu_percent > 80 else "โก" if cpu_percent > 50 else ""
+ )
+ mem_indicator = "๐พ" if memory_percent > 80 else ""
+ new_subtitle = f"CPU: {cpu_percent:.1f}%{cpu_indicator} | Memory: {memory_mb:.1f}MB ({memory_percent:.1f}%){mem_indicator}"
+
+ # Find and update the subtitle label widget
+ self._find_and_update_subtitle_label(result_item, new_subtitle)
+
+ # Update the result data for consistency
+ result_item.result.data.update(
+ {
+ "cpu_percent": cpu_percent,
+ "memory_percent": memory_percent,
+ "memory_mb": memory_mb,
+ }
+ )
+
+ except Exception as e:
+ print(f"Error updating process result item: {e}")
+
+ def _find_and_update_subtitle_label(self, result_item, new_subtitle):
+ """Find the subtitle label widget and update its text."""
+
+ 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_text"):
+ subtitle_label.set_text(new_subtitle)
diff --git a/modules/launcher/plugins/reminders.py b/modules/launcher/plugins/reminders.py
new file mode 100644
index 0000000..bcfbb26
--- /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
+
+import utils.icons as icons
+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_markup=icons.timer_on,
+ 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_markup=icons.timer_on,
+ 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_markup=icons.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_markup=icons.timer_on,
+ 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_markup=icons.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_markup=icons.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_markup=icons.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_markup=icons.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_markup=icons.timer_on,
+ 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_markup=icons.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_markup=icons.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..3db965b
--- /dev/null
+++ b/modules/launcher/plugins/screencapture.py
@@ -0,0 +1,685 @@
+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
+from utils.icons import screenrecord, screenshots, ssfull, ssregion, stop
+
+
+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"
+ # Path to the screen-capture script
+ 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",
+ ssfull,
+ lambda: self._run_script("screenshot", "eDP-1"),
+ ),
+ "ss": (
+ "Take Screenshot (eDP-1)",
+ "Capture the main display",
+ ssfull,
+ lambda: self._run_script("screenshot", "eDP-1"),
+ ),
+ "screenshot-region": (
+ "Take Region Screenshot",
+ "Capture a selected region",
+ ssregion,
+ lambda: self._run_script("screenshot", "selection"),
+ ),
+ "ss-region": (
+ "Take Region Screenshot",
+ "Capture a selected region",
+ ssregion,
+ lambda: self._run_script("screenshot", "selection"),
+ ),
+ "screenshot-both": (
+ "Take Screenshot (Both Displays)",
+ "Capture both displays combined",
+ screenshots,
+ lambda: self._run_script("screenshot", "both"),
+ ),
+ "ss-both": (
+ "Take Screenshot (Both Displays)",
+ "Capture both displays combined",
+ screenshots,
+ lambda: self._run_script("screenshot", "both"),
+ ),
+ "screenshot-hdmi": (
+ "Take Screenshot (HDMI-A-1)",
+ "Capture HDMI display",
+ ssfull,
+ lambda: self._run_script("screenshot", "HDMI-A-1"),
+ ),
+ "ss-hdmi": (
+ "Take Screenshot (HDMI-A-1)",
+ "Capture HDMI display",
+ ssfull,
+ lambda: self._run_script("screenshot", "HDMI-A-1"),
+ ),
+ # Recording commands (with audio)
+ "record": (
+ "Start Recording (eDP-1)",
+ "Record the main display with audio",
+ screenrecord,
+ lambda: self._run_script("record", "eDP-1"),
+ ),
+ "rec": (
+ "Start Recording (eDP-1)",
+ "Record the main display with audio",
+ screenrecord,
+ lambda: self._run_script("record", "eDP-1"),
+ ),
+ "record-region": (
+ "Start Region Recording",
+ "Record a selected region with audio",
+ screenrecord,
+ lambda: self._run_script("record", "selection"),
+ ),
+ "rec-region": (
+ "Start Region Recording",
+ "Record a selected region with audio",
+ screenrecord,
+ lambda: self._run_script("record", "selection"),
+ ),
+ "record-hdmi": (
+ "Start Recording (HDMI-A-1)",
+ "Record HDMI display with audio",
+ screenrecord,
+ lambda: self._run_script("record", "HDMI-A-1"),
+ ),
+ "rec-hdmi": (
+ "Start Recording (HDMI-A-1)",
+ "Record HDMI display with audio",
+ screenrecord,
+ 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",
+ screenrecord,
+ lambda: self._run_script("record-noaudio", "eDP-1"),
+ ),
+ "rec-noaudio": (
+ "Start Recording No Audio (eDP-1)",
+ "Record the main display without audio",
+ screenrecord,
+ lambda: self._run_script("record-noaudio", "eDP-1"),
+ ),
+ "record-noaudio-region": (
+ "Start Region Recording No Audio",
+ "Record a selected region without audio",
+ screenrecord,
+ lambda: self._run_script("record-noaudio", "selection"),
+ ),
+ "rec-noaudio-region": (
+ "Start Region Recording No Audio",
+ "Record a selected region without audio",
+ screenrecord,
+ lambda: self._run_script("record-noaudio", "selection"),
+ ),
+ "record-noaudio-hdmi": (
+ "Start Recording No Audio (HDMI-A-1)",
+ "Record HDMI display without audio",
+ screenrecord,
+ lambda: self._run_script("record-noaudio", "HDMI-A-1"),
+ ),
+ "rec-noaudio-hdmi": (
+ "Start Recording No Audio (HDMI-A-1)",
+ "Record HDMI display without audio",
+ screenrecord,
+ 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",
+ screenrecord,
+ lambda: self._run_script("record-hq", "eDP-1"),
+ ),
+ "rec-hq": (
+ "Start HQ Recording (eDP-1)",
+ "High-quality recording for YouTube",
+ screenrecord,
+ lambda: self._run_script("record-hq", "eDP-1"),
+ ),
+ "record-hq-region": (
+ "Start HQ Region Recording",
+ "High-quality region recording",
+ screenrecord,
+ lambda: self._run_script("record-hq", "selection"),
+ ),
+ "rec-hq-region": (
+ "Start HQ Region Recording",
+ "High-quality region recording",
+ screenrecord,
+ lambda: self._run_script("record-hq", "selection"),
+ ),
+ "record-hq-hdmi": (
+ "Start HQ Recording (HDMI-A-1)",
+ "High-quality HDMI recording",
+ screenrecord,
+ lambda: self._run_script("record-hq", "HDMI-A-1"),
+ ),
+ "rec-hq-hdmi": (
+ "Start HQ Recording (HDMI-A-1)",
+ "High-quality HDMI recording",
+ screenrecord,
+ lambda: self._run_script("record-hq", "HDMI-A-1"),
+ ),
+ # GIF recording commands
+ "record-gif": (
+ "Start GIF Recording (eDP-1)",
+ "Record as optimized GIF",
+ screenrecord,
+ lambda: self._run_script("record-gif", "eDP-1"),
+ ),
+ "rec-gif": (
+ "Start GIF Recording (eDP-1)",
+ "Record as optimized GIF",
+ screenrecord,
+ lambda: self._run_script("record-gif", "eDP-1"),
+ ),
+ "record-gif-region": (
+ "Start GIF Region Recording",
+ "Record selected region as GIF",
+ screenrecord,
+ lambda: self._run_script("record-gif", "selection"),
+ ),
+ "rec-gif-region": (
+ "Start GIF Region Recording",
+ "Record selected region as GIF",
+ screenrecord,
+ lambda: self._run_script("record-gif", "selection"),
+ ),
+ # Control commands
+ "stop": (
+ "Stop Recording",
+ "Stop the current screen recording",
+ stop,
+ lambda: self._run_script("record", "stop"),
+ ),
+ # Conversion commands
+ "convert-webm": (
+ "Convert Latest to WebM",
+ "Convert latest MKV recording to WebM format",
+ screenrecord,
+ lambda: self._run_script("convert", "webm"),
+ ),
+ "conv-webm": (
+ "Convert Latest to WebM",
+ "Convert latest MKV recording to WebM format",
+ screenrecord,
+ lambda: self._run_script("convert", "webm"),
+ ),
+ "convert-iphone": (
+ "Convert Latest for iPhone",
+ "Convert latest MKV recording for iPhone compatibility",
+ screenrecord,
+ lambda: self._run_script("convert", "iphone"),
+ ),
+ "conv-iphone": (
+ "Convert Latest for iPhone",
+ "Convert latest MKV recording for iPhone compatibility",
+ screenrecord,
+ lambda: self._run_script("convert", "iphone"),
+ ),
+ "convert-youtube": (
+ "Convert Latest for YouTube",
+ "Convert latest recording for YouTube upload",
+ screenrecord,
+ lambda: self._run_script("convert", "youtube"),
+ ),
+ "conv-youtube": (
+ "Convert Latest for YouTube",
+ "Convert latest recording for YouTube upload",
+ screenrecord,
+ lambda: self._run_script("convert", "youtube"),
+ ),
+ "convert-gif": (
+ "Convert Latest to GIF",
+ "Convert latest recording to GIF format",
+ screenrecord,
+ lambda: self._run_script("convert", "gif"),
+ ),
+ "conv-gif": (
+ "Convert Latest to GIF",
+ "Convert latest recording to GIF format",
+ screenrecord,
+ 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)",
+ screenrecord,
+ 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)",
+ screenrecord,
+ 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)",
+ screenrecord,
+ 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)",
+ screenrecord,
+ 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)",
+ screenrecord,
+ 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)",
+ screenrecord,
+ 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)",
+ screenrecord,
+ 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)",
+ screenrecord,
+ 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_markup=icon,
+ action=action,
+ relevance=1.0,
+ plugin_name=self.display_name,
+ )
+ else: # File-based command, show instruction
+ return Result(
+ title=title,
+ subtitle=subtitle,
+ icon_markup=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_markup=screenrecord,
+ 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_markup=stop,
+ 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_markup=ssfull,
+ 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_markup=ssregion,
+ 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_markup=screenshots,
+ 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_markup=ssfull,
+ 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_markup=screenrecord,
+ 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_markup=screenrecord,
+ 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_markup=screenrecord,
+ 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_markup=screenrecord,
+ 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_markup=screenrecord,
+ 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_markup=screenrecord,
+ 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_markup=screenrecord,
+ 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_markup=screenrecord,
+ 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_markup=screenrecord,
+ 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_markup=screenrecord,
+ 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_markup=screenrecord,
+ 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_markup=screenrecord,
+ 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_markup=screenrecord,
+ 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_markup=screenrecord,
+ 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_markup=screenrecord,
+ 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..65142f1
--- /dev/null
+++ b/modules/launcher/plugins/system.py
@@ -0,0 +1,234 @@
+import json
+import os
+import shlex
+import threading
+import time
+from typing import List, Set, Union
+
+from fabric.utils import exec_shell_command_async
+
+import config.data as data
+import utils.icons as icons
+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_markup=icons.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..23e1267
--- /dev/null
+++ b/modules/launcher/plugins/tmux.py
@@ -0,0 +1,292 @@
+import subprocess
+import threading
+import time
+from typing import List
+
+from fabric.utils import exec_shell_command_async
+
+import config.data as data
+import utils.icons as icons
+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
+ self._cache_ttl = 2 # Cache sessions for 2 seconds
+
+ # Threading for auto-refresh
+ self.refresh_thread = None
+ self.stop_refresh = threading.Event()
+
+ def initialize(self):
+ """Initialize the tmux plugin."""
+ self.set_triggers(["tmux"])
+ self._start_refresh_thread()
+
+ 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."""
+ while not self.stop_refresh.is_set():
+ try:
+ current_time = time.time()
+ if current_time - self._last_cache_update > self._cache_ttl:
+ self._sessions_cache = self._get_tmux_sessions()
+ self._last_cache_update = current_time
+
+ # Sleep for a short interval
+ self.stop_refresh.wait(1)
+ except Exception as e:
+ print(f"TmuxPlugin: Error in refresh thread: {e}")
+ self.stop_refresh.wait(5) # 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 = []
+
+ # Get current sessions (use cache if recent)
+ current_time = time.time()
+ 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_markup=icons.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_markup=icons.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_markup=icons.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_markup=icons.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..e349a80
--- /dev/null
+++ b/modules/launcher/plugins/wallpaper.py
@@ -0,0 +1,996 @@
+import colorsys
+import hashlib
+import json
+import os
+import random
+import re
+import threading
+import time
+from typing import Dict, List, Optional
+
+from fabric.utils.helpers import exec_shell_command_async
+from gi.repository import GdkPixbuf
+from PIL import Image
+
+import config.data as data
+import utils.icons as icons
+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:
+ print(f"Wallpapers directory not found: {data.WALLPAPERS_DIR}")
+ except Exception as e:
+ print(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:
+ print(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:
+ print(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 fabric import Application
+ from gi.repository import GLib
+
+ 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:
+ print(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:
+ print(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:
+ print(f"Error loading thumbnail for {wallpaper}: {e}")
+ self.thumbnail_cache[wallpaper] = None
+ else:
+ self.thumbnail_cache[wallpaper] = None
+ except Exception as e:
+ print(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:
+ print(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 with optional matugen integration."""
+ 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)
+
+ # Apply wallpaper with or without matugen
+ matugen_enabled = self._get_matugen_state()
+ if matugen_enabled:
+ exec_shell_command_async(f'matugen image "{full_path}" -t {scheme}')
+ else:
+ exec_shell_command_async(
+ f'swww img "{
+ full_path
+ }" -t outer --transition-duration 1.5 --transition-step 255 --transition-fps 60 -f Nearest'
+ )
+
+ 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_markup=random.choice(
+ [
+ icons.dice_1,
+ icons.dice_2,
+ icons.dice_3,
+ icons.dice_4,
+ icons.dice_5,
+ icons.dice_6,
+ ]
+ ),
+ 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_markup=random.choice(
+ [
+ icons.dice_1,
+ icons.dice_2,
+ icons.dice_3,
+ icons.dice_4,
+ icons.dice_5,
+ icons.dice_6,
+ ]
+ ),
+ 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_markup=icons.palette,
+ 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_markup=icons.palette,
+ 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_markup=icons.palette,
+ 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_markup=icons.palette,
+ 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_markup=icons.palette,
+ 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_markup=icons.palette,
+ 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_markup=icons.palette,
+ 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_markup=icons.palette,
+ 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_markup=icons.palette,
+ 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_markup=icons.palette,
+ 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_markup=icons.palette,
+ 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_markup=icons.palette,
+ 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_markup=icons.palette,
+ 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_markup=icons.palette,
+ 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_markup=icons.palette,
+ 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_markup=icons.palette,
+ 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_markup=icons.palette,
+ 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_markup=random.choice(
+ [
+ icons.dice_1,
+ icons.dice_2,
+ icons.dice_3,
+ icons.dice_4,
+ icons.dice_5,
+ icons.dice_6,
+ ]
+ ),
+ 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_markup=icons.wallpapers 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..575492a
--- /dev/null
+++ b/modules/launcher/plugins/websearch.py
@@ -0,0 +1,260 @@
+import subprocess
+import urllib.parse
+from typing import List
+
+import utils.icons as icons
+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": icons.google,
+ "description": "Search with Google",
+ },
+ "duckduckgo": {
+ "name": "DuckDuckGo",
+ "url": "https://duckduckgo.com/?q={}",
+ "icon": icons.duckduckgo,
+ "description": "Search with DuckDuckGo (privacy-focused)",
+ },
+ "youtube": {
+ "name": "YouTube",
+ "url": "https://www.youtube.com/results?search_query={}",
+ "icon": icons.youtube,
+ "description": "Search videos on YouTube",
+ },
+ "github": {
+ "name": "GitHub",
+ "url": "https://github.com/search?q={}",
+ "icon": icons.github,
+ "description": "Search repositories on GitHub",
+ },
+ "stackoverflow": {
+ "name": "Stack Overflow",
+ "url": "https://stackoverflow.com/search?q={}",
+ "icon": icons.stackoverflow,
+ "description": "Search programming questions on Stack Overflow",
+ },
+ "wikipedia": {
+ "name": "Wikipedia",
+ "url": "https://en.wikipedia.org/wiki/Special:Search?search={}",
+ "icon": icons.wikipedia,
+ "description": "Search articles on Wikipedia",
+ },
+ "reddit": {
+ "name": "Reddit",
+ "url": "https://www.reddit.com/search/?q={}",
+ "icon": icons.reddit,
+ "description": "Search discussions on Reddit",
+ },
+ "linkedin": {
+ "name": "LinkedIn",
+ "url": "https://www.linkedin.com/search/results/all/?keywords={}",
+ "icon": icons.linkedin,
+ "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_markup=icons.world,
+ 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_markup=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_markup=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_markup=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..27dcf82
--- /dev/null
+++ b/modules/launcher/result_item.py
@@ -0,0 +1,161 @@
+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=32, 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-x-executable",
+ icon_size=32,
+ 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..cb67687
--- /dev/null
+++ b/modules/launcher/trigger_config.py
@@ -0,0 +1,59 @@
+import json
+import os
+from typing import Any, Dict, List
+
+from fabric.utils import get_relative_path
+
+import utils.icons as icons
+
+
+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": "apps",
+ "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_name = self.launcher_config.get(trigger, {}).get(
+ "icon", self.settings.get("default_icon", "apps")
+ )
+ return getattr(icons, icon_name, icons.apps)
+
+ 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/panel/components/indicators.py b/modules/panel/components/indicators.py
new file mode 100644
index 0000000..421cf79
--- /dev/null
+++ b/modules/panel/components/indicators.py
@@ -0,0 +1,295 @@
+from fabric.bluetooth import BluetoothClient
+from fabric.widgets.box import Box
+from fabric.widgets.button import Button
+from fabric.widgets.label import Label
+from fabric.utils import get_relative_path
+from fabric.widgets.svg import Svg
+
+import utils.icons as icons
+from services.network import NetworkClient
+from gi.repository import GLib
+from services.battery import Battery as BatteryService
+
+
+class WifiIndicator(Button):
+ def __init__(self, **kwargs):
+ super().__init__(**kwargs)
+
+ self.network = NetworkClient()
+ self.wifi_icon = Svg(
+ name="indicators-icon",
+ size=24,
+ svg_file=get_relative_path("../../../config/assets/icons/wifi.svg"),
+ )
+
+ self.add(self.wifi_icon)
+
+ self.network.connect("ready", self.on_network_ready)
+
+ if self.network.is_ready:
+ self.setup_signals()
+
+ def on_network_ready(self, *args):
+ self.setup_signals()
+
+ def setup_signals(self):
+ self.network.connect("changed", self.on_network_changed)
+ self.network.connect("wifi-device-added", self.on_wifi_device_added)
+ self.network.connect("wifi-device-removed", self.on_wifi_device_removed)
+ self.network.connect("ethernet-device-added", self.on_ethernet_device_added)
+ self.network.connect("ethernet-device-removed", self.on_ethernet_device_removed)
+
+ if self.network.wifi_device:
+ self.network.wifi_device.connect("changed", self.on_wifi_changed)
+ self.network.wifi_device.connect("ap-added", self.on_wifi_changed)
+ self.network.wifi_device.connect("ap-removed", self.on_wifi_changed)
+
+ if self.network.ethernet_device:
+ self.network.ethernet_device.connect("changed", self.on_ethernet_changed)
+
+ self.update_state()
+
+ def update_state(self):
+ if (
+ self.network.ethernet_device
+ and self.network.ethernet_device.state == "activated"
+ ):
+ self.wifi_icon.set_from_file(get_relative_path("../../../config/assets/icons/wifi.svg"))
+ tooltip = f"Connected to Ethernet ({self.network.ethernet_device.speed})"
+ self.set_tooltip_text(tooltip)
+ return
+
+ if not self.network.wifi_device:
+ self.wifi_icon.set_from_file(get_relative_path("../../../config/assets/icons/wifi-off.svg"))
+ tooltip = "No WiFi device found"
+ elif not self.network.wifi_device.wireless_enabled:
+ self.wifi_icon.set_from_file(get_relative_path("../../../config/assets/icons/wifi-off.svg"))
+ tooltip = "WiFi disabled"
+ else:
+ active_ap = self.network.wifi_device.active_access_point
+ if active_ap:
+ strength = active_ap.strength
+ # Use wifi.svg for all connected states since we don't have signal strength icons
+ self.wifi_icon.set_from_file(get_relative_path("../../../config/assets/icons/wifi.svg"))
+ tooltip = f"Connected to {active_ap.ssid} ({strength}%)"
+ else:
+ self.wifi_icon.set_from_file(get_relative_path("../../../config/assets/icons/wifi-off.svg"))
+ tooltip = "Not connected"
+
+ self.set_tooltip_text(tooltip)
+
+ def on_wifi_device_added(self, *args):
+ self.network.wifi_device.connect("changed", self.on_wifi_changed)
+ self.network.wifi_device.connect("ap-added", self.on_wifi_changed)
+ self.network.wifi_device.connect("ap-removed", self.on_wifi_changed)
+ self.update_state()
+
+ def on_wifi_device_removed(self, *args):
+ self.update_state()
+
+ def on_network_changed(self, *args):
+ self.update_state()
+
+ def on_wifi_changed(self, *args):
+ self.update_state()
+
+ def on_ethernet_device_added(self, *args):
+ if self.network.ethernet_device:
+ self.network.ethernet_device.connect("changed", self.on_ethernet_changed)
+ self.update_state()
+
+ def on_ethernet_device_removed(self, *args):
+ self.update_state()
+
+ def on_ethernet_changed(self, *args):
+ self.update_state()
+
+
+class BluetoothIndicator(Button):
+ def __init__(self, **kwargs):
+ super().__init__(**kwargs)
+
+ self.bluetooth = BluetoothClient()
+
+ # Use SVG icons instead of font icons
+ self.bt_icon = Svg(
+ name="bt-icon",
+ size=20,
+ svg_file=get_relative_path("../../../config/assets/icons/bluetooth.svg"),
+ )
+ self.add(self.bt_icon)
+
+ self.bluetooth.connect("changed", self.on_bluetooth_changed)
+ self.bluetooth.connect("device-added", self.on_device_added)
+ self.bluetooth.connect("device-removed", self.on_device_removed)
+
+ self.update_state()
+
+ def update_state(self):
+ if not self.bluetooth.enabled:
+ self.bt_icon.set_from_file(get_relative_path("../../../config/assets/icons/bluetooth-off.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/bluetooth.svg"))
+ if len(connected_devices) == 1:
+ 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/bluetooth.svg"))
+ tooltip = "No devices connected"
+
+ self.set_tooltip_text(tooltip)
+
+ def on_bluetooth_changed(self, *args):
+ self.update_state()
+
+ def on_device_added(self, _, address):
+ self.update_state()
+
+ def on_device_removed(self, _, address):
+ self.update_state()
+
+class Battery(Box):
+ def __init__(self, **kwargs):
+ super().__init__(
+ orientation="v", visible=True, **kwargs
+ )
+
+ self._battery = BatteryService()
+ self._battery.changed.connect(self.update_battery)
+
+ self.icon = Svg(
+ name="indicators-icon",
+ size=24,
+ svg_file=get_relative_path("../../../config/assets/icons/battery/battery-100.svg"),
+ )
+ self.battery_button = Button(
+ child=self.icon,
+ )
+
+ self.battery_box = Box(
+ name="battery-box",
+ orientation="h",
+ children=[self.battery_button],
+ )
+
+ self.battery_button.set_has_tooltip(True)
+ self.battery_box.set_has_tooltip(True)
+
+ self.battery_button.connect("query-tooltip", self.on_query_tooltip)
+ self.battery_box.connect("query-tooltip", self.on_query_tooltip)
+
+ self.add(self.battery_box)
+
+ GLib.idle_add(self.update_battery)
+
+ def on_query_tooltip(self, *_):
+ tooltip = _[-1]
+ tooltip.set_markup(self.get_tooltip_text())
+ return True
+
+ def get_tooltip_text(self):
+ if not self._battery.is_present:
+ return "No battery detected"
+
+ percentage = self._battery.percentage
+ state = self._battery.state
+
+ capacity = int(float(self._battery.capacity.rstrip("%")))
+ tooltip_points = []
+
+ if state == "FULLY_CHARGED":
+ status = f"{icons.battery_full} Fully Charged"
+ elif state == "CHARGING":
+ status = f"{icons.bat_charging} Charging"
+ elif percentage <= 15 and state == "DISCHARGING":
+ status = f"{icons.bat_alert} Low Battery"
+ elif state == "DISCHARGING":
+ status = f"{icons.battery_0} Discharging"
+ else:
+ status = "Battery"
+ tooltip_points.append(f"โข Status: {status}")
+
+ tooltip_points.append(f"โข Level: {int(percentage)}%")
+ tooltip_points.append(f"โข Battery Health: {capacity}%")
+
+ power_profile = self._battery.power_profile
+ if power_profile:
+ if power_profile == "power-saver":
+ profile_icon = icons.power_saving
+ elif power_profile == "performance":
+ profile_icon = icons.power_performance
+ else:
+ profile_icon = icons.power_balanced
+ tooltip_points.append(f"โข Profile: {profile_icon} {power_profile}")
+
+ temp = self._battery.temperature
+ if temp != "N/A":
+ tooltip_points.append(f"โข Temperature: {temp}")
+
+ if state == "CHARGING":
+ time_to_full = self._battery.time_to_full
+ if time_to_full:
+ tooltip_points.append(f"โข Time until full: {time_to_full}")
+ elif state == "DISCHARGING":
+ time_to_empty = self._battery.time_to_empty
+ if time_to_empty:
+ tooltip_points.append(f"โข Time remaining: {time_to_empty}")
+
+ return "\n".join(tooltip_points)
+
+ def update_battery(self, *_):
+ if not self._battery.is_present:
+ self.set_visible(False)
+ return True
+
+ percentage = self._battery.percentage
+ state = self._battery.state
+ charging = state in ["CHARGING", "FULLY_CHARGED"]
+
+ if percentage <= 15 and not charging:
+ self.icon.add_style_class("alert")
+ else:
+ self.icon.remove_style_class("alert")
+
+ if state == "FULLY_CHARGED" or (percentage >= 100 and state == "CHARGING"):
+ self.icon.set_from_file(get_relative_path("../../../config/assets/icons/battery/battery-100-charging.svg"))
+ elif state == "CHARGING":
+ # Calculate battery level and use corresponding charging icon
+ level = min(100, max(0, int(percentage // 10) * 10))
+ if level == 0 and percentage > 0:
+ level = 10
+ icon_name = f"battery-{level:03d}-charging.svg"
+ self.icon.set_from_file(get_relative_path(f"../../../config/assets/icons/battery/{icon_name}"))
+ else:
+ # Calculate battery level and use corresponding icon
+ level = min(100, max(0, int(percentage // 10) * 10))
+ if level == 0 and percentage > 0:
+ level = 10
+ icon_name = f"battery-{level:03d}.svg"
+ self.icon.set_from_file(get_relative_path(f"../../../config/assets/icons/battery/{icon_name}"))
+
+ self.set_visible(True)
+ return True
+
+
+class Indicators(Box):
+ def __init__(self, **kwargs):
+ super().__init__(
+ name="indicators",
+ orientation="h",
+ spacing=10,
+ children=[
+ Battery(),
+ WifiIndicator(),
+ BluetoothIndicator(),
+ ],
+ **kwargs,
+ )
+ self.show_all()
diff --git a/modules/panel/components/menu_actions.py b/modules/panel/components/menu_actions.py
new file mode 100644
index 0000000..072b1e7
--- /dev/null
+++ b/modules/panel/components/menu_actions.py
@@ -0,0 +1,95 @@
+"""
+Menu action handlers for the macOS-style menu bar.
+This file contains all the implementations for menu item actions.
+"""
+
+from fabric.hyprland.service import Hyprland
+from fabric.utils import exec_shell_command
+
+
+class MenuActionHandler:
+
+ def __init__(self):
+ pass
+
+ def execute_action(self, action):
+ try:
+ # Window menu actions
+ if action == "Move Window to Left":
+ Hyprland.send_command("dispatch movewindow l")
+ elif action == "Move Window to Right":
+ Hyprland.send_command("dispatch movewindow r")
+ elif action == "Cycle Through Windows":
+ Hyprland.send_command("dispatch cyclenext")
+ elif action == "Float":
+ Hyprland.send_command("dispatch togglefloating")
+ elif action == "Pseudo":
+ Hyprland.send_command("dispatch pseudo")
+ elif action == "Center":
+ Hyprland.send_command("dispatch centerwindow")
+ elif action == "Group":
+ Hyprland.send_command("dispatch togglegroup")
+ elif action == "Pin":
+ Hyprland.send_command("dispatch pin")
+ elif action == "Quit":
+ Hyprland.send_command("dispatch killactive")
+
+
+ # Go menu actions
+ elif action == "Back":
+ Hyprland.send_command("dispatch workspace e-1")
+ elif action == "Forward":
+ Hyprland.send_command("dispatch workspace e+1")
+
+
+ # Edit menu actions
+
+
+ # View menu actions
+ elif action == "Full Screen":
+ Hyprland.send_command("dispatch fullscreen")
+ elif action == "Zoom In":
+ exec_shell_command("wtype -k ctrl+plus")
+ elif action == "Zoom Out":
+ exec_shell_command("wtype -k ctrl+minus")
+ elif action == "Actual Size":
+ exec_shell_command("wtype -k ctrl+0")
+
+ # Help menu actions
+ elif action == "Hyprland Help":
+ exec_shell_command("xdg-open https://wiki.hyprland.org/")
+ elif action == "Arch Wiki":
+ exec_shell_command("xdg-open https://wiki.archlinux.org/")
+ elif action == "Report a Bug...":
+ exec_shell_command("xdg-open https://github.com/S4NKALP/Modus/issues")
+
+ # System menu actions
+ elif action == "About This PC":
+ exec_shell_command("gnome-system-monitor")
+ elif action == "Force Quit":
+ exec_shell_command("gnome-system-monitor")
+ elif action == "Shutdown":
+ exec_shell_command("systemctl poweroff")
+ elif action == "Restart":
+ exec_shell_command("systemctl reboot")
+ elif action == "Sleep":
+ exec_shell_command("systemctl suspend")
+ elif action == "Lock":
+ exec_shell_command("hyprlock")
+
+
+ # App menu actions (for hiding apps)
+ elif action.startswith("Hide ") and not action == "Hide Others":
+ action.replace("Hide ", "")
+ Hyprland.send_command("dispatch movetoworkspacesilent special:hidden")
+ elif action == "Hide Others":
+ Hyprland.send_command("dispatch movetoworkspacesilent special:hidden,^(activewindow)")
+ elif action == "Show All":
+ Hyprland.send_command("dispatch movetoworkspace e+0,special:hidden")
+ elif action.startswith("Quit "):
+ action.replace("Quit ", "")
+ Hyprland.send_command("dispatch killactive")
+ else:
+ print(f"No implementation for action: {action}")
+ except Exception as e:
+ print(f"Error executing command for '{action}': {e}")
diff --git a/modules/panel/components/menu_contents.py b/modules/panel/components/menu_contents.py
new file mode 100644
index 0000000..6706520
--- /dev/null
+++ b/modules/panel/components/menu_contents.py
@@ -0,0 +1,75 @@
+"""
+Menu contents definitions for the macOS-style menu bar.
+This file contains all the menu items and their organization.
+"""
+
+
+def get_default_menu_contents():
+ return {
+ "Hyprland": [
+ ],
+ "File": [
+ ],
+ "Edit": [
+ ],
+ "View": [
+ "Full Screen",
+ "---",
+ "Zoom In",
+ "Zoom Out",
+ "Actual Size"
+ ],
+ "Go": [
+ "Back",
+ "Forward",
+ ],
+ "Window": [
+ "Move Window to Left",
+ "Move Window to Right",
+ "Cycle Through Windows",
+ "---",
+ "Float",
+ "Pseudo",
+ "Center",
+ "Group",
+ "Pin",
+ "Quit"
+ ],
+ "Help": [
+ "---",
+ "Hyprland Help",
+ "Arch Wiki",
+ "Keyboard Shortcuts",
+ "---",
+ "Report a Bug..."
+ ]
+ }
+
+
+def get_app_menu_template():
+ return [
+ "About {app_name}",
+ "---",
+ "Hide {app_name}",
+ "---",
+ "Quit {app_name}"
+ ]
+
+
+def create_app_menu(app_name):
+ template = get_app_menu_template()
+ return [item.format(app_name=app_name) if "{app_name}" in item else item for item in template]
+
+
+def get_menu_contents_for_app(app_name):
+ contents = get_default_menu_contents().copy()
+
+ if app_name and app_name != "Hyprland":
+ # Replace the Hyprland menu with app-specific menu
+ contents["Hyprland"] = create_app_menu(app_name)
+
+ return contents
+
+
+def get_app_specific_menu_contents(app_name):
+ return get_menu_contents_for_app(app_name)
diff --git a/modules/panel/components/menubar.py b/modules/panel/components/menubar.py
new file mode 100644
index 0000000..8c84ca3
--- /dev/null
+++ b/modules/panel/components/menubar.py
@@ -0,0 +1,362 @@
+from fabric.widgets.box import Box
+from fabric.widgets.button import Button
+from fabric.widgets.label import Label
+from fabric.hyprland.widgets import HyprlandActiveWindow
+from utils.wayland import WaylandWindow
+from gi.repository import Gtk, GLib
+from .menu_contents import get_default_menu_contents, get_app_specific_menu_contents
+from .menu_actions import MenuActionHandler
+
+
+class DropdownWindow(WaylandWindow):
+ def __init__(self, x=0, y=0, parent_menubar=None, **kwargs):
+ super().__init__(
+ name="dropdown-window",
+ title="Menu Dropdown",
+ layer="overlay",
+ anchor="top left",
+ exclusivity="none",
+ margin=f"{y}px 0px 0px {x}px",
+ visible=False,
+ all_visible=True,
+ **kwargs
+ )
+
+ # Store reference to parent menubar for closing dropdown
+ self.parent_menubar = parent_menubar
+
+ self.content_box = Box(
+ name="dropdown-content",
+ orientation="v",
+ spacing=2,
+ )
+ self.add(self.content_box)
+
+ self.connect("enter-notify-event", self.on_mouse_enter)
+ self.connect("leave-notify-event", self.on_mouse_leave)
+
+ self.auto_close_timer = None
+
+ def on_mouse_enter(self, widget, event):
+ # Only handle if the event is for the main dropdown window, not child widgets
+ if event.detail != 2: # 2 = Gdk.NotifyType.INFERIOR (child widget)
+ # Cancel auto-close timer when mouse enters
+ if self.auto_close_timer:
+ GLib.source_remove(self.auto_close_timer)
+ self.auto_close_timer = None
+ return False
+
+ def on_mouse_leave(self, widget, event):
+ # Only handle if actually leaving the dropdown window, not entering child widgets
+ if event.detail != 2: # 2 = Gdk.NotifyType.INFERIOR (child widget)
+ # Start auto-close timer when mouse leaves
+ if not self.auto_close_timer:
+ self.auto_close_timer = GLib.timeout_add(300, self.auto_close_callback) # 300ms delay
+ return False
+
+ def auto_close_callback(self):
+ if self.parent_menubar:
+ self.parent_menubar.hide_dropdown()
+ self.auto_close_timer = None
+ return False
+
+
+class MenuBar(Box):
+ def __init__(self, **kwargs):
+ super().__init__(
+ name="menubar",
+ orientation="h",
+ spacing=0,
+ **kwargs,
+ )
+
+ self.active_window = HyprlandActiveWindow(name="hyprland-window")
+ self.active_window.connect("window-activated", self.on_window_changed)
+
+ self.current_window_class = None
+ self.current_window_title = None
+
+ self.current_dropdown = None
+ self.dropdown_window = None
+
+ self.action_handler = MenuActionHandler()
+
+ # Menu items in macOS style order (excluding the first item which will be dynamic)
+ self.static_menu_items = [
+ "File",
+ "Edit",
+ "View",
+ "Go",
+ "Window",
+ "Help"
+ ]
+
+ self.menu_contents = get_default_menu_contents()
+
+ self.app_button = Button(
+ name="menubar-button",
+ child=Label(
+ name="activewindow-label",
+ label="Hyprland"
+ ),
+ )
+ self.app_button.connect("clicked", lambda widget: self.toggle_dropdown("Hyprland", widget))
+ self.add(self.app_button)
+
+ self.menu_buttons = []
+ for item in self.static_menu_items:
+ button = Button(
+ name="menubar-button",
+ child=Label(
+ name="menubar-label",
+ label=item
+ ),
+ )
+
+ button.connect("clicked", lambda widget, menu_item=item: self.on_menu_button_clicked(menu_item, widget))
+ self.menu_buttons.append(button)
+ self.add(button)
+
+ self.update_menu_button_states()
+
+ self.show_all()
+
+ def get_button_for_item(self, menu_item):
+ if menu_item == "Hyprland":
+ return self.app_button
+
+ for i, item in enumerate(self.static_menu_items):
+ if item == menu_item:
+ return self.menu_buttons[i]
+ return None
+
+ def on_menu_button_clicked(self, menu_item, button):
+ # Check if we should allow this menu to be opened
+ if not self.should_menu_be_active(menu_item):
+ return
+
+ # Menu is allowed, proceed with normal toggle
+ self.toggle_dropdown(menu_item, button)
+
+ def should_menu_be_active(self, menu_item):
+ # These menus should only work when there's an active window
+ context_dependent_menus = ["File", "Edit", "View", "Go"]
+
+ if menu_item in context_dependent_menus:
+ # Only allow if there's an active window (not just showing "Hyprland")
+ has_active_window = (
+ self.current_window_title and
+ self.current_window_title.strip() and
+ self.app_button.get_child().get_label() != "Hyprland"
+ )
+ return has_active_window
+
+ # Window and Help menus are always available
+ return True
+
+ def update_menu_button_states(self):
+ context_dependent_menus = ["File", "Edit", "View", "Go"]
+
+ for i, menu_item in enumerate(self.static_menu_items):
+ if menu_item in context_dependent_menus:
+ button = self.menu_buttons[i]
+ is_active = self.should_menu_be_active(menu_item)
+
+ # Keep buttons sensitive for hovering, but add visual styling for inactive state
+ # Don't use set_sensitive(False) as it prevents hovering
+ if is_active:
+ button.get_style_context().remove_class("inactive")
+ else:
+ button.get_style_context().add_class("inactive")
+
+ def toggle_dropdown(self, menu_item, button):
+ # If same menu is clicked and dropdown is visible, hide it
+ if (self.current_dropdown == menu_item and
+ self.dropdown_window and
+ self.dropdown_window.get_visible()):
+ self.hide_dropdown()
+ return
+
+ # (show_dropdown will handle hiding the previous one)
+ self.show_dropdown(menu_item, button)
+
+ def show_dropdown(self, menu_item, button):
+ # Hide existing dropdown if any and wait for it to be completely hidden
+ if self.dropdown_window:
+ self.dropdown_window.set_visible(False)
+ self.dropdown_window.destroy()
+ self.dropdown_window = None
+ # Wait a short moment to ensure the previous dropdown is completely gone
+ GLib.timeout_add(150, self._create_and_show_dropdown, menu_item, button)
+ else:
+ # No existing dropdown, show immediately
+ self._create_and_show_dropdown(menu_item, button)
+
+ def _create_and_show_dropdown(self, menu_item, button):
+ # Calculate position first
+ x, y = self.calculate_dropdown_position(button)
+
+ # Create new dropdown window at the calculated position
+ self.dropdown_window = DropdownWindow(x=x, y=y, parent_menubar=self)
+
+ # Get menu items for this menu
+ menu_items = self.menu_contents.get(menu_item, [])
+
+ # Add menu items to dropdown
+ for item in menu_items:
+ if item == "---":
+ # Add separator
+ separator = Box(
+ name="menu-separator",
+ size=(150, 1),
+ style="background-color: #444; margin: 2px 0;"
+ )
+ self.dropdown_window.content_box.add(separator)
+ else:
+ # Add menu item button
+ menu_button = Button(
+ name="dropdown-item",
+ child=Label(
+ name="dropdown-label",
+ label=item,
+ h_align="start" # Align text to the left
+ ),
+ on_clicked=lambda *_, action=item: self.on_menu_action(action)
+ )
+ self.dropdown_window.content_box.add(menu_button)
+
+ self.dropdown_window.set_size_request(150, -1)
+
+ self.current_dropdown = menu_item
+ self.dropdown_window.set_visible(True)
+
+ return False
+
+ def calculate_dropdown_position(self, button):
+ # Get button allocation
+ allocation = button.get_allocation()
+
+ print(f"Button allocation: x={allocation.x}, y={allocation.y}, width={allocation.width}, height={allocation.height}")
+
+ # Use the exact button position for perfect alignment
+ dropdown_x = allocation.x
+ dropdown_y = allocation.y + 3 # 3px gap below button
+
+ print(f"Perfect dropdown position: x={dropdown_x}, y={dropdown_y}")
+
+ return dropdown_x, dropdown_y
+
+ def hide_dropdown(self):
+ if self.dropdown_window:
+ self.dropdown_window.set_visible(False)
+ self.dropdown_window.destroy()
+ self.dropdown_window = None
+ self.current_dropdown = None
+
+ def on_menu_action(self, action):
+ # Hide dropdown after action
+ self.hide_dropdown()
+
+ # Handle window management actions
+ self.execute_menu_action(action)
+
+ def execute_menu_action(self, action):
+ self.action_handler.execute_action(action)
+
+ def on_window_changed(self, widget, window_class, window_title):
+ self.current_window_class = window_class
+ self.current_window_title = window_title
+
+ # Update the first button label and menu contents
+ if window_title and window_title.strip():
+ # Show the window title/app name when there's an active window
+ display_name = window_class if window_class else window_title
+ # Capitalize the first letter
+ display_name = display_name.capitalize() if display_name else "Hyprland"
+ self.app_button.get_child().set_label(display_name)
+
+ # Update the app menu contents dynamically
+ self.update_app_menu(display_name)
+ else:
+ # Show "Hyprland" when no active window
+ self.app_button.get_child().set_label("Hyprland")
+ self.update_app_menu("Hyprland")
+
+ # Update menu button states based on new context
+ self.update_menu_button_states()
+
+ def update_app_menu(self, app_name):
+ # Get app-specific menu contents (includes app-specific overrides if available)
+ self.menu_contents = get_app_specific_menu_contents(app_name)
+
+ def show_system_dropdown(self, button):
+ # If same system dropdown is open, hide it (toggle behavior)
+ if (self.current_dropdown == "System" and
+ self.dropdown_window and
+ self.dropdown_window.get_visible()):
+ self.hide_dropdown()
+ return
+
+ # Hide existing dropdown if any and show system dropdown
+ if self.dropdown_window:
+ self.dropdown_window.set_visible(False)
+ self.dropdown_window.destroy()
+ self.dropdown_window = None
+ # Wait a short moment to ensure the previous dropdown is completely gone
+ GLib.timeout_add(150, self._create_and_show_system_dropdown, button)
+ else:
+ # No existing dropdown, show immediately
+ self._create_and_show_system_dropdown(button)
+
+ def _create_and_show_system_dropdown(self, button):
+ # Calculate position first
+ x, y = self.calculate_dropdown_position(button)
+
+ # Create new dropdown window at the calculated position
+ self.dropdown_window = DropdownWindow(x=x, y=y, parent_menubar=self)
+
+ # System menu items
+ system_menu_items = [
+ "About This PC",
+ "---",
+ "Force Quit",
+ "---",
+ "Shutdown",
+ "Restart",
+ "Sleep",
+ "Lock"
+ ]
+
+ # Add menu items to dropdown
+ for item in system_menu_items:
+ if item == "---":
+ # Add separator
+ separator = Box(
+ name="menu-separator",
+ size=(150, 1),
+ style="background-color: #444; margin: 2px 0;"
+ )
+ self.dropdown_window.content_box.add(separator)
+ else:
+ # Add menu item button
+ menu_button = Button(
+ name="dropdown-item",
+ child=Label(
+ name="dropdown-label",
+ label=item,
+ h_align="start" # Align text to the left
+ ),
+ on_clicked=lambda *_, action=item: self.on_menu_action(action)
+ )
+ self.dropdown_window.content_box.add(menu_button)
+
+ # Set dropdown window size
+ self.dropdown_window.set_size_request(150, -1)
+
+ # Show the dropdown without animations
+ self.current_dropdown = "System"
+ # Use set_visible(True) for immediate display without animations
+ self.dropdown_window.set_visible(True)
+
+ # Return False to stop the timeout
+ return False
diff --git a/modules/panel/main.py b/modules/panel/main.py
new file mode 100644
index 0000000..e564a91
--- /dev/null
+++ b/modules/panel/main.py
@@ -0,0 +1,169 @@
+from fabric.system_tray.widgets import SystemTray, SystemTrayItem
+from fabric.utils import exec_shell_command, 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.label import Label
+from fabric.widgets.revealer import Revealer
+from gi.repository import Gtk
+from fabric.widgets.svg import Svg
+from modules.panel.components.indicators import Indicators
+from modules.panel.components.menubar import MenuBar
+from utils.wayland import WaylandWindow as Window
+
+original_do_update_properties = SystemTrayItem.do_update_properties
+
+
+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
+ original_do_update_properties(self, *_)
+
+
+SystemTrayItem.do_update_properties = patched_do_update_properties
+
+
+class Panel(Window):
+ def __init__(self, **kwargs):
+ super().__init__(
+ name="bar",
+ layer="top",
+ anchor="left top right",
+ exclusivity="auto",
+ visible=False,
+ )
+
+ self.launcher = kwargs.get("launcher", None)
+ self.menubar = MenuBar()
+
+ self.imac = Button(
+ name="panel-button",
+ child=Svg(
+ size=24,
+ svg_file=get_relative_path("../../config/assets/icons/logo.svg"),
+ ),
+ on_clicked=lambda *_: self.menubar.show_system_dropdown(self.imac),
+ )
+ self.notch_spot = Box(
+ name="notch-spot",
+ size=(200, 24),
+ h_expand=True,
+ v_expand=True,
+ children=Label(label="notch"),
+ )
+
+ 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=18,
+ svg_file=get_relative_path(
+ "../../config/assets/icons/chevron-right.svg"
+ ),
+ ),
+ on_clicked=self.toggle_tray,
+ )
+
+ self.indicators = Indicators()
+
+ self.search = Button(
+ name="panel-button",
+ on_clicked=lambda *_: self.search_apps(),
+ child=Svg(
+ size=20,
+ svg_file=get_relative_path("../../config/assets/icons/search.svg"),
+ ),
+ )
+
+ self.controlcenter = Button(
+ name="panel-button",
+ child=Svg(
+ size=24,
+ svg_file=get_relative_path(
+ "../../config/assets/icons/control-center.svg"
+ ),
+ ),
+ )
+
+ self.children = CenterBox(
+ name="panel",
+ start_children=Box(
+ name="modules-left",
+ children=[
+ self.imac,
+ self.menubar,
+ ],
+ ),
+ # center_children=Box(
+ # name="modules-center",
+ # children=self.notch_spot,
+ # ),
+ end_children=Box(
+ name="modules-right",
+ spacing=4,
+ orientation="h",
+ children=[
+ self.tray_revealer,
+ self.chevron_button,
+ self.indicators,
+ self.search,
+ self.controlcenter,
+ DateTime(name="date-time", formatters=["%a %b %d %I:%M"]),
+ ],
+ ),
+ )
+
+ 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/chevron-left.svg")
+ )
+ else:
+ self.chevron_button.get_child().set_from_file(
+ get_relative_path("../../config/assets/icons/chevron-right.svg")
+ )
diff --git a/modules/switcher.py b/modules/switcher.py
new file mode 100644
index 0000000..7223a1b
--- /dev/null
+++ b/modules/switcher.py
@@ -0,0 +1,292 @@
+import json
+
+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 gi.repository import Gdk
+
+import config.data as data
+from utils.icon_resolver import IconResolver
+from utils.wayland import WaylandWindow as Window
+
+
+class ApplicationSwitcher(Window):
+ def __init__(self, **kwargs):
+ super().__init__(
+ name="application-switcher",
+ layer="top",
+ anchor="center",
+ exclusivity="auto",
+ keyboard_mode="exclusive",
+ **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
+
+ 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=0,
+ 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)
+ 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 _is_special_workspace(self, client):
+ """Check if a client is in a special workspace"""
+ if "workspace" not in client:
+ return False
+
+ workspace = client["workspace"]
+ 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
+
+ if "id" in workspace:
+ workspace_id = workspace["id"]
+ # Special workspaces have negative IDs
+ if workspace_id < 0:
+ return True
+
+ return False
+
+ 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):
+ class_name = window.get("class", "").lower()
+ title = window.get("title", "")
+
+ 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
+ )
+
+ button_content = Box(
+ name="switcher-button",
+ orientation="v",
+ spacing=4,
+ h_align="center",
+ v_align="center",
+ children=[
+ Image(pixbuf=icon_img),
+ 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, widget, 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, widget, 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/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..b6b8e3c
--- /dev/null
+++ b/services/battery.py
@@ -0,0 +1,169 @@
+from fabric.core import Property, Service, Signal
+from pydbus import SystemBus
+import psutil
+from gi.repository import GLib
+
+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"
+
+ @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 self._profile_proxy:
+ return self._profile_proxy.ActiveProfile
+ return None
+
+ def set_power_profile(self, profile: str) -> bool:
+ if not self._profile_proxy:
+ return False
+ if profile not in [p.Profile for p in self._profile_proxy.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
+
+ # 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
+ GLib.timeout_add_seconds(5, self._update_psutil_battery)
+ except Exception:
+ return # psutil battery not available either
+
+ # PowerProfiles
+ 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..88d0d2e
--- /dev/null
+++ b/services/brightness.py
@@ -0,0 +1,95 @@
+import os
+
+from fabric.core.service import Property, Service, Signal
+from fabric.utils import exec_shell_command_async, monitor_file
+from gi.repository import GLib
+from loguru import logger
+
+
+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/mpris.py b/services/mpris.py
new file mode 100644
index 0000000..99dd76a
--- /dev/null
+++ b/services/mpris.py
@@ -0,0 +1,244 @@
+from gi.repository import Playerctl, GLib
+from fabric.utils import bulk_connect
+from fabric.core.service import Service, Signal
+from fabric import Fabricator
+
+from config.data import ALLOWED_PLAYERS
+
+import gi
+
+gi.require_version("Playerctl", "2.0")
+
+
+class PlayerService(Service):
+ @Signal
+ def shuffle_toggle(self, player: Playerctl.Player, status: bool) -> None: ...
+
+ @Signal
+ def meta_change(self, metadata: GLib.Variant, player: Playerctl.Player) -> None: ...
+
+ @Signal
+ def pause(self) -> None: ...
+
+ @Signal
+ def play(self) -> None: ...
+
+ @Signal
+ def track_position(self, pos: float, dur: float) -> None: ...
+
+ def __init__(self, player: Playerctl.Player, **kwargs):
+ self._player: Playerctl.Player = player
+ super().__init__(**kwargs)
+ if player.props.player_name in ALLOWED_PLAYERS:
+ self._player.connect("playback-status::playing", self.on_play)
+ self._player.connect("playback-status::paused", self.on_pause)
+ self._player.connect("shuffle", self.on_shuffle)
+ self._player.connect("metadata", self.on_metadata)
+ self._player.connect("seeked", self.on_seeked)
+
+ self.status = self._player.props.playback_status
+ self.pos_fabricator = Fabricator(
+ interval=1000, # 1s
+ poll_from=lambda f, *_: self._safe_get_position(),
+ on_changed=lambda f, *_: self.fabricating(),
+ )
+ # Add state tracking to prevent repeated operations
+ self._fabricator_running = False
+ self._cleanup_done = False
+ self.poll_progress()
+
+ def _safe_get_position(self):
+ """Safely get player position with error handling"""
+ try:
+ if not self._player or self._cleanup_done:
+ return 0
+ return self._player.get_position()
+ except Exception:
+ # Player is no longer available, stop the fabricator
+ if hasattr(self, 'pos_fabricator') and self.pos_fabricator and self._fabricator_running:
+ try:
+ self.pos_fabricator.stop()
+ self._fabricator_running = False
+ except Exception:
+ pass
+ return 0
+
+ def cleanup(self):
+ """Clean up resources when service is no longer needed"""
+ if self._cleanup_done:
+ return # Already cleaned up
+
+ try:
+ if hasattr(self, 'pos_fabricator') and self.pos_fabricator:
+ if self._fabricator_running:
+ self.pos_fabricator.stop()
+ self._fabricator_running = False
+ self.pos_fabricator = None
+ self._cleanup_done = True
+ except Exception as e:
+ print(f"[DEBUG] Error during PlayerService cleanup: {e}")
+
+ def on_seeked(self, player, position):
+ if (not self._cleanup_done and
+ self.status.value_name == "PLAYERCTL_PLAYBACK_STATUS_PLAYING" and
+ not self._fabricator_running):
+ try:
+ self.pos_fabricator.start()
+ self._fabricator_running = True
+ except Exception:
+ pass # Ignore if already started
+
+ def set_position(self, pos: float):
+ if not self._cleanup_done and self._fabricator_running:
+ try:
+ self.pos_fabricator.stop()
+ self._fabricator_running = False
+ except Exception:
+ pass # Ignore if already stopped
+
+ micro_pos = int(pos * 1_000_000)
+ try:
+ self._player.set_position(micro_pos)
+ except GLib.Error as e:
+ print(f"Failed to seek: {e}")
+
+ def poll_progress(self):
+ if self._cleanup_done:
+ return # Don't operate on cleaned up fabricator
+
+ if self.status.value_name == "PLAYERCTL_PLAYBACK_STATUS_PLAYING":
+ if not self._fabricator_running:
+ try:
+ self.pos_fabricator.start()
+ self._fabricator_running = True
+ except Exception:
+ pass # Ignore if already started
+ else:
+ if self._fabricator_running:
+ try:
+ self.pos_fabricator.stop()
+ self._fabricator_running = False
+ except Exception:
+ pass # Ignore if already stopped
+
+ def fabricating(self):
+ try:
+ # Check if cleanup has been done
+ if self._cleanup_done:
+ return
+
+ # Validate player object
+ if not self._player or not hasattr(self._player, 'get_position'):
+ return
+
+ # Get position safely
+ try:
+ pos = self._player.get_position() / 1_000_000 # seconds
+ except Exception:
+ # Player is no longer available, stop fabricator
+ if self._fabricator_running:
+ try:
+ self.pos_fabricator.stop()
+ self._fabricator_running = False
+ except Exception:
+ pass
+ return
+
+ # Get duration safely from multiple sources
+ dur = 0.0 # Default to 0
+
+ if (hasattr(self._player, 'props') and
+ hasattr(self._player.props, 'metadata') and
+ self._player.props.metadata):
+
+ metadata = self._player.props.metadata
+
+ # Try mpris:length first (most common)
+ try:
+ raw_dur = metadata["mpris:length"]
+ if raw_dur and raw_dur > 0:
+ dur = float(raw_dur) / 1_000_000 # seconds
+ except (KeyError, Exception):
+ pass
+
+ # Fallback to xesam:duration (alternative field)
+ if dur <= 0:
+ try:
+ raw_dur = metadata["xesam:duration"]
+ if raw_dur and raw_dur > 0:
+ dur = float(raw_dur) / 1_000_000 # seconds
+ except (KeyError, Exception):
+ pass
+
+ # Try to get duration from player directly (some players support this)
+ if dur <= 0:
+ try:
+ if hasattr(self._player, 'get_metadata'):
+ player_metadata = self._player.get_metadata()
+ if player_metadata:
+ raw_dur = player_metadata["mpris:length"]
+ if raw_dur and raw_dur > 0:
+ dur = float(raw_dur) / 1_000_000
+ except (KeyError, Exception):
+ pass
+
+ # Validate values before emitting signal
+ # Allow dur=0 for position-only tracking
+ if pos is not None and dur is not None and pos >= 0 and dur >= 0:
+ self.track_position(pos, dur)
+
+ except Exception:
+ pass
+
+ def on_play(self, player, status):
+ self.status = player.props.playback_status
+ self.poll_progress()
+ self.play()
+
+ def on_pause(self, player, status):
+ self.poll_progress()
+ self.pause()
+
+ def on_shuffle(self, player, status):
+ self.shuffle_toggle(player, status)
+
+ def on_metadata(self, player, metadata):
+ keys = metadata.keys()
+ self.meta_change(metadata, player)
+
+
+class PlayerManager(Service):
+ @Signal
+ def new_player(self, player: Playerctl.Player) -> Playerctl.Player: ...
+
+ @Signal
+ def player_vanish(self, player: Playerctl.Player) -> None: ...
+
+ def __init__(self, **kwargs):
+ super().__init__(**kwargs)
+ self._manager = Playerctl.PlayerManager()
+ self._manager.connect("name-appeared", self._on_name_appeared, self._manager)
+ self._manager.connect(
+ "player-vanished", self._on_player_vanished, self._manager
+ )
+
+ self._players = {}
+
+ def init_all_players(self):
+ # invoked in the UI
+ for player_obj in self._manager.props.player_names:
+ name_str = player_obj.name
+ if name_str in ALLOWED_PLAYERS:
+ player = Playerctl.Player.new_from_name(player_obj)
+ self._manager.manage_player(player)
+ self.new_player(player)
+
+ def _on_name_appeared(self, sender, name, manager):
+ name_str = name.name
+ if name_str in ALLOWED_PLAYERS:
+ player = Playerctl.Player.new_from_name(name)
+ self._manager.manage_player(player)
+ self.new_player(player)
+
+ def _on_player_vanished(self, sender, player, manager):
+ self.player_vanish(player)
\ No newline at end of file
diff --git a/services/network.py b/services/network.py
new file mode 100644
index 0000000..1124b4a
--- /dev/null
+++ b/services/network.py
@@ -0,0 +1,631 @@
+from typing import Optional
+
+import gi
+from fabric.core.service import Property, Service, Signal
+from fabric.utils import bulk_connect, get_enum_member_name, snake_case_to_kebab_case
+from gi.repository import NM, GLib
+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: ...
+
+ @Signal
+ def ready(self) -> None: ...
+
+ @Property(bool, "readable", default_value=False)
+ def is_ready(self) -> bool:
+ """Returns whether the client is ready to use."""
+ return self._client is not 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")
+ self.notify("is-ready")
+ self.ready.emit()
+
+ 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):
+ """Connect to a WiFi network."""
+
+ ssid = ap.ssid
+
+ if ssid == "Unknown":
+ logger.error("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
+
+ 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")
+ 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, ssid, device):
+ try:
+ new_connection = client.add_connection_finish(result)
+ if not new_connection:
+ logger.error(f"Failed to create connection for '{ssid}'")
+ return
+
+ logger.info(f"Connection for '{ssid}' added successfully")
+
+ client.activate_connection_async(
+ new_connection,
+ device,
+ None,
+ None,
+ lambda c, r: logger.info(f"Connected to '{ssid}'")
+ if c
+ else logger.error(f"Failed to connect to '{ssid}'"),
+ )
+ except Exception as e:
+ logger.error(f"Failed to add connection: {e}")
+
+ # Save the new connection
+ self._client._client.add_connection_async(
+ connection,
+ True,
+ None,
+ lambda c, r: on_connection_added(c, r, ssid, self._device),
+ )
+
+ # Activate the connection
+ self._client._client.activate_connection_async(
+ connection,
+ self._device,
+ None,
+ None,
+ lambda client, result: logger.info(f"Connected to '{ssid}'"),
+ )
+
+ 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/styles/colors.css b/styles/colors.css
new file mode 100644
index 0000000..0f3590e
--- /dev/null
+++ b/styles/colors.css
@@ -0,0 +1,32 @@
+:vars {
+ --foreground: #e3e1e9;
+ --background: #121318;
+ --cursor: #e3e1e9;
+ --primary: #bac3ff;
+ --on-primary: #222c61;
+ --secondary: #c3c5dd;
+ --on-secondary: #2c2f42;
+ --tertiary: #e5bad8;
+ --on-tertiary: #44263e;
+ --surface: #121318;
+ --surface-bright: #38393f;
+ --error: #ffb4ab;
+ --error-dim: #ff8678;
+ --on-error: #690005;
+ --error-container: #93000a;
+ --outline: #90909a;
+ --shadow: #000000;
+ --red: #ffb2b9;
+ --red-dim: #ff7f8b;
+ --green: #95d5a7;
+ --green-dim: #70c789;
+ --yellow: #b8cf84;
+ --yellow-dim: #a3c15f;
+ --blue: #bcc2ff;
+ --blue-dim: #8994ff;
+ --magenta: #e4b7f3;
+ --magenta-dim: #d48bec;
+ --cyan: #82d3e2;
+ --cyan-dim: #59c4d8;
+ --white: #82d3e0;
+}
diff --git a/styles/dock.css b/styles/dock.css
new file mode 100644
index 0000000..3966d08
--- /dev/null
+++ b/styles/dock.css
@@ -0,0 +1,77 @@
+#dock {
+ background-color: alpha(var(--shadow), 0.4);
+ padding: 8px;
+ margin: 8px 8px 5px 8px;
+ border-radius: 16px;
+ /* border: 2px solid var(--surface); */
+ transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275);
+}
+
+#dock.vertical {
+ margin: 8px 0 8px 8px;
+ border-radius: 20px 0 0 20px;
+}
+
+#dock.vertical.left {
+ margin: 8px 8px 8px 0;
+ border-radius: 0 20px 20px 0;
+}
+
+#dock-full {
+ transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275);
+ opacity: 1;
+}
+
+#dock-separator {
+ padding: 2px;
+ border-radius: 16px;
+ background-color: var(--surface-bright);
+}
+
+#dock-app-button {
+ padding: 4px;
+ border-radius: 24px;
+ /* box-shadow: 0 0 4px alpha(var(--shadow), 0.5); */
+ transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275);
+}
+
+#dock-app-button:hover {
+ background-color: var(--surface-bright);
+}
+
+#dock-app-button:hover.instance {
+ background-color: var(--outline);
+}
+
+#dock-app-button:active,
+#dock-app-button:active.instance {
+ background-color: var(--primary);
+}
+
+#dock-app-button.instance {
+ border-radius: 12px;
+}
+
+#dock-app-dot {
+ background-color: var(--primary);
+ border-radius: 50%;
+ margin-top: 4px;
+ min-width: 4px;
+ min-height: 4px;
+}
+
+#dock-corner-left {
+ margin: 0 -8px 0 0;
+}
+
+#dock-corner-right {
+ margin: 0 0 0 -8px;
+}
+
+#dock-corner-top {
+ margin: 0 0 -8px 0;
+}
+
+#dock-corner-bottom {
+ margin: -8px 0 0 0;
+}
diff --git a/styles/launcher.css b/styles/launcher.css
new file mode 100644
index 0000000..f115f2b
--- /dev/null
+++ b/styles/launcher.css
@@ -0,0 +1,176 @@
+#launcher {
+ background: var(--shadow);
+ padding: 12px;
+ border-radius: 34px;
+ min-width: 550px;
+ transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275);
+}
+
+#close-button,
+#config-button {
+ background-color: var(--surface);
+ border-radius: 40px;
+ padding: 8px;
+ transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275);
+}
+
+#close-button:hover,
+#close-button:focus,
+#config-button:hover,
+#config-button:focus {
+ background-color: var(--surface-bright);
+ border-radius: 16px;
+}
+
+#close-button.focused,
+#config-button.focused {
+ background-color: var(--primary);
+ border-radius: 16px;
+ box-shadow: 0 0 0 2px var(--primary-bright);
+}
+
+#close-button.focused #close-label {
+ color: var(--background);
+}
+
+#config-button.focused #config-label {
+ color: var(--background);
+}
+
+#close-button:active {
+ background-color: var(--red-dim);
+ border-radius: 40px;
+}
+
+#close-label {
+ color: var(--red-dim);
+ font-size: 24px;
+}
+
+#close-button:active #close-label {
+ color: var(--shadow);
+}
+
+#config-button:active {
+ background-color: var(--primary);
+ border-radius: 40px;
+}
+
+#config-label {
+ color: var(--primary);
+ font-size: 24px;
+}
+
+#config-button:active #config-label {
+ color: var(--shadow);
+}
+
+#launcher-icon-label {
+ font-size: 32px;
+ padding: 8px;
+}
+
+#launcher-search {
+ font-weight: bold;
+ background-color: var(--surface);
+ color: var(--foreground);
+ border-radius: 16px;
+ padding: 10px;
+}
+
+#launcher-search selection {
+ color: var(--background);
+ background-color: var(--primary);
+}
+
+#launcher-results-scroll {
+ margin-top: 10px;
+ border-radius: 16px;
+}
+
+#launcher-results-scroll scrollbar {
+ border-radius: 10px;
+ background-color: var(--surface);
+ padding: 4px;
+ margin-left: 6px;
+}
+
+#launcher-results-scroll slider {
+ border-radius: 8px;
+ min-width: 10px;
+ min-height: 38px;
+ background-color: var(--primary);
+}
+
+#launcher-results {
+ background: transparent;
+}
+
+#launcher-result-item {
+ border-radius: 40px;
+ padding: 0 16px;
+ animation: loadSlot 0.5s ease;
+ min-height: 52px;
+ transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275);
+}
+
+@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: 16px;
+ background-color: var(--surface);
+ padding-left: 20px;
+}
+
+#result-item-main {
+ min-height: 48px;
+}
+
+#launcher-result-item {
+ min-height: 48px;
+}
+
+#result-item-icon {
+ min-width: 48px;
+ min-height: 48px;
+ margin-right: 12px;
+}
+
+#result-item-title {
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--foreground);
+ margin-top: 8px;
+ margin-bottom: 2px;
+}
+
+#result-item-subtitle {
+ font-size: 12px;
+ color: var(--outline);
+ margin-bottom: 8px;
+}
+
+#result-item-plugin {
+ font-size: 10px;
+ color: var(--outline);
+ font-style: italic;
+ margin-bottom: 8px;
+ opacity: 0.7;
+}
+
+#network-password-entry {
+ border: 1px solid var(--outline);
+ padding: 8px;
+ border-radius: 8px;
+ margin-bottom: 4px;
+}
diff --git a/styles/menubar.css b/styles/menubar.css
new file mode 100644
index 0000000..9150981
--- /dev/null
+++ b/styles/menubar.css
@@ -0,0 +1,21 @@
+#menu-separator {
+ color: red;
+}
+
+#dropdown-content {
+ background-color: var(--shadow);
+ border-radius: 8px;
+}
+
+#dropdown-label {
+ margin-left: 10px;
+ padding: 4px;
+}
+
+#dropdown-item {
+ border-radius: 4px;
+}
+
+#dropdown-item:hover {
+ background-color: var(--surface-bright);
+}
diff --git a/styles/panel.css b/styles/panel.css
new file mode 100644
index 0000000..1f25eab
--- /dev/null
+++ b/styles/panel.css
@@ -0,0 +1,73 @@
+#panel {
+ background-color: alpha(var(--surface), 0.6);
+}
+
+#panel-icon {
+ font-size: 18px;
+}
+
+#panel-button {
+ border-radius: 8px;
+ margin: 0 4px 0 6px;
+ min-width: 20px;
+ min-height: 20px;
+}
+
+#panel-button:hover {
+ background-color: var(--surface-bright);
+}
+
+#tray-button {
+ border-radius: 8px;
+ margin: 0 4px 0 0px;
+ min-width: 20px;
+ min-height: 20px;
+}
+
+#tray-button:hover {
+ background-color: var(--surface-bright);
+}
+
+#date-time {
+ margin-right: 6px;
+}
+
+#modules-left,
+#modules-right {
+ margin: 0 5px;
+ padding: 3px 0;
+}
+
+#notch-spot {
+ background-color: var(--surface);
+}
+
+#indicators-icon {
+ font-size: 20px;
+}
+
+#bt-icon {
+ font-size: 18px;
+}
+
+#indicators {
+ margin: 0 8px;
+}
+
+#indicators-icon.alert {
+ color: var(--red-dim);
+}
+
+#menubar-button {
+ margin: 0 2px;
+ border-radius: 4px;
+ padding: 0 6px;
+}
+
+#menubar-button:hover {
+ background-color: var(--surface-bright);
+}
+
+#activewindow-label {
+ font-weight: bold;
+}
diff --git a/styles/switcher.css b/styles/switcher.css
new file mode 100644
index 0000000..a1e1bf5
--- /dev/null
+++ b/styles/switcher.css
@@ -0,0 +1,39 @@
+#application-switcher-container {
+ background: var(--shadow);
+ border-radius: 16px;
+}
+
+#application-switcher-view {
+ min-height: 120px;
+ padding: 4px;
+}
+
+#switcher-button {
+ padding: 4px;
+ min-width: 100px;
+ border-radius: 16px;
+}
+
+#window-button {
+ background-color: var(--surface);
+ border-radius: 16px;
+}
+
+#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/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..1799ae6
--- /dev/null
+++ b/utils/animator.py
@@ -0,0 +1,179 @@
+from typing import cast
+
+from fabric import Property, Service, Signal
+from gi.repository import GLib, Gtk
+
+
+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/circle_image.py b/utils/circle_image.py
new file mode 100644
index 0000000..acfdc6d
--- /dev/null
+++ b/utils/circle_image.py
@@ -0,0 +1,118 @@
+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
+ self._orig_image: GdkPixbuf.Pixbuf | None = None # Original image for reprocessing
+ 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/utils/conversion.py b/utils/conversion.py
new file mode 100644
index 0000000..0f15f9a
--- /dev/null
+++ b/utils/conversion.py
@@ -0,0 +1,619 @@
+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/custom_image.py b/utils/custom_image.py
new file mode 100644
index 0000000..8096a6a
--- /dev/null
+++ b/utils/custom_image.py
@@ -0,0 +1,38 @@
+import math
+from typing import cast
+
+import cairo
+from fabric.widgets.image import Image
+from gi.repository import Gtk
+
+
+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/utils/icon_resolver.py b/utils/icon_resolver.py
new file mode 100644
index 0000000..a80c8f6
--- /dev/null
+++ b/utils/icon_resolver.py
@@ -0,0 +1,102 @@
+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/icons.py b/utils/icons.py
new file mode 100644
index 0000000..2a1059f
--- /dev/null
+++ b/utils/icons.py
@@ -0,0 +1,234 @@
+# Parameters
+font_family: str = "tabler-icons"
+font_weight: str = "normal"
+
+span: str = f""
+
+# Panels
+apps: str = ""
+dashboard: str = ""
+chat: str = ""
+windows: str = ""
+
+# Bar
+colorpicker: str = ""
+media: str = ""
+
+# Toolbox
+
+toolbox: str = ""
+ssfull: str = ""
+ssregion: str = ""
+sswindow: str = ""
+screenshots: str = ""
+screenrecord: str = ""
+recordings: str = ""
+ocr: str = "ﳃ"
+gamemode: str = ""
+gamemode_off: str = ""
+close: str = ""
+
+# Circles
+temp: str = ""
+disk: str = ""
+battery: str = ""
+memory: str = "流"
+cpu: str = ""
+gpu: str = ""
+swap: str = ""
+
+
+# AIchat
+reload: str = ""
+detach: str = ""
+
+# Wallpapers
+add: str = ""
+sort: str = ""
+circle: str = ""
+
+# Chevrons
+chevron_up: str = ""
+chevron_down: str = ""
+chevron_left: str = ""
+chevron_right: str = ""
+
+# Power
+lock: str = ""
+suspend: str = ""
+logout: str = ""
+reboot: str = ""
+shutdown: str = ""
+
+# Power Manager
+power_saving: str = ""
+power_balanced: str = "勺"
+power_performance: str = ""
+charging: str = ""
+discharging: str = ""
+alert: str = ""
+bat_charging: str = ""
+bat_discharging: str = ""
+bat_low: str = "="
+bat_full: str = ""
+
+
+# Applets
+
+# Applets
+wifi_0: str = ""
+wifi_1: str = ""
+wifi_2: str = ""
+wifi_3: str = ""
+world: str = ""
+world_off: str = ""
+bluetooth: str = ""
+night: str = ""
+coffee: str = ""
+notifications: str = ""
+notifications_dots: str = ""
+
+wifi_off: str = ""
+bluetooth_off: str = ""
+night_off: str = ""
+notifications_off: str = ""
+
+notifications_clear: str = ""
+
+download: str = ""
+upload: str = ""
+
+# Bluetooth
+bluetooth_connected: str = ""
+bluetooth_disconnected: str = ""
+
+# Player
+pause: str = ""
+play: str = ""
+stop: str = ""
+skip_back: str = ""
+skip_forward: str = ""
+prev: str = ""
+next: str = ""
+shuffle: str = ""
+repeat: str = ""
+music: str = ""
+rewind_backward_5: str = "謹"
+rewind_forward_5: str = "難"
+
+next_fill: str = chr(0xF04AD)
+previous_fill: str = chr(0xF04AE)
+
+# Volume
+vol_off: str = ""
+vol_mute: str = ""
+vol_medium: str = ""
+vol_high: str = ""
+
+mic: str = ""
+mic_mute: str = ""
+
+speaker: str = "𐁅"
+headphones: str = "屮"
+mic_filled: str = "️"
+
+# Overview
+circle_plus: str = ""
+
+# Pins
+paperclip: str = ""
+
+# Clipboard Manager
+clipboard: str = ""
+clip_text: str = ""
+
+# Confirm
+accept: str = ""
+cancel: str = ""
+trash: str = ""
+
+# Config
+config: str = ""
+
+# Icons
+firefox: str = ""
+chromium: str = ""
+spotify: str = "ﺆ"
+disc: str = ""
+disc_off: str = ""
+
+# Brightness
+brightness_low: str = ""
+brightness_medium: str = ""
+brightness_high: str = ""
+
+brightness: str = ""
+
+# Dashboard
+widgets: str = ""
+pins: str = ""
+kanban: str = ""
+wallpapers: str = ""
+sparkles: str = ""
+
+# Misc
+dot: str = ""
+palette: str = ""
+cloud_off: str = ""
+loader: str = ""
+radar: str = ""
+emoji: str = ""
+keyboard: str = ""
+terminal: str = ""
+timer_off: str = ""
+timer_on: str = ""
+spy: str = ""
+calculator: str = ""
+file: str = ""
+calendar: str = ""
+connect: str = ""
+disconnect: str = "ﴖ"
+auth: str = ""
+bookmark: str = ""
+star: str = ""
+process: str = ""
+
+
+music: str = ""
+# otp
+check: str = ""
+bulb: str = ""
+key: str = ""
+magnifier: str = ""
+plus: str = ""
+info: str = ""
+
+# Dice
+dice_1: str = ""
+dice_2: str = ""
+dice_3: str = ""
+dice_4: str = ""
+dice_5: str = ""
+dice_6: str = ""
+
+# search engine
+reddit: str = ""
+youtube: str = ""
+duckduckgo: str = ""
+github: str = ""
+stackoverflow: str = ""
+google: str = ""
+linkedin: str = ""
+wikipedia: str = "啕"
+
+exceptions: list[str] = ["font_family", "font_weight", "span"]
+
+
+def apply_span() -> None:
+ global_dict = globals()
+ for key in global_dict:
+ if key not in exceptions and not key.startswith("__"):
+ global_dict[key] = f"{span}{global_dict[key]} "
+
+
+apply_span()
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/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/popup_window.py b/utils/popup_window.py
new file mode 100644
index 0000000..24d1af3
--- /dev/null
+++ b/utils/popup_window.py
@@ -0,0 +1,143 @@
+from fabric.widgets.box import Box
+from utils.wayland import WaylandWindow
+
+from config.c import c
+
+import gi # type: ignore
+gi.require_version("GtkLayerShell", "0.1")
+
+from gi.repository import Gtk, GtkLayerShell # type: ignore
+
+
+class PopupWindow(WaylandWindow):
+ def __init__(
+ self,
+ parent: WaylandWindow,
+ pointing_to: Gtk.Widget | None = None,
+ margin: tuple[int, ...] | str = "0px 0px 0px 0px",
+ **kwargs,
+ ):
+ super().__init__(**kwargs)
+ self.exclusivity = "none"
+
+ self._parent = parent
+ self._pointing_widget = pointing_to
+ self._base_margin = self.extract_margin(margin)
+ self.margin = self._base_margin.values()
+
+ 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:
+ try:
+ self._pointing_widget.disconnect_by_func(self.do_handle_size_allocate)
+ except Exception:
+ pass
+ 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:
+ 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"
+
+ 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()
+
+ if self._pointing_widget:
+ coords = self.get_coords_for_widget(self._pointing_widget)
+ if coords[0] + width > int(c.get_rule("Display.resolution").split("x")[0]):
+ coords_centered = (
+ round(coords[0] - (width - self._pointing_widget.get_allocated_width())),
+ round(coords[1] + self._pointing_widget.get_allocated_height() / 2),
+ )
+ else:
+ coords_centered = (
+ round(coords[0]),
+ 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),
+ )
+ self.margin = tuple(
+ a + b
+ for a, b in zip(
+ (
+ (
+ 0,
+ 0,
+ 0,
+ round((parent_x_margin + coords_centered[0])),
+ )
+ if move_axe == "x"
+ else (
+ round((parent_y_margin + coords_centered[1]) - (height / 2)),
+ 0,
+ 0,
+ 0,
+ )
+ ),
+ self._base_margin.values(),
+ )
+ )
diff --git a/utils/wayland.py b/utils/wayland.py
new file mode 100644
index 0000000..472dc40
--- /dev/null
+++ b/utils/wayland.py
@@ -0,0 +1,359 @@
+import re
+from collections.abc import Iterable
+from enum import Enum
+from typing import Literal, cast
+
+import cairo
+import gi
+from fabric.core.service import Property
+from fabric.utils.helpers import extract_css_values, get_enum_member
+from fabric.widgets.window import Window
+from gi.repository import Gdk, GObject, Gtk
+from loguru import logger
+
+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/utils/wiggle_bar.py b/utils/wiggle_bar.py
new file mode 100644
index 0000000..69298c2
--- /dev/null
+++ b/utils/wiggle_bar.py
@@ -0,0 +1,174 @@
+import math
+from fabric.core.service import Service, Signal
+from gi.repository import Gtk, Gdk, GLib
+import gi
+
+gi.require_version("Gtk", "3.0")
+
+
+class WigglyWidget(Gtk.DrawingArea, Service):
+ @Signal
+ def on_seek(self, ratio: float) -> None: ...
+
+ def __init__(self):
+ super().__init__(name="wiggle-bar")
+ self.phase = 0
+ self.set_size_request(-1, 20)
+ self.connect("draw", self.on_draw)
+
+ # mouse events
+ self.add_events(
+ Gdk.EventMask.BUTTON_PRESS_MASK
+ | Gdk.EventMask.BUTTON_RELEASE_MASK
+ | Gdk.EventMask.POINTER_MOTION_MASK
+ )
+
+ self.connect("button-press-event", self.on_button_press)
+ self.connect("motion-notify-event", self.on_motion)
+ self.connect("button-release-event", self.on_button_release)
+
+ GLib.timeout_add(16, self.update) # 60 FPS
+ self.value = 0.0
+ self.amplitude = 2
+ self.dragging = False
+ self.pause = False
+
+ self.show_all()
+
+ def animate_amplitude_to(self):
+ if abs(self.amplitude - self.amplitude_target) < 0.01:
+ self.amplitude = self.amplitude_target
+ self.queue_draw()
+ return False
+ else:
+ self.amplitude += self.amplitude_step
+ self.queue_draw()
+ return True
+
+ def update_amplitude(self, decrease):
+ if decrease:
+ self.amplitude_target = 0
+ self.amplitude_step = (0 - self.amplitude) / 10.0
+ else:
+ self.amplitude_target = 2
+ self.amplitude_step = (2 - self.amplitude) / 10.0
+
+ # animation stops when False is returned btw
+ GLib.timeout_add(16, self.animate_amplitude_to)
+
+ def update_value_from_x(self, x):
+ width = self.get_allocated_width()
+ self.value = max(0.0, min(1.0, x / width))
+ self.on_seek(self.value)
+ self.queue_draw()
+
+ def on_button_press(self, widget, event):
+ self.dragging = True
+ self.update_amplitude(True)
+ self.update_value_from_x(event.x)
+ return True
+
+ # def animate_progress_to(self):
+ # print("frame")
+ # if self.value < self.value_target:
+ # self.value = self.value_target
+ # print(self.value)
+ # self.queue_draw()
+ # return False
+ # else:
+ # self.value += self.value_step
+ # self.queue_draw()
+ # return True
+
+ # def update_value_from_signal(self, new_value):
+ # self.value_target = min(1.0, new_value)
+ # steps = 60 # 1s / 16ms โ 60 frames
+ # self.value_step = (self.value_target - self.value) / steps
+ # GLib.timeout_add(16, self.animate_progress_to)
+
+ def update_value_from_signal(self, new_value):
+ self.value = min(1.0, new_value)
+
+ def on_motion(self, widget, event):
+ if self.dragging and not self.pause:
+ self.update_value_from_x(event.x)
+
+ cursor = Gdk.Cursor.new(Gdk.CursorType.HAND1)
+ widget.get_window().set_cursor(cursor)
+
+ return True
+
+ def on_button_release(self, widget, event):
+ if self.pause == False:
+ self.dragging = False
+ self.update_amplitude(False)
+ return True
+
+ def update(self):
+ if self.dragging == False:
+ self.phase += 0.1
+ # if (self.value<1):
+ # self.value += 0.001
+ self.queue_draw()
+ return True
+
+ def on_draw(self, widget, cr):
+ alloc_width = self.get_allocated_width()
+ height = self.get_allocated_height()
+ center_y = height / 2
+ # self.amplitude = 2
+ frequency = 0.3
+ slider_diameter = 6
+
+ width = int(alloc_width * self.value) - slider_diameter + 1
+
+ cr.set_source_rgb(1, 1, 1) # color here btw
+ cr.set_line_width(2)
+
+ last_x = 0 # fallback values in case the slider gets dragged out of range
+ last_y = 0
+
+ cr.move_to(0, center_y)
+ for x in range(width):
+ y = center_y + self.amplitude * math.sin((x * frequency) + self.phase)
+ cr.line_to(x, y)
+ last_x, last_y = x, y
+
+ cr.stroke()
+
+ # Set the color using CSS - the widget should have CSS styling applied
+ # that sets the color to var(--primary). For now, use a fallback approach.
+ style_context = self.get_style_context()
+
+ try:
+ # Get the foreground color which should be styled with CSS
+ color = style_context.get_color(Gtk.StateFlags.NORMAL)
+ cr.set_source_rgba(color.red, color.green, color.blue, color.alpha)
+ except:
+ # Fallback to a nice blue color that matches the primary theme
+ cr.set_source_rgb(0.2, 0.6, 1.0)
+
+ rect_width = 6
+ arc_radius = slider_diameter / 2
+ self.draw_rounded_rect(cr, last_x, height, rect_width, rect_width, arc_radius)
+ cr.fill()
+
+ cr.stroke()
+
+ cr.set_source_rgb(1, 1, 1)
+ cr.set_line_width(1)
+
+ # This is for anti aliasing. When line width becomes 1px and the position is not an integer, two lines of opacity 50% is drawn on two pixel rows.
+ # This moves the line to make it pixel perfect hence not needing anti aliasing
+ cr.translate(0, 0.5)
+
+ cr.move_to(last_x + 2 * arc_radius, height / 2)
+ cr.line_to(alloc_width, height / 2)
+
+ cr.stroke()
+
+ def draw_rounded_rect(self, cr, x, y, width, _height, radius):
+ cr.new_sub_path()
+ cr.arc(x + width - radius, radius, radius, -math.pi, 0)
+ cr.arc(x + width - radius, y - radius, radius, 0, -math.pi)
+ cr.close_path()