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

-
-

Logo

@@ -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. - -

Package 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 -``` - -

Camera with Flash Screenshots

- - - - - - - - - - - - - - - - - - - -
-

Rocket 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 <url>' 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 <title> <url>' 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 <title> <url> [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 <title> <url> [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 <title|url|domain>", + 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 "<meta http-equiv" not in line + ] + else: + items = [] + + # Check if data has changed + with self.cache_lock: + data_changed = ( + not self.clipboard_items_cache + or len(items) != len(self.clipboard_items_cache) + or items != self.clipboard_items_cache + ) + + # If cache is still valid and data hasn't changed, return cached data + if ( + not data_changed + and self.clipboard_items_cache + and current_time - self.cache_timestamp < self.cache_ttl + ): + return self.clipboard_items_cache.copy() + + # Update cache with fresh data + self.clipboard_items_cache = items + self.cache_timestamp = current_time + + # Keep image cache forever like example_cliphist.py - don't clear on data changes + + return items + except subprocess.CalledProcessError as e: + print(f"Error loading clipboard history: {e}", file=sys.stderr) + return [] + except Exception as e: + print(f"Unexpected error: {e}", file=sys.stderr) + return [] + + def _load_clipboard_items(self) -> 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 <account_name> <secret> or add <account_name>```<secret>```", + 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 <account> <secret>' 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 <account> <secret> | remove <account> | qr <account>", + 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 <account> <secret>' 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 <account> <secret> | remove <account> | qr <account>", + 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 <account_name> <secret>", + 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} <secret>", + 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 <account_name>' 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 <account> <secret>' 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 <account_name> 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 <name> <password>' 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 <name> <password> | remove <name> | <name> (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 <name> <password> [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 <name>", + 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} <search>", + "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} <search>" + ) + 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 <<EOF +Usage: $SCRIPT_NAME <command> [options] + +Commands: + screenshot <target> 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 <target> 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 <target> 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 <target> 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 <target> 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 <format> [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"<span alpha='30%'>{time_remaining}s</span>" + 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"<span font-family='{font_family}' font-weight='{font_weight}'>" + +# 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]}</span>" + + +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 <number>" + # 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()