diff --git a/.gitignore b/.gitignore index f732789b..f9bd47ba 100644 --- a/.gitignore +++ b/.gitignore @@ -3,18 +3,14 @@ *.pyc *.pyo *.pyd -# config/assets/config.json -config/assets/dock.json -config/assets/accounts.json -config/assets/passwords.json -config/assets/bookmarks.json -# config/hypr/modus.conf +config/config.json +config/dock.json +config/accounts.json +config/passwords.json +config/bookmarks.json config/hypr/colors.conf -fabric/** -fabric/ -# styles/colors.css +src/shared/styles/colors.css .aider* .venv/* .venv/ -AGENTS.md diff --git a/README.md b/README.md index 3a9df24c..4e54600b 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- Logo + Logo

@@ -26,17 +26,17 @@

Home Screen:

- fabric + fabric

Lock Screen:

- fabric + fabric

- -## Installation +## Installation > [!CAUTION] +> > - You need a working installation of hyprland and knowledge of how it works > - There may not be all packages in your system install them accordingly @@ -47,23 +47,23 @@ cd ~/.config/Modus ``` > [!TIP] +> > ## Post Installation +> > - Install recommended [Icon theme](https://github.com/vinceliuice/MacTahoe-icon-theme) , [GTK theme](https://github.com/vinceliuice/MacTahoe-gtk-theme) and [Cursor Theme](https://github.com/vinceliuice/MacTahoe-icon-theme/tree/main/cursors)
> - Check `config/hypr/modus.conf` edit it according to your device and copy it to your hyprland config -> - For Lock Screen Bind keys to `python lock.py` +> - For Lock Screen Bind keys to `uv run lock`

Rocket Todo

-## Manual Installation (WIP) +## Manual Installation ```bash -paru -S glace-git gtk-session-lock python-pyotp python-pillow python-ijson python-setproctitle apple-fonts cinnamon-desktop --needed +paru -S fabric-cli-git gtk-session-lock uv apple-fonts cinnamon-desktop hyprshot hypridle hyprpicker grim slurp gnome-bluetooth-3.0 cliphist matugen-bin awww swappy wl-clipboard webp-pixbuf-loader acpi wf-recorder brightnessctl power-profile-daemon uwsm libnotify playerctl ffmpeg --needed git clone https://github.com/S4NKALP/Modus ~/.config/Modus cd ~/.config/Modus -python -m venv .venv -source .venv/bin/activate -pip install -r requirements.txt -pip install --no-deps git+https://github.com/Fabric-Development/fabric.git +uv sync +uv run start ``` - [x] Launcher @@ -80,7 +80,8 @@ pip install --no-deps git+https://github.com/Fabric-Development/fabric.git - [x] Panel Widget - [x] MacOS like Widget - [x] Expandable Notification Centre -- [ ] Installation Script +- [x] Installation Script +- [x] Migrate to a `uv` managed Python virtual environment - [ ] Proper Documentation - [ ] Pomodoro Timer Widget - [x] To-do List Widget diff --git a/assets/default.png b/assets/default.png deleted file mode 100644 index 774a4e08..00000000 Binary files a/assets/default.png and /dev/null differ diff --git a/config/assets/config.json b/config/assets/config.json deleted file mode 100644 index 3750756b..00000000 --- a/config/assets/config.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "wallpapers_dir": "~/Pictures/Wallpapers/", - "dock_position": "Bottom", - "terminal_command": "kitty -e", - "dock_enabled": true, - "dock_auto_hide": true, - "dock_always_occluded": false, - "dock_icon_size": 52, - "window_switcher_items_per_row": 10, - "hide_special_workspace": true, - "dock_hide_special_workspace_apps": true, - "dock_preview_apps": false, - "notification_timeout": "5s", - "notification_ignored_apps": ["Hyprshot"], - "notification_limited_apps_history": ["Spotify"] -} diff --git a/config/assets/emoji.json b/config/assets/emoji.json deleted file mode 100644 index fa174ffb..00000000 --- a/config/assets/emoji.json +++ /dev/null @@ -1,15509 +0,0 @@ -{ - "๐Ÿ˜€": { - "name": "grinning face", - "slug": "grinning_face", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ˜ƒ": { - "name": "grinning face with big eyes", - "slug": "grinning_face_with_big_eyes", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜„": { - "name": "grinning face with smiling eyes", - "slug": "grinning_face_with_smiling_eyes", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜": { - "name": "beaming face with smiling eyes", - "slug": "beaming_face_with_smiling_eyes", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜†": { - "name": "grinning squinting face", - "slug": "grinning_squinting_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜…": { - "name": "grinning face with sweat", - "slug": "grinning_face_with_sweat", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿคฃ": { - "name": "rolling on the floor laughing", - "slug": "rolling_on_the_floor_laughing", - "group": "Smileys & Emotion", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿ˜‚": { - "name": "face with tears of joy", - "slug": "face_with_tears_of_joy", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ™‚": { - "name": "slightly smiling face", - "slug": "slightly_smiling_face", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ™ƒ": { - "name": "upside-down face", - "slug": "upside_down_face", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿซ ": { - "name": "melting face", - "slug": "melting_face", - "group": "Smileys & Emotion", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "๐Ÿ˜‰": { - "name": "winking face", - "slug": "winking_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜Š": { - "name": "smiling face with smiling eyes", - "slug": "smiling_face_with_smiling_eyes", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜‡": { - "name": "smiling face with halo", - "slug": "smiling_face_with_halo", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿฅฐ": { - "name": "smiling face with hearts", - "slug": "smiling_face_with_hearts", - "group": "Smileys & Emotion", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿ˜": { - "name": "smiling face with heart-eyes", - "slug": "smiling_face_with_heart_eyes", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿคฉ": { - "name": "star-struck", - "slug": "star_struck", - "group": "Smileys & Emotion", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿ˜˜": { - "name": "face blowing a kiss", - "slug": "face_blowing_a_kiss", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜—": { - "name": "kissing face", - "slug": "kissing_face", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "โ˜บ๏ธ": { - "name": "smiling face", - "slug": "smiling_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜š": { - "name": "kissing face with closed eyes", - "slug": "kissing_face_with_closed_eyes", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜™": { - "name": "kissing face with smiling eyes", - "slug": "kissing_face_with_smiling_eyes", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿฅฒ": { - "name": "smiling face with tear", - "slug": "smiling_face_with_tear", - "group": "Smileys & Emotion", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿ˜‹": { - "name": "face savoring food", - "slug": "face_savoring_food", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜›": { - "name": "face with tongue", - "slug": "face_with_tongue", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ˜œ": { - "name": "winking face with tongue", - "slug": "winking_face_with_tongue", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿคช": { - "name": "zany face", - "slug": "zany_face", - "group": "Smileys & Emotion", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿ˜": { - "name": "squinting face with tongue", - "slug": "squinting_face_with_tongue", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿค‘": { - "name": "money-mouth face", - "slug": "money_mouth_face", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿค—": { - "name": "smiling face with open hands", - "slug": "smiling_face_with_open_hands", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿคญ": { - "name": "face with hand over mouth", - "slug": "face_with_hand_over_mouth", - "group": "Smileys & Emotion", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿซข": { - "name": "face with open eyes and hand over mouth", - "slug": "face_with_open_eyes_and_hand_over_mouth", - "group": "Smileys & Emotion", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "๐Ÿซฃ": { - "name": "face with peeking eye", - "slug": "face_with_peeking_eye", - "group": "Smileys & Emotion", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "๐Ÿคซ": { - "name": "shushing face", - "slug": "shushing_face", - "group": "Smileys & Emotion", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿค”": { - "name": "thinking face", - "slug": "thinking_face", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿซก": { - "name": "saluting face", - "slug": "saluting_face", - "group": "Smileys & Emotion", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "๐Ÿค": { - "name": "zipper-mouth face", - "slug": "zipper_mouth_face", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿคจ": { - "name": "face with raised eyebrow", - "slug": "face_with_raised_eyebrow", - "group": "Smileys & Emotion", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿ˜": { - "name": "neutral face", - "slug": "neutral_face", - "group": "Smileys & Emotion", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ˜‘": { - "name": "expressionless face", - "slug": "expressionless_face", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ˜ถ": { - "name": "face without mouth", - "slug": "face_without_mouth", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿซฅ": { - "name": "dotted line face", - "slug": "dotted_line_face", - "group": "Smileys & Emotion", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "๐Ÿ˜ถโ€๐ŸŒซ๏ธ": { - "name": "face in clouds", - "slug": "face_in_clouds", - "group": "Smileys & Emotion", - "emoji_version": "13.1", - "unicode_version": "13.1", - "skin_tone_support": false - }, - "๐Ÿ˜": { - "name": "smirking face", - "slug": "smirking_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜’": { - "name": "unamused face", - "slug": "unamused_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ™„": { - "name": "face with rolling eyes", - "slug": "face_with_rolling_eyes", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ˜ฌ": { - "name": "grimacing face", - "slug": "grimacing_face", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ˜ฎโ€๐Ÿ’จ": { - "name": "face exhaling", - "slug": "face_exhaling", - "group": "Smileys & Emotion", - "emoji_version": "13.1", - "unicode_version": "13.1", - "skin_tone_support": false - }, - "๐Ÿคฅ": { - "name": "lying face", - "slug": "lying_face", - "group": "Smileys & Emotion", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿซจ": { - "name": "shaking face", - "slug": "shaking_face", - "group": "Smileys & Emotion", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": false - }, - "๐Ÿ™‚โ€โ†”๏ธ": { - "name": "head shaking horizontally", - "slug": "head_shaking_horizontally", - "group": "Smileys & Emotion", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": false - }, - "๐Ÿ™‚โ€โ†•๏ธ": { - "name": "head shaking vertically", - "slug": "head_shaking_vertically", - "group": "Smileys & Emotion", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": false - }, - "๐Ÿ˜Œ": { - "name": "relieved face", - "slug": "relieved_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜”": { - "name": "pensive face", - "slug": "pensive_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜ช": { - "name": "sleepy face", - "slug": "sleepy_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿคค": { - "name": "drooling face", - "slug": "drooling_face", - "group": "Smileys & Emotion", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿ˜ด": { - "name": "sleeping face", - "slug": "sleeping_face", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ˜ท": { - "name": "face with medical mask", - "slug": "face_with_medical_mask", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿค’": { - "name": "face with thermometer", - "slug": "face_with_thermometer", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿค•": { - "name": "face with head-bandage", - "slug": "face_with_head_bandage", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿคข": { - "name": "nauseated face", - "slug": "nauseated_face", - "group": "Smileys & Emotion", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿคฎ": { - "name": "face vomiting", - "slug": "face_vomiting", - "group": "Smileys & Emotion", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿคง": { - "name": "sneezing face", - "slug": "sneezing_face", - "group": "Smileys & Emotion", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿฅต": { - "name": "hot face", - "slug": "hot_face", - "group": "Smileys & Emotion", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿฅถ": { - "name": "cold face", - "slug": "cold_face", - "group": "Smileys & Emotion", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿฅด": { - "name": "woozy face", - "slug": "woozy_face", - "group": "Smileys & Emotion", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿ˜ต": { - "name": "face with crossed-out eyes", - "slug": "face_with_crossed_out_eyes", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜ตโ€๐Ÿ’ซ": { - "name": "face with spiral eyes", - "slug": "face_with_spiral_eyes", - "group": "Smileys & Emotion", - "emoji_version": "13.1", - "unicode_version": "13.1", - "skin_tone_support": false - }, - "๐Ÿคฏ": { - "name": "exploding head", - "slug": "exploding_head", - "group": "Smileys & Emotion", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿค ": { - "name": "cowboy hat face", - "slug": "cowboy_hat_face", - "group": "Smileys & Emotion", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿฅณ": { - "name": "partying face", - "slug": "partying_face", - "group": "Smileys & Emotion", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿฅธ": { - "name": "disguised face", - "slug": "disguised_face", - "group": "Smileys & Emotion", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿ˜Ž": { - "name": "smiling face with sunglasses", - "slug": "smiling_face_with_sunglasses", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿค“": { - "name": "nerd face", - "slug": "nerd_face", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿง": { - "name": "face with monocle", - "slug": "face_with_monocle", - "group": "Smileys & Emotion", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿ˜•": { - "name": "confused face", - "slug": "confused_face", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿซค": { - "name": "face with diagonal mouth", - "slug": "face_with_diagonal_mouth", - "group": "Smileys & Emotion", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "๐Ÿ˜Ÿ": { - "name": "worried face", - "slug": "worried_face", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ™": { - "name": "slightly frowning face", - "slug": "slightly_frowning_face", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "โ˜น๏ธ": { - "name": "frowning face", - "slug": "frowning_face", - "group": "Smileys & Emotion", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ˜ฎ": { - "name": "face with open mouth", - "slug": "face_with_open_mouth", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ˜ฏ": { - "name": "hushed face", - "slug": "hushed_face", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ˜ฒ": { - "name": "astonished face", - "slug": "astonished_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜ณ": { - "name": "flushed face", - "slug": "flushed_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฅบ": { - "name": "pleading face", - "slug": "pleading_face", - "group": "Smileys & Emotion", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿฅน": { - "name": "face holding back tears", - "slug": "face_holding_back_tears", - "group": "Smileys & Emotion", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "๐Ÿ˜ฆ": { - "name": "frowning face with open mouth", - "slug": "frowning_face_with_open_mouth", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ˜ง": { - "name": "anguished face", - "slug": "anguished_face", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ˜จ": { - "name": "fearful face", - "slug": "fearful_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜ฐ": { - "name": "anxious face with sweat", - "slug": "anxious_face_with_sweat", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜ฅ": { - "name": "sad but relieved face", - "slug": "sad_but_relieved_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜ข": { - "name": "crying face", - "slug": "crying_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜ญ": { - "name": "loudly crying face", - "slug": "loudly_crying_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜ฑ": { - "name": "face screaming in fear", - "slug": "face_screaming_in_fear", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜–": { - "name": "confounded face", - "slug": "confounded_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜ฃ": { - "name": "persevering face", - "slug": "persevering_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜ž": { - "name": "disappointed face", - "slug": "disappointed_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜“": { - "name": "downcast face with sweat", - "slug": "downcast_face_with_sweat", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜ฉ": { - "name": "weary face", - "slug": "weary_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜ซ": { - "name": "tired face", - "slug": "tired_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฅฑ": { - "name": "yawning face", - "slug": "yawning_face", - "group": "Smileys & Emotion", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿ˜ค": { - "name": "face with steam from nose", - "slug": "face_with_steam_from_nose", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜ก": { - "name": "enraged face", - "slug": "enraged_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜ ": { - "name": "angry face", - "slug": "angry_face", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿคฌ": { - "name": "face with symbols on mouth", - "slug": "face_with_symbols_on_mouth", - "group": "Smileys & Emotion", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿ˜ˆ": { - "name": "smiling face with horns", - "slug": "smiling_face_with_horns", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ‘ฟ": { - "name": "angry face with horns", - "slug": "angry_face_with_horns", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’€": { - "name": "skull", - "slug": "skull", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ˜ ๏ธ": { - "name": "skull and crossbones", - "slug": "skull_and_crossbones", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ’ฉ": { - "name": "pile of poo", - "slug": "pile_of_poo", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿคก": { - "name": "clown face", - "slug": "clown_face", - "group": "Smileys & Emotion", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿ‘น": { - "name": "ogre", - "slug": "ogre", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‘บ": { - "name": "goblin", - "slug": "goblin", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‘ป": { - "name": "ghost", - "slug": "ghost", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‘ฝ": { - "name": "alien", - "slug": "alien", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‘พ": { - "name": "alien monster", - "slug": "alien_monster", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿค–": { - "name": "robot", - "slug": "robot", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ˜บ": { - "name": "grinning cat", - "slug": "grinning_cat", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜ธ": { - "name": "grinning cat with smiling eyes", - "slug": "grinning_cat_with_smiling_eyes", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜น": { - "name": "cat with tears of joy", - "slug": "cat_with_tears_of_joy", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜ป": { - "name": "smiling cat with heart-eyes", - "slug": "smiling_cat_with_heart_eyes", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜ผ": { - "name": "cat with wry smile", - "slug": "cat_with_wry_smile", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜ฝ": { - "name": "kissing cat", - "slug": "kissing_cat", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ™€": { - "name": "weary cat", - "slug": "weary_cat", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜ฟ": { - "name": "crying cat", - "slug": "crying_cat", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜พ": { - "name": "pouting cat", - "slug": "pouting_cat", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ™ˆ": { - "name": "see-no-evil monkey", - "slug": "see_no_evil_monkey", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ™‰": { - "name": "hear-no-evil monkey", - "slug": "hear_no_evil_monkey", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ™Š": { - "name": "speak-no-evil monkey", - "slug": "speak_no_evil_monkey", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’Œ": { - "name": "love letter", - "slug": "love_letter", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’˜": { - "name": "heart with arrow", - "slug": "heart_with_arrow", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’": { - "name": "heart with ribbon", - "slug": "heart_with_ribbon", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’–": { - "name": "sparkling heart", - "slug": "sparkling_heart", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’—": { - "name": "growing heart", - "slug": "growing_heart", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’“": { - "name": "beating heart", - "slug": "beating_heart", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’ž": { - "name": "revolving hearts", - "slug": "revolving_hearts", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’•": { - "name": "two hearts", - "slug": "two_hearts", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’Ÿ": { - "name": "heart decoration", - "slug": "heart_decoration", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โฃ๏ธ": { - "name": "heart exclamation", - "slug": "heart_exclamation", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ’”": { - "name": "broken heart", - "slug": "broken_heart", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โค๏ธโ€๐Ÿ”ฅ": { - "name": "heart on fire", - "slug": "heart_on_fire", - "group": "Smileys & Emotion", - "emoji_version": "13.1", - "unicode_version": "13.1", - "skin_tone_support": false - }, - "โค๏ธโ€๐Ÿฉน": { - "name": "mending heart", - "slug": "mending_heart", - "group": "Smileys & Emotion", - "emoji_version": "13.1", - "unicode_version": "13.1", - "skin_tone_support": false - }, - "โค๏ธ": { - "name": "red heart", - "slug": "red_heart", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฉท": { - "name": "pink heart", - "slug": "pink_heart", - "group": "Smileys & Emotion", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": false - }, - "๐Ÿงก": { - "name": "orange heart", - "slug": "orange_heart", - "group": "Smileys & Emotion", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿ’›": { - "name": "yellow heart", - "slug": "yellow_heart", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’š": { - "name": "green heart", - "slug": "green_heart", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’™": { - "name": "blue heart", - "slug": "blue_heart", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฉต": { - "name": "light blue heart", - "slug": "light_blue_heart", - "group": "Smileys & Emotion", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": false - }, - "๐Ÿ’œ": { - "name": "purple heart", - "slug": "purple_heart", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸคŽ": { - "name": "brown heart", - "slug": "brown_heart", - "group": "Smileys & Emotion", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿ–ค": { - "name": "black heart", - "slug": "black_heart", - "group": "Smileys & Emotion", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿฉถ": { - "name": "grey heart", - "slug": "grey_heart", - "group": "Smileys & Emotion", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": false - }, - "๐Ÿค": { - "name": "white heart", - "slug": "white_heart", - "group": "Smileys & Emotion", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿ’‹": { - "name": "kiss mark", - "slug": "kiss_mark", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’ฏ": { - "name": "hundred points", - "slug": "hundred_points", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’ข": { - "name": "anger symbol", - "slug": "anger_symbol", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’ฅ": { - "name": "collision", - "slug": "collision", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’ซ": { - "name": "dizzy", - "slug": "dizzy", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’ฆ": { - "name": "sweat droplets", - "slug": "sweat_droplets", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’จ": { - "name": "dashing away", - "slug": "dashing_away", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ•ณ๏ธ": { - "name": "hole", - "slug": "hole", - "group": "Smileys & Emotion", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ’ฌ": { - "name": "speech balloon", - "slug": "speech_balloon", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‘๏ธโ€๐Ÿ—จ๏ธ": { - "name": "eye in speech bubble", - "slug": "eye_in_speech_bubble", - "group": "Smileys & Emotion", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ—จ๏ธ": { - "name": "left speech bubble", - "slug": "left_speech_bubble", - "group": "Smileys & Emotion", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ—ฏ๏ธ": { - "name": "right anger bubble", - "slug": "right_anger_bubble", - "group": "Smileys & Emotion", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ’ญ": { - "name": "thought balloon", - "slug": "thought_balloon", - "group": "Smileys & Emotion", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ’ค": { - "name": "ZZZ", - "slug": "zzz", - "group": "Smileys & Emotion", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‘‹": { - "name": "waving hand", - "slug": "waving_hand", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿคš": { - "name": "raised back of hand", - "slug": "raised_back_of_hand", - "group": "People & Body", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "3.0" - }, - "๐Ÿ–๏ธ": { - "name": "hand with fingers splayed", - "slug": "hand_with_fingers_splayed", - "group": "People & Body", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "โœ‹": { - "name": "raised hand", - "slug": "raised_hand", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ––": { - "name": "vulcan salute", - "slug": "vulcan_salute", - "group": "People & Body", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿซฑ": { - "name": "rightwards hand", - "slug": "rightwards_hand", - "group": "People & Body", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "14.0" - }, - "๐Ÿซฒ": { - "name": "leftwards hand", - "slug": "leftwards_hand", - "group": "People & Body", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "14.0" - }, - "๐Ÿซณ": { - "name": "palm down hand", - "slug": "palm_down_hand", - "group": "People & Body", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "14.0" - }, - "๐Ÿซด": { - "name": "palm up hand", - "slug": "palm_up_hand", - "group": "People & Body", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "14.0" - }, - "๐Ÿซท": { - "name": "leftwards pushing hand", - "slug": "leftwards_pushing_hand", - "group": "People & Body", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "15.0" - }, - "๐Ÿซธ": { - "name": "rightwards pushing hand", - "slug": "rightwards_pushing_hand", - "group": "People & Body", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "15.0" - }, - "๐Ÿ‘Œ": { - "name": "OK hand", - "slug": "ok_hand", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐ŸคŒ": { - "name": "pinched fingers", - "slug": "pinched_fingers", - "group": "People & Body", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "13.0" - }, - "๐Ÿค": { - "name": "pinching hand", - "slug": "pinching_hand", - "group": "People & Body", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "โœŒ๏ธ": { - "name": "victory hand", - "slug": "victory_hand", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿคž": { - "name": "crossed fingers", - "slug": "crossed_fingers", - "group": "People & Body", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "3.0" - }, - "๐Ÿซฐ": { - "name": "hand with index finger and thumb crossed", - "slug": "hand_with_index_finger_and_thumb_crossed", - "group": "People & Body", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "14.0" - }, - "๐ŸคŸ": { - "name": "love-you gesture", - "slug": "love_you_gesture", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿค˜": { - "name": "sign of the horns", - "slug": "sign_of_the_horns", - "group": "People & Body", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿค™": { - "name": "call me hand", - "slug": "call_me_hand", - "group": "People & Body", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "3.0" - }, - "๐Ÿ‘ˆ": { - "name": "backhand index pointing left", - "slug": "backhand_index_pointing_left", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ‘‰": { - "name": "backhand index pointing right", - "slug": "backhand_index_pointing_right", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ‘†": { - "name": "backhand index pointing up", - "slug": "backhand_index_pointing_up", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ–•": { - "name": "middle finger", - "slug": "middle_finger", - "group": "People & Body", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ‘‡": { - "name": "backhand index pointing down", - "slug": "backhand_index_pointing_down", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "โ˜๏ธ": { - "name": "index pointing up", - "slug": "index_pointing_up", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿซต": { - "name": "index pointing at the viewer", - "slug": "index_pointing_at_the_viewer", - "group": "People & Body", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "14.0" - }, - "๐Ÿ‘": { - "name": "thumbs up", - "slug": "thumbs_up", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ‘Ž": { - "name": "thumbs down", - "slug": "thumbs_down", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "โœŠ": { - "name": "raised fist", - "slug": "raised_fist", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ‘Š": { - "name": "oncoming fist", - "slug": "oncoming_fist", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿค›": { - "name": "left-facing fist", - "slug": "left_facing_fist", - "group": "People & Body", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "3.0" - }, - "๐Ÿคœ": { - "name": "right-facing fist", - "slug": "right_facing_fist", - "group": "People & Body", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "3.0" - }, - "๐Ÿ‘": { - "name": "clapping hands", - "slug": "clapping_hands", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ™Œ": { - "name": "raising hands", - "slug": "raising_hands", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿซถ": { - "name": "heart hands", - "slug": "heart_hands", - "group": "People & Body", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "14.0" - }, - "๐Ÿ‘": { - "name": "open hands", - "slug": "open_hands", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿคฒ": { - "name": "palms up together", - "slug": "palms_up_together", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿค": { - "name": "handshake", - "slug": "handshake", - "group": "People & Body", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "14.0" - }, - "๐Ÿ™": { - "name": "folded hands", - "slug": "folded_hands", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "โœ๏ธ": { - "name": "writing hand", - "slug": "writing_hand", - "group": "People & Body", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ’…": { - "name": "nail polish", - "slug": "nail_polish", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿคณ": { - "name": "selfie", - "slug": "selfie", - "group": "People & Body", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "3.0" - }, - "๐Ÿ’ช": { - "name": "flexed biceps", - "slug": "flexed_biceps", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿฆพ": { - "name": "mechanical arm", - "slug": "mechanical_arm", - "group": "People & Body", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿฆฟ": { - "name": "mechanical leg", - "slug": "mechanical_leg", - "group": "People & Body", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿฆต": { - "name": "leg", - "slug": "leg", - "group": "People & Body", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "11.0" - }, - "๐Ÿฆถ": { - "name": "foot", - "slug": "foot", - "group": "People & Body", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "11.0" - }, - "๐Ÿ‘‚": { - "name": "ear", - "slug": "ear", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿฆป": { - "name": "ear with hearing aid", - "slug": "ear_with_hearing_aid", - "group": "People & Body", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "๐Ÿ‘ƒ": { - "name": "nose", - "slug": "nose", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿง ": { - "name": "brain", - "slug": "brain", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿซ€": { - "name": "anatomical heart", - "slug": "anatomical_heart", - "group": "People & Body", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿซ": { - "name": "lungs", - "slug": "lungs", - "group": "People & Body", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿฆท": { - "name": "tooth", - "slug": "tooth", - "group": "People & Body", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿฆด": { - "name": "bone", - "slug": "bone", - "group": "People & Body", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿ‘€": { - "name": "eyes", - "slug": "eyes", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‘๏ธ": { - "name": "eye", - "slug": "eye", - "group": "People & Body", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ‘…": { - "name": "tongue", - "slug": "tongue", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‘„": { - "name": "mouth", - "slug": "mouth", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿซฆ": { - "name": "biting lip", - "slug": "biting_lip", - "group": "People & Body", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "๐Ÿ‘ถ": { - "name": "baby", - "slug": "baby", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿง’": { - "name": "child", - "slug": "child", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿ‘ฆ": { - "name": "boy", - "slug": "boy", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ‘ง": { - "name": "girl", - "slug": "girl", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿง‘": { - "name": "person", - "slug": "person", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿ‘ฑ": { - "name": "person blond hair", - "slug": "person_blond_hair", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ‘จ": { - "name": "man", - "slug": "man", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿง”": { - "name": "person beard", - "slug": "person_beard", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿง”โ€โ™‚๏ธ": { - "name": "man beard", - "slug": "man_beard", - "group": "People & Body", - "emoji_version": "13.1", - "unicode_version": "13.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "13.1" - }, - "๐Ÿง”โ€โ™€๏ธ": { - "name": "woman beard", - "slug": "woman_beard", - "group": "People & Body", - "emoji_version": "13.1", - "unicode_version": "13.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "13.1" - }, - "๐Ÿ‘จโ€๐Ÿฆฐ": { - "name": "man red hair", - "slug": "man_red_hair", - "group": "People & Body", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "11.0" - }, - "๐Ÿ‘จโ€๐Ÿฆฑ": { - "name": "man curly hair", - "slug": "man_curly_hair", - "group": "People & Body", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "11.0" - }, - "๐Ÿ‘จโ€๐Ÿฆณ": { - "name": "man white hair", - "slug": "man_white_hair", - "group": "People & Body", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "11.0" - }, - "๐Ÿ‘จโ€๐Ÿฆฒ": { - "name": "man bald", - "slug": "man_bald", - "group": "People & Body", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "11.0" - }, - "๐Ÿ‘ฉ": { - "name": "woman", - "slug": "woman", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ‘ฉโ€๐Ÿฆฐ": { - "name": "woman red hair", - "slug": "woman_red_hair", - "group": "People & Body", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "11.0" - }, - "๐Ÿง‘โ€๐Ÿฆฐ": { - "name": "person red hair", - "slug": "person_red_hair", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "๐Ÿ‘ฉโ€๐Ÿฆฑ": { - "name": "woman curly hair", - "slug": "woman_curly_hair", - "group": "People & Body", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "11.0" - }, - "๐Ÿง‘โ€๐Ÿฆฑ": { - "name": "person curly hair", - "slug": "person_curly_hair", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "๐Ÿ‘ฉโ€๐Ÿฆณ": { - "name": "woman white hair", - "slug": "woman_white_hair", - "group": "People & Body", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "11.0" - }, - "๐Ÿง‘โ€๐Ÿฆณ": { - "name": "person white hair", - "slug": "person_white_hair", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "๐Ÿ‘ฉโ€๐Ÿฆฒ": { - "name": "woman bald", - "slug": "woman_bald", - "group": "People & Body", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "11.0" - }, - "๐Ÿง‘โ€๐Ÿฆฒ": { - "name": "person bald", - "slug": "person_bald", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "๐Ÿ‘ฑโ€โ™€๏ธ": { - "name": "woman blond hair", - "slug": "woman_blond_hair", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ‘ฑโ€โ™‚๏ธ": { - "name": "man blond hair", - "slug": "man_blond_hair", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿง“": { - "name": "older person", - "slug": "older_person", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿ‘ด": { - "name": "old man", - "slug": "old_man", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ‘ต": { - "name": "old woman", - "slug": "old_woman", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ™": { - "name": "person frowning", - "slug": "person_frowning", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ™โ€โ™‚๏ธ": { - "name": "man frowning", - "slug": "man_frowning", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ™โ€โ™€๏ธ": { - "name": "woman frowning", - "slug": "woman_frowning", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ™Ž": { - "name": "person pouting", - "slug": "person_pouting", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ™Žโ€โ™‚๏ธ": { - "name": "man pouting", - "slug": "man_pouting", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ™Žโ€โ™€๏ธ": { - "name": "woman pouting", - "slug": "woman_pouting", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ™…": { - "name": "person gesturing NO", - "slug": "person_gesturing_no", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ™…โ€โ™‚๏ธ": { - "name": "man gesturing NO", - "slug": "man_gesturing_no", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ™…โ€โ™€๏ธ": { - "name": "woman gesturing NO", - "slug": "woman_gesturing_no", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ™†": { - "name": "person gesturing OK", - "slug": "person_gesturing_ok", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ™†โ€โ™‚๏ธ": { - "name": "man gesturing OK", - "slug": "man_gesturing_ok", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ™†โ€โ™€๏ธ": { - "name": "woman gesturing OK", - "slug": "woman_gesturing_ok", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ’": { - "name": "person tipping hand", - "slug": "person_tipping_hand", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ’โ€โ™‚๏ธ": { - "name": "man tipping hand", - "slug": "man_tipping_hand", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ’โ€โ™€๏ธ": { - "name": "woman tipping hand", - "slug": "woman_tipping_hand", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ™‹": { - "name": "person raising hand", - "slug": "person_raising_hand", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ™‹โ€โ™‚๏ธ": { - "name": "man raising hand", - "slug": "man_raising_hand", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ™‹โ€โ™€๏ธ": { - "name": "woman raising hand", - "slug": "woman_raising_hand", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿง": { - "name": "deaf person", - "slug": "deaf_person", - "group": "People & Body", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "๐Ÿงโ€โ™‚๏ธ": { - "name": "deaf man", - "slug": "deaf_man", - "group": "People & Body", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "๐Ÿงโ€โ™€๏ธ": { - "name": "deaf woman", - "slug": "deaf_woman", - "group": "People & Body", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "๐Ÿ™‡": { - "name": "person bowing", - "slug": "person_bowing", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ™‡โ€โ™‚๏ธ": { - "name": "man bowing", - "slug": "man_bowing", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ™‡โ€โ™€๏ธ": { - "name": "woman bowing", - "slug": "woman_bowing", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿคฆ": { - "name": "person facepalming", - "slug": "person_facepalming", - "group": "People & Body", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "3.0" - }, - "๐Ÿคฆโ€โ™‚๏ธ": { - "name": "man facepalming", - "slug": "man_facepalming", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿคฆโ€โ™€๏ธ": { - "name": "woman facepalming", - "slug": "woman_facepalming", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿคท": { - "name": "person shrugging", - "slug": "person_shrugging", - "group": "People & Body", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "3.0" - }, - "๐Ÿคทโ€โ™‚๏ธ": { - "name": "man shrugging", - "slug": "man_shrugging", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿคทโ€โ™€๏ธ": { - "name": "woman shrugging", - "slug": "woman_shrugging", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿง‘โ€โš•๏ธ": { - "name": "health worker", - "slug": "health_worker", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "๐Ÿ‘จโ€โš•๏ธ": { - "name": "man health worker", - "slug": "man_health_worker", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ‘ฉโ€โš•๏ธ": { - "name": "woman health worker", - "slug": "woman_health_worker", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿง‘โ€๐ŸŽ“": { - "name": "student", - "slug": "student", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "๐Ÿ‘จโ€๐ŸŽ“": { - "name": "man student", - "slug": "man_student", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ‘ฉโ€๐ŸŽ“": { - "name": "woman student", - "slug": "woman_student", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿง‘โ€๐Ÿซ": { - "name": "teacher", - "slug": "teacher", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "๐Ÿ‘จโ€๐Ÿซ": { - "name": "man teacher", - "slug": "man_teacher", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ‘ฉโ€๐Ÿซ": { - "name": "woman teacher", - "slug": "woman_teacher", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿง‘โ€โš–๏ธ": { - "name": "judge", - "slug": "judge", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "๐Ÿ‘จโ€โš–๏ธ": { - "name": "man judge", - "slug": "man_judge", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ‘ฉโ€โš–๏ธ": { - "name": "woman judge", - "slug": "woman_judge", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿง‘โ€๐ŸŒพ": { - "name": "farmer", - "slug": "farmer", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "๐Ÿ‘จโ€๐ŸŒพ": { - "name": "man farmer", - "slug": "man_farmer", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ‘ฉโ€๐ŸŒพ": { - "name": "woman farmer", - "slug": "woman_farmer", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿง‘โ€๐Ÿณ": { - "name": "cook", - "slug": "cook", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "๐Ÿ‘จโ€๐Ÿณ": { - "name": "man cook", - "slug": "man_cook", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ‘ฉโ€๐Ÿณ": { - "name": "woman cook", - "slug": "woman_cook", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿง‘โ€๐Ÿ”ง": { - "name": "mechanic", - "slug": "mechanic", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "๐Ÿ‘จโ€๐Ÿ”ง": { - "name": "man mechanic", - "slug": "man_mechanic", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ‘ฉโ€๐Ÿ”ง": { - "name": "woman mechanic", - "slug": "woman_mechanic", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿง‘โ€๐Ÿญ": { - "name": "factory worker", - "slug": "factory_worker", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "๐Ÿ‘จโ€๐Ÿญ": { - "name": "man factory worker", - "slug": "man_factory_worker", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ‘ฉโ€๐Ÿญ": { - "name": "woman factory worker", - "slug": "woman_factory_worker", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿง‘โ€๐Ÿ’ผ": { - "name": "office worker", - "slug": "office_worker", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "๐Ÿ‘จโ€๐Ÿ’ผ": { - "name": "man office worker", - "slug": "man_office_worker", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ‘ฉโ€๐Ÿ’ผ": { - "name": "woman office worker", - "slug": "woman_office_worker", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿง‘โ€๐Ÿ”ฌ": { - "name": "scientist", - "slug": "scientist", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "๐Ÿ‘จโ€๐Ÿ”ฌ": { - "name": "man scientist", - "slug": "man_scientist", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ‘ฉโ€๐Ÿ”ฌ": { - "name": "woman scientist", - "slug": "woman_scientist", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿง‘โ€๐Ÿ’ป": { - "name": "technologist", - "slug": "technologist", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "๐Ÿ‘จโ€๐Ÿ’ป": { - "name": "man technologist", - "slug": "man_technologist", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ‘ฉโ€๐Ÿ’ป": { - "name": "woman technologist", - "slug": "woman_technologist", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿง‘โ€๐ŸŽค": { - "name": "singer", - "slug": "singer", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "๐Ÿ‘จโ€๐ŸŽค": { - "name": "man singer", - "slug": "man_singer", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ‘ฉโ€๐ŸŽค": { - "name": "woman singer", - "slug": "woman_singer", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿง‘โ€๐ŸŽจ": { - "name": "artist", - "slug": "artist", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "๐Ÿ‘จโ€๐ŸŽจ": { - "name": "man artist", - "slug": "man_artist", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ‘ฉโ€๐ŸŽจ": { - "name": "woman artist", - "slug": "woman_artist", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿง‘โ€โœˆ๏ธ": { - "name": "pilot", - "slug": "pilot", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "๐Ÿ‘จโ€โœˆ๏ธ": { - "name": "man pilot", - "slug": "man_pilot", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ‘ฉโ€โœˆ๏ธ": { - "name": "woman pilot", - "slug": "woman_pilot", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿง‘โ€๐Ÿš€": { - "name": "astronaut", - "slug": "astronaut", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "๐Ÿ‘จโ€๐Ÿš€": { - "name": "man astronaut", - "slug": "man_astronaut", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ‘ฉโ€๐Ÿš€": { - "name": "woman astronaut", - "slug": "woman_astronaut", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿง‘โ€๐Ÿš’": { - "name": "firefighter", - "slug": "firefighter", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "๐Ÿ‘จโ€๐Ÿš’": { - "name": "man firefighter", - "slug": "man_firefighter", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ‘ฉโ€๐Ÿš’": { - "name": "woman firefighter", - "slug": "woman_firefighter", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ‘ฎ": { - "name": "police officer", - "slug": "police_officer", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ‘ฎโ€โ™‚๏ธ": { - "name": "man police officer", - "slug": "man_police_officer", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ‘ฎโ€โ™€๏ธ": { - "name": "woman police officer", - "slug": "woman_police_officer", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ•ต๏ธ": { - "name": "detective", - "slug": "detective", - "group": "People & Body", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "2.0" - }, - "๐Ÿ•ต๏ธโ€โ™‚๏ธ": { - "name": "man detective", - "slug": "man_detective", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ•ต๏ธโ€โ™€๏ธ": { - "name": "woman detective", - "slug": "woman_detective", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ’‚": { - "name": "guard", - "slug": "guard", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ’‚โ€โ™‚๏ธ": { - "name": "man guard", - "slug": "man_guard", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ’‚โ€โ™€๏ธ": { - "name": "woman guard", - "slug": "woman_guard", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿฅท": { - "name": "ninja", - "slug": "ninja", - "group": "People & Body", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "13.0" - }, - "๐Ÿ‘ท": { - "name": "construction worker", - "slug": "construction_worker", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ‘ทโ€โ™‚๏ธ": { - "name": "man construction worker", - "slug": "man_construction_worker", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ‘ทโ€โ™€๏ธ": { - "name": "woman construction worker", - "slug": "woman_construction_worker", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿซ…": { - "name": "person with crown", - "slug": "person_with_crown", - "group": "People & Body", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "14.0" - }, - "๐Ÿคด": { - "name": "prince", - "slug": "prince", - "group": "People & Body", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "3.0" - }, - "๐Ÿ‘ธ": { - "name": "princess", - "slug": "princess", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ‘ณ": { - "name": "person wearing turban", - "slug": "person_wearing_turban", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ‘ณโ€โ™‚๏ธ": { - "name": "man wearing turban", - "slug": "man_wearing_turban", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ‘ณโ€โ™€๏ธ": { - "name": "woman wearing turban", - "slug": "woman_wearing_turban", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ‘ฒ": { - "name": "person with skullcap", - "slug": "person_with_skullcap", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿง•": { - "name": "woman with headscarf", - "slug": "woman_with_headscarf", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿคต": { - "name": "person in tuxedo", - "slug": "person_in_tuxedo", - "group": "People & Body", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "3.0" - }, - "๐Ÿคตโ€โ™‚๏ธ": { - "name": "man in tuxedo", - "slug": "man_in_tuxedo", - "group": "People & Body", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "13.0" - }, - "๐Ÿคตโ€โ™€๏ธ": { - "name": "woman in tuxedo", - "slug": "woman_in_tuxedo", - "group": "People & Body", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "13.0" - }, - "๐Ÿ‘ฐ": { - "name": "person with veil", - "slug": "person_with_veil", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ‘ฐโ€โ™‚๏ธ": { - "name": "man with veil", - "slug": "man_with_veil", - "group": "People & Body", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "13.0" - }, - "๐Ÿ‘ฐโ€โ™€๏ธ": { - "name": "woman with veil", - "slug": "woman_with_veil", - "group": "People & Body", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "13.0" - }, - "๐Ÿคฐ": { - "name": "pregnant woman", - "slug": "pregnant_woman", - "group": "People & Body", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "3.0" - }, - "๐Ÿซƒ": { - "name": "pregnant man", - "slug": "pregnant_man", - "group": "People & Body", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "14.0" - }, - "๐Ÿซ„": { - "name": "pregnant person", - "slug": "pregnant_person", - "group": "People & Body", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "14.0" - }, - "๐Ÿคฑ": { - "name": "breast-feeding", - "slug": "breast_feeding", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿ‘ฉโ€๐Ÿผ": { - "name": "woman feeding baby", - "slug": "woman_feeding_baby", - "group": "People & Body", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "13.0" - }, - "๐Ÿ‘จโ€๐Ÿผ": { - "name": "man feeding baby", - "slug": "man_feeding_baby", - "group": "People & Body", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "13.0" - }, - "๐Ÿง‘โ€๐Ÿผ": { - "name": "person feeding baby", - "slug": "person_feeding_baby", - "group": "People & Body", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "13.0" - }, - "๐Ÿ‘ผ": { - "name": "baby angel", - "slug": "baby_angel", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐ŸŽ…": { - "name": "Santa Claus", - "slug": "santa_claus", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿคถ": { - "name": "Mrs. Claus", - "slug": "mrs_claus", - "group": "People & Body", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "3.0" - }, - "๐Ÿง‘โ€๐ŸŽ„": { - "name": "mx claus", - "slug": "mx_claus", - "group": "People & Body", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "13.0" - }, - "๐Ÿฆธ": { - "name": "superhero", - "slug": "superhero", - "group": "People & Body", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "11.0" - }, - "๐Ÿฆธโ€โ™‚๏ธ": { - "name": "man superhero", - "slug": "man_superhero", - "group": "People & Body", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "11.0" - }, - "๐Ÿฆธโ€โ™€๏ธ": { - "name": "woman superhero", - "slug": "woman_superhero", - "group": "People & Body", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "11.0" - }, - "๐Ÿฆน": { - "name": "supervillain", - "slug": "supervillain", - "group": "People & Body", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "11.0" - }, - "๐Ÿฆนโ€โ™‚๏ธ": { - "name": "man supervillain", - "slug": "man_supervillain", - "group": "People & Body", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "11.0" - }, - "๐Ÿฆนโ€โ™€๏ธ": { - "name": "woman supervillain", - "slug": "woman_supervillain", - "group": "People & Body", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "11.0" - }, - "๐Ÿง™": { - "name": "mage", - "slug": "mage", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿง™โ€โ™‚๏ธ": { - "name": "man mage", - "slug": "man_mage", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿง™โ€โ™€๏ธ": { - "name": "woman mage", - "slug": "woman_mage", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿงš": { - "name": "fairy", - "slug": "fairy", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿงšโ€โ™‚๏ธ": { - "name": "man fairy", - "slug": "man_fairy", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿงšโ€โ™€๏ธ": { - "name": "woman fairy", - "slug": "woman_fairy", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿง›": { - "name": "vampire", - "slug": "vampire", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿง›โ€โ™‚๏ธ": { - "name": "man vampire", - "slug": "man_vampire", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿง›โ€โ™€๏ธ": { - "name": "woman vampire", - "slug": "woman_vampire", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿงœ": { - "name": "merperson", - "slug": "merperson", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿงœโ€โ™‚๏ธ": { - "name": "merman", - "slug": "merman", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿงœโ€โ™€๏ธ": { - "name": "mermaid", - "slug": "mermaid", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿง": { - "name": "elf", - "slug": "elf", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿงโ€โ™‚๏ธ": { - "name": "man elf", - "slug": "man_elf", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿงโ€โ™€๏ธ": { - "name": "woman elf", - "slug": "woman_elf", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿงž": { - "name": "genie", - "slug": "genie", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿงžโ€โ™‚๏ธ": { - "name": "man genie", - "slug": "man_genie", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿงžโ€โ™€๏ธ": { - "name": "woman genie", - "slug": "woman_genie", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐ŸงŸ": { - "name": "zombie", - "slug": "zombie", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐ŸงŸโ€โ™‚๏ธ": { - "name": "man zombie", - "slug": "man_zombie", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐ŸงŸโ€โ™€๏ธ": { - "name": "woman zombie", - "slug": "woman_zombie", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐ŸงŒ": { - "name": "troll", - "slug": "troll", - "group": "People & Body", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "๐Ÿ’†": { - "name": "person getting massage", - "slug": "person_getting_massage", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ’†โ€โ™‚๏ธ": { - "name": "man getting massage", - "slug": "man_getting_massage", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ’†โ€โ™€๏ธ": { - "name": "woman getting massage", - "slug": "woman_getting_massage", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ’‡": { - "name": "person getting haircut", - "slug": "person_getting_haircut", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ’‡โ€โ™‚๏ธ": { - "name": "man getting haircut", - "slug": "man_getting_haircut", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ’‡โ€โ™€๏ธ": { - "name": "woman getting haircut", - "slug": "woman_getting_haircut", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿšถ": { - "name": "person walking", - "slug": "person_walking", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿšถโ€โ™‚๏ธ": { - "name": "man walking", - "slug": "man_walking", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿšถโ€โ™€๏ธ": { - "name": "woman walking", - "slug": "woman_walking", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿšถโ€โžก๏ธ": { - "name": "person walking facing right", - "slug": "person_walking_facing_right", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "15.1" - }, - "๐Ÿšถโ€โ™€๏ธโ€โžก๏ธ": { - "name": "woman walking facing right", - "slug": "woman_walking_facing_right", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "15.1" - }, - "๐Ÿšถโ€โ™‚๏ธโ€โžก๏ธ": { - "name": "man walking facing right", - "slug": "man_walking_facing_right", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "15.1" - }, - "๐Ÿง": { - "name": "person standing", - "slug": "person_standing", - "group": "People & Body", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "๐Ÿงโ€โ™‚๏ธ": { - "name": "man standing", - "slug": "man_standing", - "group": "People & Body", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "๐Ÿงโ€โ™€๏ธ": { - "name": "woman standing", - "slug": "woman_standing", - "group": "People & Body", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "๐ŸงŽ": { - "name": "person kneeling", - "slug": "person_kneeling", - "group": "People & Body", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "๐ŸงŽโ€โ™‚๏ธ": { - "name": "man kneeling", - "slug": "man_kneeling", - "group": "People & Body", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "๐ŸงŽโ€โ™€๏ธ": { - "name": "woman kneeling", - "slug": "woman_kneeling", - "group": "People & Body", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "๐ŸงŽโ€โžก๏ธ": { - "name": "person kneeling facing right", - "slug": "person_kneeling_facing_right", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "15.1" - }, - "๐ŸงŽโ€โ™€๏ธโ€โžก๏ธ": { - "name": "woman kneeling facing right", - "slug": "woman_kneeling_facing_right", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "15.1" - }, - "๐ŸงŽโ€โ™‚๏ธโ€โžก๏ธ": { - "name": "man kneeling facing right", - "slug": "man_kneeling_facing_right", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "15.1" - }, - "๐Ÿง‘โ€๐Ÿฆฏ": { - "name": "person with white cane", - "slug": "person_with_white_cane", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "๐Ÿง‘โ€๐Ÿฆฏโ€โžก๏ธ": { - "name": "person with white cane facing right", - "slug": "person_with_white_cane_facing_right", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "15.1" - }, - "๐Ÿ‘จโ€๐Ÿฆฏ": { - "name": "man with white cane", - "slug": "man_with_white_cane", - "group": "People & Body", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "๐Ÿ‘จโ€๐Ÿฆฏโ€โžก๏ธ": { - "name": "man with white cane facing right", - "slug": "man_with_white_cane_facing_right", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "15.1" - }, - "๐Ÿ‘ฉโ€๐Ÿฆฏ": { - "name": "woman with white cane", - "slug": "woman_with_white_cane", - "group": "People & Body", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "๐Ÿ‘ฉโ€๐Ÿฆฏโ€โžก๏ธ": { - "name": "woman with white cane facing right", - "slug": "woman_with_white_cane_facing_right", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "15.1" - }, - "๐Ÿง‘โ€๐Ÿฆผ": { - "name": "person in motorized wheelchair", - "slug": "person_in_motorized_wheelchair", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "๐Ÿง‘โ€๐Ÿฆผโ€โžก๏ธ": { - "name": "person in motorized wheelchair facing right", - "slug": "person_in_motorized_wheelchair_facing_right", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "15.1" - }, - "๐Ÿ‘จโ€๐Ÿฆผ": { - "name": "man in motorized wheelchair", - "slug": "man_in_motorized_wheelchair", - "group": "People & Body", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "๐Ÿ‘จโ€๐Ÿฆผโ€โžก๏ธ": { - "name": "man in motorized wheelchair facing right", - "slug": "man_in_motorized_wheelchair_facing_right", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "15.1" - }, - "๐Ÿ‘ฉโ€๐Ÿฆผ": { - "name": "woman in motorized wheelchair", - "slug": "woman_in_motorized_wheelchair", - "group": "People & Body", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "๐Ÿ‘ฉโ€๐Ÿฆผโ€โžก๏ธ": { - "name": "woman in motorized wheelchair facing right", - "slug": "woman_in_motorized_wheelchair_facing_right", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "15.1" - }, - "๐Ÿง‘โ€๐Ÿฆฝ": { - "name": "person in manual wheelchair", - "slug": "person_in_manual_wheelchair", - "group": "People & Body", - "emoji_version": "12.1", - "unicode_version": "12.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.1" - }, - "๐Ÿง‘โ€๐Ÿฆฝโ€โžก๏ธ": { - "name": "person in manual wheelchair facing right", - "slug": "person_in_manual_wheelchair_facing_right", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "15.1" - }, - "๐Ÿ‘จโ€๐Ÿฆฝ": { - "name": "man in manual wheelchair", - "slug": "man_in_manual_wheelchair", - "group": "People & Body", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "๐Ÿ‘จโ€๐Ÿฆฝโ€โžก๏ธ": { - "name": "man in manual wheelchair facing right", - "slug": "man_in_manual_wheelchair_facing_right", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "15.1" - }, - "๐Ÿ‘ฉโ€๐Ÿฆฝ": { - "name": "woman in manual wheelchair", - "slug": "woman_in_manual_wheelchair", - "group": "People & Body", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "๐Ÿ‘ฉโ€๐Ÿฆฝโ€โžก๏ธ": { - "name": "woman in manual wheelchair facing right", - "slug": "woman_in_manual_wheelchair_facing_right", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "15.1" - }, - "๐Ÿƒ": { - "name": "person running", - "slug": "person_running", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿƒโ€โ™‚๏ธ": { - "name": "man running", - "slug": "man_running", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿƒโ€โ™€๏ธ": { - "name": "woman running", - "slug": "woman_running", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿƒโ€โžก๏ธ": { - "name": "person running facing right", - "slug": "person_running_facing_right", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "15.1" - }, - "๐Ÿƒโ€โ™€๏ธโ€โžก๏ธ": { - "name": "woman running facing right", - "slug": "woman_running_facing_right", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "15.1" - }, - "๐Ÿƒโ€โ™‚๏ธโ€โžก๏ธ": { - "name": "man running facing right", - "slug": "man_running_facing_right", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "15.1" - }, - "๐Ÿ’ƒ": { - "name": "woman dancing", - "slug": "woman_dancing", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ•บ": { - "name": "man dancing", - "slug": "man_dancing", - "group": "People & Body", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "3.0" - }, - "๐Ÿ•ด๏ธ": { - "name": "person in suit levitating", - "slug": "person_in_suit_levitating", - "group": "People & Body", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ‘ฏ": { - "name": "people with bunny ears", - "slug": "people_with_bunny_ears", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‘ฏโ€โ™‚๏ธ": { - "name": "men with bunny ears", - "slug": "men_with_bunny_ears", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": false - }, - "๐Ÿ‘ฏโ€โ™€๏ธ": { - "name": "women with bunny ears", - "slug": "women_with_bunny_ears", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": false - }, - "๐Ÿง–": { - "name": "person in steamy room", - "slug": "person_in_steamy_room", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿง–โ€โ™‚๏ธ": { - "name": "man in steamy room", - "slug": "man_in_steamy_room", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿง–โ€โ™€๏ธ": { - "name": "woman in steamy room", - "slug": "woman_in_steamy_room", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿง—": { - "name": "person climbing", - "slug": "person_climbing", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿง—โ€โ™‚๏ธ": { - "name": "man climbing", - "slug": "man_climbing", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿง—โ€โ™€๏ธ": { - "name": "woman climbing", - "slug": "woman_climbing", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿคบ": { - "name": "person fencing", - "slug": "person_fencing", - "group": "People & Body", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿ‡": { - "name": "horse racing", - "slug": "horse_racing", - "group": "People & Body", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "โ›ท๏ธ": { - "name": "skier", - "slug": "skier", - "group": "People & Body", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ‚": { - "name": "snowboarder", - "slug": "snowboarder", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐ŸŒ๏ธ": { - "name": "person golfing", - "slug": "person_golfing", - "group": "People & Body", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐ŸŒ๏ธโ€โ™‚๏ธ": { - "name": "man golfing", - "slug": "man_golfing", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐ŸŒ๏ธโ€โ™€๏ธ": { - "name": "woman golfing", - "slug": "woman_golfing", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ„": { - "name": "person surfing", - "slug": "person_surfing", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ„โ€โ™‚๏ธ": { - "name": "man surfing", - "slug": "man_surfing", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ„โ€โ™€๏ธ": { - "name": "woman surfing", - "slug": "woman_surfing", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿšฃ": { - "name": "person rowing boat", - "slug": "person_rowing_boat", - "group": "People & Body", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿšฃโ€โ™‚๏ธ": { - "name": "man rowing boat", - "slug": "man_rowing_boat", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿšฃโ€โ™€๏ธ": { - "name": "woman rowing boat", - "slug": "woman_rowing_boat", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐ŸŠ": { - "name": "person swimming", - "slug": "person_swimming", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐ŸŠโ€โ™‚๏ธ": { - "name": "man swimming", - "slug": "man_swimming", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐ŸŠโ€โ™€๏ธ": { - "name": "woman swimming", - "slug": "woman_swimming", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "โ›น๏ธ": { - "name": "person bouncing ball", - "slug": "person_bouncing_ball", - "group": "People & Body", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "2.0" - }, - "โ›น๏ธโ€โ™‚๏ธ": { - "name": "man bouncing ball", - "slug": "man_bouncing_ball", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "โ›น๏ธโ€โ™€๏ธ": { - "name": "woman bouncing ball", - "slug": "woman_bouncing_ball", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ‹๏ธ": { - "name": "person lifting weights", - "slug": "person_lifting_weights", - "group": "People & Body", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "2.0" - }, - "๐Ÿ‹๏ธโ€โ™‚๏ธ": { - "name": "man lifting weights", - "slug": "man_lifting_weights", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿ‹๏ธโ€โ™€๏ธ": { - "name": "woman lifting weights", - "slug": "woman_lifting_weights", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿšด": { - "name": "person biking", - "slug": "person_biking", - "group": "People & Body", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿšดโ€โ™‚๏ธ": { - "name": "man biking", - "slug": "man_biking", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿšดโ€โ™€๏ธ": { - "name": "woman biking", - "slug": "woman_biking", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿšต": { - "name": "person mountain biking", - "slug": "person_mountain_biking", - "group": "People & Body", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿšตโ€โ™‚๏ธ": { - "name": "man mountain biking", - "slug": "man_mountain_biking", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿšตโ€โ™€๏ธ": { - "name": "woman mountain biking", - "slug": "woman_mountain_biking", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿคธ": { - "name": "person cartwheeling", - "slug": "person_cartwheeling", - "group": "People & Body", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "3.0" - }, - "๐Ÿคธโ€โ™‚๏ธ": { - "name": "man cartwheeling", - "slug": "man_cartwheeling", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿคธโ€โ™€๏ธ": { - "name": "woman cartwheeling", - "slug": "woman_cartwheeling", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿคผ": { - "name": "people wrestling", - "slug": "people_wrestling", - "group": "People & Body", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿคผโ€โ™‚๏ธ": { - "name": "men wrestling", - "slug": "men_wrestling", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": false - }, - "๐Ÿคผโ€โ™€๏ธ": { - "name": "women wrestling", - "slug": "women_wrestling", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": false - }, - "๐Ÿคฝ": { - "name": "person playing water polo", - "slug": "person_playing_water_polo", - "group": "People & Body", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "3.0" - }, - "๐Ÿคฝโ€โ™‚๏ธ": { - "name": "man playing water polo", - "slug": "man_playing_water_polo", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿคฝโ€โ™€๏ธ": { - "name": "woman playing water polo", - "slug": "woman_playing_water_polo", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿคพ": { - "name": "person playing handball", - "slug": "person_playing_handball", - "group": "People & Body", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "3.0" - }, - "๐Ÿคพโ€โ™‚๏ธ": { - "name": "man playing handball", - "slug": "man_playing_handball", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿคพโ€โ™€๏ธ": { - "name": "woman playing handball", - "slug": "woman_playing_handball", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿคน": { - "name": "person juggling", - "slug": "person_juggling", - "group": "People & Body", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "3.0" - }, - "๐Ÿคนโ€โ™‚๏ธ": { - "name": "man juggling", - "slug": "man_juggling", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿคนโ€โ™€๏ธ": { - "name": "woman juggling", - "slug": "woman_juggling", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿง˜": { - "name": "person in lotus position", - "slug": "person_in_lotus_position", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿง˜โ€โ™‚๏ธ": { - "name": "man in lotus position", - "slug": "man_in_lotus_position", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿง˜โ€โ™€๏ธ": { - "name": "woman in lotus position", - "slug": "woman_in_lotus_position", - "group": "People & Body", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "5.0" - }, - "๐Ÿ›€": { - "name": "person taking bath", - "slug": "person_taking_bath", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "1.0" - }, - "๐Ÿ›Œ": { - "name": "person in bed", - "slug": "person_in_bed", - "group": "People & Body", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "4.0" - }, - "๐Ÿง‘โ€๐Ÿคโ€๐Ÿง‘": { - "name": "people holding hands", - "slug": "people_holding_hands", - "group": "People & Body", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "๐Ÿ‘ญ": { - "name": "women holding hands", - "slug": "women_holding_hands", - "group": "People & Body", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "๐Ÿ‘ซ": { - "name": "woman and man holding hands", - "slug": "woman_and_man_holding_hands", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "๐Ÿ‘ฌ": { - "name": "men holding hands", - "slug": "men_holding_hands", - "group": "People & Body", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "12.0" - }, - "๐Ÿ’": { - "name": "kiss", - "slug": "kiss", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "13.1" - }, - "๐Ÿ‘ฉโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ": { - "name": "kiss woman, man", - "slug": "kiss_woman_man", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "13.1" - }, - "๐Ÿ‘จโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ": { - "name": "kiss man, man", - "slug": "kiss_man_man", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "13.1" - }, - "๐Ÿ‘ฉโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘ฉ": { - "name": "kiss woman, woman", - "slug": "kiss_woman_woman", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "13.1" - }, - "๐Ÿ’‘": { - "name": "couple with heart", - "slug": "couple_with_heart", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "13.1" - }, - "๐Ÿ‘ฉโ€โค๏ธโ€๐Ÿ‘จ": { - "name": "couple with heart woman, man", - "slug": "couple_with_heart_woman_man", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "13.1" - }, - "๐Ÿ‘จโ€โค๏ธโ€๐Ÿ‘จ": { - "name": "couple with heart man, man", - "slug": "couple_with_heart_man_man", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "13.1" - }, - "๐Ÿ‘ฉโ€โค๏ธโ€๐Ÿ‘ฉ": { - "name": "couple with heart woman, woman", - "slug": "couple_with_heart_woman_woman", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": true, - "skin_tone_support_unicode_version": "13.1" - }, - "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘ฆ": { - "name": "family man, woman, boy", - "slug": "family_man_woman_boy", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘ง": { - "name": "family man, woman, girl", - "slug": "family_man_woman_girl", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ": { - "name": "family man, woman, girl, boy", - "slug": "family_man_woman_girl_boy", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘ฆโ€๐Ÿ‘ฆ": { - "name": "family man, woman, boy, boy", - "slug": "family_man_woman_boy_boy", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ง": { - "name": "family man, woman, girl, girl", - "slug": "family_man_woman_girl_girl", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‘จโ€๐Ÿ‘จโ€๐Ÿ‘ฆ": { - "name": "family man, man, boy", - "slug": "family_man_man_boy", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‘จโ€๐Ÿ‘จโ€๐Ÿ‘ง": { - "name": "family man, man, girl", - "slug": "family_man_man_girl", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‘จโ€๐Ÿ‘จโ€๐Ÿ‘งโ€๐Ÿ‘ฆ": { - "name": "family man, man, girl, boy", - "slug": "family_man_man_girl_boy", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‘จโ€๐Ÿ‘จโ€๐Ÿ‘ฆโ€๐Ÿ‘ฆ": { - "name": "family man, man, boy, boy", - "slug": "family_man_man_boy_boy", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‘จโ€๐Ÿ‘จโ€๐Ÿ‘งโ€๐Ÿ‘ง": { - "name": "family man, man, girl, girl", - "slug": "family_man_man_girl_girl", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘ฆ": { - "name": "family woman, woman, boy", - "slug": "family_woman_woman_boy", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘ง": { - "name": "family woman, woman, girl", - "slug": "family_woman_woman_girl", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ": { - "name": "family woman, woman, girl, boy", - "slug": "family_woman_woman_girl_boy", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘ฆโ€๐Ÿ‘ฆ": { - "name": "family woman, woman, boy, boy", - "slug": "family_woman_woman_boy_boy", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ง": { - "name": "family woman, woman, girl, girl", - "slug": "family_woman_woman_girl_girl", - "group": "People & Body", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‘จโ€๐Ÿ‘ฆ": { - "name": "family man, boy", - "slug": "family_man_boy", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": false - }, - "๐Ÿ‘จโ€๐Ÿ‘ฆโ€๐Ÿ‘ฆ": { - "name": "family man, boy, boy", - "slug": "family_man_boy_boy", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": false - }, - "๐Ÿ‘จโ€๐Ÿ‘ง": { - "name": "family man, girl", - "slug": "family_man_girl", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": false - }, - "๐Ÿ‘จโ€๐Ÿ‘งโ€๐Ÿ‘ฆ": { - "name": "family man, girl, boy", - "slug": "family_man_girl_boy", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": false - }, - "๐Ÿ‘จโ€๐Ÿ‘งโ€๐Ÿ‘ง": { - "name": "family man, girl, girl", - "slug": "family_man_girl_girl", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": false - }, - "๐Ÿ‘ฉโ€๐Ÿ‘ฆ": { - "name": "family woman, boy", - "slug": "family_woman_boy", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": false - }, - "๐Ÿ‘ฉโ€๐Ÿ‘ฆโ€๐Ÿ‘ฆ": { - "name": "family woman, boy, boy", - "slug": "family_woman_boy_boy", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": false - }, - "๐Ÿ‘ฉโ€๐Ÿ‘ง": { - "name": "family woman, girl", - "slug": "family_woman_girl", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": false - }, - "๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ": { - "name": "family woman, girl, boy", - "slug": "family_woman_girl_boy", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": false - }, - "๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ง": { - "name": "family woman, girl, girl", - "slug": "family_woman_girl_girl", - "group": "People & Body", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": false - }, - "๐Ÿ—ฃ๏ธ": { - "name": "speaking head", - "slug": "speaking_head", - "group": "People & Body", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ‘ค": { - "name": "bust in silhouette", - "slug": "bust_in_silhouette", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‘ฅ": { - "name": "busts in silhouette", - "slug": "busts_in_silhouette", - "group": "People & Body", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿซ‚": { - "name": "people hugging", - "slug": "people_hugging", - "group": "People & Body", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿ‘ช": { - "name": "family", - "slug": "family", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿง‘โ€๐Ÿง‘โ€๐Ÿง’": { - "name": "family adult, adult, child", - "slug": "family_adult_adult_child", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": false - }, - "๐Ÿง‘โ€๐Ÿง‘โ€๐Ÿง’โ€๐Ÿง’": { - "name": "family adult, adult, child, child", - "slug": "family_adult_adult_child_child", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": false - }, - "๐Ÿง‘โ€๐Ÿง’": { - "name": "family adult, child", - "slug": "family_adult_child", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": false - }, - "๐Ÿง‘โ€๐Ÿง’โ€๐Ÿง’": { - "name": "family adult, child, child", - "slug": "family_adult_child_child", - "group": "People & Body", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": false - }, - "๐Ÿ‘ฃ": { - "name": "footprints", - "slug": "footprints", - "group": "People & Body", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿต": { - "name": "monkey face", - "slug": "monkey_face", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’": { - "name": "monkey", - "slug": "monkey", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฆ": { - "name": "gorilla", - "slug": "gorilla", - "group": "Animals & Nature", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿฆง": { - "name": "orangutan", - "slug": "orangutan", - "group": "Animals & Nature", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿถ": { - "name": "dog face", - "slug": "dog_face", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ•": { - "name": "dog", - "slug": "dog", - "group": "Animals & Nature", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿฆฎ": { - "name": "guide dog", - "slug": "guide_dog", - "group": "Animals & Nature", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿ•โ€๐Ÿฆบ": { - "name": "service dog", - "slug": "service_dog", - "group": "Animals & Nature", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿฉ": { - "name": "poodle", - "slug": "poodle", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿบ": { - "name": "wolf", - "slug": "wolf", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸฆŠ": { - "name": "fox", - "slug": "fox", - "group": "Animals & Nature", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿฆ": { - "name": "raccoon", - "slug": "raccoon", - "group": "Animals & Nature", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿฑ": { - "name": "cat face", - "slug": "cat_face", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿˆ": { - "name": "cat", - "slug": "cat", - "group": "Animals & Nature", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿˆโ€โฌ›": { - "name": "black cat", - "slug": "black_cat", - "group": "Animals & Nature", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿฆ": { - "name": "lion", - "slug": "lion", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿฏ": { - "name": "tiger face", - "slug": "tiger_face", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ…": { - "name": "tiger", - "slug": "tiger", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ†": { - "name": "leopard", - "slug": "leopard", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿด": { - "name": "horse face", - "slug": "horse_face", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸซŽ": { - "name": "moose", - "slug": "moose", - "group": "Animals & Nature", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": false - }, - "๐Ÿซ": { - "name": "donkey", - "slug": "donkey", - "group": "Animals & Nature", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": false - }, - "๐ŸŽ": { - "name": "horse", - "slug": "horse", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฆ„": { - "name": "unicorn", - "slug": "unicorn", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿฆ“": { - "name": "zebra", - "slug": "zebra", - "group": "Animals & Nature", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐ŸฆŒ": { - "name": "deer", - "slug": "deer", - "group": "Animals & Nature", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿฆฌ": { - "name": "bison", - "slug": "bison", - "group": "Animals & Nature", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿฎ": { - "name": "cow face", - "slug": "cow_face", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‚": { - "name": "ox", - "slug": "ox", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿƒ": { - "name": "water buffalo", - "slug": "water_buffalo", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ„": { - "name": "cow", - "slug": "cow", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿท": { - "name": "pig face", - "slug": "pig_face", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ–": { - "name": "pig", - "slug": "pig", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ—": { - "name": "boar", - "slug": "boar", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฝ": { - "name": "pig nose", - "slug": "pig_nose", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ": { - "name": "ram", - "slug": "ram", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ‘": { - "name": "ewe", - "slug": "ewe", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ": { - "name": "goat", - "slug": "goat", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿช": { - "name": "camel", - "slug": "camel", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿซ": { - "name": "two-hump camel", - "slug": "two_hump_camel", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฆ™": { - "name": "llama", - "slug": "llama", - "group": "Animals & Nature", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿฆ’": { - "name": "giraffe", - "slug": "giraffe", - "group": "Animals & Nature", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿ˜": { - "name": "elephant", - "slug": "elephant", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฆฃ": { - "name": "mammoth", - "slug": "mammoth", - "group": "Animals & Nature", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿฆ": { - "name": "rhinoceros", - "slug": "rhinoceros", - "group": "Animals & Nature", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿฆ›": { - "name": "hippopotamus", - "slug": "hippopotamus", - "group": "Animals & Nature", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿญ": { - "name": "mouse face", - "slug": "mouse_face", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ": { - "name": "mouse", - "slug": "mouse", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ€": { - "name": "rat", - "slug": "rat", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿน": { - "name": "hamster", - "slug": "hamster", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฐ": { - "name": "rabbit face", - "slug": "rabbit_face", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‡": { - "name": "rabbit", - "slug": "rabbit", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿฟ๏ธ": { - "name": "chipmunk", - "slug": "chipmunk", - "group": "Animals & Nature", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿฆซ": { - "name": "beaver", - "slug": "beaver", - "group": "Animals & Nature", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿฆ”": { - "name": "hedgehog", - "slug": "hedgehog", - "group": "Animals & Nature", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿฆ‡": { - "name": "bat", - "slug": "bat", - "group": "Animals & Nature", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿป": { - "name": "bear", - "slug": "bear", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿปโ€โ„๏ธ": { - "name": "polar bear", - "slug": "polar_bear", - "group": "Animals & Nature", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿจ": { - "name": "koala", - "slug": "koala", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿผ": { - "name": "panda", - "slug": "panda", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฆฅ": { - "name": "sloth", - "slug": "sloth", - "group": "Animals & Nature", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿฆฆ": { - "name": "otter", - "slug": "otter", - "group": "Animals & Nature", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿฆจ": { - "name": "skunk", - "slug": "skunk", - "group": "Animals & Nature", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿฆ˜": { - "name": "kangaroo", - "slug": "kangaroo", - "group": "Animals & Nature", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿฆก": { - "name": "badger", - "slug": "badger", - "group": "Animals & Nature", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿพ": { - "name": "paw prints", - "slug": "paw_prints", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฆƒ": { - "name": "turkey", - "slug": "turkey", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ”": { - "name": "chicken", - "slug": "chicken", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“": { - "name": "rooster", - "slug": "rooster", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿฃ": { - "name": "hatching chick", - "slug": "hatching_chick", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿค": { - "name": "baby chick", - "slug": "baby_chick", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฅ": { - "name": "front-facing baby chick", - "slug": "front_facing_baby_chick", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฆ": { - "name": "bird", - "slug": "bird", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿง": { - "name": "penguin", - "slug": "penguin", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ•Š๏ธ": { - "name": "dove", - "slug": "dove", - "group": "Animals & Nature", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿฆ…": { - "name": "eagle", - "slug": "eagle", - "group": "Animals & Nature", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿฆ†": { - "name": "duck", - "slug": "duck", - "group": "Animals & Nature", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿฆข": { - "name": "swan", - "slug": "swan", - "group": "Animals & Nature", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿฆ‰": { - "name": "owl", - "slug": "owl", - "group": "Animals & Nature", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿฆค": { - "name": "dodo", - "slug": "dodo", - "group": "Animals & Nature", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿชถ": { - "name": "feather", - "slug": "feather", - "group": "Animals & Nature", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿฆฉ": { - "name": "flamingo", - "slug": "flamingo", - "group": "Animals & Nature", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿฆš": { - "name": "peacock", - "slug": "peacock", - "group": "Animals & Nature", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿฆœ": { - "name": "parrot", - "slug": "parrot", - "group": "Animals & Nature", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿชฝ": { - "name": "wing", - "slug": "wing", - "group": "Animals & Nature", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": false - }, - "๐Ÿฆโ€โฌ›": { - "name": "black bird", - "slug": "black_bird", - "group": "Animals & Nature", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": false - }, - "๐Ÿชฟ": { - "name": "goose", - "slug": "goose", - "group": "Animals & Nature", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": false - }, - "๐Ÿฆโ€๐Ÿ”ฅ": { - "name": "phoenix", - "slug": "phoenix", - "group": "Animals & Nature", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": false - }, - "๐Ÿธ": { - "name": "frog", - "slug": "frog", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŠ": { - "name": "crocodile", - "slug": "crocodile", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿข": { - "name": "turtle", - "slug": "turtle", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸฆŽ": { - "name": "lizard", - "slug": "lizard", - "group": "Animals & Nature", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿ": { - "name": "snake", - "slug": "snake", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฒ": { - "name": "dragon face", - "slug": "dragon_face", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‰": { - "name": "dragon", - "slug": "dragon", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿฆ•": { - "name": "sauropod", - "slug": "sauropod", - "group": "Animals & Nature", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿฆ–": { - "name": "T-Rex", - "slug": "t_rex", - "group": "Animals & Nature", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿณ": { - "name": "spouting whale", - "slug": "spouting_whale", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‹": { - "name": "whale", - "slug": "whale", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿฌ": { - "name": "dolphin", - "slug": "dolphin", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฆญ": { - "name": "seal", - "slug": "seal", - "group": "Animals & Nature", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐ŸŸ": { - "name": "fish", - "slug": "fish", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ ": { - "name": "tropical fish", - "slug": "tropical_fish", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿก": { - "name": "blowfish", - "slug": "blowfish", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฆˆ": { - "name": "shark", - "slug": "shark", - "group": "Animals & Nature", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿ™": { - "name": "octopus", - "slug": "octopus", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿš": { - "name": "spiral shell", - "slug": "spiral_shell", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿชธ": { - "name": "coral", - "slug": "coral", - "group": "Animals & Nature", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "๐Ÿชผ": { - "name": "jellyfish", - "slug": "jellyfish", - "group": "Animals & Nature", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": false - }, - "๐ŸŒ": { - "name": "snail", - "slug": "snail", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฆ‹": { - "name": "butterfly", - "slug": "butterfly", - "group": "Animals & Nature", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿ›": { - "name": "bug", - "slug": "bug", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿœ": { - "name": "ant", - "slug": "ant", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ": { - "name": "honeybee", - "slug": "honeybee", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿชฒ": { - "name": "beetle", - "slug": "beetle", - "group": "Animals & Nature", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿž": { - "name": "lady beetle", - "slug": "lady_beetle", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฆ—": { - "name": "cricket", - "slug": "cricket", - "group": "Animals & Nature", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿชณ": { - "name": "cockroach", - "slug": "cockroach", - "group": "Animals & Nature", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿ•ท๏ธ": { - "name": "spider", - "slug": "spider", - "group": "Animals & Nature", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ•ธ๏ธ": { - "name": "spider web", - "slug": "spider_web", - "group": "Animals & Nature", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿฆ‚": { - "name": "scorpion", - "slug": "scorpion", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐ŸฆŸ": { - "name": "mosquito", - "slug": "mosquito", - "group": "Animals & Nature", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿชฐ": { - "name": "fly", - "slug": "fly", - "group": "Animals & Nature", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿชฑ": { - "name": "worm", - "slug": "worm", - "group": "Animals & Nature", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿฆ ": { - "name": "microbe", - "slug": "microbe", - "group": "Animals & Nature", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿ’": { - "name": "bouquet", - "slug": "bouquet", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŒธ": { - "name": "cherry blossom", - "slug": "cherry_blossom", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’ฎ": { - "name": "white flower", - "slug": "white_flower", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿชท": { - "name": "lotus", - "slug": "lotus", - "group": "Animals & Nature", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "๐Ÿต๏ธ": { - "name": "rosette", - "slug": "rosette", - "group": "Animals & Nature", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐ŸŒน": { - "name": "rose", - "slug": "rose", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฅ€": { - "name": "wilted flower", - "slug": "wilted_flower", - "group": "Animals & Nature", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐ŸŒบ": { - "name": "hibiscus", - "slug": "hibiscus", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŒป": { - "name": "sunflower", - "slug": "sunflower", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŒผ": { - "name": "blossom", - "slug": "blossom", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŒท": { - "name": "tulip", - "slug": "tulip", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿชป": { - "name": "hyacinth", - "slug": "hyacinth", - "group": "Animals & Nature", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": false - }, - "๐ŸŒฑ": { - "name": "seedling", - "slug": "seedling", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿชด": { - "name": "potted plant", - "slug": "potted_plant", - "group": "Animals & Nature", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐ŸŒฒ": { - "name": "evergreen tree", - "slug": "evergreen_tree", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐ŸŒณ": { - "name": "deciduous tree", - "slug": "deciduous_tree", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐ŸŒด": { - "name": "palm tree", - "slug": "palm_tree", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŒต": { - "name": "cactus", - "slug": "cactus", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŒพ": { - "name": "sheaf of rice", - "slug": "sheaf_of_rice", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŒฟ": { - "name": "herb", - "slug": "herb", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ˜˜๏ธ": { - "name": "shamrock", - "slug": "shamrock", - "group": "Animals & Nature", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ€": { - "name": "four leaf clover", - "slug": "four_leaf_clover", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ": { - "name": "maple leaf", - "slug": "maple_leaf", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‚": { - "name": "fallen leaf", - "slug": "fallen_leaf", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿƒ": { - "name": "leaf fluttering in wind", - "slug": "leaf_fluttering_in_wind", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿชน": { - "name": "empty nest", - "slug": "empty_nest", - "group": "Animals & Nature", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "๐Ÿชบ": { - "name": "nest with eggs", - "slug": "nest_with_eggs", - "group": "Animals & Nature", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "๐Ÿ„": { - "name": "mushroom", - "slug": "mushroom", - "group": "Animals & Nature", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‡": { - "name": "grapes", - "slug": "grapes", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿˆ": { - "name": "melon", - "slug": "melon", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‰": { - "name": "watermelon", - "slug": "watermelon", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŠ": { - "name": "tangerine", - "slug": "tangerine", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‹": { - "name": "lemon", - "slug": "lemon", - "group": "Food & Drink", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ‹โ€๐ŸŸฉ": { - "name": "lime", - "slug": "lime", - "group": "Food & Drink", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": false - }, - "๐ŸŒ": { - "name": "banana", - "slug": "banana", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ": { - "name": "pineapple", - "slug": "pineapple", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฅญ": { - "name": "mango", - "slug": "mango", - "group": "Food & Drink", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐ŸŽ": { - "name": "red apple", - "slug": "red_apple", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ": { - "name": "green apple", - "slug": "green_apple", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ": { - "name": "pear", - "slug": "pear", - "group": "Food & Drink", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ‘": { - "name": "peach", - "slug": "peach", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’": { - "name": "cherries", - "slug": "cherries", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“": { - "name": "strawberry", - "slug": "strawberry", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿซ": { - "name": "blueberries", - "slug": "blueberries", - "group": "Food & Drink", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿฅ": { - "name": "kiwi fruit", - "slug": "kiwi_fruit", - "group": "Food & Drink", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿ…": { - "name": "tomato", - "slug": "tomato", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿซ’": { - "name": "olive", - "slug": "olive", - "group": "Food & Drink", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿฅฅ": { - "name": "coconut", - "slug": "coconut", - "group": "Food & Drink", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿฅ‘": { - "name": "avocado", - "slug": "avocado", - "group": "Food & Drink", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿ†": { - "name": "eggplant", - "slug": "eggplant", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฅ”": { - "name": "potato", - "slug": "potato", - "group": "Food & Drink", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿฅ•": { - "name": "carrot", - "slug": "carrot", - "group": "Food & Drink", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐ŸŒฝ": { - "name": "ear of corn", - "slug": "ear_of_corn", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŒถ๏ธ": { - "name": "hot pepper", - "slug": "hot_pepper", - "group": "Food & Drink", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿซ‘": { - "name": "bell pepper", - "slug": "bell_pepper", - "group": "Food & Drink", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿฅ’": { - "name": "cucumber", - "slug": "cucumber", - "group": "Food & Drink", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿฅฌ": { - "name": "leafy green", - "slug": "leafy_green", - "group": "Food & Drink", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿฅฆ": { - "name": "broccoli", - "slug": "broccoli", - "group": "Food & Drink", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿง„": { - "name": "garlic", - "slug": "garlic", - "group": "Food & Drink", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿง…": { - "name": "onion", - "slug": "onion", - "group": "Food & Drink", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿฅœ": { - "name": "peanuts", - "slug": "peanuts", - "group": "Food & Drink", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿซ˜": { - "name": "beans", - "slug": "beans", - "group": "Food & Drink", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "๐ŸŒฐ": { - "name": "chestnut", - "slug": "chestnut", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿซš": { - "name": "ginger root", - "slug": "ginger_root", - "group": "Food & Drink", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": false - }, - "๐Ÿซ›": { - "name": "pea pod", - "slug": "pea_pod", - "group": "Food & Drink", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": false - }, - "๐Ÿ„โ€๐ŸŸซ": { - "name": "brown mushroom", - "slug": "brown_mushroom", - "group": "Food & Drink", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": false - }, - "๐Ÿž": { - "name": "bread", - "slug": "bread", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฅ": { - "name": "croissant", - "slug": "croissant", - "group": "Food & Drink", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿฅ–": { - "name": "baguette bread", - "slug": "baguette_bread", - "group": "Food & Drink", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿซ“": { - "name": "flatbread", - "slug": "flatbread", - "group": "Food & Drink", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿฅจ": { - "name": "pretzel", - "slug": "pretzel", - "group": "Food & Drink", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿฅฏ": { - "name": "bagel", - "slug": "bagel", - "group": "Food & Drink", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿฅž": { - "name": "pancakes", - "slug": "pancakes", - "group": "Food & Drink", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿง‡": { - "name": "waffle", - "slug": "waffle", - "group": "Food & Drink", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿง€": { - "name": "cheese wedge", - "slug": "cheese_wedge", - "group": "Food & Drink", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ–": { - "name": "meat on bone", - "slug": "meat_on_bone", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ—": { - "name": "poultry leg", - "slug": "poultry_leg", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฅฉ": { - "name": "cut of meat", - "slug": "cut_of_meat", - "group": "Food & Drink", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿฅ“": { - "name": "bacon", - "slug": "bacon", - "group": "Food & Drink", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿ”": { - "name": "hamburger", - "slug": "hamburger", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŸ": { - "name": "french fries", - "slug": "french_fries", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ•": { - "name": "pizza", - "slug": "pizza", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŒญ": { - "name": "hot dog", - "slug": "hot_dog", - "group": "Food & Drink", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿฅช": { - "name": "sandwich", - "slug": "sandwich", - "group": "Food & Drink", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐ŸŒฎ": { - "name": "taco", - "slug": "taco", - "group": "Food & Drink", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐ŸŒฏ": { - "name": "burrito", - "slug": "burrito", - "group": "Food & Drink", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿซ”": { - "name": "tamale", - "slug": "tamale", - "group": "Food & Drink", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿฅ™": { - "name": "stuffed flatbread", - "slug": "stuffed_flatbread", - "group": "Food & Drink", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿง†": { - "name": "falafel", - "slug": "falafel", - "group": "Food & Drink", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿฅš": { - "name": "egg", - "slug": "egg", - "group": "Food & Drink", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿณ": { - "name": "cooking", - "slug": "cooking", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฅ˜": { - "name": "shallow pan of food", - "slug": "shallow_pan_of_food", - "group": "Food & Drink", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿฒ": { - "name": "pot of food", - "slug": "pot_of_food", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿซ•": { - "name": "fondue", - "slug": "fondue", - "group": "Food & Drink", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿฅฃ": { - "name": "bowl with spoon", - "slug": "bowl_with_spoon", - "group": "Food & Drink", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿฅ—": { - "name": "green salad", - "slug": "green_salad", - "group": "Food & Drink", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿฟ": { - "name": "popcorn", - "slug": "popcorn", - "group": "Food & Drink", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿงˆ": { - "name": "butter", - "slug": "butter", - "group": "Food & Drink", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿง‚": { - "name": "salt", - "slug": "salt", - "group": "Food & Drink", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿฅซ": { - "name": "canned food", - "slug": "canned_food", - "group": "Food & Drink", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿฑ": { - "name": "bento box", - "slug": "bento_box", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ˜": { - "name": "rice cracker", - "slug": "rice_cracker", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ™": { - "name": "rice ball", - "slug": "rice_ball", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿš": { - "name": "cooked rice", - "slug": "cooked_rice", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ›": { - "name": "curry rice", - "slug": "curry_rice", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿœ": { - "name": "steaming bowl", - "slug": "steaming_bowl", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ": { - "name": "spaghetti", - "slug": "spaghetti", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ ": { - "name": "roasted sweet potato", - "slug": "roasted_sweet_potato", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿข": { - "name": "oden", - "slug": "oden", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฃ": { - "name": "sushi", - "slug": "sushi", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿค": { - "name": "fried shrimp", - "slug": "fried_shrimp", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฅ": { - "name": "fish cake with swirl", - "slug": "fish_cake_with_swirl", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฅฎ": { - "name": "moon cake", - "slug": "moon_cake", - "group": "Food & Drink", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿก": { - "name": "dango", - "slug": "dango", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸฅŸ": { - "name": "dumpling", - "slug": "dumpling", - "group": "Food & Drink", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿฅ ": { - "name": "fortune cookie", - "slug": "fortune_cookie", - "group": "Food & Drink", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿฅก": { - "name": "takeout box", - "slug": "takeout_box", - "group": "Food & Drink", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿฆ€": { - "name": "crab", - "slug": "crab", - "group": "Food & Drink", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿฆž": { - "name": "lobster", - "slug": "lobster", - "group": "Food & Drink", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿฆ": { - "name": "shrimp", - "slug": "shrimp", - "group": "Food & Drink", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿฆ‘": { - "name": "squid", - "slug": "squid", - "group": "Food & Drink", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿฆช": { - "name": "oyster", - "slug": "oyster", - "group": "Food & Drink", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿฆ": { - "name": "soft ice cream", - "slug": "soft_ice_cream", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿง": { - "name": "shaved ice", - "slug": "shaved_ice", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿจ": { - "name": "ice cream", - "slug": "ice_cream", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฉ": { - "name": "doughnut", - "slug": "doughnut", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿช": { - "name": "cookie", - "slug": "cookie", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽ‚": { - "name": "birthday cake", - "slug": "birthday_cake", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฐ": { - "name": "shortcake", - "slug": "shortcake", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿง": { - "name": "cupcake", - "slug": "cupcake", - "group": "Food & Drink", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿฅง": { - "name": "pie", - "slug": "pie", - "group": "Food & Drink", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿซ": { - "name": "chocolate bar", - "slug": "chocolate_bar", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฌ": { - "name": "candy", - "slug": "candy", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿญ": { - "name": "lollipop", - "slug": "lollipop", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฎ": { - "name": "custard", - "slug": "custard", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฏ": { - "name": "honey pot", - "slug": "honey_pot", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿผ": { - "name": "baby bottle", - "slug": "baby_bottle", - "group": "Food & Drink", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿฅ›": { - "name": "glass of milk", - "slug": "glass_of_milk", - "group": "Food & Drink", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "โ˜•": { - "name": "hot beverage", - "slug": "hot_beverage", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿซ–": { - "name": "teapot", - "slug": "teapot", - "group": "Food & Drink", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿต": { - "name": "teacup without handle", - "slug": "teacup_without_handle", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿถ": { - "name": "sake", - "slug": "sake", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿพ": { - "name": "bottle with popping cork", - "slug": "bottle_with_popping_cork", - "group": "Food & Drink", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿท": { - "name": "wine glass", - "slug": "wine_glass", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿธ": { - "name": "cocktail glass", - "slug": "cocktail_glass", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿน": { - "name": "tropical drink", - "slug": "tropical_drink", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿบ": { - "name": "beer mug", - "slug": "beer_mug", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿป": { - "name": "clinking beer mugs", - "slug": "clinking_beer_mugs", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฅ‚": { - "name": "clinking glasses", - "slug": "clinking_glasses", - "group": "Food & Drink", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿฅƒ": { - "name": "tumbler glass", - "slug": "tumbler_glass", - "group": "Food & Drink", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿซ—": { - "name": "pouring liquid", - "slug": "pouring_liquid", - "group": "Food & Drink", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "๐Ÿฅค": { - "name": "cup with straw", - "slug": "cup_with_straw", - "group": "Food & Drink", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿง‹": { - "name": "bubble tea", - "slug": "bubble_tea", - "group": "Food & Drink", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿงƒ": { - "name": "beverage box", - "slug": "beverage_box", - "group": "Food & Drink", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿง‰": { - "name": "mate", - "slug": "mate", - "group": "Food & Drink", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐ŸงŠ": { - "name": "ice", - "slug": "ice", - "group": "Food & Drink", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿฅข": { - "name": "chopsticks", - "slug": "chopsticks", - "group": "Food & Drink", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿฝ๏ธ": { - "name": "fork and knife with plate", - "slug": "fork_and_knife_with_plate", - "group": "Food & Drink", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿด": { - "name": "fork and knife", - "slug": "fork_and_knife", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฅ„": { - "name": "spoon", - "slug": "spoon", - "group": "Food & Drink", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿ”ช": { - "name": "kitchen knife", - "slug": "kitchen_knife", - "group": "Food & Drink", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿซ™": { - "name": "jar", - "slug": "jar", - "group": "Food & Drink", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "๐Ÿบ": { - "name": "amphora", - "slug": "amphora", - "group": "Food & Drink", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐ŸŒ": { - "name": "globe showing Europe-Africa", - "slug": "globe_showing_europe_africa", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐ŸŒŽ": { - "name": "globe showing Americas", - "slug": "globe_showing_americas", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐ŸŒ": { - "name": "globe showing Asia-Australia", - "slug": "globe_showing_asia_australia", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŒ": { - "name": "globe with meridians", - "slug": "globe_with_meridians", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ—บ๏ธ": { - "name": "world map", - "slug": "world_map", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ—พ": { - "name": "map of Japan", - "slug": "map_of_japan", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿงญ": { - "name": "compass", - "slug": "compass", - "group": "Travel & Places", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿ”๏ธ": { - "name": "snow-capped mountain", - "slug": "snow_capped_mountain", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "โ›ฐ๏ธ": { - "name": "mountain", - "slug": "mountain", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐ŸŒ‹": { - "name": "volcano", - "slug": "volcano", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ—ป": { - "name": "mount fuji", - "slug": "mount_fuji", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ•๏ธ": { - "name": "camping", - "slug": "camping", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ–๏ธ": { - "name": "beach with umbrella", - "slug": "beach_with_umbrella", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿœ๏ธ": { - "name": "desert", - "slug": "desert", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ๏ธ": { - "name": "desert island", - "slug": "desert_island", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿž๏ธ": { - "name": "national park", - "slug": "national_park", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐ŸŸ๏ธ": { - "name": "stadium", - "slug": "stadium", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ›๏ธ": { - "name": "classical building", - "slug": "classical_building", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ—๏ธ": { - "name": "building construction", - "slug": "building_construction", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿงฑ": { - "name": "brick", - "slug": "brick", - "group": "Travel & Places", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿชจ": { - "name": "rock", - "slug": "rock", - "group": "Travel & Places", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿชต": { - "name": "wood", - "slug": "wood", - "group": "Travel & Places", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿ›–": { - "name": "hut", - "slug": "hut", - "group": "Travel & Places", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿ˜๏ธ": { - "name": "houses", - "slug": "houses", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿš๏ธ": { - "name": "derelict house", - "slug": "derelict_house", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ ": { - "name": "house", - "slug": "house", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿก": { - "name": "house with garden", - "slug": "house_with_garden", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿข": { - "name": "office building", - "slug": "office_building", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฃ": { - "name": "Japanese post office", - "slug": "japanese_post_office", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿค": { - "name": "post office", - "slug": "post_office", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿฅ": { - "name": "hospital", - "slug": "hospital", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฆ": { - "name": "bank", - "slug": "bank", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿจ": { - "name": "hotel", - "slug": "hotel", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฉ": { - "name": "love hotel", - "slug": "love_hotel", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿช": { - "name": "convenience store", - "slug": "convenience_store", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿซ": { - "name": "school", - "slug": "school", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฌ": { - "name": "department store", - "slug": "department_store", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿญ": { - "name": "factory", - "slug": "factory", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฏ": { - "name": "Japanese castle", - "slug": "japanese_castle", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฐ": { - "name": "castle", - "slug": "castle", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’’": { - "name": "wedding", - "slug": "wedding", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ—ผ": { - "name": "Tokyo tower", - "slug": "tokyo_tower", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ—ฝ": { - "name": "Statue of Liberty", - "slug": "statue_of_liberty", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ›ช": { - "name": "church", - "slug": "church", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ•Œ": { - "name": "mosque", - "slug": "mosque", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ›•": { - "name": "hindu temple", - "slug": "hindu_temple", - "group": "Travel & Places", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿ•": { - "name": "synagogue", - "slug": "synagogue", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "โ›ฉ๏ธ": { - "name": "shinto shrine", - "slug": "shinto_shrine", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ•‹": { - "name": "kaaba", - "slug": "kaaba", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "โ›ฒ": { - "name": "fountain", - "slug": "fountain", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ›บ": { - "name": "tent", - "slug": "tent", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŒ": { - "name": "foggy", - "slug": "foggy", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŒƒ": { - "name": "night with stars", - "slug": "night_with_stars", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ™๏ธ": { - "name": "cityscape", - "slug": "cityscape", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐ŸŒ„": { - "name": "sunrise over mountains", - "slug": "sunrise_over_mountains", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŒ…": { - "name": "sunrise", - "slug": "sunrise", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŒ†": { - "name": "cityscape at dusk", - "slug": "cityscape_at_dusk", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŒ‡": { - "name": "sunset", - "slug": "sunset", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŒ‰": { - "name": "bridge at night", - "slug": "bridge_at_night", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ™จ๏ธ": { - "name": "hot springs", - "slug": "hot_springs", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽ ": { - "name": "carousel horse", - "slug": "carousel_horse", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ›": { - "name": "playground slide", - "slug": "playground_slide", - "group": "Travel & Places", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "๐ŸŽก": { - "name": "ferris wheel", - "slug": "ferris_wheel", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽข": { - "name": "roller coaster", - "slug": "roller_coaster", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’ˆ": { - "name": "barber pole", - "slug": "barber_pole", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽช": { - "name": "circus tent", - "slug": "circus_tent", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿš‚": { - "name": "locomotive", - "slug": "locomotive", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿšƒ": { - "name": "railway car", - "slug": "railway_car", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿš„": { - "name": "high-speed train", - "slug": "high_speed_train", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿš…": { - "name": "bullet train", - "slug": "bullet_train", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿš†": { - "name": "train", - "slug": "train", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿš‡": { - "name": "metro", - "slug": "metro", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿšˆ": { - "name": "light rail", - "slug": "light_rail", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿš‰": { - "name": "station", - "slug": "station", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸšŠ": { - "name": "tram", - "slug": "tram", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿš": { - "name": "monorail", - "slug": "monorail", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿšž": { - "name": "mountain railway", - "slug": "mountain_railway", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿš‹": { - "name": "tram car", - "slug": "tram_car", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐ŸšŒ": { - "name": "bus", - "slug": "bus", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿš": { - "name": "oncoming bus", - "slug": "oncoming_bus", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐ŸšŽ": { - "name": "trolleybus", - "slug": "trolleybus", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿš": { - "name": "minibus", - "slug": "minibus", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿš‘": { - "name": "ambulance", - "slug": "ambulance", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿš’": { - "name": "fire engine", - "slug": "fire_engine", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿš“": { - "name": "police car", - "slug": "police_car", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿš”": { - "name": "oncoming police car", - "slug": "oncoming_police_car", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿš•": { - "name": "taxi", - "slug": "taxi", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿš–": { - "name": "oncoming taxi", - "slug": "oncoming_taxi", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿš—": { - "name": "automobile", - "slug": "automobile", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿš˜": { - "name": "oncoming automobile", - "slug": "oncoming_automobile", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿš™": { - "name": "sport utility vehicle", - "slug": "sport_utility_vehicle", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ›ป": { - "name": "pickup truck", - "slug": "pickup_truck", - "group": "Travel & Places", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿšš": { - "name": "delivery truck", - "slug": "delivery_truck", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿš›": { - "name": "articulated lorry", - "slug": "articulated_lorry", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿšœ": { - "name": "tractor", - "slug": "tractor", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐ŸŽ๏ธ": { - "name": "racing car", - "slug": "racing_car", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ๏ธ": { - "name": "motorcycle", - "slug": "motorcycle", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ›ต": { - "name": "motor scooter", - "slug": "motor_scooter", - "group": "Travel & Places", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿฆฝ": { - "name": "manual wheelchair", - "slug": "manual_wheelchair", - "group": "Travel & Places", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿฆผ": { - "name": "motorized wheelchair", - "slug": "motorized_wheelchair", - "group": "Travel & Places", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿ›บ": { - "name": "auto rickshaw", - "slug": "auto_rickshaw", - "group": "Travel & Places", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿšฒ": { - "name": "bicycle", - "slug": "bicycle", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ›ด": { - "name": "kick scooter", - "slug": "kick_scooter", - "group": "Travel & Places", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿ›น": { - "name": "skateboard", - "slug": "skateboard", - "group": "Travel & Places", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿ›ผ": { - "name": "roller skate", - "slug": "roller_skate", - "group": "Travel & Places", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿš": { - "name": "bus stop", - "slug": "bus_stop", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ›ฃ๏ธ": { - "name": "motorway", - "slug": "motorway", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ›ค๏ธ": { - "name": "railway track", - "slug": "railway_track", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ›ข๏ธ": { - "name": "oil drum", - "slug": "oil_drum", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "โ›ฝ": { - "name": "fuel pump", - "slug": "fuel_pump", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ›ž": { - "name": "wheel", - "slug": "wheel", - "group": "Travel & Places", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "๐Ÿšจ": { - "name": "police car light", - "slug": "police_car_light", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿšฅ": { - "name": "horizontal traffic light", - "slug": "horizontal_traffic_light", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿšฆ": { - "name": "vertical traffic light", - "slug": "vertical_traffic_light", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ›‘": { - "name": "stop sign", - "slug": "stop_sign", - "group": "Travel & Places", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿšง": { - "name": "construction", - "slug": "construction", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โš“": { - "name": "anchor", - "slug": "anchor", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ›Ÿ": { - "name": "ring buoy", - "slug": "ring_buoy", - "group": "Travel & Places", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "โ›ต": { - "name": "sailboat", - "slug": "sailboat", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ›ถ": { - "name": "canoe", - "slug": "canoe", - "group": "Travel & Places", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿšค": { - "name": "speedboat", - "slug": "speedboat", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ›ณ๏ธ": { - "name": "passenger ship", - "slug": "passenger_ship", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "โ›ด๏ธ": { - "name": "ferry", - "slug": "ferry", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ›ฅ๏ธ": { - "name": "motor boat", - "slug": "motor_boat", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿšข": { - "name": "ship", - "slug": "ship", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โœˆ๏ธ": { - "name": "airplane", - "slug": "airplane", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ›ฉ๏ธ": { - "name": "small airplane", - "slug": "small_airplane", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ›ซ": { - "name": "airplane departure", - "slug": "airplane_departure", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ›ฌ": { - "name": "airplane arrival", - "slug": "airplane_arrival", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿช‚": { - "name": "parachute", - "slug": "parachute", - "group": "Travel & Places", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿ’บ": { - "name": "seat", - "slug": "seat", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿš": { - "name": "helicopter", - "slug": "helicopter", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐ŸšŸ": { - "name": "suspension railway", - "slug": "suspension_railway", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿš ": { - "name": "mountain cableway", - "slug": "mountain_cableway", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿšก": { - "name": "aerial tramway", - "slug": "aerial_tramway", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ›ฐ๏ธ": { - "name": "satellite", - "slug": "satellite", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿš€": { - "name": "rocket", - "slug": "rocket", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ›ธ": { - "name": "flying saucer", - "slug": "flying_saucer", - "group": "Travel & Places", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿ›Ž๏ธ": { - "name": "bellhop bell", - "slug": "bellhop_bell", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿงณ": { - "name": "luggage", - "slug": "luggage", - "group": "Travel & Places", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "โŒ›": { - "name": "hourglass done", - "slug": "hourglass_done", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โณ": { - "name": "hourglass not done", - "slug": "hourglass_not_done", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โŒš": { - "name": "watch", - "slug": "watch", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โฐ": { - "name": "alarm clock", - "slug": "alarm_clock", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โฑ๏ธ": { - "name": "stopwatch", - "slug": "stopwatch", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "โฒ๏ธ": { - "name": "timer clock", - "slug": "timer_clock", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ•ฐ๏ธ": { - "name": "mantelpiece clock", - "slug": "mantelpiece_clock", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ•›": { - "name": "twelve oโ€™clock", - "slug": "twelve_o_clock", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ•ง": { - "name": "twelve-thirty", - "slug": "twelve_thirty", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ•": { - "name": "one oโ€™clock", - "slug": "one_o_clock", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ•œ": { - "name": "one-thirty", - "slug": "one_thirty", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ•‘": { - "name": "two oโ€™clock", - "slug": "two_o_clock", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ•": { - "name": "two-thirty", - "slug": "two_thirty", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ•’": { - "name": "three oโ€™clock", - "slug": "three_o_clock", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ•ž": { - "name": "three-thirty", - "slug": "three_thirty", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ•“": { - "name": "four oโ€™clock", - "slug": "four_o_clock", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ•Ÿ": { - "name": "four-thirty", - "slug": "four_thirty", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ•”": { - "name": "five oโ€™clock", - "slug": "five_o_clock", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ• ": { - "name": "five-thirty", - "slug": "five_thirty", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ••": { - "name": "six oโ€™clock", - "slug": "six_o_clock", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ•ก": { - "name": "six-thirty", - "slug": "six_thirty", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ•–": { - "name": "seven oโ€™clock", - "slug": "seven_o_clock", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ•ข": { - "name": "seven-thirty", - "slug": "seven_thirty", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ•—": { - "name": "eight oโ€™clock", - "slug": "eight_o_clock", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ•ฃ": { - "name": "eight-thirty", - "slug": "eight_thirty", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ•˜": { - "name": "nine oโ€™clock", - "slug": "nine_o_clock", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ•ค": { - "name": "nine-thirty", - "slug": "nine_thirty", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ•™": { - "name": "ten oโ€™clock", - "slug": "ten_o_clock", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ•ฅ": { - "name": "ten-thirty", - "slug": "ten_thirty", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ•š": { - "name": "eleven oโ€™clock", - "slug": "eleven_o_clock", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ•ฆ": { - "name": "eleven-thirty", - "slug": "eleven_thirty", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐ŸŒ‘": { - "name": "new moon", - "slug": "new_moon", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŒ’": { - "name": "waxing crescent moon", - "slug": "waxing_crescent_moon", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐ŸŒ“": { - "name": "first quarter moon", - "slug": "first_quarter_moon", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŒ”": { - "name": "waxing gibbous moon", - "slug": "waxing_gibbous_moon", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŒ•": { - "name": "full moon", - "slug": "full_moon", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŒ–": { - "name": "waning gibbous moon", - "slug": "waning_gibbous_moon", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐ŸŒ—": { - "name": "last quarter moon", - "slug": "last_quarter_moon", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐ŸŒ˜": { - "name": "waning crescent moon", - "slug": "waning_crescent_moon", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐ŸŒ™": { - "name": "crescent moon", - "slug": "crescent_moon", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŒš": { - "name": "new moon face", - "slug": "new_moon_face", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐ŸŒ›": { - "name": "first quarter moon face", - "slug": "first_quarter_moon_face", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŒœ": { - "name": "last quarter moon face", - "slug": "last_quarter_moon_face", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐ŸŒก๏ธ": { - "name": "thermometer", - "slug": "thermometer", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "โ˜€๏ธ": { - "name": "sun", - "slug": "sun", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŒ": { - "name": "full moon face", - "slug": "full_moon_face", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐ŸŒž": { - "name": "sun with face", - "slug": "sun_with_face", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿช": { - "name": "ringed planet", - "slug": "ringed_planet", - "group": "Travel & Places", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "โญ": { - "name": "star", - "slug": "star", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŒŸ": { - "name": "glowing star", - "slug": "glowing_star", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŒ ": { - "name": "shooting star", - "slug": "shooting_star", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŒŒ": { - "name": "milky way", - "slug": "milky_way", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ˜๏ธ": { - "name": "cloud", - "slug": "cloud", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ›…": { - "name": "sun behind cloud", - "slug": "sun_behind_cloud", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ›ˆ๏ธ": { - "name": "cloud with lightning and rain", - "slug": "cloud_with_lightning_and_rain", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐ŸŒค๏ธ": { - "name": "sun behind small cloud", - "slug": "sun_behind_small_cloud", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐ŸŒฅ๏ธ": { - "name": "sun behind large cloud", - "slug": "sun_behind_large_cloud", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐ŸŒฆ๏ธ": { - "name": "sun behind rain cloud", - "slug": "sun_behind_rain_cloud", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐ŸŒง๏ธ": { - "name": "cloud with rain", - "slug": "cloud_with_rain", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐ŸŒจ๏ธ": { - "name": "cloud with snow", - "slug": "cloud_with_snow", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐ŸŒฉ๏ธ": { - "name": "cloud with lightning", - "slug": "cloud_with_lightning", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐ŸŒช๏ธ": { - "name": "tornado", - "slug": "tornado", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐ŸŒซ๏ธ": { - "name": "fog", - "slug": "fog", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐ŸŒฌ๏ธ": { - "name": "wind face", - "slug": "wind_face", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐ŸŒ€": { - "name": "cyclone", - "slug": "cyclone", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŒˆ": { - "name": "rainbow", - "slug": "rainbow", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŒ‚": { - "name": "closed umbrella", - "slug": "closed_umbrella", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ˜‚๏ธ": { - "name": "umbrella", - "slug": "umbrella", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "โ˜”": { - "name": "umbrella with rain drops", - "slug": "umbrella_with_rain_drops", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ›ฑ๏ธ": { - "name": "umbrella on ground", - "slug": "umbrella_on_ground", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "โšก": { - "name": "high voltage", - "slug": "high_voltage", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ„๏ธ": { - "name": "snowflake", - "slug": "snowflake", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ˜ƒ๏ธ": { - "name": "snowman", - "slug": "snowman", - "group": "Travel & Places", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "โ›„": { - "name": "snowman without snow", - "slug": "snowman_without_snow", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ˜„๏ธ": { - "name": "comet", - "slug": "comet", - "group": "Travel & Places", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ”ฅ": { - "name": "fire", - "slug": "fire", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’ง": { - "name": "droplet", - "slug": "droplet", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŒŠ": { - "name": "water wave", - "slug": "water_wave", - "group": "Travel & Places", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽƒ": { - "name": "jack-o-lantern", - "slug": "jack_o_lantern", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽ„": { - "name": "Christmas tree", - "slug": "christmas_tree", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽ†": { - "name": "fireworks", - "slug": "fireworks", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽ‡": { - "name": "sparkler", - "slug": "sparkler", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿงจ": { - "name": "firecracker", - "slug": "firecracker", - "group": "Activities", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "โœจ": { - "name": "sparkles", - "slug": "sparkles", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽˆ": { - "name": "balloon", - "slug": "balloon", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽ‰": { - "name": "party popper", - "slug": "party_popper", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽŠ": { - "name": "confetti ball", - "slug": "confetti_ball", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽ‹": { - "name": "tanabata tree", - "slug": "tanabata_tree", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽ": { - "name": "pine decoration", - "slug": "pine_decoration", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽŽ": { - "name": "Japanese dolls", - "slug": "japanese_dolls", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽ": { - "name": "carp streamer", - "slug": "carp_streamer", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽ": { - "name": "wind chime", - "slug": "wind_chime", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽ‘": { - "name": "moon viewing ceremony", - "slug": "moon_viewing_ceremony", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿงง": { - "name": "red envelope", - "slug": "red_envelope", - "group": "Activities", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐ŸŽ€": { - "name": "ribbon", - "slug": "ribbon", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽ": { - "name": "wrapped gift", - "slug": "wrapped_gift", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽ—๏ธ": { - "name": "reminder ribbon", - "slug": "reminder_ribbon", - "group": "Activities", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐ŸŽŸ๏ธ": { - "name": "admission tickets", - "slug": "admission_tickets", - "group": "Activities", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐ŸŽซ": { - "name": "ticket", - "slug": "ticket", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽ–๏ธ": { - "name": "military medal", - "slug": "military_medal", - "group": "Activities", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ†": { - "name": "trophy", - "slug": "trophy", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ…": { - "name": "sports medal", - "slug": "sports_medal", - "group": "Activities", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿฅ‡": { - "name": "1st place medal", - "slug": "1st_place_medal", - "group": "Activities", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿฅˆ": { - "name": "2nd place medal", - "slug": "2nd_place_medal", - "group": "Activities", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿฅ‰": { - "name": "3rd place medal", - "slug": "3rd_place_medal", - "group": "Activities", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "โšฝ": { - "name": "soccer ball", - "slug": "soccer_ball", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โšพ": { - "name": "baseball", - "slug": "baseball", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸฅŽ": { - "name": "softball", - "slug": "softball", - "group": "Activities", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿ€": { - "name": "basketball", - "slug": "basketball", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ": { - "name": "volleyball", - "slug": "volleyball", - "group": "Activities", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿˆ": { - "name": "american football", - "slug": "american_football", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‰": { - "name": "rugby football", - "slug": "rugby_football", - "group": "Activities", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐ŸŽพ": { - "name": "tennis", - "slug": "tennis", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฅ": { - "name": "flying disc", - "slug": "flying_disc", - "group": "Activities", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐ŸŽณ": { - "name": "bowling", - "slug": "bowling", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ": { - "name": "cricket game", - "slug": "cricket_game", - "group": "Activities", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ‘": { - "name": "field hockey", - "slug": "field_hockey", - "group": "Activities", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ’": { - "name": "ice hockey", - "slug": "ice_hockey", - "group": "Activities", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿฅ": { - "name": "lacrosse", - "slug": "lacrosse", - "group": "Activities", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿ“": { - "name": "ping pong", - "slug": "ping_pong", - "group": "Activities", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿธ": { - "name": "badminton", - "slug": "badminton", - "group": "Activities", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐ŸฅŠ": { - "name": "boxing glove", - "slug": "boxing_glove", - "group": "Activities", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿฅ‹": { - "name": "martial arts uniform", - "slug": "martial_arts_uniform", - "group": "Activities", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿฅ…": { - "name": "goal net", - "slug": "goal_net", - "group": "Activities", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "โ›ณ": { - "name": "flag in hole", - "slug": "flag_in_hole", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ›ธ๏ธ": { - "name": "ice skate", - "slug": "ice_skate", - "group": "Activities", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐ŸŽฃ": { - "name": "fishing pole", - "slug": "fishing_pole", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿคฟ": { - "name": "diving mask", - "slug": "diving_mask", - "group": "Activities", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐ŸŽฝ": { - "name": "running shirt", - "slug": "running_shirt", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽฟ": { - "name": "skis", - "slug": "skis", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ›ท": { - "name": "sled", - "slug": "sled", - "group": "Activities", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐ŸฅŒ": { - "name": "curling stone", - "slug": "curling_stone", - "group": "Activities", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐ŸŽฏ": { - "name": "bullseye", - "slug": "bullseye", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿช€": { - "name": "yo-yo", - "slug": "yo_yo", - "group": "Activities", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿช": { - "name": "kite", - "slug": "kite", - "group": "Activities", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿ”ซ": { - "name": "water pistol", - "slug": "water_pistol", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽฑ": { - "name": "pool 8 ball", - "slug": "pool_8_ball", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”ฎ": { - "name": "crystal ball", - "slug": "crystal_ball", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿช„": { - "name": "magic wand", - "slug": "magic_wand", - "group": "Activities", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐ŸŽฎ": { - "name": "video game", - "slug": "video_game", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ•น๏ธ": { - "name": "joystick", - "slug": "joystick", - "group": "Activities", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐ŸŽฐ": { - "name": "slot machine", - "slug": "slot_machine", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽฒ": { - "name": "game die", - "slug": "game_die", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿงฉ": { - "name": "puzzle piece", - "slug": "puzzle_piece", - "group": "Activities", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿงธ": { - "name": "teddy bear", - "slug": "teddy_bear", - "group": "Activities", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿช…": { - "name": "piรฑata", - "slug": "pinata", - "group": "Activities", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿชฉ": { - "name": "mirror ball", - "slug": "mirror_ball", - "group": "Activities", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "๐Ÿช†": { - "name": "nesting dolls", - "slug": "nesting_dolls", - "group": "Activities", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "โ™ ๏ธ": { - "name": "spade suit", - "slug": "spade_suit", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ™ฅ๏ธ": { - "name": "heart suit", - "slug": "heart_suit", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ™ฆ๏ธ": { - "name": "diamond suit", - "slug": "diamond_suit", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ™ฃ๏ธ": { - "name": "club suit", - "slug": "club_suit", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ™Ÿ๏ธ": { - "name": "chess pawn", - "slug": "chess_pawn", - "group": "Activities", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿƒ": { - "name": "joker", - "slug": "joker", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ€„": { - "name": "mahjong red dragon", - "slug": "mahjong_red_dragon", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽด": { - "name": "flower playing cards", - "slug": "flower_playing_cards", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽญ": { - "name": "performing arts", - "slug": "performing_arts", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ–ผ๏ธ": { - "name": "framed picture", - "slug": "framed_picture", - "group": "Activities", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐ŸŽจ": { - "name": "artist palette", - "slug": "artist_palette", - "group": "Activities", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿงต": { - "name": "thread", - "slug": "thread", - "group": "Activities", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿชก": { - "name": "sewing needle", - "slug": "sewing_needle", - "group": "Activities", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿงถ": { - "name": "yarn", - "slug": "yarn", - "group": "Activities", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿชข": { - "name": "knot", - "slug": "knot", - "group": "Activities", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿ‘“": { - "name": "glasses", - "slug": "glasses", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ•ถ๏ธ": { - "name": "sunglasses", - "slug": "sunglasses", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿฅฝ": { - "name": "goggles", - "slug": "goggles", - "group": "Objects", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿฅผ": { - "name": "lab coat", - "slug": "lab_coat", - "group": "Objects", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿฆบ": { - "name": "safety vest", - "slug": "safety_vest", - "group": "Objects", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿ‘”": { - "name": "necktie", - "slug": "necktie", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‘•": { - "name": "t-shirt", - "slug": "t_shirt", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‘–": { - "name": "jeans", - "slug": "jeans", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿงฃ": { - "name": "scarf", - "slug": "scarf", - "group": "Objects", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿงค": { - "name": "gloves", - "slug": "gloves", - "group": "Objects", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿงฅ": { - "name": "coat", - "slug": "coat", - "group": "Objects", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿงฆ": { - "name": "socks", - "slug": "socks", - "group": "Objects", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿ‘—": { - "name": "dress", - "slug": "dress", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‘˜": { - "name": "kimono", - "slug": "kimono", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฅป": { - "name": "sari", - "slug": "sari", - "group": "Objects", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿฉฑ": { - "name": "one-piece swimsuit", - "slug": "one_piece_swimsuit", - "group": "Objects", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿฉฒ": { - "name": "briefs", - "slug": "briefs", - "group": "Objects", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿฉณ": { - "name": "shorts", - "slug": "shorts", - "group": "Objects", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿ‘™": { - "name": "bikini", - "slug": "bikini", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‘š": { - "name": "womanโ€™s clothes", - "slug": "woman_s_clothes", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿชญ": { - "name": "folding hand fan", - "slug": "folding_hand_fan", - "group": "Objects", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": false - }, - "๐Ÿ‘›": { - "name": "purse", - "slug": "purse", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‘œ": { - "name": "handbag", - "slug": "handbag", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‘": { - "name": "clutch bag", - "slug": "clutch_bag", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ›๏ธ": { - "name": "shopping bags", - "slug": "shopping_bags", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐ŸŽ’": { - "name": "backpack", - "slug": "backpack", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฉด": { - "name": "thong sandal", - "slug": "thong_sandal", - "group": "Objects", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿ‘ž": { - "name": "manโ€™s shoe", - "slug": "man_s_shoe", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‘Ÿ": { - "name": "running shoe", - "slug": "running_shoe", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฅพ": { - "name": "hiking boot", - "slug": "hiking_boot", - "group": "Objects", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿฅฟ": { - "name": "flat shoe", - "slug": "flat_shoe", - "group": "Objects", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿ‘ ": { - "name": "high-heeled shoe", - "slug": "high_heeled_shoe", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‘ก": { - "name": "womanโ€™s sandal", - "slug": "woman_s_sandal", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฉฐ": { - "name": "ballet shoes", - "slug": "ballet_shoes", - "group": "Objects", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿ‘ข": { - "name": "womanโ€™s boot", - "slug": "woman_s_boot", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿชฎ": { - "name": "hair pick", - "slug": "hair_pick", - "group": "Objects", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": false - }, - "๐Ÿ‘‘": { - "name": "crown", - "slug": "crown", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‘’": { - "name": "womanโ€™s hat", - "slug": "woman_s_hat", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽฉ": { - "name": "top hat", - "slug": "top_hat", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽ“": { - "name": "graduation cap", - "slug": "graduation_cap", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿงข": { - "name": "billed cap", - "slug": "billed_cap", - "group": "Objects", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿช–": { - "name": "military helmet", - "slug": "military_helmet", - "group": "Objects", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "โ›‘๏ธ": { - "name": "rescue workerโ€™s helmet", - "slug": "rescue_worker_s_helmet", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ“ฟ": { - "name": "prayer beads", - "slug": "prayer_beads", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ’„": { - "name": "lipstick", - "slug": "lipstick", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’": { - "name": "ring", - "slug": "ring", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’Ž": { - "name": "gem stone", - "slug": "gem_stone", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”‡": { - "name": "muted speaker", - "slug": "muted_speaker", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ”ˆ": { - "name": "speaker low volume", - "slug": "speaker_low_volume", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ”‰": { - "name": "speaker medium volume", - "slug": "speaker_medium_volume", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ”Š": { - "name": "speaker high volume", - "slug": "speaker_high_volume", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“ข": { - "name": "loudspeaker", - "slug": "loudspeaker", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“ฃ": { - "name": "megaphone", - "slug": "megaphone", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“ฏ": { - "name": "postal horn", - "slug": "postal_horn", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ””": { - "name": "bell", - "slug": "bell", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”•": { - "name": "bell with slash", - "slug": "bell_with_slash", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐ŸŽผ": { - "name": "musical score", - "slug": "musical_score", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽต": { - "name": "musical note", - "slug": "musical_note", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽถ": { - "name": "musical notes", - "slug": "musical_notes", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽ™๏ธ": { - "name": "studio microphone", - "slug": "studio_microphone", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐ŸŽš๏ธ": { - "name": "level slider", - "slug": "level_slider", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐ŸŽ›๏ธ": { - "name": "control knobs", - "slug": "control_knobs", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐ŸŽค": { - "name": "microphone", - "slug": "microphone", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽง": { - "name": "headphone", - "slug": "headphone", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“ป": { - "name": "radio", - "slug": "radio", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽท": { - "name": "saxophone", - "slug": "saxophone", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿช—": { - "name": "accordion", - "slug": "accordion", - "group": "Objects", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐ŸŽธ": { - "name": "guitar", - "slug": "guitar", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽน": { - "name": "musical keyboard", - "slug": "musical_keyboard", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽบ": { - "name": "trumpet", - "slug": "trumpet", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽป": { - "name": "violin", - "slug": "violin", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿช•": { - "name": "banjo", - "slug": "banjo", - "group": "Objects", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿฅ": { - "name": "drum", - "slug": "drum", - "group": "Objects", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿช˜": { - "name": "long drum", - "slug": "long_drum", - "group": "Objects", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿช‡": { - "name": "maracas", - "slug": "maracas", - "group": "Objects", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": false - }, - "๐Ÿชˆ": { - "name": "flute", - "slug": "flute", - "group": "Objects", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": false - }, - "๐Ÿ“ฑ": { - "name": "mobile phone", - "slug": "mobile_phone", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“ฒ": { - "name": "mobile phone with arrow", - "slug": "mobile_phone_with_arrow", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ˜Ž๏ธ": { - "name": "telephone", - "slug": "telephone", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“ž": { - "name": "telephone receiver", - "slug": "telephone_receiver", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“Ÿ": { - "name": "pager", - "slug": "pager", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“ ": { - "name": "fax machine", - "slug": "fax_machine", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”‹": { - "name": "battery", - "slug": "battery", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿชซ": { - "name": "low battery", - "slug": "low_battery", - "group": "Objects", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "๐Ÿ”Œ": { - "name": "electric plug", - "slug": "electric_plug", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’ป": { - "name": "laptop", - "slug": "laptop", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ–ฅ๏ธ": { - "name": "desktop computer", - "slug": "desktop_computer", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ–จ๏ธ": { - "name": "printer", - "slug": "printer", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "โŒจ๏ธ": { - "name": "keyboard", - "slug": "keyboard", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ–ฑ๏ธ": { - "name": "computer mouse", - "slug": "computer_mouse", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ–ฒ๏ธ": { - "name": "trackball", - "slug": "trackball", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ’ฝ": { - "name": "computer disk", - "slug": "computer_disk", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’พ": { - "name": "floppy disk", - "slug": "floppy_disk", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’ฟ": { - "name": "optical disk", - "slug": "optical_disk", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“€": { - "name": "dvd", - "slug": "dvd", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿงฎ": { - "name": "abacus", - "slug": "abacus", - "group": "Objects", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐ŸŽฅ": { - "name": "movie camera", - "slug": "movie_camera", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽž๏ธ": { - "name": "film frames", - "slug": "film_frames", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ“ฝ๏ธ": { - "name": "film projector", - "slug": "film_projector", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐ŸŽฌ": { - "name": "clapper board", - "slug": "clapper_board", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“บ": { - "name": "television", - "slug": "television", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“ท": { - "name": "camera", - "slug": "camera", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“ธ": { - "name": "camera with flash", - "slug": "camera_with_flash", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ“น": { - "name": "video camera", - "slug": "video_camera", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“ผ": { - "name": "videocassette", - "slug": "videocassette", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”": { - "name": "magnifying glass tilted left", - "slug": "magnifying_glass_tilted_left", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”Ž": { - "name": "magnifying glass tilted right", - "slug": "magnifying_glass_tilted_right", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ•ฏ๏ธ": { - "name": "candle", - "slug": "candle", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ’ก": { - "name": "light bulb", - "slug": "light_bulb", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”ฆ": { - "name": "flashlight", - "slug": "flashlight", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฎ": { - "name": "red paper lantern", - "slug": "red_paper_lantern", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿช”": { - "name": "diya lamp", - "slug": "diya_lamp", - "group": "Objects", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿ“”": { - "name": "notebook with decorative cover", - "slug": "notebook_with_decorative_cover", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“•": { - "name": "closed book", - "slug": "closed_book", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“–": { - "name": "open book", - "slug": "open_book", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“—": { - "name": "green book", - "slug": "green_book", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“˜": { - "name": "blue book", - "slug": "blue_book", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“™": { - "name": "orange book", - "slug": "orange_book", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“š": { - "name": "books", - "slug": "books", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ““": { - "name": "notebook", - "slug": "notebook", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“’": { - "name": "ledger", - "slug": "ledger", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“ƒ": { - "name": "page with curl", - "slug": "page_with_curl", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“œ": { - "name": "scroll", - "slug": "scroll", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“„": { - "name": "page facing up", - "slug": "page_facing_up", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“ฐ": { - "name": "newspaper", - "slug": "newspaper", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ—ž๏ธ": { - "name": "rolled-up newspaper", - "slug": "rolled_up_newspaper", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ“‘": { - "name": "bookmark tabs", - "slug": "bookmark_tabs", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”–": { - "name": "bookmark", - "slug": "bookmark", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿท๏ธ": { - "name": "label", - "slug": "label", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ’ฐ": { - "name": "money bag", - "slug": "money_bag", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿช™": { - "name": "coin", - "slug": "coin", - "group": "Objects", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿ’ด": { - "name": "yen banknote", - "slug": "yen_banknote", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’ต": { - "name": "dollar banknote", - "slug": "dollar_banknote", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’ถ": { - "name": "euro banknote", - "slug": "euro_banknote", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ’ท": { - "name": "pound banknote", - "slug": "pound_banknote", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ’ธ": { - "name": "money with wings", - "slug": "money_with_wings", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’ณ": { - "name": "credit card", - "slug": "credit_card", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿงพ": { - "name": "receipt", - "slug": "receipt", - "group": "Objects", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿ’น": { - "name": "chart increasing with yen", - "slug": "chart_increasing_with_yen", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โœ‰๏ธ": { - "name": "envelope", - "slug": "envelope", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“ง": { - "name": "e-mail", - "slug": "e_mail", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“จ": { - "name": "incoming envelope", - "slug": "incoming_envelope", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“ฉ": { - "name": "envelope with arrow", - "slug": "envelope_with_arrow", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“ค": { - "name": "outbox tray", - "slug": "outbox_tray", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“ฅ": { - "name": "inbox tray", - "slug": "inbox_tray", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“ฆ": { - "name": "package", - "slug": "package", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“ซ": { - "name": "closed mailbox with raised flag", - "slug": "closed_mailbox_with_raised_flag", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“ช": { - "name": "closed mailbox with lowered flag", - "slug": "closed_mailbox_with_lowered_flag", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“ฌ": { - "name": "open mailbox with raised flag", - "slug": "open_mailbox_with_raised_flag", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ“ญ": { - "name": "open mailbox with lowered flag", - "slug": "open_mailbox_with_lowered_flag", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ“ฎ": { - "name": "postbox", - "slug": "postbox", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ—ณ๏ธ": { - "name": "ballot box with ballot", - "slug": "ballot_box_with_ballot", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "โœ๏ธ": { - "name": "pencil", - "slug": "pencil", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โœ’๏ธ": { - "name": "black nib", - "slug": "black_nib", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ–‹๏ธ": { - "name": "fountain pen", - "slug": "fountain_pen", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ–Š๏ธ": { - "name": "pen", - "slug": "pen", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ–Œ๏ธ": { - "name": "paintbrush", - "slug": "paintbrush", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ–๏ธ": { - "name": "crayon", - "slug": "crayon", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ“": { - "name": "memo", - "slug": "memo", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’ผ": { - "name": "briefcase", - "slug": "briefcase", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“": { - "name": "file folder", - "slug": "file_folder", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“‚": { - "name": "open file folder", - "slug": "open_file_folder", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ—‚๏ธ": { - "name": "card index dividers", - "slug": "card_index_dividers", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ“…": { - "name": "calendar", - "slug": "calendar", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“†": { - "name": "tear-off calendar", - "slug": "tear_off_calendar", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ—’๏ธ": { - "name": "spiral notepad", - "slug": "spiral_notepad", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ—“๏ธ": { - "name": "spiral calendar", - "slug": "spiral_calendar", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ“‡": { - "name": "card index", - "slug": "card_index", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“ˆ": { - "name": "chart increasing", - "slug": "chart_increasing", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“‰": { - "name": "chart decreasing", - "slug": "chart_decreasing", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“Š": { - "name": "bar chart", - "slug": "bar_chart", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“‹": { - "name": "clipboard", - "slug": "clipboard", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“Œ": { - "name": "pushpin", - "slug": "pushpin", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“": { - "name": "round pushpin", - "slug": "round_pushpin", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“Ž": { - "name": "paperclip", - "slug": "paperclip", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ–‡๏ธ": { - "name": "linked paperclips", - "slug": "linked_paperclips", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ“": { - "name": "straight ruler", - "slug": "straight_ruler", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“": { - "name": "triangular ruler", - "slug": "triangular_ruler", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โœ‚๏ธ": { - "name": "scissors", - "slug": "scissors", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ—ƒ๏ธ": { - "name": "card file box", - "slug": "card_file_box", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ—„๏ธ": { - "name": "file cabinet", - "slug": "file_cabinet", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ—‘๏ธ": { - "name": "wastebasket", - "slug": "wastebasket", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ”’": { - "name": "locked", - "slug": "locked", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”“": { - "name": "unlocked", - "slug": "unlocked", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”": { - "name": "locked with pen", - "slug": "locked_with_pen", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”": { - "name": "locked with key", - "slug": "locked_with_key", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”‘": { - "name": "key", - "slug": "key", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ—๏ธ": { - "name": "old key", - "slug": "old_key", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ”จ": { - "name": "hammer", - "slug": "hammer", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿช“": { - "name": "axe", - "slug": "axe", - "group": "Objects", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "โ›๏ธ": { - "name": "pick", - "slug": "pick", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "โš’๏ธ": { - "name": "hammer and pick", - "slug": "hammer_and_pick", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ› ๏ธ": { - "name": "hammer and wrench", - "slug": "hammer_and_wrench", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ—ก๏ธ": { - "name": "dagger", - "slug": "dagger", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "โš”๏ธ": { - "name": "crossed swords", - "slug": "crossed_swords", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ’ฃ": { - "name": "bomb", - "slug": "bomb", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿชƒ": { - "name": "boomerang", - "slug": "boomerang", - "group": "Objects", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿน": { - "name": "bow and arrow", - "slug": "bow_and_arrow", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ›ก๏ธ": { - "name": "shield", - "slug": "shield", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿชš": { - "name": "carpentry saw", - "slug": "carpentry_saw", - "group": "Objects", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿ”ง": { - "name": "wrench", - "slug": "wrench", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿช›": { - "name": "screwdriver", - "slug": "screwdriver", - "group": "Objects", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿ”ฉ": { - "name": "nut and bolt", - "slug": "nut_and_bolt", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โš™๏ธ": { - "name": "gear", - "slug": "gear", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ—œ๏ธ": { - "name": "clamp", - "slug": "clamp", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "โš–๏ธ": { - "name": "balance scale", - "slug": "balance_scale", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿฆฏ": { - "name": "white cane", - "slug": "white_cane", - "group": "Objects", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿ”—": { - "name": "link", - "slug": "link", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ›“๏ธโ€๐Ÿ’ฅ": { - "name": "broken chain", - "slug": "broken_chain", - "group": "Objects", - "emoji_version": "15.1", - "unicode_version": "15.1", - "skin_tone_support": false - }, - "โ›“๏ธ": { - "name": "chains", - "slug": "chains", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿช": { - "name": "hook", - "slug": "hook", - "group": "Objects", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿงฐ": { - "name": "toolbox", - "slug": "toolbox", - "group": "Objects", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿงฒ": { - "name": "magnet", - "slug": "magnet", - "group": "Objects", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿชœ": { - "name": "ladder", - "slug": "ladder", - "group": "Objects", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "โš—๏ธ": { - "name": "alembic", - "slug": "alembic", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿงช": { - "name": "test tube", - "slug": "test_tube", - "group": "Objects", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿงซ": { - "name": "petri dish", - "slug": "petri_dish", - "group": "Objects", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿงฌ": { - "name": "dna", - "slug": "dna", - "group": "Objects", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿ”ฌ": { - "name": "microscope", - "slug": "microscope", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ”ญ": { - "name": "telescope", - "slug": "telescope", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ“ก": { - "name": "satellite antenna", - "slug": "satellite_antenna", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’‰": { - "name": "syringe", - "slug": "syringe", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฉธ": { - "name": "drop of blood", - "slug": "drop_of_blood", - "group": "Objects", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿ’Š": { - "name": "pill", - "slug": "pill", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿฉน": { - "name": "adhesive bandage", - "slug": "adhesive_bandage", - "group": "Objects", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿฉผ": { - "name": "crutch", - "slug": "crutch", - "group": "Objects", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "๐Ÿฉบ": { - "name": "stethoscope", - "slug": "stethoscope", - "group": "Objects", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿฉป": { - "name": "x-ray", - "slug": "x_ray", - "group": "Objects", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "๐Ÿšช": { - "name": "door", - "slug": "door", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ›—": { - "name": "elevator", - "slug": "elevator", - "group": "Objects", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿชž": { - "name": "mirror", - "slug": "mirror", - "group": "Objects", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐ŸชŸ": { - "name": "window", - "slug": "window", - "group": "Objects", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿ›๏ธ": { - "name": "bed", - "slug": "bed", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ›‹๏ธ": { - "name": "couch and lamp", - "slug": "couch_and_lamp", - "group": "Objects", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿช‘": { - "name": "chair", - "slug": "chair", - "group": "Objects", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿšฝ": { - "name": "toilet", - "slug": "toilet", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿช ": { - "name": "plunger", - "slug": "plunger", - "group": "Objects", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿšฟ": { - "name": "shower", - "slug": "shower", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ›": { - "name": "bathtub", - "slug": "bathtub", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿชค": { - "name": "mouse trap", - "slug": "mouse_trap", - "group": "Objects", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿช’": { - "name": "razor", - "slug": "razor", - "group": "Objects", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿงด": { - "name": "lotion bottle", - "slug": "lotion_bottle", - "group": "Objects", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿงท": { - "name": "safety pin", - "slug": "safety_pin", - "group": "Objects", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿงน": { - "name": "broom", - "slug": "broom", - "group": "Objects", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿงบ": { - "name": "basket", - "slug": "basket", - "group": "Objects", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿงป": { - "name": "roll of paper", - "slug": "roll_of_paper", - "group": "Objects", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿชฃ": { - "name": "bucket", - "slug": "bucket", - "group": "Objects", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿงผ": { - "name": "soap", - "slug": "soap", - "group": "Objects", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿซง": { - "name": "bubbles", - "slug": "bubbles", - "group": "Objects", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "๐Ÿชฅ": { - "name": "toothbrush", - "slug": "toothbrush", - "group": "Objects", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿงฝ": { - "name": "sponge", - "slug": "sponge", - "group": "Objects", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿงฏ": { - "name": "fire extinguisher", - "slug": "fire_extinguisher", - "group": "Objects", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿ›’": { - "name": "shopping cart", - "slug": "shopping_cart", - "group": "Objects", - "emoji_version": "3.0", - "unicode_version": "3.0", - "skin_tone_support": false - }, - "๐Ÿšฌ": { - "name": "cigarette", - "slug": "cigarette", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โšฐ๏ธ": { - "name": "coffin", - "slug": "coffin", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿชฆ": { - "name": "headstone", - "slug": "headstone", - "group": "Objects", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "โšฑ๏ธ": { - "name": "funeral urn", - "slug": "funeral_urn", - "group": "Objects", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿงฟ": { - "name": "nazar amulet", - "slug": "nazar_amulet", - "group": "Objects", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿชฌ": { - "name": "hamsa", - "slug": "hamsa", - "group": "Objects", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "๐Ÿ—ฟ": { - "name": "moai", - "slug": "moai", - "group": "Objects", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿชง": { - "name": "placard", - "slug": "placard", - "group": "Objects", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿชช": { - "name": "identification card", - "slug": "identification_card", - "group": "Objects", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "๐Ÿง": { - "name": "ATM sign", - "slug": "atm_sign", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿšฎ": { - "name": "litter in bin sign", - "slug": "litter_in_bin_sign", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿšฐ": { - "name": "potable water", - "slug": "potable_water", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "โ™ฟ": { - "name": "wheelchair symbol", - "slug": "wheelchair_symbol", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿšน": { - "name": "menโ€™s room", - "slug": "men_s_room", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿšบ": { - "name": "womenโ€™s room", - "slug": "women_s_room", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿšป": { - "name": "restroom", - "slug": "restroom", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿšผ": { - "name": "baby symbol", - "slug": "baby_symbol", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿšพ": { - "name": "water closet", - "slug": "water_closet", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ›‚": { - "name": "passport control", - "slug": "passport_control", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ›ƒ": { - "name": "customs", - "slug": "customs", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ›„": { - "name": "baggage claim", - "slug": "baggage_claim", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ›…": { - "name": "left luggage", - "slug": "left_luggage", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "โš ๏ธ": { - "name": "warning", - "slug": "warning", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿšธ": { - "name": "children crossing", - "slug": "children_crossing", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "โ›”": { - "name": "no entry", - "slug": "no_entry", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿšซ": { - "name": "prohibited", - "slug": "prohibited", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿšณ": { - "name": "no bicycles", - "slug": "no_bicycles", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿšญ": { - "name": "no smoking", - "slug": "no_smoking", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿšฏ": { - "name": "no littering", - "slug": "no_littering", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿšฑ": { - "name": "non-potable water", - "slug": "non_potable_water", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿšท": { - "name": "no pedestrians", - "slug": "no_pedestrians", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ“ต": { - "name": "no mobile phones", - "slug": "no_mobile_phones", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ”ž": { - "name": "no one under eighteen", - "slug": "no_one_under_eighteen", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ˜ข๏ธ": { - "name": "radioactive", - "slug": "radioactive", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "โ˜ฃ๏ธ": { - "name": "biohazard", - "slug": "biohazard", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "โฌ†๏ธ": { - "name": "up arrow", - "slug": "up_arrow", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ†—๏ธ": { - "name": "up-right arrow", - "slug": "up_right_arrow", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โžก๏ธ": { - "name": "right arrow", - "slug": "right_arrow", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ†˜๏ธ": { - "name": "down-right arrow", - "slug": "down_right_arrow", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โฌ‡๏ธ": { - "name": "down arrow", - "slug": "down_arrow", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ†™๏ธ": { - "name": "down-left arrow", - "slug": "down_left_arrow", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โฌ…๏ธ": { - "name": "left arrow", - "slug": "left_arrow", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ†–๏ธ": { - "name": "up-left arrow", - "slug": "up_left_arrow", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ†•๏ธ": { - "name": "up-down arrow", - "slug": "up_down_arrow", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ†”๏ธ": { - "name": "left-right arrow", - "slug": "left_right_arrow", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ†ฉ๏ธ": { - "name": "right arrow curving left", - "slug": "right_arrow_curving_left", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ†ช๏ธ": { - "name": "left arrow curving right", - "slug": "left_arrow_curving_right", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โคด๏ธ": { - "name": "right arrow curving up", - "slug": "right_arrow_curving_up", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โคต๏ธ": { - "name": "right arrow curving down", - "slug": "right_arrow_curving_down", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”ƒ": { - "name": "clockwise vertical arrows", - "slug": "clockwise_vertical_arrows", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”„": { - "name": "counterclockwise arrows button", - "slug": "counterclockwise_arrows_button", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ”™": { - "name": "BACK arrow", - "slug": "back_arrow", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”š": { - "name": "END arrow", - "slug": "end_arrow", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”›": { - "name": "ON! arrow", - "slug": "on_arrow", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”œ": { - "name": "SOON arrow", - "slug": "soon_arrow", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”": { - "name": "TOP arrow", - "slug": "top_arrow", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ›": { - "name": "place of worship", - "slug": "place_of_worship", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "โš›๏ธ": { - "name": "atom symbol", - "slug": "atom_symbol", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ•‰๏ธ": { - "name": "om", - "slug": "om", - "group": "Symbols", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "โœก๏ธ": { - "name": "star of David", - "slug": "star_of_david", - "group": "Symbols", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "โ˜ธ๏ธ": { - "name": "wheel of dharma", - "slug": "wheel_of_dharma", - "group": "Symbols", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "โ˜ฏ๏ธ": { - "name": "yin yang", - "slug": "yin_yang", - "group": "Symbols", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "โœ๏ธ": { - "name": "latin cross", - "slug": "latin_cross", - "group": "Symbols", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "โ˜ฆ๏ธ": { - "name": "orthodox cross", - "slug": "orthodox_cross", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "โ˜ช๏ธ": { - "name": "star and crescent", - "slug": "star_and_crescent", - "group": "Symbols", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "โ˜ฎ๏ธ": { - "name": "peace symbol", - "slug": "peace_symbol", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ•Ž": { - "name": "menorah", - "slug": "menorah", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ”ฏ": { - "name": "dotted six-pointed star", - "slug": "dotted_six_pointed_star", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿชฏ": { - "name": "khanda", - "slug": "khanda", - "group": "Symbols", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": false - }, - "โ™ˆ": { - "name": "Aries", - "slug": "aries", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ™‰": { - "name": "Taurus", - "slug": "taurus", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ™Š": { - "name": "Gemini", - "slug": "gemini", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ™‹": { - "name": "Cancer", - "slug": "cancer", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ™Œ": { - "name": "Leo", - "slug": "leo", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ™": { - "name": "Virgo", - "slug": "virgo", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ™Ž": { - "name": "Libra", - "slug": "libra", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ™": { - "name": "Scorpio", - "slug": "scorpio", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ™": { - "name": "Sagittarius", - "slug": "sagittarius", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ™‘": { - "name": "Capricorn", - "slug": "capricorn", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ™’": { - "name": "Aquarius", - "slug": "aquarius", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ™“": { - "name": "Pisces", - "slug": "pisces", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ›Ž": { - "name": "Ophiuchus", - "slug": "ophiuchus", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”€": { - "name": "shuffle tracks button", - "slug": "shuffle_tracks_button", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ”": { - "name": "repeat button", - "slug": "repeat_button", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ”‚": { - "name": "repeat single button", - "slug": "repeat_single_button", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "โ–ถ๏ธ": { - "name": "play button", - "slug": "play_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โฉ": { - "name": "fast-forward button", - "slug": "fast_forward_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โญ๏ธ": { - "name": "next track button", - "slug": "next_track_button", - "group": "Symbols", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "โฏ๏ธ": { - "name": "play or pause button", - "slug": "play_or_pause_button", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "โ—€๏ธ": { - "name": "reverse button", - "slug": "reverse_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โช": { - "name": "fast reverse button", - "slug": "fast_reverse_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โฎ๏ธ": { - "name": "last track button", - "slug": "last_track_button", - "group": "Symbols", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿ”ผ": { - "name": "upwards button", - "slug": "upwards_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โซ": { - "name": "fast up button", - "slug": "fast_up_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”ฝ": { - "name": "downwards button", - "slug": "downwards_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โฌ": { - "name": "fast down button", - "slug": "fast_down_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โธ๏ธ": { - "name": "pause button", - "slug": "pause_button", - "group": "Symbols", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "โน๏ธ": { - "name": "stop button", - "slug": "stop_button", - "group": "Symbols", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "โบ๏ธ": { - "name": "record button", - "slug": "record_button", - "group": "Symbols", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "โ๏ธ": { - "name": "eject button", - "slug": "eject_button", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐ŸŽฆ": { - "name": "cinema", - "slug": "cinema", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”…": { - "name": "dim button", - "slug": "dim_button", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ”†": { - "name": "bright button", - "slug": "bright_button", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ“ถ": { - "name": "antenna bars", - "slug": "antenna_bars", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ›œ": { - "name": "wireless", - "slug": "wireless", - "group": "Symbols", - "emoji_version": "15.0", - "unicode_version": "15.0", - "skin_tone_support": false - }, - "๐Ÿ“ณ": { - "name": "vibration mode", - "slug": "vibration_mode", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“ด": { - "name": "mobile phone off", - "slug": "mobile_phone_off", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ™€๏ธ": { - "name": "female sign", - "slug": "female_sign", - "group": "Symbols", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": false - }, - "โ™‚๏ธ": { - "name": "male sign", - "slug": "male_sign", - "group": "Symbols", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": false - }, - "โšง๏ธ": { - "name": "transgender symbol", - "slug": "transgender_symbol", - "group": "Symbols", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "โœ–๏ธ": { - "name": "multiply", - "slug": "multiply", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โž•": { - "name": "plus", - "slug": "plus", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โž–": { - "name": "minus", - "slug": "minus", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โž—": { - "name": "divide", - "slug": "divide", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŸฐ": { - "name": "heavy equals sign", - "slug": "heavy_equals_sign", - "group": "Symbols", - "emoji_version": "14.0", - "unicode_version": "14.0", - "skin_tone_support": false - }, - "โ™พ๏ธ": { - "name": "infinity", - "slug": "infinity", - "group": "Symbols", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "โ€ผ๏ธ": { - "name": "double exclamation mark", - "slug": "double_exclamation_mark", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ‰๏ธ": { - "name": "exclamation question mark", - "slug": "exclamation_question_mark", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ“": { - "name": "red question mark", - "slug": "red_question_mark", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ”": { - "name": "white question mark", - "slug": "white_question_mark", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ•": { - "name": "white exclamation mark", - "slug": "white_exclamation_mark", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ—": { - "name": "red exclamation mark", - "slug": "red_exclamation_mark", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "ใ€ฐ๏ธ": { - "name": "wavy dash", - "slug": "wavy_dash", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’ฑ": { - "name": "currency exchange", - "slug": "currency_exchange", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’ฒ": { - "name": "heavy dollar sign", - "slug": "heavy_dollar_sign", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โš•๏ธ": { - "name": "medical symbol", - "slug": "medical_symbol", - "group": "Symbols", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": false - }, - "โ™ป๏ธ": { - "name": "recycling symbol", - "slug": "recycling_symbol", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โšœ๏ธ": { - "name": "fleur-de-lis", - "slug": "fleur_de_lis", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿ”ฑ": { - "name": "trident emblem", - "slug": "trident_emblem", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ“›": { - "name": "name badge", - "slug": "name_badge", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”ฐ": { - "name": "Japanese symbol for beginner", - "slug": "japanese_symbol_for_beginner", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โญ•": { - "name": "hollow red circle", - "slug": "hollow_red_circle", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โœ…": { - "name": "check mark button", - "slug": "check_mark_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ˜‘๏ธ": { - "name": "check box with check", - "slug": "check_box_with_check", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โœ”๏ธ": { - "name": "check mark", - "slug": "check_mark", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โŒ": { - "name": "cross mark", - "slug": "cross_mark", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โŽ": { - "name": "cross mark button", - "slug": "cross_mark_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โžฐ": { - "name": "curly loop", - "slug": "curly_loop", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โžฟ": { - "name": "double curly loop", - "slug": "double_curly_loop", - "group": "Symbols", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "ใ€ฝ๏ธ": { - "name": "part alternation mark", - "slug": "part_alternation_mark", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โœณ๏ธ": { - "name": "eight-spoked asterisk", - "slug": "eight_spoked_asterisk", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โœด๏ธ": { - "name": "eight-pointed star", - "slug": "eight_pointed_star", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ‡๏ธ": { - "name": "sparkle", - "slug": "sparkle", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "ยฉ๏ธ": { - "name": "copyright", - "slug": "copyright", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "ยฎ๏ธ": { - "name": "registered", - "slug": "registered", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ„ข๏ธ": { - "name": "trade mark", - "slug": "trade_mark", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "#๏ธโƒฃ": { - "name": "keycap #", - "slug": "keycap_number_sign", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "*๏ธโƒฃ": { - "name": "keycap *", - "slug": "keycap_asterisk", - "group": "Symbols", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "0๏ธโƒฃ": { - "name": "keycap 0", - "slug": "keycap_0", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "1๏ธโƒฃ": { - "name": "keycap 1", - "slug": "keycap_1", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "2๏ธโƒฃ": { - "name": "keycap 2", - "slug": "keycap_2", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "3๏ธโƒฃ": { - "name": "keycap 3", - "slug": "keycap_3", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "4๏ธโƒฃ": { - "name": "keycap 4", - "slug": "keycap_4", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "5๏ธโƒฃ": { - "name": "keycap 5", - "slug": "keycap_5", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "6๏ธโƒฃ": { - "name": "keycap 6", - "slug": "keycap_6", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "7๏ธโƒฃ": { - "name": "keycap 7", - "slug": "keycap_7", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "8๏ธโƒฃ": { - "name": "keycap 8", - "slug": "keycap_8", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "9๏ธโƒฃ": { - "name": "keycap 9", - "slug": "keycap_9", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”Ÿ": { - "name": "keycap 10", - "slug": "keycap_10", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ” ": { - "name": "input latin uppercase", - "slug": "input_latin_uppercase", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”ก": { - "name": "input latin lowercase", - "slug": "input_latin_lowercase", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”ข": { - "name": "input numbers", - "slug": "input_numbers", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”ฃ": { - "name": "input symbols", - "slug": "input_symbols", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”ค": { - "name": "input latin letters", - "slug": "input_latin_letters", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ…ฐ๏ธ": { - "name": "A button (blood type)", - "slug": "a_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ†Ž": { - "name": "AB button (blood type)", - "slug": "ab_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ…ฑ๏ธ": { - "name": "B button (blood type)", - "slug": "b_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ†‘": { - "name": "CL button", - "slug": "cl_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ†’": { - "name": "COOL button", - "slug": "cool_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ†“": { - "name": "FREE button", - "slug": "free_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ„น๏ธ": { - "name": "information", - "slug": "information", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ†”": { - "name": "ID button", - "slug": "id_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ“‚๏ธ": { - "name": "circled M", - "slug": "circled_m", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ†•": { - "name": "NEW button", - "slug": "new_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ†–": { - "name": "NG button", - "slug": "ng_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ…พ๏ธ": { - "name": "O button (blood type)", - "slug": "o_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ†—": { - "name": "OK button", - "slug": "ok_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ…ฟ๏ธ": { - "name": "P button", - "slug": "p_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ†˜": { - "name": "SOS button", - "slug": "sos_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ†™": { - "name": "UP! button", - "slug": "up_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ†š": { - "name": "VS button", - "slug": "vs_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿˆ": { - "name": "Japanese โ€œhereโ€ button", - "slug": "japanese_here_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿˆ‚๏ธ": { - "name": "Japanese โ€œservice chargeโ€ button", - "slug": "japanese_service_charge_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿˆท๏ธ": { - "name": "Japanese โ€œmonthly amountโ€ button", - "slug": "japanese_monthly_amount_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿˆถ": { - "name": "Japanese โ€œnot free of chargeโ€ button", - "slug": "japanese_not_free_of_charge_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿˆฏ": { - "name": "Japanese โ€œreservedโ€ button", - "slug": "japanese_reserved_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‰": { - "name": "Japanese โ€œbargainโ€ button", - "slug": "japanese_bargain_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿˆน": { - "name": "Japanese โ€œdiscountโ€ button", - "slug": "japanese_discount_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿˆš": { - "name": "Japanese โ€œfree of chargeโ€ button", - "slug": "japanese_free_of_charge_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿˆฒ": { - "name": "Japanese โ€œprohibitedโ€ button", - "slug": "japanese_prohibited_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‰‘": { - "name": "Japanese โ€œacceptableโ€ button", - "slug": "japanese_acceptable_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿˆธ": { - "name": "Japanese โ€œapplicationโ€ button", - "slug": "japanese_application_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿˆด": { - "name": "Japanese โ€œpassing gradeโ€ button", - "slug": "japanese_passing_grade_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿˆณ": { - "name": "Japanese โ€œvacancyโ€ button", - "slug": "japanese_vacancy_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "ใŠ—๏ธ": { - "name": "Japanese โ€œcongratulationsโ€ button", - "slug": "japanese_congratulations_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "ใŠ™๏ธ": { - "name": "Japanese โ€œsecretโ€ button", - "slug": "japanese_secret_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿˆบ": { - "name": "Japanese โ€œopen for businessโ€ button", - "slug": "japanese_open_for_business_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿˆต": { - "name": "Japanese โ€œno vacancyโ€ button", - "slug": "japanese_no_vacancy_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”ด": { - "name": "red circle", - "slug": "red_circle", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŸ ": { - "name": "orange circle", - "slug": "orange_circle", - "group": "Symbols", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐ŸŸก": { - "name": "yellow circle", - "slug": "yellow_circle", - "group": "Symbols", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐ŸŸข": { - "name": "green circle", - "slug": "green_circle", - "group": "Symbols", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐Ÿ”ต": { - "name": "blue circle", - "slug": "blue_circle", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŸฃ": { - "name": "purple circle", - "slug": "purple_circle", - "group": "Symbols", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐ŸŸค": { - "name": "brown circle", - "slug": "brown_circle", - "group": "Symbols", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "โšซ": { - "name": "black circle", - "slug": "black_circle", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โšช": { - "name": "white circle", - "slug": "white_circle", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŸฅ": { - "name": "red square", - "slug": "red_square", - "group": "Symbols", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐ŸŸง": { - "name": "orange square", - "slug": "orange_square", - "group": "Symbols", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐ŸŸจ": { - "name": "yellow square", - "slug": "yellow_square", - "group": "Symbols", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐ŸŸฉ": { - "name": "green square", - "slug": "green_square", - "group": "Symbols", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐ŸŸฆ": { - "name": "blue square", - "slug": "blue_square", - "group": "Symbols", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐ŸŸช": { - "name": "purple square", - "slug": "purple_square", - "group": "Symbols", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "๐ŸŸซ": { - "name": "brown square", - "slug": "brown_square", - "group": "Symbols", - "emoji_version": "12.0", - "unicode_version": "12.0", - "skin_tone_support": false - }, - "โฌ›": { - "name": "black large square", - "slug": "black_large_square", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โฌœ": { - "name": "white large square", - "slug": "white_large_square", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ—ผ๏ธ": { - "name": "black medium square", - "slug": "black_medium_square", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ—ป๏ธ": { - "name": "white medium square", - "slug": "white_medium_square", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ—พ": { - "name": "black medium-small square", - "slug": "black_medium_small_square", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ—ฝ": { - "name": "white medium-small square", - "slug": "white_medium_small_square", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ–ช๏ธ": { - "name": "black small square", - "slug": "black_small_square", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "โ–ซ๏ธ": { - "name": "white small square", - "slug": "white_small_square", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”ถ": { - "name": "large orange diamond", - "slug": "large_orange_diamond", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”ท": { - "name": "large blue diamond", - "slug": "large_blue_diamond", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”ธ": { - "name": "small orange diamond", - "slug": "small_orange_diamond", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”น": { - "name": "small blue diamond", - "slug": "small_blue_diamond", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”บ": { - "name": "red triangle pointed up", - "slug": "red_triangle_pointed_up", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”ป": { - "name": "red triangle pointed down", - "slug": "red_triangle_pointed_down", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ’ ": { - "name": "diamond with a dot", - "slug": "diamond_with_a_dot", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”˜": { - "name": "radio button", - "slug": "radio_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”ณ": { - "name": "white square button", - "slug": "white_square_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ”ฒ": { - "name": "black square button", - "slug": "black_square_button", - "group": "Symbols", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ": { - "name": "chequered flag", - "slug": "chequered_flag", - "group": "Flags", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿšฉ": { - "name": "triangular flag", - "slug": "triangular_flag", - "group": "Flags", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐ŸŽŒ": { - "name": "crossed flags", - "slug": "crossed_flags", - "group": "Flags", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿด": { - "name": "black flag", - "slug": "black_flag", - "group": "Flags", - "emoji_version": "1.0", - "unicode_version": "1.0", - "skin_tone_support": false - }, - "๐Ÿณ๏ธ": { - "name": "white flag", - "slug": "white_flag", - "group": "Flags", - "emoji_version": "0.7", - "unicode_version": "0.7", - "skin_tone_support": false - }, - "๐Ÿณ๏ธโ€๐ŸŒˆ": { - "name": "rainbow flag", - "slug": "rainbow_flag", - "group": "Flags", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": false - }, - "๐Ÿณ๏ธโ€โšง๏ธ": { - "name": "transgender flag", - "slug": "transgender_flag", - "group": "Flags", - "emoji_version": "13.0", - "unicode_version": "13.0", - "skin_tone_support": false - }, - "๐Ÿดโ€โ˜ ๏ธ": { - "name": "pirate flag", - "slug": "pirate_flag", - "group": "Flags", - "emoji_version": "11.0", - "unicode_version": "11.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฆ๐Ÿ‡จ": { - "name": "flag Ascension Island", - "slug": "flag_ascension_island", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฆ๐Ÿ‡ฉ": { - "name": "flag Andorra", - "slug": "flag_andorra", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฆ๐Ÿ‡ช": { - "name": "flag United Arab Emirates", - "slug": "flag_united_arab_emirates", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฆ๐Ÿ‡ซ": { - "name": "flag Afghanistan", - "slug": "flag_afghanistan", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฆ๐Ÿ‡ฌ": { - "name": "flag Antigua & Barbuda", - "slug": "flag_antigua_barbuda", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฆ๐Ÿ‡ฎ": { - "name": "flag Anguilla", - "slug": "flag_anguilla", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฆ๐Ÿ‡ฑ": { - "name": "flag Albania", - "slug": "flag_albania", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฆ๐Ÿ‡ฒ": { - "name": "flag Armenia", - "slug": "flag_armenia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฆ๐Ÿ‡ด": { - "name": "flag Angola", - "slug": "flag_angola", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฆ๐Ÿ‡ถ": { - "name": "flag Antarctica", - "slug": "flag_antarctica", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฆ๐Ÿ‡ท": { - "name": "flag Argentina", - "slug": "flag_argentina", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฆ๐Ÿ‡ธ": { - "name": "flag American Samoa", - "slug": "flag_american_samoa", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฆ๐Ÿ‡น": { - "name": "flag Austria", - "slug": "flag_austria", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฆ๐Ÿ‡บ": { - "name": "flag Australia", - "slug": "flag_australia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฆ๐Ÿ‡ผ": { - "name": "flag Aruba", - "slug": "flag_aruba", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฆ๐Ÿ‡ฝ": { - "name": "flag ร…land Islands", - "slug": "flag_aland_islands", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฆ๐Ÿ‡ฟ": { - "name": "flag Azerbaijan", - "slug": "flag_azerbaijan", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ง๐Ÿ‡ฆ": { - "name": "flag Bosnia & Herzegovina", - "slug": "flag_bosnia_herzegovina", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ง๐Ÿ‡ง": { - "name": "flag Barbados", - "slug": "flag_barbados", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ง๐Ÿ‡ฉ": { - "name": "flag Bangladesh", - "slug": "flag_bangladesh", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ง๐Ÿ‡ช": { - "name": "flag Belgium", - "slug": "flag_belgium", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ง๐Ÿ‡ซ": { - "name": "flag Burkina Faso", - "slug": "flag_burkina_faso", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ง๐Ÿ‡ฌ": { - "name": "flag Bulgaria", - "slug": "flag_bulgaria", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ง๐Ÿ‡ญ": { - "name": "flag Bahrain", - "slug": "flag_bahrain", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ง๐Ÿ‡ฎ": { - "name": "flag Burundi", - "slug": "flag_burundi", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ง๐Ÿ‡ฏ": { - "name": "flag Benin", - "slug": "flag_benin", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ง๐Ÿ‡ฑ": { - "name": "flag St. Barthรฉlemy", - "slug": "flag_st_barthelemy", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ง๐Ÿ‡ฒ": { - "name": "flag Bermuda", - "slug": "flag_bermuda", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ง๐Ÿ‡ณ": { - "name": "flag Brunei", - "slug": "flag_brunei", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ง๐Ÿ‡ด": { - "name": "flag Bolivia", - "slug": "flag_bolivia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ง๐Ÿ‡ถ": { - "name": "flag Caribbean Netherlands", - "slug": "flag_caribbean_netherlands", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ง๐Ÿ‡ท": { - "name": "flag Brazil", - "slug": "flag_brazil", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ง๐Ÿ‡ธ": { - "name": "flag Bahamas", - "slug": "flag_bahamas", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ง๐Ÿ‡น": { - "name": "flag Bhutan", - "slug": "flag_bhutan", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ง๐Ÿ‡ป": { - "name": "flag Bouvet Island", - "slug": "flag_bouvet_island", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ง๐Ÿ‡ผ": { - "name": "flag Botswana", - "slug": "flag_botswana", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ง๐Ÿ‡พ": { - "name": "flag Belarus", - "slug": "flag_belarus", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ง๐Ÿ‡ฟ": { - "name": "flag Belize", - "slug": "flag_belize", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡จ๐Ÿ‡ฆ": { - "name": "flag Canada", - "slug": "flag_canada", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡จ๐Ÿ‡จ": { - "name": "flag Cocos (Keeling) Islands", - "slug": "flag_cocos_islands", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡จ๐Ÿ‡ฉ": { - "name": "flag Congo - Kinshasa", - "slug": "flag_congo_kinshasa", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡จ๐Ÿ‡ซ": { - "name": "flag Central African Republic", - "slug": "flag_central_african_republic", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡จ๐Ÿ‡ฌ": { - "name": "flag Congo - Brazzaville", - "slug": "flag_congo_brazzaville", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡จ๐Ÿ‡ญ": { - "name": "flag Switzerland", - "slug": "flag_switzerland", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡จ๐Ÿ‡ฎ": { - "name": "flag Cรดte dโ€™Ivoire", - "slug": "flag_cote_d_ivoire", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡จ๐Ÿ‡ฐ": { - "name": "flag Cook Islands", - "slug": "flag_cook_islands", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡จ๐Ÿ‡ฑ": { - "name": "flag Chile", - "slug": "flag_chile", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡จ๐Ÿ‡ฒ": { - "name": "flag Cameroon", - "slug": "flag_cameroon", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡จ๐Ÿ‡ณ": { - "name": "flag China", - "slug": "flag_china", - "group": "Flags", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‡จ๐Ÿ‡ด": { - "name": "flag Colombia", - "slug": "flag_colombia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡จ๐Ÿ‡ต": { - "name": "flag Clipperton Island", - "slug": "flag_clipperton_island", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡จ๐Ÿ‡ท": { - "name": "flag Costa Rica", - "slug": "flag_costa_rica", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡จ๐Ÿ‡บ": { - "name": "flag Cuba", - "slug": "flag_cuba", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡จ๐Ÿ‡ป": { - "name": "flag Cape Verde", - "slug": "flag_cape_verde", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡จ๐Ÿ‡ผ": { - "name": "flag Curaรงao", - "slug": "flag_curacao", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡จ๐Ÿ‡ฝ": { - "name": "flag Christmas Island", - "slug": "flag_christmas_island", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡จ๐Ÿ‡พ": { - "name": "flag Cyprus", - "slug": "flag_cyprus", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡จ๐Ÿ‡ฟ": { - "name": "flag Czechia", - "slug": "flag_czechia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฉ๐Ÿ‡ช": { - "name": "flag Germany", - "slug": "flag_germany", - "group": "Flags", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‡ฉ๐Ÿ‡ฌ": { - "name": "flag Diego Garcia", - "slug": "flag_diego_garcia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฉ๐Ÿ‡ฏ": { - "name": "flag Djibouti", - "slug": "flag_djibouti", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฉ๐Ÿ‡ฐ": { - "name": "flag Denmark", - "slug": "flag_denmark", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฉ๐Ÿ‡ฒ": { - "name": "flag Dominica", - "slug": "flag_dominica", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฉ๐Ÿ‡ด": { - "name": "flag Dominican Republic", - "slug": "flag_dominican_republic", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฉ๐Ÿ‡ฟ": { - "name": "flag Algeria", - "slug": "flag_algeria", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ช๐Ÿ‡ฆ": { - "name": "flag Ceuta & Melilla", - "slug": "flag_ceuta_melilla", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ช๐Ÿ‡จ": { - "name": "flag Ecuador", - "slug": "flag_ecuador", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ช๐Ÿ‡ช": { - "name": "flag Estonia", - "slug": "flag_estonia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ช๐Ÿ‡ฌ": { - "name": "flag Egypt", - "slug": "flag_egypt", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ช๐Ÿ‡ญ": { - "name": "flag Western Sahara", - "slug": "flag_western_sahara", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ช๐Ÿ‡ท": { - "name": "flag Eritrea", - "slug": "flag_eritrea", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ช๐Ÿ‡ธ": { - "name": "flag Spain", - "slug": "flag_spain", - "group": "Flags", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‡ช๐Ÿ‡น": { - "name": "flag Ethiopia", - "slug": "flag_ethiopia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ช๐Ÿ‡บ": { - "name": "flag European Union", - "slug": "flag_european_union", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ซ๐Ÿ‡ฎ": { - "name": "flag Finland", - "slug": "flag_finland", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ซ๐Ÿ‡ฏ": { - "name": "flag Fiji", - "slug": "flag_fiji", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ซ๐Ÿ‡ฐ": { - "name": "flag Falkland Islands", - "slug": "flag_falkland_islands", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ซ๐Ÿ‡ฒ": { - "name": "flag Micronesia", - "slug": "flag_micronesia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ซ๐Ÿ‡ด": { - "name": "flag Faroe Islands", - "slug": "flag_faroe_islands", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ซ๐Ÿ‡ท": { - "name": "flag France", - "slug": "flag_france", - "group": "Flags", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‡ฌ๐Ÿ‡ฆ": { - "name": "flag Gabon", - "slug": "flag_gabon", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฌ๐Ÿ‡ง": { - "name": "flag United Kingdom", - "slug": "flag_united_kingdom", - "group": "Flags", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‡ฌ๐Ÿ‡ฉ": { - "name": "flag Grenada", - "slug": "flag_grenada", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฌ๐Ÿ‡ช": { - "name": "flag Georgia", - "slug": "flag_georgia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฌ๐Ÿ‡ซ": { - "name": "flag French Guiana", - "slug": "flag_french_guiana", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฌ๐Ÿ‡ฌ": { - "name": "flag Guernsey", - "slug": "flag_guernsey", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฌ๐Ÿ‡ญ": { - "name": "flag Ghana", - "slug": "flag_ghana", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฌ๐Ÿ‡ฎ": { - "name": "flag Gibraltar", - "slug": "flag_gibraltar", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฌ๐Ÿ‡ฑ": { - "name": "flag Greenland", - "slug": "flag_greenland", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฌ๐Ÿ‡ฒ": { - "name": "flag Gambia", - "slug": "flag_gambia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฌ๐Ÿ‡ณ": { - "name": "flag Guinea", - "slug": "flag_guinea", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฌ๐Ÿ‡ต": { - "name": "flag Guadeloupe", - "slug": "flag_guadeloupe", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฌ๐Ÿ‡ถ": { - "name": "flag Equatorial Guinea", - "slug": "flag_equatorial_guinea", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฌ๐Ÿ‡ท": { - "name": "flag Greece", - "slug": "flag_greece", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฌ๐Ÿ‡ธ": { - "name": "flag South Georgia & South Sandwich Islands", - "slug": "flag_south_georgia_south_sandwich_islands", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฌ๐Ÿ‡น": { - "name": "flag Guatemala", - "slug": "flag_guatemala", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฌ๐Ÿ‡บ": { - "name": "flag Guam", - "slug": "flag_guam", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฌ๐Ÿ‡ผ": { - "name": "flag Guinea-Bissau", - "slug": "flag_guinea_bissau", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฌ๐Ÿ‡พ": { - "name": "flag Guyana", - "slug": "flag_guyana", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ญ๐Ÿ‡ฐ": { - "name": "flag Hong Kong SAR China", - "slug": "flag_hong_kong_sar_china", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ญ๐Ÿ‡ฒ": { - "name": "flag Heard & McDonald Islands", - "slug": "flag_heard_mcdonald_islands", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ญ๐Ÿ‡ณ": { - "name": "flag Honduras", - "slug": "flag_honduras", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ญ๐Ÿ‡ท": { - "name": "flag Croatia", - "slug": "flag_croatia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ญ๐Ÿ‡น": { - "name": "flag Haiti", - "slug": "flag_haiti", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ญ๐Ÿ‡บ": { - "name": "flag Hungary", - "slug": "flag_hungary", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฎ๐Ÿ‡จ": { - "name": "flag Canary Islands", - "slug": "flag_canary_islands", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฎ๐Ÿ‡ฉ": { - "name": "flag Indonesia", - "slug": "flag_indonesia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฎ๐Ÿ‡ช": { - "name": "flag Ireland", - "slug": "flag_ireland", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฎ๐Ÿ‡ฑ": { - "name": "flag Israel", - "slug": "flag_israel", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฎ๐Ÿ‡ฒ": { - "name": "flag Isle of Man", - "slug": "flag_isle_of_man", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฎ๐Ÿ‡ณ": { - "name": "flag India", - "slug": "flag_india", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฎ๐Ÿ‡ด": { - "name": "flag British Indian Ocean Territory", - "slug": "flag_british_indian_ocean_territory", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฎ๐Ÿ‡ถ": { - "name": "flag Iraq", - "slug": "flag_iraq", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฎ๐Ÿ‡ท": { - "name": "flag Iran", - "slug": "flag_iran", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฎ๐Ÿ‡ธ": { - "name": "flag Iceland", - "slug": "flag_iceland", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฎ๐Ÿ‡น": { - "name": "flag Italy", - "slug": "flag_italy", - "group": "Flags", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‡ฏ๐Ÿ‡ช": { - "name": "flag Jersey", - "slug": "flag_jersey", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฏ๐Ÿ‡ฒ": { - "name": "flag Jamaica", - "slug": "flag_jamaica", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฏ๐Ÿ‡ด": { - "name": "flag Jordan", - "slug": "flag_jordan", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฏ๐Ÿ‡ต": { - "name": "flag Japan", - "slug": "flag_japan", - "group": "Flags", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‡ฐ๐Ÿ‡ช": { - "name": "flag Kenya", - "slug": "flag_kenya", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฐ๐Ÿ‡ฌ": { - "name": "flag Kyrgyzstan", - "slug": "flag_kyrgyzstan", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฐ๐Ÿ‡ญ": { - "name": "flag Cambodia", - "slug": "flag_cambodia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฐ๐Ÿ‡ฎ": { - "name": "flag Kiribati", - "slug": "flag_kiribati", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฐ๐Ÿ‡ฒ": { - "name": "flag Comoros", - "slug": "flag_comoros", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฐ๐Ÿ‡ณ": { - "name": "flag St. Kitts & Nevis", - "slug": "flag_st_kitts_nevis", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฐ๐Ÿ‡ต": { - "name": "flag North Korea", - "slug": "flag_north_korea", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฐ๐Ÿ‡ท": { - "name": "flag South Korea", - "slug": "flag_south_korea", - "group": "Flags", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‡ฐ๐Ÿ‡ผ": { - "name": "flag Kuwait", - "slug": "flag_kuwait", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฐ๐Ÿ‡พ": { - "name": "flag Cayman Islands", - "slug": "flag_cayman_islands", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฐ๐Ÿ‡ฟ": { - "name": "flag Kazakhstan", - "slug": "flag_kazakhstan", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฑ๐Ÿ‡ฆ": { - "name": "flag Laos", - "slug": "flag_laos", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฑ๐Ÿ‡ง": { - "name": "flag Lebanon", - "slug": "flag_lebanon", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฑ๐Ÿ‡จ": { - "name": "flag St. Lucia", - "slug": "flag_st_lucia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฑ๐Ÿ‡ฎ": { - "name": "flag Liechtenstein", - "slug": "flag_liechtenstein", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฑ๐Ÿ‡ฐ": { - "name": "flag Sri Lanka", - "slug": "flag_sri_lanka", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฑ๐Ÿ‡ท": { - "name": "flag Liberia", - "slug": "flag_liberia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฑ๐Ÿ‡ธ": { - "name": "flag Lesotho", - "slug": "flag_lesotho", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฑ๐Ÿ‡น": { - "name": "flag Lithuania", - "slug": "flag_lithuania", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฑ๐Ÿ‡บ": { - "name": "flag Luxembourg", - "slug": "flag_luxembourg", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฑ๐Ÿ‡ป": { - "name": "flag Latvia", - "slug": "flag_latvia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฑ๐Ÿ‡พ": { - "name": "flag Libya", - "slug": "flag_libya", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฒ๐Ÿ‡ฆ": { - "name": "flag Morocco", - "slug": "flag_morocco", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฒ๐Ÿ‡จ": { - "name": "flag Monaco", - "slug": "flag_monaco", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฒ๐Ÿ‡ฉ": { - "name": "flag Moldova", - "slug": "flag_moldova", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฒ๐Ÿ‡ช": { - "name": "flag Montenegro", - "slug": "flag_montenegro", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฒ๐Ÿ‡ซ": { - "name": "flag St. Martin", - "slug": "flag_st_martin", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฒ๐Ÿ‡ฌ": { - "name": "flag Madagascar", - "slug": "flag_madagascar", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฒ๐Ÿ‡ญ": { - "name": "flag Marshall Islands", - "slug": "flag_marshall_islands", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฒ๐Ÿ‡ฐ": { - "name": "flag North Macedonia", - "slug": "flag_north_macedonia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฒ๐Ÿ‡ฑ": { - "name": "flag Mali", - "slug": "flag_mali", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฒ๐Ÿ‡ฒ": { - "name": "flag Myanmar (Burma)", - "slug": "flag_myanmar", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฒ๐Ÿ‡ณ": { - "name": "flag Mongolia", - "slug": "flag_mongolia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฒ๐Ÿ‡ด": { - "name": "flag Macao SAR China", - "slug": "flag_macao_sar_china", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฒ๐Ÿ‡ต": { - "name": "flag Northern Mariana Islands", - "slug": "flag_northern_mariana_islands", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฒ๐Ÿ‡ถ": { - "name": "flag Martinique", - "slug": "flag_martinique", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฒ๐Ÿ‡ท": { - "name": "flag Mauritania", - "slug": "flag_mauritania", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฒ๐Ÿ‡ธ": { - "name": "flag Montserrat", - "slug": "flag_montserrat", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฒ๐Ÿ‡น": { - "name": "flag Malta", - "slug": "flag_malta", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฒ๐Ÿ‡บ": { - "name": "flag Mauritius", - "slug": "flag_mauritius", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฒ๐Ÿ‡ป": { - "name": "flag Maldives", - "slug": "flag_maldives", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฒ๐Ÿ‡ผ": { - "name": "flag Malawi", - "slug": "flag_malawi", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฒ๐Ÿ‡ฝ": { - "name": "flag Mexico", - "slug": "flag_mexico", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฒ๐Ÿ‡พ": { - "name": "flag Malaysia", - "slug": "flag_malaysia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฒ๐Ÿ‡ฟ": { - "name": "flag Mozambique", - "slug": "flag_mozambique", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ณ๐Ÿ‡ฆ": { - "name": "flag Namibia", - "slug": "flag_namibia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ณ๐Ÿ‡จ": { - "name": "flag New Caledonia", - "slug": "flag_new_caledonia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ณ๐Ÿ‡ช": { - "name": "flag Niger", - "slug": "flag_niger", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ณ๐Ÿ‡ซ": { - "name": "flag Norfolk Island", - "slug": "flag_norfolk_island", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ณ๐Ÿ‡ฌ": { - "name": "flag Nigeria", - "slug": "flag_nigeria", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ณ๐Ÿ‡ฎ": { - "name": "flag Nicaragua", - "slug": "flag_nicaragua", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ณ๐Ÿ‡ฑ": { - "name": "flag Netherlands", - "slug": "flag_netherlands", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ณ๐Ÿ‡ด": { - "name": "flag Norway", - "slug": "flag_norway", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ณ๐Ÿ‡ต": { - "name": "flag Nepal", - "slug": "flag_nepal", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ณ๐Ÿ‡ท": { - "name": "flag Nauru", - "slug": "flag_nauru", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ณ๐Ÿ‡บ": { - "name": "flag Niue", - "slug": "flag_niue", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ณ๐Ÿ‡ฟ": { - "name": "flag New Zealand", - "slug": "flag_new_zealand", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ด๐Ÿ‡ฒ": { - "name": "flag Oman", - "slug": "flag_oman", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ต๐Ÿ‡ฆ": { - "name": "flag Panama", - "slug": "flag_panama", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ต๐Ÿ‡ช": { - "name": "flag Peru", - "slug": "flag_peru", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ต๐Ÿ‡ซ": { - "name": "flag French Polynesia", - "slug": "flag_french_polynesia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ต๐Ÿ‡ฌ": { - "name": "flag Papua New Guinea", - "slug": "flag_papua_new_guinea", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ต๐Ÿ‡ญ": { - "name": "flag Philippines", - "slug": "flag_philippines", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ต๐Ÿ‡ฐ": { - "name": "flag Pakistan", - "slug": "flag_pakistan", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ต๐Ÿ‡ฑ": { - "name": "flag Poland", - "slug": "flag_poland", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ต๐Ÿ‡ฒ": { - "name": "flag St. Pierre & Miquelon", - "slug": "flag_st_pierre_miquelon", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ต๐Ÿ‡ณ": { - "name": "flag Pitcairn Islands", - "slug": "flag_pitcairn_islands", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ต๐Ÿ‡ท": { - "name": "flag Puerto Rico", - "slug": "flag_puerto_rico", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ต๐Ÿ‡ธ": { - "name": "flag Palestinian Territories", - "slug": "flag_palestinian_territories", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ต๐Ÿ‡น": { - "name": "flag Portugal", - "slug": "flag_portugal", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ต๐Ÿ‡ผ": { - "name": "flag Palau", - "slug": "flag_palau", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ต๐Ÿ‡พ": { - "name": "flag Paraguay", - "slug": "flag_paraguay", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ถ๐Ÿ‡ฆ": { - "name": "flag Qatar", - "slug": "flag_qatar", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ท๐Ÿ‡ช": { - "name": "flag Rรฉunion", - "slug": "flag_reunion", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ท๐Ÿ‡ด": { - "name": "flag Romania", - "slug": "flag_romania", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ท๐Ÿ‡ธ": { - "name": "flag Serbia", - "slug": "flag_serbia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ท๐Ÿ‡บ": { - "name": "flag Russia", - "slug": "flag_russia", - "group": "Flags", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‡ท๐Ÿ‡ผ": { - "name": "flag Rwanda", - "slug": "flag_rwanda", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ธ๐Ÿ‡ฆ": { - "name": "flag Saudi Arabia", - "slug": "flag_saudi_arabia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ธ๐Ÿ‡ง": { - "name": "flag Solomon Islands", - "slug": "flag_solomon_islands", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ธ๐Ÿ‡จ": { - "name": "flag Seychelles", - "slug": "flag_seychelles", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ธ๐Ÿ‡ฉ": { - "name": "flag Sudan", - "slug": "flag_sudan", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ธ๐Ÿ‡ช": { - "name": "flag Sweden", - "slug": "flag_sweden", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ธ๐Ÿ‡ฌ": { - "name": "flag Singapore", - "slug": "flag_singapore", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ธ๐Ÿ‡ญ": { - "name": "flag St. Helena", - "slug": "flag_st_helena", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ธ๐Ÿ‡ฎ": { - "name": "flag Slovenia", - "slug": "flag_slovenia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ธ๐Ÿ‡ฏ": { - "name": "flag Svalbard & Jan Mayen", - "slug": "flag_svalbard_jan_mayen", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ธ๐Ÿ‡ฐ": { - "name": "flag Slovakia", - "slug": "flag_slovakia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ธ๐Ÿ‡ฑ": { - "name": "flag Sierra Leone", - "slug": "flag_sierra_leone", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ธ๐Ÿ‡ฒ": { - "name": "flag San Marino", - "slug": "flag_san_marino", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ธ๐Ÿ‡ณ": { - "name": "flag Senegal", - "slug": "flag_senegal", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ธ๐Ÿ‡ด": { - "name": "flag Somalia", - "slug": "flag_somalia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ธ๐Ÿ‡ท": { - "name": "flag Suriname", - "slug": "flag_suriname", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ธ๐Ÿ‡ธ": { - "name": "flag South Sudan", - "slug": "flag_south_sudan", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ธ๐Ÿ‡น": { - "name": "flag Sรฃo Tomรฉ & Prรญncipe", - "slug": "flag_sao_tome_principe", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ธ๐Ÿ‡ป": { - "name": "flag El Salvador", - "slug": "flag_el_salvador", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ธ๐Ÿ‡ฝ": { - "name": "flag Sint Maarten", - "slug": "flag_sint_maarten", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ธ๐Ÿ‡พ": { - "name": "flag Syria", - "slug": "flag_syria", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ธ๐Ÿ‡ฟ": { - "name": "flag Eswatini", - "slug": "flag_eswatini", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡น๐Ÿ‡ฆ": { - "name": "flag Tristan da Cunha", - "slug": "flag_tristan_da_cunha", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡น๐Ÿ‡จ": { - "name": "flag Turks & Caicos Islands", - "slug": "flag_turks_caicos_islands", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡น๐Ÿ‡ฉ": { - "name": "flag Chad", - "slug": "flag_chad", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡น๐Ÿ‡ซ": { - "name": "flag French Southern Territories", - "slug": "flag_french_southern_territories", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡น๐Ÿ‡ฌ": { - "name": "flag Togo", - "slug": "flag_togo", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡น๐Ÿ‡ญ": { - "name": "flag Thailand", - "slug": "flag_thailand", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡น๐Ÿ‡ฏ": { - "name": "flag Tajikistan", - "slug": "flag_tajikistan", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡น๐Ÿ‡ฐ": { - "name": "flag Tokelau", - "slug": "flag_tokelau", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡น๐Ÿ‡ฑ": { - "name": "flag Timor-Leste", - "slug": "flag_timor_leste", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡น๐Ÿ‡ฒ": { - "name": "flag Turkmenistan", - "slug": "flag_turkmenistan", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡น๐Ÿ‡ณ": { - "name": "flag Tunisia", - "slug": "flag_tunisia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡น๐Ÿ‡ด": { - "name": "flag Tonga", - "slug": "flag_tonga", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡น๐Ÿ‡ท": { - "name": "flag Tรผrkiye", - "slug": "flag_turkiye", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡น๐Ÿ‡น": { - "name": "flag Trinidad & Tobago", - "slug": "flag_trinidad_tobago", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡น๐Ÿ‡ป": { - "name": "flag Tuvalu", - "slug": "flag_tuvalu", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡น๐Ÿ‡ผ": { - "name": "flag Taiwan", - "slug": "flag_taiwan", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡น๐Ÿ‡ฟ": { - "name": "flag Tanzania", - "slug": "flag_tanzania", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡บ๐Ÿ‡ฆ": { - "name": "flag Ukraine", - "slug": "flag_ukraine", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡บ๐Ÿ‡ฌ": { - "name": "flag Uganda", - "slug": "flag_uganda", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡บ๐Ÿ‡ฒ": { - "name": "flag U.S. Outlying Islands", - "slug": "flag_u_s_outlying_islands", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡บ๐Ÿ‡ณ": { - "name": "flag United Nations", - "slug": "flag_united_nations", - "group": "Flags", - "emoji_version": "4.0", - "unicode_version": "4.0", - "skin_tone_support": false - }, - "๐Ÿ‡บ๐Ÿ‡ธ": { - "name": "flag United States", - "slug": "flag_united_states", - "group": "Flags", - "emoji_version": "0.6", - "unicode_version": "0.6", - "skin_tone_support": false - }, - "๐Ÿ‡บ๐Ÿ‡พ": { - "name": "flag Uruguay", - "slug": "flag_uruguay", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡บ๐Ÿ‡ฟ": { - "name": "flag Uzbekistan", - "slug": "flag_uzbekistan", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ป๐Ÿ‡ฆ": { - "name": "flag Vatican City", - "slug": "flag_vatican_city", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ป๐Ÿ‡จ": { - "name": "flag St. Vincent & Grenadines", - "slug": "flag_st_vincent_grenadines", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ป๐Ÿ‡ช": { - "name": "flag Venezuela", - "slug": "flag_venezuela", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ป๐Ÿ‡ฌ": { - "name": "flag British Virgin Islands", - "slug": "flag_british_virgin_islands", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ป๐Ÿ‡ฎ": { - "name": "flag U.S. Virgin Islands", - "slug": "flag_u_s_virgin_islands", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ป๐Ÿ‡ณ": { - "name": "flag Vietnam", - "slug": "flag_vietnam", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ป๐Ÿ‡บ": { - "name": "flag Vanuatu", - "slug": "flag_vanuatu", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ผ๐Ÿ‡ซ": { - "name": "flag Wallis & Futuna", - "slug": "flag_wallis_futuna", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ผ๐Ÿ‡ธ": { - "name": "flag Samoa", - "slug": "flag_samoa", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฝ๐Ÿ‡ฐ": { - "name": "flag Kosovo", - "slug": "flag_kosovo", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡พ๐Ÿ‡ช": { - "name": "flag Yemen", - "slug": "flag_yemen", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡พ๐Ÿ‡น": { - "name": "flag Mayotte", - "slug": "flag_mayotte", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฟ๐Ÿ‡ฆ": { - "name": "flag South Africa", - "slug": "flag_south_africa", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฟ๐Ÿ‡ฒ": { - "name": "flag Zambia", - "slug": "flag_zambia", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿ‡ฟ๐Ÿ‡ผ": { - "name": "flag Zimbabwe", - "slug": "flag_zimbabwe", - "group": "Flags", - "emoji_version": "2.0", - "unicode_version": "2.0", - "skin_tone_support": false - }, - "๐Ÿด๓ ง๓ ข๓ ฅ๓ ฎ๓ ง๓ ฟ": { - "name": "flag England", - "slug": "flag_england", - "group": "Flags", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿด๓ ง๓ ข๓ ณ๓ ฃ๓ ด๓ ฟ": { - "name": "flag Scotland", - "slug": "flag_scotland", - "group": "Flags", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - }, - "๐Ÿด๓ ง๓ ข๓ ท๓ ฌ๓ ณ๓ ฟ": { - "name": "flag Wales", - "slug": "flag_wales", - "group": "Flags", - "emoji_version": "5.0", - "unicode_version": "5.0", - "skin_tone_support": false - } -} diff --git a/config/hypr/modus.conf b/config/hypr/modus.conf index 11fda2a4..f3a439ea 100644 --- a/config/hypr/modus.conf +++ b/config/hypr/modus.conf @@ -1,7 +1,7 @@ # exec-once -exec-once = uwsm app -- python ~/.config/Modus/main.py +exec-once = uwsm app -- uv run $HOME/.config/Modus/main.py exec = pgrep -x "hypridle" > /dev/null || uwsm app -- hypridle -exec-once = uwsm app -- swww-daemon +exec-once = uwsm app -- awww-daemon exec-once = wl-paste --type text --watch cliphist store exec-once = wl-paste --type image --watch cliphist store @@ -13,28 +13,27 @@ layerrule = blur on, xray 0, blur_popups on, ignore_alpha 0, animation popin, ma layerrule = blur on, ignore_alpha 0, xray 0, blur_popups on, match:namespace ^fabric$ layerrule = blur on, xray 0, blur_popups on, ignore_alpha 0, match:namespace ^modus$ layerrule = animation slide right, match:namespace ^notification-center$ - # KEYBINDS -$fabricSend = fabric-cli exec modus -# Reload Modus -bind = SUPER ALT, B, exec, killall modus; uwsm-app $(python $HOME/.config/Modus/main.py) -# Message -bind = SUPER SHIFT, y, exec, $fabricSend 'app.set_css()' # Reload CSS -# # Application Switcher -bind = ALT, TAB, exec, $fabricSend 'switcher.show_switcher()' -# App Launcher -bind = SUPER, SPACE, exec, $fabricSend "launcher.show_launcher()" -# Clipboard History -bind = SUPER, V, exec, $fabricSend "launcher.show_launcher('clip')" -# Wallpapers -bind = SUPER, W, exec, $fabricSend "launcher.show_launcher('wall')" -# Random Wallpaper -bind = ALT SHIFT, W, exec, $fabricSend "launcher.show_launcher('wall random', external=True)" -# Emoji Picker -bind = SUPER, Period, exec, $fabricSend "launcher.show_launcher('em')" -# Power Menu -bind = SUPER, ESCAPE, exec, $fabricSend "launcher.show_launcher('power')" -# Toggle Caffeine -bind = SUPER SHIFT, M, exec, $fabricSend "launcher.show_launcher('caffeine on', external=True)" - +# $fabricSend = fabric-cli exec modus1 +# # Reload Modus +# bind = SUPER ALT, B, exec, killall modus; uwsm-app $(python $HOME/.config/Modus/main.py) +# # Message +# bind = SUPER SHIFT, y, exec, $fabricSend 'app.set_css()' # Reload CSS +# # # Application Switcher +# bind = ALT, TAB, exec, $fabricSend 'switcher.show_switcher()' +# # App Launcher +# bind = SUPER, SPACE, exec, $fabricSend "launcher.show_launcher()" +# # Clipboard History +# bind = SUPER, V, exec, $fabricSend "launcher.show_launcher('clip')" +# # Wallpapers +# bind = SUPER, W, exec, $fabricSend "launcher.show_launcher('wall')" +# # Random Wallpaper +# bind = ALT SHIFT, W, exec, $fabricSend "launcher.show_launcher('wall random', external=True)" +# # Emoji Picker +# bind = SUPER, Period, exec, $fabricSend "launcher.show_launcher('em')" +# # Power Menu +# bind = SUPER, ESCAPE, exec, $fabricSend "launcher.show_launcher('power')" +# # Toggle Caffeine +# bind = SUPER SHIFT, M, exec, $fabricSend "launcher.show_launcher('caffeine on', external=True)" +# diff --git a/config/assets/launcher.json b/config/launcher.json similarity index 58% rename from config/assets/launcher.json rename to config/launcher.json index 9d411e0d..332e2d2b 100644 --- a/config/assets/launcher.json +++ b/config/launcher.json @@ -1,6 +1,9 @@ { "launcher_config": { "em": { + "examples": [ + "em updatejson" + ], "icon": "emoji-people-symbolic", "description": "Emoji - Search and copy emojis" }, @@ -21,10 +24,6 @@ "icon": "apps", "description": "Applications - Launch installed applications" }, - "bin": { - "icon": "terminal", - "description": "Bins - Search and run executable binaries" - }, "power": { "icon": "shutdown", "description": "Power - System power management and session control" @@ -55,38 +54,6 @@ "? google cats", "? youtube music" ] - }, - "remind": { - "icon": "alarm-timer", - "description": "Reminders - Set time-based reminders with notifications" - }, - "otp": { - "icon": "auth-otp-symbolic", - "description": "Manage TOTP codes and 2FA authentication" - }, - "pass": { - "icon": "nextcloud-password-client", - "description": "Password Manager - Search and manage passwords" - }, - "bm": { - "icon": "bookmark-add-symbolic", - "description": "Bookmarks - Search and manage bookmarks" - }, - "script": { - "icon": "terminal", - "description": "Bash Scripts - Manage and execute bash scripts" - }, - "bash": { - "icon": "terminal", - "description": "Bash Scripts - Manage and execute bash scripts" - }, - "sh": { - "icon": "terminal", - "description": "Bash Scripts - Manage and execute bash scripts" - }, - "tmux": { - "icon": "terminal", - "description": "Tmux Manager - Create, attach, rename, and kill tmux sessions" } }, "settings": { diff --git a/debug_memory.py b/debug_memory.py deleted file mode 100644 index 2e175e74..00000000 --- a/debug_memory.py +++ /dev/null @@ -1,165 +0,0 @@ -#!/usr/bin/env python3 -""" -Real-time memory monitor for debugging expanded player memory leaks. -This module provides functions to track memory usage in real-time. -""" - -import psutil -import os -import gc -import threading -import time -from loguru import logger - - -class MemoryMonitor: - """Real-time memory monitoring for debugging memory leaks.""" - - def __init__(self): - self.process = psutil.Process(os.getpid()) - self.baseline_memory = None - self.last_memory = None - self.monitoring = False - self.monitor_thread = None - - def get_memory_usage(self): - """Get current memory usage in MB.""" - return self.process.memory_info().rss / 1024 / 1024 - - def get_memory_details(self): - """Get detailed memory information.""" - memory_info = self.process.memory_info() - memory_percent = self.process.memory_percent() - - return { - "rss_mb": memory_info.rss / 1024 / 1024, - "vms_mb": memory_info.vms / 1024 / 1024, - "percent": memory_percent, - "num_threads": self.process.num_threads(), - } - - def set_baseline(self, label="Baseline"): - """Set the baseline memory usage.""" - self.baseline_memory = self.get_memory_usage() - logger.info(f"๐ŸŽฏ {label} memory: {self.baseline_memory:.1f} MB") - return self.baseline_memory - - def log_memory_change(self, label="Memory Check", force_gc=True): - """Log current memory usage and change from baseline.""" - if force_gc: - gc.collect() - - current_memory = self.get_memory_usage() - details = self.get_memory_details() - - if self.baseline_memory: - delta = current_memory - self.baseline_memory - logger.info( - f"๐Ÿ“Š {label}: {current_memory:.1f} MB (ฮ”: {delta:+.1f} MB) | Threads: { - details['num_threads'] - }" - ) - else: - logger.info( - f"๐Ÿ“Š {label}: {current_memory:.1f} MB | Threads: { - details['num_threads'] - }" - ) - - self.last_memory = current_memory - return current_memory - - def log_memory_spike(self, threshold_mb=10): - """Log if there's a significant memory increase.""" - if self.last_memory: - current = self.get_memory_usage() - increase = current - self.last_memory - if increase > threshold_mb: - logger.warning( - f"๐Ÿšจ MEMORY SPIKE: +{increase:.1f} MB (from {self.last_memory:.1f} to {current:.1f} MB)" - ) - return True - return False - - def start_continuous_monitoring(self, interval_seconds=2): - """Start continuous background monitoring.""" - if self.monitoring: - return - - self.monitoring = True - - def monitor_loop(): - while self.monitoring: - try: - self.log_memory_change("Continuous Monitor", force_gc=False) - time.sleep(interval_seconds) - except Exception as e: - logger.error(f"Memory monitor error: {e}") - break - - self.monitor_thread = threading.Thread(target=monitor_loop, daemon=True) - self.monitor_thread.start() - logger.info( - f"๐Ÿ” Started continuous memory monitoring (every {interval_seconds}s)" - ) - - def stop_continuous_monitoring(self): - """Stop continuous monitoring.""" - if self.monitoring: - self.monitoring = False - logger.info("๐Ÿ›‘ Stopped continuous memory monitoring") - - -# Global memory monitor instance -memory_monitor = MemoryMonitor() - -# Convenience functions for easy use - - -def set_memory_baseline(label="Baseline"): - """Set memory baseline.""" - return memory_monitor.set_baseline(label) - - -def log_memory(label="Memory Check"): - """Log current memory usage.""" - return memory_monitor.log_memory_change(label) - - -def start_memory_monitoring(): - """Start continuous memory monitoring.""" - memory_monitor.start_continuous_monitoring() - - -def stop_memory_monitoring(): - """Stop continuous memory monitoring.""" - memory_monitor.stop_continuous_monitoring() - - -def check_memory_spike(): - """Check for memory spike.""" - return memory_monitor.log_memory_spike() - - -# Test function - - -def test_memory_monitor(): - """Test the memory monitor.""" - print("Testing Memory Monitor...") - monitor = MemoryMonitor() - - monitor.set_baseline("Test Start") - - # Simulate some memory usage - big_list = [i for i in range(100000)] - monitor.log_memory_change("After creating big list") - - del big_list - monitor.log_memory_change("After deleting big list") - - print("Memory monitor test complete!") - - -if __name__ == "__main__": - test_memory_monitor() diff --git a/install.sh b/install.sh index dc66055a..49ac6ab9 100755 --- a/install.sh +++ b/install.sh @@ -21,39 +21,22 @@ REPO_URL="https://github.com/S4NKALP/Modus.git" INSTALL_DIR="$HOME/.config/Modus" PACKAGES=( - python-fabric-git + uv fabric-cli-git - glace-git cliphist gnome-bluetooth-3.0 - gobject-introspection slurp ffmpeg hypridle hyprsunset hyprpicker - imagemagick + hyprshot + grim libnotify matugen-bin playerctl - python-gobject - python-pillow - python-setproctitle - python-toml - python-requests - python-numpy - python-pywayland - python-pyxdg - python-ijson - python-watchdog - python-pyotp - pyzbar - python-psutil - python-pydbus - python-thefuzz - python-pam gtk-session-lock - swww + awww apple-fonts swappy wl-clipboard @@ -302,20 +285,6 @@ else success "All packages are up-to-date" fi -# Configuration -# progress "Running configuration" -# if [ -f "$INSTALL_DIR/config/config.py" ]; then -# step "Initializing Modus configuration..." -# if python "$INSTALL_DIR/config/config.py" 2>/dev/null; then -# success "Configuration completed" -# else -# warn "Configuration step failed or was skipped" -# fi -# else -# info "No configuration file found, skipping" -# fi - -# Hyprland configuration progress "Configuring Hyprland" HYPR_CONFIG="$HOME/.config/hypr/hyprland.conf" @@ -349,10 +318,10 @@ else fi step "Starting Modus..." -if uwsm app -- python "$INSTALL_DIR/main.py" >/dev/null 2>&1 & then +if uwsm app -- uv run --project "$INSTALL_DIR" start >/dev/null 2>&1 & then disown - sleep 1 - if pgrep -f "python.*main.py" >/dev/null; then + sleep 2 + if pgrep -x "modus" >/dev/null; then success "Modus is now running" else warn "Modus may not have started correctly" @@ -366,7 +335,7 @@ fi echo "" echo -e "${GREEN}${BOLD}โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—${RESET}" echo -e "${GREEN}${BOLD}โ•‘ โ•‘${RESET}" -echo -e "${GREEN}${BOLD}โ•‘ Installation completed! ๐ŸŽ‰ โ•‘${RESET}" +echo -e "${GREEN}${BOLD}โ•‘ Installation completed! โ•‘${RESET}" echo -e "${GREEN}${BOLD}โ•‘ โ•‘${RESET}" echo -e "${GREEN}${BOLD}โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${RESET}" echo "" diff --git a/main.css b/main.css deleted file mode 100644 index 738870f4..00000000 --- a/main.css +++ /dev/null @@ -1,34 +0,0 @@ -@import url("./styles/colors.css"); -@import url("./styles/panel.css"); -@import url("./styles/dock.css"); -@import url("./styles/switcher.css"); -@import url("./styles/launcher.css"); -@import url("./styles/osd.css"); -@import url("./styles/controlcenter.css"); -@import url("./styles/notification.css"); -@import url("./styles/dropdown.css"); -@import url("./styles/about.css"); -@import url("./styles/notification-center.css"); -@import url("./styles/player.css"); -@import url("./styles/widgets.css"); -@import url("./styles/tray.css"); -@import url("./styles/battery-widget.css"); -@import url("./styles/lock.css"); -@import url("./styles/todo.css"); - -* { - all: unset; - color: var(--foreground); - font-size: unset; - font-family: "SF Pro Rounded"; -} - -#corner { - background-color: var(--shadow); - border-radius: 0; -} - -#corner-container { - min-width: 20px; - min-height: 20px; -} diff --git a/main.py b/main.py deleted file mode 100644 index f0db31a9..00000000 --- a/main.py +++ /dev/null @@ -1,67 +0,0 @@ -import setproctitle -from fabric import Application -from fabric.utils import get_relative_path, monitor_file -from loguru import logger - -from config.data import APP_NAME -from modules.dock import Dock -from modules.launcher.main import Launcher -from modules.notification.notification import ModusNoti -from modules.osd import OSD -from modules.panel.main import Panel -from modules.switcher import ApplicationSwitcher -from modules.widget import Deskwidgets - -# from modules.corners import Corners - -for log in [ - "fabric", - "services", - "utils", - # "modules", -]: - logger.disable(log) - - -if __name__ == "__main__": - setproctitle.setproctitle(APP_NAME) - - # Load configuration - from config.data import load_config - - # About().toggle(None) - config = load_config() - - panel = Panel() - # corners = Corners() - dock = Dock() - modusnoti = ModusNoti() - switcher = ApplicationSwitcher() - launcher = Launcher() - panel.launcher = launcher - osd = OSD() - - widgets = Deskwidgets() - # Set corners visibility based on config - # corners_visible = config.get("corners_visible", True) - # corners.set_visible(corners_visible) - - # Monitor CSS files for changes - css_file = monitor_file(get_relative_path("styles")) - _ = css_file.connect("changed", lambda *_: set_css()) - - # Make sure corners is added to the app - app = Application( - f"{APP_NAME}", panel, dock, switcher, launcher, modusnoti, osd, widgets - ) - - def set_css(): - app.set_stylesheet_from_file( - get_relative_path("main.css"), - ) - - app.set_css = set_css - - app.set_css() - - app.run() diff --git a/modules/corners.py b/modules/corners.py deleted file mode 100644 index cf3c79e8..00000000 --- a/modules/corners.py +++ /dev/null @@ -1,68 +0,0 @@ -from fabric.widgets.box import Box -from fabric.widgets.shapes import Corner -from widgets.wayland import WaylandWindow as Window - - -class MyCorner(Box): - def __init__(self, corner): - super().__init__( - name="corner-container", - children=Corner( - name="corner", - orientation=corner, - h_expand=False, - v_expand=False, - h_align="center", - v_align="center", - size=20, - ), - ) - - -class Corners(Window): - def __init__(self): - super().__init__( - name="corners", - layer="bottom", - anchor="top bottom left right", - exclusivity="normal", - # pass_through=True, - visible=False, - all_visible=False, - ) - - self.all_corners = Box( - name="all-corners", - orientation="v", - h_expand=True, - v_expand=True, - h_align="fill", - v_align="fill", - children=[ - Box( - name="top-corners", - orientation="h", - h_align="fill", - children=[ - MyCorner("top-left"), - Box(h_expand=True), - MyCorner("top-right"), - ], - ), - Box(v_expand=True), - Box( - name="bottom-corners", - orientation="h", - h_align="fill", - children=[ - MyCorner("bottom-left"), - Box(h_expand=True), - MyCorner("bottom-right"), - ], - ), - ], - ) - - self.add(self.all_corners) - - self.show_all() diff --git a/modules/launcher/plugins/bash_scripts.py b/modules/launcher/plugins/bash_scripts.py deleted file mode 100644 index 44bb2e80..00000000 --- a/modules/launcher/plugins/bash_scripts.py +++ /dev/null @@ -1,431 +0,0 @@ -import json -import os -import threading -import time -from typing import Dict, List - -import config.data as data -from fabric.utils import exec_shell_command_async -from modules.launcher.plugin_base import PluginBase -from modules.launcher.result import Result - - -class BashScriptsPlugin(PluginBase): - """ - Plugin for managing and executing bash scripts. - """ - - def __init__(self): - super().__init__() - self.display_name = "Bash Scripts" - self.description = "Manage and execute bash scripts" - - # Configuration - self.scripts_cache_file = os.path.join(data.CACHE_DIR, "bash_scripts.json") - - # Default script directory to scan (only Modus scripts) - self.modus_scripts_dir = os.path.expanduser("~/.config/Modus/scripts") - - # Scripts to exclude from discovery - self.excluded_scripts = { - "screen-capture.sh" # Exclude screen-capture.sh as it's handled by screencapture plugin - } - - # In-memory cache - self._scripts_cache: Dict[str, Dict] = {} - self._last_cache_update = 0 - self._cache_update_interval = 300 # 5 minutes - - # Background cache building - self._cache_building = False - self._cache_thread = None - - def initialize(self): - """Initialize the bash scripts plugin.""" - self.set_triggers(["sh"]) - self._load_scripts_cache() - self._start_background_cache_update() - - def cleanup(self): - """Cleanup the bash scripts plugin.""" - self._scripts_cache.clear() - if self._cache_thread and self._cache_thread.is_alive(): - # Note: We don't join the thread to avoid blocking cleanup - pass - - def _load_scripts_cache(self): - """Load scripts cache from JSON file.""" - try: - if os.path.exists(self.scripts_cache_file): - with open(self.scripts_cache_file, "r", encoding="utf-8") as f: - cache_data = json.load(f) - self._scripts_cache = cache_data.get("scripts", {}) - self._last_cache_update = cache_data.get("last_update", 0) - else: - print( - "BashScriptsPlugin: No cache file found, will build cache in background" - ) - except Exception as e: - print(f"BashScriptsPlugin: Error loading scripts cache: {e}") - self._scripts_cache = {} - self._last_cache_update = 0 - - def _save_scripts_cache(self): - """Save scripts cache to JSON file.""" - try: - os.makedirs(data.CACHE_DIR, exist_ok=True) - cache_data = { - "scripts": self._scripts_cache, - "last_update": self._last_cache_update, - } - with open(self.scripts_cache_file, "w", encoding="utf-8") as f: - json.dump(cache_data, f, indent=2) - except Exception as e: - print(f"BashScriptsPlugin: Error saving scripts cache: {e}") - - def _start_background_cache_update(self): - """Start background thread to update scripts cache.""" - current_time = time.time() - - # Check if cache needs updating - if ( - current_time - self._last_cache_update > self._cache_update_interval - or not self._scripts_cache - ): - if not self._cache_building: - self._cache_building = True - self._cache_thread = threading.Thread( - target=self._build_scripts_cache_background, daemon=True - ) - self._cache_thread.start() - - def _build_scripts_cache_background(self): - """Build scripts cache in background thread.""" - try: - new_cache = {} - - # Scan Modus scripts directory for discovered scripts - if os.path.exists(self.modus_scripts_dir) and os.path.isdir( - self.modus_scripts_dir - ): - try: - self._scan_directory_for_scripts(self.modus_scripts_dir, new_cache) - except (PermissionError, FileNotFoundError, OSError) as e: - print( - f"BashScriptsPlugin: Error scanning Modus scripts directory: { - e - }" - ) - - # Update cache atomically - self._scripts_cache = new_cache - self._last_cache_update = time.time() - - # Save to disk - self._save_scripts_cache() - - except Exception as e: - print(f"BashScriptsPlugin: Error building scripts cache: {e}") - finally: - self._cache_building = False - - def _scan_directory_for_scripts(self, directory: str, cache: Dict): - """Scan a directory for bash scripts and add them to cache.""" - try: - with os.scandir(directory) as entries: - for entry in entries: - if entry.is_file(follow_symlinks=False): - script_path = entry.path - script_name = entry.name - - # Skip excluded scripts - if script_name in self.excluded_scripts: - continue - - # Check if it's a script file - if self._is_script_file(script_path): - cache[script_name] = { - "path": script_path, - "name": script_name, - "description": self._get_script_description( - script_path - ), - "type": "discovered", - "executable": os.access(script_path, os.X_OK), - "args": [], - "category": os.path.basename(directory), - } - - except (PermissionError, FileNotFoundError, OSError) as e: - print(f"BashScriptsPlugin: Error scanning directory {directory}: {e}") - except Exception as e: - print(f"BashScriptsPlugin: Unexpected error scanning {directory}: {e}") - - def _is_script_file(self, file_path: str) -> bool: - """Check if a file is a bash script.""" - try: - # Check file extension first (most common case) - if file_path.endswith((".sh", ".bash")): - return True - - # For files without extension, check shebang - try: - with open(file_path, "rb") as f: - first_line = f.readline(100).decode("utf-8", errors="ignore") - if first_line.startswith("#!") and ( - "bash" in first_line or "sh" in first_line - ): - return True - except (PermissionError, FileNotFoundError, UnicodeDecodeError): - pass - - return False - except Exception: - return False - - def _get_script_description(self, script_path: str) -> str: - """Extract description from script comments.""" - try: - with open(script_path, "r", encoding="utf-8", errors="ignore") as f: - lines = f.readlines() - - # Look for description in first few comment lines - for line in lines[:10]: - line = line.strip() - if line.startswith("#") and not line.startswith("#!"): - # Remove leading # and whitespace - desc = line[1:].strip() - if desc and len(desc) > 5: # Meaningful description - return desc - - return f"Script: {os.path.basename(script_path)}" - except (PermissionError, FileNotFoundError, UnicodeDecodeError): - return f"Script: {os.path.basename(script_path)}" - - def query(self, query_string: str) -> List[Result]: - """Search for bash scripts matching the query.""" - query = query_string.strip() - - # Start background update if needed (non-blocking) - if not self._scripts_cache or ( - time.time() - self._last_cache_update > self._cache_update_interval - ): - self._start_background_cache_update() - - results = [] - - # Handle special commands - if not query: - # Show all scripts when no query - results.extend(self._list_all_scripts()) - else: - # Search for scripts - results.extend(self._search_scripts(query)) - - return results - - def _list_all_scripts(self) -> List[Result]: - """List all available scripts.""" - results = [] - max_results = 20 - - # Sort scripts by name for consistent ordering - sorted_scripts = sorted( - self._scripts_cache.items(), key=lambda x: x[1].get("name", "") - ) - - # Add scripts (limit to max_results) - for script_name, script_info in sorted_scripts: - script_results = self._create_script_results_with_args( - script_name, script_info, 0.8 - ) - for script_result in script_results: - if len(results) < max_results: - results.append(script_result) - else: - break - if len(results) >= max_results: - break - - return results - - def _search_scripts(self, query: str) -> List[Result]: - """Search for scripts matching the query.""" - results = [] - query_lower = query.lower() - max_results = 15 - - # Categorize matches for better sorting - exact_matches = [] - prefix_matches = [] - partial_matches = [] - description_matches = [] - - for script_name, script_info in self._scripts_cache.items(): - script_name_lower = script_name.lower() - description_lower = script_info.get("description", "").lower() - - # Skip if no match at all - if ( - query_lower not in script_name_lower - and query_lower not in description_lower - ): - continue - - # Categorize matches - if script_name_lower == query_lower: - exact_matches.append((script_name, script_info, 1.0)) - elif script_name_lower.startswith(query_lower): - prefix_matches.append((script_name, script_info, 0.9)) - elif query_lower in script_name_lower: - partial_matches.append((script_name, script_info, 0.7)) - elif query_lower in description_lower: - description_matches.append((script_name, script_info, 0.5)) - - # Combine results in priority order - all_matches = ( - exact_matches + prefix_matches + partial_matches + description_matches - ) - - # Convert to Result objects - for script_name, script_info, relevance in all_matches: - script_results = self._create_script_results_with_args( - script_name, script_info, relevance - ) - for script_result in script_results: - if len(results) < max_results: - results.append(script_result) - else: - break - if len(results) >= max_results: - break - - return results - - def _create_script_result( - self, script_name: str, script_info: Dict, relevance: float - ) -> Result: - """Create a Result object for a script.""" - script_path = script_info.get("path", "") - description = script_info.get("description", "") - script_type = script_info.get("type", "discovered") - executable = script_info.get("executable", False) - category = script_info.get("category", "") - - # Create subtitle with additional info - subtitle_parts = [] - if description: - subtitle_parts.append(description) - if category: - subtitle_parts.append(f"[{category}]") - if not executable: - subtitle_parts.append("(not executable)") - - subtitle = ( - " | ".join(subtitle_parts) if subtitle_parts else f"Execute: {script_name}" - ) - - # Choose icon based on script type and status - if not executable: - icon_name = "gtk-file" - elif script_type == "custom": - icon_name = "folder-script-symbolic" - else: - icon_name = "terminalc" - - return Result( - title=script_name, - subtitle=subtitle, - icon_name=icon_name, - action=self._create_script_action(script_name, script_info), - relevance=relevance, - plugin_name=self.display_name, - data={ - "script_name": script_name, - "script_path": script_path, - "type": script_type, - }, - ) - - def _create_script_results_with_args( - self, script_name: str, script_info: Dict, relevance: float - ) -> List[Result]: - """Create multiple Result objects for scripts that support arguments.""" - results = [] - - # Check for special scripts that need argument variants - if script_name == "hyprpicker.sh": - # For hyprpicker, only show the argument variants (skip basic version) - variants = [ - ("-rgb", "Pick RGB color"), - ("-hex", "Pick HEX color"), - ("-hsv", "Pick HSV color"), - ] - - for arg, desc in variants: - variant_result = Result( - title=f"{script_name} {arg}", - subtitle=f"{desc} | [scripts]", - icon_name="terminal-symbolic", - action=self._create_script_action_with_args( - script_name, script_info, [arg] - ), - relevance=relevance - + 0.1, # Slightly higher relevance for specific variants - plugin_name=self.display_name, - data={ - "script_name": script_name, - "script_path": script_info.get("path", ""), - "type": script_info.get("type", "discovered"), - "args": [arg], - }, - ) - results.append(variant_result) - else: - # For other scripts, create the basic result - basic_result = self._create_script_result( - script_name, script_info, relevance - ) - results.append(basic_result) - - return results - - def _create_script_action(self, script_name: str, script_info: Dict): - """Create an action function for executing a script.""" - - def action(): - self._execute_script(script_name, script_info) - - return action - - def _create_script_action_with_args( - self, script_name: str, script_info: Dict, args: List[str] - ): - """Create an action function for executing a script with specific arguments.""" - - def action(): - # Create a modified script_info with the specific arguments - modified_script_info = script_info.copy() - modified_script_info["args"] = args - self._execute_script(script_name, modified_script_info) - - return action - - def _execute_script(self, script_name: str, script_info: Dict): - """Execute a bash script.""" - try: - script_path = script_info.get("path", "") - script_args = script_info.get("args", []) - - if not os.path.exists(script_path): - return - - if not script_info.get("executable", False): - return - - # Build command - command = [script_path] + script_args - exec_shell_command_async(command) - - except Exception as e: - print(f"BashScriptsPlugin: Error executing script '{script_name}': {e}") diff --git a/modules/launcher/plugins/bookmarks.py b/modules/launcher/plugins/bookmarks.py deleted file mode 100644 index ee551398..00000000 --- a/modules/launcher/plugins/bookmarks.py +++ /dev/null @@ -1,786 +0,0 @@ -import json -import subprocess -import threading -import time -from pathlib import Path -from typing import Dict, List, Optional -from urllib.parse import urlparse - -from thefuzz import fuzz - -from fabric.utils.helpers import get_relative_path -from modules.launcher.plugin_base import PluginBase -from modules.launcher.result import Result - - -class BookmarkManager: - """Manages user's custom bookmarks.""" - - def __init__(self, storage_file: Path): - self.storage_file = storage_file - self.bookmarks: List[Dict] = [] - self.cache_lock = threading.Lock() - self.last_loaded = 0 - self.cache_ttl = 30 # Cache for 30 seconds - self._load_bookmarks() - - def _get_favicon_url(self, url: str) -> str: - """Generate favicon URL for a given website URL.""" - try: - parsed = urlparse(url) - return f"{parsed.scheme}://{parsed.netloc}/favicon.ico" - except: - return "" - - def _extract_domain(self, url: str) -> str: - """Extract domain from URL.""" - try: - parsed = urlparse(url) - domain = parsed.netloc - # Remove www. prefix - if domain.startswith("www."): - domain = domain[4:] - return domain - except: - return url - - def _normalize_url(self, url: str) -> str: - """Normalize URL by adding protocol if missing.""" - url = url.strip() - if not url.startswith(("http://", "https://")): - if url.startswith("www."): - url = "https://" + url - else: - url = "https://" + url - return url - - def _load_bookmarks(self): - """Load bookmarks from JSON file with caching.""" - with self.cache_lock: - current_time = time.time() - - # Check if cache is still valid - if ( - current_time - self.last_loaded - ) < self.cache_ttl and self.last_loaded > 0: - return - - try: - if self.storage_file.exists(): - with open(self.storage_file, "r", encoding="utf-8") as f: - data = json.load(f) - self.bookmarks = data.get("bookmarks", []) - else: - # File doesn't exist, start with empty list but don't save yet - self.bookmarks = [] - - self.last_loaded = current_time - except Exception as e: - print(f"Error loading bookmarks: {e}") - self.bookmarks = [] - - def get_bookmarks(self) -> List[Dict]: - """Get bookmarks, loading from file if needed.""" - self._load_bookmarks() - return self.bookmarks - - def _save_bookmarks_unlocked(self): - """Save bookmarks to JSON file without acquiring lock.""" - try: - self.storage_file.parent.mkdir(parents=True, exist_ok=True) - data = { - "bookmarks": self.bookmarks, - "last_updated": time.time(), - } - with open(self.storage_file, "w", encoding="utf-8") as f: - json.dump(data, f, indent=2, ensure_ascii=False) - - # Update cache timestamp - self.last_loaded = time.time() - except Exception as e: - print(f"Error saving bookmarks: {e}") - - def _save_bookmarks(self): - """Save bookmarks to JSON file.""" - with self.cache_lock: - self._save_bookmarks_unlocked() - - def add_bookmark( - self, title: str, url: str, description: str = "", tags: List[str] = None - ) -> bool: - """Add a new bookmark.""" - try: - url = self._normalize_url(url) - - # Check if bookmark already exists - for bookmark in self.bookmarks: - if bookmark["url"] == url: - return False # Already exists - - new_bookmark = { - "title": title.strip(), - "url": url, - "description": description.strip(), - "tags": tags or [], - "created": time.time(), - "accessed": 0, - } - - self.bookmarks.append(new_bookmark) - self._save_bookmarks() - - # Clear cache to force reload - self.last_loaded = 0 - - return True - - except Exception as e: - print(f"Error adding bookmark: {e}") - return False - - def remove_bookmark(self, identifier: str) -> bool: - """Remove a bookmark by title or URL.""" - try: - identifier = identifier.lower().strip() - - for i, bookmark in enumerate(self.bookmarks): - if ( - bookmark["title"].lower() == identifier - or bookmark["url"].lower() == identifier - or self._extract_domain(bookmark["url"]).lower() == identifier - ): - self.bookmarks.pop(i) - self._save_bookmarks() - - # Clear cache to force reload - self.last_loaded = 0 - - return True - - return False - - except Exception as e: - print(f"Error removing bookmark: {e}") - return False - - def update_access_time(self, url: str): - """Update the last accessed time for a bookmark.""" - try: - for bookmark in self.bookmarks: - if bookmark["url"] == url: - bookmark["accessed"] = time.time() - self._save_bookmarks() - break - except Exception as e: - print(f"Error updating access time: {e}") - - def get_bookmark_count(self) -> int: - """Get total number of bookmarks.""" - return len(self.get_bookmarks()) - - -class BookmarksPlugin(PluginBase): - """ - User bookmarks plugin for the launcher. - Allows users to add, remove, and search their own bookmarks. - """ - - def __init__(self): - super().__init__() - self.display_name = "Bookmarks" - self.description = "Manage and search your personal bookmarks" - - # Initialize bookmark manager with storage file - self.bookmark_file = Path( - get_relative_path("../../../config/assets/bookmarks.json") - ) - self.bookmark_manager = BookmarkManager(self.bookmark_file) - self.max_results = 15 - - # Cache for results - self._results_cache = {} - self._cache_timestamps = {} - self._cache_ttl = 30 # 30 seconds - - # Launcher instance for refreshing - self._launcher_instance = None - self._original_close_launcher = None - - def initialize(self): - """Initialize the bookmarks plugin.""" - self.set_triggers(["bm"]) - self._setup_launcher_hooks() - - def cleanup(self): - """Cleanup the bookmarks plugin.""" - self._results_cache.clear() - self._cache_timestamps.clear() - self._cleanup_launcher_hooks() - - def query(self, query_string: str) -> List[Result]: - """Process bookmark queries with caching.""" - query_key = query_string.strip() - current_time = time.time() - - # Check cache first (except for add/remove commands which should always execute) - if ( - not query_key.startswith(("add ", "remove ", "delete ", "rm ")) - and query_key in self._results_cache - and (current_time - self._cache_timestamps.get(query_key, 0)) - < self._cache_ttl - ): - return self._results_cache[query_key] - - query = query_key.lower() - results = [] - - if not query: - # Show recent/popular bookmarks when no query - results = self._get_recent_bookmarks() - elif query.startswith("add "): - # Add new bookmark (don't cache) - results = self._handle_add_command(query[4:].strip()) - elif query.startswith(("remove ", "delete ", "rm ")): - # Remove bookmark (don't cache) - command_parts = query_key.split(" ", 1) - if len(command_parts) > 1: - results = self._handle_remove_command(command_parts[1].strip()) - else: - results = self._show_remove_help() - else: - # Search bookmarks - results = self._search_bookmarks(query) - - # Cache results (except for add/remove commands) - if not query.startswith(("add ", "remove ", "delete ", "rm ")): - self._results_cache[query_key] = results - self._cache_timestamps[query_key] = current_time - - return results - - def _search_bookmarks(self, query: str) -> List[Result]: - """Search through bookmarks.""" - bookmarks = self.bookmark_manager.get_bookmarks() - - if not bookmarks: - return [ - Result( - title="No Bookmarks Found", - subtitle="Use 'add <url>' to add first bookmark", - icon_name="info", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "info", "keep_launcher_open": True}, - ) - ] - - # Search bookmarks - results = [] - for bookmark in bookmarks: - relevance = self._calculate_relevance(bookmark, query) - if relevance > 0.3: # Only show relevant results - result = self._create_bookmark_result(bookmark, relevance) - if result: - results.append(result) - - # Sort by relevance and limit results - results.sort(key=lambda r: r.relevance, reverse=True) - return results[: self.max_results] - - def _get_recent_bookmarks(self) -> List[Result]: - """Get recent/popular bookmarks when no query is provided.""" - bookmarks = self.bookmark_manager.get_bookmarks() - - if not bookmarks: - return [ - Result( - title="No Bookmarks Yet", - subtitle="Use 'add <title> <url>' to add first bookmark", - icon_name="info", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "help", "keep_launcher_open": True}, - ), - Result( - title="Example: Add Google", - subtitle="add Google https://google.com", - icon_name="info", - action=lambda: None, - relevance=0.9, - plugin_name=self.display_name, - data={"type": "example", "keep_launcher_open": True}, - ), - ] - - # Sort by access time (most recent first) and show top 10 - sorted_bookmarks = sorted( - bookmarks, key=lambda b: b.get("accessed", 0), reverse=True - ) - results = [] - for bookmark in sorted_bookmarks[:10]: - result = self._create_bookmark_result(bookmark, 0.8) - if result: - results.append(result) - - return results - - def _handle_add_command(self, args: str) -> List[Result]: - """Handle add bookmark command.""" - if not args: - return [ - Result( - title="Add Bookmark", - subtitle="Usage: add <title> <url> [description]", - icon_name="info", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "help", "keep_launcher_open": True}, - ), - Result( - title="Example", - subtitle="add Google https://google.com Search engine", - icon_name="info", - action=lambda: None, - relevance=0.9, - plugin_name=self.display_name, - data={"type": "example", "keep_launcher_open": True}, - ), - ] - - # Parse arguments: title url [description] - parts = args.split() - if len(parts) < 2: - return [ - Result( - title="Invalid Format", - subtitle="Usage: add <title> <url> [description]", - icon_name="alert", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "error", "keep_launcher_open": True}, - ) - ] - - title = parts[0] - url = parts[1] - description = " ".join(parts[2:]) if len(parts) > 2 else "" - - # Check if bookmark already exists - normalized_url = self.bookmark_manager._normalize_url(url) - existing_bookmarks = self.bookmark_manager.get_bookmarks() - already_exists = any( - bookmark["url"] == normalized_url for bookmark in existing_bookmarks - ) - - if already_exists: - # Truncate URL for display to prevent launcher resize - display_url = normalized_url - if len(display_url) > 35: - display_url = display_url[:32] + "..." - - return [ - Result( - title="Bookmark Already Exists", - subtitle=f"URL '{display_url}' already exists", - icon_name="alert", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "error", "keep_launcher_open": True}, - ) - ] - - # Show add action - will execute on Enter - domain = self.bookmark_manager._extract_domain(normalized_url) - - # Truncate domain if too long - if len(domain) > 25: - domain = domain[:22] + "..." - - subtitle = f"Click to add: {domain}" - if description: - # Truncate description to prevent launcher resize - max_desc_len = 35 - len(domain) # Account for domain + separator - if len(description) > max_desc_len: - description = description[: max_desc_len - 3] + "..." - subtitle += f" โ€ข {description}" - - # Truncate title for display - display_title = title - if len(display_title) > 25: - display_title = display_title[:22] + "..." - - return [ - Result( - title=f"Add bookmark '{display_title}'", - subtitle=subtitle, - icon_name="plus", - action=lambda: self._add_bookmark_action(title, url, description), - relevance=1.0, - plugin_name=self.display_name, - data={"type": "add", "name": title, "keep_launcher_open": True}, - ) - ] - - def _handle_remove_command(self, identifier: str) -> List[Result]: - """Handle remove bookmark command.""" - if not identifier: - return self._show_remove_help() - - # Find matching bookmarks - bookmarks = self.bookmark_manager.get_bookmarks() - identifier_lower = identifier.lower().strip() - - matching_bookmarks = [] - for bookmark in bookmarks: - if ( - bookmark["title"].lower() == identifier_lower - or bookmark["url"].lower() == identifier_lower - or self.bookmark_manager._extract_domain(bookmark["url"]).lower() - == identifier_lower - ): - matching_bookmarks.append(bookmark) - - if not matching_bookmarks: - return [ - Result( - title="Bookmark Not Found", - subtitle=f"No bookmark found matching '{identifier}'", - icon_name="alert", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "error", "keep_launcher_open": True}, - ) - ] - - # Show remove action - will execute on Enter - bookmark = matching_bookmarks[0] # Take first match - title = bookmark.get("title", "Untitled") - domain = self.bookmark_manager._extract_domain(bookmark.get("url", "")) - - # Truncate title and domain for display - display_title = title - if len(display_title) > 25: - display_title = display_title[:22] + "..." - - display_domain = domain - if len(display_domain) > 30: - display_domain = display_domain[:27] + "..." - - return [ - Result( - title=f"Remove '{display_title}'?", - subtitle=f"Click to confirm: {display_domain}", - icon_name="trash", - action=lambda: self._remove_bookmark_action(identifier), - relevance=1.0, - plugin_name=self.display_name, - data={"type": "remove", "name": title, "keep_launcher_open": True}, - ) - ] - - def _show_remove_help(self) -> List[Result]: - """Show help for remove command.""" - bookmarks = self.bookmark_manager.get_bookmarks() - results = [ - Result( - title="Remove Bookmark", - subtitle="Usage: remove <title|url|domain>", - icon_name="info", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "help", "keep_launcher_open": True}, - ) - ] - - # Show available bookmarks to remove - if bookmarks: - results.append( - Result( - title="Available Bookmarks:", - subtitle=f"{len(bookmarks)} bookmarks available to remove", - icon_name="bookmarks-organize", - action=lambda: None, - relevance=0.9, - plugin_name=self.display_name, - data={"type": "info", "keep_launcher_open": True}, - ) - ) - - # Show first few bookmarks as examples - for bookmark in bookmarks[:3]: - title = bookmark.get("title", "Untitled") - domain = self._extract_domain(bookmark.get("url", "")) - - # Truncate title and domain for consistent display - display_title = title - if len(display_title) > 20: - display_title = display_title[:17] + "..." - - display_domain = domain - if len(display_domain) > 20: - display_domain = display_domain[:17] + "..." - - results.append( - Result( - title=f"remove {display_title}", - subtitle=f"Click to remove: {display_title} ({display_domain})", - icon_name="trash", - action=lambda t=title: self._remove_bookmark_action(t), - relevance=0.8, - plugin_name=self.display_name, - data={ - "type": "remove_option", - "bookmark": bookmark, - "keep_launcher_open": True, - }, - ) - ) - - return results - - def _add_bookmark_action(self, title: str, url: str, description: str = ""): - """Execute the add bookmark action.""" - success = self.bookmark_manager.add_bookmark(title, url, description) - if success: - print(f"โœ“ Added bookmark '{title}' - {url}") - # Clear cache to force refresh - self._results_cache.clear() - self._cache_timestamps.clear() - # Reset to trigger word and refresh - self._reset_to_trigger() - else: - print(f"โœ— Failed to add bookmark '{title}' - already exists") - - def _remove_bookmark_action(self, identifier: str): - """Execute the remove bookmark action.""" - success = self.bookmark_manager.remove_bookmark(identifier) - if success: - print(f"โœ“ Removed bookmark '{identifier}'") - # Clear cache to force refresh - self._results_cache.clear() - self._cache_timestamps.clear() - # Reset to trigger word and refresh - self._reset_to_trigger() - else: - print(f"โœ— Failed to remove bookmark '{identifier}' - not found") - - def _remove_bookmark_with_reset(self, identifier: str): - """Execute the remove bookmark action via alt_action (Shift+Enter) and reset to trigger.""" - success = self.bookmark_manager.remove_bookmark(identifier) - if success: - print(f"โœ“ Removed bookmark '{identifier}'") - # Clear cache to force refresh - self._results_cache.clear() - self._cache_timestamps.clear() - # Reset to trigger word and refresh - self._reset_to_trigger() - else: - print(f"โœ— Failed to remove bookmark '{identifier}' - not found") - - def _calculate_relevance(self, bookmark: Dict, query: str) -> float: - """Calculate relevance score for a bookmark.""" - title = bookmark.get("title", "").lower() - url = bookmark.get("url", "").lower() - description = bookmark.get("description", "").lower() - - # Exact title match - if query == title: - return 1.0 - - # Title starts with query - if title.startswith(query): - return 0.95 - - # Query in title - if query in title: - position = title.index(query) - position_score = 1.0 - (position / len(title)) - return 0.8 + (position_score * 0.1) - - # Query in URL - if query in url: - return 0.7 - - # Query in description - if query in description: - return 0.6 - - # Fuzzy matching for title - if len(query) >= 3: - fuzzy_score = fuzz.partial_ratio(query, title) / 100.0 - if fuzzy_score >= 0.7: - return fuzzy_score * 0.6 - - return 0.0 - - def _create_bookmark_result( - self, bookmark: Dict, relevance: float - ) -> Optional[Result]: - """Create a Result object for a bookmark.""" - try: - title = bookmark.get("title", "Untitled") - url = bookmark.get("url", "") - description = bookmark.get("description", "") - - # Truncate long titles to prevent launcher resize - if len(title) > 45: - title = title[:42] + "..." - - # Create subtitle with domain and description - domain = self._extract_domain(url) - - # Truncate domain if too long - if len(domain) > 30: - domain = domain[:27] + "..." - - if description: - # Truncate description to prevent launcher resize - # Account for domain + separator - max_desc_len = 50 - len(domain) - if len(description) > max_desc_len: - description = description[: max_desc_len - 3] + "..." - subtitle = f"{domain} โ€ข {description}" - else: - subtitle = domain - - # Final subtitle length check to ensure consistent launcher size - if len(subtitle) > 60: - subtitle = subtitle[:57] + "..." - - return Result( - title=title, - subtitle=subtitle, - icon_name="bookmark-organize", - action=lambda u=url: self._open_bookmark(u), - relevance=relevance, - plugin_name=self.display_name, - data={ - "type": "bookmark", - "url": url, - "domain": domain, - "description": description, - "keep_launcher_open": False, - "alt_action": lambda t=title: self._remove_bookmark_with_reset(t), - }, - ) - except Exception as e: - print(f"Error creating bookmark result: {e}") - return None - - def _extract_domain(self, url: str) -> str: - """Extract domain from URL.""" - try: - parsed = urlparse(url) - domain = parsed.netloc - # Remove www. prefix - if domain.startswith("www."): - domain = domain[4:] - return domain - except: - return url - - def _open_bookmark(self, url: str): - """Open bookmark URL in default browser and update access time.""" - try: - # Update access time - self.bookmark_manager.update_access_time(url) - - # Open URL - subprocess.Popen( - ["xdg-open", url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL - ) - except Exception as e: - print(f"Failed to open bookmark: {e}") - - def _setup_launcher_hooks(self): - """Setup hooks to monitor launcher state.""" - try: - # Try to find the launcher instance - import gc - - for obj in gc.get_objects(): - if ( - hasattr(obj, "__class__") - and obj.__class__.__name__ == "Launcher" - and hasattr(obj, "close_launcher") - ): - self._launcher_instance = obj - break - except Exception as e: - print(f"Warning: Could not setup launcher hooks: {e}") - - def _cleanup_launcher_hooks(self): - """Cleanup launcher hooks.""" - try: - self._launcher_instance = None - except Exception as e: - print(f"Warning: Could not cleanup launcher hooks: {e}") - - def _reset_to_trigger(self): - """Reset launcher to trigger word and refresh.""" - try: - if self._launcher_instance and hasattr( - self._launcher_instance, "search_entry" - ): - # Get the current trigger (bookmark or bm) - current_text = self._launcher_instance.search_entry.get_text() - trigger = "bookmark " - - # Determine which trigger was used - if current_text.lower().startswith("bm "): - trigger = "bm " - - # Reset to trigger word with space - try: - from gi.repository import GLib - - def reset_and_refresh(): - # Set text to trigger word - self._launcher_instance.search_entry.set_text(trigger) - # Position cursor at end - self._launcher_instance.search_entry.set_position(-1) - # Trigger search to show default bookmarks - self._launcher_instance._perform_search(trigger) - return False - - GLib.timeout_add(50, reset_and_refresh) - except ImportError: - # Fallback: direct call if GLib not available - self._launcher_instance.search_entry.set_text(trigger) - self._launcher_instance.search_entry.set_position(-1) - self._launcher_instance._perform_search(trigger) - except Exception as e: - print(f"Could not reset to trigger: {e}") - - def _force_launcher_refresh(self): - """Force the launcher to refresh and show updated results.""" - try: - if self._launcher_instance and hasattr( - self._launcher_instance, "_perform_search" - ): - # Get current search text - current_text = "" - if hasattr(self._launcher_instance, "search_entry"): - current_text = self._launcher_instance.search_entry.get_text() - - # Trigger a search to refresh results - try: - from gi.repository import GLib - - def refresh(): - self._launcher_instance._perform_search(current_text) - return False - - GLib.timeout_add(50, refresh) - except ImportError: - # Fallback: direct call if GLib not available - self._launcher_instance._perform_search(current_text) - except Exception as e: - print(f"Could not force launcher refresh: {e}") diff --git a/modules/launcher/plugins/emoji.py b/modules/launcher/plugins/emoji.py deleted file mode 100644 index 8842e804..00000000 --- a/modules/launcher/plugins/emoji.py +++ /dev/null @@ -1,187 +0,0 @@ -import json -import os -import subprocess -import time -from collections import OrderedDict -from typing import Dict, List - -import config.data as data -from fabric.utils import get_relative_path -from modules.launcher.plugin_base import PluginBase -from modules.launcher.result import Result - - -class EmojiPlugin(PluginBase): - """ - Plugin for searching and copying emojis. - """ - - def __init__(self): - super().__init__() - self.name = "emoji" - self.display_name = "Emoji" - self.description = "Search and copy emojis" - self.emoji_data = {} - self.emoji_path = get_relative_path("../../../config/assets/emoji.json") - - # Use cache directory for recent emojis (save directly in cache dir) - self.recent_emoji_path = os.path.join(data.CACHE_DIR, "recent_emoji.json") - self.recent_emojis = OrderedDict() - self.max_recent_emojis = 20 # Maximum number of recent emojis to track - - def initialize(self): - """Initialize the emoji plugin.""" - self.set_triggers(["em"]) - self._load_emoji_data() - self._load_recent_emojis() - - def cleanup(self): - """Cleanup the emoji plugin.""" - pass - - def _load_emoji_data(self): - """Load emoji data from JSON file.""" - try: - if os.path.exists(self.emoji_path): - with open(self.emoji_path, "r", encoding="utf-8") as f: - self.emoji_data = json.load(f) - else: - print(f"Emoji file not found: {self.emoji_path}") - except Exception as e: - print(f"Error loading emoji data: {e}") - - def _load_recent_emojis(self): - """Load recently used emojis from JSON file.""" - try: - if os.path.exists(self.recent_emoji_path): - with open(self.recent_emoji_path, "r", encoding="utf-8") as f: - recent_data = json.load(f) - # Convert to OrderedDict to maintain order - self.recent_emojis = OrderedDict(recent_data) - else: - # Create empty recent emojis file - self.recent_emojis = OrderedDict() - self._save_recent_emojis() - except Exception as e: - print(f"Error loading recent emoji data: {e}") - self.recent_emojis = OrderedDict() - - def _save_recent_emojis(self): - """Save recently used emojis to JSON file.""" - try: - # Ensure the cache directory exists - os.makedirs(data.CACHE_DIR, exist_ok=True) - - with open(self.recent_emoji_path, "w", encoding="utf-8") as f: - json.dump(dict(self.recent_emojis), f, ensure_ascii=False, indent=2) - except Exception as e: - print(f"Error saving recent emoji data: {e}") - - def _add_to_recent(self, emoji: str): - """Add an emoji to the recent list.""" - # Remove if already exists (to move it to front) - if emoji in self.recent_emojis: - del self.recent_emojis[emoji] - - # Add to front with current timestamp - self.recent_emojis[emoji] = time.time() - - # Keep only the most recent emojis - while len(self.recent_emojis) > self.max_recent_emojis: - # Remove the oldest item - self.recent_emojis.popitem(last=False) - - # Save to file - self._save_recent_emojis() - - def _copy_to_clipboard(self, emoji: str): - """Copy emoji to clipboard and track usage.""" - try: - # Try Wayland first - try: - subprocess.run(["wl-copy"], input=emoji.encode(), check=True) - except subprocess.SubprocessError: - # Fall back to X11 - subprocess.run( - ["xclip", "-selection", "clipboard"], - input=emoji.encode(), - check=True, - ) - - # Track this emoji as recently used - self._add_to_recent(emoji) - - except Exception as e: - print(f"Failed to copy to clipboard: {e}") - - def query(self, query_string: str) -> List[Result]: - """Search emojis based on query.""" - results = [] - query = query_string.lower().strip() - - # If no query, show recently used emojis - if not query: - if self.recent_emojis: - # Show recent emojis in reverse order (most recent first) - for emoji in reversed(list(self.recent_emojis.keys())): - if emoji in self.emoji_data: - emoji_info = self.emoji_data[emoji] - results.append( - self._create_emoji_result(emoji, emoji_info, 1.0) - ) - else: - # If no recent emojis, show some popular ones as fallback - popular_emojis = ["๐Ÿ˜€", "๐Ÿ‘", "โค๏ธ", "๐ŸŽ‰", "๐Ÿ”ฅ", "โœจ", "๐Ÿš€", "๐ŸŒˆ"] - for emoji in popular_emojis: - if emoji in self.emoji_data: - emoji_info = self.emoji_data[emoji] - results.append( - self._create_emoji_result(emoji, emoji_info, 1.0) - ) - return results - - # Search by name, group, or the emoji itself - for emoji, info in self.emoji_data.items(): - relevance = 0 - name = info.get("name", "").lower() - group = info.get("group", "").lower() - slug = info.get("slug", "").lower() - - # Exact match with emoji - if query == emoji: - relevance = 1.0 - # Name contains query - elif query in name: - relevance = 0.9 - # Slug contains query - elif query in slug: - relevance = 0.8 - # Group contains query - elif query in group: - relevance = 0.7 - - if relevance > 0: - results.append(self._create_emoji_result(emoji, info, relevance)) - - # Sort by relevance - results.sort(key=lambda x: x.relevance, reverse=True) - return results[:20] # Limit to 20 results - - def _create_emoji_result(self, emoji: str, info: Dict, relevance: float) -> Result: - """Create a Result object for an emoji.""" - name = info.get("name", "") - group = info.get("group", "") - - # Check if this is a recently used emoji - is_recent = emoji in self.recent_emojis - subtitle = f"{group}" + (" โ€ข Recently used" if is_recent else "") - - return Result( - title=name, # Show only the name, not the emoji - subtitle=subtitle, - icon_markup=emoji, # Use the emoji itself as the icon - action=lambda e=emoji: self._copy_to_clipboard(e), - relevance=relevance, - plugin_name=self.display_name, - data={"emoji": emoji, "name": name, "group": group, "recent": is_recent}, - ) diff --git a/modules/launcher/plugins/otp.py b/modules/launcher/plugins/otp.py deleted file mode 100644 index 7ab0b6de..00000000 --- a/modules/launcher/plugins/otp.py +++ /dev/null @@ -1,850 +0,0 @@ -import json -import subprocess -import threading -import time -from pathlib import Path -from typing import Dict, List, Optional - -from fabric.utils import get_relative_path -from modules.launcher.plugin_base import PluginBase -from modules.launcher.result import Result -from services.auth import ( - generate_totp, - get_time_remaining_with_blink, - parse_otpauth_uri, - scan_qr_and_add_account, - validate_base32_secret, -) - - -class OTPPlugin(PluginBase): - """Plugin for managing TOTP (Time-based One-Time Password) codes.""" - - def __init__(self): - super().__init__() - self.display_name = "OTP Manager" - self.description = "Manage TOTP codes and 2FA authentication" - - self.secrets_file = Path( - get_relative_path("../../../config/assets/accounts.json") - ) - self.secrets: Dict[str, Dict] = {} - self.last_update = 0 - - # Threading for auto-refresh - self.refresh_thread = None - self.stop_refresh = threading.Event() - - def initialize(self): - """Initialize the OTP plugin.""" - self.set_triggers(["otp"]) - self._load_secrets() - self._ensure_config_file() - self._start_refresh_thread() - - def cleanup(self): - """Cleanup the OTP plugin.""" - if self.refresh_thread and self.refresh_thread.is_alive(): - self.stop_refresh.set() - self.refresh_thread.join(timeout=1) - - def _load_secrets(self): - """Load secrets from JSON file.""" - try: - if self.secrets_file.exists(): - with open(self.secrets_file, "r", encoding="utf-8") as f: - self.secrets = json.load(f) - else: - self.secrets = {} - except Exception as e: - print(f"Error loading OTP secrets: {e}") - self.secrets = {} - - def _save_secrets(self): - """Save secrets to JSON file.""" - try: - self.secrets_file.parent.mkdir(parents=True, exist_ok=True) - with open(self.secrets_file, "w", encoding="utf-8") as f: - json.dump(self.secrets, f, indent=2) - except Exception as e: - print(f"Error saving OTP secrets: {e}") - - def _ensure_config_file(self): - """Ensure the config file exists.""" - if not self.secrets_file.exists(): - self.secrets_file.parent.mkdir(parents=True, exist_ok=True) - with open(self.secrets_file, "w", encoding="utf-8") as f: - json.dump({}, f, indent=2) - - def _start_refresh_thread(self): - """Start background thread for auto-refreshing tokens.""" - - def refresh_loop(): - while not self.stop_refresh.wait(5): - current_time = time.time() - if current_time - self.last_update >= 5: - self.last_update = current_time - # Only refresh if we have secrets and launcher is likely active - if self.secrets: - try: - self._selective_force_refresh() - except Exception: - pass - - self.refresh_thread = threading.Thread(target=refresh_loop, daemon=True) - self.refresh_thread.start() - - def _selective_force_refresh(self): - """Update time display in existing OTP result items.""" - try: - import gc - - from gi.repository import GLib - - def do_update(): - try: - for obj in gc.get_objects(): - if ( - hasattr(obj, "__class__") - and obj.__class__.__name__ == "Launcher" - and hasattr(obj, "results_box") - and hasattr(obj, "visible") - and obj.visible - and hasattr(obj, "results") - and obj.results - ): - has_otp_results = any( - result.data and result.data.get("type") == "totp" - for result in obj.results - if hasattr(result, "data") and result.data - ) - - if has_otp_results: - self._update_existing_result_labels(obj.results_box) - return False - except Exception: - pass - return False - - GLib.idle_add(do_update) - except Exception: - pass - - def _update_existing_result_labels(self, results_box): - """Update subtitle labels in existing ResultItem widgets.""" - try: - time_display = self._get_time_remaining_with_blink() - for child in results_box.get_children(): - if ( - hasattr(child, "__class__") - and child.__class__.__name__ == "ResultItem" - and hasattr(child, "result") - and hasattr(child.result, "data") - and child.result.data - and child.result.data.get("type") == "totp" - ): - self._update_result_item_content(child, time_display) - except Exception as e: - print(f"Error updating result labels: {e}") - - def _update_result_item_content(self, result_item, time_display): - """Update both the title (OTP code) and subtitle (time display) of a specific ResultItem.""" - try: - account_name = result_item.result.data.get("account", "") - if not account_name or account_name not in self.secrets: - return - - account_data = self.secrets[account_name] - secret = account_data.get("secret", "") - issuer = account_data.get("issuer", "") - display_name = f"{issuer} - {account_name}" if issuer else account_name - - current_totp_code = self._generate_totp(secret) - if not current_totp_code: - return - - old_code = result_item.result.data.get("code", "") - if current_totp_code != old_code: - result_item.result.data["code"] = current_totp_code - self._find_and_update_title_label(result_item, current_totp_code) - result_item.result.action = ( - lambda code=current_totp_code: self._copy_to_clipboard(code) - ) - - new_subtitle_markup = f"{display_name} โ€ข {time_display} remaining" - self._find_and_update_subtitle_label(result_item, new_subtitle_markup) - except Exception as e: - print(f"Error updating result item: {e}") - - def _find_and_update_title_label(self, result_item, new_title): - """Find the title label widget and update its text.""" - - def find_title_label(widget): - if hasattr(widget, "get_name") and widget.get_name() == "result-item-title": - return widget - if hasattr(widget, "get_children"): - for child in widget.get_children(): - found = find_title_label(child) - if found: - return found - return None - - title_label = find_title_label(result_item) - if title_label and hasattr(title_label, "set_label"): - title_label.set_label(new_title) - - def _find_and_update_subtitle_label(self, result_item, new_markup): - """Find the subtitle label widget and update its markup.""" - - def find_subtitle_label(widget): - if ( - hasattr(widget, "get_name") - and widget.get_name() == "result-item-subtitle" - ): - return widget - if hasattr(widget, "get_children"): - for child in widget.get_children(): - found = find_subtitle_label(child) - if found: - return found - return None - - subtitle_label = find_subtitle_label(result_item) - if subtitle_label and hasattr(subtitle_label, "set_markup"): - subtitle_label.set_markup(new_markup) - - def _copy_to_clipboard(self, text: str): - """Copy text to clipboard.""" - try: - try: - subprocess.run(["wl-copy"], input=text.encode(), check=True) - except subprocess.SubprocessError: - subprocess.run( - ["xclip", "-selection", "clipboard"], - input=text.encode(), - check=True, - ) - except Exception as e: - print(f"Failed to copy to clipboard: {e}") - - def _trigger_refresh(self): - """Trigger launcher refresh to return to default OTP view.""" - try: - from gi.repository import GLib - - # Use a small delay to ensure the action completes first - def trigger_refresh(): - try: - # Try to access the launcher through the fabric Application - from fabric import Application - - app = Application.get_default() - - if app and hasattr(app, "launcher"): - launcher = app.launcher - if launcher and hasattr(launcher, "search_entry"): - # Clear the search entry and set it to just "otp " - launcher.search_entry.set_text("otp ") - # Position cursor at the end - launcher.search_entry.set_position(-1) - # Trigger the search to show default OTP view - if hasattr(launcher, "_perform_search"): - launcher._perform_search("otp ") - return False - - # Fallback: try to find launcher instance through other means - import gc - - for obj in gc.get_objects(): - if ( - hasattr(obj, "__class__") - and obj.__class__.__name__ == "Launcher" - ): - if hasattr(obj, "search_entry") and hasattr( - obj, "_perform_search" - ): - obj.search_entry.set_text("otp ") - obj.search_entry.set_position(-1) - obj._perform_search("otp ") - return False - - except Exception as e: - print(f"Error forcing launcher refresh: {e}") - - return False # Don't repeat - - # Use a small delay to ensure the action completes first - GLib.timeout_add(50, trigger_refresh) - - except Exception as e: - print(f"Could not trigger refresh: {e}") - - def _remove_account_and_refresh(self, account_name: str): - """Remove an account and trigger refresh to return to default OTP view.""" - try: - if account_name in self.secrets: - # Remove the account - del self.secrets[account_name] - self._save_secrets() - - # Trigger refresh to return to default OTP view - self._trigger_refresh() - except Exception as e: - print(f"Error removing account {account_name}: {e}") - - def _generate_totp(self, secret: str) -> Optional[str]: - """Generate TOTP code from secret.""" - return generate_totp(secret) - - def _get_time_remaining_with_blink(self) -> str: - """Get time remaining with blinking effect.""" - return get_time_remaining_with_blink() - - def query(self, query_string: str) -> List[Result]: - """Process OTP queries.""" - query = query_string.strip() - - if not query: - return self._list_otp_codes() - - query_lower = query.lower() - if query_lower.startswith("add "): - add_content = query[4:].strip() - - # Handle both formats: with ``` and without ``` - if "```" in add_content: - # Old format: add account```secret``` - parts = add_content.split("```", 1) - if len(parts) == 2: - account_name = parts[0].strip() - secret_or_uri = parts[1].strip() - return self._handle_direct_add(account_name, secret_or_uri) - elif " " in add_content: - # New format: add account secret - parts = add_content.split(" ", 1) - if len(parts) == 2: - account_name = parts[0].strip() - secret_or_uri = parts[1].strip() - return self._handle_direct_add(account_name, secret_or_uri) - - return self._handle_add_command(add_content) - elif query_lower == "remove" or query_lower.startswith("remove "): - # Handle both "remove" and "remove accountname" - if query_lower == "remove": - remove_content = "" - else: - remove_content = query[7:].strip() - return self._handle_remove_command(remove_content) - elif query_lower == "qr" or query_lower.startswith("qr "): - # Handle QR scanning command - if query_lower == "qr": - qr_content = "" - else: - qr_content = query[3:].strip() - return self._handle_qr_command(qr_content) - else: - return self._search_accounts(query) - - def _handle_direct_add(self, account_name: str, secret_or_uri: str) -> List[Result]: - """Handle direct addition of OTP account.""" - if not account_name or not secret_or_uri: - return [ - Result( - title="Invalid format", - subtitle="Usage: add <account_name> <secret> or add <account_name>```<secret>```", - icon_name="info", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "help", "keep_launcher_open": True}, - ) - ] - - try: - if secret_or_uri.startswith("otpauth://"): - return self._handle_otpauth_uri(account_name, secret_or_uri) - else: - return self._handle_base32_secret(account_name, secret_or_uri) - except Exception as e: - print(f"OTP Debug: Error in _handle_direct_add: {e}") - return [ - Result( - title="Error adding account", - subtitle=f"Debug: {str(e)}", - icon_name="cancel", - action=lambda: None, - relevance=0.5, - plugin_name=self.display_name, - data={"type": "error", "keep_launcher_open": True}, - ) - ] - - def _list_otp_codes(self) -> List[Result]: - """List all OTP codes with current tokens.""" - results = [] - - if not self.secrets: - results.append( - Result( - title="No OTP accounts configured", - subtitle="Use 'add <account> <secret>' to add your first account", - icon_name="gtk-authentication-symbolic", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "empty", "keep_launcher_open": True}, - ) - ) - results.append( - Result( - title="Available commands:", - subtitle="add <account> <secret> | remove <account> | qr <account>", - icon_name="info", - action=lambda: None, - relevance=0.9, - plugin_name=self.display_name, - data={"type": "help", "keep_launcher_open": True}, - ) - ) - return results - - time_display = self._get_time_remaining_with_blink() - - for account_name, account_data in self.secrets.items(): - secret = account_data.get("secret", "") - issuer = account_data.get("issuer", "") - totp_code = self._generate_totp(secret) - - if totp_code: - display_name = f"{issuer} - {account_name}" if issuer else account_name - results.append( - Result( - title=f"{totp_code}", - subtitle_markup=f"{display_name} โ€ข { - time_display - } remaining โ€ข Shift+Enter: remove", - icon_name="gtk-authentication-symbolic", - action=lambda code=totp_code: self._copy_to_clipboard(code), - relevance=1.0, - plugin_name=self.display_name, - data={ - "type": "totp", - "account": account_name, - "code": totp_code, - "alt_action": lambda acc=account_name: self._remove_account_and_refresh( - acc - ), - }, - ) - ) - else: - results.append( - Result( - title=f"Error: {account_name}", - subtitle="Invalid secret or configuration", - icon_name="dialog-cancel-symbolic", - action=lambda: None, - relevance=0.5, - plugin_name=self.display_name, - data={ - "type": "error", - "account": account_name, - "keep_launcher_open": True, - }, - ) - ) - - return results - - def _search_accounts(self, query: str) -> List[Result]: - """Search accounts by name or issuer.""" - results = [] - query_lower = query.lower() - - for account_name, account_data in self.secrets.items(): - issuer = account_data.get("issuer", "").lower() - account_lower = account_name.lower() - - if query_lower in account_lower or query_lower in issuer: - secret = account_data.get("secret", "") - totp_code = self._generate_totp(secret) - - if totp_code: - display_name = ( - f"{account_data.get('issuer', '')} - {account_name}" - if account_data.get("issuer") - else account_name - ) - time_display = self._get_time_remaining_with_blink() - - results.append( - Result( - title=f"{totp_code}", - subtitle_markup=f"{display_name} โ€ข { - time_display - } remaining โ€ข Shift+Enter: remove", - icon_name="gtk-authentication-symbolic", - action=lambda code=totp_code: self._copy_to_clipboard(code), - relevance=1.0, - plugin_name=self.display_name, - data={ - "type": "totp", - "account": account_name, - "code": totp_code, - "alt_action": lambda acc=account_name: self._remove_account_and_refresh( - acc - ), - }, - ) - ) - - if not results: - results.append( - Result( - title=f"No accounts found for '{query}'", - subtitle="Use 'add <account> <secret>' to create new account", - icon_name="edit-find-symbolic", - action=lambda: None, - relevance=0.5, - plugin_name=self.display_name, - data={"type": "no_results", "keep_launcher_open": True}, - ) - ) - results.append( - Result( - title="Available commands:", - subtitle="add <account> <secret> | remove <account> | qr <account>", - icon_name="info", - action=lambda: None, - relevance=0.4, - plugin_name=self.display_name, - data={"type": "help", "keep_launcher_open": True}, - ) - ) - - return results - - def _handle_add_command(self, account_name: str) -> List[Result]: - """Handle manual addition of OTP secret.""" - if not account_name: - return [ - Result( - title="Enter account name", - subtitle="Usage: add <account_name> <secret>", - icon_name="dialog-question-symbolic", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "help", "keep_launcher_open": True}, - ) - ] - - return [ - Result( - title=f"To add '{account_name}':", - subtitle=f"Type: add {account_name} <secret>", - icon_name="info", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={ - "type": "instruction", - "account": account_name, - "keep_launcher_open": True, - }, - ), - Result( - title="Base32 Secret Format:", - subtitle="Example: add gmail JBSWY3DPEHPK3PXP", - icon_name="info", - action=lambda: None, - relevance=0.9, - plugin_name=self.display_name, - data={"type": "help", "keep_launcher_open": True}, - ), - Result( - title="otpauth URI Format:", - subtitle="Example: add github otpauth://totp/GitHub:user?secret=JBSWY3DPEHPK3PXP", - icon_name="info", - action=lambda: None, - relevance=0.9, - plugin_name=self.display_name, - data={"type": "help", "keep_launcher_open": True}, - ), - Result( - title="Remove Account:", - subtitle="Example: remove gmail", - icon_name="trash", - action=lambda: None, - relevance=0.8, - plugin_name=self.display_name, - data={"type": "help", "keep_launcher_open": True}, - ), - ] - - def _handle_qr_command(self, account_name: str) -> List[Result]: - """Handle QR scanning command.""" - if not account_name: - return [ - Result( - title="Scan QR Code", - subtitle="Click to scan QR code from screen", - icon_name="view-barcode-qr-symbolic", - action=lambda: self._scan_qr_and_add_account(""), - relevance=1.0, - plugin_name=self.display_name, - data={"type": "qr_scan"}, - ), - Result( - title="QR Scan Instructions:", - subtitle="Use 'qr <account_name>' to specify account name", - icon_name="info", - action=lambda: None, - relevance=0.9, - plugin_name=self.display_name, - data={"type": "help", "keep_launcher_open": True}, - ), - ] - else: - return [ - Result( - title=f"Scan QR Code for '{account_name}'", - subtitle="Click to scan QR code from screen", - icon_name="view-barcode-qr-symbolic", - action=lambda name=account_name: self._scan_qr_and_add_account( - name - ), - relevance=1.0, - plugin_name=self.display_name, - data={"type": "qr_scan"}, - ), - ] - - def _scan_qr_and_add_account(self, account_name: str): - """Scan QR code and add OTP account.""" - print(f"QR scan action called for account: '{account_name}'") - print("Starting QR scan process asynchronously...") - - # Run QR scanning asynchronously so launcher closes immediately - import threading - - thread = threading.Thread(target=self._scan_qr_async, args=(account_name,)) - thread.daemon = True - thread.start() - - def _scan_qr_async(self, account_name: str): - """Async QR scanning process.""" - result = scan_qr_and_add_account(account_name, str(self.secrets_file)) - - if result["success"]: - print(result["message"]) - # Reload secrets from file - self._load_secrets() - # Trigger refresh to show the new account - self._trigger_refresh() - else: - print(f"QR scan failed: {result['error']}") - - def _handle_remove_command(self, account_name: str) -> List[Result]: - """Handle removal of OTP account.""" - if not account_name: - # Show all available accounts for removal - results = [] - - if not self.secrets: - results.append( - Result( - title="No OTP accounts to remove", - subtitle="Use 'add <account> <secret>' to add accounts first", - icon_name="info", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "empty", "keep_launcher_open": True}, - ) - ) - else: - results.append( - Result( - title="Select account to remove:", - subtitle="Type: remove <account_name> to remove an account", - icon_name="user-trash-symbolic", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "help", "keep_launcher_open": True}, - ) - ) - - # Get time display for consistency with main OTP view - time_display = self._get_time_remaining_with_blink() - - # Show all accounts with their current OTP codes and remove actions - for acc_name, account_data in self.secrets.items(): - secret = account_data.get("secret", "") - issuer = account_data.get("issuer", "") - display_name = f"{issuer} - {acc_name}" if issuer else acc_name - - # Generate current TOTP code - totp_code = self._generate_totp(secret) - - if totp_code: - results.append( - Result( - title=f"{totp_code}", - subtitle_markup=f"Press Enter to remove โ€ข { - time_display - } remaining", - icon_name="user-trash-symbolic", - action=lambda acc=acc_name: self._remove_account_and_refresh( - acc - ), - relevance=0.9, - plugin_name=self.display_name, - data={ - "type": "remove_instruction", - "account": acc_name, - "code": totp_code, - "keep_launcher_open": True, - }, - ) - ) - else: - results.append( - Result( - title=f"Error: {acc_name}", - subtitle="Press Enter to remove (Invalid secret)", - icon_name="user-trash-symbolic", - action=lambda acc=acc_name: self._remove_account_and_refresh( - acc - ), - relevance=0.8, - plugin_name=self.display_name, - data={ - "type": "remove_instruction", - "account": acc_name, - "keep_launcher_open": True, - }, - ) - ) - - return results - - # Check if account exists - if account_name not in self.secrets: - return [ - Result( - title=f"Account '{account_name}' not found", - subtitle="Use 'remove' to see all available accounts", - icon_name="dialog-cancel-symbolic", - action=lambda: None, - relevance=0.5, - plugin_name=self.display_name, - data={"type": "error", "keep_launcher_open": True}, - ) - ] - - # Get account info for confirmation - account_data = self.secrets[account_name] - issuer = account_data.get("issuer", "") - display_name = f"{issuer} - {account_name}" if issuer else account_name - - # Show confirmation for removal - return [ - Result( - title=f"Remove '{display_name}'?", - subtitle="Press Enter to confirm removal", - icon_name="user-trash-symbolic", - action=lambda acc=account_name: self._remove_account_and_refresh(acc), - relevance=1.0, - plugin_name=self.display_name, - data={ - "type": "remove_confirm", - "account": account_name, - "keep_launcher_open": True, - }, - ) - ] - - def _handle_base32_secret(self, account_name: str, secret: str) -> List[Result]: - """Handle raw Base32 secret.""" - result = validate_base32_secret(secret) - - if not result["success"]: - return [ - Result( - title="Invalid Base32 secret", - subtitle=result["error"], - icon_name="dialog-cancel-symbolic", - action=lambda: None, - relevance=0.5, - plugin_name=self.display_name, - data={"type": "error", "keep_launcher_open": True}, - ) - ] - - self.secrets[account_name] = { - "secret": result["secret"], - "issuer": "", - "algorithm": "SHA1", - "digits": 6, - "period": 30, - } - self._save_secrets() - - return [ - Result( - title=f"โœ“ Added '{account_name}'", - subtitle=f"OTP account added successfully (secret: { - result['secret'][:4] - }...)", - icon_name="emblem-ok-symbolic", - action=lambda: self._trigger_refresh(), - relevance=1.0, - plugin_name=self.display_name, - data={"type": "success", "keep_launcher_open": True}, - ) - ] - - def _handle_otpauth_uri(self, account_name: str, uri: str) -> List[Result]: - """Handle otpauth:// URI.""" - result = parse_otpauth_uri(uri, account_name) - - if not result["success"]: - return [ - Result( - title="Error parsing otpauth URI", - subtitle=result["error"], - icon_name="dialog-cancel-symbolic", - action=lambda: None, - relevance=0.5, - plugin_name=self.display_name, - data={"type": "error", "keep_launcher_open": True}, - ) - ] - - self.secrets[result["account_name"]] = { - "secret": result["secret"], - "issuer": result["issuer"], - "algorithm": result["algorithm"], - "digits": result["digits"], - "period": result["period"], - } - self._save_secrets() - - display_name = ( - f"{result['issuer']} - {result['account_name']}" - if result["issuer"] - else result["account_name"] - ) - return [ - Result( - title=f"โœ“ Added '{display_name}'", - subtitle="OTP account added from URI", - icon_name="emblem-ok-symbolic", - action=lambda: self._trigger_refresh(), - relevance=1.0, - plugin_name=self.display_name, - data={"type": "success", "keep_launcher_open": True}, - ) - ] diff --git a/modules/launcher/plugins/password.py b/modules/launcher/plugins/password.py deleted file mode 100644 index 1551b200..00000000 --- a/modules/launcher/plugins/password.py +++ /dev/null @@ -1,690 +0,0 @@ -import base64 -import json -import subprocess -import threading -import time -from datetime import datetime -from pathlib import Path -from typing import Dict, List, Optional - -from fabric.utils import get_relative_path -from modules.launcher.plugin_base import PluginBase -from modules.launcher.result import Result - - -class PasswordManager: - """Simple password manager with basic encryption and caching.""" - - def __init__(self, storage_file: Path): - self.storage_file = storage_file - self.passwords: Dict[str, Dict] = {} - self._cache_lock = threading.Lock() - self._last_loaded = 0 - self._cache_ttl = 30 # Cache for 30 seconds - self._load_passwords() - - def _simple_encrypt(self, text: str, key: str = "modus_pass") -> str: - """Simple encryption using XOR with base64 encoding.""" - key_bytes = key.encode("utf-8") - text_bytes = text.encode("utf-8") - - # XOR encryption - encrypted = bytearray() - for i, byte in enumerate(text_bytes): - encrypted.append(byte ^ key_bytes[i % len(key_bytes)]) - - # Base64 encode - return base64.b64encode(encrypted).decode("utf-8") - - def _simple_decrypt(self, encrypted_text: str, key: str = "modus_pass") -> str: - """Simple decryption using XOR with base64 decoding.""" - try: - key_bytes = key.encode("utf-8") - - # Base64 decode - encrypted_bytes = base64.b64decode(encrypted_text.encode("utf-8")) - - # XOR decryption - decrypted = bytearray() - for i, byte in enumerate(encrypted_bytes): - decrypted.append(byte ^ key_bytes[i % len(key_bytes)]) - - return decrypted.decode("utf-8") - except Exception: - return encrypted_text # Return as-is if decryption fails - - def _load_passwords(self): - """Load passwords from JSON file with caching.""" - with self._cache_lock: - current_time = time.time() - - # Check if cache is still valid - if (current_time - self._last_loaded) < self._cache_ttl and self.passwords: - return - - try: - if self.storage_file.exists(): - with open(self.storage_file, "r", encoding="utf-8") as f: - data = json.load(f) - self.passwords = data.get("passwords", {}) - else: - self.passwords = {} - - self._last_loaded = current_time - except Exception as e: - print(f"Error loading passwords: {e}") - self.passwords = {} - - def _save_passwords(self): - """Save passwords to JSON file.""" - with self._cache_lock: - try: - self.storage_file.parent.mkdir(parents=True, exist_ok=True) - data = { - "passwords": self.passwords, - "last_modified": datetime.now().isoformat(), - } - with open(self.storage_file, "w", encoding="utf-8") as f: - json.dump(data, f, indent=2) - - # Update cache timestamp - self._last_loaded = time.time() - except Exception as e: - print(f"Error saving passwords: {e}") - - def add_password(self, name: str, password: str, description: str = "") -> bool: - """Add a new password entry.""" - try: - encrypted_password = self._simple_encrypt(password) - self.passwords[name] = { - "password": encrypted_password, - "description": description, - "created": datetime.now().isoformat(), - "last_accessed": None, - } - self._save_passwords() - return True - except Exception as e: - print(f"Error adding password: {e}") - return False - - def get_password(self, name: str, update_access_time: bool = True) -> Optional[str]: - """Get decrypted password by name.""" - # Ensure we have fresh data - self._load_passwords() - - if name in self.passwords: - try: - encrypted = self.passwords[name]["password"] - decrypted = self._simple_decrypt(encrypted) - - # Update last accessed time only if requested (to avoid frequent saves) - if update_access_time: - self.passwords[name]["last_accessed"] = datetime.now().isoformat() - # Don't save immediately - batch saves for better performance - - return decrypted - except Exception as e: - print(f"Error decrypting password: {e}") - return None - return None - - def remove_password(self, name: str) -> bool: - """Remove a password entry.""" - if name in self.passwords: - del self.passwords[name] - self._save_passwords() - return True - return False - - def list_passwords(self) -> List[str]: - """Get list of all password names.""" - # Ensure we have fresh data - self._load_passwords() - return list(self.passwords.keys()) - - def get_password_info(self, name: str) -> Optional[Dict]: - """Get password metadata without decrypting.""" - if name in self.passwords: - info = self.passwords[name].copy() - info.pop("password", None) # Remove encrypted password - return info - return None - - -class PasswordPlugin(PluginBase): - """ - Password manager plugin for the launcher. - Stores passwords securely and allows easy access. - """ - - def __init__(self): - super().__init__() - self.display_name = "Password Manager" - self.description = "Secure password storage and management" - - # Initialize password manager - self.password_file = Path( - get_relative_path("../../../config/assets/passwords.json") - ) - self.password_manager = PasswordManager(self.password_file) - - # State for password visibility - self.revealed_passwords: Dict[str, str] = {} - - # Cache for results to avoid repeated queries - self._results_cache: Dict[str, List[Result]] = {} - self._cache_timestamps: Dict[str, float] = {} - self._cache_ttl = 5 # Cache results for 5 seconds - - # Track launcher state for auto-hiding passwords - self._launcher_instance = None - - def initialize(self): - """Initialize the password plugin.""" - self.set_triggers(["pass"]) - self._setup_launcher_hooks() - - def cleanup(self): - """Cleanup the password plugin.""" - self.revealed_passwords.clear() - self._results_cache.clear() - self._cache_timestamps.clear() - self._cleanup_launcher_hooks() - - def query(self, query_string: str) -> List[Result]: - """Process password manager queries with caching.""" - query_key = query_string.strip() - current_time = time.time() - - # Check cache first (except for add/remove commands which should always execute) - if ( - not query_key.startswith(("add ", "remove ", "delete ")) - and query_key in self._results_cache - and (current_time - self._cache_timestamps.get(query_key, 0)) - < self._cache_ttl - ): - return self._results_cache[query_key] - - results = [] - query = query_key.lower() - - # Handle different commands - if not query: - # Show all passwords - results.extend(self._list_all_passwords()) - elif query.startswith("add "): - # Add new password (don't cache) - results.extend(self._handle_add_command(query_string)) - elif query.startswith("remove ") or query.startswith("delete "): - # Remove password (don't cache) - results.extend(self._handle_remove_command(query_string)) - else: - # Search for specific password - results.extend(self._search_passwords(query)) - - # Cache results (except for add/remove commands) - if not query.startswith(("add ", "remove ", "delete ")): - self._results_cache[query_key] = results - self._cache_timestamps[query_key] = current_time - - return results - - def _list_all_passwords(self) -> List[Result]: - """List all stored passwords.""" - results = [] - password_names = self.password_manager.list_passwords() - - if not password_names: - results.append( - Result( - title="No passwords stored", - subtitle="Use 'pass add <name> <password>' to add your first password", - icon_name="password-symbolic", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "empty", "keep_launcher_open": True}, - ) - ) - results.append( - Result( - title="Available commands:", - subtitle="add <name> <password> | remove <name> | <name> (to search)", - icon_name="info", - action=lambda: None, - relevance=0.9, - plugin_name=self.display_name, - data={"type": "help", "keep_launcher_open": True}, - ) - ) - return results - - # Sort passwords alphabetically - password_names.sort() - - for name in password_names: - info = self.password_manager.get_password_info(name) - description = info.get("description", "") if info else "" - - # Check if password is revealed - if name in self.revealed_passwords: - title = f"{name}: {self.revealed_passwords[name]}" - subtitle = "Password revealed - Enter: copy | Shift+Enter: hide" - else: - title = f"{name}: {'*' * 8}" - subtitle = "Enter: copy | Shift+Enter: reveal password" - - if description: - subtitle += f" | {description}" - - results.append( - Result( - title=title, - subtitle=subtitle, - icon_name="key", - action=lambda n=name: self._copy_password_to_clipboard(n), - relevance=1.0, - plugin_name=self.display_name, - data={ - "type": "password", - "name": name, - "keep_launcher_open": False, - "alt_action": lambda n=name: self._toggle_password_visibility( - n - ), - }, - ) - ) - - return results - - def _handle_add_command(self, query_string: str) -> List[Result]: - """Handle add password command.""" - results = [] - parts = query_string.strip().split(" ", 3) - - if len(parts) < 3: - results.append( - Result( - title="Add Password - Invalid format", - subtitle="Usage: add <name> <password> [description]", - icon_name="cancel", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "error", "keep_launcher_open": True}, - ) - ) - return results - - name = parts[1] - password = parts[2] - description = parts[3] if len(parts) > 3 else "" - - # Check if password already exists - if name in self.password_manager.list_passwords(): - results.append( - Result( - title=f"Update password for '{name}'?", - subtitle="Password already exists. Click to update it.", - icon_name="key", - action=lambda: self._add_password_action( - name, password, description, update=True - ), - relevance=1.0, - plugin_name=self.display_name, - data={"type": "update", "name": name, "keep_launcher_open": False}, - ) - ) - else: - results.append( - Result( - title=f"Add password for '{name}'", - subtitle="Click to save password" - + (f" | {description}" if description else ""), - icon_name="plus", - action=lambda: self._add_password_action( - name, password, description - ), - relevance=1.0, - plugin_name=self.display_name, - data={"type": "add", "name": name, "keep_launcher_open": False}, - ) - ) - - return results - - def _handle_remove_command(self, query_string: str) -> List[Result]: - """Handle remove password command.""" - results = [] - parts = query_string.strip().split(" ", 1) - - if len(parts) < 2: - results.append( - Result( - title="Remove Password - Invalid format", - subtitle="Usage: remove <name>", - icon_name="cancel", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "error", "keep_launcher_open": True}, - ) - ) - return results - - name = parts[1] - - if name not in self.password_manager.list_passwords(): - results.append( - Result( - title=f"Password '{name}' not found", - subtitle="Check the name and try again", - icon_name="cancel", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "error", "keep_launcher_open": True}, - ) - ) - else: - results.append( - Result( - title=f"Remove password '{name}'?", - subtitle="Click to confirm deletion (this cannot be undone)", - icon_name="trash", - action=lambda: self._remove_password_action(name), - relevance=1.0, - plugin_name=self.display_name, - data={"type": "remove", "name": name, "keep_launcher_open": False}, - ) - ) - - return results - - def _search_passwords(self, query: str) -> List[Result]: - """Search for passwords by name.""" - results = [] - password_names = self.password_manager.list_passwords() - - # Filter passwords that match the query - matching_passwords = [ - name for name in password_names if query.lower() in name.lower() - ] - - if not matching_passwords: - results.append( - Result( - title=f"No passwords found matching '{query}'", - subtitle="Try a different search term or use 'pass' to see all passwords", - icon_name="magnifier", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "no_results", "keep_launcher_open": True}, - ) - ) - return results - - # Sort by relevance (exact match first, then starts with, then contains) - def get_relevance(name: str) -> float: - name_lower = name.lower() - query_lower = query.lower() - - if name_lower == query_lower: - return 1.0 - elif name_lower.startswith(query_lower): - return 0.9 - else: - return 0.7 - - matching_passwords.sort(key=get_relevance, reverse=True) - - for name in matching_passwords: - info = self.password_manager.get_password_info(name) - description = info.get("description", "") if info else "" - - # Check if password is revealed - if name in self.revealed_passwords: - title = f"{name}: {self.revealed_passwords[name]}" - subtitle = "Password revealed - Enter: copy | Shift+Enter: hide" - else: - title = f"{name}: {'*' * 8}" - subtitle = "Enter: copy | Shift+Enter: reveal password" - - if description: - subtitle += f" | {description}" - - results.append( - Result( - title=title, - subtitle=subtitle, - icon_name="key", - action=lambda n=name: self._copy_password_to_clipboard(n), - relevance=get_relevance(name), - plugin_name=self.display_name, - data={ - "type": "password", - "name": name, - "keep_launcher_open": False, - "alt_action": lambda n=name: self._toggle_password_visibility( - n - ), - }, - ) - ) - - return results - - def _add_password_action( - self, name: str, password: str, description: str = "", update: bool = False - ): - """Action to add/update a password.""" - try: - success = self.password_manager.add_password(name, password, description) - if success: - action_word = "updated" if update else "added" - # Clear cache to force refresh - self._results_cache.clear() - - # Send notification if available (non-blocking) - try: - subprocess.Popen( - [ - "notify-send", - "Password Manager", - f"Password '{name}' {action_word} successfully", - ] - ) - except: - pass - else: - print(f"Failed to add password '{name}'") - except Exception as e: - print(f"Error adding password: {e}") - - def _remove_password_action(self, name: str): - """Action to remove a password.""" - try: - success = self.password_manager.remove_password(name) - if success: - # Remove from revealed passwords if present - self.revealed_passwords.pop(name, None) - - # Clear cache to force refresh - self._results_cache.clear() - - # Send notification if available (non-blocking) - try: - subprocess.Popen( - [ - "notify-send", - "Password Manager", - f"Password '{name}' removed successfully", - ] - ) - except: - pass - else: - print(f"Failed to remove password '{name}'") - except Exception as e: - print(f"Error removing password: {e}") - - def _copy_password_to_clipboard(self, name: str): - """Copy password to clipboard and reveal it temporarily.""" - try: - password = self.password_manager.get_password( - name, update_access_time=False - ) - if password: - # Copy to clipboard (use timeout to avoid hanging) - try: - subprocess.run( - ["wl-copy"], input=password.encode(), check=True, timeout=2 - ) - except subprocess.SubprocessError: - # Fall back to X11 - subprocess.run( - ["xclip", "-selection", "clipboard"], - input=password.encode(), - check=True, - timeout=2, - ) - - # Reveal password temporarily - self.revealed_passwords[name] = password - - # Send notification if available (non-blocking) - try: - subprocess.Popen( - [ - "notify-send", - "Password Manager", - f"Password for '{name}' copied to clipboard", - ] - ) - except: - pass - - # Clear cache to force refresh - self._results_cache.clear() - - else: - print(f"Failed to retrieve password for '{name}'") - except Exception as e: - print(f"Error copying password: {e}") - - def _reveal_password(self, name: str): - """Reveal password without copying to clipboard.""" - try: - password = self.password_manager.get_password( - name, update_access_time=False - ) - if password: - self.revealed_passwords[name] = password - # Clear cache to force refresh with revealed password - self._results_cache.clear() - else: - print(f"Failed to retrieve password for '{name}'") - except Exception as e: - print(f"Error revealing password: {e}") - - def _hide_password(self, name: str): - """Hide revealed password.""" - self.revealed_passwords.pop(name, None) - - def _hide_all_passwords(self): - """Hide all revealed passwords.""" - if self.revealed_passwords: - self.revealed_passwords.clear() - # Clear cache to force refresh with hidden passwords - self._results_cache.clear() - - def _setup_launcher_hooks(self): - """Setup hooks to monitor launcher state.""" - try: - # Try to find the launcher instance - import gc - - for obj in gc.get_objects(): - if ( - hasattr(obj, "__class__") - and obj.__class__.__name__ == "Launcher" - and hasattr(obj, "close_launcher") - ): - self._launcher_instance = obj - # Store original close_launcher method - self._original_close_launcher = obj.close_launcher - # Replace with our wrapper - obj.close_launcher = self._wrapped_close_launcher - break - except Exception as e: - print(f"Warning: Could not setup launcher hooks: {e}") - - def _cleanup_launcher_hooks(self): - """Cleanup launcher hooks.""" - try: - if self._launcher_instance and hasattr(self, "_original_close_launcher"): - # Restore original close_launcher method - self._launcher_instance.close_launcher = self._original_close_launcher - self._launcher_instance = None - except Exception as e: - print(f"Warning: Could not cleanup launcher hooks: {e}") - - def _wrapped_close_launcher(self): - """Wrapper for launcher close that hides passwords.""" - # Hide all passwords when launcher closes - self._hide_all_passwords() - # Call original close_launcher method - if hasattr(self, "_original_close_launcher"): - self._original_close_launcher() - - def _toggle_password_visibility(self, name: str): - """Toggle password visibility when Shift+Enter is pressed.""" - if name in self.revealed_passwords: - # Hide password - self.revealed_passwords.pop(name, None) - self._results_cache.clear() - else: - # Reveal password - try: - password = self.password_manager.get_password( - name, update_access_time=False - ) - if password: - self.revealed_passwords[name] = password - self._results_cache.clear() - else: - print(f"Failed to retrieve password for '{name}'") - except Exception as e: - print(f"Error revealing password: {e}") - - # Force refresh of the launcher to show updated state - self._force_launcher_refresh() - - def _force_launcher_refresh(self): - """Force the launcher to refresh and show updated results.""" - try: - if self._launcher_instance and hasattr( - self._launcher_instance, "_perform_search" - ): - # Get current search text - current_text = "" - if hasattr(self._launcher_instance, "search_entry"): - current_text = self._launcher_instance.search_entry.get_text() - - # Trigger a search to refresh results - try: - from gi.repository import GLib - - def refresh(): - self._launcher_instance._perform_search(current_text) - return False - - GLib.timeout_add(50, refresh) - except ImportError: - # Fallback: direct call if GLib not available - self._launcher_instance._perform_search(current_text) - except Exception as e: - print(f"Could not force launcher refresh: {e}") diff --git a/modules/launcher/plugins/reminders.py b/modules/launcher/plugins/reminders.py deleted file mode 100644 index ded8c20b..00000000 --- a/modules/launcher/plugins/reminders.py +++ /dev/null @@ -1,448 +0,0 @@ -import re -import subprocess -import threading -from datetime import datetime, timedelta -from typing import Dict, List, Optional - -from modules.launcher.plugin_base import PluginBase -from modules.launcher.result import Result - - -class Reminder: - """ - Represents a single reminder with its timer and metadata. - """ - - def __init__( - self, - reminder_id: int, - message: str, - target_time: datetime, - timer: threading.Timer, - ): - self.id = reminder_id - self.message = message - self.target_time = target_time - self.timer = timer - self.created_time = datetime.now() - - def cancel(self): - """Cancel this reminder.""" - if self.timer: - self.timer.cancel() - - def get_time_remaining(self) -> str: - """Get formatted time remaining until reminder.""" - now = datetime.now() - if self.target_time <= now: - return "Overdue" - - delta = self.target_time - now - total_seconds = int(delta.total_seconds()) - - if total_seconds < 60: - return f"{total_seconds}s" - elif total_seconds < 3600: - minutes = total_seconds // 60 - seconds = total_seconds % 60 - return f"{minutes}m {seconds}s" if seconds > 0 else f"{minutes}m" - else: - hours = total_seconds // 3600 - minutes = (total_seconds % 3600) // 60 - return f"{hours}h {minutes}m" if minutes > 0 else f"{hours}h" - - def get_target_time_str(self) -> str: - """Get formatted target time.""" - return self.target_time.strftime("%H:%M") - - -class RemindersPlugin(PluginBase): - """ - Time-based reminders plugin for the launcher. - """ - - def __init__(self): - super().__init__() - self.display_name = "Reminders" - self.description = "Set time-based reminders with notifications" - self.reminders: Dict[int, Reminder] = {} - self.next_id = 1 - - # Regex patterns for time parsing - self.time_patterns = { - "relative_time": re.compile(r"^(\d+)([smhd])$"), # 5m, 30s, 2h, 1d - "absolute_time": re.compile(r"^(\d{1,2}):(\d{2})$"), # 14:30, 9:15 - "relative_with_unit": re.compile( - r"^(\d+)\s*(min|mins|minute|minutes|hour|hours|sec|seconds|day|days)$", - re.IGNORECASE, - ), - } - - def initialize(self): - """Initialize the reminders plugin.""" - self.set_triggers(["remind"]) - - def cleanup(self): - """Cleanup the reminders plugin.""" - # Cancel all active reminders - for reminder in self.reminders.values(): - reminder.cancel() - self.reminders.clear() - - def _send_notification(self, title: str, message: str): - """Send a desktop notification using notify-send.""" - try: - subprocess.run( - ["notify-send", "-a", "Reminders", "-i", "alarm-clock", title, message], - check=False, - ) - except Exception as e: - print(f"Failed to send notification: {e}") - - def _parse_time_input(self, time_str: str) -> Optional[datetime]: - """ - Parse various time input formats and return target datetime. - - Supported formats: - - 5m, 30s, 2h, 1d (relative time) - - 14:30, 9:15 (absolute time today) - - 5 minutes, 2 hours (relative with full unit names) - """ - time_str = time_str.strip().lower() - - # Try relative time format (5m, 30s, 2h, 1d) - match = self.time_patterns["relative_time"].match(time_str) - if match: - value, unit = match.groups() - value = int(value) - - if unit == "s": - delta = timedelta(seconds=value) - elif unit == "m": - delta = timedelta(minutes=value) - elif unit == "h": - delta = timedelta(hours=value) - elif unit == "d": - delta = timedelta(days=value) - else: - return None - - return datetime.now() + delta - - # Try absolute time format (14:30, 9:15) - match = self.time_patterns["absolute_time"].match(time_str) - if match: - hour, minute = map(int, match.groups()) - if 0 <= hour <= 23 and 0 <= minute <= 59: - target = datetime.now().replace( - hour=hour, minute=minute, second=0, microsecond=0 - ) - # If the time has already passed today, schedule for tomorrow - if target <= datetime.now(): - target += timedelta(days=1) - return target - - # Try relative time with full unit names - match = self.time_patterns["relative_with_unit"].match(time_str) - if match: - value, unit = match.groups() - value = int(value) - unit = unit.lower() - - if unit in ["sec", "seconds"]: - delta = timedelta(seconds=value) - elif unit in ["min", "mins", "minute", "minutes"]: - delta = timedelta(minutes=value) - elif unit in ["hour", "hours"]: - delta = timedelta(hours=value) - elif unit in ["day", "days"]: - delta = timedelta(days=value) - else: - return None - - return datetime.now() + delta - - return None - - def _create_reminder(self, time_str: str, message: str) -> Optional[Reminder]: - """Create a new reminder with the given time and message.""" - target_time = self._parse_time_input(time_str) - if not target_time: - return None - - # Calculate delay in seconds - delay = (target_time - datetime.now()).total_seconds() - if delay <= 0: - return None - - # Create timer that will trigger the notification - timer = threading.Timer(delay, self._trigger_reminder, [self.next_id, message]) - - # Create reminder object - reminder = Reminder(self.next_id, message, target_time, timer) - - # Store reminder and start timer - self.reminders[self.next_id] = reminder - timer.start() - - # Increment ID for next reminder - self.next_id += 1 - - return reminder - - def _trigger_reminder(self, reminder_id: int, message: str): - """Trigger a reminder notification and remove it from active reminders.""" - # Send notification - self._send_notification("โฐ Reminder", message) - - # Remove from active reminders - if reminder_id in self.reminders: - del self.reminders[reminder_id] - - def _cancel_reminder(self, reminder_id: Optional[int] = None) -> int: - """Cancel a specific reminder or all reminders. Returns number of cancelled reminders.""" - if reminder_id is not None: - if reminder_id in self.reminders: - self.reminders[reminder_id].cancel() - del self.reminders[reminder_id] - return 1 - return 0 - else: - # Cancel all reminders - count = len(self.reminders) - for reminder in self.reminders.values(): - reminder.cancel() - self.reminders.clear() - return count - - def _format_time_remaining(self, total_seconds: float) -> str: - """Format time remaining in a human-readable way.""" - total_seconds = int(total_seconds) - - if total_seconds < 60: - return f"{total_seconds}s" - elif total_seconds < 3600: - minutes = total_seconds // 60 - seconds = total_seconds % 60 - return f"{minutes}m {seconds}s" if seconds > 0 else f"{minutes}m" - else: - hours = total_seconds // 3600 - minutes = (total_seconds % 3600) // 60 - return f"{hours}h {minutes}m" if minutes > 0 else f"{hours}h" - - def _create_and_confirm_reminder(self, time_str: str, message: str): - """Actually create the reminder when the user presses Enter.""" - reminder = self._create_reminder(time_str, message) - if reminder: - time_remaining = reminder.get_time_remaining() - self._send_notification( - "โœ… Reminder Created", f"Reminder set for {time_remaining}: {message}" - ) - else: - self._send_notification( - "โŒ Failed to Create Reminder", "The specified time may be in the past" - ) - - def query(self, query_string: str) -> List[Result]: - """Process reminder queries.""" - results = [] - query = query_string.strip() - - if not query: - # Show help and active reminders count - active_count = len(self.reminders) - results.append( - Result( - title="Reminders Help", - subtitle=f"Active reminders: { - active_count - } | Usage: remind 5m Take a break", - icon_name="alarm-timer", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "help"}, - ) - ) - - # Show quick examples - examples = [ - ("remind 5m Take a break", "Set 5 minute reminder"), - ("remind 14:30 Meeting", "Set reminder for 2:30 PM"), - ("remind list", "List active reminders"), - ("remind cancel", "Cancel all reminders"), - ] - - for example, desc in examples: - results.append( - Result( - title=example, - subtitle=desc, - icon_name="alarm-timer", - action=lambda: None, - relevance=0.8, - plugin_name=self.display_name, - data={"type": "example"}, - ) - ) - - return results - - # Handle list command - if query.lower() in ["list", "ls", "show"]: - if not self.reminders: - results.append( - Result( - title="No Active Reminders", - subtitle="Use 'remind 5m message' to set a reminder", - icon_name="timer-off", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "empty_list"}, - ) - ) - else: - for reminder in sorted( - self.reminders.values(), key=lambda r: r.target_time - ): - time_remaining = reminder.get_time_remaining() - target_time = reminder.get_target_time_str() - - results.append( - Result( - title=f"#{reminder.id}: {reminder.message}", - subtitle=f"In {time_remaining} (at {target_time})", - icon_name="alarm-timer", - action=lambda rid=reminder.id: self._cancel_reminder(rid), - relevance=1.0, - plugin_name=self.display_name, - data={"type": "active_reminder", "id": reminder.id}, - ) - ) - - return results - - # Handle cancel command - if query.lower().startswith("cancel") or query.lower().startswith("stop"): - parts = query.split() - if len(parts) == 1: - # Cancel all reminders - count = self._cancel_reminder() - results.append( - Result( - title=f"Cancelled {count} Reminders", - subtitle="All active reminders have been cancelled", - icon_name="timer-off", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "cancel_all"}, - ) - ) - else: - # Try to cancel specific reminder by ID - try: - reminder_id = int(parts[1]) - count = self._cancel_reminder(reminder_id) - if count > 0: - results.append( - Result( - title=f"Cancelled Reminder #{reminder_id}", - subtitle="Reminder has been cancelled", - icon_name="timer-off", - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"type": "cancel_specific"}, - ) - ) - else: - results.append( - Result( - title="Reminder Not Found", - subtitle=f"No reminder with ID #{reminder_id}", - icon_name="alert", - action=lambda: None, - relevance=0.5, - plugin_name=self.display_name, - data={"type": "error"}, - ) - ) - except ValueError: - results.append( - Result( - title="Invalid Reminder ID", - subtitle="Please provide a valid reminder ID number", - icon_name="alert", - action=lambda: None, - relevance=0.5, - plugin_name=self.display_name, - data={"type": "error"}, - ) - ) - - return results - - # Handle setting new reminders - parts = query.split(None, 1) - if len(parts) >= 1: - time_str = parts[0] - message = parts[1] if len(parts) > 1 else "Reminder" - - # Try to parse the time (but don't create the reminder yet!) - target_time = self._parse_time_input(time_str) - if target_time: - # Calculate delay and check if it's valid - delay = (target_time - datetime.now()).total_seconds() - if delay > 0: - # Show what would happen, but don't create the reminder yet - time_remaining = self._format_time_remaining(delay) - target_time_str = target_time.strftime("%H:%M") - - results.append( - Result( - title=f"Set Reminder: {message}", - subtitle=f"Will remind in {time_remaining} (at { - target_time_str - })", - icon_name="alarm-timer", - action=lambda ts=time_str, msg=message: self._create_and_confirm_reminder( - ts, msg - ), - relevance=1.0, - plugin_name=self.display_name, - data={ - "type": "preview", - "time_str": time_str, - "message": message, - }, - ) - ) - else: - results.append( - Result( - title="Time is in the Past", - subtitle="Please specify a future time", - icon_name="alert", - action=lambda: None, - relevance=0.5, - plugin_name=self.display_name, - data={"type": "error"}, - ) - ) - else: - # Invalid time format - results.append( - Result( - title="Invalid Time Format", - subtitle="Use formats like: 5m, 30s, 2h, 14:30, or '5 minutes'", - icon_name="alert", - action=lambda: None, - relevance=0.5, - plugin_name=self.display_name, - data={"type": "error"}, - ) - ) - - return results diff --git a/modules/launcher/plugins/screencapture.py b/modules/launcher/plugins/screencapture.py deleted file mode 100644 index 05253b6b..00000000 --- a/modules/launcher/plugins/screencapture.py +++ /dev/null @@ -1,687 +0,0 @@ -import subprocess -from typing import List - -from fabric.utils import get_relative_path -from modules.launcher.plugin_base import PluginBase -from modules.launcher.result import Result - - -class ScreencapturePlugin(PluginBase): - """ - Plugin for taking screenshots and screen recordings using screen-capture.sh script. - """ - - def __init__(self): - super().__init__() - self.display_name = "Screencapture" - self.description = "Take screenshots and screen recordings" - self.script_path = get_relative_path("../../../scripts/screen-capture.sh") - - def initialize(self): - """Initialize the screencapture plugin.""" - self.set_triggers(["sc"]) - - def cleanup(self): - """Cleanup the screencapture plugin.""" - pass - - def get_commands(self): - """Return available commands for this plugin.""" - return { - # Screenshot commands - "screenshot": "Take a screenshot of the main display", - "ss": "Take a screenshot of the main display", - "screenshot-region": "Take a screenshot of selected region", - "ss-region": "Take a screenshot of selected region", - "screenshot-both": "Take a screenshot of both displays", - "ss-both": "Take a screenshot of both displays", - "screenshot-hdmi": "Take a screenshot of HDMI display", - "ss-hdmi": "Take a screenshot of HDMI display", - # Recording commands (with audio) - "record": "Start recording main display with audio", - "rec": "Start recording main display with audio", - "record-region": "Start recording selected region with audio", - "rec-region": "Start recording selected region with audio", - "record-hdmi": "Start recording HDMI display with audio", - "rec-hdmi": "Start recording HDMI display with audio", - # Recording commands (no audio) - "record-noaudio": "Start recording main display without audio", - "rec-noaudio": "Start recording main display without audio", - "record-noaudio-region": "Start recording selected region without audio", - "rec-noaudio-region": "Start recording selected region without audio", - "record-noaudio-hdmi": "Start recording HDMI display without audio", - "rec-noaudio-hdmi": "Start recording HDMI display without audio", - # High-quality recording commands - "record-hq": "Start high-quality recording of main display", - "rec-hq": "Start high-quality recording of main display", - "record-hq-region": "Start high-quality recording of selected region", - "rec-hq-region": "Start high-quality recording of selected region", - "record-hq-hdmi": "Start high-quality recording of HDMI display", - "rec-hq-hdmi": "Start high-quality recording of HDMI display", - # GIF recording commands - "record-gif": "Start GIF recording of main display", - "rec-gif": "Start GIF recording of main display", - "record-gif-region": "Start GIF recording of selected region", - "rec-gif-region": "Start GIF recording of selected region", - # Control commands - "stop": "Stop current recording", - # Conversion commands - "convert-webm": "Convert latest MKV recording to WebM format", - "conv-webm": "Convert latest MKV recording to WebM format", - "convert-iphone": "Convert latest MKV recording for iPhone compatibility", - "conv-iphone": "Convert latest MKV recording for iPhone compatibility", - "convert-youtube": "Convert latest recording for YouTube upload", - "conv-youtube": "Convert latest recording for YouTube upload", - "convert-gif": "Convert latest recording to GIF format", - "conv-gif": "Convert latest recording to GIF format", - # Conversion commands with file input - "convert-webm-file": "Convert specific MKV file to WebM format", - "conv-webm-file": "Convert specific MKV file to WebM format", - "convert-iphone-file": "Convert specific MKV file for iPhone compatibility", - "conv-iphone-file": "Convert specific MKV file for iPhone compatibility", - "convert-youtube-file": "Convert specific video file for YouTube upload", - "conv-youtube-file": "Convert specific video file for YouTube upload", - "convert-gif-file": "Convert specific video file to GIF format", - "conv-gif-file": "Convert specific video file to GIF format", - } - - def _run_script(self, *args): - """Execute the screen-capture script with given arguments.""" - try: - subprocess.Popen([self.script_path] + list(args)) - except Exception as e: - print(f"Error running screen-capture script: {e}") - - def _run_script_with_file(self, format_type: str, file_path: str): - """Execute the screen-capture script with file parameter.""" - try: - subprocess.Popen([self.script_path, "convert", format_type, file_path]) - except Exception as e: - print(f"Error running screen-capture script with file: {e}") - - def _is_recording(self): - """Check if recording is currently active.""" - try: - result = subprocess.run( - [self.script_path, "status"], capture_output=True, text=True - ) - return result.stdout.strip() == "true" - except Exception: - return False - - def _get_command_result(self, command: str) -> Result: - """Get a Result object for a specific command.""" - # Import here to avoid circular imports - - command_info = { - # Screenshot commands - "screenshot": ( - "Take Screenshot (eDP-1)", - "Capture the main display", - "camera-photo-symbolic", - lambda: self._run_script("screenshot", "eDP-1"), - ), - "ss": ( - "Take Screenshot (eDP-1)", - "Capture the main display", - "camera-photo-symbolic", - lambda: self._run_script("screenshot", "eDP-1"), - ), - "screenshot-region": ( - "Take Region Screenshot", - "Capture a selected region", - "camera-photo-symbolic", - lambda: self._run_script("screenshot", "selection"), - ), - "ss-region": ( - "Take Region Screenshot", - "Capture a selected region", - "camera-photo-symbolic", - lambda: self._run_script("screenshot", "selection"), - ), - "screenshot-both": ( - "Take Screenshot (Both Displays)", - "Capture both displays combined", - "video-joined-displays-symbolic", - lambda: self._run_script("screenshot", "both"), - ), - "ss-both": ( - "Take Screenshot (Both Displays)", - "Capture both displays combined", - "video-joined-displays-symbolic", - lambda: self._run_script("screenshot", "both"), - ), - "screenshot-hdmi": ( - "Take Screenshot (HDMI-A-1)", - "Capture HDMI display", - "video-display-symbolic", - lambda: self._run_script("screenshot", "HDMI-A-1"), - ), - "ss-hdmi": ( - "Take Screenshot (HDMI-A-1)", - "Capture HDMI display", - "video-display-symbolic", - lambda: self._run_script("screenshot", "HDMI-A-1"), - ), - # Recording commands (with audio) - "record": ( - "Start Recording (eDP-1)", - "Record the main display with audio", - "media-record-symbolic", - lambda: self._run_script("record", "eDP-1"), - ), - "rec": ( - "Start Recording (eDP-1)", - "Record the main display with audio", - "media-record-symbolic", - lambda: self._run_script("record", "eDP-1"), - ), - "record-region": ( - "Start Region Recording", - "Record a selected region with audio", - "media-record-symbolic", - lambda: self._run_script("record", "selection"), - ), - "rec-region": ( - "Start Region Recording", - "Record a selected region with audio", - "media-record-symbolic", - lambda: self._run_script("record", "selection"), - ), - "record-hdmi": ( - "Start Recording (HDMI-A-1)", - "Record HDMI display with audio", - "media-record-symbolic", - lambda: self._run_script("record", "HDMI-A-1"), - ), - "rec-hdmi": ( - "Start Recording (HDMI-A-1)", - "Record HDMI display with audio", - "media-record-symbolic", - lambda: self._run_script("record", "HDMI-A-1"), - ), - # Recording commands (no audio) - "record-noaudio": ( - "Start Recording No Audio (eDP-1)", - "Record the main display without audio", - "media-record-symbolic", - lambda: self._run_script("record-noaudio", "eDP-1"), - ), - "rec-noaudio": ( - "Start Recording No Audio (eDP-1)", - "Record the main display without audio", - "media-record-symbolic", - lambda: self._run_script("record-noaudio", "eDP-1"), - ), - "record-noaudio-region": ( - "Start Region Recording No Audio", - "Record a selected region without audio", - "media-record-symbolic", - lambda: self._run_script("record-noaudio", "selection"), - ), - "rec-noaudio-region": ( - "Start Region Recording No Audio", - "Record a selected region without audio", - "media-record-symbolic", - lambda: self._run_script("record-noaudio", "selection"), - ), - "record-noaudio-hdmi": ( - "Start Recording No Audio (HDMI-A-1)", - "Record HDMI display without audio", - "media-record-symbolic", - lambda: self._run_script("record-noaudio", "HDMI-A-1"), - ), - "rec-noaudio-hdmi": ( - "Start Recording No Audio (HDMI-A-1)", - "Record HDMI display without audio", - "media-record-symbolic", - lambda: self._run_script("record-noaudio", "HDMI-A-1"), - ), - # High-quality recording commands - "record-hq": ( - "Start HQ Recording (eDP-1)", - "High-quality recording for YouTube", - "media-record-symbolic", - lambda: self._run_script("record-hq", "eDP-1"), - ), - "rec-hq": ( - "Start HQ Recording (eDP-1)", - "High-quality recording for YouTube", - "media-record-symbolic", - lambda: self._run_script("record-hq", "eDP-1"), - ), - "record-hq-region": ( - "Start HQ Region Recording", - "High-quality region recording", - "media-record-symbolic", - lambda: self._run_script("record-hq", "selection"), - ), - "rec-hq-region": ( - "Start HQ Region Recording", - "High-quality region recording", - "media-record-symbolic", - lambda: self._run_script("record-hq", "selection"), - ), - "record-hq-hdmi": ( - "Start HQ Recording (HDMI-A-1)", - "High-quality HDMI recording", - "media-record-symbolic", - lambda: self._run_script("record-hq", "HDMI-A-1"), - ), - "rec-hq-hdmi": ( - "Start HQ Recording (HDMI-A-1)", - "High-quality HDMI recording", - "media-record-symbolic", - lambda: self._run_script("record-hq", "HDMI-A-1"), - ), - # GIF recording commands - "record-gif": ( - "Start GIF Recording (eDP-1)", - "Record as optimized GIF", - "media-record-symbolic", - lambda: self._run_script("record-gif", "eDP-1"), - ), - "rec-gif": ( - "Start GIF Recording (eDP-1)", - "Record as optimized GIF", - "media-record-symbolic", - lambda: self._run_script("record-gif", "eDP-1"), - ), - "record-gif-region": ( - "Start GIF Region Recording", - "Record selected region as GIF", - "media-record-symbolic", - lambda: self._run_script("record-gif", "selection"), - ), - "rec-gif-region": ( - "Start GIF Region Recording", - "Record selected region as GIF", - "media-record-symbolic", - lambda: self._run_script("record-gif", "selection"), - ), - # Control commands - "stop": ( - "Stop Recording", - "Stop the current screen recording", - "media-playback-stop-symbolic", - lambda: self._run_script("record", "stop"), - ), - # Conversion commands - "convert-webm": ( - "Convert Latest to WebM", - "Convert latest MKV recording to WebM format", - "video-x-generic-symbolic", - lambda: self._run_script("convert", "webm"), - ), - "conv-webm": ( - "Convert Latest to WebM", - "Convert latest MKV recording to WebM format", - "video-x-generic-symbolic", - lambda: self._run_script("convert", "webm"), - ), - "convert-iphone": ( - "Convert Latest for iPhone", - "Convert latest MKV recording for iPhone compatibility", - "video-x-generic-symbolic", - lambda: self._run_script("convert", "iphone"), - ), - "conv-iphone": ( - "Convert Latest for iPhone", - "Convert latest MKV recording for iPhone compatibility", - "video-x-generic-symbolic", - lambda: self._run_script("convert", "iphone"), - ), - "convert-youtube": ( - "Convert Latest for YouTube", - "Convert latest recording for YouTube upload", - "video-x-generic-symbolic", - lambda: self._run_script("convert", "youtube"), - ), - "conv-youtube": ( - "Convert Latest for YouTube", - "Convert latest recording for YouTube upload", - "video-x-generic-symbolic", - lambda: self._run_script("convert", "youtube"), - ), - "convert-gif": ( - "Convert Latest to GIF", - "Convert latest recording to GIF format", - "image-x-generic-symbolic", - lambda: self._run_script("convert", "gif"), - ), - "conv-gif": ( - "Convert Latest to GIF", - "Convert latest recording to GIF format", - "image-x-generic-symbolic", - lambda: self._run_script("convert", "gif"), - ), - # File-based conversion commands (these will be handled specially) - "convert-webm-file": ( - "Convert File to WebM", - "Type filename after command (e.g., convert-webm-file video.mkv)", - "video-x-generic-symbolic", - None, # Will be handled in query method - ), - "conv-webm-file": ( - "Convert File to WebM", - "Type filename after command (e.g., conv-webm-file video.mkv)", - "video-x-generic-symbolic", - None, # Will be handled in query method - ), - "convert-iphone-file": ( - "Convert File for iPhone", - "Type filename after command (e.g., convert-iphone-file video.mkv)", - "video-x-generic-symbolic", - None, # Will be handled in query method - ), - "conv-iphone-file": ( - "Convert File for iPhone", - "Type filename after command (e.g., conv-iphone-file video.mkv)", - "video-x-generic-symbolic", - None, # Will be handled in query method - ), - "convert-youtube-file": ( - "Convert File for YouTube", - "Type filename after command (e.g., convert-youtube-file video.mkv)", - "video-x-generic-symbolic", - None, # Will be handled in query method - ), - "conv-youtube-file": ( - "Convert File for YouTube", - "Type filename after command (e.g., conv-youtube-file video.mkv)", - "video-x-generic-symbolic", - None, # Will be handled in query method - ), - "convert-gif-file": ( - "Convert File to GIF", - "Type filename after command (e.g., convert-gif-file video.mkv)", - "image-x-generic-symbolic", - None, # Will be handled in query method - ), - "conv-gif-file": ( - "Convert File to GIF", - "Type filename after command (e.g., conv-gif-file video.mkv)", - "image-x-generic-symbolic", - None, # Will be handled in query method - ), - } - - if command in command_info: - title, subtitle, icon, action = command_info[command] - if action is not None: # Regular command - return Result( - title=title, - subtitle=subtitle, - icon_name=icon, - action=action, - relevance=1.0, - plugin_name=self.display_name, - ) - else: # File-based command, show instruction - return Result( - title=title, - subtitle=subtitle, - icon_name=icon, - action=lambda: None, # No action for instruction - relevance=1.0, - plugin_name=self.display_name, - ) - - return None - - def query(self, query_string: str) -> List[Result]: - """Search for screencapture actions based on query.""" - # Import here to avoid circular imports - - # Clean the query string - query = query_string.strip().lower() - - results = [] - - # Check for file-based conversion commands with parameters - file_conversion_commands = { - "convert-webm-file": "webm", - "conv-webm-file": "webm", - "convert-iphone-file": "iphone", - "conv-iphone-file": "iphone", - "convert-youtube-file": "youtube", - "conv-youtube-file": "youtube", - "convert-gif-file": "gif", - "conv-gif-file": "gif", - } - - # Parse query for file-based commands - query_parts = query.split() - if len(query_parts) >= 2: - command = query_parts[0] - file_param = " ".join(query_parts[1:]) - - if command in file_conversion_commands: - format_type = file_conversion_commands[command] - return [ - Result( - title=f"Convert {file_param} to {format_type.upper()}", - subtitle=f"Convert specified file to {format_type} format", - icon_name=( - "video-x-generic-symbolic" - if format_type != "gif" - else "image-x-generic-symbolic" - ), - action=lambda fp=file_param, ft=format_type: self._run_script_with_file( - ft, fp - ), - relevance=1.0, - plugin_name=self.display_name, - ) - ] - - # Check if query matches a command and return it as a result - command_result = self._get_command_result(query) - if command_result: - return [command_result] - - # Check recording status - is_recording = self._is_recording() - - # If recording is active, show stop button first with highest relevance - if is_recording: - results.append( - Result( - title="Stop Recording", - subtitle="Stop the current screen recording", - icon_name="media-playback-stop-symbolic", - action=lambda: self._run_script("record", "stop"), - relevance=2.0, # Highest relevance to appear at top - plugin_name=self.display_name, - ) - ) - - # Screenshot actions - results.extend( - [ - Result( - title="Take Screenshot", - subtitle="Capture the entire screen (eDP-1)", - icon_name="camera-photo-symbolic", - action=lambda: self._run_script("screenshot", "eDP-1"), - relevance=1.0, - plugin_name=self.display_name, - ), - Result( - title="Take Region Screenshot", - subtitle="Capture a selected region", - icon_name="camera-photo-symbolic", - action=lambda: self._run_script("screenshot", "selection"), - relevance=0.9, - plugin_name=self.display_name, - ), - Result( - title="Take Screenshot (Both Displays)", - subtitle="Capture both displays combined", - icon_name="video-joined-displays-symbolic", - action=lambda: self._run_script("screenshot", "both"), - relevance=0.8, - plugin_name=self.display_name, - ), - Result( - title="Take Screenshot (HDMI-A-1)", - subtitle="Capture HDMI display", - icon_name="video-display-symbolic", - action=lambda: self._run_script("screenshot", "HDMI-A-1"), - relevance=0.7, - plugin_name=self.display_name, - ), - ] - ) - - # Standard recording actions - results.extend( - [ - Result( - title="Start Recording (eDP-1)", - subtitle="Record the main display with audio", - icon_name="media-record-symbolic", - action=lambda: self._run_script("record", "eDP-1"), - relevance=0.7, - plugin_name=self.display_name, - ), - Result( - title="Start Region Recording", - subtitle="Record a selected region", - icon_name="media-record-symbolic", - action=lambda: self._run_script("record", "selection"), - relevance=0.6, - plugin_name=self.display_name, - ), - Result( - title="Start Recording (HDMI-A-1)", - subtitle="Record HDMI display with audio", - icon_name="media-record-symbolic", - action=lambda: self._run_script("record", "HDMI-A-1"), - relevance=0.5, - plugin_name=self.display_name, - ), - ] - ) - - # No-audio recording actions - results.extend( - [ - Result( - title="Start Recording No Audio (eDP-1)", - subtitle="Record the main display without audio", - icon_name="media-record-symbolic", - action=lambda: self._run_script("record-noaudio", "eDP-1"), - relevance=0.65, - plugin_name=self.display_name, - ), - Result( - title="Start Region Recording No Audio", - subtitle="Record a selected region without audio", - icon_name="media-record-symbolic", - action=lambda: self._run_script("record-noaudio", "selection"), - relevance=0.55, - plugin_name=self.display_name, - ), - Result( - title="Start Recording No Audio (HDMI-A-1)", - subtitle="Record HDMI display without audio", - icon_name="media-record-symbolic", - action=lambda: self._run_script("record-noaudio", "HDMI-A-1"), - relevance=0.45, - plugin_name=self.display_name, - ), - ] - ) - - # High-quality recording actions - results.extend( - [ - Result( - title="Start HQ Recording (eDP-1)", - subtitle="High-quality recording for YouTube", - icon_name="media-record-symbolic", - action=lambda: self._run_script("record-hq", "eDP-1"), - relevance=0.4, - plugin_name=self.display_name, - ), - Result( - title="Start HQ Region Recording", - subtitle="High-quality region recording", - icon_name="media-record-symbolic", - action=lambda: self._run_script("record-hq", "selection"), - relevance=0.3, - plugin_name=self.display_name, - ), - Result( - title="Start HQ Recording (HDMI-A-1)", - subtitle="High-quality HDMI recording", - icon_name="media-record-symbolic", - action=lambda: self._run_script("record-hq", "HDMI-A-1"), - relevance=0.2, - plugin_name=self.display_name, - ), - ] - ) - - # GIF recording actions - results.extend( - [ - Result( - title="Start GIF Recording (eDP-1)", - subtitle="Record as optimized GIF", - icon_name="media-record-symbolic", - action=lambda: self._run_script("record-gif", "eDP-1"), - relevance=0.1, - plugin_name=self.display_name, - ), - Result( - title="Start GIF Region Recording", - subtitle="Record selected region as GIF", - icon_name="media-record-symbolic", - action=lambda: self._run_script("record-gif", "selection"), - relevance=0.05, - plugin_name=self.display_name, - ), - ] - ) - - # Conversion actions - results.extend( - [ - Result( - title="Convert Latest to WebM", - subtitle="Convert latest MKV recording to WebM format", - icon_name="video-x-generic-symbolic", - action=lambda: self._run_script("convert", "webm"), - relevance=0.01, - plugin_name=self.display_name, - ), - Result( - title="Convert Latest for iPhone", - subtitle="Convert latest MKV recording for iPhone compatibility", - icon_name="video-x-generic-symbolic", - action=lambda: self._run_script("convert", "iphone"), - relevance=0.01, - plugin_name=self.display_name, - ), - Result( - title="Convert Latest for YouTube", - subtitle="Convert latest recording for YouTube upload", - icon_name="video-x-generic-symbolic", - action=lambda: self._run_script("convert", "youtube"), - relevance=0.01, - plugin_name=self.display_name, - ), - Result( - title="Convert Latest to GIF", - subtitle="Convert latest recording to GIF format", - icon_name="image-x-generic-symbolic", - action=lambda: self._run_script("convert", "gif"), - relevance=0.01, - plugin_name=self.display_name, - ), - ] - ) - - return results diff --git a/modules/launcher/plugins/system.py b/modules/launcher/plugins/system.py deleted file mode 100644 index bc7dba09..00000000 --- a/modules/launcher/plugins/system.py +++ /dev/null @@ -1,242 +0,0 @@ -import json -import os -import shlex -import threading -import time -from typing import List, Set, Union - -import config.data as data -from fabric.utils import exec_shell_command_async -from modules.launcher.plugin_base import PluginBase -from modules.launcher.result import Result - - -class SystemPlugin(PluginBase): - """ - Plugin for system commands and actions. - """ - - def __init__(self): - super().__init__() - self.display_name = "System" - self.description = "System commands and actions" - - # JSON cache file for system binaries - self.bin_cache_file = os.path.join(data.CACHE_DIR, "system_binaries.json") - - # In-memory cache for system binaries - self._bin_cache: Set[str] = set() - self._last_bin_update = 0 - self._bin_update_interval = 300 # 5 minutes - - # Background cache building - self._cache_building = False - self._cache_thread = None - - def initialize(self): - """Initialize the system plugin.""" - self.set_triggers(["bin"]) - self._load_bin_cache() - self._start_background_cache_update() - - def cleanup(self): - """Cleanup the system plugin.""" - self._bin_cache.clear() - if self._cache_thread and self._cache_thread.is_alive(): - # Note: We don't join the thread to avoid blocking cleanup - pass - - def _load_bin_cache(self): - """Load binary cache from JSON file.""" - try: - if os.path.exists(self.bin_cache_file): - with open(self.bin_cache_file, "r", encoding="utf-8") as f: - cache_data = json.load(f) - self._bin_cache = set(cache_data.get("binaries", [])) - self._last_bin_update = cache_data.get("last_update", 0) - else: - print( - "SystemPlugin: No cache file found, will build cache in background" - ) - except Exception as e: - print(f"SystemPlugin: Error loading binary cache: {e}") - self._bin_cache = set() - self._last_bin_update = 0 - - def _save_bin_cache(self): - """Save binary cache to JSON file.""" - try: - # Ensure the cache directory exists - os.makedirs(data.CACHE_DIR, exist_ok=True) - - cache_data = { - "binaries": sorted(list(self._bin_cache)), - "last_update": self._last_bin_update, - "cache_version": "1.0", - } - - with open(self.bin_cache_file, "w", encoding="utf-8") as f: - json.dump(cache_data, f, indent=2) - except Exception as e: - print(f"SystemPlugin: Error saving binary cache: {e}") - - def _start_background_cache_update(self): - """Start background thread to update binary cache.""" - current_time = time.time() - - # Check if cache needs updating - if ( - current_time - self._last_bin_update > self._bin_update_interval - or not self._bin_cache - ): - if not self._cache_building: - self._cache_building = True - self._cache_thread = threading.Thread( - target=self._build_bin_cache_background, daemon=True - ) - self._cache_thread.start() - - def _build_bin_cache_background(self): - """Build binary cache in background thread.""" - try: - new_cache = set() - processed_paths = set() # Avoid duplicate paths - - for path in os.environ["PATH"].split(":"): - # Skip empty paths and duplicates - if not path or path in processed_paths: - continue - processed_paths.add(path) - - if os.path.exists(path) and os.path.isdir(path): - try: - # Use os.scandir for better performance than os.listdir - with os.scandir(path) as entries: - for entry in entries: - if entry.is_file(follow_symlinks=False) and os.access( - entry.path, os.X_OK - ): - new_cache.add(entry.name) - except (PermissionError, FileNotFoundError, OSError): - continue - - # Update cache atomically - self._bin_cache = new_cache - self._last_bin_update = time.time() - - # Save to disk - self._save_bin_cache() - - except Exception as e: - print(f"SystemPlugin: Error building binary cache: {e}") - finally: - self._cache_building = False - - def query(self, query_string: str) -> List[Result]: - """Search for system commands matching the query.""" - query = query_string.strip() - - if not query: - return [] - - results = [] - - # Parse the query to extract binary name and arguments - query_parts = query.split() - if not query_parts: - return [] - - binary_query = query_parts[0].lower() - full_command = query # Keep the original case and spacing - - # Check system binaries - # Start background update if needed (non-blocking) - but only if cache is empty or very old - if not self._bin_cache or ( - time.time() - self._last_bin_update > self._bin_update_interval - ): - self._start_background_cache_update() - - # Optimize search with early termination and result limiting - exact_matches = [] - prefix_matches = [] - partial_matches = [] - max_results = 20 # Limit total results for performance - - for binary in self._bin_cache: - # Pre-compute lowercase once - binary_lower = binary.lower() - - # Skip if no match at all - if binary_query not in binary_lower: - continue - - # Categorize matches for better sorting - if binary_lower == binary_query: - # Exact match - highest priority - display_command = full_command - command_to_execute = full_command - relevance = 1.0 - exact_matches.append( - (binary, display_command, command_to_execute, relevance) - ) - elif binary_lower.startswith(binary_query): - # Prefix match - high priority - display_command = binary - command_to_execute = binary - relevance = 0.9 - prefix_matches.append( - (binary, display_command, command_to_execute, relevance) - ) - else: - # Partial match - lower priority - display_command = binary - command_to_execute = binary - relevance = 0.7 - partial_matches.append( - (binary, display_command, command_to_execute, relevance) - ) - - # Early termination if we have enough good matches - if len(exact_matches) + len(prefix_matches) >= max_results: - break - - # Combine results in priority order - all_matches = exact_matches + prefix_matches + partial_matches - - # Convert to Result objects (limit to max_results) - for binary, display_command, command_to_execute, relevance in all_matches[ - :max_results - ]: - result = Result( - title=display_command, - subtitle=f"Execute: {display_command}", - icon_name="terminal", - action=self._create_action(command_to_execute), - relevance=relevance, - plugin_name=self.display_name, - data={"command": command_to_execute, "id": binary}, - ) - results.append(result) - - return results # Already sorted by priority - - def _create_action(self, command: Union[str, List[str]]): - """Create an action function for the given command.""" - - def action(): - self._execute_command(command) - - return action - - def _execute_command(self, command: Union[str, List[str]]): - """Execute a system command.""" - try: - if isinstance(command, str): - # Handle string commands with arguments - split into list for proper execution - command_list = shlex.split(command) - exec_shell_command_async(command_list) - else: - # Handle list commands (backward compatibility) - exec_shell_command_async(command) - except Exception as e: - print(f"SystemPlugin: Error executing command '{command}': {e}") diff --git a/modules/launcher/plugins/tmux.py b/modules/launcher/plugins/tmux.py deleted file mode 100644 index 5c93fadb..00000000 --- a/modules/launcher/plugins/tmux.py +++ /dev/null @@ -1,334 +0,0 @@ -import subprocess -import threading -import time -from typing import List - -import config.data as data -from fabric.utils import exec_shell_command_async -from modules.launcher.plugin_base import PluginBase -from modules.launcher.result import Result - - -class TmuxPlugin(PluginBase): - """ - Plugin for managing tmux sessions through the launcher. - """ - - def __init__(self): - super().__init__() - self.display_name = "Tmux Manager" - self.description = "Manage tmux sessions - create, attach, rename, and kill" - - # Cache for sessions to avoid repeated subprocess calls - self._sessions_cache = [] - self._last_cache_update = 0 - # Cache sessions for 10 seconds (increased from 2) - self._cache_ttl = 10 - - # Threading for auto-refresh - only when actively used - self.refresh_thread = None - self.stop_refresh = threading.Event() - self._last_query_time = 0 - # Stop refreshing after 30 seconds of inactivity - self._active_refresh_timeout = 30 - - def initialize(self): - """Initialize the tmux plugin.""" - self.set_triggers(["tmux"]) - # Don't start refresh thread immediately - start it when first used - - def cleanup(self): - """Cleanup the tmux plugin.""" - self.stop_refresh.set() - if self.refresh_thread: - self.refresh_thread.join(timeout=1) - self._sessions_cache.clear() - - def _start_refresh_thread(self): - """Start background thread to refresh session cache.""" - if not self.refresh_thread or not self.refresh_thread.is_alive(): - self.refresh_thread = threading.Thread( - target=self._refresh_sessions_background, daemon=True - ) - self.refresh_thread.start() - - def _refresh_sessions_background(self): - """Background thread to refresh sessions cache only when actively used.""" - while not self.stop_refresh.is_set(): - try: - current_time = time.time() - - # Stop refreshing if plugin hasn't been used recently - if current_time - self._last_query_time > self._active_refresh_timeout: - print("TmuxPlugin: Stopping background refresh due to inactivity") - break - - if current_time - self._last_cache_update > self._cache_ttl: - self._sessions_cache = self._get_tmux_sessions() - self._last_cache_update = current_time - - self.stop_refresh.wait(5) - except Exception as e: - print(f"TmuxPlugin: Error in refresh thread: {e}") - self.stop_refresh.wait(10) # Wait longer on error - - def _get_tmux_sessions(self): - """Get list of tmux sessions.""" - try: - result = subprocess.run( - ["tmux", "list-sessions", "-F", "#{session_name}"], - capture_output=True, - text=True, - timeout=5, - ) - if result.returncode == 0: - return [ - s.strip() for s in result.stdout.strip().split("\n") if s.strip() - ] - return [] - except ( - subprocess.TimeoutExpired, - subprocess.CalledProcessError, - FileNotFoundError, - ) as e: - print(f"TmuxPlugin: Error getting tmux sessions: {e}") - return [] - - def query(self, query_string: str) -> List[Result]: - """Process tmux queries.""" - query = query_string.strip().lower() - results = [] - - # Track usage and start refresh thread if needed - current_time = time.time() - self._last_query_time = current_time - - # Start refresh thread if not running and plugin is being used - if not self.refresh_thread or not self.refresh_thread.is_alive(): - self._start_refresh_thread() - - # Get current sessions (use cache if recent) - if current_time - self._last_cache_update > self._cache_ttl: - self._sessions_cache = self._get_tmux_sessions() - self._last_cache_update = current_time - - sessions = self._sessions_cache - - # Handle specific commands - if query.startswith("new ") or query.startswith("create "): - session_name = query.split(" ", 1)[1].strip() if " " in query else "" - results.append(self._create_new_session_result(session_name)) - - elif query.startswith("kill ") or query.startswith("delete "): - session_name = query.split(" ", 1)[1].strip() if " " in query else "" - if session_name: - matching_sessions = [ - s for s in sessions if session_name.lower() in s.lower() - ] - for session in matching_sessions: - results.append(self._create_kill_session_result(session)) - - elif query.startswith("rename "): - parts = query.split(" ", 2) - if len(parts) >= 3: - old_name, new_name = parts[1], parts[2] - if old_name in sessions: - results.append( - self._create_rename_session_result(old_name, new_name) - ) - - else: - # Show existing sessions for attachment - if sessions: - # Filter sessions based on query - if query: - filtered_sessions = [s for s in sessions if query in s.lower()] - else: - filtered_sessions = sessions - - for session in filtered_sessions: - results.append(self._create_attach_session_result(session)) - - # Always show option to create new session - if not query or "new" in query or "create" in query: - results.append( - self._create_new_session_result( - query - if query and not any(cmd in query for cmd in ["new", "create"]) - else "" - ) - ) - - return results - - def _create_attach_session_result(self, session_name: str) -> Result: - """Create result for attaching to a session.""" - return Result( - title=f"Attach to '{session_name}'", - subtitle=f"Connect to tmux session: {session_name}", - icon_name="terminal", - action=lambda: self._attach_to_session(session_name), - relevance=0.9, - data={"type": "attach", "session": session_name}, - ) - - def _create_new_session_result(self, session_name: str = "") -> Result: - """Create result for creating a new session.""" - display_name = session_name if session_name else "new session" - return Result( - title=f"Create '{display_name}'", - subtitle=f"Create new tmux session{ - f': {session_name}' if session_name else '' - }", - icon_name="plus", - action=lambda: self._create_session(session_name), - relevance=0.8, - data={"type": "create", "session": session_name}, - ) - - def _create_kill_session_result(self, session_name: str) -> Result: - """Create result for killing a session.""" - return Result( - title=f"Kill '{session_name}'", - subtitle=f"Terminate tmux session: {session_name}", - icon_name="trash", - action=lambda: self._kill_session(session_name), - relevance=0.7, - data={"type": "kill", "session": session_name}, - ) - - def _create_rename_session_result(self, old_name: str, new_name: str) -> Result: - """Create result for renaming a session.""" - return Result( - title=f"Rename '{old_name}' to '{new_name}'", - subtitle=f"Rename tmux session from {old_name} to {new_name}", - icon_name="config", - action=lambda: self._rename_session(old_name, new_name), - relevance=0.6, - data={"type": "rename", "old_session": old_name, "new_session": new_name}, - ) - - def _attach_to_session(self, session_name: str): - """Attach to an existing tmux session.""" - try: - terminal_cmd = self._get_terminal_command( - f"tmux attach-session -t '{session_name}'" - ) - exec_shell_command_async(terminal_cmd) - print(f"TmuxPlugin: Attaching to session '{session_name}'") - except Exception as e: - print(f"TmuxPlugin: Error attaching to session '{session_name}': {e}") - - def _create_session(self, session_name: str = ""): - """Create a new tmux session.""" - try: - if not session_name: - # Generate a default name - existing_sessions = self._get_tmux_sessions() - counter = 0 - while str(counter) in existing_sessions: - counter += 1 - session_name = str(counter) - - # Clean the session name - clean_name = session_name.strip().replace(" ", "_") - - # Create session - subprocess.run( - ["tmux", "new-session", "-d", "-s", clean_name], check=True, timeout=10 - ) - - # Launch terminal and attach - terminal_cmd = self._get_terminal_command( - f"tmux attach-session -t '{clean_name}'" - ) - exec_shell_command_async(terminal_cmd) - - # Refresh cache - self._sessions_cache = self._get_tmux_sessions() - self._last_cache_update = time.time() - - print(f"TmuxPlugin: Created and attached to session '{clean_name}'") - except Exception as e: - print(f"TmuxPlugin: Error creating session '{session_name}': {e}") - - def _kill_session(self, session_name: str): - """Kill a tmux session.""" - try: - subprocess.run( - ["tmux", "kill-session", "-t", session_name], check=True, timeout=10 - ) - - # Refresh cache - self._sessions_cache = self._get_tmux_sessions() - self._last_cache_update = time.time() - - print(f"TmuxPlugin: Killed session '{session_name}'") - except Exception as e: - print(f"TmuxPlugin: Error killing session '{session_name}': {e}") - - def _rename_session(self, old_name: str, new_name: str): - """Rename a tmux session.""" - try: - clean_name = new_name.strip().replace(" ", "_") - subprocess.run( - ["tmux", "rename-session", "-t", old_name, clean_name], - check=True, - timeout=10, - ) - - # Refresh cache - self._sessions_cache = self._get_tmux_sessions() - self._last_cache_update = time.time() - - print(f"TmuxPlugin: Renamed session '{old_name}' to '{clean_name}'") - except Exception as e: - print( - f"TmuxPlugin: Error renaming session '{old_name}' to '{new_name}': {e}" - ) - - def _get_terminal_command(self, cmd: str) -> str: - """Get terminal command based on configured terminal or available terminals.""" - # First try to use the configured terminal command - if hasattr(data, "TERMINAL_COMMAND") and data.TERMINAL_COMMAND: - parts = data.TERMINAL_COMMAND.split() - terminal = parts[0] - - try: - # Check if the configured terminal is available - subprocess.run( - ["which", terminal], - check=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - return f"{data.TERMINAL_COMMAND} {cmd}" - except subprocess.CalledProcessError: - # If configured terminal is not available, fall back to defaults - pass - - # Fallback to checking available terminals - terminals = [ - ("kitty", f"kitty -e {cmd}"), - ("alacritty", f"alacritty -e {cmd}"), - ("foot", f"foot {cmd}"), - ("gnome-terminal", f"gnome-terminal -- {cmd}"), - ("konsole", f"konsole -e {cmd}"), - ("xfce4-terminal", f"xfce4-terminal -e '{cmd}'"), - ] - - for term, term_cmd in terminals: - try: - # Check if terminal is available - subprocess.run( - ["which", term], - check=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - return term_cmd - except subprocess.CalledProcessError: - continue - - # Default fallback - return f"kitty -e {cmd}" diff --git a/modules/osd.py b/modules/osd.py deleted file mode 100644 index 6b27aaa7..00000000 --- a/modules/osd.py +++ /dev/null @@ -1,467 +0,0 @@ -import math -import time -from typing import ClassVar, Literal - -from gi.repository import GLib, GObject - -from fabric.audio import Audio -from fabric.utils.helpers import get_relative_path -from fabric.widgets.box import Box -from fabric.widgets.revealer import Revealer -from fabric.widgets.scale import Scale, ScaleMark -from fabric.widgets.svg import Svg -from services.brightness import Brightness -from utils.animator import Animator -from widgets.wayland import WaylandWindow as Window - - -class AnimatedScale(Scale): - def __init__(self, **kwargs): - super().__init__(**kwargs) - self.animator = None - - def animate_value(self, value: float): - if not self.animator: - self.animator = Animator( - bezier_curve=(0.34, 1.56, 0.64, 1.0), - duration=0.8, - min_value=self.min_value, - max_value=self.value, - tick_widget=self, - notify_value=lambda p, *_: self.set_value(p.value), - ) - self.animator.pause() - self.animator.min_value = self.value - self.animator.max_value = value - self.animator.play() - - -class BrightnessOSDContainer(Box): - def __init__(self, **kwargs): - super().__init__(**kwargs, orientation="v", spacing=3, name="osd") - self.brightness_service = Brightness.get_initial() - self.scale = AnimatedScale( - marks=(ScaleMark(value=i) for i in range(0, 101, 10)), - value=70, - min_value=0, - max_value=100, - increments=(1, 1), - orientation="h", - ) - self.osd_window_image = Svg( - get_relative_path("../config/assets/icons/brightness/brightness.svg"), - size=(100, 150), - name="osd-image", - h_align="center", - v_align="center", - h_expand=True, - v_expand=True, - ) - - self.add(self.osd_window_image) - self.add(self.scale) - self.update_brightness() - - self.scale.connect("value-changed", lambda *_: self.update_brightness()) - self.brightness_service.connect("screen", self.on_brightness_changed) - - def update_brightness(self) -> None: - current_brightness = self.brightness_service.screen_brightness - normalized_brightness = self._normalize_brightness(current_brightness) - if current_brightness != 0: - self.scale.animate_value(normalized_brightness) - - def get_svg(self, value): - b_level = 0 if value == 0 else min(int(math.ceil(value / 33)), 3) - return b_level - - def on_brightness_changed(self, _sender: any, value: float, *_args) -> None: - normalized_brightness = self._normalize_brightness(value) - self.osd_window_image.set_from_file( - get_relative_path( - f"../config/assets/icons/brightness/brightness-{ - self.get_svg(normalized_brightness) - }.svg" - ) - ) - self.scale.animate_value(normalized_brightness) - - def _normalize_brightness(self, brightness: float) -> float: - return (brightness / self.brightness_service.max_screen) * 100 - - -class AudioOSDContainer(Box): - __gsignals__: ClassVar[dict] = { - "volume-changed": (GObject.SIGNAL_RUN_FIRST, GObject.TYPE_NONE, ()), - } - - def __init__(self, **kwargs): - super().__init__( - **kwargs, - orientation="v", - name="osd", - ) - self.audio = Audio() - self.scale = AnimatedScale( - value=70, - marks=(ScaleMark(value=i) for i in range(1, 100, 10)), - min_value=0, - max_value=100, - increments=(1, 1), - orientation="h", - ) - self.osd_window_image = Svg( - get_relative_path("../config/assets/icons/volume/audio-volume.svg"), - size=(150, 150), - name="osd-image", - h_align="center", - v_align="center", - h_expand=True, - v_expand=True, - ) - - self.previous_volume = None - self.previous_muted = None - - self.add(self.osd_window_image) - self.add(self.scale) - self.sync_with_audio() - - self.scale.connect("value-changed", self.on_volume_changed) - self.audio.connect("notify::speaker", self.on_audio_speaker_changed) - self.audio.connect("speaker-changed", self.on_speaker_changed) - - # Connect to current speaker if available - self._connect_speaker_signals() - - def _connect_speaker_signals(self): - """Connect to speaker's changed signal which should fire on both volume and mute changes""" - if self.audio.speaker: - # Connect to the main 'changed' signal from AudioStream - self.audio.speaker.connect("changed", self.on_speaker_stream_changed) - - def on_speaker_stream_changed(self, *_): - """This should be called whenever the speaker stream changes (volume OR mute)""" - if self.audio.speaker: - current_volume = ( - round(self.audio.speaker.volume) - if hasattr(self.audio.speaker, "volume") - else 0 - ) - current_muted = ( - self.audio.speaker.muted - if hasattr(self.audio.speaker, "muted") - else False - ) - - # Check if either volume or mute state changed - if ( - self.previous_volume != current_volume - or self.previous_muted != current_muted - ): - self.previous_volume = current_volume - self.previous_muted = current_muted - self.update_volume() - self.emit("volume-changed") - - def get_svg(self, value): - audio_level = 0 if value == 0 else min(int(math.ceil(value / 33)), 3) - return audio_level - - def sync_with_audio(self): - if self.audio.speaker: - volume = ( - round(self.audio.speaker.volume) - if hasattr(self.audio.speaker, "volume") - else 0 - ) - self.scale.set_value(volume) - self.previous_volume = volume - self.previous_muted = ( - self.audio.speaker.muted - if hasattr(self.audio.speaker, "muted") - else False - ) - - def on_volume_changed(self, *_): - if self.audio.speaker: - volume = self.scale.value - if 0 <= volume <= 100: - self.audio.speaker.set_volume(volume) - self.update_volume_display(volume) - self.emit("volume-changed") - - def update_volume_display(self, volume=None): - """Update the visual display based on current volume/mute state""" - if not self.audio.speaker: - return - - if volume is None: - volume = ( - round(self.audio.speaker.volume) - if hasattr(self.audio.speaker, "volume") - else 0 - ) - - is_muted = ( - self.audio.speaker.muted if hasattr(self.audio.speaker, "muted") else False - ) - - if volume == 0 or is_muted: - self.scale.add_style_class("muted") - display_volume = 0 - else: - self.scale.remove_style_class("muted") - display_volume = volume - - self.osd_window_image.set_from_file( - get_relative_path( - f"../config/assets/icons/volume/audio-volume-{ - self.get_svg(display_volume) - }.svg" - ) - ) - - def on_audio_speaker_changed(self, *_): - self._connect_speaker_signals() - self.update_volume() - - def on_speaker_changed(self, *_): - self._connect_speaker_signals() - self.update_volume() - - def update_volume(self, *_): - if self.audio.speaker and not self.is_hovered(): - volume = ( - round(self.audio.speaker.volume) - if hasattr(self.audio.speaker, "volume") - else 0 - ) - self.scale.set_value(volume) - self.update_volume_display(volume) - - -class MicrophoneOSDContainer(Box): - __gsignals__: ClassVar[dict] = { - "mic-changed": (GObject.SIGNAL_RUN_FIRST, GObject.TYPE_NONE, ()), - } - - def __init__(self, **kwargs): - super().__init__(**kwargs, orientation="v", spacing=13, name="osd") - self.audio = Audio() - self.scale = AnimatedScale( - marks=(ScaleMark(value=i) for i in range(1, 100, 10)), - value=70, - min_value=0, - max_value=100, - increments=(1, 1), - orientation="h", - ) - - self.osd_window_image = Svg( - get_relative_path("../config/assets/icons/mic/microphone.svg"), - name="osd-image", - size=(100, 150), - h_align="center", - v_align="center", - h_expand=True, - v_expand=True, - ) - self.previous_volume = None - self.previous_muted = None - - self.add(self.osd_window_image) - self.add(self.scale) - self.sync_with_audio() - - self.scale.connect("value-changed", self.on_volume_changed) - self.audio.connect("notify::microphone", self.on_audio_microphone_changed) - self.audio.connect("microphone-changed", self.on_microphone_changed) - - # Connect to current microphone if available - self._connect_microphone_signals() - - def _connect_microphone_signals(self): - """Connect to microphone's changed signal which should fire on both volume and mute changes""" - if self.audio.microphone: - # Connect to the main 'changed' signal from AudioStream - self.audio.microphone.connect("changed", self.on_microphone_stream_changed) - - def on_microphone_stream_changed(self, *_): - """This should be called whenever the microphone stream changes (volume OR mute)""" - if self.audio.microphone: - current_volume = ( - round(self.audio.microphone.volume) - if hasattr(self.audio.microphone, "volume") - else 0 - ) - current_muted = ( - self.audio.microphone.muted - if hasattr(self.audio.microphone, "muted") - else False - ) - - # Check if either volume or mute state changed - if ( - self.previous_volume != current_volume - or self.previous_muted != current_muted - ): - self.previous_volume = current_volume - self.previous_muted = current_muted - self.update_volume() - self.emit("mic-changed") - - def get_svg(self, value): - audio_level = 0 if value == 0 else min(int(math.ceil(value / 33)), 3) - return audio_level - - def sync_with_audio(self): - if self.audio.microphone: - volume = ( - round(self.audio.microphone.volume) - if hasattr(self.audio.microphone, "volume") - else 0 - ) - self.scale.set_value(volume) - self.previous_volume = volume - self.previous_muted = ( - self.audio.microphone.muted - if hasattr(self.audio.microphone, "muted") - else False - ) - - def on_volume_changed(self, *_): - if self.audio.microphone: - volume = self.scale.value - if 0 <= volume <= 100: - self.audio.microphone.set_volume(volume) - self.update_volume_display(volume) - self.emit("mic-changed") - - def update_volume_display(self, volume=None): - """Update the visual display based on current volume/mute state""" - if not self.audio.microphone: - return - - if volume is None: - volume = ( - round(self.audio.microphone.volume) - if hasattr(self.audio.microphone, "volume") - else 0 - ) - - is_muted = ( - self.audio.microphone.muted - if hasattr(self.audio.microphone, "muted") - else False - ) - - if volume == 0 or is_muted: - self.scale.add_style_class("muted") - display_volume = 0 - else: - self.scale.remove_style_class("muted") - display_volume = volume - - self.osd_window_image.set_from_file( - get_relative_path( - f"../config/assets/icons/mic/microphone-{ - self.get_svg(display_volume) - }.svg" - ) - ) - - def on_audio_microphone_changed(self, *_): - self._connect_microphone_signals() - self.update_volume() - - def on_microphone_changed(self, *_): - self._connect_microphone_signals() - self.update_volume() - - def update_volume(self, *_): - if self.audio.microphone and not self.is_hovered(): - volume = ( - round(self.audio.microphone.volume) - if hasattr(self.audio.microphone, "volume") - else 0 - ) - self.scale.set_value(volume) - self.update_volume_display(volume) - - -class OSD(Window): - def __init__(self, **kwargs): - self.audio_container = AudioOSDContainer() - self.brightness_container = BrightnessOSDContainer() - self.microphone_container = MicrophoneOSDContainer() - - self.timeout = 1000 - - self.revealer = Revealer( - transition_type="slide-up", - transition_duration=100, - child_revealed=False, - ) - - self.main_box = Box( - orientation="v", - h_expand=True, - children=[self.revealer], - ) - - super().__init__( - layer="overlay", - anchor="bottom", - title="modus", - child=self.main_box, - visible=False, - pass_through=True, - keyboard_mode="on-demand", - **kwargs, - ) - - self.last_activity_time = time.time() - - # Connect to the containers' signals - self.audio_container.connect("volume-changed", self.show_audio) - self.brightness_container.brightness_service.connect( - "screen", self.show_brightness - ) - self.microphone_container.connect("mic-changed", self.show_microphone) - - GLib.timeout_add(100, self.check_inactivity) - - def show_audio(self, *_): - self.show_box(box_to_show="audio") - self.reset_inactivity_timer() - - def show_brightness(self, *_): - self.show_box(box_to_show="brightness") - self.reset_inactivity_timer() - - def show_microphone(self, *_): - self.show_box(box_to_show="microphone") - self.reset_inactivity_timer() - - def show_box(self, box_to_show: Literal["audio", "brightness", "microphone"]): - self.set_visible(True) - if box_to_show == "audio": - self.revealer.children = self.audio_container - elif box_to_show == "brightness": - self.revealer.children = self.brightness_container - elif box_to_show == "microphone": - self.revealer.children = self.microphone_container - self.revealer.set_reveal_child(True) - self.reset_inactivity_timer() - - def start_hide_timer(self): - self.set_visible(False) - - def reset_inactivity_timer(self): - self.last_activity_time = time.time() - - def check_inactivity(self): - if time.time() - self.last_activity_time >= (self.timeout / 1000): - self.start_hide_timer() - return True diff --git a/modules/panel/components/enhanced_system_tray.py b/modules/panel/components/enhanced_system_tray.py deleted file mode 100644 index ac7e63b1..00000000 --- a/modules/panel/components/enhanced_system_tray.py +++ /dev/null @@ -1,165 +0,0 @@ -""" -Enhanced System Tray Icon Handling - -This module provides enhanced icon loading capabilities for system tray items, -including fallback mechanisms for file paths and common icon locations. -""" - -import os - -from gi.repository import GdkPixbuf, Gtk - -from fabric.system_tray.widgets import SystemTrayItem - -# FIX: the tooltip should show application names instead of unknown - - -def patched_do_update_properties(self, *_): - # Try default GTK theme first - icon_name = self._item.icon_name - attention_icon_name = self._item.attention_icon_name - - if self._item.status == "NeedsAttention" and attention_icon_name: - preferred_icon_name = attention_icon_name - else: - preferred_icon_name = icon_name - - # Try to load from default GTK theme - if preferred_icon_name: - try: - default_theme = Gtk.IconTheme.get_default() - if default_theme.has_icon(preferred_icon_name): - pixbuf = default_theme.load_icon( - preferred_icon_name, self._icon_size, Gtk.IconLookupFlags.FORCE_SIZE - ) - if pixbuf: - self._image.set_from_pixbuf(pixbuf) - # Set tooltip - tooltip = self._item.tooltip - self.set_tooltip_markup( - tooltip.description or tooltip.title or self._item.title.title() - if self._item.title - else "Unknown" - ) - return - except: - pass - - # Enhanced fallback handling for file paths - if preferred_icon_name and self._try_load_icon_from_path(preferred_icon_name): - return - - # Fallback to original implementation - original_do_update_properties(self, *_) - - -def _try_load_icon_from_path(self, icon_path): - try: - # Check if it's a file path and handle it directly - if os.path.isabs(icon_path) or "/" in icon_path: - # Try to load as SVG from the original path if it exists - if os.path.exists(icon_path): - if icon_path.lower().endswith(".svg"): - # Load SVG directly - pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size( - icon_path, self._icon_size, self._icon_size - ) - if pixbuf: - self._image.set_from_pixbuf(pixbuf) - self._set_tooltip() - return True - else: - # Load other image formats - pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size( - icon_path, self._icon_size, self._icon_size - ) - if pixbuf: - self._image.set_from_pixbuf(pixbuf) - self._set_tooltip() - return True - - # If it's a file path, try to extract just the filename for theme lookup - filename = os.path.basename(icon_path) - if filename: - # Remove extension for theme lookup - name_without_ext = os.path.splitext(filename)[0] - default_theme = Gtk.IconTheme.get_default() - - # Try filename without extension - if default_theme.has_icon(name_without_ext): - pixbuf = default_theme.load_icon( - name_without_ext, - self._icon_size, - Gtk.IconLookupFlags.FORCE_SIZE, - ) - if pixbuf: - self._image.set_from_pixbuf(pixbuf) - self._set_tooltip() - return True - - # Try full filename - if default_theme.has_icon(filename): - pixbuf = default_theme.load_icon( - filename, self._icon_size, Gtk.IconLookupFlags.FORCE_SIZE - ) - if pixbuf: - self._image.set_from_pixbuf(pixbuf) - self._set_tooltip() - return True - - # If it looks like a file path but doesn't exist, try common icon locations - if os.path.isabs(icon_path): - common_icon_dirs = [ - "/usr/share/icons", - "/usr/share/pixmaps", - "/usr/local/share/icons", - "/usr/local/share/pixmaps", - os.path.expanduser("~/.local/share/icons"), - os.path.expanduser("~/.icons"), - ] - - filename = os.path.basename(icon_path) - for icon_dir in common_icon_dirs: - potential_path = os.path.join(icon_dir, filename) - if os.path.exists(potential_path): - try: - pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size( - potential_path, self._icon_size, self._icon_size - ) - if pixbuf: - self._image.set_from_pixbuf(pixbuf) - self._set_tooltip() - return True - except: - continue - - except Exception: - pass - - return False - - -def _set_tooltip(self): - tooltip = self._item.tooltip - self.set_tooltip_markup( - tooltip.description or tooltip.title or self._item.title.title() - if self._item.title - else "Unknown" - ) - - -def apply_enhanced_system_tray(): - # Store original method - global original_do_update_properties - original_do_update_properties = SystemTrayItem.do_update_properties - - # Attach helper methods to SystemTrayItem class - SystemTrayItem._try_load_icon_from_path = _try_load_icon_from_path - SystemTrayItem._set_tooltip = _set_tooltip - - # Replace the do_update_properties method - SystemTrayItem.do_update_properties = patched_do_update_properties - - -# Store reference to original method -original_do_update_properties = None diff --git a/modules/panel/components/menubar.py b/modules/panel/components/menubar.py deleted file mode 100644 index 61e77351..00000000 --- a/modules/panel/components/menubar.py +++ /dev/null @@ -1,411 +0,0 @@ -import json -import os -import subprocess - -from fabric.hyprland.widgets import HyprlandActiveWindow as ActiveWindow -from fabric.utils import FormattedString -from fabric.widgets.box import Box -from fabric.widgets.button import Button -from fabric.widgets.centerbox import CenterBox -from fabric.widgets.label import Label - -from modules.about import About, AboutApp -from utils.roam import modus_service -from widgets.dropdown import ModusDropdown, dropdown_divider -from widgets.mousecapture import DropDownMouseCapture -from utils.app_name_resolver import format_window - - -def show_about_app(): - """Show about dialog for current active application""" - try: - # Use modus_service's Hyprland connection to get current window info - wmclass = "" - title = "" - - if ( - hasattr(modus_service, "_hyprland_connection") - and modus_service._hyprland_connection - ): - window_data = modus_service._hyprland_connection.send_command( - "j/activewindow" - ).reply - if window_data: - window_info = json.loads(window_data.decode("utf-8")) - wmclass = window_info.get("class", "") - title = window_info.get("title", "") - - # Don't show about dialog if there's no active window (Finder state) - if not wmclass and not title: - return - - app_name = modus_service.current_active_app_name or "Finder" - # Don't show about dialog for Finder - if app_name == "Finder": - return - - about_window = AboutApp(app_name=app_name, wmclass=wmclass) - about_window.toggle(None) - except Exception: - # Fallback: only show if we have a real app name - app_name = modus_service.current_active_app_name or "" - if app_name and app_name != "Finder": - about_window = AboutApp(app_name=app_name, wmclass="") - about_window.toggle(None) - - -def dropdown_option( - label: str = "", - keybind: str = "", - on_click='echo "ModusPanelDropdown Action"', - on_clicked=None, -): - def on_click_subthread(button): - # Execute the action first - if on_clicked: - on_clicked(button) - else: - subprocess.Popen( - f"nohup {on_click} &", - shell=True, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - - # Hide dropdown by finding the current visible dropdown and calling its hide method - from widgets.dropdown import dropdowns - - for dropdown in dropdowns: - if dropdown.is_visible() and hasattr(dropdown, "hide_via_mousecapture"): - dropdown.hide_via_mousecapture() - break - - return Button( - child=CenterBox( - start_children=[ - Label(label=label, h_align="start", name="dropdown-option-label"), - ], - end_children=[ - Label(label=keybind, h_align="end", name="dropdown-option-keybind") - ], - orientation="horizontal", - h_align="fill", - h_expand=True, - v_expand=True, - ), - name="dropdown-option", - h_align="fill", - on_clicked=on_click_subthread, - h_expand=True, - v_expand=True, - ) - - -class SystemDropdown(ModusDropdown): - def __init__(self, parent, **kwargs): - super().__init__( - dropdown_id="os-menu", - parent=parent, - dropdown_children=[ - dropdown_option( - "About this PC", on_clicked=lambda _: About().toggle(_) - ), - dropdown_divider("---------------------"), - dropdown_option( - "System Settings...", - # TODO: Open Modus own setting - # on_click="xdg-open settings", - ), - dropdown_divider("---------------------"), - dropdown_option("Force Quit", "", "hyprctl kill"), - dropdown_divider("---------------------"), - dropdown_option("Sleep", "", "systemctl suspend"), - dropdown_option("Restart...", "", "systemctl reboot"), - dropdown_option("Shut Down...", "", "shutdown now"), - dropdown_divider("---------------------"), - dropdown_option("Lock Screen", "๓ฐ˜ณ L", "hyprlock"), - ], - **kwargs, - ) - - -class MenuBarDropdowns: - def __init__(self, parent): - self.parent = parent - - # System dropdown - self.system_dropdown = SystemDropdown(parent=parent) - self.menu_button_dropdown = DropDownMouseCapture( - layer="bottom", child_window=self.system_dropdown - ) - self.menu_button = Button( - label="Modus", - name="menu-button", - style_classes="button", - on_clicked=lambda _: self.menu_button_dropdown.toggle_mousecapture(), - ) - self.menu_button_dropdown.child_window.set_pointing_to(self.menu_button) - - # Global menu dropdowns - self.global_title_menu_about = dropdown_option( - f"About {modus_service.current_active_app_name}", - on_clicked=lambda _: show_about_app(), - ) - self.global_menu_title = DropDownMouseCapture( - layer="bottom", - child_window=ModusDropdown( - dropdown_id="global-menu-title", - parent=parent, - dropdown_children=[self.global_title_menu_about], - ), - ) - - self.global_menu_file = None - self.global_menu_edit = None - self.global_menu_view = DropDownMouseCapture( - layer="bottom", - child_window=ModusDropdown( - dropdown_id="global-menu-view", - parent=parent, - dropdown_children=[ - dropdown_option( - "Enter Full Screen", - on_click="hyprctl dispatch fullscreen", - ), - ], - ), - ) - self.global_menu_go = None - self.global_menu_window = DropDownMouseCapture( - layer="bottom", - child_window=ModusDropdown( - dropdown_id="global-menu-window", - parent=parent, - dropdown_children=[ - dropdown_option( - "Zoom In", - "๓ฐ‰ +", - on_click="hyprctl -q keyword cursor:zoom_factor $(hyprctl getoption cursor:zoom_factor -j | jq '.float * 1.1')", - ), - dropdown_option( - "Zoom Out", - "๓ฐ‰ -", - on_click="hyprctl -q keyword cursor:zoom_factor $(hyprctl getoption cursor:zoom_factor -j | jq '(.float * 0.9) | if . < 1 then 1 else . end')", - ), - dropdown_divider("---------------------"), - dropdown_option( - "Move Window to Left", - on_click="hyprctl dispatch movewindow l", - ), - dropdown_option( - "Move Window to Right", - on_click="hyprctl dispatch movewindow r", - ), - dropdown_option( - "Cycle Through Windows", - on_click="hyprctl dispatch cyclenext", - ), - dropdown_divider("---------------------"), - dropdown_option( - "Float", on_click="hyprctl dispatch togglefloating" - ), - dropdown_option("Quit", on_click="hyprctl dispatch killactive"), - dropdown_option("Pseudo", on_click="hyprctl dispatch pseudo"), - dropdown_option( - "Toggle Split", on_click="hyprctl dispatch togglesplit" - ), - dropdown_option("Center", on_click="hyprctl dispatch centerwindow"), - dropdown_option("Group", on_click="hyprctl dispatch togglegroup"), - dropdown_option( - "Pin", - on_clicked=lambda _: subprocess.run( - "bash ~/.config/scripts/winpin.sh", shell=True - ), - ), - ], - ), - ) - - self.global_menu_help = DropDownMouseCapture( - layer="bottom", - child_window=ModusDropdown( - dropdown_id="global-menu-help", - parent=parent, - dropdown_children=[ - dropdown_option( - "Modus", - on_click="xdg-open https://github.com/S4NKALP/Modus/issues", - ), - dropdown_divider("---------------------"), - dropdown_option( - "Hyprland Wiki", on_click="xdg-open https://wiki.hyprland.org/" - ), - ], - ), - ) - - # Create menu buttons - modus_service.connect( - "current-active-app-name-changed", - lambda _, value: self.global_title_menu_about.set_property( - "label", f"About {value}" - ), - ) - - # Connect to active app name changes to update the title button - modus_service.connect("current-active-app-name-changed", self._on_active_app_changed) - - self.global_menu_button_title = Button( - child=ActiveWindow( - formatter=FormattedString( - "{ format_window(win_title, win_class) }", - format_window=format_window, - ) - ), - name="global-title-button", - style_classes="button", - on_clicked=self._on_title_button_clicked, - ) - - self.global_menu_title.child_window.set_pointing_to( - self.global_menu_button_title - ) - - self.global_menu_button_file = Button( - label="File", name="global-menu-button-file", style_classes="button" - ) - self.global_menu_button_edit = Button( - label="Edit", name="global-menu-button-edit", style_classes="button" - ) - self.global_menu_button_view = Button( - label="View", - name="global-menu-button-view", - style_classes="button", - on_clicked=lambda _: self.global_menu_view.toggle_mousecapture(), - ) - self.global_menu_view.child_window.set_pointing_to(self.global_menu_button_view) - self.global_menu_button_go = Button( - label="Go", name="global-menu-button-go", style_classes="button" - ) - self.global_menu_button_window = Button( - label="Window", - name="global-menu-button-window", - style_classes="button", - on_clicked=lambda _: self.global_menu_window.toggle_mousecapture(), - ) - self.global_menu_window.child_window.set_pointing_to( - self.global_menu_button_window - ) - self.global_menu_button_help = Button( - label="Help", - name="global-menu-button-help", - style_classes="button", - on_clicked=lambda _: self.global_menu_help.toggle_mousecapture(), - ) - self.global_menu_help.child_window.set_pointing_to(self.global_menu_button_help) - - modus_service.connect("current-dropdown-changed", self.changed_dropdown) - modus_service.connect("dropdowns-hide-changed", self.hide_dropdowns) - - def _on_title_button_clicked(self, _): - """Handle title button click - only show dropdown if there's an active window""" - try: - # Use modus_service's Hyprland connection to get current window info - if ( - hasattr(modus_service, "_hyprland_connection") - and modus_service._hyprland_connection - ): - window_data = modus_service._hyprland_connection.send_command( - "j/activewindow" - ).reply - if window_data: - window_info = json.loads(window_data.decode("utf-8")) - wmclass = window_info.get("class", "") - title = window_info.get("title", "") - - # Only show dropdown if there's an active window (not Finder) - if wmclass or title: - self.global_menu_title.toggle_mousecapture() - return - - # Fallback: check if current_active_app_name is not "Finder" - if ( - modus_service.current_active_app_name - and modus_service.current_active_app_name != "Finder" - ): - self.global_menu_title.toggle_mousecapture() - except Exception: - # If we can't get window info, don't show dropdown - pass - - def _on_active_app_changed(self, _, value): - """Handle active app name changes""" - # Update the "About" menu item label - self.global_title_menu_about.set_property("label", f"About {value}") - - def hide_dropdowns(self, *_): - self.menu_button.remove_style_class("active") - self.global_menu_button_edit.remove_style_class("active") - self.global_menu_button_file.remove_style_class("active") - self.global_menu_button_go.remove_style_class("active") - self.global_menu_button_help.remove_style_class("active") - self.global_menu_button_title.remove_style_class("active") - self.global_menu_button_view.remove_style_class("active") - self.global_menu_button_window.remove_style_class("active") - - def changed_dropdown(self, _, dropdown_id): - self.hide_dropdowns(_, True) - match dropdown_id: - case "os-menu": - self.menu_button.add_style_class("active") - case "global-menu-edit": - self.global_menu_button_edit.add_style_class("active") - case "global-menu-file": - self.global_menu_button_file.add_style_class("active") - case "global-menu-go": - self.global_menu_button_go.add_style_class("active") - case "global-menu-help": - self.global_menu_button_help.add_style_class("active") - case "global-menu-title": - self.global_menu_button_title.add_style_class("active") - case "global-menu-view": - self.global_menu_button_view.add_style_class("active") - case "global-menu-window": - self.global_menu_button_window.add_style_class("active") - case _: - pass - - -class MenuBar(Box): - """Main MenuBar widget that contains all menu buttons""" - - def __init__(self, parent_window=None, **kwargs): - # Extract parent_window from kwargs if not provided as parameter - if parent_window is None: - parent_window = kwargs.pop("parent_window", None) - - super().__init__(name="menubar", orientation="horizontal", spacing=0, **kwargs) - - # Create the dropdown system - self.dropdown_system = MenuBarDropdowns(parent=parent_window) - - # Add all the menu buttons to the menubar - self.children = [ - self.dropdown_system.global_menu_button_title, - self.dropdown_system.global_menu_button_file, - self.dropdown_system.global_menu_button_edit, - self.dropdown_system.global_menu_button_view, - self.dropdown_system.global_menu_button_go, - self.dropdown_system.global_menu_button_window, - self.dropdown_system.global_menu_button_help, - ] - - def show_system_dropdown(self, imac_button): - self.dropdown_system.menu_button_dropdown.child_window.set_pointing_to( - imac_button - ) - mouse_capture = self.dropdown_system.menu_button_dropdown - if mouse_capture.is_visible(): - mouse_capture.set_child_window_visible(False) - else: - mouse_capture.set_child_window_visible(True) diff --git a/modules/panel/main.py b/modules/panel/main.py deleted file mode 100644 index 9008264e..00000000 --- a/modules/panel/main.py +++ /dev/null @@ -1,250 +0,0 @@ -from fabric.hyprland.widgets import HyprlandWorkspaces, WorkspaceButton -from fabric.system_tray.widgets import SystemTray -from fabric.utils import get_relative_path -from fabric.widgets.box import Box -from fabric.widgets.button import Button -from fabric.widgets.centerbox import CenterBox -from fabric.widgets.datetime import DateTime -from fabric.widgets.revealer import Revealer -from fabric.widgets.svg import Svg - -import config.data as data -from modules.controlcenter.main import ModusControlCenter -from modules.notification.notification_center import NotificationCenter -from modules.panel.components.enhanced_system_tray import apply_enhanced_system_tray -from modules.panel.components.indicators import ( - BatteryIndicator, - BluetoothIndicator, - NetworkIndicator, -) -from modules.panel.components.menubar import MenuBar -from modules.panel.components.recording_indicator import RecordingIndicator -from modules.todo.todo_widget import TodoListCapture -from services.modus import notification_service -from utils.functions import is_special_workspace_id -from utils.roam import modus_service -from widgets.mousecapture import MouseCapture -from widgets.wayland import WaylandWindow as Window - -# Apply enhanced system tray icon handling -apply_enhanced_system_tray() - - -class Panel(Window): - def __init__(self, **kwargs): - super().__init__( - name="bar", - title="modus", - layer="top", - anchor="left top right", - exclusivity="auto", - visible=False, - ) - - self.launcher = kwargs.get("launcher", None) - self.menubar = MenuBar(parent_window=self) - - self.workspace_indicator = HyprlandWorkspaces( - name="workspaces", - spacing=4, - buttons_factory=lambda ws_id: ( - None - if data.HIDE_SPECIAL_WORKSPACE and is_special_workspace_id(ws_id) - else WorkspaceButton(id=ws_id, label=str(ws_id)) - ), - ) - - self.imac = Button( - name="panel-button", - child=Svg( - size=16, - svg_file=get_relative_path("../../config/assets/icons/misc/logo.svg"), - ), - on_clicked=lambda *_: self.menubar.show_system_dropdown(self.imac), - ) - - self.tray = SystemTray(name="system-tray", spacing=4, icon_size=20) - self.tray_revealer = Revealer( - name="tray-revealer", - child=self.tray, - child_revealed=False, - transition_type="slide-left", - transition_duration=300, - ) - - self.chevron_button = Button( - name="panel-button", - child=Svg( - size=16, - svg_file=get_relative_path( - "../../config/assets/icons/misc/chevron-right.svg" - ), - ), - on_clicked=self.toggle_tray, - ) - - self.indicators = Box( - name="indicators", - orientation="h", - spacing=4, - children=[ - BatteryIndicator(), - NetworkIndicator(), - BluetoothIndicator(), - ], - ) - - self.search = Button( - name="panel-button", - on_clicked=lambda *_: self.search_apps(), - child=Svg( - size=22, - svg_file=get_relative_path("../../config/assets/icons/misc/search.svg"), - ), - ) - - self.control_center = MouseCapture( - layer="top", child_window=ModusControlCenter() - ) - - self.control_center_button = Button( - name="control-center-button", - style_classes="button", - on_clicked=self.control_center.toggle_mousecapture, - child=Svg( - size=22, - svg_file=get_relative_path( - "../../config/assets/icons/misc/control-center.svg" - ), - ), - ) - - # Notification Center with MouseCapture - self.notification_center = MouseCapture( - layer="overlay", child_window=NotificationCenter() - ) - - # Todo List with MouseCapture - self.todo_list = TodoListCapture() - - # Notification Center Icon - self.notification_icon = Svg( - size=22, - svg_file=get_relative_path( - "../../config/assets/icons/notifications/notification-inactive.svg" - ), - ) - - self.notification_center_icon_button = Button( - name="notification-center-icon-button", - child=self.notification_icon, - on_clicked=self.on_notification_icon_clicked, - ) - - # Clickable DateTime for todo list - self.datetime_button = Button( - name="datetime-button", - child=DateTime(name="date-time", formatters=["%a %-d %b %I:%M %P"]), - on_clicked=self.on_datetime_clicked, - ) - - self.recording_indicator = RecordingIndicator() - - self.children = CenterBox( - name="panel", - start_children=Box( - name="modules-left", - children=[ - self.imac, - self.menubar, - ], - ), - center_children=Box( - name="modules-center", - children=self.recording_indicator, - ), - end_children=Box( - name="modules-right", - spacing=4, - orientation="h", - children=[ - self.workspace_indicator, - self.tray_revealer, - self.chevron_button, - self.indicators, - self.search, - self.control_center_button, - self.datetime_button, - self.notification_center_icon_button, - ], - ), - ) - - # Connect to DND state changes for notification icon - modus_service.connect("dont-disturb-changed", self.on_dnd_changed) - - # Connect to notification service for icon state updates - notification_service.connect( - "notify::count", self.on_notification_count_changed - ) - - # Set initial notification icon state - self.update_notification_icon() - - return self.show_all() - - def search_apps(self): - self.launcher.show_launcher() - - def toggle_tray(self, *_): - current_state = self.tray_revealer.child_revealed - self.tray_revealer.child_revealed = not current_state - - if self.tray_revealer.child_revealed: - self.chevron_button.get_child().set_from_file( - get_relative_path("../../config/assets/icons/misc/chevron-left.svg") - ) - else: - self.chevron_button.get_child().set_from_file( - get_relative_path("../../config/assets/icons/misc/chevron-right.svg") - ) - - def on_dnd_changed(self, _, dnd_state): - """Handle DND state changes from the service.""" - self.update_notification_icon() # Update notification icon when DND changes - - def on_notification_count_changed(self, service, *args): - """Handle notification count changes from the service.""" - self.update_notification_icon() - - def on_notification_icon_clicked(self, *args): - """Handle notification icon clicks - only open center if there are notifications.""" - count = notification_service.count - if count > 0: - # Only open notification center if there are notifications - self.notification_center.toggle_mousecapture() - # Do nothing if no notifications - - def on_datetime_clicked(self, *args): - """Handle datetime button clicks - open todo list.""" - self.todo_list.toggle_mousecapture() - - def update_notification_icon(self): - """Update the notification icon based on count and DND state.""" - count = notification_service.count - dnd_enabled = modus_service.dont_disturb - - if dnd_enabled: - # DND is enabled - show disabled icon - icon_file = "notification-disabled.svg" - elif count > 0: - # Has notifications - show active icon - icon_file = "notification-active.svg" - else: - # No notifications - show inactive icon - icon_file = "notification-inactive.svg" - - icon_path = get_relative_path( - f"../../config/assets/icons/notifications/{icon_file}" - ) - self.notification_icon.set_from_file(icon_path) diff --git a/modules/todo/__init__.py b/modules/todo/__init__.py deleted file mode 100644 index 30afe866..00000000 --- a/modules/todo/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Todo module \ No newline at end of file diff --git a/modules/widget.py b/modules/widget.py deleted file mode 100644 index 21ce2876..00000000 --- a/modules/widget.py +++ /dev/null @@ -1,887 +0,0 @@ -# Standard library imports -import psutil -import requests -import urllib.parse -import datetime -import time -import subprocess -import calendar -from concurrent.futures import ThreadPoolExecutor -from typing import Optional, Tuple, List, Dict, Any - -# Fabric imports -from fabric.widgets.box import Box -from fabric.widgets.label import Label -from fabric.widgets.overlay import Overlay -from fabric.widgets.datetime import DateTime -from fabric.widgets.circularprogressbar import CircularProgressBar -from widgets.wayland import WaylandWindow as Window -from fabric.utils import invoke_repeater -from gi.repository import GLib - -# Local imports -from config.data import load_config - -# Module-level constants -WEATHER_UPDATE_INTERVAL = 600 # 10 minutes -WEATHER_CACHE_TIMEOUT = 1800 # 30 minutes -SYSTEM_UPDATE_INTERVAL = 1000 # 1 second -CALENDAR_UPDATE_INTERVAL = int( - ( - ( - datetime.datetime.combine( - datetime.date.today() + datetime.timedelta(days=1), datetime.time.min - ) - - datetime.datetime.now() - ).total_seconds() - ) - * 1000 -) # Calculate time till midnight -LOCATION_CACHE_TIMEOUT = 604800 # 7 days (extended from 24h) - -# Thread pool for async operations -executor = ThreadPoolExecutor(max_workers=4) - -# Weather condition to CSS class mapping (iOS-style gradients) -WEATHER_GRADIENT_MAP = { - # Clear/Sunny conditions - bright blue to lighter blue - 0: "weather-clear", # Clear sky - 1: "weather-mostly-clear", # Mainly clear - # Cloudy conditions - grey gradients - 2: "weather-partly-cloudy", # Partly cloudy - 3: "weather-overcast", # Overcast - # Fog conditions - muted grey/blue - 45: "weather-fog", # Fog - 48: "weather-fog", # Depositing rime fog - # Light rain/drizzle - blue-grey gradients - 51: "weather-light-rain", # Light drizzle - 53: "weather-rain", # Moderate drizzle - 55: "weather-rain", # Dense drizzle - 61: "weather-light-rain", # Slight rain - 80: "weather-light-rain", # Slight rain showers - # Heavy rain - darker blue-grey - 63: "weather-heavy-rain", # Moderate rain - 65: "weather-heavy-rain", # Heavy rain - 81: "weather-heavy-rain", # Moderate rain showers - 82: "weather-storm", # Violent rain showers - # Snow conditions - blue-white gradients - 56: "weather-snow", # Light freezing drizzle - 57: "weather-snow", # Dense freezing drizzle - 66: "weather-snow", # Light freezing rain - 67: "weather-snow", # Heavy freezing rain - 71: "weather-snow", # Slight snow fall - 73: "weather-heavy-snow", # Moderate snow fall - 75: "weather-heavy-snow", # Heavy snow fall - 77: "weather-snow", # Snow grains - 85: "weather-snow", # Slight snow showers - 86: "weather-heavy-snow", # Heavy snow showers - # Storm conditions - dark dramatic gradients - 95: "weather-storm", # Thunderstorm - 96: "weather-storm", # Thunderstorm with slight hail - 99: "weather-storm", # Thunderstorm with heavy hail -} - -# Weather condition to emoji mapping -WEATHER_EMOJI_MAP = { - 0: "โ˜€๏ธ", # Clear sky - 1: "๐ŸŒค๏ธ", # Mainly clear - 2: "โ›…", # Partly cloudy - 3: "โ˜๏ธ", # Overcast - 45: "๐ŸŒซ๏ธ", # Fog - 48: "๐ŸŒซ๏ธ", # Depositing rime fog - 51: "๐ŸŒฆ๏ธ", # Light drizzle - 53: "๐ŸŒง๏ธ", # Moderate drizzle - 55: "๐ŸŒง๏ธ", # Dense drizzle - 56: "๐ŸŒจ๏ธ", # Light freezing drizzle - 57: "๐ŸŒจ๏ธ", # Dense freezing drizzle - 61: "๐ŸŒฆ๏ธ", # Slight rain - 63: "๐ŸŒง๏ธ", # Moderate rain - 65: "๐ŸŒง๏ธ", # Heavy rain - 66: "๐ŸŒจ๏ธ", # Light freezing rain - 67: "๐ŸŒจ๏ธ", # Heavy freezing rain - 71: "๐ŸŒจ๏ธ", # Slight snow fall - 73: "โ„๏ธ", # Moderate snow fall - 75: "โ„๏ธ", # Heavy snow fall - 77: "๐ŸŒจ๏ธ", # Snow grains - 80: "๐ŸŒฆ๏ธ", # Slight rain showers - 81: "๐ŸŒง๏ธ", # Moderate rain showers - 82: "โ›ˆ๏ธ", # Violent rain showers - 85: "๐ŸŒจ๏ธ", # Slight snow showers - 86: "โ„๏ธ", # Heavy snow showers - 95: "โ›ˆ๏ธ", # Thunderstorm - 96: "โ›ˆ๏ธ", # Thunderstorm with slight hail - 99: "โ›ˆ๏ธ", # Thunderstorm with heavy hail -} - -# Weather condition descriptions -WEATHER_DESC_MAP = { - 0: "Clear sky", - 1: "Mainly clear", - 2: "Partly cloudy", - 3: "Overcast", - 45: "Fog", - 48: "Depositing rime fog", - 51: "Light drizzle", - 53: "Moderate drizzle", - 55: "Dense drizzle", - 56: "Light freezing drizzle", - 57: "Dense freezing drizzle", - 61: "Slight rain", - 63: "Moderate rain", - 65: "Heavy rain", - 66: "Light freezing rain", - 67: "Heavy freezing rain", - 71: "Slight snow", - 73: "Moderate snow", - 75: "Heavy snow", - 77: "Snow grains", - 80: "Light rain showers", - 81: "Moderate rain showers", - 82: "Violent rain showers", - 85: "Slight snow showers", - 86: "Heavy snow showers", - 95: "Thunderstorm", - 96: "Thunderstorm with hail", - 99: "Thunderstorm with heavy hail", -} - -# Location APIs in order of preference (fastest first) -LOCATION_APIS = [ - "https://ipapi.co/json/", # Fastest, 200ms average - "http://ip-api.com/json/", # Fast fallback, 150ms average - "https://ipinfo.io/json", # Original fallback -] - -# Global cache for weather data -_weather_cache: Dict[str, Tuple[Any, float]] = {} -_location_cache: Dict[str, Tuple[float, float, float]] = {} - - -def get_location() -> str: - """Get current location using multiple IP geolocation APIs with fallback.""" - for api_url in LOCATION_APIS: - try: - response = requests.get(api_url, timeout=2) - if response.status_code == 200: - data = response.json() - # Handle different API response formats - city = data.get("city", "") - if city: - return city.replace(" ", "") - except requests.RequestException as e: - print(f"Location API {api_url} failed: {e}") - continue - - print("All location APIs failed") - return "" - - -def get_coordinates(city: str) -> Optional[Tuple[float, float]]: - """Get coordinates for a city using Nominatim geocoding API.""" - cache_key = city.lower() - current_time = time.time() - - # Check cache first (cache for 7 days) - if cache_key in _location_cache: - lat, lon, timestamp = _location_cache[cache_key] - if current_time - timestamp < LOCATION_CACHE_TIMEOUT: - return lat, lon - - try: - encoded_city = urllib.parse.quote(city) - url = f"https://nominatim.openstreetmap.org/search?q={encoded_city}&format=json&limit=1" - response = requests.get( - url, timeout=3, headers={"User-Agent": "Modus-Desktop/1.0"} - ) - - if response.status_code == 200: - data = response.json() - if data: - lat = float(data[0]["lat"]) - lon = float(data[0]["lon"]) - _location_cache[cache_key] = (lat, lon, current_time) - return lat, lon - except (requests.RequestException, ValueError, KeyError) as e: - print(f"Error geocoding {city}: {e}") - - return None - - -def get_weather_data(lat: float, lon: float) -> Optional[Dict[str, Any]]: - """Fetch weather data from Open-Meteo API.""" - try: - url = ( - f"https://api.open-meteo.com/v1/forecast?" - f"latitude={lat}&longitude={lon}" - f"¤t_weather=true" - f"&daily=temperature_2m_max,temperature_2m_min" - f"&timezone=auto" - f"&forecast_days=1" - ) - - response = requests.get(url, timeout=3) - if response.status_code == 200: - return response.json() - except requests.RequestException as e: - print(f"Error fetching weather data: {e}") - - return None - - -def format_weather_data(weather_data: Dict[str, Any], city: str) -> List[str]: - """Format weather data into the expected format.""" - try: - current = weather_data["current_weather"] - daily = weather_data["daily"] - - # Get weather code and map to emoji and description - weather_code = current["weathercode"] - emoji = WEATHER_EMOJI_MAP.get(weather_code, "๐ŸŒค๏ธ") - condition = WEATHER_DESC_MAP.get(weather_code, "Unknown") - gradient_class = WEATHER_GRADIENT_MAP.get(weather_code, "weather-clear") - - # Temperature - temp = f"{round(current['temperature'])}ยฐ" - - # Daily min/max temperatures - max_temp = f"{round(daily['temperature_2m_max'][0])}ยฐ" - min_temp = f"{round(daily['temperature_2m_min'][0])}ยฐ" - - return [emoji, temp, condition, city, max_temp, min_temp, gradient_class] - - except (KeyError, IndexError, TypeError) as e: - print(f"Error formatting weather data: {e}") - return None - - -def get_weather(callback): - """Fetch weather data asynchronously using Open-Meteo API.""" - - def fetch_weather(): - # Get location - location = get_location() - if not location: - return GLib.idle_add(callback, None) - - # Check cache first - cache_key = location.lower() - current_time = time.time() - - if cache_key in _weather_cache: - cached_data, timestamp = _weather_cache[cache_key] - if current_time - timestamp < WEATHER_CACHE_TIMEOUT: - return GLib.idle_add(callback, cached_data) - - # Get coordinates for the location - coords = get_coordinates(location) - if not coords: - return GLib.idle_add(callback, None) - - lat, lon = coords - - # Fetch weather data - weather_data = get_weather_data(lat, lon) - if not weather_data: - return GLib.idle_add(callback, None) - - # Format data - formatted_data = format_weather_data(weather_data, location) - if formatted_data: - # Cache the result - _weather_cache[cache_key] = (formatted_data, current_time) - GLib.idle_add(callback, formatted_data) - else: - GLib.idle_add(callback, None) - - executor.submit(fetch_weather) - - -def update_weather(widget): - """Update weather widget with new data.""" - - def fetch_and_update(): - get_weather(lambda weather_info: update_widget(widget, weather_info)) - return True - - GLib.timeout_add_seconds(WEATHER_UPDATE_INTERVAL, fetch_and_update) - fetch_and_update() - - -def update_widget(widget, weather_info): - """Update widget labels with weather information.""" - if weather_info: - widget.weatherinfo = weather_info - widget.update_labels(weather_info) - - -class Weather(Box): - """Weather widget displaying current conditions and forecast.""" - - def __init__(self, parent, **kwargs): - super().__init__( - name="weather-widget", - h_expand=True, - v_expand=True, - justification="right", - orientation="v", - all_visible=False, - **kwargs, - ) - - self.parent = parent - self.weatherinfo = None - - # Create labels with better organization - self._create_labels() - self._layout_labels() - - # Start weather updates - update_weather(self) - - def _create_labels(self): - """Create all weather labels.""" - self.city = Label( - name="city", - label="Loading...", - justification="right", - h_align="start", - max_chars_width=12, - ellipsization="end", - ) - self.temperature = Label(name="temperature", label="--ยฐ", h_align="start") - self.condition_em = Label(name="condition-emoji", label="๐ŸŒค๏ธ", h_align="start") - self.condition = Label( - name="condition", - label="Loading...", - max_chars_width=18, - ellipsization="end", - h_align="start", - ) - self.feels_like = Label(name="feels-like", label="H:-- L:--", h_align="start") - - def _layout_labels(self): - """Add labels to the widget in proper order.""" - labels = [ - self.city, - self.temperature, - self.condition_em, - self.condition, - self.feels_like, - ] - for label in labels: - self.add(label) - - def update_labels(self, weather_info: List[str]): - """Update weather labels with new data.""" - if not weather_info or len(weather_info) != 7: - return - - emoji, temp, condition, location, maxtemp, mintemp, gradient_class = ( - weather_info - ) - maxmin = f"H:{maxtemp} L:{mintemp}" - - # Batch update labels for better performance - label_updates = [ - (self.city, location), - (self.temperature, temp), - (self.condition_em, emoji), - (self.condition, condition), - (self.feels_like, maxmin), - ] - - for label, text in label_updates: - label.set_label(text) - - # Apply gradient background based on weather condition - self.parent.set_visible(True) - - -class WeatherContainer(Box): - """Container for weather widget.""" - - def __init__(self, **kwargs): - super().__init__( - orientation="v", - name="weather-container", - v_expand=True, - v_align="center", - size=(170, 170), - visible=True, - h_align="center", - children=[Weather(self)], - **kwargs, - ) - - -class Date(Box): - """Date widget displaying day, month, and date.""" - - def __init__(self, **kwargs): - super().__init__( - name="date-widget", - h_expand=True, - v_expand=True, - justification="center", - h_align="center", - v_align="start", - orientation="v", - **kwargs, - ) - - # Create date components - self.top = Box(orientation="h", name="date-top", h_expand=True) - - # Use consistent interval for all date components - date_interval = 10000 # 10 seconds - self.dateone = DateTime(formatters=["%a"], interval=date_interval, name="day") - self.datetwo = DateTime(formatters=["%b"], interval=date_interval, name="month") - self.datethree = DateTime( - formatters=["%-d"], interval=date_interval, name="date" - ) - - # Layout components - self.top.add(self.dateone) - self.top.add(self.datetwo) - self.add(self.top) - self.add(self.datethree) - - -class DateContainer(Box): - """Container for date widget.""" - - def __init__(self, **kwargs): - super().__init__( - orientation="v", - name="date-container", - v_expand=True, - size=(170, 170), - v_align="center", - h_align="center", - children=[Date()], - **kwargs, - ) - - -class Calendar(Box): - """Calendar widget displaying current month.""" - - def __init__(self, **kwargs): - # Set Sunday as first day of week - calendar.setfirstweekday(6) # 6 = Sunday - super().__init__( - name="calendar-widget", - h_expand=True, - v_expand=True, - orientation="v", - **kwargs, - ) - - # Cache current date for efficiency - self._update_current_date() - - # Create calendar components - self._create_header() - self._create_days_header() - self._create_calendar_grid() - - # Layout components - self.add(self.month_label) - self.add(self.days_header) - self.add(self.calendar_grid) - - # Schedule updates - invoke_repeater(CALENDAR_UPDATE_INTERVAL, self.update_calendar_if_needed) - - def _update_current_date(self): - """Update cached current date values.""" - now = datetime.datetime.now() - self.current_month = now.month - self.current_year = now.year - self.current_day = now.day - - def _create_header(self): - """Create month header label.""" - self.month_label = Label( - name="calendar-month", - label=calendar.month_name[self.current_month], - h_align="start", - justification="left", - ) - - def _create_days_header(self): - """Create day abbreviations header.""" - self.days_header = Box( - name="calendar-days-header", orientation="h", h_expand=True, spacing=2 - ) - - day_names = ["S", "M", "T", "W", "T", "F", "S"] - for i, day_name in enumerate(day_names): - is_weekend = i in (0, 6) # Sunday or Saturday - day_label = Label( - name=( - "calendar-day-header-weekend" - if is_weekend - else "calendar-day-header" - ), - label=day_name, - h_align="center", - h_expand=True, - ) - self.days_header.add(day_label) - - def _create_calendar_grid(self): - """Create calendar grid container.""" - self.calendar_grid = Box(name="calendar-grid", orientation="v", spacing=1) - self.update_calendar() - - def update_calendar_if_needed(self) -> bool: - """Check if date changed and update calendar if needed.""" - now = datetime.datetime.now() - if ( - now.month != self.current_month - or now.year != self.current_year - or now.day != self.current_day - ): - - self._update_current_date() - self.update_calendar() - return True - - def update_calendar(self): - """Update the calendar grid with current month.""" - # Clear existing calendar efficiently - children = self.calendar_grid.get_children() - for child in children: - self.calendar_grid.remove(child) - - # Update month label - self.month_label.set_label(calendar.month_name[self.current_month]) - - # Generate calendar - cal = calendar.monthcalendar(self.current_year, self.current_month) - current_date = datetime.datetime.now() - - for week in cal: - week_box = Box(orientation="h", spacing=2, h_expand=True) - - for day_index, day in enumerate(week): - if day == 0: - # Empty day slot - day_label = Label( - name="calendar-day-empty", - label="", - h_align="center", - h_expand=True, - ) - else: - # Regular day - is_today = ( - day == self.current_day - and self.current_month == current_date.month - and self.current_year == current_date.year - ) - is_weekend = day_index in (0, 6) # Sunday or Saturday - - if is_today: - name = "calendar-day-today" - elif is_weekend: - name = "calendar-day-weekend" - else: - name = "calendar-day" - - day_label = Label( - name=name, label=str(day), h_align="center", h_expand=True - ) - - week_box.add(day_label) - self.calendar_grid.add(week_box) - - -class CalendarContainer(Box): - """Container for calendar widget.""" - - def __init__(self, **kwargs): - super().__init__( - orientation="v", - name="calendar-box-widget", - v_expand=True, - size=(170, 170), - v_align="center", - h_align="center", - children=[Calendar()], - **kwargs, - ) - - -class SystemInfoBase(Box): - """Base class for system information widgets.""" - - @staticmethod - def create_progress_bar(name: str = "progress-bar", size: int = 80, **kwargs): - """Create a standardized circular progress bar.""" - return CircularProgressBar( - name=name, - start_angle=270, - end_angle=630, - min_value=0, - max_value=100, - size=size, - **kwargs, - ) - - def __init__(self, name: str, **kwargs): - super().__init__( - layer="bottom", - title="sysinfo", - name=name, - visible=True, - size=(170, 170), - h_expand=True, - v_expand=True, - all_visible=True, - **kwargs, - ) - - # Create progress bar and labels - self.progress = self.create_progress_bar(name="progress") - self.main_label = Label( - label="0%\nLoading", justification="center", name="progress-label" - ) - - # Create info container - self.info_container = Box( - name="info-container", - orientation="v", - spacing=2, - h_align="center", - ) - - # Create main layout - self.progress_container = Box( - name="progress-bar-container", - h_expand=True, - v_expand=True, - orientation="v", - spacing=12, - h_align="center", - v_align="center", - children=[ - Box( - children=[ - Overlay( - child=self.progress, - tooltip_text="", - overlays=self.main_label, - ) - ] - ), - Box( - h_align="center", - justification="centre", - orientation="v", - children=[self.info_container], - ), - ], - ) - - self.add(self.progress_container) - - # Don't start updates here - let subclasses call start_updates() when ready - - def start_updates(self): - """Start the update timer - call this after subclass initialization is complete.""" - invoke_repeater(SYSTEM_UPDATE_INTERVAL, self.update) - - def create_info_line( - self, indicator_name: str, info_text: str, value_text: str - ) -> Box: - """Create an information line with indicator, label, and value.""" - indicator = Label(label="โ– ", name=indicator_name) - info_label = Label(label=info_text, name="info-text") - value_label = Label(label=value_text, name="info-value") - - line = Box( - orientation="h", - spacing=4, - h_align="start", - children=[indicator, info_label, value_label], - ) - - # Store references for easy updates - line.indicator = indicator - line.info_label = info_label - line.value_label = value_label - - return line - - def update(self) -> bool: - """Override in subclasses.""" - raise NotImplementedError - - -class RamInfo(SystemInfoBase): - """RAM usage information widget.""" - - def __init__(self, **kwargs): - super().__init__("info-box-widget", **kwargs) - - # Create info lines and store references - self.used_line = self.create_info_line("used-color-indicator", "Used", "0.0GB") - self.free_line = self.create_info_line("free-color-indicator", "Free", "0.0GB") - - # Add to info container - self.info_container.add(self.used_line) - self.info_container.add(self.free_line) - - # Now that everything is set up, start updates - self.start_updates() - - def update(self) -> bool: - """Update RAM information.""" - try: - mem = psutil.virtual_memory() - - # Update main label - self.main_label.set_label(f" {round(mem.percent):<2} %\nRAM") - - # Calculate values - used_gb = mem.used / (1024**3) - free_gb = mem.available / (1024**3) - - # Update info labels using stored references - self.used_line.value_label.set_label(f"{round(used_gb, 1)}GB") - self.free_line.value_label.set_label(f"{round(free_gb, 1)}GB") - - # Update progress bar (use GLib.idle_add for thread safety) - GLib.idle_add(self.progress.set_value, mem.percent) - - except Exception as e: - print(f"Error updating RAM info: {e}") - - return True - - -class CpuInfo(SystemInfoBase): - """CPU usage and temperature information widget.""" - - def __init__(self, **kwargs): - super().__init__("info-box-widget", **kwargs) - - # Create temperature info components - self.temp_info = Label(label="Temp", name="info-text") - self.temp_value = Label(label="0ยฐC", name="info-value") - - # Create temperature info line (no indicator) - self.temp_line = Box( - orientation="h", - spacing=4, - h_align="start", - children=[self.temp_info, self.temp_value], - ) - - # Add to info container - self.info_container.add(self.temp_line) - - # Now that everything is set up, start updates - self.start_updates() - - def get_cpu_temp(self) -> Optional[float]: - """Get CPU temperature from system sensors.""" - try: - temps = psutil.sensors_temperatures() - if not temps: - return None - - # Search for CPU temperature sensors - cpu_sensor_names = ["coretemp", "k10temp", "cpu"] - cpu_label_patterns = ["package id 0", "core 0", ""] - - for name, entries in temps.items(): - if any(sensor in name.lower() for sensor in cpu_sensor_names): - for entry in entries: - entry_label = (entry.label or "").lower() - if any( - pattern in entry_label for pattern in cpu_label_patterns - ): - return round(entry.current, 1) - except Exception as e: - print(f"Error reading CPU temperature: {e}") - - return None - - def update(self) -> bool: - """Update CPU information.""" - try: - # Get CPU usage - cpu = psutil.cpu_percent() - - # Update main label - self.main_label.set_label(f" {round(cpu):<2} %\nCPU") - - # Update temperature using stored reference - temp = self.get_cpu_temp() - temp_text = f"{temp}ยฐC" if temp is not None else "N/A" - self.temp_value.set_label(temp_text) - - # Update progress bar (use GLib.idle_add for thread safety) - GLib.idle_add(self.progress.set_value, cpu) - - except Exception as e: - print(f"Error updating CPU info: {e}") - - return True - - -class Deskwidgets(Window): - """Desktop widgets manager - handles all desktop widgets.""" - - config = load_config() - - def __init__(self, **kwargs): - # Create the main invisible window that manages the widgets - super().__init__( - name="desktop-widget-manager", - layer="bottom", - title="modus-desktop-widget-manager", - visible=False, # This window is invisible - just manages the others - size=(1, 1), # Minimal size - anchor="top left", - **kwargs, - ) - - # Create separate independent windows as attributes - self.top_left = Window( - anchor="top left", - title="modus-widgets-topleft", - orientation="h", - layer="bottom", - visible=False, # Start hidden until content ready - child=Box( - name="desktop-widgets-container", - children=[ - DateContainer(), - WeatherContainer(), - CalendarContainer(), - ], - ), - ) - - self.bottom_left = Window( - anchor="bottom right", - title="modus-widgets-bottomright", - orientation="h", - layer="bottom", - visible=False, # Start hidden until content ready - child=Box( - name="desktop-widgets-container", - children=[ - CpuInfo(), - RamInfo(), - ], - ), - ) - - # Show widgets after initialization is complete - self.top_left.set_visible(True) - self.bottom_left.set_visible(True) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..04f81399 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,37 @@ +[project] +name = "modus" +version = "0.2.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.13" +dependencies = [ + "fabric", + "httpx>=0.28.1", + "pam>=0.2.0", + "psutil>=7.2.2", + "pywayland>=0.4.18", + "six>=1.17.0", +] + +[dependency-groups] +dev = [ + "pyinstrument>=5.1.2", + "ruff>=0.15.2", +] + +[tool.uv.sources] +fabric = { git = "https://github.com/Fabric-Development/fabric.git" } + +[project.scripts] +start = "main:main" +lock = "lock:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/shared", "src/utils", "src/window", "src/services", "src/main.py"] + +[tool.hatch.build.targets.wheel.sources] +"src" = "" diff --git a/requirements.txt b/requirements.txt index 4da8a516..a40480aa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,17 +1,6 @@ -certifi==2025.8.3 -charset-normalizer==3.4.2 -click==8.2.1 -idna==3.10 -loguru==0.7.3 -pillow==12.2.0 -psutil==7.0.0 -pycairo==1.28.0 -pydbus==0.6.0 -PyGObject==3.52.3 -pyotp==2.9.0 -pyzbar==0.1.9 -RapidFuzz==3.13.0 -requests==2.33.0 -setproctitle==1.3.6 -thefuzz==0.22.1 -urllib3==2.6.3 +fabric @ git+https://github.com/Fabric-Development/fabric.git@fd2aabbd7e1859aa7c11c626a6c36a937aca736a +pam>=0.2.0 +psutil>=7.2.2 +pywayland==0.4.18 +six>=1.17.0 +httpx>=0.28.1 \ No newline at end of file diff --git a/scripts/gamemode.sh b/scripts/gamemode.sh deleted file mode 100755 index 3652ccdc..00000000 --- a/scripts/gamemode.sh +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env sh - -# Check if animations are disabled (game mode is active) -check_gamemode() { - HYPRGAMEMODE=$(hyprctl getoption animations:enabled | awk 'NR==1{print $2}') - if [ "$HYPRGAMEMODE" = 0 ]; then - echo "t" - return 0 - else - echo "f" - return 1 - fi -} - -# Toggle game mode state -toggle_gamemode() { - HYPRGAMEMODE=$(hyprctl getoption animations:enabled | awk 'NR==1{print $2}') - if [ "$HYPRGAMEMODE" = 1 ]; then - hyprctl --batch "\ - keyword animations:enabled 0;\ - keyword decoration:shadow:enabled 0;\ - keyword decoration:blur:enabled 0;\ - keyword general:gaps_in 0;\ - keyword general:gaps_out 0;\ - keyword general:border_size 1;\ - keyword decoration:rounding 0" - exit - fi - hyprctl reload -} - -# Main script logic -case "$1" in -check) - check_gamemode - ;; -*) - toggle_gamemode - ;; -esac diff --git a/scripts/hyprpicker.sh b/scripts/hyprpicker.sh deleted file mode 100755 index 703c7df7..00000000 --- a/scripts/hyprpicker.sh +++ /dev/null @@ -1,75 +0,0 @@ -#!/bin/bash - -pick_rgb() { - - # Execute hyprpicker with RGB format and save the output to a variable - hyprpicker -a -n -f rgb && sleep 0.1 - - # Create a temporal 64x64 PNG file with the color in /tmp using convert - magick -size 64x64 xc:"rgb($(wl-paste))" /tmp/color.png - - # Send a notification using the file as an icon - notify-send "RGB color picked" "rgb($(wl-paste))" -i /tmp/color.png -a "Hyprpicker" - - # Remove the temporal file - rm /tmp/color.png - - # Exit - exit 0 - -} - -pick_hex() { - - # Execute hyprpicker and save the output to a variable - hyprpicker -a -n -f hex && sleep 0.1 - - # Create a temporal 64x64 PNG file with the color in /tmp using convert - magick -size 64x64 xc:"$(wl-paste)" /tmp/color.png - - # Send a notification using the file as an icon - notify-send "HEX color picked" "$(wl-paste)" -i /tmp/color.png -a "Hyprpicker" - - # Remove the temporal file - rm /tmp/color.png - - # Exit - exit 0 - -} - -pick_hsv() { - - # Copy the color to the clipboard - echo -n "$(hyprpicker -n -f hsv)" | wl-copy -n - - # Create a temporal 64x64 PNG file with the color in /tmp using convert - magick -size 64x64 xc:"hsv($(wl-paste))" /tmp/color.png - - # Send a notification using the file as an icon - notify-send "HSV color picked" "hsv($(wl-paste))" -i /tmp/color.png -a "Hyprpicker" - - # Remove the temporal file - rm /tmp/color.png - - # Exit - exit 0 - -} - -case "$1" in --rgb) - pick_rgb - ;; --hsv) - pick_hsv - ;; --hex) - pick_hex - ;; - -*) - echo "Usage: $0 [-rgb|-hex|-hsv]" - exit 1 - ;; -esac diff --git a/scripts/screen-capture.sh b/scripts/screen-capture.sh deleted file mode 100755 index a1fd8997..00000000 --- a/scripts/screen-capture.sh +++ /dev/null @@ -1,704 +0,0 @@ -#!/bin/env bash - -# Script name -SCRIPT_NAME=$(basename "$0") - -# Function to display usage -usage() { - cat <<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/auth.py b/services/auth.py deleted file mode 100644 index 3a7b0708..00000000 --- a/services/auth.py +++ /dev/null @@ -1,348 +0,0 @@ -import json -import os -import subprocess -import time -from datetime import datetime -from pathlib import Path -from urllib.parse import parse_qs, urlparse - -import pyotp -from PIL import Image -from pyzbar.pyzbar import decode - -import config.data as data - - -def get_otp_file_path(): - """Returns the path to the OTP file inside the cache directory.""" - cache_dir = Path(data.CACHE_DIR) / "otp" - # Create the directory if it doesn't exist - cache_dir.mkdir(parents=True, exist_ok=True) - - file_path = cache_dir / "otp_codes.json" - - # Create the file if it doesn't exist - if not file_path.exists(): - with open(file_path, "w") as f: - json.dump([], f) - print(f"File {file_path} created successfully!") - - return file_path - - -def capture_selected_area(filename="/tmp/screenshot.png"): - """ - Uses slurp to let the user select an area of the screen, - then captures that area using grim. - """ - try: - # slurp returns coordinates in format: x,y widthxheight (e.g. 100,200 300x400) - result = subprocess.run(["slurp"], check=True, capture_output=True, text=True) - geometry = result.stdout.strip() - if not geometry: - print("No area selected with slurp.") - return None - except subprocess.CalledProcessError as e: - print("Error selecting area with slurp:", e) - return None - - try: - # grim uses -g to capture a specific region - subprocess.run(["grim", "-g", geometry, filename], check=True) - except subprocess.CalledProcessError as e: - print("Error capturing screenshot with grim:", e) - return None - - return filename - - -def read_and_save_to_json(): - # Get the full path to the JSON file - json_file = get_otp_file_path() - - screenshot = capture_selected_area() - if screenshot is None: - print("Failed to capture the selected area.") - return False - - # Short delay to ensure the file is written - time.sleep(1) - - # Open the captured image - try: - img = Image.open(screenshot) - except Exception as e: - print("Error opening the image:", e) - return False - - # Decode QR Code(s) from the image - decoded_objects = decode(img) - if not decoded_objects: - print("No QR Code detected in the selected area.") - return False - - results = [] - - for obj in decoded_objects: - data = obj.data.decode("utf-8") - print("QR Code detected:", data) - - result_entry = {"timestamp": datetime.now().isoformat(), "qr_data": data} - - if data.startswith("otpauth://"): - parsed = urlparse(data) - query = parse_qs(parsed.query) - - # Extract the secret properly - secret = query.get("secret", [None])[0] - issuer_from_query = query.get("issuer", [None])[0] - - # Extract the label (path), which may contain issuer and account - label = parsed.path.lstrip("/") if parsed.path else "" - account_name = label - issuer_from_path = None - - if ":" in label: - parts = label.split(":", 1) - issuer_from_path = parts[0] - account_name = parts[1] - - # Prefer issuer from path if available, otherwise use from query - issuer = issuer_from_path or issuer_from_query - - # Extract the period (default is 30 seconds) - period = int(query.get("period", ["30"])[0]) - - # Create TOTP object with the correct interval - totp = pyotp.TOTP(secret, interval=period) - current_otp = totp.now() - print(f"Generated OTP: {current_otp} (valid for {period} seconds)") - - result_entry.update( - { - "type": "otp", - "secret": secret, - "issuer": issuer, - "account_name": account_name, - } - ) - else: - result_entry["type"] = "unknown" - print("Unrecognized format. Expected a URI like otpauth://") - return False - - results.append(result_entry) - - # Load existing data if the file exists - existing_data = [] - if os.path.exists(json_file): - try: - with open(json_file, "r") as f: - existing_data = json.load(f) - except json.JSONDecodeError: - print(f"Error reading the file {json_file}. Creating a new one.") - - # Append new results - existing_data.extend(results) - - # Save to JSON file - with open(json_file, "w") as f: - json.dump(existing_data, f, indent=4) - - print(f"OTP data saved to {json_file}") - return True - - -def CodeOTP(uri): - parsed = urlparse(uri) - query = parse_qs(parsed.query) - secret = query.get("secret", [None])[0] - - if secret is None: - return None - else: - totp = pyotp.TOTP(secret) - return totp.now() - - -# TOTP/OTP utility functions -def generate_totp(secret: str) -> str: - """Generate TOTP code from secret.""" - try: - return pyotp.TOTP(secret).now() - except Exception as e: - print(f"Error generating TOTP: {e}") - return None - - -def get_time_remaining() -> int: - """Get seconds remaining until next token refresh.""" - return 30 - (int(time.time()) % 30) - - -def get_time_remaining_with_blink() -> str: - """Get time remaining with blinking effect.""" - time_remaining = get_time_remaining() - current_second = int(time.time()) - should_blink = current_second % 2 == 0 - - if should_blink: - return f"<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 deleted file mode 100644 index a28cceb8..00000000 --- a/services/battery.py +++ /dev/null @@ -1,251 +0,0 @@ -import psutil -from gi.repository import GLib -from pydbus import SystemBus - -from fabric.core import Property, Service, Signal - -DeviceState = { - 0: "UNKNOWN", - 1: "CHARGING", - 2: "DISCHARGING", - 3: "EMPTY", - 4: "FULLY_CHARGED", - 5: "PENDING_CHARGE", - 6: "PENDING_DISCHARGE", -} - - -class Battery(Service): - @staticmethod - def seconds_to_hours_minutes(seconds): - hours = seconds // 3600 - minutes = (seconds % 3600) // 60 - return f"{hours}h {minutes}m" if hours else f"{minutes}m" - - @staticmethod - def get_battery_icon_level(percentage): - """Get battery icon level based on percentage""" - if percentage >= 90: - return "100" - elif percentage >= 80: - return "090" - elif percentage >= 70: - return "080" - elif percentage >= 60: - return "070" - elif percentage >= 50: - return "060" - elif percentage >= 40: - return "050" - elif percentage >= 30: - return "040" - elif percentage >= 20: - return "030" - elif percentage >= 10: - return "020" - else: - return "010" - - @staticmethod - def get_battery_icon_file(percentage, is_charging, base_path=""): - """Get battery icon file path""" - level = Battery.get_battery_icon_level(percentage) - suffix = "-charging" if is_charging else "" - return f"{base_path}battery/battery-{level}{suffix}.svg" - - @staticmethod - def get_profile_display_name(profile: str) -> str: - """Get user-friendly display name for power profile""" - profile_names = { - "power-saver": "Power Saver", - "powersave": "Power Saver", - "power_saver": "Power Saver", - "balanced": "Balanced", - "balance": "Balanced", - "performance": "Performance", - "performance-mode": "Performance", - } - return profile_names.get(profile, profile.title()) - - @Signal - def changed(self) -> None: ... - - @Signal - def profile_changed(self, value: str) -> None: ... - - @Property(int, "readable") - def percentage(self): - if self._use_psutil_fallback: - if self._psutil_battery: - return int(self._psutil_battery.percent) - return 0 - return int(self._battery.Percentage) - - @Property(str, "readable") - def temperature(self): - if self._use_psutil_fallback: - return "N/A" # psutil doesn't provide temperature - return ( - f"{self._battery.Temperature}ยฐC" - if hasattr(self._battery, "Temperature") - else "N/A" - ) - - @Property(str, "readable") - def time_to_empty(self): - if self._use_psutil_fallback: - if self._psutil_battery and hasattr(self._psutil_battery, "secsleft"): - return self.seconds_to_hours_minutes(self._psutil_battery.secsleft) - return "N/A" - return self.seconds_to_hours_minutes(getattr(self._battery, "TimeToEmpty", 0)) - - @Property(str, "readable") - def time_to_full(self): - if self._use_psutil_fallback: - return "N/A" # psutil doesn't provide time to full - return self.seconds_to_hours_minutes(getattr(self._battery, "TimeToFull", 0)) - - @Property(str, "readable") - def icon_name(self): - if self._use_psutil_fallback: - return "battery" # Generic icon name for psutil fallback - return self._battery.IconName - - @Property(str, "readable") - def state(self): - if self._use_psutil_fallback: - if self._psutil_battery: - # psutil returns power_plugged boolean, convert to state - if self._psutil_battery.power_plugged: - if self._psutil_battery.percent >= 100: - return "FULLY_CHARGED" - else: - return "CHARGING" - else: - return "DISCHARGING" - return "UNKNOWN" - return DeviceState.get(self._battery.State, "UNKNOWN") - - @Property(str, "readable") - def capacity(self): - if self._use_psutil_fallback: - return "N/A" # psutil doesn't provide capacity info - return f"{int(self._battery.Capacity)}%" - - @Property(bool, "readable", default_value=False) - def is_present(self): - if self._use_psutil_fallback: - return self._psutil_battery is not None - return self._battery.IsPresent - - @Property(str, "readable") - def power_profile(self): - if hasattr(self, "_profile_proxy") and self._profile_proxy: - try: - return self._profile_proxy.ActiveProfile - except Exception: - return None - return None - - @Property(list, "readable") - def available_profiles(self): - if hasattr(self, "_profile_proxy") and self._profile_proxy: - try: - profiles = [] - for p in self._profile_proxy.Profiles: - if hasattr(p, "Profile"): - profiles.append(p.Profile) - elif isinstance(p, dict) and "Profile" in p: - profiles.append(p["Profile"]) - elif isinstance(p, str): - profiles.append(p) - return profiles - except Exception: - return [] - return [] - - def change_power_profile(self, profile: str) -> bool: - if not hasattr(self, "_profile_proxy") or not self._profile_proxy: - return False - - # Get available profiles using the same logic as available_profiles property - available_profiles = [] - try: - for p in self._profile_proxy.Profiles: - if hasattr(p, "Profile"): - available_profiles.append(p.Profile) - elif isinstance(p, dict) and "Profile" in p: - available_profiles.append(p["Profile"]) - elif isinstance(p, str): - available_profiles.append(p) - except Exception: - return False - - if profile not in available_profiles: - return False - - try: - self._profile_proxy.ActiveProfile = profile - self.profile_changed.emit(profile) - self.changed.emit() - return True - except Exception: - return False - - def __init__(self): - super().__init__() - self._bus = SystemBus() - self._use_psutil_fallback = False - self._psutil_battery = None - self._profile_proxy = None # Initialize to None first - - # Battery device - try: - self._battery = self._bus.get( - "org.freedesktop.UPower", "/org/freedesktop/UPower/devices/battery_BAT0" - ) - self._battery.onPropertiesChanged = self.handle_battery_change - except Exception: - # Fallback to psutil if UPower is not available - self._use_psutil_fallback = True - try: - self._psutil_battery = psutil.sensors_battery() - if self._psutil_battery is None: - return # No battery found - # Start periodic updates for psutil fallback - increased interval - GLib.timeout_add_seconds(10, self._update_psutil_battery) - except Exception: - return # psutil battery not available either - - # PowerProfiles - Initialize after other attributes - try: - self._profile_proxy = self._bus.get( - "net.hadess.PowerProfiles", "/net/hadess/PowerProfiles" - ) - # Use onPropertiesChanged for consistency with battery device - self._profile_proxy.onPropertiesChanged = ( - lambda _, changed, __: self._handle_profile_props_changed(changed) - ) - except Exception: - self._profile_proxy = None - - self.changed.emit() - - def _update_psutil_battery(self): - """Update psutil battery data periodically""" - try: - self._psutil_battery = psutil.sensors_battery() - self.changed.emit() - except Exception: - pass # Continue trying - return True # Keep the timeout active - - def _handle_profile_props_changed(self, changed): - """Internal handler for property changes that processes only the changed properties""" - if "ActiveProfile" in changed: - new_profile = changed["ActiveProfile"] - self.profile_changed.emit(new_profile) - self.changed.emit() - - def handle_battery_change(self, iface, changed, invalidated): - self.changed.emit() diff --git a/services/mpris.py b/services/mpris.py deleted file mode 100644 index 539a7274..00000000 --- a/services/mpris.py +++ /dev/null @@ -1,303 +0,0 @@ -# Standard library imports -import contextlib - -import gi - -# Fabric imports -from fabric.core.service import Property, Service, Signal -from fabric.utils import bulk_connect -from gi.repository import GLib -from loguru import logger - - -class PlayerctlImportError(ImportError): - def __init__(self, *args): - super().__init__( - "Playerctl is not installed, please install it first", - *args, - ) - - -try: - gi.require_version("Playerctl", "2.0") - from gi.repository import Playerctl -except ValueError: - raise PlayerctlImportError - - -class MprisPlayer(Service): - """A service to manage a mpris player.""" - - @Signal - def exit(self, value: bool) -> bool: ... - - @Signal - def changed(self) -> None: ... - - def __init__( - self, - player: Playerctl.Player, - **kwargs, - ): - self._signal_connectors: dict = {} - self._player: Playerctl.Player = player - super().__init__(**kwargs) - for sn in ["playback-status", "loop-status", "shuffle"]: - self._signal_connectors[sn] = self._player.connect( - sn, - lambda *args, sn=sn: self.notifier(sn, args), - ) - - self._signal_connectors["exit"] = self._player.connect( - "exit", - self.on_player_exit, - ) - self._signal_connectors["metadata"] = self._player.connect( - "metadata", - lambda *_: self.update_status(), - ) - GLib.idle_add(self.update_status_once) - - def update_status(self): - # schedule each notifier asynchronously. - def notify_property(prop): - if self.get_property(prop) is not None: - self.notifier(prop) - - for prop in [ - "metadata", - "title", - "artist", - "arturl", - "length", - ]: - GLib.idle_add(lambda p=prop: (notify_property(p), False)) - for prop in [ - "can-seek", - "can-pause", - "can-shuffle", - "can-go-next", - "can-go-previous", - ]: - GLib.idle_add(lambda p=prop: (self.notifier(p), False)) - - def update_status_once(self): - # schedule notifier calls for each property - def notify_all(): - for prop in self.list_properties(): # type: ignore - self.notifier(prop.name) - return False - - GLib.idle_add(notify_all, priority=GLib.PRIORITY_DEFAULT_IDLE) - - def notifier(self, name: str, args=None): - def notify_and_emit(): - self.notify(name) - self.emit("changed") - return False - - GLib.idle_add(notify_and_emit, priority=GLib.PRIORITY_DEFAULT_IDLE) - - def on_player_exit(self, player): - for id in list(self._signal_connectors.values()): - with contextlib.suppress(Exception): - self._player.disconnect(id) - del self._signal_connectors - GLib.idle_add(lambda: (self.emit("exit", True), False)) - del self._player - - def toggle_shuffle(self, *_): - if self.can_shuffle: - # schedule the shuffle toggle in the GLib idle loop - GLib.idle_add(lambda: (setattr(self, "shuffle", not self.shuffle), False)) - # else do nothing - - def play_pause(self, *_): - if self.can_pause: - GLib.idle_add(lambda: (self._player.play_pause(), False)) - - def next(self, *_): - if self.can_go_next: - GLib.idle_add(lambda: (self._player.next(), False)) - - def previous(self, *_): - if self.can_go_previous: - GLib.idle_add(lambda: (self._player.previous(), False)) - - # Properties - @Property(str, "readable") - def player_name(self) -> int: - return self._player.get_property("player-name") # type: ignore - - @Property(int, "read-write", default_value=0) - def position(self) -> int: - return self._player.get_property("position") # type: ignore - - @position.setter - def position(self, new_pos: int): - self._player.set_position(new_pos) - - @Property(object, "readable") - def metadata(self) -> dict: - return self._player.get_property("metadata") # type: ignore - - @Property(str or None, "readable") - def arturl(self) -> str | None: - if "mpris:artUrl" in self.metadata.keys(): # type: ignore # noqa: SIM118 - return self.metadata["mpris:artUrl"] # type: ignore - return None - - @Property(str or None, "readable") - def length(self) -> str | None: - if "mpris:length" in self.metadata.keys(): # type: ignore # noqa: SIM118 - return self.metadata["mpris:length"] # type: ignore - return None - - @Property(str, "readable") - def artist(self) -> str: - artist = self._player.get_artist() # type: ignore - if isinstance(artist, (list, tuple)): - return ", ".join(artist) - return artist - - @Property(str, "readable") - def album(self) -> str: - return self._player.get_album() # type: ignore - - @Property(str, "readable") - def title(self): - return self._player.get_title() - - @Property(bool, "read-write", default_value=False) - def shuffle(self) -> bool: - return self._player.get_property("shuffle") # type: ignore - - @shuffle.setter - def shuffle(self, do_shuffle: bool): - self.notifier("shuffle") - return self._player.set_shuffle(do_shuffle) - - @Property(str, "readable") - def playback_status(self) -> str: - return { - Playerctl.PlaybackStatus.PAUSED: "paused", - Playerctl.PlaybackStatus.PLAYING: "playing", - Playerctl.PlaybackStatus.STOPPED: "stopped", - # type: ignore - }.get(self._player.get_property("playback_status"), "unknown") - - @Property(str, "read-write") - def loop_status(self) -> str: - return { - Playerctl.LoopStatus.NONE: "none", - Playerctl.LoopStatus.TRACK: "track", - Playerctl.LoopStatus.PLAYLIST: "playlist", - }.get( - self._player.get_property("loop_status"), "unknown" - ) # type: ignore - - @loop_status.setter - def loop_status(self, status: str): - loop_status = { - "none": Playerctl.LoopStatus.NONE, - "track": Playerctl.LoopStatus.TRACK, - "playlist": Playerctl.LoopStatus.PLAYLIST, - }.get(status) - self._player.set_loop_status(loop_status) if loop_status else None - - @Property(bool, "readable", default_value=False) - def can_go_next(self) -> bool: - return self._player.get_property("can_go_next") # type: ignore - - @Property(bool, "readable", default_value=False) - def can_go_previous(self) -> bool: - return self._player.get_property("can_go_previous") # type: ignore - - @Property(bool, "readable", default_value=False) - def can_seek(self) -> bool: - return self._player.get_property("can_seek") # type: ignore - - @Property(bool, "readable", default_value=False) - def can_pause(self) -> bool: - return self._player.get_property("can_pause") # type: ignore - - @Property(bool, "readable", default_value=False) - def can_shuffle(self) -> bool: - try: - self._player.set_shuffle(self._player.get_property("shuffle")) - return True - except Exception: - return False - - @Property(bool, "readable", default_value=False) - def can_loop(self) -> bool: - try: - self._player.set_shuffle(self._player.get_property("shuffle")) - return True - except Exception: - return False - - -class MprisPlayerManager(Service): - """A service to manage mpris players.""" - - @Signal - def player_appeared(self, player: Playerctl.Player) -> Playerctl.Player: ... - - @Signal - def player_vanished(self, player_name: str) -> str: ... - - def __init__( - self, - **kwargs, - ): - self._manager = Playerctl.PlayerManager.new() - self._signal_connections = [] - - # Track signal connections for cleanup - connections = bulk_connect( - self._manager, - { - "name-appeared": self.on_name_appeared, - "name-vanished": self.on_name_vanished, - }, - ) - # Store as (object, handler_id) tuples - for handler_id in connections: - self._signal_connections.append((self._manager, handler_id)) - - self.add_players() - super().__init__(**kwargs) - - def destroy(self): - """Clean up resources when the manager is destroyed.""" - # Disconnect all signal connections - for obj, handler_id in self._signal_connections: - try: - obj.disconnect(handler_id) - except Exception as e: - logger.warning(f"Failed to disconnect manager signal: {e}") - self._signal_connections.clear() - - # Clean up the manager - if hasattr(self, '_manager'): - del self._manager - - def on_name_appeared(self, manager, player_name: Playerctl.PlayerName): - logger.info(f"[MprisPlayer] {player_name.name} appeared") - new_player = Playerctl.Player.new_from_name(player_name) - manager.manage_player(new_player) - self.emit("player-appeared", new_player) # type: ignore - - def on_name_vanished(self, manager, player_name: Playerctl.PlayerName): - logger.info(f"[MprisPlayer] {player_name.name} vanished") - self.emit("player-vanished", player_name.name) # type: ignore - - def add_players(self): - # type: ignore - for player in self._manager.get_property("player-names"): - self._manager.manage_player(Playerctl.Player.new_from_name(player)) # type: ignore - - @Property(object, "readable") - def players(self): - return self._manager.get_property("players") # type: ignore diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/config/assets/icons/applets/bluetooth-clear.svg b/src/assets/icons/applets/bluetooth-clear.svg similarity index 100% rename from config/assets/icons/applets/bluetooth-clear.svg rename to src/assets/icons/applets/bluetooth-clear.svg diff --git a/config/assets/icons/applets/bluetooth-off-clear.svg b/src/assets/icons/applets/bluetooth-off-clear.svg similarity index 100% rename from config/assets/icons/applets/bluetooth-off-clear.svg rename to src/assets/icons/applets/bluetooth-off-clear.svg diff --git a/config/assets/icons/applets/bluetooth-off.svg b/src/assets/icons/applets/bluetooth-off.svg similarity index 100% rename from config/assets/icons/applets/bluetooth-off.svg rename to src/assets/icons/applets/bluetooth-off.svg diff --git a/config/assets/icons/applets/bluetooth-paired.svg b/src/assets/icons/applets/bluetooth-paired.svg similarity index 100% rename from config/assets/icons/applets/bluetooth-paired.svg rename to src/assets/icons/applets/bluetooth-paired.svg diff --git a/config/assets/icons/applets/bluetooth.svg b/src/assets/icons/applets/bluetooth.svg similarity index 100% rename from config/assets/icons/applets/bluetooth.svg rename to src/assets/icons/applets/bluetooth.svg diff --git a/config/assets/icons/applets/brightness.svg b/src/assets/icons/applets/brightness.svg similarity index 100% rename from config/assets/icons/applets/brightness.svg rename to src/assets/icons/applets/brightness.svg diff --git a/config/assets/icons/applets/caffeine-off.svg b/src/assets/icons/applets/caffeine-off.svg similarity index 100% rename from config/assets/icons/applets/caffeine-off.svg rename to src/assets/icons/applets/caffeine-off.svg diff --git a/config/assets/icons/applets/caffeine-on.svg b/src/assets/icons/applets/caffeine-on.svg similarity index 100% rename from config/assets/icons/applets/caffeine-on.svg rename to src/assets/icons/applets/caffeine-on.svg diff --git a/config/assets/icons/applets/dnd-clear.svg b/src/assets/icons/applets/dnd-clear.svg similarity index 100% rename from config/assets/icons/applets/dnd-clear.svg rename to src/assets/icons/applets/dnd-clear.svg diff --git a/config/assets/icons/applets/dnd-off.svg b/src/assets/icons/applets/dnd-off.svg similarity index 100% rename from config/assets/icons/applets/dnd-off.svg rename to src/assets/icons/applets/dnd-off.svg diff --git a/config/assets/icons/applets/dnd.svg b/src/assets/icons/applets/dnd.svg similarity index 100% rename from config/assets/icons/applets/dnd.svg rename to src/assets/icons/applets/dnd.svg diff --git a/config/assets/icons/applets/flight-off.svg b/src/assets/icons/applets/flight-off.svg similarity index 100% rename from config/assets/icons/applets/flight-off.svg rename to src/assets/icons/applets/flight-off.svg diff --git a/config/assets/icons/applets/flight-on.svg b/src/assets/icons/applets/flight-on.svg similarity index 100% rename from config/assets/icons/applets/flight-on.svg rename to src/assets/icons/applets/flight-on.svg diff --git a/config/assets/icons/applets/network-wired-offline.svg b/src/assets/icons/applets/network-wired-offline.svg similarity index 100% rename from config/assets/icons/applets/network-wired-offline.svg rename to src/assets/icons/applets/network-wired-offline.svg diff --git a/config/assets/icons/applets/network-wired.svg b/src/assets/icons/applets/network-wired.svg similarity index 100% rename from config/assets/icons/applets/network-wired.svg rename to src/assets/icons/applets/network-wired.svg diff --git a/config/assets/icons/applets/redshift-status-off.svg b/src/assets/icons/applets/redshift-status-off.svg similarity index 100% rename from config/assets/icons/applets/redshift-status-off.svg rename to src/assets/icons/applets/redshift-status-off.svg diff --git a/config/assets/icons/applets/redshift-status-on.svg b/src/assets/icons/applets/redshift-status-on.svg similarity index 100% rename from config/assets/icons/applets/redshift-status-on.svg rename to src/assets/icons/applets/redshift-status-on.svg diff --git a/config/assets/icons/applets/wifi-clear.svg b/src/assets/icons/applets/wifi-clear.svg similarity index 100% rename from config/assets/icons/applets/wifi-clear.svg rename to src/assets/icons/applets/wifi-clear.svg diff --git a/config/assets/icons/applets/wifi-off-clear.svg b/src/assets/icons/applets/wifi-off-clear.svg similarity index 100% rename from config/assets/icons/applets/wifi-off-clear.svg rename to src/assets/icons/applets/wifi-off-clear.svg diff --git a/config/assets/icons/applets/wifi-off.svg b/src/assets/icons/applets/wifi-off.svg similarity index 100% rename from config/assets/icons/applets/wifi-off.svg rename to src/assets/icons/applets/wifi-off.svg diff --git a/config/assets/icons/applets/wifi.svg b/src/assets/icons/applets/wifi.svg similarity index 100% rename from config/assets/icons/applets/wifi.svg rename to src/assets/icons/applets/wifi.svg diff --git a/config/assets/icons/battery/battery-000-charging.svg b/src/assets/icons/battery/battery-000-charging.svg similarity index 100% rename from config/assets/icons/battery/battery-000-charging.svg rename to src/assets/icons/battery/battery-000-charging.svg diff --git a/config/assets/icons/battery/battery-000.svg b/src/assets/icons/battery/battery-000.svg similarity index 100% rename from config/assets/icons/battery/battery-000.svg rename to src/assets/icons/battery/battery-000.svg diff --git a/config/assets/icons/battery/battery-010-charging.svg b/src/assets/icons/battery/battery-010-charging.svg similarity index 100% rename from config/assets/icons/battery/battery-010-charging.svg rename to src/assets/icons/battery/battery-010-charging.svg diff --git a/config/assets/icons/battery/battery-010.svg b/src/assets/icons/battery/battery-010.svg similarity index 100% rename from config/assets/icons/battery/battery-010.svg rename to src/assets/icons/battery/battery-010.svg diff --git a/config/assets/icons/battery/battery-020-charging.svg b/src/assets/icons/battery/battery-020-charging.svg similarity index 100% rename from config/assets/icons/battery/battery-020-charging.svg rename to src/assets/icons/battery/battery-020-charging.svg diff --git a/config/assets/icons/battery/battery-020.svg b/src/assets/icons/battery/battery-020.svg similarity index 100% rename from config/assets/icons/battery/battery-020.svg rename to src/assets/icons/battery/battery-020.svg diff --git a/config/assets/icons/battery/battery-030-charging.svg b/src/assets/icons/battery/battery-030-charging.svg similarity index 100% rename from config/assets/icons/battery/battery-030-charging.svg rename to src/assets/icons/battery/battery-030-charging.svg diff --git a/config/assets/icons/battery/battery-030.svg b/src/assets/icons/battery/battery-030.svg similarity index 100% rename from config/assets/icons/battery/battery-030.svg rename to src/assets/icons/battery/battery-030.svg diff --git a/config/assets/icons/battery/battery-040-charging.svg b/src/assets/icons/battery/battery-040-charging.svg similarity index 100% rename from config/assets/icons/battery/battery-040-charging.svg rename to src/assets/icons/battery/battery-040-charging.svg diff --git a/config/assets/icons/battery/battery-040.svg b/src/assets/icons/battery/battery-040.svg similarity index 100% rename from config/assets/icons/battery/battery-040.svg rename to src/assets/icons/battery/battery-040.svg diff --git a/config/assets/icons/battery/battery-050-charging.svg b/src/assets/icons/battery/battery-050-charging.svg similarity index 100% rename from config/assets/icons/battery/battery-050-charging.svg rename to src/assets/icons/battery/battery-050-charging.svg diff --git a/config/assets/icons/battery/battery-050.svg b/src/assets/icons/battery/battery-050.svg similarity index 100% rename from config/assets/icons/battery/battery-050.svg rename to src/assets/icons/battery/battery-050.svg diff --git a/config/assets/icons/battery/battery-060-charging.svg b/src/assets/icons/battery/battery-060-charging.svg similarity index 100% rename from config/assets/icons/battery/battery-060-charging.svg rename to src/assets/icons/battery/battery-060-charging.svg diff --git a/config/assets/icons/battery/battery-060.svg b/src/assets/icons/battery/battery-060.svg similarity index 100% rename from config/assets/icons/battery/battery-060.svg rename to src/assets/icons/battery/battery-060.svg diff --git a/config/assets/icons/battery/battery-070-charging.svg b/src/assets/icons/battery/battery-070-charging.svg similarity index 100% rename from config/assets/icons/battery/battery-070-charging.svg rename to src/assets/icons/battery/battery-070-charging.svg diff --git a/config/assets/icons/battery/battery-070.svg b/src/assets/icons/battery/battery-070.svg similarity index 100% rename from config/assets/icons/battery/battery-070.svg rename to src/assets/icons/battery/battery-070.svg diff --git a/config/assets/icons/battery/battery-080-charging.svg b/src/assets/icons/battery/battery-080-charging.svg similarity index 100% rename from config/assets/icons/battery/battery-080-charging.svg rename to src/assets/icons/battery/battery-080-charging.svg diff --git a/config/assets/icons/battery/battery-080.svg b/src/assets/icons/battery/battery-080.svg similarity index 100% rename from config/assets/icons/battery/battery-080.svg rename to src/assets/icons/battery/battery-080.svg diff --git a/config/assets/icons/battery/battery-090-charging.svg b/src/assets/icons/battery/battery-090-charging.svg similarity index 100% rename from config/assets/icons/battery/battery-090-charging.svg rename to src/assets/icons/battery/battery-090-charging.svg diff --git a/config/assets/icons/battery/battery-090.svg b/src/assets/icons/battery/battery-090.svg similarity index 100% rename from config/assets/icons/battery/battery-090.svg rename to src/assets/icons/battery/battery-090.svg diff --git a/config/assets/icons/battery/battery-100-charging.svg b/src/assets/icons/battery/battery-100-charging.svg similarity index 100% rename from config/assets/icons/battery/battery-100-charging.svg rename to src/assets/icons/battery/battery-100-charging.svg diff --git a/config/assets/icons/battery/battery-100.svg b/src/assets/icons/battery/battery-100.svg similarity index 100% rename from config/assets/icons/battery/battery-100.svg rename to src/assets/icons/battery/battery-100.svg diff --git a/config/assets/icons/brightness/brightness-0.svg b/src/assets/icons/brightness/brightness-0.svg similarity index 100% rename from config/assets/icons/brightness/brightness-0.svg rename to src/assets/icons/brightness/brightness-0.svg diff --git a/config/assets/icons/brightness/brightness-1.svg b/src/assets/icons/brightness/brightness-1.svg similarity index 100% rename from config/assets/icons/brightness/brightness-1.svg rename to src/assets/icons/brightness/brightness-1.svg diff --git a/config/assets/icons/brightness/brightness-2.svg b/src/assets/icons/brightness/brightness-2.svg similarity index 100% rename from config/assets/icons/brightness/brightness-2.svg rename to src/assets/icons/brightness/brightness-2.svg diff --git a/config/assets/icons/brightness/brightness-3.svg b/src/assets/icons/brightness/brightness-3.svg similarity index 100% rename from config/assets/icons/brightness/brightness-3.svg rename to src/assets/icons/brightness/brightness-3.svg diff --git a/config/assets/icons/brightness/brightness.svg b/src/assets/icons/brightness/brightness.svg similarity index 100% rename from config/assets/icons/brightness/brightness.svg rename to src/assets/icons/brightness/brightness.svg diff --git a/config/assets/icons/mic/microphone-0.svg b/src/assets/icons/mic/microphone-0.svg similarity index 100% rename from config/assets/icons/mic/microphone-0.svg rename to src/assets/icons/mic/microphone-0.svg diff --git a/config/assets/icons/mic/microphone-1.svg b/src/assets/icons/mic/microphone-1.svg similarity index 100% rename from config/assets/icons/mic/microphone-1.svg rename to src/assets/icons/mic/microphone-1.svg diff --git a/config/assets/icons/mic/microphone-2.svg b/src/assets/icons/mic/microphone-2.svg similarity index 100% rename from config/assets/icons/mic/microphone-2.svg rename to src/assets/icons/mic/microphone-2.svg diff --git a/config/assets/icons/mic/microphone-3.svg b/src/assets/icons/mic/microphone-3.svg similarity index 100% rename from config/assets/icons/mic/microphone-3.svg rename to src/assets/icons/mic/microphone-3.svg diff --git a/config/assets/icons/mic/microphone.svg b/src/assets/icons/mic/microphone.svg similarity index 100% rename from config/assets/icons/mic/microphone.svg rename to src/assets/icons/mic/microphone.svg diff --git a/src/assets/icons/misc/caps-lock-off.svg b/src/assets/icons/misc/caps-lock-off.svg new file mode 100644 index 00000000..8dd8dbfc --- /dev/null +++ b/src/assets/icons/misc/caps-lock-off.svg @@ -0,0 +1,7 @@ +<svg width="16" height="16" version="1.1" xmlns="http://www.w3.org/2000/svg"> + <path d="m12 0c-2.216 0-4 1.784-4 4v2h1v-2c0-1.662 1.338-3 3-3s3 1.338 3 3v2h1v-2c0-2.216-1.784-4-4-4z" fill="#363636"/> + <path d="m2 6c-1.108 0-2 0.892-2 2v5c0 1.108 0.892 2 2 2h8c1.108 0 2-0.892 2-2v-5c0-1.108-0.892-2-2-2zm0 1h8c0.554 0 1 0.446 1 1v5c0 0.554-0.446 1-1 1h-8c-0.554 0-1-0.446-1-1v-5c0-0.554 0.446-1 1-1z" fill="#363636"/> + <g transform="translate(0 -.1162)" aria-label="A"> + <path d="m5.9304 8.7826-0.91762 2.4883h1.8386zm-0.38178-0.66644h0.76691l1.9056 5h-0.70328l-0.45546-1.2827h-2.2539l-0.45546 1.2827h-0.71333z" fill="#363636"/> + </g> +</svg> diff --git a/src/assets/icons/misc/caps-lock.svg b/src/assets/icons/misc/caps-lock.svg new file mode 100644 index 00000000..41c18aad --- /dev/null +++ b/src/assets/icons/misc/caps-lock.svg @@ -0,0 +1,16 @@ +<svg width="32" height="32" version="1.1" xmlns="http://www.w3.org/2000/svg"> + <style id="current-color-scheme" type="text/css">.ColorScheme-Text { + color:#f2f2f2; + } + .ColorScheme-Background { + color:#eff0f1; + } + .ColorScheme-Highlight { + color:#3daee9; + } + .ColorScheme-ButtonText { + color:#f2f2f2; + }</style> + <path class="ColorScheme-Text" d="m10.995 5.496c-2.77 0-5 2.23-5 5v11c0 2.77 3 4.2 5 5l5 2 5-2c2-0.8 5-2.23 5-5v-11c0-2.77-2.23-5-5-5zm-0.02539 1.5h10.051c1.9251 0 3.4746 1.5496 3.4746 3.4746v9.0508c0 1.9251-1.5496 3.4746-3.4746 3.4746h-10.051c-1.9251 0-3.4746-1.5496-3.4746-3.4746v-9.0508c0-1.9251 1.5496-3.4746 3.4746-3.4746z" fill="currentColor"/> + <path class="ColorScheme-Text" d="m14.882 9.996-3.8086 10h1.6055l0.95313-2.5h4.7266l0.95313 2.5h1.6055l-3.8086-10zm1.1133 1.2969 1.793 4.7031h-3.5859z" fill="currentColor"/> +</svg> diff --git a/config/assets/icons/misc/chevron-left.svg b/src/assets/icons/misc/chevron-left.svg similarity index 100% rename from config/assets/icons/misc/chevron-left.svg rename to src/assets/icons/misc/chevron-left.svg diff --git a/config/assets/icons/misc/chevron-right.svg b/src/assets/icons/misc/chevron-right.svg similarity index 100% rename from config/assets/icons/misc/chevron-right.svg rename to src/assets/icons/misc/chevron-right.svg diff --git a/config/assets/icons/misc/control-center.svg b/src/assets/icons/misc/control-center.svg similarity index 100% rename from config/assets/icons/misc/control-center.svg rename to src/assets/icons/misc/control-center.svg diff --git a/config/assets/icons/misc/control.svg b/src/assets/icons/misc/control.svg similarity index 100% rename from config/assets/icons/misc/control.svg rename to src/assets/icons/misc/control.svg diff --git a/config/assets/icons/misc/imac.svg b/src/assets/icons/misc/imac.svg similarity index 100% rename from config/assets/icons/misc/imac.svg rename to src/assets/icons/misc/imac.svg diff --git a/src/assets/icons/misc/keyboard-layout.svg b/src/assets/icons/misc/keyboard-layout.svg new file mode 100644 index 00000000..b20cca72 --- /dev/null +++ b/src/assets/icons/misc/keyboard-layout.svg @@ -0,0 +1,16 @@ +<svg width="32" height="32" version="1.1" xmlns="http://www.w3.org/2000/svg"> + <style id="current-color-scheme" type="text/css">.ColorScheme-Text { + color:#f2f2f2; + } + .ColorScheme-Background { + color:#eff0f1; + } + .ColorScheme-Highlight { + color:#3daee9; + } + .ColorScheme-ButtonText { + color:#f2f2f2; + }</style> + <path class="ColorScheme-Text" d="m25 4c0 0.83929-0.09344 1.4399-0.24805 1.8457-0.1546 0.40583-0.34789 0.62123-0.62891 0.78516-0.56203 0.32785-1.623 0.36914-3.123 0.36914s-2.9356-0.0273-4.0996 0.55469c-0.58199 0.29099-1.0806 0.75295-1.4082 1.4082-0.27288 0.54576-0.4251 1.2217-0.4707 2.0371h1c0.04382-0.68545 0.17093-1.2071 0.36133-1.5879 0.23487-0.46974 0.54879-0.75779 0.9668-0.9668 0.83602-0.41801 2.1504-0.44531 3.6504-0.44531s2.689 0.04129 3.627-0.50586c0.46899-0.27358 0.8382-0.71443 1.0586-1.293s0.31445-1.2905 0.31445-2.2012z" fill="currentColor"/> + <path class="ColorScheme-Text" d="m5 10c-2.216 0-4 1.784-4 4v10c0 2.216 1.784 4 4 4h22c2.216 0 4-1.784 4-4v-10c0-2.216-1.784-4-4-4zm0 1.5h22c1.385 0 2.5 1.115 2.5 2.5v10c0 1.385-1.115 2.5-2.5 2.5h-22c-1.385 0-2.5-1.115-2.5-2.5v-10c0-1.385 1.115-2.5 2.5-2.5zm1.5 2.5c-0.277 0-0.5 0.223-0.5 0.5v1c0 0.277 0.223 0.5 0.5 0.5h1c0.277 0 0.5-0.223 0.5-0.5v-1c0-0.277-0.223-0.5-0.5-0.5zm4 0c-0.277 0-0.5 0.223-0.5 0.5v1c0 0.277 0.223 0.5 0.5 0.5h1c0.277 0 0.5-0.223 0.5-0.5v-1c0-0.277-0.223-0.5-0.5-0.5zm4 0c-0.277 0-0.5 0.223-0.5 0.5v1c0 0.277 0.223 0.5 0.5 0.5h1c0.277 0 0.5-0.223 0.5-0.5v-1c0-0.277-0.223-0.5-0.5-0.5zm4 0c-0.277 0-0.5 0.223-0.5 0.5v1c0 0.277 0.223 0.5 0.5 0.5h1c0.277 0 0.5-0.223 0.5-0.5v-1c0-0.277-0.223-0.5-0.5-0.5zm4 0c-0.277 0-0.5 0.223-0.5 0.5v1c0 0.277 0.223 0.5 0.5 0.5h3c0.277 0 0.5-0.223 0.5-0.5v-1c0-0.277-0.223-0.5-0.5-0.5zm-16 4c-0.277 0-0.5 0.223-0.5 0.5v1c0 0.277 0.223 0.5 0.5 0.5h3c0.277 0 0.5-0.223 0.5-0.5v-1c0-0.277-0.223-0.5-0.5-0.5zm6 0c-0.277 0-0.5 0.223-0.5 0.5v1c0 0.277 0.223 0.5 0.5 0.5h1c0.277 0 0.5-0.223 0.5-0.5v-1c0-0.277-0.223-0.5-0.5-0.5zm4 0c-0.277 0-0.5 0.223-0.5 0.5v1c0 0.277 0.223 0.5 0.5 0.5h1c0.277 0 0.5-0.223 0.5-0.5v-1c0-0.277-0.223-0.5-0.5-0.5zm4 0c-0.277 0-0.5 0.223-0.5 0.5v1c0 0.277 0.223 0.5 0.5 0.5h5c0.277 0 0.5-0.223 0.5-0.5v-1c0-0.277-0.223-0.5-0.5-0.5zm-14 4c-0.277 0-0.5 0.223-0.5 0.5v1c0 0.277 0.223 0.5 0.5 0.5h1c0.277 0 0.5-0.223 0.5-0.5v-1c0-0.277-0.223-0.5-0.5-0.5zm4 0c-0.277 0-0.5 0.223-0.5 0.5v1c0 0.277 0.223 0.5 0.5 0.5h11c0.277 0 0.5-0.223 0.5-0.5v-1c0-0.277-0.223-0.5-0.5-0.5zm14 0c-0.277 0-0.5 0.223-0.5 0.5v1c0 0.277 0.223 0.5 0.5 0.5h1c0.277 0 0.5-0.223 0.5-0.5v-1c0-0.277-0.223-0.5-0.5-0.5z" fill="currentColor"/> +</svg> diff --git a/config/assets/icons/misc/logo.svg b/src/assets/icons/misc/logo.svg similarity index 100% rename from config/assets/icons/misc/logo.svg rename to src/assets/icons/misc/logo.svg diff --git a/config/assets/icons/misc/media-record.svg b/src/assets/icons/misc/media-record.svg similarity index 100% rename from config/assets/icons/misc/media-record.svg rename to src/assets/icons/misc/media-record.svg diff --git a/config/assets/icons/misc/search.svg b/src/assets/icons/misc/search.svg similarity index 100% rename from config/assets/icons/misc/search.svg rename to src/assets/icons/misc/search.svg diff --git a/src/assets/icons/music.svg b/src/assets/icons/music.svg new file mode 100644 index 00000000..a95e0cb6 --- /dev/null +++ b/src/assets/icons/music.svg @@ -0,0 +1,29 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" id="apple-music"> + <defs> + <radialGradient id="b" cx="-400.171" cy="-984.067" r="1.587" gradientTransform="matrix(0 31.8923 31.8923 0 31421.78 12852.762)" gradientUnits="userSpaceOnUse"> + <stop offset="0" stop-color="#7470f9"></stop> + <stop offset="1" stop-color="#7a70fe" stop-opacity="0"></stop> + </radialGradient> + <radialGradient id="c" cx="-489.182" cy="-1019.984" r="1.281" gradientTransform="scale(-31.8923 31.8923)rotate(-74.462 426.955 -829.031)" gradientUnits="userSpaceOnUse"> + <stop offset="0" stop-color="#4ca3f8"></stop> + <stop offset=".362" stop-color="#4ca4f7"></stop> + <stop offset="1" stop-color="#4aa2f9" stop-opacity="0"></stop> + </radialGradient> + <linearGradient id="a" x1="-392" x2="-272" y1="-764" y2="-764" gradientTransform="rotate(-90 276 -548)" gradientUnits="userSpaceOnUse"> + <stop offset="0" stop-color="#fd355a"></stop> + <stop offset="1" stop-color="#fd5163"></stop> + </linearGradient> + <linearGradient id="d" x1="-418.42" x2="-418.034" y1="-1040.402" y2="-1041.278" gradientTransform="matrix(31.8923 0 0 -39.8681 13392.678 -41444.467)" gradientUnits="userSpaceOnUse"> + <stop offset="0" stop-color="#ff6380"></stop> + <stop offset="1" stop-color="#f65e79" stop-opacity="0"></stop> + </linearGradient> + </defs> + <path fill="url(#a)" fill-rule="evenodd" d="M94,120H26A25.9482,25.9482,0,0,1,0,94V26A25.9482,25.9482,0,0,1,26,0H94a25.9482,25.9482,0,0,1,26,26V94A25.9482,25.9482,0,0,1,94,120Z"></path> + <g> + <path fill="#bb58bb" fill-rule="evenodd" d="M88,72.5V76a37.93334,37.93334,0,0,1-.6,6.7,8.5338,8.5338,0,0,1-3.6,5.6h-.1a12.35558,12.35558,0,0,1-6.9,2.5l-1.9.2A9.53981,9.53981,0,0,1,67,87.6a8.779,8.779,0,0,1-1.4-8.8v-.1a9.24834,9.24834,0,0,1,6.5-5.8l7.5-2a4.29583,4.29583,0,0,0,3.3-4.2V61.6h0V40.1a1.61246,1.61246,0,0,0-.6-1.3,1.73546,1.73546,0,0,0-1.4-.3L48.9,45a2.37562,2.37562,0,0,0-2,2.4V81.6h0v2.5a37.93323,37.93323,0,0,1-.6,6.7,8.53375,8.53375,0,0,1-3.6,5.6h-.1a12.35561,12.35561,0,0,1-6.9,2.5l-1.8.1A9.53981,9.53981,0,0,1,26,95.6a8.779,8.779,0,0,1-1.4-8.8v-.1a9.24834,9.24834,0,0,1,6.5-5.8l7.5-2a4.29583,4.29583,0,0,0,3.3-4.2V69.6h0V29.9a3.71845,3.71845,0,0,1,3-3.6L85,19a3.14378,3.14378,0,0,1,2.1.5,2.46275,2.46275,0,0,1,.9,1.9V72.5Z"></path> + <path fill="url(#b)" fill-rule="evenodd" d="M88,72.5V76a37.93334,37.93334,0,0,1-.6,6.7,8.5338,8.5338,0,0,1-3.6,5.6h-.1a12.35558,12.35558,0,0,1-6.9,2.5l-1.9.2A9.53981,9.53981,0,0,1,67,87.6a8.779,8.779,0,0,1-1.4-8.8v-.1a9.24834,9.24834,0,0,1,6.5-5.8l7.5-2a4.29583,4.29583,0,0,0,3.3-4.2V61.6h0V40.1a1.61246,1.61246,0,0,0-.6-1.3,1.73546,1.73546,0,0,0-1.4-.3L48.9,45a2.37562,2.37562,0,0,0-2,2.4V81.6h0v2.5a37.93323,37.93323,0,0,1-.6,6.7,8.53375,8.53375,0,0,1-3.6,5.6h-.1a12.35561,12.35561,0,0,1-6.9,2.5l-1.8.1A9.53981,9.53981,0,0,1,26,95.6a8.779,8.779,0,0,1-1.4-8.8v-.1a9.24834,9.24834,0,0,1,6.5-5.8l7.5-2a4.29583,4.29583,0,0,0,3.3-4.2V69.6h0V29.9a3.71845,3.71845,0,0,1,3-3.6L85,19a3.14378,3.14378,0,0,1,2.1.5,2.46275,2.46275,0,0,1,.9,1.9V72.5Z"></path> + <path fill="url(#c)" fill-rule="evenodd" d="M88,72.5V76a37.93334,37.93334,0,0,1-.6,6.7,8.5338,8.5338,0,0,1-3.6,5.6h-.1a12.35558,12.35558,0,0,1-6.9,2.5l-1.9.2A9.53981,9.53981,0,0,1,67,87.6a8.779,8.779,0,0,1-1.4-8.8v-.1a9.24834,9.24834,0,0,1,6.5-5.8l7.5-2a4.29583,4.29583,0,0,0,3.3-4.2V61.6h0V40.1a1.61246,1.61246,0,0,0-.6-1.3,1.73546,1.73546,0,0,0-1.4-.3L48.9,45a2.37562,2.37562,0,0,0-2,2.4V81.6h0v2.5a37.93323,37.93323,0,0,1-.6,6.7,8.53375,8.53375,0,0,1-3.6,5.6h-.1a12.35561,12.35561,0,0,1-6.9,2.5l-1.8.1A9.53981,9.53981,0,0,1,26,95.6a8.779,8.779,0,0,1-1.4-8.8v-.1a9.24834,9.24834,0,0,1,6.5-5.8l7.5-2a4.29583,4.29583,0,0,0,3.3-4.2V69.6h0V29.9a3.71845,3.71845,0,0,1,3-3.6L85,19a3.14378,3.14378,0,0,1,2.1.5,2.46275,2.46275,0,0,1,.9,1.9V72.5Z"></path> + <path fill="url(#d)" fill-rule="evenodd" d="M88,72.5V76a37.93334,37.93334,0,0,1-.6,6.7,8.5338,8.5338,0,0,1-3.6,5.6h-.1a12.35558,12.35558,0,0,1-6.9,2.5l-1.9.2A9.53981,9.53981,0,0,1,67,87.6a8.779,8.779,0,0,1-1.4-8.8v-.1a9.24834,9.24834,0,0,1,6.5-5.8l7.5-2a4.29583,4.29583,0,0,0,3.3-4.2V61.6h0V40.1a1.61246,1.61246,0,0,0-.6-1.3,1.73546,1.73546,0,0,0-1.4-.3L48.9,45a2.37562,2.37562,0,0,0-2,2.4V81.6h0v2.5a37.93323,37.93323,0,0,1-.6,6.7,8.53375,8.53375,0,0,1-3.6,5.6h-.1a12.35561,12.35561,0,0,1-6.9,2.5l-1.8.1A9.53981,9.53981,0,0,1,26,95.6a8.779,8.779,0,0,1-1.4-8.8v-.1a9.24834,9.24834,0,0,1,6.5-5.8l7.5-2a4.29583,4.29583,0,0,0,3.3-4.2V69.6h0V29.9a3.71845,3.71845,0,0,1,3-3.6L85,19a3.14378,3.14378,0,0,1,2.1.5,2.46275,2.46275,0,0,1,.9,1.9V72.5Z"></path> + <path fill="#fff" fill-rule="evenodd" d="M88,72.5V76a37.93334,37.93334,0,0,1-.6,6.7,8.5338,8.5338,0,0,1-3.6,5.6h-.1a12.35558,12.35558,0,0,1-6.9,2.5l-1.9.2A9.53981,9.53981,0,0,1,67,87.6a8.779,8.779,0,0,1-1.4-8.8v-.1a9.24834,9.24834,0,0,1,6.5-5.8l7.5-2a4.29583,4.29583,0,0,0,3.3-4.2V61.6h0V40.1a1.61246,1.61246,0,0,0-.6-1.3,1.73546,1.73546,0,0,0-1.4-.3L48.9,45a2.37562,2.37562,0,0,0-2,2.4V81.6h0v2.5a37.93323,37.93323,0,0,1-.6,6.7,8.53375,8.53375,0,0,1-3.6,5.6h-.1a12.35561,12.35561,0,0,1-6.9,2.5l-1.8.1A9.53981,9.53981,0,0,1,26,95.6a8.779,8.779,0,0,1-1.4-8.8v-.1a9.24834,9.24834,0,0,1,6.5-5.8l7.5-2a4.29583,4.29583,0,0,0,3.3-4.2V69.6h0V29.9a3.71845,3.71845,0,0,1,3-3.6L85,19a3.14378,3.14378,0,0,1,2.1.5,2.46275,2.46275,0,0,1,.9,1.9V72.5Z"></path> + </g> +</svg> diff --git a/config/assets/icons/notification.png b/src/assets/icons/notification.png similarity index 100% rename from config/assets/icons/notification.png rename to src/assets/icons/notification.png diff --git a/config/assets/icons/notifications/notification-active.svg b/src/assets/icons/notifications/notification-active.svg similarity index 100% rename from config/assets/icons/notifications/notification-active.svg rename to src/assets/icons/notifications/notification-active.svg diff --git a/config/assets/icons/notifications/notification-disabled.svg b/src/assets/icons/notifications/notification-disabled.svg similarity index 100% rename from config/assets/icons/notifications/notification-disabled.svg rename to src/assets/icons/notifications/notification-disabled.svg diff --git a/config/assets/icons/notifications/notification-inactive.svg b/src/assets/icons/notifications/notification-inactive.svg similarity index 100% rename from config/assets/icons/notifications/notification-inactive.svg rename to src/assets/icons/notifications/notification-inactive.svg diff --git a/config/assets/icons/player/Pause.svg b/src/assets/icons/player/Pause.svg similarity index 100% rename from config/assets/icons/player/Pause.svg rename to src/assets/icons/player/Pause.svg diff --git a/config/assets/icons/player/Rewind.svg b/src/assets/icons/player/Rewind.svg similarity index 100% rename from config/assets/icons/player/Rewind.svg rename to src/assets/icons/player/Rewind.svg diff --git a/config/assets/icons/player/audio-switcher.svg b/src/assets/icons/player/audio-switcher.svg similarity index 100% rename from config/assets/icons/player/audio-switcher.svg rename to src/assets/icons/player/audio-switcher.svg diff --git a/config/assets/icons/player/fwd.svg b/src/assets/icons/player/fwd.svg similarity index 100% rename from config/assets/icons/player/fwd.svg rename to src/assets/icons/player/fwd.svg diff --git a/config/assets/icons/player/play.svg b/src/assets/icons/player/play.svg similarity index 100% rename from config/assets/icons/player/play.svg rename to src/assets/icons/player/play.svg diff --git a/config/assets/icons/power_modes/battery-balanced.svg b/src/assets/icons/power_modes/battery-balanced.svg similarity index 100% rename from config/assets/icons/power_modes/battery-balanced.svg rename to src/assets/icons/power_modes/battery-balanced.svg diff --git a/config/assets/icons/power_modes/battery-performance.svg b/src/assets/icons/power_modes/battery-performance.svg similarity index 100% rename from config/assets/icons/power_modes/battery-performance.svg rename to src/assets/icons/power_modes/battery-performance.svg diff --git a/config/assets/icons/power_modes/battery-power.svg b/src/assets/icons/power_modes/battery-power.svg similarity index 100% rename from config/assets/icons/power_modes/battery-power.svg rename to src/assets/icons/power_modes/battery-power.svg diff --git a/config/assets/icons/todo/checkbox-check.svg b/src/assets/icons/todo/checkbox-check.svg similarity index 100% rename from config/assets/icons/todo/checkbox-check.svg rename to src/assets/icons/todo/checkbox-check.svg diff --git a/config/assets/icons/todo/checkbox-uncheck.svg b/src/assets/icons/todo/checkbox-uncheck.svg similarity index 100% rename from config/assets/icons/todo/checkbox-uncheck.svg rename to src/assets/icons/todo/checkbox-uncheck.svg diff --git a/config/assets/icons/todo/delete-symbolic.svg b/src/assets/icons/todo/delete-symbolic.svg similarity index 100% rename from config/assets/icons/todo/delete-symbolic.svg rename to src/assets/icons/todo/delete-symbolic.svg diff --git a/config/assets/icons/todo/edit.svg b/src/assets/icons/todo/edit.svg similarity index 100% rename from config/assets/icons/todo/edit.svg rename to src/assets/icons/todo/edit.svg diff --git a/config/assets/icons/todo/plus-symbolic.svg b/src/assets/icons/todo/plus-symbolic.svg similarity index 100% rename from config/assets/icons/todo/plus-symbolic.svg rename to src/assets/icons/todo/plus-symbolic.svg diff --git a/config/assets/icons/todo/to-do-app-symbolic.svg b/src/assets/icons/todo/to-do-app-symbolic.svg similarity index 100% rename from config/assets/icons/todo/to-do-app-symbolic.svg rename to src/assets/icons/todo/to-do-app-symbolic.svg diff --git a/config/assets/icons/volume/audio-volume-0.svg b/src/assets/icons/volume/audio-volume-0.svg similarity index 100% rename from config/assets/icons/volume/audio-volume-0.svg rename to src/assets/icons/volume/audio-volume-0.svg diff --git a/config/assets/icons/volume/audio-volume-1.svg b/src/assets/icons/volume/audio-volume-1.svg similarity index 100% rename from config/assets/icons/volume/audio-volume-1.svg rename to src/assets/icons/volume/audio-volume-1.svg diff --git a/config/assets/icons/volume/audio-volume-2.svg b/src/assets/icons/volume/audio-volume-2.svg similarity index 100% rename from config/assets/icons/volume/audio-volume-2.svg rename to src/assets/icons/volume/audio-volume-2.svg diff --git a/config/assets/icons/volume/audio-volume-3.svg b/src/assets/icons/volume/audio-volume-3.svg similarity index 100% rename from config/assets/icons/volume/audio-volume-3.svg rename to src/assets/icons/volume/audio-volume-3.svg diff --git a/config/assets/icons/volume/audio-volume.svg b/src/assets/icons/volume/audio-volume.svg similarity index 100% rename from config/assets/icons/volume/audio-volume.svg rename to src/assets/icons/volume/audio-volume.svg diff --git a/src/assets/icons/weather/weather-clear-night.svg b/src/assets/icons/weather/weather-clear-night.svg new file mode 100644 index 00000000..1c56d8c1 --- /dev/null +++ b/src/assets/icons/weather/weather-clear-night.svg @@ -0,0 +1 @@ +<svg width="32" height="32" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><linearGradient id="linearGradient9700" x1="12.336" x2="12.336" y1="6.674" y2="26.137" gradientUnits="userSpaceOnUse"><stop stop-color="#f0f3f4" offset="0"/><stop stop-color="#cbd3d9" offset="1"/></linearGradient><style id="current-color-scheme" type="text/css">.ColorScheme-Text{color:#4d4d4d}.ColorScheme-Background{color:#eff0f1}.ColorScheme-Highlight{color:#3daee9}.ColorScheme-ViewText{color:#31363b}.ColorScheme-ViewBackground{color:#fcfcfc}.ColorScheme-ViewHover{color:#93cee9}.ColorScheme-ViewFocus{color:#3daee9}.ColorScheme-ButtonText{color:#31363b}.ColorScheme-ButtonBackground{color:#eff0f1}.ColorScheme-ButtonHover{color:#93cee9}.ColorScheme-ButtonFocus{color:#3daee9}</style></defs><path d="M16 3A13 13 0 0 0 3 16a13 13 0 0 0 13 13 13 13 0 0 0 13-13 13 13 0 0 0-.014-.521A9 9 0 0 1 24 17a9 9 0 0 1-9-9 9 9 0 0 1 1.522-4.986A13 13 0 0 0 16 3z" fill="url(#linearGradient9700)"/><circle cx="3" cy="4" r="1" color="#eff0f1" fill="#d1d9dd"/><circle cx="29.5" cy="26.5" r="1.5" color="#eff0f1" fill="#d1d9dd"/><circle cx="24.5" cy="29.5" r=".5" color="#eff0f1" fill="#d1d9dd"/><path d="M22 3a3 3 0 0 1-3 3 3 3 0 0 1 3 3 3 3 0 0 1 3-3 3 3 0 0 1-3-3zM26 8a2 2 0 0 1-2 2 2 2 0 0 1 2 2 2 2 0 0 1 2-2 2 2 0 0 1-2-2z" color="#eff0f1" fill="#d1d9dd"/><circle cx="28.5" cy="2.5" r=".5" color="#eff0f1" fill="#d1d9dd"/><image x="5.668" y="8.636" width="18" height="19" image-rendering="optimizeQuality" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAATCAYAAACdkl3yAAAACXBIWXMAAA7DAAAOwwHHb6hkAAAA GXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAAwFJREFUOI2NlE1rW0cYhc87cz8s W4rkIF9Z1Fmb4DpUkQkkuEXKosU/oD+jP6sbZeu2tBtfWpNAm4vBwcJQSkuQ7frKdmXr837MnC4c l9BCqrMamOFw5p1njuCdSEoYhrpUKslwOGSr1TIiQswpuTN51estOIOkOp6NywtFb3STpv3PHz2a zGumACAMQ+0Mkmpus23X1V/mSf78vhRqIaDnTaQAoN/vq8ymZUvzEMATgXw8g1kO3u3PIwcAVlZW rAM1zsS+BVAi+IcwHU2jaO4ZKQBotVpmAZU4yyWEcr4WuN/D9093d3fNvEZytyCpoyjy43LZ0ZOJ 8a6ukna7bQDMlUoAYI90Fo+Py1mSrFLcohYzIXnuZ9lfW1tb2d3ZTqejsLGhi54nwfW1bTabRkQs ADgk1atu916S4BOt3U+FeGCoYkW1n7j4heSFiNhO58j9aN2vGDtdVZPJ4swpDH8+Pv5zj7xui+RO FEUaXikQZZ5a2h0h1gBeWLEKRvdedLsDkvnh4W+VG06faCPbhK5B8a1J7Y9+t3tAcuDEcaxKNW9J tFoFUYPIsgBKIHURu9TwfHnR7Tp1w7qI+oyKX4Co0qJnIJk2+iSKoqEKgsDmxJjkGYRnBC8Fckbg RFmMDg5SNjxPtHAJsHUAASDLQtRAu5rTLMZxrFSz2TR+qmMReQnItxB8p5R8Q8OXtli5bDQ82d/v SW4xgkgPxBnBC0BORcmJQjYOgsDevtrenuMHwT2xtkalSiSpjChrjLLKvSzk3nni0rdIHgv5DJCA lj0N/ZNxssPtzc3r9zlSURTpS3fNu4/ztRTuc4Dr1vJXC/WDWy31vMlkaTbNazm56Gl3iFEeJ0l8 0263c+cfoG55sJ2jI1lM9IJoPgC4LmAKSYuv37yxX+3sXEVRdB3HsSoGgW0+bRqRjVuO/k3oShxb Va4NQHatMLWwv0NksFko3MFnP/hF3ruiRFFUmBUKVWQoiZWRv1rpN+v16Ye66T+JRIQkp2EYnt61 5bPGw/9ty78BO4inhTsr1+wAAAAASUVORK5CYII="/></svg> \ No newline at end of file diff --git a/src/assets/icons/weather/weather-clear.svg b/src/assets/icons/weather/weather-clear.svg new file mode 100644 index 00000000..7da66b94 --- /dev/null +++ b/src/assets/icons/weather/weather-clear.svg @@ -0,0 +1 @@ +<svg width="32" height="32" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="linearGradient1025" x1="18.124" x2="18.124" y1="3.336" y2="28.783" gradientTransform="translate(-2)" gradientUnits="userSpaceOnUse"><stop stop-color="#fff133" offset="0"/><stop stop-color="#ffc40a" offset="1"/></linearGradient><style id="current-color-scheme" type="text/css">.ColorScheme-Text{color:#4d4d4d}.ColorScheme-Background{color:#eff0f1}.ColorScheme-Highlight{color:#3daee9}.ColorScheme-ViewText{color:#31363b}.ColorScheme-ViewBackground{color:#fcfcfc}.ColorScheme-ViewHover{color:#93cee9}.ColorScheme-ViewFocus{color:#3daee9}.ColorScheme-ButtonText{color:#31363b}.ColorScheme-ButtonBackground{color:#eff0f1}.ColorScheme-ButtonHover{color:#93cee9}.ColorScheme-ButtonFocus{color:#3daee9}</style></defs><path d="M16 2c-.554 0-1 .446-1 1v1c0 .554.446 1 1 1s1-.446 1-1V3c0-.554-.446-1-1-1zM6.762 5.772a.995.995 0 0 0-.659.292.998.998 0 0 0 0 1.415l.708.707a.998.998 0 0 0 1.414 0 .998.998 0 0 0 0-1.415l-.707-.707a.999.999 0 0 0-.756-.293zm18.385 0a.995.995 0 0 0-.658.292l-.708.708a.998.998 0 0 0 0 1.414.998.998 0 0 0 1.415 0l.707-.707a.998.998 0 0 0 0-1.415.999.999 0 0 0-.756-.293zM16 6A10 10 0 0 0 6 16a10 10 0 0 0 10 10 10 10 0 0 0 10-10A10 10 0 0 0 16 6zM3 15c-.554 0-1 .446-1 1s.446 1 1 1h1c.554 0 1-.446 1-1s-.446-1-1-1zm25 0c-.554 0-1 .446-1 1s.446 1 1 1h1c.554 0 1-.446 1-1s-.446-1-1-1zM7.471 23.45a1 1 0 0 0-.66.292l-.707.707a.998.998 0 0 0 0 1.414.998.998 0 0 0 1.414 0l.707-.707a.998.998 0 0 0 0-1.414.995.995 0 0 0-.754-.293zm16.97 0a.995.995 0 0 0-.659.292.998.998 0 0 0 0 1.414l.707.707c.392.392 1.022.392 1.414 0s.392-1.022 0-1.414l-.707-.707a.999.999 0 0 0-.756-.293zM16 27c-.554 0-1 .446-1 1v1c0 .554.446 1 1 1s1-.446 1-1v-1c0-.554-.446-1-1-1z" fill="url(#linearGradient1025)"/></svg> \ No newline at end of file diff --git a/src/assets/icons/weather/weather-clouds-night.svg b/src/assets/icons/weather/weather-clouds-night.svg new file mode 100644 index 00000000..c6cf30c8 --- /dev/null +++ b/src/assets/icons/weather/weather-clouds-night.svg @@ -0,0 +1 @@ +<svg width="32" height="32" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><linearGradient id="linearGradient9700" x1="12.336" x2="12.336" y1="6.674" y2="26.137" gradientUnits="userSpaceOnUse"><stop stop-color="#f0f3f4" offset="0"/><stop stop-color="#cbd3d9" offset="1"/></linearGradient><linearGradient id="linearGradient6967" x1="9.761" x2="9.761" y1="11.498" y2="28.044" gradientUnits="userSpaceOnUse"><stop stop-color="#9aafb2" offset="0"/><stop stop-color="#778b92" offset="1"/></linearGradient><style id="current-color-scheme" type="text/css">.ColorScheme-Text{color:#4d4d4d}.ColorScheme-Background{color:#eff0f1}.ColorScheme-Highlight{color:#3daee9}.ColorScheme-ViewText{color:#31363b}.ColorScheme-ViewBackground{color:#fcfcfc}.ColorScheme-ViewHover{color:#93cee9}.ColorScheme-ViewFocus{color:#3daee9}.ColorScheme-ButtonText{color:#31363b}.ColorScheme-ButtonBackground{color:#eff0f1}.ColorScheme-ButtonHover{color:#93cee9}.ColorScheme-ButtonFocus{color:#3daee9}</style></defs><path d="M16 3A13 13 0 0 0 3 16a13 13 0 0 0 13 13 13 13 0 0 0 13-13 13 13 0 0 0-.014-.521A9 9 0 0 1 24 17a9 9 0 0 1-9-9 9 9 0 0 1 1.522-4.986A13 13 0 0 0 16 3z" fill="url(#linearGradient9700)"/><path d="M28.5 2a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5zM3 3a1 1 0 0 0-1 1 1 1 0 0 0 1 1 1 1 0 0 0 1-1 1 1 0 0 0-1-1zm19 0a3 3 0 0 1-3 3 3 3 0 0 1 3 3 3 3 0 0 1 3-3 3 3 0 0 1-3-3zm4 5a2 2 0 0 1-2 2 2 2 0 0 1 2 2 2 2 0 0 1 2-2 2 2 0 0 1-2-2zm3.5 17a1.5 1.5 0 0 0-1.5 1.5 1.5 1.5 0 0 0 1.5 1.5 1.5 1.5 0 0 0 1.5-1.5 1.5 1.5 0 0 0-1.5-1.5zm-5 4a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5z" fill="#d1d9dd"/><path d="M10.98 9A10 10 0 0 0 1 19a10 10 0 0 0 10 10 10 10 0 0 0 .019 0h8.98a7 7 0 0 0 7-7 7 7 0 0 0-6.833-6.998A10 10 0 0 0 11 9a10 10 0 0 0-.02 0z" fill="url(#linearGradient6967)"/><image x="5.668" y="8.636" width="18" height="19" image-rendering="optimizeQuality" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAATCAYAAACdkl3yAAAACXBIWXMAAA7DAAAOwwHHb6hkAAAA GXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAAwFJREFUOI2NlE1rW0cYhc87cz8s W4rkIF9Z1Fmb4DpUkQkkuEXKosU/oD+jP6sbZeu2tBtfWpNAm4vBwcJQSkuQ7frKdmXr837MnC4c l9BCqrMamOFw5p1njuCdSEoYhrpUKslwOGSr1TIiQswpuTN51estOIOkOp6NywtFb3STpv3PHz2a zGumACAMQ+0Mkmpus23X1V/mSf78vhRqIaDnTaQAoN/vq8ymZUvzEMATgXw8g1kO3u3PIwcAVlZW rAM1zsS+BVAi+IcwHU2jaO4ZKQBotVpmAZU4yyWEcr4WuN/D9093d3fNvEZytyCpoyjy43LZ0ZOJ 8a6ukna7bQDMlUoAYI90Fo+Py1mSrFLcohYzIXnuZ9lfW1tb2d3ZTqejsLGhi54nwfW1bTabRkQs ADgk1atu916S4BOt3U+FeGCoYkW1n7j4heSFiNhO58j9aN2vGDtdVZPJ4swpDH8+Pv5zj7xui+RO FEUaXikQZZ5a2h0h1gBeWLEKRvdedLsDkvnh4W+VG06faCPbhK5B8a1J7Y9+t3tAcuDEcaxKNW9J tFoFUYPIsgBKIHURu9TwfHnR7Tp1w7qI+oyKX4Co0qJnIJk2+iSKoqEKgsDmxJjkGYRnBC8Fckbg RFmMDg5SNjxPtHAJsHUAASDLQtRAu5rTLMZxrFSz2TR+qmMReQnItxB8p5R8Q8OXtli5bDQ82d/v SW4xgkgPxBnBC0BORcmJQjYOgsDevtrenuMHwT2xtkalSiSpjChrjLLKvSzk3nni0rdIHgv5DJCA lj0N/ZNxssPtzc3r9zlSURTpS3fNu4/ztRTuc4Dr1vJXC/WDWy31vMlkaTbNazm56Gl3iFEeJ0l8 0263c+cfoG55sJ2jI1lM9IJoPgC4LmAKSYuv37yxX+3sXEVRdB3HsSoGgW0+bRqRjVuO/k3oShxb Va4NQHatMLWwv0NksFko3MFnP/hF3ruiRFFUmBUKVWQoiZWRv1rpN+v16Ye66T+JRIQkp2EYnt61 5bPGw/9ty78BO4inhTsr1+wAAAAASUVORK5CYII="/><image x="1" y="9" width="26" height="20" image-rendering="optimizeQuality" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABoAAAAUCAYAAACTQC2+AAAACXBIWXMAAA7DAAAOwwHHb6hkAAAA GXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAA8VJREFUSImNlctuXEUURfc+597u tt15OrGDEMoEBshSiAQfmM9gCj+A+AGUGZNkGCGLARYOcTqYPNzPe6vO2Qwck05sh2yppHovnVcV cYEk2bOXLz9vgC+MvCHZJgAwc4HWno/c/xyPx8ckddH5i8QPJ55Ppzvs474iboC0s3l3l1QzZZ2E k5Z+tL195YDk6lNAtj549urVXS3Lt4G4AgCxttZHzwyQgCmz7ZXjyeTNZ4fSxqeAmrPOZDLZjYK7 STQKmJhEvA8LJAW4G1spNzp1m5vH5frvUn5Fdv8LOjw83CjAnciuJRs38hwEABwAIqxKAzk2jBx3 HcrVFy9S0j8k60dBMRpd56I00bobwmBkRNI/2NyfxtRaM1fNUQpBQ8xKgSYTAXhxKejRo0dtTsug GYF8mxwRQMaZCedVMp1km9CmskOo8Wmn9rc/jjabEeYLcvHT97vLBw+YZ2d4eHi4UYrvqMFtA7ZJ 35KpzQi7GAPIXYgQyZpSAbhyYClgCUZn1csiouLq8Pnezs4MAOxl21oOld4ga63qIxARiLcxuqhl BAFQUuNmg8zYqsprCdwswVu94/awtV1flm/295/dOo3RBIhRZrQMuAUSKUkCxThfZ2dKBAVHRGlM ZnB4LTGgWVgtmXIJAQzsuydPnvzSzG+WtFLqKLwILIEIBZRJ8FLMmeJ0h6VFgQEQVZsABAYIFxTA ePxlc+3kJICrBQN1IDsGi4gWkEUE3S/JiHXrMpgEECDgelcYKTMLLHWHknx/f3+zc7/m6dtwu0nl FQBDJR283H0fQQMySUpvmj6yzhsAORwOy6zvVxm2GNBHCg1kdChJ0CKTl6X6eW8CbqcQ0EqNvoM1 s4akJNW/9ve7W9bOu9oNG2dbAk6CQDQULYLnCvgCBlxSVU3IqjI6c5u1Fsdnb13suvfL5etlNONp lOIwmpupRI5M1hhgARCZp670tdsBwEwGKKVkspprhbC5ASed2d8NALy1qvz69OnKZzMb+IiKHulM pKWIYc1sSRpNxgShdyQZhKgqtHCyZKqD2zyzTIfD5tXPP/x48l6gJdnjx49HsbW1UZcabw3bcYXG UXOrbTgMaABZI6Xjv+SXSAswK4LFpFXT2Lyvdd4HpteHOtrb2+ubdRDJlLR8+PAgm+1pplvxbHup 9CHfdLMNJUYA20zzU49lQChptoLZMqsWSZuxrbPhva+P98gCrP1HazABWEnqDw4OlkeLxWrQjjrV rpRUGhxJwpkCgApWE1ZZYy7nm5rxelAWb+7fu7dY/+r/BfU4hQT4PYE3AAAAAElFTkSuQmCC"/></svg> \ No newline at end of file diff --git a/src/assets/icons/weather/weather-clouds.svg b/src/assets/icons/weather/weather-clouds.svg new file mode 100644 index 00000000..cc2e2b51 --- /dev/null +++ b/src/assets/icons/weather/weather-clouds.svg @@ -0,0 +1 @@ +<svg width="32" height="32" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><linearGradient id="linearGradient842" x1="10.087" x2="10.087" y1="9.97" y2="28.26" gradientUnits="userSpaceOnUse"><stop stop-color="#fff" offset="0"/><stop stop-color="#f2f2f2" offset="1"/></linearGradient><linearGradient id="linearGradient1025" x1="18.124" x2="18.124" y1="3.336" y2="28.783" gradientUnits="userSpaceOnUse"><stop stop-color="#fff133" offset="0"/><stop stop-color="#ffc40a" offset="1"/></linearGradient><style id="current-color-scheme" type="text/css">.ColorScheme-Text{color:#4d4d4d}.ColorScheme-Background{color:#eff0f1}.ColorScheme-Highlight{color:#3daee9}.ColorScheme-ViewText{color:#31363b}.ColorScheme-ViewBackground{color:#fcfcfc}.ColorScheme-ViewHover{color:#93cee9}.ColorScheme-ViewFocus{color:#3daee9}.ColorScheme-ButtonText{color:#31363b}.ColorScheme-ButtonBackground{color:#eff0f1}.ColorScheme-ButtonHover{color:#93cee9}.ColorScheme-ButtonFocus{color:#3daee9}</style></defs><path d="M18 2c-.554 0-1 .446-1 1v1c0 .554.446 1 1 1s1-.446 1-1V3c0-.554-.446-1-1-1zM8.762 5.772a.995.995 0 0 0-.659.292.998.998 0 0 0 0 1.415l.708.707a.998.998 0 0 0 1.414 0 .998.998 0 0 0 0-1.415l-.707-.707a.999.999 0 0 0-.756-.293zm18.385 0a.995.995 0 0 0-.658.292l-.708.708a.998.998 0 0 0 0 1.414.998.998 0 0 0 1.415 0l.707-.707a.998.998 0 0 0 0-1.415.999.999 0 0 0-.756-.293zM18 6A10 10 0 0 0 8 16a10 10 0 0 0 10 10 10 10 0 0 0 10-10A10 10 0 0 0 18 6zM5 15c-.554 0-1 .446-1 1s.446 1 1 1h1c.554 0 1-.446 1-1s-.446-1-1-1H5zm25 0c-.554 0-1 .446-1 1s.446 1 1 1h1c.554 0 1-.446 1-1s-.446-1-1-1h-1zM9.471 23.45a1 1 0 0 0-.66.292l-.707.707a.998.998 0 0 0 0 1.414.998.998 0 0 0 1.414 0l.707-.707a.998.998 0 0 0 0-1.414.995.995 0 0 0-.754-.293zm16.97 0a.995.995 0 0 0-.659.292.998.998 0 0 0 0 1.414l.707.707c.392.392 1.022.392 1.414 0s.392-1.022 0-1.414l-.707-.707a.999.999 0 0 0-.756-.293zM18 27c-.554 0-1 .446-1 1v1c0 .554.446 1 1 1s1-.446 1-1v-1c0-.554-.446-1-1-1z" fill="url(#linearGradient1025)"/><path d="M10.98 9A10 10 0 0 0 1 19a10 10 0 0 0 10 10 10 10 0 0 0 .019 0h8.98a7 7 0 0 0 7-7 7 7 0 0 0-6.833-6.998A10 10 0 0 0 11 9a10 10 0 0 0-.02 0z" fill="url(#linearGradient842)"/><image x="1" y="9" width="26" height="20" image-rendering="optimizeQuality" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABoAAAAUCAYAAACTQC2+AAAACXBIWXMAAA7DAAAOwwHHb6hkAAAA GXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAA2BJREFUSImVlU2PFUUUhp9TVX27 594ZYIYwC8VEExNixo0SNTEscO+W36P8HWaLcSlrcXQjmBAXJoCJ4BgRuLe7q+q8Lu4dmBkYPt6k k05/PfW+5/Qp4wTpGpGdnTWe2iZetom2iSsC/1HsT7787a4ZftL7x2UvhfxwOcHDjhnrOFuQt4jx FGgNWUIKBM1R87N9/uvdNwGFIwBhukbk/L1IetxQa4e8I9BSvcEV8IPF2RQrl/TTR5++FUjC4Epg Zydyr21o4oSklqgOSxOCRSwE4rHlSRfeBBaeQfYuJm7cbIAJ09qSpi2uDmOCPGErN64X45Yu6MeP 33u9oxuXI+NfifNpwiJ3VO+odQnBEsEiii+6OSzLn0gn3sUkjNs7SydPakujlsEmxDBDfgo4RbIp IxMSBi9x9NxZxfSYavdZDHfsqz/6o44Oa/6qAF4jswjhDNF2WG+/PhznEvTgnPPoUWWrZDqNyDPy jHkhWKUgouktqQ0qlw5gy+jA2LsYebwfYZaY1pYYZ9S8SQibiA1MLTUkTHZyJVZyIJioQNDI3+W7 YIYAcX2vcvmzzDYj8zhQ5gPBesRIDQWZE8wJplfOgwOIEMkrANvhg2eFFSyRuwTOvd9wdmONp/k0 jW1R/QxmM1wtIURs1ebHnR2GyB1CAUbE/rNHDZYh3kZsz5z5oiLPuA3Yyhlk3CsyRyY4dsiEzHGv EDLBevAFMqUj5QMJjFvAO1FMUsV9JFmPWaQ4GA7eEBVQDNRVu0cTsfoq4kzUyKgBCz2h9EdAq8aA 7wcjYXgjzAsl9cghUtHBtLAGKRJW70gCq6CMGBkZCAwkjdDMj4AA2L1inLtppGAUwJoCeQEhQxhw 9UgdDROMtNo6IFgFCtlGzHqiDcgz2UTK/74IYhfOfyj2EWmslDwCI8ygjAkLDY2ez0HC8huqBTHS WE9hoNRMaiqDG1/c+edojQxJiL3TzruTzP2HkMwoQZQskhvTNjJ/MqF2HUktFppV22Zi7Cnzgdn6 yHxRGYuYrvdm+Ms3PhHYuxg5/WjZlWMrFp1Y640HTwNN25DVstZ11Hm7bIbpwKLvaWwgD5mNs5Xr e9W+Xf51Jw5ICeMqxjerC1dheX7F+P2XxD4toZ2SfAkqYcCHORttz+6tcgA40P+7aMHF6iqeAAAA AABJRU5ErkJggg=="/></svg> \ No newline at end of file diff --git a/src/assets/icons/weather/weather-few-clouds-night.svg b/src/assets/icons/weather/weather-few-clouds-night.svg new file mode 100644 index 00000000..7448ada8 --- /dev/null +++ b/src/assets/icons/weather/weather-few-clouds-night.svg @@ -0,0 +1 @@ +<svg width="32" height="32" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><linearGradient id="linearGradient9700" x1="12.336" x2="12.336" y1="6.674" y2="26.137" gradientUnits="userSpaceOnUse"><stop stop-color="#f0f3f4" offset="0"/><stop stop-color="#cbd3d9" offset="1"/></linearGradient><linearGradient id="linearGradient1012" x1="7.068" x2="7.068" y1="15.719" y2="28.446" gradientTransform="translate(21.028 1.151)" gradientUnits="userSpaceOnUse"><stop stop-color="#9aafb2" offset="0"/><stop stop-color="#778b92" offset="1"/></linearGradient><style id="current-color-scheme" type="text/css">.ColorScheme-Text{color:#4d4d4d}.ColorScheme-Background{color:#eff0f1}.ColorScheme-Highlight{color:#3daee9}.ColorScheme-ViewText{color:#31363b}.ColorScheme-ViewBackground{color:#fcfcfc}.ColorScheme-ViewHover{color:#93cee9}.ColorScheme-ViewFocus{color:#3daee9}.ColorScheme-ButtonText{color:#31363b}.ColorScheme-ButtonBackground{color:#eff0f1}.ColorScheme-ButtonHover{color:#93cee9}.ColorScheme-ButtonFocus{color:#3daee9}</style></defs><path d="M16 3A13 13 0 0 0 3 16a13 13 0 0 0 13 13 13 13 0 0 0 13-13 13 13 0 0 0-.014-.521A9 9 0 0 1 24 17a9 9 0 0 1-9-9 9 9 0 0 1 1.522-4.986A13 13 0 0 0 16 3z" fill="url(#linearGradient9700)"/><path d="M28.5 2a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5zM3 3a1 1 0 0 0-1 1 1 1 0 0 0 1 1 1 1 0 0 0 1-1 1 1 0 0 0-1-1zm19 0a3 3 0 0 1-3 3 3 3 0 0 1 3 3 3 3 0 0 1 3-3 3 3 0 0 1-3-3zm4 5a2 2 0 0 1-2 2 2 2 0 0 1 2 2 2 2 0 0 1 2-2 2 2 0 0 1-2-2zm3.5 17a1.5 1.5 0 0 0-1.5 1.5 1.5 1.5 0 0 0 1.5 1.5 1.5 1.5 0 0 0 1.5-1.5 1.5 1.5 0 0 0-1.5-1.5zm-5 4a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5z" fill="#d1d9dd"/><image x="5.668" y="8.636" width="18" height="19" image-rendering="optimizeQuality" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAATCAYAAACdkl3yAAAACXBIWXMAAA7DAAAOwwHHb6hkAAAA GXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAAwFJREFUOI2NlE1rW0cYhc87cz8s W4rkIF9Z1Fmb4DpUkQkkuEXKosU/oD+jP6sbZeu2tBtfWpNAm4vBwcJQSkuQ7frKdmXr837MnC4c l9BCqrMamOFw5p1njuCdSEoYhrpUKslwOGSr1TIiQswpuTN51estOIOkOp6NywtFb3STpv3PHz2a zGumACAMQ+0Mkmpus23X1V/mSf78vhRqIaDnTaQAoN/vq8ymZUvzEMATgXw8g1kO3u3PIwcAVlZW rAM1zsS+BVAi+IcwHU2jaO4ZKQBotVpmAZU4yyWEcr4WuN/D9093d3fNvEZytyCpoyjy43LZ0ZOJ 8a6ukna7bQDMlUoAYI90Fo+Py1mSrFLcohYzIXnuZ9lfW1tb2d3ZTqejsLGhi54nwfW1bTabRkQs ADgk1atu916S4BOt3U+FeGCoYkW1n7j4heSFiNhO58j9aN2vGDtdVZPJ4swpDH8+Pv5zj7xui+RO FEUaXikQZZ5a2h0h1gBeWLEKRvdedLsDkvnh4W+VG06faCPbhK5B8a1J7Y9+t3tAcuDEcaxKNW9J tFoFUYPIsgBKIHURu9TwfHnR7Tp1w7qI+oyKX4Co0qJnIJk2+iSKoqEKgsDmxJjkGYRnBC8Fckbg RFmMDg5SNjxPtHAJsHUAASDLQtRAu5rTLMZxrFSz2TR+qmMReQnItxB8p5R8Q8OXtli5bDQ82d/v SW4xgkgPxBnBC0BORcmJQjYOgsDevtrenuMHwT2xtkalSiSpjChrjLLKvSzk3nni0rdIHgv5DJCA lj0N/ZNxssPtzc3r9zlSURTpS3fNu4/ztRTuc4Dr1vJXC/WDWy31vMlkaTbNazm56Gl3iFEeJ0l8 0263c+cfoG55sJ2jI1lM9IJoPgC4LmAKSYuv37yxX+3sXEVRdB3HsSoGgW0+bRqRjVuO/k3oShxb Va4NQHatMLWwv0NksFko3MFnP/hF3ruiRFFUmBUKVWQoiZWRv1rpN+v16Ye66T+JRIQkp2EYnt61 5bPGw/9ty78BO4inhTsr1+wAAAAASUVORK5CYII="/><path d="M7.906 15A7 7 0 0 0 1 22a7 7 0 0 0 7 7h6a4.997 4.997 0 0 0 .332-9.984A7 7 0 0 0 8 15a7 7 0 0 0-.094 0z" fill="url(#linearGradient1012)"/><image x="1" y="15" width="18" height="14" image-rendering="optimizeQuality" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAAOCAYAAAAi2ky3AAAACXBIWXMAAA7DAAAOwwHHb6hkAAAA GXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAAhNJREFUKJF1kU9PU1EUxGfOva+t paVSMW6AEGKC8a2Mf7Z+YL4EG+MGEmMiYtoNsYIuRAxFynvvnHEBSIkwJze5i3N/uTNDzGk0GrXR 6j3N1IrERZmCwmkSJ9Pp8bgsywr3iNeXg4OTpQZnL2EcmFkOd7u1KKtN+d36+uOju0AGADuHh90a vzeCNHfI3eEOOG5Owygaq96OJpOVO0GS2K2qgXtqQuESQpIE6BbJgXAwGr0ejUbt/6zt7OwU7eFw 2PH0SInLDg0IdCKYyBvrtx5RDYVjtGx/c3X126V1qdjbm/RbLS0pczkcSyK6kooIt5TSffleQfnp 2cbqxwxAKZ3Vf5AuUpVmSHbBiBaABJHufuev5lR++PL1ZyrLkmtra5g2jcktZxZZrkKwBMkI0iMo E+4bqulQEnd3d/Os1+sso9u/iGqYEx9WjkUSHTAyg+Yk7zVJ1pmkJPl4PK7Oz0/OPfdOva4TjJbM VHt0TJYNMAeIiEur11QHcN2KJAKw95NJK02n3RY6fXk1sCL3vY4FEm2XCpJGhjFA2A0pIv3IV8lL UlyMx3W/3z+vFoCmkhZojai6aWKhyGw7oqWwLEXCXAmWqv08V6Mk+fb2dtWunih6vyKS1SmKSqor V+omswcKdAAWEZaQAQGfXz3fPPoHmodtbUG9F7Oge9OdRd3Q6uxR16Gwy3CcitNAM35Tlt8B4C+v hkqbRNp3GwAAAABJRU5ErkJggg=="/></svg> \ No newline at end of file diff --git a/src/assets/icons/weather/weather-few-clouds.svg b/src/assets/icons/weather/weather-few-clouds.svg new file mode 100644 index 00000000..23f386a4 --- /dev/null +++ b/src/assets/icons/weather/weather-few-clouds.svg @@ -0,0 +1 @@ +<svg width="32" height="32" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><linearGradient id="linearGradient1025" x1="18.124" x2="18.124" y1="3.336" y2="28.783" gradientUnits="userSpaceOnUse"><stop stop-color="#fff133" offset="0"/><stop stop-color="#ffc40a" offset="1"/></linearGradient><linearGradient id="linearGradient963" x1="7.068" x2="7.068" y1="15.719" y2="28.446" gradientUnits="userSpaceOnUse"><stop stop-color="#fff" offset="0"/><stop stop-color="#ececec" offset="1"/></linearGradient><style id="current-color-scheme" type="text/css">.ColorScheme-Text{color:#4d4d4d}.ColorScheme-Background{color:#eff0f1}.ColorScheme-Highlight{color:#3daee9}.ColorScheme-ViewText{color:#31363b}.ColorScheme-ViewBackground{color:#fcfcfc}.ColorScheme-ViewHover{color:#93cee9}.ColorScheme-ViewFocus{color:#3daee9}.ColorScheme-ButtonText{color:#31363b}.ColorScheme-ButtonBackground{color:#eff0f1}.ColorScheme-ButtonHover{color:#93cee9}.ColorScheme-ButtonFocus{color:#3daee9}</style></defs><path d="M18 2c-.554 0-1 .446-1 1v1c0 .554.446 1 1 1s1-.446 1-1V3c0-.554-.446-1-1-1zM8.762 5.772a.995.995 0 0 0-.659.292.998.998 0 0 0 0 1.415l.708.707a.998.998 0 0 0 1.414 0 .998.998 0 0 0 0-1.415l-.707-.707a.999.999 0 0 0-.756-.293zm18.385 0a.995.995 0 0 0-.658.292l-.708.708a.998.998 0 0 0 0 1.414.998.998 0 0 0 1.415 0l.707-.707a.998.998 0 0 0 0-1.415.999.999 0 0 0-.756-.293zM18 6A10 10 0 0 0 8 16a10 10 0 0 0 10 10 10 10 0 0 0 10-10A10 10 0 0 0 18 6zM5 15c-.554 0-1 .446-1 1s.446 1 1 1h1c.554 0 1-.446 1-1s-.446-1-1-1zm25 0c-.554 0-1 .446-1 1s.446 1 1 1h1c.554 0 1-.446 1-1s-.446-1-1-1zM9.471 23.45a1 1 0 0 0-.66.292l-.707.707a.998.998 0 0 0 0 1.414.998.998 0 0 0 1.414 0l.707-.707a.998.998 0 0 0 0-1.414.995.995 0 0 0-.754-.293zm16.97 0a.995.995 0 0 0-.659.292.998.998 0 0 0 0 1.414l.707.707c.392.392 1.022.392 1.414 0s.392-1.022 0-1.414l-.707-.707a.999.999 0 0 0-.756-.293zM18 27c-.554 0-1 .446-1 1v1c0 .554.446 1 1 1s1-.446 1-1v-1c0-.554-.446-1-1-1z" fill="url(#linearGradient1025)"/><path d="M7.906 15A7 7 0 0 0 1 22a7 7 0 0 0 7 7h6a4.997 4.997 0 0 0 .332-9.984A7 7 0 0 0 8 15a7 7 0 0 0-.094 0z" fill="url(#linearGradient963)"/><image x="1" y="15" width="18" height="14" image-rendering="optimizeQuality" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAAOCAYAAAAi2ky3AAAACXBIWXMAAA7DAAAOwwHHb6hkAAAA GXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAAgBJREFUKJF1k81qFFEQRk/VvT3T nTgkjhEXZqEgLhTFjVvBN9PX0QdwqaDoyo0QEQSjoAghKhozP9331ufCUTIYa1eLOpz6qDJOlERi //IOg86S6xmKtQhBOqRu7Nv1vZ7/lP2F7F0fkX7skOjIqWGgRXEGYoLRAVDSC7v24fNpIF+ZOOWw obM5Kf9k1s8QC4ge84IsCGVS3NHbi7v/BQFwc7fHY0F/NCMzoy7noCU1BqJWIKjVGOy23l4Zn7qa hAHGE5zdK4n+e0Nnm/R5m4FzKLYw63Bl5I5FhfSVwd7YrY+f1jMCQyvL95cajqKj6beQT1HdRmxS bYwrYRh1NevasxsHr+yk3gpmvCZzNG1pbQLNNim2qExwa1E0OE7471gSUMpTPwkyEPdXTXHRe0W1 J2KBaUZo8Ts3KxAFRVBCYFfzmpFWuo8wpmF0WcSyUNOCcEhWqWrJGlHU4EoEjtlkDQTAQ4zzGFnG 8TH4uDCucywNDGWJ+4JlbTEfEWpAGbf4FwSwi/hiInulLHvm9GyOYW6ZrjQ0eUwtLTXGWBpR4+d6 2H/O4CWJC2Q+TRuyjGKiuMhhbNSEjUcMfcuybEDqKOyvGZkhCXhHAIVzXwOAHjFHdBgHOM1OYaME npxZ+WZ3j7+dupmESbgekPSAJOG6h0u4RNJjWj3bmej56geBX/TTAk+Ve0LrAAAAAElFTkSuQmCC"/></svg> \ No newline at end of file diff --git a/src/assets/icons/weather/weather-fog.svg b/src/assets/icons/weather/weather-fog.svg new file mode 100644 index 00000000..0838f14c --- /dev/null +++ b/src/assets/icons/weather/weather-fog.svg @@ -0,0 +1,29 @@ +<svg width="32" height="32" version="1.1" xmlns="http://www.w3.org/2000/svg"> + <defs> + <linearGradient id="linearGradient3791" x1="51.322" x2="51.322" y1="4.8305" y2="21.717" gradientTransform="translate(-35.322 -2.8305)" gradientUnits="userSpaceOnUse"> + <stop stop-color="#fff" stop-opacity=".75" offset="0"/> + <stop stop-color="#ececec" offset="1"/> + </linearGradient> + <linearGradient id="linearGradient3799" x1="37.322" x2="51.322" y1="25.83" y2="25.83" gradientTransform="translate(-35.322 -2.8305)" gradientUnits="userSpaceOnUse"> + <stop stop-color="#fff" stop-opacity="0" offset="0"/> + <stop stop-color="#ececec" offset="1"/> + </linearGradient> + <linearGradient id="linearGradient3801" x1="39.322" x2="43.348" y1="29.83" y2="29.83" gradientTransform="translate(-35.322 -2.8305)" gradientUnits="userSpaceOnUse"> + <stop stop-color="#fff" stop-opacity="0" offset="0"/> + <stop stop-color="#ececec" offset="1"/> + </linearGradient> + <linearGradient id="linearGradient3803" x1="45.322" x2="63.321" y1="29.83" y2="29.83" gradientTransform="translate(-35.322 -2.8305)" gradientUnits="userSpaceOnUse"> + <stop stop-color="#fff" stop-opacity="0" offset="0"/> + <stop stop-color="#ececec" offset="1"/> + </linearGradient> + <linearGradient id="linearGradient3805" x1="55.322" x2="63.321" y1="25.83" y2="25.83" gradientTransform="translate(-35.322 -2.8305)" gradientUnits="userSpaceOnUse"> + <stop stop-color="#fff" stop-opacity="0" offset="0"/> + <stop stop-color="#ececec" offset="1"/> + </linearGradient> + </defs> + <path d="m16 4c-3.6727 0-6.7332 2.4036-7.8748 5.6876-3.4461 0.43672-6.1248 3.349-6.1248 6.914 0 1.2409 0.35163 2.3878 0.91794 3.3984h26.695c0.23923-0.62302 0.38671-1.2928 0.38671-2 0-3.0928-2.5087-5.6016-5.6014-5.6016 0-4.639-3.7593-8.3984-8.3982-8.3984z" fill="url(#linearGradient3791)" stroke-width="2"/> + <path d="m3.1666 22h11.666a1.1666 1 0 0 1 1.1666 1 1.1666 1 0 0 1-1.1666 1h-11.666a1.1666 1 0 0 1-1.1666-1 1.1666 1 0 0 1 1.1666-1" fill="url(#linearGradient3799)" stroke-width="1.4416"/> + <path d="m21.142 22h5.714a1.1428 1 0 0 1 1.1428 1 1.1428 1 0 0 1-1.1428 1h-5.714a1.1428 1 0 0 1-1.1428-1 1.1428 1 0 0 1 1.1428-1" fill="url(#linearGradient3805)" stroke-width="1.4268"/> + <path d="m4.9802 26h2.0131a1.0066 1 0 0 1 1.0066 1 1.0066 1 0 0 1-1.0066 1h-2.0131a1.0066 1 0 0 1-1.0066-1 1.0066 1 0 0 1 1.0066-1" fill="url(#linearGradient3801)" stroke-width="1.339"/> + <path d="m11.2 26h15.6a1.2 1 0 0 1 1.2 1 1.2 1 0 0 1-1.2 1h-15.6a1.2 1 0 0 1-1.2-1 1.2 1 0 0 1 1.2-1z" fill="url(#linearGradient3803)" stroke-width="1.462"/> +</svg> diff --git a/src/assets/icons/weather/weather-freezing-rain.svg b/src/assets/icons/weather/weather-freezing-rain.svg new file mode 100644 index 00000000..ac8a18bf --- /dev/null +++ b/src/assets/icons/weather/weather-freezing-rain.svg @@ -0,0 +1 @@ +<svg width="32" height="32" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><linearGradient id="linearGradient865" x1="14.309" x2="14.309" y1="4.555" y2="23.488" gradientUnits="userSpaceOnUse"><stop stop-color="#fff" offset="0"/><stop stop-color="#ececec" offset="1"/></linearGradient><linearGradient id="linearGradient6590" x1="13.641" x2="13.641" y1="18.935" y2="29.235" gradientUnits="userSpaceOnUse"><stop stop-color="#648df6" offset="0"/><stop stop-color="#52c0ff" offset="1"/></linearGradient><style id="current-color-scheme" type="text/css">.ColorScheme-Text{color:#4d4d4d}.ColorScheme-Background{color:#eff0f1}.ColorScheme-Highlight{color:#3daee9}.ColorScheme-ViewText{color:#31363b}.ColorScheme-ViewBackground{color:#fcfcfc}.ColorScheme-ViewHover{color:#93cee9}.ColorScheme-ViewFocus{color:#3daee9}.ColorScheme-ButtonText{color:#31363b}.ColorScheme-ButtonBackground{color:#eff0f1}.ColorScheme-ButtonHover{color:#93cee9}.ColorScheme-ButtonFocus{color:#3daee9}</style></defs><path d="M19.5 18c-.831 0-1.5.669-1.5 1.5s.669 1.5 1.5 1.5 1.5-.669 1.5-1.5-.669-1.5-1.5-1.5zm-11 2c-.831 0-1.5.669-1.5 1.5S7.669 23 8.5 23s1.5-.669 1.5-1.5S9.331 20 8.5 20zm7 2c-.831 0-1.5.669-1.5 1.5s.669 1.5 1.5 1.5 1.5-.669 1.5-1.5-.669-1.5-1.5-1.5zm8 3c-.831 0-1.5.669-1.5 1.5s.669 1.5 1.5 1.5 1.5-.669 1.5-1.5-.669-1.5-1.5-1.5zm-12 2c-.831 0-1.5.669-1.5 1.5s.669 1.5 1.5 1.5 1.5-.669 1.5-1.5-.669-1.5-1.5-1.5z" fill="url(#linearGradient6590)"/><path d="M13 4A10 10 0 0 0 3 14a10 10 0 0 0 10 10h9a7 7 0 0 0 7-7 7 7 0 0 0-6.836-6.996A10 10 0 0 0 13 4z" fill="url(#linearGradient865)"/><image x="5.072" y="16" width="22" height="8" image-rendering="optimizeQuality" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABYAAAAICAYAAAD9aA/QAAAACXBIWXMAAA7DAAAOwwHHb6hkAAAA GXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAAWJJREFUKJGdUktOAlEQrPemh5kB UV4Qf9EQlo6X4BKwduU1bC9hohv3cgi8gEtcsjERRRNUBGaYea9dSYzAxlp10pVKVVcD/4CIKGbR EFHrOGsX6/hN7npHpeMgsgWCSbKR2U87bWX/EvUvG4pZNDNrYLUTZlZxrRmWaKuWFvxDJGbbjBCs cq4BoHUr3tn1IHraR3lYOy+eXd3TKvFeL1Zp+llIYCukqWqhNwH4fLGcXANQZjQIsrxSnbn50YTS 3UnaiJgXZLW4aauFIm3mvk9TaDfOlcwohwUultIRAAnSiXVRdUOgjXJScIH/0YuhAFFNhte+RFiq g8rjN/u8t52Z/vQl2yCiSXG+M0PCzLJUxs9weiOhdqjnNpVQpi8HAzMGgGGMYpJhZ55kZc9zX54N hv3Hu1ktbsrJA4QZAqj1wouCukK9V0inDde6haZ8bLQLG4Ayns3eMz/q54TRqk/4jW+9wp0aXkxt hgAAAABJRU5ErkJggg=="/></svg> \ No newline at end of file diff --git a/src/assets/icons/weather/weather-hail.svg b/src/assets/icons/weather/weather-hail.svg new file mode 100644 index 00000000..13f4cdf1 --- /dev/null +++ b/src/assets/icons/weather/weather-hail.svg @@ -0,0 +1 @@ +<svg width="32" height="32" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><linearGradient id="linearGradient865" x1="14.309" x2="14.309" y1="4.555" y2="23.488" gradientUnits="userSpaceOnUse"><stop stop-color="#fff" offset="0"/><stop stop-color="#ececec" offset="1"/></linearGradient><linearGradient id="linearGradient980" x1="16.5" x2="16.5" y1="20" y2="29.487" gradientUnits="userSpaceOnUse"><stop stop-color="#648df6" offset="0"/><stop stop-color="#52c0ff" offset="1"/></linearGradient><style id="current-color-scheme" type="text/css">.ColorScheme-Text{color:#4d4d4d}.ColorScheme-Background{color:#eff0f1}.ColorScheme-Highlight{color:#3daee9}.ColorScheme-ViewText{color:#31363b}.ColorScheme-ViewBackground{color:#fcfcfc}.ColorScheme-ViewHover{color:#93cee9}.ColorScheme-ViewFocus{color:#3daee9}.ColorScheme-ButtonText{color:#31363b}.ColorScheme-ButtonBackground{color:#eff0f1}.ColorScheme-ButtonHover{color:#93cee9}.ColorScheme-ButtonFocus{color:#3daee9}</style></defs><path d="M16.5 20c-.831 0-1.5.669-1.5 1.5s.669 1.5 1.5 1.5 1.5-.669 1.5-1.5-.669-1.5-1.5-1.5zm7 5c-.831 0-1.5.669-1.5 1.5s.669 1.5 1.5 1.5 1.5-.669 1.5-1.5-.669-1.5-1.5-1.5zm-12 2c-.831 0-1.5.669-1.5 1.5s.669 1.5 1.5 1.5 1.5-.669 1.5-1.5-.669-1.5-1.5-1.5z" fill="url(#linearGradient980)"/><path d="M13 4A10 10 0 0 0 3 14a10 10 0 0 0 10 10h9a7 7 0 0 0 7-7 7 7 0 0 0-6.836-6.996A10 10 0 0 0 13 4z" fill="url(#linearGradient865)"/><image x="8.261" y="18" width="19" height="6" image-rendering="optimizeQuality" opacity=".35" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABMAAAAGCAYAAAAhS6XkAAAACXBIWXMAAA7DAAAOwwHHb6hkAAAA GXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAAMFJREFUGJV90T0KwkAQBeA3M7sh agI2oohtGsE75AaCkNLCxs47eADB3so+l7Cw1i4nsJEUSkT8wd31AtlMOTw+eDME7zhK15AewCUO 9rBODUDOnweo3nE036Ct9WPkoLrG/G5hFF92S7xAfpDrllkOlqAaOtIzYqyUVlMxGGR5fb4RKwuQ BK0IGolVmFih5PN7xmXhadKE9cZwBuYGkROYj07RmdpyTwHbhHlvlm0RdlrvPpjjr9gqkPC6X+DT 9IQ/+y05SfmYFFUAAAAASUVORK5CYII="/></svg> \ No newline at end of file diff --git a/src/assets/icons/weather/weather-many-clouds.svg b/src/assets/icons/weather/weather-many-clouds.svg new file mode 100644 index 00000000..adf556d9 --- /dev/null +++ b/src/assets/icons/weather/weather-many-clouds.svg @@ -0,0 +1,15 @@ +<svg width="32" height="32" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> + <defs> + <linearGradient id="linearGradient852" x1="14.975" x2="14.975" y1="5.031" y2="20.723" gradientUnits="userSpaceOnUse"> + <stop stop-color="#f5f5f5" offset="0"/> + <stop stop-color="#dbdbdb" offset="1"/> + </linearGradient> + <linearGradient id="linearGradient898" x1="7.906" x2="7.906" y1="15" y2="28.077" gradientUnits="userSpaceOnUse"> + <stop stop-color="#fff" offset="0"/> + <stop stop-color="#f2f2f2" offset="1"/> + </linearGradient> + </defs> + <path d="M14.98 3A10 10 0 0 0 5 13a10 10 0 0 0 10 10h9a7 7 0 0 0 7-7 7 7 0 0 0-6.834-6.998A10 10 0 0 0 15 3a10 10 0 0 0-.02 0z" fill="url(#linearGradient852)"/> + <path d="M7.906 15A7 7 0 0 0 1 22a7 7 0 0 0 7 7 7 7 0 0 0 .094 0H14a4.997 4.997 0 0 0 .332-9.984A7 7 0 0 0 8 15a7 7 0 0 0-.094 0z" fill="url(#linearGradient898)"/> + <image x="2" y="15" width="17" height="11" image-rendering="optimizeQuality" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABEAAAALCAYAAACZIGYHAAAACXBIWXMAAA7DAAAOwwHHb6hkAAAA GXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAAZVJREFUKJF1kM2OElEQhevU7UuD RHpGJNEoBlZufIJZ6M6HhVdw68TEPSs6gCEhEEIHp4dM/9w6LoaJ7Q8nOandV18V5Hew2WxehhA+ mNl7AAmAI8kfIvJtOBwe5EIgIkISWZb18jwfkhwDeEsyEZGWqkJEGEL4MhqNbv8HURGR6XSqdV0H 731+3n5nZgXJYGY0M5D8vFgsPl2EzGYzHo/HKs/z+6qqMpIHAD/N7CGEEMyMj8L8mKbpu4vniIhu t9t2VVVXZvaqLMs3zrkByZ6IxCQjVQVJkLxX1e/j8fgrgBCJiAAgSVuv12WSJHeq6gAIyRpAQfI5 ybaZRQAUgCd5k6bpa5JTNK3ORm6/37cPh0PPOXetqi9CCFcku6oak4wA6NMVAG6jBoTnGVar1YP3 3gaDQVEURV7Xdeace0ayrarezBwAhBAgIknTRBr/ERHR+XwelWXpVTXudDqt0+nU6na7rqoqBYCy LOG9t38gf8Geqsvl0sVxrFmWqXMOIiL9fl92ux0vMf6AnaskdTKZOJLN6i/hExHiJUebiAAAAABJ RU5ErkJggg=="/> +</svg> diff --git a/src/assets/icons/weather/weather-none-available.svg b/src/assets/icons/weather/weather-none-available.svg new file mode 100644 index 00000000..cc31231a --- /dev/null +++ b/src/assets/icons/weather/weather-none-available.svg @@ -0,0 +1,16 @@ +<svg width="32" height="32" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> + <defs> + <linearGradient id="linearGradient1090" x1="15.909" x2="15.909" y1="7.1975" y2="25.334" gradientTransform="translate(29.474 -3.3333)" gradientUnits="userSpaceOnUse"> + <stop stop-color="#fff" offset="0"/> + <stop stop-color="#ececec" offset="1"/> + </linearGradient> + <linearGradient id="linearGradient2743" x1="15.956" x2="15.956" y1="18.074" y2="27.801" gradientUnits="userSpaceOnUse"> + <stop stop-color="#ff7f2a" offset="0"/> + <stop stop-color="#ffb72a" offset="1"/> + </linearGradient> + </defs> + <path d="m16 6c-3.6728 0-6.7334 2.4527-7.875 5.8035-3.4462 0.44563-6.125 3.4159-6.125 7.0537 0 3.9449 3.134 7.1429 7 7.1429h15.4c3.0928 0 5.6-2.5584 5.6-5.7143s-2.5072-5.7143-5.6-5.7143c0-4.7337-3.761-8.5714-8.4-8.5714z" fill="url(#linearGradient1090)"/> + <circle cx="16" cy="23" r="6" fill="url(#linearGradient2743)" fill-rule="evenodd" stop-color="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-width="2.0773" style="paint-order:stroke fill markers"/> + <image x="10" y="17" width="12" height="12" image-rendering="optimizeQuality" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAYAAABWdVznAAAACXBIWXMAAA7DAAAOwwHHb6hkAAAA GXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAAN5JREFUKM+NkUFKxEAQRX9VN4JC MkMIBCHgCDIXmAvMzmvlXG4aD+ANBnQRkJBAYwIKktSfTdCNDv3gbz7vb6qEJFZ0mqYHM9sB2Kzd h6q+ZVl2AmAAICTRtu11nudHAFv8TRzH8bmu6y8hqTHGxwsyAIBkLIriSfq+3zvnDkhgWZYXVdUd ElHVOw8gB8DEzcaTXABIik1y9mbWi8g2ZWBm0QM4mdl9ykBEXoWkDMNw672/uiTP8/xdluW7rJ+W rutunHP6zzmtqqpPAAT5m6ZplKQLIfgQgifp1u7HOQPSHHd9Z9yShwAAAABJRU5ErkJggg== "/> + <path d="m15.259 24.656v-0.19577q0-0.30159 0.03704-0.54498 0.04233-0.24339 0.14286-0.45503 0.10582-0.21164 0.28042-0.4127 0.1746-0.20635 0.43915-0.42857 0.25397-0.21693 0.43386-0.39154 0.1799-0.17989 0.29101-0.35979 0.1164-0.18518 0.16931-0.39154 0.0582-0.21164 0.0582-0.49206 0-0.25397-0.07936-0.46032-0.07937-0.21164-0.2328-0.35979-0.14815-0.15344-0.37566-0.2328-0.22222-0.08466-0.51852-0.08466-0.43915 0-0.83598 0.13757t-0.7672 0.31746l-0.33334-0.76719q0.42857-0.22222 0.92593-0.37566 0.49736-0.15873 1.0106-0.15873 0.49206 0 0.8836 0.13757 0.39683 0.13228 0.67196 0.38624t0.42328 0.62434q0.14815 0.36508 0.14815 0.8254 0 0.3545-0.07408 0.63492-0.06878 0.27513-0.21164 0.51323t-0.3545 0.46032-0.49736 0.4709q-0.27513 0.2381-0.44974 0.41799-0.1746 0.1746-0.27513 0.34392-0.10053 0.16402-0.13757 0.33862t-0.03704 0.40741v0.09524zm-0.25397 1.6032q0-0.20106 0.05291-0.33862 0.05291-0.14286 0.14286-0.22751 0.08995-0.08995 0.21164-0.12698 0.12169-0.04233 0.26455-0.04233 0.13757 0 0.25926 0.04233 0.12698 0.03704 0.21693 0.12698 0.08995 0.08466 0.14286 0.22751 0.05291 0.13757 0.05291 0.33862 0 0.19577-0.05291 0.33862-0.05291 0.13757-0.14286 0.22751-0.08995 0.08995-0.21693 0.13228-0.12169 0.04233-0.25926 0.04233-0.14286 0-0.26455-0.04233t-0.21164-0.13228-0.14286-0.22751q-0.05291-0.14286-0.05291-0.33862z" fill="#f2f2f2"/> +</svg> diff --git a/src/assets/icons/weather/weather-overcast-night.svg b/src/assets/icons/weather/weather-overcast-night.svg new file mode 100644 index 00000000..e830d22f --- /dev/null +++ b/src/assets/icons/weather/weather-overcast-night.svg @@ -0,0 +1,10 @@ +<svg width="32" height="32" version="1.1" xmlns="http://www.w3.org/2000/svg"> + <defs> + <linearGradient id="linearGradient1090" x1="15.909" x2="15.909" y1="7.1975" y2="25.334" gradientTransform="translate(29.474 -3.3333)" gradientUnits="userSpaceOnUse"> + <stop stop-color="#fff" offset="0"/> + <stop stop-color="#ececec" offset="1"/> + </linearGradient> + </defs> + <path d="m16 6c-3.6728 0-6.7334 2.4527-7.875 5.8035-3.4462 0.44563-6.125 3.4159-6.125 7.0537 0 3.9449 3.134 7.1429 7 7.1429h15.4c3.0928 0 5.6-2.5584 5.6-5.7143s-2.5072-5.7143-5.6-5.7143c0-4.7337-3.761-8.5714-8.4-8.5714z" fill="url(#linearGradient1090)" stroke-width="2.0203"/> + <path d="m29.031 1.2354a0.5 0.5 0 0 0-0.5 0.5 0.5 0.5 0 0 0 0.5 0.5 0.5 0.5 0 0 0 0.5-0.5 0.5 0.5 0 0 0-0.5-0.5zm-25.5 1a1 1 0 0 0-1 1 1 1 0 0 0 1 1 1 1 0 0 0 1-1 1 1 0 0 0-1-1zm19 0a3 3 0 0 1-3 3 3 3 0 0 1 3 3 3 3 0 0 1 3-3 3 3 0 0 1-3-3zm4 5a2 2 0 0 1-2 2 2 2 0 0 1 2 2 2 2 0 0 1 2-2 2 2 0 0 1-2-2zm3.5 17a1.5 1.5 0 0 0-1.5 1.5 1.5 1.5 0 0 0 1.5 1.5 1.5 1.5 0 0 0 1.5-1.5 1.5 1.5 0 0 0-1.5-1.5zm-5 4a0.5 0.5 0 0 0-0.5 0.5 0.5 0.5 0 0 0 0.5 0.5 0.5 0.5 0 0 0 0.5-0.5 0.5 0.5 0 0 0-0.5-0.5z" fill="#d1d9dd"/> +</svg> diff --git a/src/assets/icons/weather/weather-overcast.svg b/src/assets/icons/weather/weather-overcast.svg new file mode 100644 index 00000000..27d0593d --- /dev/null +++ b/src/assets/icons/weather/weather-overcast.svg @@ -0,0 +1,9 @@ +<svg width="32" height="32" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> + <defs> + <linearGradient id="linearGradient1090" x1="15.909" x2="15.909" y1="7.1975" y2="25.334" gradientTransform="translate(29.474 -3.3333)" gradientUnits="userSpaceOnUse"> + <stop stop-color="#fff" offset="0"/> + <stop stop-color="#ececec" offset="1"/> + </linearGradient> + </defs> + <path d="m16 6c-3.6728 0-6.7334 2.4527-7.875 5.8035-3.4462 0.44563-6.125 3.4159-6.125 7.0537 0 3.9449 3.134 7.1429 7 7.1429h15.4c3.0928 0 5.6-2.5584 5.6-5.7143 0-3.1559-2.5072-5.7143-5.6-5.7143 0-4.7337-3.761-8.5714-8.4-8.5714z" fill="url(#linearGradient1090)" stroke-width="2.0203"/> +</svg> diff --git a/src/assets/icons/weather/weather-severe-alert.svg b/src/assets/icons/weather/weather-severe-alert.svg new file mode 100644 index 00000000..9812e233 --- /dev/null +++ b/src/assets/icons/weather/weather-severe-alert.svg @@ -0,0 +1,17 @@ +<svg width="32" height="32" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> + <defs> + <linearGradient id="linearGradient1090" x1="15.909" x2="15.909" y1="7.1975" y2="25.334" gradientTransform="translate(29.474 -3.3333)" gradientUnits="userSpaceOnUse"> + <stop stop-color="#fff" offset="0"/> + <stop stop-color="#ececec" offset="1"/> + </linearGradient> + <linearGradient id="linearGradient2743" x1="15.956" x2="15.956" y1="18.074" y2="27.801" gradientUnits="userSpaceOnUse"> + <stop stop-color="#ff382a" offset="0"/> + <stop stop-color="#ff772a" offset="1"/> + </linearGradient> + </defs> + <path d="m16 6c-3.6728 0-6.7334 2.4527-7.875 5.8035-3.4462 0.44563-6.125 3.4159-6.125 7.0537 0 3.9449 3.134 7.1429 7 7.1429h15.4c3.0928 0 5.6-2.5584 5.6-5.7143s-2.5072-5.7143-5.6-5.7143c0-4.7337-3.761-8.5714-8.4-8.5714z" fill="url(#linearGradient1090)" stroke-width="2.0203"/> + <circle cx="16" cy="23" r="6" fill="url(#linearGradient2743)" fill-rule="evenodd" stop-color="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-width="2.0773" style="paint-order:stroke fill markers"/> + <image x="10" y="17" width="12" height="12" image-rendering="optimizeQuality" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAYAAABWdVznAAAACXBIWXMAAA7DAAAOwwHHb6hkAAAA GXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAAN5JREFUKM+NkUFKxEAQRX9VN4JC MkMIBCHgCDIXmAvMzmvlXG4aD+ANBnQRkJBAYwIKktSfTdCNDv3gbz7vb6qEJFZ0mqYHM9sB2Kzd h6q+ZVl2AmAAICTRtu11nudHAFv8TRzH8bmu6y8hqTHGxwsyAIBkLIriSfq+3zvnDkhgWZYXVdUd ElHVOw8gB8DEzcaTXABIik1y9mbWi8g2ZWBm0QM4mdl9ykBEXoWkDMNw672/uiTP8/xdluW7rJ+W rutunHP6zzmtqqpPAAT5m6ZplKQLIfgQgifp1u7HOQPSHHd9Z9yShwAAAABJRU5ErkJggg== "/> + <rect x="15" y="19" width="2" height="5" rx="1" ry="1" fill="#f2f2f2"/> + <rect x="15" y="25" width="2" height="2" rx="2" ry="2" fill="#f2f2f2"/> +</svg> diff --git a/src/assets/icons/weather/weather-showers-day.svg b/src/assets/icons/weather/weather-showers-day.svg new file mode 100644 index 00000000..de8527ee --- /dev/null +++ b/src/assets/icons/weather/weather-showers-day.svg @@ -0,0 +1 @@ +<svg width="32" height="32" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><linearGradient id="linearGradient1025" x1="18.124" x2="18.124" y1="3.336" y2="28.783" gradientUnits="userSpaceOnUse"><stop stop-color="#fff133" offset="0"/><stop stop-color="#ffc40a" offset="1"/></linearGradient><linearGradient id="linearGradient1041" x1="7.906" x2="7.906" y1="11" y2="24.527" gradientUnits="userSpaceOnUse"><stop stop-color="#fff" offset="0"/><stop stop-color="#ededed" offset="1"/></linearGradient><linearGradient id="linearGradient6590" x1="13.641" x2="13.641" y1="18.935" y2="29.235" gradientTransform="translate(-19 -3)" gradientUnits="userSpaceOnUse"><stop stop-color="#648df6" offset="0"/><stop stop-color="#52c0ff" offset="1"/></linearGradient><linearGradient id="linearGradient1167" x1="13" x2="13" y1="1049.4" y2="1042.4" gradientUnits="userSpaceOnUse" xlink:href="#linearGradient6590"/><linearGradient id="linearGradient1781" x1="14" x2="14" y1="22" y2="28" gradientUnits="userSpaceOnUse" xlink:href="#linearGradient6590"/><style id="current-color-scheme" type="text/css">.ColorScheme-Text{color:#4d4d4d}.ColorScheme-Background{color:#eff0f1}.ColorScheme-Highlight{color:#3daee9}.ColorScheme-ViewText{color:#31363b}.ColorScheme-ViewBackground{color:#fcfcfc}.ColorScheme-ViewHover{color:#93cee9}.ColorScheme-ViewFocus{color:#3daee9}.ColorScheme-ButtonText{color:#31363b}.ColorScheme-ButtonBackground{color:#eff0f1}.ColorScheme-ButtonHover{color:#93cee9}.ColorScheme-ButtonFocus{color:#3daee9}</style></defs><path d="M18 2c-.554 0-1 .446-1 1v1c0 .554.446 1 1 1s1-.446 1-1V3c0-.554-.446-1-1-1zM8.762 5.772a.995.995 0 0 0-.659.292.998.998 0 0 0 0 1.415l.708.707a.998.998 0 0 0 1.414 0 .998.998 0 0 0 0-1.415l-.707-.707a.999.999 0 0 0-.756-.293zm18.385 0a.995.995 0 0 0-.658.292l-.708.708a.998.998 0 0 0 0 1.414.998.998 0 0 0 1.415 0l.707-.707a.998.998 0 0 0 0-1.415.999.999 0 0 0-.756-.293zM18 6A10 10 0 0 0 8 16a10 10 0 0 0 10 10 10 10 0 0 0 10-10A10 10 0 0 0 18 6zM5 15c-.554 0-1 .446-1 1s.446 1 1 1h1c.554 0 1-.446 1-1s-.446-1-1-1zm25 0c-.554 0-1 .446-1 1s.446 1 1 1h1c.554 0 1-.446 1-1s-.446-1-1-1zM9.471 23.45a1 1 0 0 0-.66.292l-.707.707a.998.998 0 0 0 0 1.414.998.998 0 0 0 1.414 0l.707-.707a.998.998 0 0 0 0-1.414.995.995 0 0 0-.754-.293zm16.97 0a.995.995 0 0 0-.659.292.998.998 0 0 0 0 1.414l.707.707c.392.392 1.022.392 1.414 0s.392-1.022 0-1.414l-.707-.707a.999.999 0 0 0-.756-.293zM18 27c-.554 0-1 .446-1 1v1c0 .554.446 1 1 1s1-.446 1-1v-1c0-.554-.446-1-1-1z" fill="url(#linearGradient1025)"/><path d="M6 21c-.554 0-1 .446-1 1v6c0 .554.446 1 1 1s1-.446 1-1v-6c0-.554-.446-1-1-1zm4 0c-.554 0-1 .446-1 1v7c0 .554.446 1 1 1s1-.446 1-1v-7c0-.554-.446-1-1-1zm4 0c-.554 0-1 .446-1 1v6c0 .554.446 1 1 1s1-.446 1-1v-6c0-.554-.446-1-1-1z" fill="url(#linearGradient1781)"/><path d="M7.906 11A7 7 0 0 0 1 18a7 7 0 0 0 7 7h6a4.997 4.997 0 0 0 .332-9.984A7 7 0 0 0 8 11a7 7 0 0 0-.094 0z" fill="url(#linearGradient1041)"/><image x="2" y="11" width="17" height="14" image-rendering="optimizeQuality" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABEAAAAOCAYAAADJ7fe0AAAACXBIWXMAAA7DAAAOwwHHb6hkAAAA GXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAAjRJREFUKJF9kslOVFEQhr+qc/re HqDBDjK0OOC4Mhp1Y6IJT8PCp4DH0L2P4Nph40Y2Jk4JDsEWJ+wWweZ233NOuSCgDLGS2lX++ur/ Szii7OG859LaOJKmSC7Qjx/kwsrgqFkAOSRgCJ3ZKlZtYzpHTC0chuOpzK50jhJRAAOxRdQM5RGO RpYR4giEKsmUlDyl3bK3Z08dSWIgPLvuGduokfoZQRN1nxE4g9gM+ByxXeIhmh7I3Idiv4ghvDze IM+nKXUMzzbYgEQb0UmQbP/aFFC6pPhGLnzq7J2zV17sEGuMwD9t5ok2ieltezN35f/nlOE0gTZO ctLhAP66ak9UwLixHLi/ssXFtS7fOr9Ius3QCsRKygMkBzvYJQewBCw9MkREvjfu0Koty6jr5yFp M6BVLKkaRHOYKWoGthMrknK3w2SycBffi/P1z4OZ7Oe3WhppbvqtQWu0KEeqXoYuxor0imn6ZRPn AhUrQSOIJAewuLioX7JuQ71OB8uaX4tjYVhp68fe5fFumMqb2tUiNvR196Z86c8x6rrUXQ/FILHu d/1p5K3MQjkeLfhcJ36v9qZdtFbWLNvpRP6qyHy/9r5/1VlKcrLxgomqw8UhwGvdIcEAQkUq0UvF j/zITjbfNzSP+ZZNWM9mNwpaW7/TeOrrMYuW7zhi8YVcW1/T3Z+7t8CPYd2vWi1tHq+v+snau7E8 38zKirOPg/Mba8Nz6z91dLDt6tsFlU5I+liu954D/AFXrRkogSNpXQAAAABJRU5ErkJggg=="/></svg> \ No newline at end of file diff --git a/src/assets/icons/weather/weather-showers-night.svg b/src/assets/icons/weather/weather-showers-night.svg new file mode 100644 index 00000000..85b1dd9b --- /dev/null +++ b/src/assets/icons/weather/weather-showers-night.svg @@ -0,0 +1 @@ +<svg width="32" height="32" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><linearGradient id="linearGradient1041" x1="7.906" x2="7.906" y1="11" y2="24.527" gradientUnits="userSpaceOnUse"><stop stop-color="#fff" offset="0"/><stop stop-color="#ededed" offset="1"/></linearGradient><linearGradient id="linearGradient1781" x1="14" x2="14" y1="22" y2="28" gradientTransform="translate(-19 -3)" gradientUnits="userSpaceOnUse"><stop stop-color="#648df6" offset="0"/><stop stop-color="#52c0ff" offset="1"/></linearGradient><linearGradient id="linearGradient9700" x1="12.336" x2="12.336" y1="6.674" y2="26.137" gradientUnits="userSpaceOnUse"><stop stop-color="#f0f3f4" offset="0"/><stop stop-color="#cbd3d9" offset="1"/></linearGradient><style id="current-color-scheme" type="text/css">.ColorScheme-Text{color:#4d4d4d}.ColorScheme-Background{color:#eff0f1}.ColorScheme-Highlight{color:#3daee9}.ColorScheme-ViewText{color:#31363b}.ColorScheme-ViewBackground{color:#fcfcfc}.ColorScheme-ViewHover{color:#93cee9}.ColorScheme-ViewFocus{color:#3daee9}.ColorScheme-ButtonText{color:#31363b}.ColorScheme-ButtonBackground{color:#eff0f1}.ColorScheme-ButtonHover{color:#93cee9}.ColorScheme-ButtonFocus{color:#3daee9}</style></defs><path d="M16 3A13 13 0 0 0 3 16a13 13 0 0 0 13 13 13 13 0 0 0 13-13 13 13 0 0 0-.014-.521A9 9 0 0 1 24 17a9 9 0 0 1-9-9 9 9 0 0 1 1.522-4.986A13 13 0 0 0 16 3z" fill="url(#linearGradient9700)"/><path d="M28.5 2a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5zM3 3a1 1 0 0 0-1 1 1 1 0 0 0 1 1 1 1 0 0 0 1-1 1 1 0 0 0-1-1zm19 0a3 3 0 0 1-3 3 3 3 0 0 1 3 3 3 3 0 0 1 3-3 3 3 0 0 1-3-3zm4 5a2 2 0 0 1-2 2 2 2 0 0 1 2 2 2 2 0 0 1 2-2 2 2 0 0 1-2-2zm3.5 17a1.5 1.5 0 0 0-1.5 1.5 1.5 1.5 0 0 0 1.5 1.5 1.5 1.5 0 0 0 1.5-1.5 1.5 1.5 0 0 0-1.5-1.5zm-5 4a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5z" fill="#d1d9dd"/><image x="5.668" y="8.636" width="18" height="19" image-rendering="optimizeQuality" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAATCAYAAACdkl3yAAAACXBIWXMAAA7DAAAOwwHHb6hkAAAA GXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAAwFJREFUOI2NlE1rW0cYhc87cz8s W4rkIF9Z1Fmb4DpUkQkkuEXKosU/oD+jP6sbZeu2tBtfWpNAm4vBwcJQSkuQ7frKdmXr837MnC4c l9BCqrMamOFw5p1njuCdSEoYhrpUKslwOGSr1TIiQswpuTN51estOIOkOp6NywtFb3STpv3PHz2a zGumACAMQ+0Mkmpus23X1V/mSf78vhRqIaDnTaQAoN/vq8ymZUvzEMATgXw8g1kO3u3PIwcAVlZW rAM1zsS+BVAi+IcwHU2jaO4ZKQBotVpmAZU4yyWEcr4WuN/D9093d3fNvEZytyCpoyjy43LZ0ZOJ 8a6ukna7bQDMlUoAYI90Fo+Py1mSrFLcohYzIXnuZ9lfW1tb2d3ZTqejsLGhi54nwfW1bTabRkQs ADgk1atu916S4BOt3U+FeGCoYkW1n7j4heSFiNhO58j9aN2vGDtdVZPJ4swpDH8+Pv5zj7xui+RO FEUaXikQZZ5a2h0h1gBeWLEKRvdedLsDkvnh4W+VG06faCPbhK5B8a1J7Y9+t3tAcuDEcaxKNW9J tFoFUYPIsgBKIHURu9TwfHnR7Tp1w7qI+oyKX4Co0qJnIJk2+iSKoqEKgsDmxJjkGYRnBC8Fckbg RFmMDg5SNjxPtHAJsHUAASDLQtRAu5rTLMZxrFSz2TR+qmMReQnItxB8p5R8Q8OXtli5bDQ82d/v SW4xgkgPxBnBC0BORcmJQjYOgsDevtrenuMHwT2xtkalSiSpjChrjLLKvSzk3nni0rdIHgv5DJCA lj0N/ZNxssPtzc3r9zlSURTpS3fNu4/ztRTuc4Dr1vJXC/WDWy31vMlkaTbNazm56Gl3iFEeJ0l8 0263c+cfoG55sJ2jI1lM9IJoPgC4LmAKSYuv37yxX+3sXEVRdB3HsSoGgW0+bRqRjVuO/k3oShxb Va4NQHatMLWwv0NksFko3MFnP/hF3ruiRFFUmBUKVWQoiZWRv1rpN+v16Ye66T+JRIQkp2EYnt61 5bPGw/9ty78BO4inhTsr1+wAAAAASUVORK5CYII="/><path d="M6 21c-.554 0-1 .446-1 1v6c0 .554.446 1 1 1s1-.446 1-1v-6c0-.554-.446-1-1-1zm4 0c-.554 0-1 .446-1 1v7c0 .554.446 1 1 1s1-.446 1-1v-7c0-.554-.446-1-1-1zm4 0c-.554 0-1 .446-1 1v6c0 .554.446 1 1 1s1-.446 1-1v-6c0-.554-.446-1-1-1z" fill="url(#linearGradient1781)"/><path d="M7.906 11A7 7 0 0 0 1 18a7 7 0 0 0 7 7h6a4.997 4.997 0 0 0 .332-9.984A7 7 0 0 0 8 11a7 7 0 0 0-.094 0z" fill="url(#linearGradient1041)"/><image x="3.5" y="20" width="13" height="5" image-rendering="optimizeQuality" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA0AAAAFCAYAAACeuGYRAAAACXBIWXMAAA7DAAAOwwHHb6hkAAAA GXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAANVJREFUGJVdx0tOwlAYBtDvv6+2 tmACBKMSZVzW022whu7BXch6YIw6U/FB26Svez8HzBydHAGAoqDG5qA3yMeyBMsSssfB/D/2ud/t xEvxTD1/qechdjM/tO86nVaoqmsgWgDd53f9dp5OV5m28VK1/dfpMTuZtDnaNr67UeStdlEYfzAw kaWAK0LUJMk75brFGPgQYmfT5ng2k2rN36vB0NOSYvp7iG2cDqAVON3PIK4WE4SWWozCGuppKx1s +8pYffiEIwAgBXx0EQD67HI6Dq6H/wNOomMfKYDv2wAAAABJRU5ErkJggg=="/></svg> \ No newline at end of file diff --git a/src/assets/icons/weather/weather-showers-scattered-day.svg b/src/assets/icons/weather/weather-showers-scattered-day.svg new file mode 100644 index 00000000..07e7c053 --- /dev/null +++ b/src/assets/icons/weather/weather-showers-scattered-day.svg @@ -0,0 +1 @@ +<svg width="32" height="32" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><linearGradient id="linearGradient1025" x1="18.124" x2="18.124" y1="3.336" y2="28.783" gradientUnits="userSpaceOnUse"><stop stop-color="#fff133" offset="0"/><stop stop-color="#ffc40a" offset="1"/></linearGradient><linearGradient id="linearGradient1041" x1="7.906" x2="7.906" y1="11" y2="24.527" gradientUnits="userSpaceOnUse"><stop stop-color="#fff" offset="0"/><stop stop-color="#ededed" offset="1"/></linearGradient><linearGradient id="linearGradient1196" x1="28" x2="28" y1="30" y2="33" gradientTransform="translate(-19 -3)" gradientUnits="userSpaceOnUse"><stop stop-color="#648df6" offset="0"/><stop stop-color="#52c0ff" offset="1"/></linearGradient><style id="current-color-scheme" type="text/css">.ColorScheme-Text{color:#4d4d4d}.ColorScheme-Background{color:#eff0f1}.ColorScheme-Highlight{color:#3daee9}.ColorScheme-ViewText{color:#31363b}.ColorScheme-ViewBackground{color:#fcfcfc}.ColorScheme-ViewHover{color:#93cee9}.ColorScheme-ViewFocus{color:#3daee9}.ColorScheme-ButtonText{color:#31363b}.ColorScheme-ButtonBackground{color:#eff0f1}.ColorScheme-ButtonHover{color:#93cee9}.ColorScheme-ButtonFocus{color:#3daee9}</style></defs><path d="M18 2c-.554 0-1 .446-1 1v1c0 .554.446 1 1 1s1-.446 1-1V3c0-.554-.446-1-1-1zM8.762 5.772a.995.995 0 0 0-.659.292.998.998 0 0 0 0 1.415l.708.707a.998.998 0 0 0 1.414 0 .998.998 0 0 0 0-1.415l-.707-.707a.999.999 0 0 0-.756-.293zm18.385 0a.995.995 0 0 0-.658.292l-.708.708a.998.998 0 0 0 0 1.414.998.998 0 0 0 1.415 0l.707-.707a.998.998 0 0 0 0-1.415.999.999 0 0 0-.756-.293zM18 6A10 10 0 0 0 8 16a10 10 0 0 0 10 10 10 10 0 0 0 10-10A10 10 0 0 0 18 6zM5 15c-.554 0-1 .446-1 1s.446 1 1 1h1c.554 0 1-.446 1-1s-.446-1-1-1zm25 0c-.554 0-1 .446-1 1s.446 1 1 1h1c.554 0 1-.446 1-1s-.446-1-1-1zM9.471 23.45a1 1 0 0 0-.66.292l-.707.707a.998.998 0 0 0 0 1.414.998.998 0 0 0 1.414 0l.707-.707a.998.998 0 0 0 0-1.414.995.995 0 0 0-.754-.293zm16.97 0a.995.995 0 0 0-.659.292.998.998 0 0 0 0 1.414l.707.707c.392.392 1.022.392 1.414 0s.392-1.022 0-1.414l-.707-.707a.999.999 0 0 0-.756-.293zM18 27c-.554 0-1 .446-1 1v1c0 .554.446 1 1 1s1-.446 1-1v-1c0-.554-.446-1-1-1z" fill="url(#linearGradient1025)"/><rect x="9" y="27" width="2" height="3" ry="1" fill="url(#linearGradient1196)"/><path d="M7.906 11A7 7 0 0 0 1 18a7 7 0 0 0 7 7h6a4.997 4.997 0 0 0 .332-9.984A7 7 0 0 0 8 11a7 7 0 0 0-.094 0z" fill="url(#linearGradient1041)"/><image x="1" y="11" width="18" height="14" image-rendering="optimizeQuality" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAAOCAYAAAAi2ky3AAAACXBIWXMAAA7DAAAOwwHHb6hkAAAA GXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAAclJREFUKJF1ks1uEzEUhb9rezIz TSNKS/mRKpRFpS4iIaSoW+AleCBeh8dgwwIWbAJB7aJI/EhIhEI7YTJj38uCtCHt9EhXlnXt48/H FjpkL/E8G5VE3aFOW2Rxzu/yk4wmTdd6AFkzMAQQ3o8Cm2d9tLiHpAd4vwUmpPBahh++dRm5NQrG gZNhD773qKSApsBSj7b1RO1hzVM72t+70cgMx+Ew492PPrEeQH+TYCUtOerCJbmaIO2hHe3nnVez t+OMW79KtB7gpFi2coTbOLZJlJjzq10accyIOpWDz18A3DKX67qINV2MaTlJYBZIdheRJzbde7Qi Mhwnwx6nFGzWGb50xNSn1V0yu4OygTiP3nCos1cXYRtvTloe71SE4ow/5xWZmxOsJlmLia4RXa1o Bzc8P4H0sE+WdjG9T5RtJOWId3RJtF1riGAIxoSELRoa5iTmoAvERVKybrL//tGlGRjPUTYGkdZq aCpIFUkbVBNg13yizbpRXyBwDM25YmEBVkGscFaDrshWNfVXPZY5eYrdHPMbKDkuCSpKQjA8og7k X56WJjKeHYdOogHCeZ0TpI/5koUowSqcQNKAc6Cc4fgo459fAf4CFTrscPT4omMAAAAASUVORK5C YII="/></svg> \ No newline at end of file diff --git a/src/assets/icons/weather/weather-showers-scattered-night.svg b/src/assets/icons/weather/weather-showers-scattered-night.svg new file mode 100644 index 00000000..9a03c614 --- /dev/null +++ b/src/assets/icons/weather/weather-showers-scattered-night.svg @@ -0,0 +1 @@ +<svg width="32" height="32" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><linearGradient id="linearGradient1196" x1="28" x2="28" y1="30" y2="33" gradientTransform="translate(-20 -3)" gradientUnits="userSpaceOnUse"><stop stop-color="#648df6" offset="0"/><stop stop-color="#52c0ff" offset="1"/></linearGradient><linearGradient id="linearGradient9700" x1="12.336" x2="12.336" y1="6.674" y2="26.137" gradientUnits="userSpaceOnUse"><stop stop-color="#f0f3f4" offset="0"/><stop stop-color="#cbd3d9" offset="1"/></linearGradient><linearGradient id="linearGradient1724" x1="7.906" x2="7.906" y1="11" y2="24.527" gradientTransform="translate(-.906)" gradientUnits="userSpaceOnUse"><stop stop-color="#9aafb2" offset="0"/><stop stop-color="#778b92" offset="1"/></linearGradient><style id="current-color-scheme" type="text/css">.ColorScheme-Text{color:#4d4d4d}.ColorScheme-Background{color:#eff0f1}.ColorScheme-Highlight{color:#3daee9}.ColorScheme-ViewText{color:#31363b}.ColorScheme-ViewBackground{color:#fcfcfc}.ColorScheme-ViewHover{color:#93cee9}.ColorScheme-ViewFocus{color:#3daee9}.ColorScheme-ButtonText{color:#31363b}.ColorScheme-ButtonBackground{color:#eff0f1}.ColorScheme-ButtonHover{color:#93cee9}.ColorScheme-ButtonFocus{color:#3daee9}</style></defs><path d="M16 3A13 13 0 0 0 3 16a13 13 0 0 0 13 13 13 13 0 0 0 13-13 13 13 0 0 0-.014-.521A9 9 0 0 1 24 17a9 9 0 0 1-9-9 9 9 0 0 1 1.522-4.986A13 13 0 0 0 16 3z" fill="url(#linearGradient9700)"/><path d="M28.5 2a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5zM3 3a1 1 0 0 0-1 1 1 1 0 0 0 1 1 1 1 0 0 0 1-1 1 1 0 0 0-1-1zm19 0a3 3 0 0 1-3 3 3 3 0 0 1 3 3 3 3 0 0 1 3-3 3 3 0 0 1-3-3zm4 5a2 2 0 0 1-2 2 2 2 0 0 1 2 2 2 2 0 0 1 2-2 2 2 0 0 1-2-2zm3.5 17a1.5 1.5 0 0 0-1.5 1.5 1.5 1.5 0 0 0 1.5 1.5 1.5 1.5 0 0 0 1.5-1.5 1.5 1.5 0 0 0-1.5-1.5zm-5 4a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5z" fill="#d1d9dd"/><image x="5.668" y="8.636" width="18" height="19" image-rendering="optimizeQuality" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAATCAYAAACdkl3yAAAACXBIWXMAAA7DAAAOwwHHb6hkAAAA GXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAAwFJREFUOI2NlE1rW0cYhc87cz8s W4rkIF9Z1Fmb4DpUkQkkuEXKosU/oD+jP6sbZeu2tBtfWpNAm4vBwcJQSkuQ7frKdmXr837MnC4c l9BCqrMamOFw5p1njuCdSEoYhrpUKslwOGSr1TIiQswpuTN51estOIOkOp6NywtFb3STpv3PHz2a zGumACAMQ+0Mkmpus23X1V/mSf78vhRqIaDnTaQAoN/vq8ymZUvzEMATgXw8g1kO3u3PIwcAVlZW rAM1zsS+BVAi+IcwHU2jaO4ZKQBotVpmAZU4yyWEcr4WuN/D9093d3fNvEZytyCpoyjy43LZ0ZOJ 8a6ukna7bQDMlUoAYI90Fo+Py1mSrFLcohYzIXnuZ9lfW1tb2d3ZTqejsLGhi54nwfW1bTabRkQs ADgk1atu916S4BOt3U+FeGCoYkW1n7j4heSFiNhO58j9aN2vGDtdVZPJ4swpDH8+Pv5zj7xui+RO FEUaXikQZZ5a2h0h1gBeWLEKRvdedLsDkvnh4W+VG06faCPbhK5B8a1J7Y9+t3tAcuDEcaxKNW9J tFoFUYPIsgBKIHURu9TwfHnR7Tp1w7qI+oyKX4Co0qJnIJk2+iSKoqEKgsDmxJjkGYRnBC8Fckbg RFmMDg5SNjxPtHAJsHUAASDLQtRAu5rTLMZxrFSz2TR+qmMReQnItxB8p5R8Q8OXtli5bDQ82d/v SW4xgkgPxBnBC0BORcmJQjYOgsDevtrenuMHwT2xtkalSiSpjChrjLLKvSzk3nni0rdIHgv5DJCA lj0N/ZNxssPtzc3r9zlSURTpS3fNu4/ztRTuc4Dr1vJXC/WDWy31vMlkaTbNazm56Gl3iFEeJ0l8 0263c+cfoG55sJ2jI1lM9IJoPgC4LmAKSYuv37yxX+3sXEVRdB3HsSoGgW0+bRqRjVuO/k3oShxb Va4NQHatMLWwv0NksFko3MFnP/hF3ruiRFFUmBUKVWQoiZWRv1rpN+v16Ye66T+JRIQkp2EYnt61 5bPGw/9ty78BO4inhTsr1+wAAAAASUVORK5CYII="/><rect x="8" y="27" width="2" height="3" ry="1" fill="url(#linearGradient1196)"/><path d="M7 11a7 7 0 0 0-6.906 7 7 7 0 0 0 7 7h6a4.997 4.997 0 0 0 .332-9.984A7 7 0 0 0 7.094 11 7 7 0 0 0 7 11z" fill="url(#linearGradient1724)"/><image y="11" width="18" height="14" image-rendering="optimizeQuality" opacity=".5" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAAOCAYAAAAi2ky3AAAACXBIWXMAAA7DAAAOwwHHb6hkAAAA GXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAAc1JREFUKJGNUr1uE2EQnPnuzsbG sQQFFnQGp4CCnwqJBp6AB4CCV+AN4GWoeAoqGlPasmSFCF1CkEOKKMmd7ft2h8axHSW2mNVK+0mf RrMzS6xBUv3gz8mzJOVTSPekEEGdQBzNi9Mf3W53ig3g5TAaHe80dvxlkia7gbgjMQOcq58sU9nX Tqfz8yaiAAD9fj9rtXA/TULTzGvRPQAOd6za1JghvM8nk92NRK1W65YlMZNUI5hAZIxOd8d6K3pi prd5njeuEX2WQpZlIZDB3ZOoGMzjNZI1sp3I7OP+wdGHvXylLnxaDPMKMACwxSrbSlZ3eRewd/v5 0RsASAGgarc9KQona2aQU5S0KZ91OB14PdzLD1MAmJl5LcYKTOcSo2QiKfe11DaDSeCrFIDaZWkl OZNQmPssAGbyBO7/IwsAHgSSKorCa94sK/czyi7MYpTkAHTlBDY0fBH/YDAw6WyaIp5Gw6kLhcwt +lbLl2W0w6UH47HqVfbrrpWxizQ8hKMjoUkyOLZ75YYv4fLR62EeprfPDfxbzf3EpAtJMS5v6Oa1 JH178eTROF1aT0rS+XA4+V1aqAdmbSdagjIiAFdVzSQeEvz+/HFvDAD/AI6BdJRNyF/iAAAAAElF TkSuQmCC"/></svg> \ No newline at end of file diff --git a/src/assets/icons/weather/weather-showers-scattered.svg b/src/assets/icons/weather/weather-showers-scattered.svg new file mode 100644 index 00000000..66e03d9d --- /dev/null +++ b/src/assets/icons/weather/weather-showers-scattered.svg @@ -0,0 +1 @@ +<svg width="32" height="32" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="linearGradient865" x1="14.309" x2="14.309" y1="4.555" y2="23.488" gradientUnits="userSpaceOnUse"><stop stop-color="#fff" offset="0"/><stop stop-color="#ececec" offset="1"/></linearGradient><linearGradient id="linearGradient965" x1="16.814" x2="16.814" y1="25.94" y2="30.153" gradientTransform="translate(13.851 -.092)" gradientUnits="userSpaceOnUse"><stop stop-color="#648df6" offset="0"/><stop stop-color="#52c0ff" offset="1"/></linearGradient><style id="current-color-scheme" type="text/css">.ColorScheme-Text{color:#4d4d4d}.ColorScheme-Background{color:#eff0f1}.ColorScheme-Highlight{color:#3daee9}.ColorScheme-ViewText{color:#31363b}.ColorScheme-ViewBackground{color:#fcfcfc}.ColorScheme-ViewHover{color:#93cee9}.ColorScheme-ViewFocus{color:#3daee9}.ColorScheme-ButtonText{color:#31363b}.ColorScheme-ButtonBackground{color:#eff0f1}.ColorScheme-ButtonHover{color:#93cee9}.ColorScheme-ButtonFocus{color:#3daee9}</style></defs><rect x="16" y="26" width="2" height="4" ry="1" fill="url(#linearGradient965)"/><path d="M13 4A10 10 0 0 0 3 14a10 10 0 0 0 10 10h9a7 7 0 0 0 7-7 7 7 0 0 0-6.836-6.996A10 10 0 0 0 13 4z" fill="url(#linearGradient865)"/></svg> \ No newline at end of file diff --git a/src/assets/icons/weather/weather-showers.svg b/src/assets/icons/weather/weather-showers.svg new file mode 100644 index 00000000..b7cb07a3 --- /dev/null +++ b/src/assets/icons/weather/weather-showers.svg @@ -0,0 +1 @@ +<svg width="32" height="32" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><linearGradient id="linearGradient865" x1="14.309" x2="14.309" y1="4.555" y2="23.488" gradientUnits="userSpaceOnUse"><stop stop-color="#fff" offset="0"/><stop stop-color="#ececec" offset="1"/></linearGradient><linearGradient id="linearGradient1072" x1="32.01" x2="32.01" y1="27.247" y2="32.089" gradientTransform="translate(-19 -3)" gradientUnits="userSpaceOnUse"><stop stop-color="#648df6" offset="0"/><stop stop-color="#52c0ff" offset="1"/></linearGradient><style id="current-color-scheme" type="text/css">.ColorScheme-Text{color:#4d4d4d}.ColorScheme-Background{color:#eff0f1}.ColorScheme-Highlight{color:#3daee9}.ColorScheme-ViewText{color:#31363b}.ColorScheme-ViewBackground{color:#fcfcfc}.ColorScheme-ViewHover{color:#93cee9}.ColorScheme-ViewFocus{color:#3daee9}.ColorScheme-ButtonText{color:#31363b}.ColorScheme-ButtonBackground{color:#eff0f1}.ColorScheme-ButtonHover{color:#93cee9}.ColorScheme-ButtonFocus{color:#3daee9}</style></defs><path d="M13 20c-.554 0-1 .446-1 1v7c0 .554.446 1 1 1s1-.446 1-1v-7c0-.554-.446-1-1-1zm4 0c-.554 0-1 .446-1 1v8c0 .554.446 1 1 1s1-.446 1-1v-8c0-.554-.446-1-1-1zm4 0c-.554 0-1 .446-1 1v7c0 .554.446 1 1 1s1-.446 1-1v-7c0-.554-.446-1-1-1z" fill="url(#linearGradient1072)"/><path d="M13 4A10 10 0 0 0 3 14a10 10 0 0 0 10 10h9a7 7 0 0 0 7-7 7 7 0 0 0-6.836-6.996A10 10 0 0 0 13 4z" fill="url(#linearGradient865)"/><image x="10.5" y="19" width="13" height="5" image-rendering="optimizeQuality" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA0AAAAFCAYAAACeuGYRAAAACXBIWXMAAA7DAAAOwwHHb6hkAAAA GXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAAMlJREFUGJV1x71OAkEUhuHvnJkz Iy5LyJooJi5QWEnvndh7KXM93o9UFBppMWQXgu78HAuNVr7V+xAAhKD8jLVd4S6FAA0B9L+p0MOT mstNPz1WrjH5c9d128PItTVXrjHZ77pufZhMbsbZ+ItyHN5Pt/Xe3K8efRpVrZC2hV1qzq8/CDRj wpxLydPx7ETAFWmaG3HFbt86xmIJo2SRrVCM0uc9DxwFGTLwj2MUZCtIUbBYgut+o9lrKgKFw2/Z /z3ct/lMFK8v+AI04F6oiTGVDQAAAABJRU5ErkJggg=="/></svg> \ No newline at end of file diff --git a/src/assets/icons/weather/weather-snow-night.svg b/src/assets/icons/weather/weather-snow-night.svg new file mode 100644 index 00000000..d577ca24 --- /dev/null +++ b/src/assets/icons/weather/weather-snow-night.svg @@ -0,0 +1,17 @@ +<svg width="32" height="32" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> + <defs> + <linearGradient id="linearGradient9002" x1="16" x2="16" y1="24" y2="30" gradientUnits="userSpaceOnUse"> + <stop stop-color="#dce1e5" offset="0"/> + <stop stop-color="#b8c8cc" offset="1"/> + </linearGradient> + <linearGradient id="linearGradient865" x1="14.309" x2="14.309" y1="4.555" y2="23.488" gradientUnits="userSpaceOnUse"> + <stop stop-color="#fff" offset="0"/> + <stop stop-color="#ececec" offset="1"/> + </linearGradient> + <style id="current-color-scheme" type="text/css">.ColorScheme-Text{color:#4d4d4d}.ColorScheme-Background{color:#eff0f1}.ColorScheme-Highlight{color:#3daee9}.ColorScheme-ViewText{color:#31363b}.ColorScheme-ViewBackground{color:#fcfcfc}.ColorScheme-ViewHover{color:#93cee9}.ColorScheme-ViewFocus{color:#3daee9}.ColorScheme-ButtonText{color:#31363b}.ColorScheme-ButtonBackground{color:#eff0f1}.ColorScheme-ButtonHover{color:#93cee9}.ColorScheme-ButtonFocus{color:#3daee9}</style> + </defs> + <path d="M13 4A10 10 0 0 0 3 14a10 10 0 0 0 10 10h9a7 7 0 0 0 7-7 7 7 0 0 0-6.836-6.996A10 10 0 0 0 13 4z" fill="url(#linearGradient865)"/> + <path d="M11.5 24c-.831 0-1.5.669-1.5 1.5s.669 1.5 1.5 1.5 1.5-.669 1.5-1.5-.669-1.5-1.5-1.5zm10 0c-.831 0-1.5.669-1.5 1.5s.669 1.5 1.5 1.5 1.5-.669 1.5-1.5-.669-1.5-1.5-1.5zm-14 3c-.831 0-1.5.669-1.5 1.5S6.669 30 7.5 30 9 29.331 9 28.5 8.331 27 7.5 27zm9 0c-.831 0-1.5.669-1.5 1.5s.669 1.5 1.5 1.5 1.5-.669 1.5-1.5-.669-1.5-1.5-1.5zm9 0c-.831 0-1.5.669-1.5 1.5s.669 1.5 1.5 1.5 1.5-.669 1.5-1.5-.669-1.5-1.5-1.5z" fill="url(#linearGradient9002)"/> + <image x="8" y="13" width="17" height="11" image-rendering="optimizeQuality" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABEAAAALCAYAAACZIGYHAAAACXBIWXMAAA7DAAAOwwHHb6hkAAAA GXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAAalJREFUKJGVkb9qFFEYxc93751J xtmYJUGS4L8UA3YhQogIsjBYaGFtbZFCfIo8hF3eYF/ARhgsRNEZicQpNoEVxhQyskKyu2bcuXOP lRDjbpFTH3585/cJLpkkSYy3thYstNuut7JSPRVp5DKANE09HYbXQW+zgasB/1P/y4dSAICk7vV6 V7TWchxFv2IRexFAUg6Kom2ryRNQngnw2zp56dfhG9XtdvXnw8PVMzEPRw0etQ6ObqVp6k27RI+U kDQAPBKeEqeNGYjqdDrzjZO7JJ4D8kIb9SAIggWS/0wVEeb56ciyeQvhnhLuNZ7sl2VZmcFgIFAe BCIAxQkEPOdgeTkEgM319RGAOsuyr8b3f0zC0I2LYhzHsZXdJDGPl26szgdq21n6zuNHRFGBLAOv XrtpnL0Pgkr0u407t7/JFF9qN47t2c/j75W4125OXuHkpNgCrNY61Ky3Qe4A3HFwW1m/HwL476MG AOI4tgBOzyto0pSKygKsAVDEWetI4O/YC5Ap4XA4HC8tLr5vGkyUUq4S7t+LovGM/uyQ1HletvI8 b5HUs3p/APwmx/3KS2fDAAAAAElFTkSuQmCC"/> + <path d="m29.333 1.3336c-0.27614 0-0.5 0.22386-0.5 0.5s0.22386 0.5 0.5 0.5 0.5-0.22386 0.5-0.5-0.22386-0.5-0.5-0.5zm-25.5 1c-0.55228 0-1 0.44772-1 1s0.44772 1 1 1 1-0.44772 1-1-0.44772-1-1-1zm19 0c0 1.6569-1.3431 3-3 3 1.6569 0 3 1.3431 3 3 0-1.6569 1.3431-3 3-3-1.6569 0-3-1.3431-3-3zm4 5c0 1.1046-0.89543 2-2 2 1.1046 0 2 0.89543 2 2 0-1.1046 0.89543-2 2-2-1.1046 0-2-0.89543-2-2z" fill="#d1d9dd"/> +</svg> diff --git a/src/assets/icons/weather/weather-snow-rain.svg b/src/assets/icons/weather/weather-snow-rain.svg new file mode 100644 index 00000000..518c9cdd --- /dev/null +++ b/src/assets/icons/weather/weather-snow-rain.svg @@ -0,0 +1 @@ +<svg width="32" height="32" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><linearGradient id="linearGradient9002" x1="16" x2="16" y1="24" y2="30" gradientTransform="translate(0 -2)" gradientUnits="userSpaceOnUse"><stop stop-color="#dce1e5" offset="0"/><stop stop-color="#b8c8cc" offset="1"/></linearGradient><linearGradient id="linearGradient865" x1="14.309" x2="14.309" y1="4.555" y2="23.488" gradientUnits="userSpaceOnUse"><stop stop-color="#fff" offset="0"/><stop stop-color="#ececec" offset="1"/></linearGradient><linearGradient id="linearGradient1203" x1="28" x2="28" y1="21" y2="25" gradientTransform="translate(-11 5)" gradientUnits="userSpaceOnUse"><stop stop-color="#648df6" offset="0"/><stop stop-color="#52c0ff" offset="1"/></linearGradient><style id="current-color-scheme" type="text/css">.ColorScheme-Text{color:#4d4d4d}.ColorScheme-Background{color:#eff0f1}.ColorScheme-Highlight{color:#3daee9}.ColorScheme-ViewText{color:#31363b}.ColorScheme-ViewBackground{color:#fcfcfc}.ColorScheme-ViewHover{color:#93cee9}.ColorScheme-ViewFocus{color:#3daee9}.ColorScheme-ButtonText{color:#31363b}.ColorScheme-ButtonBackground{color:#eff0f1}.ColorScheme-ButtonHover{color:#93cee9}.ColorScheme-ButtonFocus{color:#3daee9}</style></defs><path d="M11.5 22c-.831 0-1.5.669-1.5 1.5s.669 1.5 1.5 1.5 1.5-.669 1.5-1.5-.669-1.5-1.5-1.5zm10 0c-.831 0-1.5.669-1.5 1.5s.669 1.5 1.5 1.5 1.5-.669 1.5-1.5-.669-1.5-1.5-1.5zm-14 3c-.831 0-1.5.669-1.5 1.5S6.669 28 7.5 28 9 27.331 9 26.5 8.331 25 7.5 25zm18 0c-.831 0-1.5.669-1.5 1.5s.669 1.5 1.5 1.5 1.5-.669 1.5-1.5-.669-1.5-1.5-1.5z" fill="url(#linearGradient9002)"/><path d="M13 26c-.554 0-1 .446-1 1v1c0 .554.446 1 1 1s1-.446 1-1v-1c0-.554-.446-1-1-1zm4 0c-.554 0-1 .446-1 1v2c0 .554.446 1 1 1s1-.446 1-1v-2c0-.554-.446-1-1-1zm4 0c-.554 0-1 .446-1 1v1c0 .554.446 1 1 1s1-.446 1-1v-1c0-.554-.446-1-1-1z" fill="url(#linearGradient1203)"/><path d="M13 4A10 10 0 0 0 3 14a10 10 0 0 0 10 10h9a7 7 0 0 0 7-7 7 7 0 0 0-6.836-6.996A10 10 0 0 0 13 4z" fill="url(#linearGradient865)"/><image x="8" y="12" width="17" height="11" image-rendering="optimizeQuality" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABEAAAALCAYAAACZIGYHAAAACXBIWXMAAA7DAAAOwwHHb6hkAAAA GXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAAalJREFUKJGVkb9qFFEYxc93751J xtmYJUGS4L8UA3YhQogIsjBYaGFtbZFCfIo8hF3eYF/ARhgsRNEZicQpNoEVxhQyskKyu2bcuXOP lRDjbpFTH3585/cJLpkkSYy3thYstNuut7JSPRVp5DKANE09HYbXQW+zgasB/1P/y4dSAICk7vV6 V7TWchxFv2IRexFAUg6Kom2ryRNQngnw2zp56dfhG9XtdvXnw8PVMzEPRw0etQ6ObqVp6k27RI+U kDQAPBKeEqeNGYjqdDrzjZO7JJ4D8kIb9SAIggWS/0wVEeb56ciyeQvhnhLuNZ7sl2VZmcFgIFAe BCIAxQkEPOdgeTkEgM319RGAOsuyr8b3f0zC0I2LYhzHsZXdJDGPl26szgdq21n6zuNHRFGBLAOv XrtpnL0Pgkr0u407t7/JFF9qN47t2c/j75W4125OXuHkpNgCrNY61Ky3Qe4A3HFwW1m/HwL476MG AOI4tgBOzyto0pSKygKsAVDEWetI4O/YC5Ap4XA4HC8tLr5vGkyUUq4S7t+LovGM/uyQ1HletvI8 b5HUs3p/APwmx/3KS2fDAAAAAElFTkSuQmCC"/><image x="4.5" y="21" width="24" height="3" image-rendering="optimizeQuality" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAADCAYAAACJZs+gAAAACXBIWXMAAA7DAAAOwwHHb6hkAAAA GXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAAK1JREFUGJVlzTsLglAYxvHnPZpD SRRio4NLF6Hp0NxHCCo/pNE3OVMURGCgtDkZTl7O29Di5Zl/D3/CYEyKYVppagNAmWWFlLIaOpBS yrRc9+88r5CEGiDuoP4rih7WVk5XRDhqaDYFLnmWvfoRpdRoPFssyeCTgCBmXG/q+wzDoGw7sx84 nzf6Hic2aay1gFNVnMx9/wMgbzvDcSZNrXdU86EBA1rHwd59A+gEfiiQQox2XhCoAAAAAElFTkSu QmCC"/></svg> \ No newline at end of file diff --git a/src/assets/icons/weather/weather-snow-scattered-day.svg b/src/assets/icons/weather/weather-snow-scattered-day.svg new file mode 100644 index 00000000..fba0379f --- /dev/null +++ b/src/assets/icons/weather/weather-snow-scattered-day.svg @@ -0,0 +1 @@ +<svg width="32" height="32" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><linearGradient id="linearGradient1041" x1="7.906" x2="7.906" y1="11" y2="24.527" gradientTransform="translate(-1 -1)" gradientUnits="userSpaceOnUse"><stop stop-color="#fff" offset="0"/><stop stop-color="#ededed" offset="1"/></linearGradient><linearGradient id="linearGradient1025" x1="18.124" x2="18.124" y1="3.336" y2="28.783" gradientTransform="translate(-1 -1)" gradientUnits="userSpaceOnUse"><stop stop-color="#fff133" offset="0"/><stop stop-color="#ffc40a" offset="1"/></linearGradient><linearGradient id="linearGradient5533" x1="8" x2="8" y1="25" y2="30" gradientUnits="userSpaceOnUse"><stop stop-color="#dce1e5" offset="0"/><stop stop-color="#b8c8cc" offset="1"/></linearGradient><style id="current-color-scheme" type="text/css">.ColorScheme-Text{color:#4d4d4d}.ColorScheme-Background{color:#eff0f1}.ColorScheme-Highlight{color:#3daee9}.ColorScheme-ViewText{color:#31363b}.ColorScheme-ViewBackground{color:#fcfcfc}.ColorScheme-ViewHover{color:#93cee9}.ColorScheme-ViewFocus{color:#3daee9}.ColorScheme-ButtonText{color:#31363b}.ColorScheme-ButtonBackground{color:#eff0f1}.ColorScheme-ButtonHover{color:#93cee9}.ColorScheme-ButtonFocus{color:#3daee9}</style></defs><path d="M4.5 25c-.831 0-1.5.669-1.5 1.5S3.669 28 4.5 28 6 27.331 6 26.5 5.331 25 4.5 25zm8 0c-.831 0-1.5.669-1.5 1.5s.669 1.5 1.5 1.5 1.5-.669 1.5-1.5-.669-1.5-1.5-1.5zm-4 2c-.831 0-1.5.669-1.5 1.5S7.669 30 8.5 30s1.5-.669 1.5-1.5S9.331 27 8.5 27z" fill="url(#linearGradient5533)"/><path d="M17 1c-.554 0-1 .446-1 1v1c0 .554.446 1 1 1s1-.446 1-1V2c0-.554-.446-1-1-1zM7.762 4.772a.995.995 0 0 0-.659.292.998.998 0 0 0 0 1.415l.708.707a.998.998 0 0 0 1.414 0 .998.998 0 0 0 0-1.415l-.707-.707a.999.999 0 0 0-.756-.293zm18.385 0a.995.995 0 0 0-.658.292l-.708.708a.998.998 0 0 0 0 1.414.998.998 0 0 0 1.415 0l.707-.707a.998.998 0 0 0 0-1.415.999.999 0 0 0-.756-.293zM17 5A10 10 0 0 0 7 15a10 10 0 0 0 10 10 10 10 0 0 0 10-10A10 10 0 0 0 17 5zM4 14c-.554 0-1 .446-1 1s.446 1 1 1h1c.554 0 1-.446 1-1s-.446-1-1-1zm25 0c-.554 0-1 .446-1 1s.446 1 1 1h1c.554 0 1-.446 1-1s-.446-1-1-1zM8.471 22.45a1 1 0 0 0-.66.292l-.707.707a.998.998 0 0 0 0 1.414.998.998 0 0 0 1.414 0l.707-.707a.998.998 0 0 0 0-1.414.995.995 0 0 0-.754-.293zm16.97 0a.995.995 0 0 0-.659.292.998.998 0 0 0 0 1.414l.707.707c.392.392 1.022.392 1.414 0s.392-1.022 0-1.414l-.707-.707a.999.999 0 0 0-.756-.293zM17 26c-.554 0-1 .446-1 1v1c0 .554.446 1 1 1s1-.446 1-1v-1c0-.554-.446-1-1-1z" fill="url(#linearGradient1025)"/><path d="M6.906 10A7 7 0 0 0 0 17a7 7 0 0 0 7 7h6a4.997 4.997 0 0 0 .332-9.984A7 7 0 0 0 7 10a7 7 0 0 0-.094 0z" fill="url(#linearGradient1041)"/><image y="10" width="18" height="14" image-rendering="optimizeQuality" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAAOCAYAAAAi2ky3AAAACXBIWXMAAA7DAAAOwwHHb6hkAAAA GXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAAdJJREFUKJF1ksFqFEEQhr/qntmZ 2d1gTExUCLKHQA6BEAi5qi/h+/g8PoYXDyp4iQaCsIKiKC5qsuvsTFeVl93ETSY/NE1T3V/9/XcL HfIXRJ7uVyTbpNZ18jTjT/VJ9k+arv0AsgJwBBDe72cMzwdYeR/Rh8S4Di5o9kpGH752gcKKC44y xqMefO8xlRKaEtcebRtJ1sObJ362u3MryJ3A8Sjn3c8BqV6DwZDMK1oKLGSXzs0FaY/9bLfovJq/ Ocq587vC6jWClItSgXCXwAZKhYd4dcoSgQnJTmXv8xeAsMjlppax6nLWxULBPUN9G5HHfrpzcOXI CYxHPX5RMqxzYhVIOqC1LXK/h9FHQsRuaRr85TJs5/W45XBzSlae8/diSh5mZF6j3uJiK46uj+R7 tzw/GfpoQK5buD0gyQaiBRIDXRJrVwoiOIJzguLzhoYZygxsjoSEqnc7++8fXcLAeYbRX0u0XkMz BZ2i1oAlwG9wkk/idRDAcyFwMImcS44XOW4Rs4ARCCK4C2KgLriD+9sboEVOkXKroMwq0rxHEIeg iBiOgIE5uAu5ncjh5GPWGd42gYu6QGVIkec0bUtfatpshlHjWhHDHI1jOfrxDeAfF83qDB0lEVYA AAAASUVORK5CYII="/></svg> \ No newline at end of file diff --git a/src/assets/icons/weather/weather-snow-scattered-night.svg b/src/assets/icons/weather/weather-snow-scattered-night.svg new file mode 100644 index 00000000..52c79452 --- /dev/null +++ b/src/assets/icons/weather/weather-snow-scattered-night.svg @@ -0,0 +1 @@ +<svg width="32" height="32" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><linearGradient id="linearGradient1724" x1="7.906" x2="7.906" y1="11" y2="24.527" gradientTransform="translate(-.906)" gradientUnits="userSpaceOnUse"><stop stop-color="#9aafb2" offset="0"/><stop stop-color="#778b92" offset="1"/></linearGradient><linearGradient id="linearGradient9700" x1="12.336" x2="12.336" y1="6.674" y2="26.137" gradientUnits="userSpaceOnUse"><stop stop-color="#f0f3f4" offset="0"/><stop stop-color="#cbd3d9" offset="1"/></linearGradient><linearGradient id="linearGradient5533" x1="8" x2="8" y1="25" y2="30" gradientTransform="translate(0 1)" gradientUnits="userSpaceOnUse"><stop stop-color="#adb7c3" offset="0"/><stop stop-color="#869cac" offset="1"/></linearGradient><style id="current-color-scheme" type="text/css">.ColorScheme-Text{color:#4d4d4d}.ColorScheme-Background{color:#eff0f1}.ColorScheme-Highlight{color:#3daee9}.ColorScheme-ViewText{color:#31363b}.ColorScheme-ViewBackground{color:#fcfcfc}.ColorScheme-ViewHover{color:#93cee9}.ColorScheme-ViewFocus{color:#3daee9}.ColorScheme-ButtonText{color:#31363b}.ColorScheme-ButtonBackground{color:#eff0f1}.ColorScheme-ButtonHover{color:#93cee9}.ColorScheme-ButtonFocus{color:#3daee9}</style></defs><path d="M16 3A13 13 0 0 0 3 16a13 13 0 0 0 13 13 13 13 0 0 0 13-13 13 13 0 0 0-.014-.521A9 9 0 0 1 24 17a9 9 0 0 1-9-9 9 9 0 0 1 1.522-4.986A13 13 0 0 0 16 3z" fill="url(#linearGradient9700)"/><path d="M28.5 2a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5zM3 3a1 1 0 0 0-1 1 1 1 0 0 0 1 1 1 1 0 0 0 1-1 1 1 0 0 0-1-1zm19 0a3 3 0 0 1-3 3 3 3 0 0 1 3 3 3 3 0 0 1 3-3 3 3 0 0 1-3-3zm4 5a2 2 0 0 1-2 2 2 2 0 0 1 2 2 2 2 0 0 1 2-2 2 2 0 0 1-2-2zm3.5 17a1.5 1.5 0 0 0-1.5 1.5 1.5 1.5 0 0 0 1.5 1.5 1.5 1.5 0 0 0 1.5-1.5 1.5 1.5 0 0 0-1.5-1.5zm-5 4a.5.5 0 0 0-.5.5.5.5 0 0 0 .5.5.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5z" fill="#d1d9dd"/><image x="5.668" y="8.636" width="18" height="19" image-rendering="optimizeQuality" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAATCAYAAACdkl3yAAAACXBIWXMAAA7DAAAOwwHHb6hkAAAA GXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAAwFJREFUOI2NlE1rW0cYhc87cz8s W4rkIF9Z1Fmb4DpUkQkkuEXKosU/oD+jP6sbZeu2tBtfWpNAm4vBwcJQSkuQ7frKdmXr837MnC4c l9BCqrMamOFw5p1njuCdSEoYhrpUKslwOGSr1TIiQswpuTN51estOIOkOp6NywtFb3STpv3PHz2a zGumACAMQ+0Mkmpus23X1V/mSf78vhRqIaDnTaQAoN/vq8ymZUvzEMATgXw8g1kO3u3PIwcAVlZW rAM1zsS+BVAi+IcwHU2jaO4ZKQBotVpmAZU4yyWEcr4WuN/D9093d3fNvEZytyCpoyjy43LZ0ZOJ 8a6ukna7bQDMlUoAYI90Fo+Py1mSrFLcohYzIXnuZ9lfW1tb2d3ZTqejsLGhi54nwfW1bTabRkQs ADgk1atu916S4BOt3U+FeGCoYkW1n7j4heSFiNhO58j9aN2vGDtdVZPJ4swpDH8+Pv5zj7xui+RO FEUaXikQZZ5a2h0h1gBeWLEKRvdedLsDkvnh4W+VG06faCPbhK5B8a1J7Y9+t3tAcuDEcaxKNW9J tFoFUYPIsgBKIHURu9TwfHnR7Tp1w7qI+oyKX4Co0qJnIJk2+iSKoqEKgsDmxJjkGYRnBC8Fckbg RFmMDg5SNjxPtHAJsHUAASDLQtRAu5rTLMZxrFSz2TR+qmMReQnItxB8p5R8Q8OXtli5bDQ82d/v SW4xgkgPxBnBC0BORcmJQjYOgsDevtrenuMHwT2xtkalSiSpjChrjLLKvSzk3nni0rdIHgv5DJCA lj0N/ZNxssPtzc3r9zlSURTpS3fNu4/ztRTuc4Dr1vJXC/WDWy31vMlkaTbNazm56Gl3iFEeJ0l8 0263c+cfoG55sJ2jI1lM9IJoPgC4LmAKSYuv37yxX+3sXEVRdB3HsSoGgW0+bRqRjVuO/k3oShxb Va4NQHatMLWwv0NksFko3MFnP/hF3ruiRFFUmBUKVWQoiZWRv1rpN+v16Ye66T+JRIQkp2EYnt61 5bPGw/9ty78BO4inhTsr1+wAAAAASUVORK5CYII="/><path d="M7 11a7 7 0 0 0-6.906 7 7 7 0 0 0 7 7h6a4.997 4.997 0 0 0 .332-9.984A7 7 0 0 0 7.094 11 7 7 0 0 0 7 11z" fill="url(#linearGradient1724)"/><image y="11" width="18" height="14" image-rendering="optimizeQuality" opacity=".5" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAAOCAYAAAAi2ky3AAAACXBIWXMAAA7DAAAOwwHHb6hkAAAA GXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAAc1JREFUKJGNUr1uE2EQnPnuzsbG sQQFFnQGp4CCnwqJBp6AB4CCV+AN4GWoeAoqGlPasmSFCF1CkEOKKMmd7ft2h8axHSW2mNVK+0mf RrMzS6xBUv3gz8mzJOVTSPekEEGdQBzNi9Mf3W53ig3g5TAaHe80dvxlkia7gbgjMQOcq58sU9nX Tqfz8yaiAAD9fj9rtXA/TULTzGvRPQAOd6za1JghvM8nk92NRK1W65YlMZNUI5hAZIxOd8d6K3pi prd5njeuEX2WQpZlIZDB3ZOoGMzjNZI1sp3I7OP+wdGHvXylLnxaDPMKMACwxSrbSlZ3eRewd/v5 0RsASAGgarc9KQona2aQU5S0KZ91OB14PdzLD1MAmJl5LcYKTOcSo2QiKfe11DaDSeCrFIDaZWkl OZNQmPssAGbyBO7/IwsAHgSSKorCa94sK/czyi7MYpTkAHTlBDY0fBH/YDAw6WyaIp5Gw6kLhcwt +lbLl2W0w6UH47HqVfbrrpWxizQ8hKMjoUkyOLZ75YYv4fLR62EeprfPDfxbzf3EpAtJMS5v6Oa1 JH178eTROF1aT0rS+XA4+V1aqAdmbSdagjIiAFdVzSQeEvz+/HFvDAD/AI6BdJRNyF/iAAAAAElF TkSuQmCC"/><path d="M4.5 26c-.831 0-1.5.669-1.5 1.5S3.669 29 4.5 29 6 28.331 6 27.5 5.331 26 4.5 26zm8 0c-.831 0-1.5.669-1.5 1.5s.669 1.5 1.5 1.5 1.5-.669 1.5-1.5-.669-1.5-1.5-1.5zm-4 2c-.831 0-1.5.669-1.5 1.5S7.669 31 8.5 31s1.5-.669 1.5-1.5S9.331 28 8.5 28z" fill="url(#linearGradient5533)"/></svg> \ No newline at end of file diff --git a/src/assets/icons/weather/weather-snow-scattered.svg b/src/assets/icons/weather/weather-snow-scattered.svg new file mode 100644 index 00000000..e88f7124 --- /dev/null +++ b/src/assets/icons/weather/weather-snow-scattered.svg @@ -0,0 +1 @@ +<svg width="32" height="32" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><linearGradient id="linearGradient865" x1="14.309" x2="14.309" y1="4.555" y2="23.488" gradientUnits="userSpaceOnUse"><stop stop-color="#fff" offset="0"/><stop stop-color="#ececec" offset="1"/></linearGradient><linearGradient id="linearGradient9002" x1="17" x2="17" y1="27" y2="32" gradientTransform="translate(0 -2)" gradientUnits="userSpaceOnUse"><stop stop-color="#dce1e5" offset="0"/><stop stop-color="#b8c8cc" offset="1"/></linearGradient><style id="current-color-scheme" type="text/css">.ColorScheme-Text{color:#4d4d4d}.ColorScheme-Background{color:#eff0f1}.ColorScheme-Highlight{color:#3daee9}.ColorScheme-ViewText{color:#31363b}.ColorScheme-ViewBackground{color:#fcfcfc}.ColorScheme-ViewHover{color:#93cee9}.ColorScheme-ViewFocus{color:#3daee9}.ColorScheme-ButtonText{color:#31363b}.ColorScheme-ButtonBackground{color:#eff0f1}.ColorScheme-ButtonHover{color:#93cee9}.ColorScheme-ButtonFocus{color:#3daee9}</style></defs><path d="M11.5 22c-.831 0-1.5.669-1.5 1.5s.669 1.5 1.5 1.5 1.5-.669 1.5-1.5-.669-1.5-1.5-1.5zm10 0c-.831 0-1.5.669-1.5 1.5s.669 1.5 1.5 1.5 1.5-.669 1.5-1.5-.669-1.5-1.5-1.5zM17 27c-.831 0-1.5.669-1.5 1.5S16.169 30 17 30s1.5-.669 1.5-1.5S17.831 27 17 27z" fill="url(#linearGradient9002)"/><path d="M13 4A10 10 0 0 0 3 14a10 10 0 0 0 10 10h9a7 7 0 0 0 7-7 7 7 0 0 0-6.836-6.996A10 10 0 0 0 13 4z" fill="url(#linearGradient865)"/><image x="4.5" y="21" width="24" height="3" image-rendering="optimizeQuality" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAADCAYAAACJZs+gAAAACXBIWXMAAA7DAAAOwwHHb6hkAAAA GXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAAK1JREFUGJVlzTsLglAYxvHnPZpD SRRio4NLF6Hp0NxHCCo/pNE3OVMURGCgtDkZTl7O29Di5Zl/D3/CYEyKYVppagNAmWWFlLIaOpBS yrRc9+88r5CEGiDuoP4rih7WVk5XRDhqaDYFLnmWvfoRpdRoPFssyeCTgCBmXG/q+wzDoGw7sx84 nzf6Hic2aay1gFNVnMx9/wMgbzvDcSZNrXdU86EBA1rHwd59A+gEfiiQQox2XhCoAAAAAElFTkSu QmCC"/></svg> \ No newline at end of file diff --git a/src/assets/icons/weather/weather-snow.svg b/src/assets/icons/weather/weather-snow.svg new file mode 100644 index 00000000..a27de549 --- /dev/null +++ b/src/assets/icons/weather/weather-snow.svg @@ -0,0 +1 @@ +<svg width="32" height="32" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><linearGradient id="linearGradient9002" x1="16" x2="16" y1="24" y2="30" gradientUnits="userSpaceOnUse"><stop stop-color="#dce1e5" offset="0"/><stop stop-color="#b8c8cc" offset="1"/></linearGradient><linearGradient id="linearGradient865" x1="14.309" x2="14.309" y1="4.555" y2="23.488" gradientUnits="userSpaceOnUse"><stop stop-color="#fff" offset="0"/><stop stop-color="#ececec" offset="1"/></linearGradient><style id="current-color-scheme" type="text/css">.ColorScheme-Text{color:#4d4d4d}.ColorScheme-Background{color:#eff0f1}.ColorScheme-Highlight{color:#3daee9}.ColorScheme-ViewText{color:#31363b}.ColorScheme-ViewBackground{color:#fcfcfc}.ColorScheme-ViewHover{color:#93cee9}.ColorScheme-ViewFocus{color:#3daee9}.ColorScheme-ButtonText{color:#31363b}.ColorScheme-ButtonBackground{color:#eff0f1}.ColorScheme-ButtonHover{color:#93cee9}.ColorScheme-ButtonFocus{color:#3daee9}</style></defs><path d="M13 4A10 10 0 0 0 3 14a10 10 0 0 0 10 10h9a7 7 0 0 0 7-7 7 7 0 0 0-6.836-6.996A10 10 0 0 0 13 4z" fill="url(#linearGradient865)"/><path d="M11.5 24c-.831 0-1.5.669-1.5 1.5s.669 1.5 1.5 1.5 1.5-.669 1.5-1.5-.669-1.5-1.5-1.5zm10 0c-.831 0-1.5.669-1.5 1.5s.669 1.5 1.5 1.5 1.5-.669 1.5-1.5-.669-1.5-1.5-1.5zm-14 3c-.831 0-1.5.669-1.5 1.5S6.669 30 7.5 30 9 29.331 9 28.5 8.331 27 7.5 27zm9 0c-.831 0-1.5.669-1.5 1.5s.669 1.5 1.5 1.5 1.5-.669 1.5-1.5-.669-1.5-1.5-1.5zm9 0c-.831 0-1.5.669-1.5 1.5s.669 1.5 1.5 1.5 1.5-.669 1.5-1.5-.669-1.5-1.5-1.5z" fill="url(#linearGradient9002)"/><image x="8" y="13" width="17" height="11" image-rendering="optimizeQuality" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABEAAAALCAYAAACZIGYHAAAACXBIWXMAAA7DAAAOwwHHb6hkAAAA GXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAAalJREFUKJGVkb9qFFEYxc93751J xtmYJUGS4L8UA3YhQogIsjBYaGFtbZFCfIo8hF3eYF/ARhgsRNEZicQpNoEVxhQyskKyu2bcuXOP lRDjbpFTH3585/cJLpkkSYy3thYstNuut7JSPRVp5DKANE09HYbXQW+zgasB/1P/y4dSAICk7vV6 V7TWchxFv2IRexFAUg6Kom2ryRNQngnw2zp56dfhG9XtdvXnw8PVMzEPRw0etQ6ObqVp6k27RI+U kDQAPBKeEqeNGYjqdDrzjZO7JJ4D8kIb9SAIggWS/0wVEeb56ciyeQvhnhLuNZ7sl2VZmcFgIFAe BCIAxQkEPOdgeTkEgM319RGAOsuyr8b3f0zC0I2LYhzHsZXdJDGPl26szgdq21n6zuNHRFGBLAOv XrtpnL0Pgkr0u407t7/JFF9qN47t2c/j75W4125OXuHkpNgCrNY61Ky3Qe4A3HFwW1m/HwL476MG AOI4tgBOzyto0pSKygKsAVDEWetI4O/YC5Ap4XA4HC8tLr5vGkyUUq4S7t+LovGM/uyQ1HletvI8 b5HUs3p/APwmx/3KS2fDAAAAAElFTkSuQmCC"/></svg> \ No newline at end of file diff --git a/src/assets/icons/weather/weather-storm-night.svg b/src/assets/icons/weather/weather-storm-night.svg new file mode 100644 index 00000000..2231272f --- /dev/null +++ b/src/assets/icons/weather/weather-storm-night.svg @@ -0,0 +1,19 @@ +<svg width="32" height="32" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> + <defs> + <linearGradient id="linearGradient865" x1="14.309" x2="14.309" y1="4.555" y2="23.488" gradientUnits="userSpaceOnUse"> + <stop stop-color="#fff" offset="0"/> + <stop stop-color="#ececec" offset="1"/> + </linearGradient> + <linearGradient id="linearGradient1072" x1="32.01" x2="32.01" y1="27.247" y2="32.089" gradientTransform="translate(-19 -3)" gradientUnits="userSpaceOnUse"> + <stop stop-color="#648df6" offset="0"/> + <stop stop-color="#52c0ff" offset="1"/> + </linearGradient> + <style id="current-color-scheme" type="text/css">.ColorScheme-Text{color:#4d4d4d}.ColorScheme-Background{color:#eff0f1}.ColorScheme-Highlight{color:#3daee9}.ColorScheme-ViewText{color:#31363b}.ColorScheme-ViewBackground{color:#fcfcfc}.ColorScheme-ViewHover{color:#93cee9}.ColorScheme-ViewFocus{color:#3daee9}.ColorScheme-ButtonText{color:#31363b}.ColorScheme-ButtonBackground{color:#eff0f1}.ColorScheme-ButtonHover{color:#93cee9}.ColorScheme-ButtonFocus{color:#3daee9}</style> + </defs> + <path d="M13 20c-.554 0-1 .446-1 1v7c0 .554.446 1 1 1s1-.446 1-1v-7c0-.554-.446-1-1-1zm4 0c-.554 0-1 .446-1 1v8c0 .554.446 1 1 1s1-.446 1-1v-8c0-.554-.446-1-1-1zm4 0c-.554 0-1 .446-1 1v7c0 .554.446 1 1 1s1-.446 1-1v-7c0-.554-.446-1-1-1z" fill="url(#linearGradient1072)"/> + <path d="M13 4A10 10 0 0 0 3 14a10 10 0 0 0 10 10h9a7 7 0 0 0 7-7 7 7 0 0 0-6.836-6.996A10 10 0 0 0 13 4z" fill="url(#linearGradient865)"/> + <image x="10.5" y="19" width="13" height="5" image-rendering="optimizeQuality" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA0AAAAFCAYAAACeuGYRAAAACXBIWXMAAA7DAAAOwwHHb6hkAAAA GXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAAMlJREFUGJV1x71OAkEUhuHvnJkz Iy5LyJooJi5QWEnvndh7KXM93o9UFBppMWQXgu78HAuNVr7V+xAAhKD8jLVd4S6FAA0B9L+p0MOT mstNPz1WrjH5c9d128PItTVXrjHZ77pufZhMbsbZ+ItyHN5Pt/Xe3K8efRpVrZC2hV1qzq8/CDRj wpxLydPx7ETAFWmaG3HFbt86xmIJo2SRrVCM0uc9DxwFGTLwj2MUZCtIUbBYgut+o9lrKgKFw2/Z /z3ct/lMFK8v+AI04F6oiTGVDQAAAABJRU5ErkJggg=="/> + <path d="M7.97 13a1 1 0 0 0-.818.469l-4.999 8A1 1 0 0 0 3 22.997h2v5c0 1.004 1.314 1.382 1.847.53l4.999-7.999a1 1 0 0 0-.848-1.531h-2v-5A1 1 0 0 0 7.97 13z" fill="#ffca28" fill-rule="evenodd" style="isolation:auto;mix-blend-mode:normal;text-decoration-color:#000;text-decoration-line:none;text-decoration-style:solid;text-indent:0;text-transform:none;white-space:normal"/> + <image x="2" y="13" width="10" height="14" image-rendering="optimizeQuality" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAOCAYAAAAWo42rAAAACXBIWXMAAA7DAAAOwwHHb6hkAAAA GXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAAQxJREFUKJGFkcFKw2AQhGe2aRLQ VkJBL/Ug1JcQvPkk4uP5Dn0LsaeCSL3pT2PTqmTGSyslVZzj8LE7uwN0ZDtbrVZ36/X6dt/PuuBm s7kGMJaEP8G6rk8lXXUhAIi9lSR5IykA+E+wrutL2yPbLQB3p8Z2Wl/Sue13AB8AWgBOKVUppcp2 TgBYLpcjSRe2xyTPImIgqSDZA0BJbz3b0TRNvyxL2+Y2Y9gOSQSAiHjukeRkMnFRFLLdRkQrySS9 zfoJ4J67iwFkKaUj20OSFYCqbduTPM9nw+HwMQCApEl+LRaLpqqq1yzLXkg+SXoYDAazg6faDtvZ fD4vbR9Pp9OfQvgLzJ1P8rCi//QNqhSnk/LP4fQAAAAASUVORK5CYII="/> + <path d="m29.333 1.3336c-0.27614 0-0.5 0.22386-0.5 0.5s0.22386 0.5 0.5 0.5 0.5-0.22386 0.5-0.5-0.22386-0.5-0.5-0.5zm-25.5 1c-0.55228 0-1 0.44772-1 1s0.44772 1 1 1 1-0.44772 1-1-0.44772-1-1-1zm19 0c0 1.6569-1.3431 3-3 3 1.6569 0 3 1.3431 3 3 0-1.6569 1.3431-3 3-3-1.6569 0-3-1.3431-3-3zm4 5c0 1.1046-0.89543 2-2 2 1.1046 0 2 0.89543 2 2 0-1.1046 0.89543-2 2-2-1.1046 0-2-0.89543-2-2z" fill="#d1d9dd"/> +</svg> diff --git a/src/assets/icons/weather/weather-storm-tornado.svg b/src/assets/icons/weather/weather-storm-tornado.svg new file mode 100644 index 00000000..41b7c6bc --- /dev/null +++ b/src/assets/icons/weather/weather-storm-tornado.svg @@ -0,0 +1,9 @@ +<svg width="32" height="32" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> + <defs> + <linearGradient id="linearGradient5478" x1="6" x2="26" y1="16" y2="16" gradientUnits="userSpaceOnUse"> + <stop stop-color="#f9f9f9" offset="0"/> + <stop stop-color="#b3b3b3" offset="1"/> + </linearGradient> + </defs> + <path d="m7 7a1 1 0 1 0 0 2h18a1 1 0 1 0 0-2zm3 4a1 1 0 1 0 0 2h12a1 1 0 1 0 0-2zm3 4a1 1 0 1 0 0 2h10a1 1 0 1 0 0-2zm1 4a1 1 0 1 0 0 2h5a1 1 0 1 0 0-2zm0 4a1 1 0 1 0 0 2h1a1 1 0 1 0 0-2z" fill="url(#linearGradient5478)" stroke-width="2"/> +</svg> diff --git a/src/assets/icons/weather/weather-storm.svg b/src/assets/icons/weather/weather-storm.svg new file mode 100644 index 00000000..b752ca57 --- /dev/null +++ b/src/assets/icons/weather/weather-storm.svg @@ -0,0 +1 @@ +<svg width="32" height="32" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><linearGradient id="linearGradient865" x1="14.309" x2="14.309" y1="4.555" y2="23.488" gradientUnits="userSpaceOnUse"><stop stop-color="#fff" offset="0"/><stop stop-color="#ececec" offset="1"/></linearGradient><linearGradient id="linearGradient1072" x1="32.01" x2="32.01" y1="27.247" y2="32.089" gradientTransform="translate(-19 -3)" gradientUnits="userSpaceOnUse"><stop stop-color="#648df6" offset="0"/><stop stop-color="#52c0ff" offset="1"/></linearGradient><style id="current-color-scheme" type="text/css">.ColorScheme-Text{color:#4d4d4d}.ColorScheme-Background{color:#eff0f1}.ColorScheme-Highlight{color:#3daee9}.ColorScheme-ViewText{color:#31363b}.ColorScheme-ViewBackground{color:#fcfcfc}.ColorScheme-ViewHover{color:#93cee9}.ColorScheme-ViewFocus{color:#3daee9}.ColorScheme-ButtonText{color:#31363b}.ColorScheme-ButtonBackground{color:#eff0f1}.ColorScheme-ButtonHover{color:#93cee9}.ColorScheme-ButtonFocus{color:#3daee9}</style></defs><path d="M13 20c-.554 0-1 .446-1 1v7c0 .554.446 1 1 1s1-.446 1-1v-7c0-.554-.446-1-1-1zm4 0c-.554 0-1 .446-1 1v8c0 .554.446 1 1 1s1-.446 1-1v-8c0-.554-.446-1-1-1zm4 0c-.554 0-1 .446-1 1v7c0 .554.446 1 1 1s1-.446 1-1v-7c0-.554-.446-1-1-1z" fill="url(#linearGradient1072)"/><path d="M13 4A10 10 0 0 0 3 14a10 10 0 0 0 10 10h9a7 7 0 0 0 7-7 7 7 0 0 0-6.836-6.996A10 10 0 0 0 13 4z" fill="url(#linearGradient865)"/><image x="10.5" y="19" width="13" height="5" image-rendering="optimizeQuality" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA0AAAAFCAYAAACeuGYRAAAACXBIWXMAAA7DAAAOwwHHb6hkAAAA GXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAAMlJREFUGJV1x71OAkEUhuHvnJkz Iy5LyJooJi5QWEnvndh7KXM93o9UFBppMWQXgu78HAuNVr7V+xAAhKD8jLVd4S6FAA0B9L+p0MOT mstNPz1WrjH5c9d128PItTVXrjHZ77pufZhMbsbZ+ItyHN5Pt/Xe3K8efRpVrZC2hV1qzq8/CDRj wpxLydPx7ETAFWmaG3HFbt86xmIJo2SRrVCM0uc9DxwFGTLwj2MUZCtIUbBYgut+o9lrKgKFw2/Z /z3ct/lMFK8v+AI04F6oiTGVDQAAAABJRU5ErkJggg=="/><path d="M7.97 13a1 1 0 0 0-.818.469l-4.999 8A1 1 0 0 0 3 22.997h2v5c0 1.004 1.314 1.382 1.847.53l4.999-7.999a1 1 0 0 0-.848-1.531h-2v-5A1 1 0 0 0 7.97 13z" color="#000" fill="#ffca28" fill-rule="evenodd" style="isolation:auto;mix-blend-mode:normal;text-decoration-color:#000;text-decoration-line:none;text-decoration-style:solid;text-indent:0;text-transform:none;white-space:normal"/><image x="2" y="13" width="10" height="14" image-rendering="optimizeQuality" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAOCAYAAAAWo42rAAAACXBIWXMAAA7DAAAOwwHHb6hkAAAA GXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAAQxJREFUKJGFkcFKw2AQhGe2aRLQ VkJBL/Ug1JcQvPkk4uP5Dn0LsaeCSL3pT2PTqmTGSyslVZzj8LE7uwN0ZDtbrVZ36/X6dt/PuuBm s7kGMJaEP8G6rk8lXXUhAIi9lSR5IykA+E+wrutL2yPbLQB3p8Z2Wl/Sue13AB8AWgBOKVUppcp2 TgBYLpcjSRe2xyTPImIgqSDZA0BJbz3b0TRNvyxL2+Y2Y9gOSQSAiHjukeRkMnFRFLLdRkQrySS9 zfoJ4J67iwFkKaUj20OSFYCqbduTPM9nw+HwMQCApEl+LRaLpqqq1yzLXkg+SXoYDAazg6faDtvZ fD4vbR9Pp9OfQvgLzJ1P8rCi//QNqhSnk/LP4fQAAAAASUVORK5CYII="/></svg> \ No newline at end of file diff --git a/src/assets/icons/weather/weather-windy.svg b/src/assets/icons/weather/weather-windy.svg new file mode 100644 index 00000000..e76d9daf --- /dev/null +++ b/src/assets/icons/weather/weather-windy.svg @@ -0,0 +1,9 @@ +<svg width="32" height="32" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> + <defs> + <linearGradient id="linearGradient6037" x1="-.00038077" x2="32" y1="16" y2="16" gradientUnits="userSpaceOnUse"> + <stop stop-color="#f2f2f2" offset="0"/> + <stop stop-color="#cccccc" offset="1"/> + </linearGradient> + </defs> + <path d="m26.805 5.0043a4.9838 4.9833 0 0 0-3.6318 1.7797 0.99996 0.99985 0 1 0 1.5299 1.2858 2.9899 2.9896 0 0 1 3.6438-0.74989c1.2479 0.62591 1.8919 2.0137 1.5699 3.3715a2.9899 2.9896 0 0 1-2.9179 2.3077h-13.999a0.99996 0.99985 0 1 0 0 1.9997h13.999a0.99996 0.99985 0 0 0 0.19999-0.016 5.0038 5.0033 0 0 0 4.6678-3.8314 5.0078 5.0073 0 0 0-2.6199-5.6192 4.9918 4.9913 0 0 0-2.4419-0.52792zm-9.4116 0.019997a2.9959 2.9956 0 0 0-2.6759 1.0458 1.0001 0.99995 0 1 0 1.5319 1.2858c0.29999-0.35995 0.79596-0.45993 1.2159-0.24996 0.41998 0.20997 0.62997 0.6679 0.52198 1.1238a0.98996 0.98985 0 0 1-0.97196 0.76989 0.99996 0.99985 0 0 0-0.016 0h-13.999a0.99996 0.99985 0 1 0 0 1.9997h13.999a0.99996 0.99985 0 0 0 0.016 0h4e-3a0.99996 0.99985 0 0 0 0.32399-0.053993 2.9839 2.9836 0 0 0 2.5899-2.2537 3.0059 3.0056 0 0 0-2.5399-3.6675zm-16.393 7.9748a0.99996 0.99985 0 1 0 0 1.9997h7.9996a0.99996 0.99985 0 1 0 0-1.9997zm1.9019 4.0034a1.0012 1.0011 0 0 0 0.099996 1.9997h21.999a0.99996 0.99985 0 0 0 0.014 0c0.46998 0 0.86396 0.30995 0.97396 0.76589a0.99196 0.99185 0 0 1-0.52398 1.1238 0.98996 0.98985 0 0 1-1.2159-0.24996 0.99996 0.99985 0 1 0-1.5299 1.2858 2.9979 2.9976 0 0 0 3.6438 0.75389c1.2379-0.62191 1.8899-2.0277 1.5699-3.3755a2.9779 2.9776 0 0 0-2.5859-2.2457 0.99996 0.99985 0 0 0-0.34798-0.05999h-21.999a0.99996 0.99985 0 0 0-0.097996 0zm4.0998 3.9954a0.99996 0.99985 0 1 0 0 1.9997l9.9996 4e-3a0.99996 0.99985 0 0 0 0.014 0c0.46998 0 0.86396 0.30995 0.97396 0.76589a0.99196 0.99185 0 0 1-0.52398 1.1238 0.98996 0.98985 0 0 1-1.2159-0.24996 0.99996 0.99985 0 1 0-1.5299 1.2858 2.9979 2.9976 0 0 0 3.6438 0.75389c1.2379-0.61991 1.8899-2.0277 1.5699-3.3755a2.9779 2.9776 0 0 0-2.5859-2.2457 0.99996 0.99985 0 0 0-0.34798-0.05799z" color="#000000" fill="url(#linearGradient6037)" font-weight="400" overflow="visible" stroke-width="1.9998" style="font-feature-settings:normal;font-variant-alternates:normal;font-variant-caps:normal;font-variant-ligatures:normal;font-variant-numeric:normal;font-variant-position:normal;isolation:auto;mix-blend-mode:normal;shape-padding:0;text-decoration-color:#000000;text-decoration-line:none;text-decoration-style:solid;text-indent:0;text-orientation:mixed;text-transform:none;white-space:normal"/> +</svg> diff --git a/config/assets/icons/wifi/network-wireless-0.svg b/src/assets/icons/wifi/network-wireless-0.svg similarity index 100% rename from config/assets/icons/wifi/network-wireless-0.svg rename to src/assets/icons/wifi/network-wireless-0.svg diff --git a/config/assets/icons/wifi/network-wireless-100.svg b/src/assets/icons/wifi/network-wireless-100.svg similarity index 100% rename from config/assets/icons/wifi/network-wireless-100.svg rename to src/assets/icons/wifi/network-wireless-100.svg diff --git a/config/assets/icons/wifi/network-wireless-20.svg b/src/assets/icons/wifi/network-wireless-20.svg similarity index 100% rename from config/assets/icons/wifi/network-wireless-20.svg rename to src/assets/icons/wifi/network-wireless-20.svg diff --git a/config/assets/icons/wifi/network-wireless-40.svg b/src/assets/icons/wifi/network-wireless-40.svg similarity index 100% rename from config/assets/icons/wifi/network-wireless-40.svg rename to src/assets/icons/wifi/network-wireless-40.svg diff --git a/config/assets/icons/wifi/network-wireless-60.svg b/src/assets/icons/wifi/network-wireless-60.svg similarity index 100% rename from config/assets/icons/wifi/network-wireless-60.svg rename to src/assets/icons/wifi/network-wireless-60.svg diff --git a/config/assets/icons/wifi/network-wireless-80.svg b/src/assets/icons/wifi/network-wireless-80.svg similarity index 100% rename from config/assets/icons/wifi/network-wireless-80.svg rename to src/assets/icons/wifi/network-wireless-80.svg diff --git a/config/assets/icons/wifi/wifi-connecting.svg b/src/assets/icons/wifi/wifi-connecting.svg similarity index 100% rename from config/assets/icons/wifi/wifi-connecting.svg rename to src/assets/icons/wifi/wifi-connecting.svg diff --git a/assets/modus.png b/src/assets/modus.png similarity index 100% rename from assets/modus.png rename to src/assets/modus.png diff --git a/assets/screenshots/home.png b/src/assets/screenshots/home.png similarity index 100% rename from assets/screenshots/home.png rename to src/assets/screenshots/home.png diff --git a/assets/screenshots/lock.png b/src/assets/screenshots/lock.png similarity index 100% rename from assets/screenshots/lock.png rename to src/assets/screenshots/lock.png diff --git a/assets/wallpapers_example/example-1.png b/src/assets/wallpaper_example/example-1.png similarity index 100% rename from assets/wallpapers_example/example-1.png rename to src/assets/wallpaper_example/example-1.png diff --git a/assets/wallpapers_example/example-2.png b/src/assets/wallpaper_example/example-2.png similarity index 100% rename from assets/wallpapers_example/example-2.png rename to src/assets/wallpaper_example/example-2.png diff --git a/assets/wallpapers_example/example-3.jpg b/src/assets/wallpaper_example/example-3.jpg similarity index 100% rename from assets/wallpapers_example/example-3.jpg rename to src/assets/wallpaper_example/example-3.jpg diff --git a/assets/wallpapers_example/example-4.png b/src/assets/wallpaper_example/example-4.png similarity index 100% rename from assets/wallpapers_example/example-4.png rename to src/assets/wallpaper_example/example-4.png diff --git a/assets/wallpapers_example/example-5.png b/src/assets/wallpaper_example/example-5.png similarity index 100% rename from assets/wallpapers_example/example-5.png rename to src/assets/wallpaper_example/example-5.png diff --git a/lock.py b/src/lock.py similarity index 91% rename from lock.py rename to src/lock.py index 86a889c7..f548990b 100644 --- a/lock.py +++ b/src/lock.py @@ -1,36 +1,32 @@ -from widgets.circle_image import CircleImage as Image -from modules.panel.components.indicators import ( +import getpass + +import pam +from fabric import Application +from fabric.utils import Gdk, GLib, get_relative_path, logger, os +from fabric.widgets.box import Box +from fabric.widgets.centerbox import CenterBox +from fabric.widgets.datetime import DateTime +from fabric.widgets.entry import Entry +from fabric.widgets.label import Label +from fabric.widgets.window import Window +from gi.repository import GtkSessionLock # pyright: ignore[reportAttributeAccessIssue] + +from shared.widgets.circle_image import CircleImage as Image +from utils.functions import set_process_name +from window.panel.components.indicators import ( BatteryIndicator, BluetoothIndicator, NetworkIndicator, ) -from gi.repository import ( - Gdk, # pyright: ignore[reportMissingModuleSource] - GLib, - GtkSessionLock, # pyright: ignore[reportAttributeAccessIssue] -) -from fabric.widgets.window import Window -from fabric.widgets.label import Label -from fabric.widgets.entry import Entry -from fabric.widgets.datetime import DateTime -from fabric.widgets.centerbox import CenterBox -from fabric.widgets.box import Box -from fabric.utils import get_relative_path -from fabric import Application -import os -import getpass -import setproctitle -import gi -import pam -gi.require_version("Gdk", "3.0") -gi.require_version("Gtk", "3.0") - - -gi.require_version("GtkSessionLock", "0.1") -# from fabric.widgets.image import Image - -# from widgets.wayland import WaylandWindow as Window +for log in [ + "fabric.audio.service", + "fabric.core.application", + "fabric.bluetooth.service", + "fabric.notifications.service", + "services.network", +]: + logger.disable(log) class IndicatorBox(Box): @@ -216,19 +212,24 @@ def initialize(): lockscreen.show() -if __name__ == "__main__": - setproctitle.setproctitle("lockscreen") +def main(): + set_process_name("lockscreen") initialize() lockscreen = LockScreen(GtkSessionLock.Lock()) + global app app = Application("lock", lockscreen) def set_css(): app.set_stylesheet_from_file( - get_relative_path("main.css"), + get_relative_path("shared/styles/main.css"), ) app.set_css = set_css # pyright: ignore[reportAttributeAccessIssue] app.set_css() # pyright: ignore[reportAttributeAccessIssue] app.run() + + +if __name__ == "__main__": + main() diff --git a/src/main.py b/src/main.py new file mode 100644 index 00000000..12c35459 --- /dev/null +++ b/src/main.py @@ -0,0 +1,88 @@ +import os +from fabric import Application +from fabric.utils import get_relative_path, logger, monitor_file + +from shared.data import APP_NAME, load_config +from utils.functions import set_process_name +from window.desktop.widget import Deskwidgets +from window.dock import Dock +from window.launcher.main import Launcher +from window.notification.notification import ModusNoti +from window.osd.main import OSDWindow +from window.panel.main import Panel +from window.switcher import ApplicationSwitcher + +for log in [ + "fabric", + "services", + "window", + "utils", +]: + logger.disable(log) + + +def main(): + + # Generate colors.css if it doesn't exist + colors_css_path = get_relative_path("shared/styles/colors.css") + if not os.path.exists(colors_css_path): + from utils.utils import generate_colors_from_wallpaper + + default_wallpaper = get_relative_path("assets/wallpaper_example/example-1.png") + if os.path.exists(default_wallpaper): + generate_colors_from_wallpaper(default_wallpaper) + + set_process_name(APP_NAME) + + load_config() + switcher = ApplicationSwitcher() + panel = Panel() + modusnoti = ModusNoti() + launcher = Launcher() + deskwidget = Deskwidgets() + dock = Dock() + osd = OSDWindow() + + # Monitor CSS files for changes + css_file = monitor_file(get_relative_path("shared/styles")) + _ = css_file.connect("changed", lambda *_: set_css()) + + app = Application( + f"{APP_NAME}", + panel, + modusnoti, + deskwidget.top_left, + deskwidget.bottom_left, + osd, + launcher, + switcher, + dock, + ) + + def set_css(): + app.set_stylesheet_from_file( + get_relative_path("shared/styles/main.css"), + ) + + app.set_css = set_css + app.set_css() + + # Inject into the executing module's namespace (__main__) + # to emulate what happened when this file was run directly. + # This allows fabric-cli exec to execute commands flawlessly. + import __main__ + + __main__.app = app + __main__.switcher = switcher + __main__.panel = panel + __main__.modusnoti = modusnoti + __main__.launcher = launcher + __main__.deskwidget = deskwidget + __main__.dock = dock + __main__.osd = osd + + app.run() + + +if __name__ == "__main__": + main() diff --git a/src/services/__init__.py b/src/services/__init__.py new file mode 100644 index 00000000..7d00053c --- /dev/null +++ b/src/services/__init__.py @@ -0,0 +1,10 @@ +from fabric.audio import Audio # noqa: F401 +from .battery import BatteryService # noqa: F401 +from .brightness import Brightness # noqa: F401 +from .capslock import CapsLock # noqa: F401 +from .keyboard_layout import KeyboardLayout # noqa: F401 +from .network import NetworkClient, Wifi, Ethernet, AccessPoint # noqa: F401 +from .mpris import MprisPlayer, MprisPlayerManager # noqa: F401 +from .todo import TodoService # noqa: F401 +from .modus import ModusService # noqa: F401 +from .config import on_config_change, start_config_service, ConfigService # noqa: F401 diff --git a/src/services/battery.py b/src/services/battery.py new file mode 100644 index 00000000..554c8c72 --- /dev/null +++ b/src/services/battery.py @@ -0,0 +1,202 @@ +from typing import Literal, Optional + +from fabric.core.service import Property, Service, Signal +from fabric.utils import Gio, GLib, logger + +from utils.dbus_helper import GioDBusHelper + +DeviceState = { + 0: "UNKNOWN", + 1: "CHARGING", + 2: "DISCHARGING", + 3: "EMPTY", + 4: "FULLY_CHARGED", + 5: "PENDING_CHARGE", + 6: "PENDING_DISCHARGE", +} + +PowerProfile = { + "power-saver": "Power Saver", + "balanced": "Balanced", + "performance": "Performance", +} + + +class BatteryService(Service): + """Service to interact with UPower and Power Profiles via GIO D-Bus""" + + @Signal + def changed(self) -> None: + """Signal emitted when battery changes.""" + + @Signal + def power_profile_changed(self) -> None: + """Signal emitted when power profile changes.""" + + _instance = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + # UPower D-Bus configuration + self.bus_name = "org.freedesktop.UPower" + self.object_path = "/org/freedesktop/UPower/devices/DisplayDevice" + self.interface_name = "org.freedesktop.UPower.Device" + + self.dbus_helper = GioDBusHelper( + bus_type=Gio.BusType.SYSTEM, + bus_name=self.bus_name, + object_path=self.object_path, + interface_name=self.interface_name, + ) + + self.proxy = self.dbus_helper.proxy + + # Listen for PropertiesChanged signals from UPower + self.dbus_helper.listen_signal( + member="PropertiesChanged", + callback=self.handle_property_change, + ) + + # Power Profiles D-Bus configuration + self.power_profile_bus_name = "net.hadess.PowerProfiles" + self.power_profile_object_path = "/net/hadess/PowerProfiles" + self.power_profile_interface_name = "net.hadess.PowerProfiles" + + try: + self.power_profile_helper = GioDBusHelper( + bus_type=Gio.BusType.SYSTEM, + bus_name=self.power_profile_bus_name, + object_path=self.power_profile_object_path, + interface_name=self.power_profile_interface_name, + ) + + self.power_profile_proxy = self.power_profile_helper.proxy + + # Listen for PropertiesChanged signals from Power Profiles + self.power_profile_helper.listen_signal( + member="PropertiesChanged", + callback=self.handle_power_profile_change, + ) + + self._power_profiles_available = True + except Exception as e: + logger.warning(f"[Battery] Power Profiles daemon not available: {e}") + self.power_profile_helper = None + self.power_profile_proxy = None + self._power_profiles_available = False + + def get_property( + self, + property: Literal[ + "Percentage", + "Temperature", + "TimeToEmpty", + "TimeToFull", + "IconName", + "State", + "Capacity", + "IsPresent", + "Vendor", + ], + ): + try: + result = self.proxy.get_cached_property(property) + return result.unpack() if result is not None else None + except Exception as e: + logger.exception(f"[Battery] Error retrieving '{property}': {e}") + return None + + def get_power_profile(self) -> Optional[str]: + """Get the current active power profile.""" + if not self._power_profiles_available: + return None + + try: + result = self.power_profile_proxy.get_cached_property("ActiveProfile") + return result.unpack() if result is not None else None + except Exception as e: + logger.exception(f"[Battery] Error retrieving active power profile: {e}") + return None + + def set_power_profile( + self, profile: Literal["power-saver", "balanced", "performance"] + ) -> bool: + """Set the active power profile.""" + if not self._power_profiles_available: + logger.warning("[Battery] Power Profiles daemon not available") + return False + + if profile not in PowerProfile: + return False + + try: + self.power_profile_helper.set_property( + interface_name=self.power_profile_interface_name, + property_name="ActiveProfile", + value_variant=GLib.Variant("s", profile), + ) + return True + except Exception as e: + logger.exception( + f"[Battery] Error setting power profile to '{profile}': {e}" + ) + return False + + def get_available_power_profiles(self) -> Optional[list]: + """Get list of available power profiles.""" + if not self._power_profiles_available: + return None + + try: + result = self.power_profile_proxy.get_cached_property("Profiles") + if result is not None: + profiles_data = result.unpack() + # Extract profile names from the array of dictionaries + profiles = [] + for profile_dict in profiles_data: + if "Profile" in profile_dict: + profiles.append(profile_dict["Profile"]) + return profiles + return None + except Exception as e: + logger.exception( + f"[Battery] Error retrieving available power profiles: {e}" + ) + return None + + def is_power_profiles_available(self) -> bool: + """Check if power profiles daemon is available.""" + return self._power_profiles_available + + def get_power_profile_display_name(self, profile: str) -> str: + """Get display name for a power profile.""" + return PowerProfile.get(profile, profile.title()) + + @Property(int, "readable", default_value=0) + def percentage(self) -> int: + return self.get_property("Percentage") or 0 + + @Property(int, "readable", default_value=0) + def state(self) -> int: + return self.get_property("State") or 0 + + @Property(bool, "readable", default_value=False) + def is_present(self) -> bool: + return self.get_property("IsPresent") or False + + def handle_property_change(self, *args): + # Notify about property changes for OSD and other listeners + self.notify("percentage") + self.notify("state") + self.notify("is-present") + self.emit("changed") + + def handle_power_profile_change(self, *_): + """Handle power profile property changes.""" + self.emit("power_profile_changed") diff --git a/services/brightness.py b/src/services/brightness.py similarity index 96% rename from services/brightness.py rename to src/services/brightness.py index 10314441..0723e2f4 100644 --- a/services/brightness.py +++ b/src/services/brightness.py @@ -1,10 +1,5 @@ -import os - -from gi.repository import GLib -from loguru import logger - from fabric.core.service import Property, Service, Signal -from fabric.utils import exec_shell_command_async, monitor_file +from fabric.utils import GLib, exec_shell_command_async, logger, monitor_file, os def exec_brightnessctl_async(args: str): diff --git a/src/services/capslock.py b/src/services/capslock.py new file mode 100644 index 00000000..931c4205 --- /dev/null +++ b/src/services/capslock.py @@ -0,0 +1,100 @@ +from pathlib import Path + +from fabric.core.service import Property, Service, Signal +from fabric.utils import GLib, logger + +# Discover CapsLock LED device +capslock_leds = list(Path("/sys/class/leds").glob("input*::capslock")) +capslock_device = capslock_leds[0] if capslock_leds else None + + +class CapsLock(Service): + instance = None + + @staticmethod + def get_initial(): + if CapsLock.instance is None: + CapsLock.instance = CapsLock() + + return CapsLock.instance + + @Signal + def state_changed(self, is_on: bool) -> None: + """Signal emitted when CapsLock state changes.""" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + self.capslock_led_path = capslock_device + self._timeout_id = None + self._last_state = None + self._monitoring_started = False + + if capslock_device is None: + logger.warning("CapsLock device not found, CapsLock service disabled") + return + + # Don't access is_on property during init to avoid starting monitoring + # The last_state will be set when monitoring actually starts + + def _start_led_monitoring(self): + """Start efficient polling for CapsLock LED state changes.""" + brightness_path = self.capslock_led_path / "brightness" + if not brightness_path.exists(): + logger.warning( + f"CapsLock brightness file does not exist: {brightness_path}" + ) + return + + self._start_efficient_polling() + + def _start_efficient_polling(self): + """Start efficient polling with 100ms intervals.""" + if self._timeout_id is None: + self._timeout_id = GLib.timeout_add(100, self._efficient_poll) + + def _efficient_poll(self) -> bool: + """Monitor CapsLock state with 100ms polling intervals. + + Returns: + bool: True to continue polling, False to stop. + """ + try: + current_state = self._get_current_state() + if current_state != self._last_state: + self._last_state = current_state + self.emit("state_changed", current_state) + except Exception as e: + logger.error(f"CapsLock polling error: {e}") + self._timeout_id = None + return False # Stop polling on error + + return True # Continue polling + + def stop(self): + if self._timeout_id is not None: + GLib.source_remove(self._timeout_id) + self._timeout_id = None + + def _ensure_monitoring_started(self): + if not self._monitoring_started and self.capslock_led_path: + self._monitoring_started = True + # Set initial state before starting monitoring + self._last_state = self._get_current_state() + self._start_led_monitoring() + + def _get_current_state(self) -> bool: + if not self.capslock_led_path: + return False + brightness_path = self.capslock_led_path / "brightness" + if brightness_path.exists(): + try: + return bool(int(brightness_path.read_text().strip())) + except (ValueError, OSError): + return False + return False + + @Property(bool, "read-write", default_value=False) + def is_on(self) -> bool: + self._ensure_monitoring_started() + return self._get_current_state() diff --git a/src/services/config.py b/src/services/config.py new file mode 100644 index 00000000..74f8d90b --- /dev/null +++ b/src/services/config.py @@ -0,0 +1,201 @@ +""" +Centralized configuration service with file watching and dynamic reloads. + +This service encapsulates the logic for loading the application's JSON config, +watching for file changes, and notifying registered listeners when the config +changes. It is implemented as a singleton and intended to be reused by any +module that needs dynamic configuration. +""" + +from __future__ import annotations + +import json +from typing import Any, Callable, Dict, List, Optional + +from fabric.utils import Gio, GLib, get_relative_path, logger, os + + +class ConfigService: + """Singleton service handling config state and reload notifications.""" + + _instance: Optional["ConfigService"] = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self): + if getattr(self, "_initialized", False): + return + + self._initialized = True + self._config: Dict[str, Any] = {} + self._reload_callbacks: List[ + Callable[[Dict[str, Any], Dict[str, Any]], None] + ] = [] + self._config_file: str = get_relative_path("../../config/config.json") + self._monitors: List[Gio.FileMonitor] = [] + self._reload_pending: bool = False + self.RELOAD_DELAY_MS: int = 100 + + self._load_config() + self._last_notified_config = self._config.copy() + self._setup_monitors() + + def get(self, key: str, default: Any = None) -> Any: + return self._config.get(key, default) + + def get_all(self) -> Dict[str, Any]: + return self._config.copy() + + def has_changed(self, key: str, old_config: Dict[str, Any]) -> bool: + return self._config.get(key) != old_config.get(key) + + def set(self, key: str, value: Any) -> None: + """Set a config value (local state only).""" + self._config[key] = value + + def save(self) -> bool: + """Persist the current config state to disk and trigger reloads.""" + try: + os.makedirs(os.path.dirname(self._config_file), exist_ok=True) + with open(self._config_file, "w") as f: + json.dump(self._config, f, indent=4) + + # Manually trigger a reload to notify listeners immediately + GLib.idle_add(self._reload_config) + return True + except Exception as e: + logger.error(f"[ConfigService] Failed to save config: {e}") + return False + + def register_reload_callback( + self, callback: Callable[[Dict[str, Any], Dict[str, Any]], None] + ) -> None: + if callback not in self._reload_callbacks: + self._reload_callbacks.append(callback) + + def unregister_reload_callback( + self, callback: Callable[[Dict[str, Any], Dict[str, Any]], None] + ) -> None: + if callback in self._reload_callbacks: + self._reload_callbacks.remove(callback) + + def stop(self) -> None: + for monitor in self._monitors: + try: + monitor.cancel() + except Exception: + pass + self._monitors.clear() + + def _load_config(self) -> None: + try: + if os.path.exists(self._config_file): + with open(self._config_file, "r") as f: + self._config = json.load(f) + else: + try: + from utils.constants import DEFAULT + + # Ensure the config directory exists + os.makedirs(os.path.dirname(self._config_file), exist_ok=True) + with open(self._config_file, "w") as f: + json.dump(DEFAULT, f, indent=4) + + self._config = DEFAULT.copy() + logger.info( + f"[ConfigService] Generated default config at {self._config_file}" + ) + except ImportError as e: + logger.error(f"[ConfigService] Failed to import defaults: {e}") + self._config = {} + except Exception as e: + logger.error(f"[ConfigService] Failed to load config: {e}") + self._config = {} + + def _setup_monitors(self) -> None: + files_to_watch = [self._config_file, os.path.dirname(self._config_file)] + for file_path in files_to_watch: + if not os.path.exists(file_path): + continue + try: + gio_file = Gio.File.new_for_path(file_path) + monitor = gio_file.monitor_file(Gio.FileMonitorFlags.NONE, None) + monitor.connect("changed", self._on_file_changed, file_path) + self._monitors.append(monitor) + except Exception as e: + logger.error(f"[ConfigService] Failed to monitor {file_path}: {e}") + + def _on_file_changed(self, monitor, file, other_file, event_type, file_path: str): + # Trigger on various change events to be more robust across editors/OSs + valid_events = [ + Gio.FileMonitorEvent.CHANGES_DONE_HINT, + Gio.FileMonitorEvent.CHANGED, + Gio.FileMonitorEvent.CREATED, + Gio.FileMonitorEvent.DELETED, + ] + if event_type in valid_events and not self._reload_pending: + self._reload_pending = True + GLib.timeout_add(self.RELOAD_DELAY_MS, self._reload_config) + + def _reload_config(self) -> bool: + try: + self._reload_pending = False + old_config = self._last_notified_config.copy() + self._load_config() + + # Always notify listeners on reload request to be safe + for callback in list(self._reload_callbacks): + try: + callback(self._config, old_config) + except Exception as e: + logger.error(f"[ConfigService] Callback failed: {e}") + + self._last_notified_config = self._config.copy() + return False + except Exception as e: + logger.error(f"[ConfigService] Failed to reload configuration: {e}") + self._reload_pending = False + return False + + +_service: Optional[ConfigService] = None + + +def start_config_service() -> ConfigService: + global _service + if _service is None: + _service = ConfigService() + return _service + + +def stop_config_service() -> None: + global _service + if _service is not None: + _service.stop() + _service = None + + +# Convenience helpers for concise usage +def config() -> ConfigService: + """Get the singleton service (concise alias).""" + return start_config_service() + + +def get_config(key: str, default: Any = None) -> Any: + """Get a single config value from the singleton service.""" + return start_config_service().get(key, default) + + +def get_config_all() -> Dict[str, Any]: + """Get the entire current configuration state.""" + return start_config_service().get_all() + + +def on_config_change( + callback: Callable[[Dict[str, Any], Dict[str, Any]], None], +) -> None: + """Register a reload callback on the singleton service.""" + start_config_service().register_reload_callback(callback) diff --git a/services/custom_notification.py b/src/services/custom_notification.py similarity index 70% rename from services/custom_notification.py rename to src/services/custom_notification.py index 76198875..c7cf3c35 100644 --- a/services/custom_notification.py +++ b/src/services/custom_notification.py @@ -1,18 +1,6 @@ -# Standard library imports import json -import os -import time from typing import List -# Fabric imports -import gi - -gi.require_version("Gtk", "3.0") -gi.require_version("GdkPixbuf", "2.0") - -from gi.repository import GdkPixbuf - -import config.data as data from fabric.core.service import Property, Service, Signal from fabric.notifications import ( Notification, @@ -20,9 +8,10 @@ NotificationImagePixmap, Notifications, ) +from fabric.utils import GdkPixbuf, logger, os, time -gi.require_version("Gtk", "3.0") -gi.require_version("GdkPixbuf", "2.0") +import shared.data as data +from services.config import config, on_config_change NOTIFICATION_CACHE_FILE = f"{data.CACHE_DIR}/notification_history.json" @@ -36,11 +25,11 @@ def create_from_dict(cls, data, **kwargs): Service.__init__(self, **kwargs) self._notification = Notification.deserialize(data) self._cache_id = data["cached-id"] # Set directly to private var - + # Store cache metadata for cleanup self.cache_metadata = data.get("cache_metadata", {}) self.timestamp = data.get("timestamp", int(time.time())) - + return self @Signal @@ -109,33 +98,37 @@ def image_pixbuf(self) -> GdkPixbuf.Pixbuf | None: @Property(dict, "readable") def serialized(self) -> dict: """Enhanced serialization with cache metadata - stores only cache keys""" - from modules.notification.notification import ( + from window.notification.notification import ( get_cache_key, get_notification_image_cache_key, ) - + # Get better cache keys for icons app_icon_cache_key = None notification_image_cache_key = None - + if self.app_icon: app_icon_cache_key = get_cache_key(self.app_icon, (35, 35), self.app_name) - + # Only try to get notification image cache key if we can safely access image_pixbuf if self.id: try: # First check if we already have the cache key stored - if hasattr(self, 'cache_metadata') and self.cache_metadata: - notification_image_cache_key = self.cache_metadata.get('notification_image_cache_key') - + if hasattr(self, "cache_metadata") and self.cache_metadata: + notification_image_cache_key = self.cache_metadata.get( + "notification_image_cache_key" + ) + # If not, try to generate it safely - if not notification_image_cache_key and hasattr(self._notification, 'image_pixbuf'): + if not notification_image_cache_key and hasattr( + self._notification, "image_pixbuf" + ): try: # Check if image_pixbuf exists and can be accessed without loading from file - image_pixbuf = getattr(self._notification, 'image_pixbuf', None) + image_pixbuf = getattr(self._notification, "image_pixbuf", None) if image_pixbuf: - notification_image_cache_key = get_notification_image_cache_key( - self.id, image_pixbuf + notification_image_cache_key = ( + get_notification_image_cache_key(self.id, image_pixbuf) ) except (AttributeError, OSError, Exception): # If temp file is gone or any other error, just mark as None @@ -143,7 +136,7 @@ def serialized(self) -> dict: except Exception: # If any error occurs during cache key generation, skip it pass - + return { "cached-id": self.cache_id, "id": self.id, @@ -164,8 +157,8 @@ def serialized(self) -> dict: "app_icon_cache_key": app_icon_cache_key, "notification_image_cache_key": notification_image_cache_key, "has_cached_image": notification_image_cache_key is not None, - "cache_timestamp": int(time.time()) - } + "cache_timestamp": int(time.time()), + }, } def __init__(self, notification: Notification, cache_id: int, **kwargs): @@ -211,7 +204,7 @@ def count(self) -> int: def dont_disturb(self) -> bool: """Return the pause status.""" return self._dont_disturb - + def set_dont_disturb(self, value: bool): """Set the pause status.""" self._dont_disturb = value @@ -224,15 +217,49 @@ def __init__(self, **kwargs): self._dont_disturb = False self._count = 0 self._next_cache_id = 1 # Track next available cache ID - self._session_start_time = int(time.time()) # Track session start time for deduplication + self._session_start_time = int( + time.time() + ) # Track session start time for deduplication self.load_cached_notifications() - - # Connect to the notification_added signal to cache new notifications - # Note: self here refers to the CachedNotifications service, which inherits from Notifications - # So we connect to our own notification_added signal + super().notification_added.connect(self.on_notification_added) + # Listen for config changes to prune history if needed + on_config_change(self._on_config_changed) + + def _on_config_changed(self, new_config, old_config): + # If ignored apps changed, remove them from history + if config().has_changed("notification_ignored_apps", old_config): + ignored_apps = new_config.get("notification_ignored_apps", []) + ids_to_remove = [ + cid + for cid, cnotif in self._cached_notifications.items() + if cnotif.app_name in ignored_apps + ] + for cid in ids_to_remove: + self.remove_cached_notification(cid) + if ids_to_remove: + logger.debug( + f"Hot-reload: Removed {len(ids_to_remove)} notifications from ignored apps" + ) + + # If limited apps changed, prune duplicates + if config().has_changed("notification_limited_apps_history", old_config): + limited_apps = new_config.get("notification_limited_apps_history", []) + for app in limited_apps: + app_notifs = [ + (cid, cnotif.timestamp) + for cid, cnotif in self._cached_notifications.items() + if cnotif.app_name == app + ] + if len(app_notifs) > 1: + # Sort by timestamp descending and keep only the first (latest) + app_notifs.sort(key=lambda x: x[1], reverse=True) + for cid, _ in app_notifs[1:]: + self.remove_cached_notification(cid) + logger.debug(f"Hot-reload: Pruned history for limited app {app}") + def load_cached_notifications(self) -> dict[int, CachedNotification]: """Load cached notifications from a JSON file (deserialization).""" try: @@ -247,12 +274,10 @@ def load_cached_notifications(self) -> dict[int, CachedNotification]: cached_notification = CachedNotification.create_from_dict(notification) cache_id = cached_notification.cache_id max_cache_id = max(max_cache_id, cache_id) - + handler_id = cached_notification.connect( "removed-from-cache", - lambda *args: self.remove_cached_notification( - notification_id=cache_id - ), + lambda *args: self.remove_cached_notification(notification_id=cache_id), ) self._signal_handlers[cache_id] = handler_id self._cached_notifications[cache_id] = cached_notification @@ -277,16 +302,16 @@ def cache_notifications(self) -> None: def clear_all_cached_notifications(self): """Empty the notifications with enhanced cache cleanup""" # Clean up all cached files before clearing notifications - from modules.notification.notification import cleanup_all_notification_caches - + from window.notification.notification import cleanup_all_notification_caches + for cached_notification in self._cached_notifications.values(): handler_id = self._signal_handlers.pop(cached_notification.cache_id, None) if handler_id: cached_notification.disconnect(handler_id) - + # Clear all notification caches (icons and images) cleanup_all_notification_caches() - + self._cached_notifications = {} self.cache_notifications() self._count = 0 @@ -297,55 +322,78 @@ def clear_all_cached_notifications(self): def on_notification_added(self, service, notification_id: int) -> None: """Handle notification added and cache it with enhanced metadata - GUARANTEED STORAGE""" # Don't call super() - we're handling this ourselves - + # Import logger at the top of the function - from loguru import logger - notification = self.get_notification_from_id(notification_id) if not notification: - logger.error(f"CRITICAL: Failed to get notification with ID {notification_id}") + logger.error( + f"CRITICAL: Failed to get notification with ID {notification_id}" + ) return # Import here to avoid circular imports - from config import data - from modules.notification.notification import ( - preload_notification_assets, + from window.notification.notification import ( cache_notification_icon, cache_notification_image, get_cache_key, - get_notification_image_cache_key + get_notification_image_cache_key, + preload_notification_assets, ) # Check if this app should be ignored for history (don't cache) - if notification.app_name in data.NOTIFICATION_IGNORED_APPS_HISTORY: + ignored_apps = config().get("notification_ignored_apps", []) + if notification.app_name in ignored_apps: # Don't cache notifications from ignored apps, but still allow popup display - logger.debug(f"Ignoring notification from {notification.app_name} (in ignore list)") + logger.debug( + f"Ignoring notification from {notification.app_name} (in ignore list)" + ) return # Check for duplicates using both notification ID and timestamp to avoid session restart issues existing_notification = None current_time = int(time.time()) - + for cached_notif in self._cached_notifications.values(): # Only consider it a duplicate if: # 1. Same notification ID AND - # 2. Notification was cached in the current session (after session start time) AND + # 2. Notification was cached in the current session (after session start time) AND # 3. Notification was cached recently (within last 5 minutes) - cached_time = getattr(cached_notif, 'timestamp', 0) + cached_time = getattr(cached_notif, "timestamp", 0) is_recent = (current_time - cached_time) < 300 # 5 minutes is_current_session = cached_time >= self._session_start_time - - if (cached_notif._notification.id == notification.id and - is_current_session and is_recent): + + if ( + cached_notif._notification.id == notification.id + and is_current_session + and is_recent + ): existing_notification = cached_notif break - + if existing_notification: - logger.debug(f"Notification ID {notification.id} already cached in current session, skipping") + logger.debug( + f"Notification ID {notification.id} already cached in current session, skipping" + ) return - logger.debug(f"Caching new notification: ID={notification.id}, App={notification.app_name}, Summary={notification.summary[:50]}...") + logger.debug( + f"Caching new notification: ID={notification.id}, App={notification.app_name}, Summary={notification.summary[:50]}..." + ) + + # Handle limited history apps - remove previous notifications from same app + limited_apps = config().get("notification_limited_apps_history", []) + if notification.app_name in limited_apps: + ids_to_remove = [ + cid + for cid, cnotif in self._cached_notifications.items() + if cnotif.app_name == notification.app_name + ] + for cid in ids_to_remove: + self.remove_cached_notification(cid) + logger.debug( + f"Pruned {len(ids_to_remove)} old notifications for limited app {notification.app_name}" + ) # GUARANTEED STORAGE: Always create and store notification to history first cache_id = self._next_cache_id @@ -355,17 +403,17 @@ def on_notification_added(self, service, notification_id: int) -> None: cached_notification = CachedNotification( notification=notification, cache_id=cache_id ) - # Set cache_id directly since it's read-only property + # Set cache_id directly since it's read-only property cached_notification._cache_id = cache_id - + # Initialize cache metadata (will be populated below) cached_notification.cache_metadata = { "app_icon_cache_key": None, "notification_image_cache_key": None, "has_cached_image": False, - "cache_timestamp": int(time.time()) + "cache_timestamp": int(time.time()), } - + # IMMEDIATELY store to history before attempting any caching operations handler_id = cached_notification.connect( "removed-from-cache", @@ -373,83 +421,110 @@ def on_notification_added(self, service, notification_id: int) -> None: ) self._signal_handlers[cache_id] = handler_id self._cached_notifications[cache_id] = cached_notification - + # Save to JSON file immediately - GUARANTEED STORAGE try: self.cache_notifications() logger.debug(f"GUARANTEED: Notification {cache_id} stored to history") except Exception as e: - logger.error(f"CRITICAL: Failed to save notification {cache_id} to history: {e}") - + logger.error( + f"CRITICAL: Failed to save notification {cache_id} to history: {e}" + ) + # Now attempt asset caching (failures here won't affect history storage) try: # Preload assets and store cache metadata preload_notification_assets(notification) - + # Store enhanced cache metadata app_icon_cache_key = None notification_image_cache_key = None - + if notification.app_icon: try: # Only cache at 35x35 to reduce disk usage - headers will scale this down - app_icon_cache_key = get_cache_key(notification.app_icon, (35, 35), notification.app_name) - cache_notification_icon(notification.app_icon, (35, 35), notification.app_name) - cached_notification.cache_metadata["app_icon_cache_key"] = app_icon_cache_key + app_icon_cache_key = get_cache_key( + notification.app_icon, (35, 35), notification.app_name + ) + cache_notification_icon( + notification.app_icon, (35, 35), notification.app_name + ) + cached_notification.cache_metadata["app_icon_cache_key"] = ( + app_icon_cache_key + ) logger.debug(f"Cached app icon (35x35) for notification {cache_id}") except Exception as e: - logger.warning(f"Failed to cache app icon for notification {cache_id}: {e}") - - if hasattr(notification, 'image_pixbuf'): + logger.warning( + f"Failed to cache app icon for notification {cache_id}: {e}" + ) + + if hasattr(notification, "image_pixbuf"): try: # Safely try to access image_pixbuf - image_pixbuf = getattr(notification, 'image_pixbuf', None) + image_pixbuf = getattr(notification, "image_pixbuf", None) if image_pixbuf: notification_image_cache_key = get_notification_image_cache_key( notification.id, image_pixbuf ) - cache_notification_image(notification.id, image_pixbuf, (35, 35)) - cached_notification.cache_metadata["notification_image_cache_key"] = notification_image_cache_key + cache_notification_image( + notification.id, image_pixbuf, (35, 35) + ) + cached_notification.cache_metadata[ + "notification_image_cache_key" + ] = notification_image_cache_key cached_notification.cache_metadata["has_cached_image"] = True - logger.debug(f"Cached notification image for notification {cache_id}") + logger.debug( + f"Cached notification image for notification {cache_id}" + ) except (AttributeError, OSError, Exception) as e: - logger.warning(f"Failed to cache notification image for notification {cache_id}: {e}") - + logger.warning( + f"Failed to cache notification image for notification {cache_id}: {e}" + ) + # Update cached notification with final metadata self._cached_notifications[cache_id] = cached_notification - + # Save updated metadata to JSON self.cache_notifications() - + except Exception as e: - logger.error(f"Asset caching failed for notification {cache_id}, but notification is still stored: {e}") + logger.error( + f"Asset caching failed for notification {cache_id}, but notification is still stored: {e}" + ) # Always emit signals regardless of caching success self.notify("count") self.emit("cached-notification-added", cached_notification) - - logger.debug(f"Successfully processed notification: Cache ID={cache_id}, Total cached={len(self._cached_notifications)}") + + logger.debug( + f"Successfully processed notification: Cache ID={cache_id}, Total cached={len(self._cached_notifications)}" + ) def remove_cached_notification(self, notification_id: int): """Remove the notification of given id with enhanced cache cleanup""" if notification_id in self._cached_notifications: cached_notification = self._cached_notifications.pop(notification_id) - + # Enhanced cache cleanup using stored metadata - if hasattr(cached_notification, 'cache_metadata'): + if hasattr(cached_notification, "cache_metadata"): cache_metadata = cached_notification.cache_metadata - + # Clean up specific cached files using stored keys - from modules.notification.notification import cleanup_notification_specific_caches + from window.notification.notification import ( + cleanup_notification_specific_caches, + ) + cleanup_notification_specific_caches( app_icon_source=cached_notification.app_icon, - notification_image_cache_key=cache_metadata.get('notification_image_cache_key') + notification_image_cache_key=cache_metadata.get( + "notification_image_cache_key" + ), ) - + self.cache_notifications() # Update JSON self._count -= 1 self.notify("count") - + # Get the stored signal handler ID and disconnect it handler_id = self._signal_handlers.pop(notification_id, None) if handler_id: diff --git a/src/services/gamemode.py b/src/services/gamemode.py new file mode 100644 index 00000000..449d81c3 --- /dev/null +++ b/src/services/gamemode.py @@ -0,0 +1,102 @@ +""" +Hyprland Game Mode Toggle Script + +This script toggles game mode in Hyprland by disabling/enabling animations +and other visual effects for better performance during gaming. + +Uses a marker file in /tmp to track state. +""" + +import subprocess +import sys +from datetime import datetime +from typing import Literal + +from utils.functions import read_json_file, write_json_file + +STATE_FILE = "/tmp/hyprland_gamemode" + + +def run_hyprctl(command: str) -> str: + """Run a hyprctl command and return the output (raises on error).""" + result = subprocess.run( + ["hyprctl"] + command.split(), capture_output=True, text=True, check=True + ) + return result.stdout.strip() + + +def check_gamemode() -> Literal["t", "f"]: + """Check if game mode is active.""" + state = read_json_file(STATE_FILE) + return "t" if state and state.get("enabled") else "f" + + +def enable_gamemode(): + """Enable game mode by disabling visual effects.""" + batch_commands = [ + "keyword animations:enabled 0", + "keyword decoration:shadow:enabled 0", + "keyword decoration:blur:enabled 0", + "keyword general:gaps_in 0", + "keyword general:gaps_out 0", + "keyword general:border_size 1", + "keyword decoration:rounding 0", + ] + run_hyprctl(f'--batch "{"; ".join(batch_commands)}"') + + state = { + "enabled": True, + "last_toggled": datetime.now().isoformat(timespec="seconds"), + } + write_json_file(state, STATE_FILE) + print("Game mode enabled - visual effects disabled for better performance") + + +def disable_gamemode(): + """Disable game mode by reloading Hyprland configuration.""" + run_hyprctl("reload") + + state = { + "enabled": False, + "last_toggled": datetime.now().isoformat(timespec="seconds"), + } + write_json_file(state, STATE_FILE) + print("Game mode disabled - visual effects restored") + + +def toggle_gamemode(): + """Toggle game mode state using JSON file.""" + state = read_json_file(STATE_FILE) or {} + if state.get("enabled"): + disable_gamemode() + else: + enable_gamemode() + + +def print_usage(): + print("Usage:") + print(" gamemode check - Check if game mode is active (t/f)") + print(" gamemode toggle - Toggle game mode") + + +def main(): + if len(sys.argv) < 2: + toggle_gamemode() + return + + command = sys.argv[1].lower() + + if command == "check": + print(check_gamemode()) + elif command == "toggle": + toggle_gamemode() + elif command in ["help", "-h", "--help"]: + print_usage() + else: + print(f"Unknown command: {command}", file=sys.stderr) + print_usage() + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/src/services/keyboard_layout.py b/src/services/keyboard_layout.py new file mode 100644 index 00000000..717672ac --- /dev/null +++ b/src/services/keyboard_layout.py @@ -0,0 +1,154 @@ +import json +import socket +import threading +from pathlib import Path + +from fabric.core.service import Property, Service, Signal +from fabric.utils import GLib, logger, os + +import shared.data as data +from services.config import on_config_change +from utils.functions import read_json_file, run_command, write_json_file + +HYPRCTL_BIN = "hyprctl" + + +class KeyboardLayout(Service): + """Service to manage keyboard layout switching and monitoring via Hyprland.""" + + _instance = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + @staticmethod + def get_initial(): + if KeyboardLayout._instance is None: + KeyboardLayout._instance = KeyboardLayout() + return KeyboardLayout._instance + + @Signal + def layout_changed(self, layout: str) -> None: + """Signal emitted when keyboard layout changes.""" + + def __init__(self, **kwargs): + if getattr(self, "_initialized", False): + return + super().__init__(**kwargs) + self._initialized = True + + self.layout_json_file = Path(data.CACHE_DIR) / "kb_layout.json" + self._last_layout = None + self.layouts = [] + self.current_index = 0 + + self._init_layout_config() + self._start_event_listener() + + on_config_change(self._on_config_change) + + def _init_layout_config(self): + config_layouts = data.load_config().get("keyboard_layouts", ["us", "np"]) + json_data = read_json_file(self.layout_json_file) + + if json_data: + self.layouts = json_data.get("layouts", config_layouts) + self.current_index = json_data.get("current_index", 0) + else: + self.layouts = config_layouts + self.current_index = 0 + self._save_layout_json() + + # Sync with actual Hyprland state + self._sync_with_hyprland() + + def _sync_with_hyprland(self): + try: + result = run_command([HYPRCTL_BIN, "devices", "-j"]) + devices = json.loads(result) + keyboards = devices.get("keyboards", []) + for k in keyboards: + if k.get("main"): + layout_name = k.get("active_keymap") + if layout_name: + self._last_layout = layout_name + # Try to match with our list to update index + for i, layout_item in enumerate(self.layouts): + if layout_item.lower() in layout_name.lower(): + self.current_index = i + break + break + except Exception as e: + logger.error(f"[KeyboardLayout] Sync error: {e}") + + def _save_layout_json(self): + write_json_file( + { + "layouts": self.layouts, + "current_index": self.current_index, + }, + self.layout_json_file, + ) + + def _on_config_change(self, new_config, old_config): + new_layouts = new_config.get("keyboard_layouts") + if new_layouts and new_layouts != self.layouts: + self.layouts = new_layouts + self._save_layout_json() + logger.info(f"[KeyboardLayout] Layouts updated from config: {self.layouts}") + + def _start_event_listener(self): + """Start a thread to listen for Hyprland layout events.""" + thread = threading.Thread(target=self._event_loop, daemon=True) + thread.start() + + def _event_loop(self): + his = os.getenv("HYPRLAND_INSTANCE_SIGNATURE") + xdg_runtime_dir = os.getenv("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}") + socket_path = f"{xdg_runtime_dir}/hypr/{his}/.socket2.sock" + + if not os.path.exists(socket_path): + logger.error(f"[KeyboardLayout] Hyprland socket not found: {socket_path}") + return + + try: + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s: + s.connect(socket_path) + logger.info("[KeyboardLayout] Connected to Hyprland event socket.") + while True: + data = s.recv(1024).decode("utf-8") + if not data: + break + for line in data.split("\n"): + if line.startswith("activelayout>>"): + # Format: activelayout>>keyboardname,layoutname + parts = line.split(">>")[1].split(",") + if len(parts) >= 2: + layout_name = parts[1] + if layout_name != self._last_layout: + self._last_layout = layout_name + GLib.idle_add( + self.emit, "layout_changed", layout_name + ) + except Exception as e: + logger.error(f"[KeyboardLayout] Event listener error: {e}") + + def switch_to_next(self) -> bool: + if not self.layouts: + return False + + self.current_index = (self.current_index + 1) % len(self.layouts) + self.layouts[self.current_index] + + # Use hyprctl to switch layout for all devices + # We assume the layouts in Hyprland config match self.layouts in order + run_command([HYPRCTL_BIN, "switchxkblayout", "all", "next"]) + + self._save_layout_json() + return True + + @Property(str, "readable") + def current_layout(self) -> str: + return self._last_layout or "Unknown" diff --git a/services/modus.py b/src/services/modus.py similarity index 91% rename from services/modus.py rename to src/services/modus.py index f9e94443..ce467681 100644 --- a/services/modus.py +++ b/src/services/modus.py @@ -2,7 +2,7 @@ from fabric.core.service import Property, Service, Signal from fabric.hyprland.service import Hyprland -from loguru import logger +from fabric.utils import logger from services.custom_notification import CachedNotifications @@ -31,6 +31,9 @@ def dont_disturb_changed(self, value: bool) -> None: ... @Signal def current_active_app_name_changed(self, value: str) -> None: ... + @Signal + def current_active_wm_class_changed(self, value: str) -> None: ... + @Signal def current_workspace_changed(self, value: str) -> None: ... @@ -62,6 +65,10 @@ def notification_count_changed(self, value: int) -> None: ... def current_active_app_name(self) -> str: return self._current_active_app_name + @Property(str, flags="read-write") + def current_active_wm_class(self) -> str: + return self._current_active_wm_class + @Property(str, flags="read-write") def current_workspace(self) -> str: return self._current_workspace @@ -124,6 +131,12 @@ def current_active_app_name(self, value: str): self._current_active_app_name = value self.current_active_app_name_changed(value) + @current_active_wm_class.setter + def current_active_wm_class(self, value: str): + if value != self._current_active_wm_class: + self._current_active_wm_class = value + self.current_active_wm_class_changed(value) + @current_workspace.setter def current_workspace(self, value: str): if value != self._current_workspace: @@ -231,6 +244,7 @@ def __init__(self): self._dock_apps = "" self._dont_disturb = False self._current_active_app_name = "Finder" # Changed from "Hyprland" to "Finder" + self._current_active_wm_class = "" self._current_workspace = "1" self._music = "" self._current_dropdown = None @@ -269,23 +283,24 @@ def _setup_workspace_monitoring(self): def _setup_active_window_monitoring(self): """Setup active window monitoring""" try: - if not hasattr(self, '_hyprland_connection') or not self._hyprland_connection: + if ( + not hasattr(self, "_hyprland_connection") + or not self._hyprland_connection + ): return - # Get initial active window self._update_active_window() - - # Note: The HyprlandActiveWindow widget from Fabric library - # should handle active window updates automatically. - # We just need to ensure the initial state is correct. - except Exception as e: - logger.error(f"[ModusService] Failed to setup active window monitoring: {e}") + logger.error( + f"[ModusService] Failed to setup active window monitoring: {e}" + ) def _update_active_window(self): - """Update the current active app name based on active window""" try: - if not hasattr(self, '_hyprland_connection') or not self._hyprland_connection: + if ( + not hasattr(self, "_hyprland_connection") + or not self._hyprland_connection + ): return window_data = self._hyprland_connection.send_command("j/activewindow").reply @@ -297,15 +312,12 @@ def _update_active_window(self): wmclass = window_info.get("class", "") title = window_info.get("title", "") - # Handle the case when there's no active window if not title and not wmclass: self.current_active_app_name = "Finder" return - # Simple app name formatting without circular import name = wmclass if wmclass else title if name: - # Basic formatting: capitalize first letter and remove file extensions name = str(name).title() if "." in name: name = name.split(".")[-1] @@ -313,13 +325,13 @@ def _update_active_window(self): name = "Finder" self.current_active_app_name = name + self.current_active_wm_class = wmclass except Exception as e: logger.error(f"[ModusService] Error updating active window: {e}") self.current_active_app_name = "Finder" def _on_workspace_changed(self, obj, signal): - """Handle workspace change events from Hyprland""" try: workspace_name = json.loads(signal.data[0]) self.current_workspace = str(workspace_name) diff --git a/src/services/mpris.py b/src/services/mpris.py new file mode 100644 index 00000000..2b6becc4 --- /dev/null +++ b/src/services/mpris.py @@ -0,0 +1,444 @@ +from typing import Literal, Optional + +from fabric.core.service import Property, Service, Signal +from fabric.utils import Gio, GLib, logger, GObject +from fabric.utils.helpers import ( + bulk_connect, + clamp, + pascal_case_to_snake_case, + snake_case_to_kebab_case, +) + +from utils.dbus_helper import GioDBusHelper + +MPRIS_MEDIAPLAYER_BUS_NAME = "org.mpris.MediaPlayer2" +MPRIS_MEDIAPLAYER_BUS_PATH = "/org/mpris/MediaPlayer2" +MPRIS_MEDIAPLAYER_PLAYER_BUS_NAME = "org.mpris.MediaPlayer2.Player" + + +# TODO Shuffle is broken because there is no CanShuffle property, it relies on can_control but thats not all inclusive + + +class MprisPlayer(Service): + @Signal + def closed(self, value: bool) -> bool: ... + + @Signal + def changed(self) -> None: ... + + seeked = Signal(name="seeked", arg_types=(GObject.TYPE_INT64,)) + + @Property(str, "readable") + def player_name(self): + return self.bus_name.split(".", 3)[-1].split(".")[0] + + @Property(str, "readable") + def playback_status(self) -> Literal["Playing", "Paused", "Stopped"]: + # type: ignore + return self._dbus_helper.proxy.get_cached_property( + "PlaybackStatus" + ).get_string() + + @Property(str, "read-write", default_value="None") + def loop_status(self) -> Literal["None", "Track", "Playlist"]: + # type: ignore + return self._dbus_helper.proxy.get_cached_property("LoopStatus").get_string() + + @loop_status.setter + def loop_status(self, status: Literal["None", "Track", "Playlist"]) -> None: + if self._dbus_helper.proxy.get_cached_property("LoopStatus") is None: + return + + ( + self._dbus_helper.set_property( + MPRIS_MEDIAPLAYER_PLAYER_BUS_NAME, + "LoopStatus", + GLib.Variant("s", value=status), + ) + if self.can_control + else None + ) + + # TODO: Playback rate??? (Rate) idk i dont really see why someone would use this ngl + + @Property(bool, "read-write", default_value=False) + def shuffle(self) -> bool: + if self._dbus_helper.proxy.get_cached_property("Shuffle"): + # type: ignore + return self._dbus_helper.proxy.get_cached_property("Shuffle").get_boolean() + return False + + @shuffle.setter + def shuffle(self, is_shuffle: bool) -> None: + if self._dbus_helper.proxy.get_cached_property("Shuffle") is None: + return + + ( + self._dbus_helper.set_property( + MPRIS_MEDIAPLAYER_PLAYER_BUS_NAME, + "Shuffle", + GLib.Variant("b", value=is_shuffle), + ) + if self.can_control + else None + ) + + @Property(dict, "readable") + def metadata(self) -> dict: + prop: GLib.Variant | None = self._dbus_helper.proxy.get_cached_property( + "Metadata" + ) # type: ignore + return dict(prop) if prop else {} # type: ignore + + # RELY ON METADATA + @Property(str, "readable") + def arturl(self) -> str: + return self.metadata.get("mpris:artUrl", "") + + @Property(int, "readable", default_value=0) + def length(self) -> int: + return self.metadata.get("mpris:length", 0) + + @Property(list, "readable") + def artist(self) -> list: + return self.metadata.get("xesam:artist", "") + + @Property(str, "readable") + def album(self) -> str: + return self.metadata.get("xesam:album", "") + + @Property(str, "readable") + def title(self): + return self.metadata.get("xesam:title", "") + + # END RELY ON METADATA + + @Property(float, "read-write") + def volume(self) -> float: + # type: ignore + return self._dbus_helper.proxy.get_cached_property("Volume").get_double() + + @volume.setter + def volume(self, volume: float) -> None: + ( + self._dbus_helper.set_property( + MPRIS_MEDIAPLAYER_PLAYER_BUS_NAME, + "Volume", + GLib.Variant("d", clamp(value=volume, min_value=0.0, max_value=1.0)), + ) + if self.can_control + else None + ) + + @Property(int, "read-write", default_value=0) + def position(self) -> int: + # type: ignore + return self._dbus_helper.proxy.get_cached_property("Position").get_int64() + + @position.setter + def position(self, new_pos: int) -> None: + self._dbus_helper.call_method( + self.bus_name, + MPRIS_MEDIAPLAYER_BUS_PATH, + MPRIS_MEDIAPLAYER_PLAYER_BUS_NAME, + "SetPosition", + GLib.Variant( + "(ox)", + (self.metadata["mpris:trackid"], new_pos), + ), + ) + + # TODO: consider MinimumRate, MaximumRate + + @Property(bool, "readable", default_value=False) + def can_go_next(self) -> bool: + return self._dbus_helper.proxy.get_cached_property("CanGoNext").get_boolean() + + @Property(bool, "readable", default_value=False) + def can_go_previous(self) -> bool: + return self._dbus_helper.proxy.get_cached_property( + "CanGoPrevious" + ).get_boolean() + + @Property(bool, "readable", default_value=False) + def can_play(self) -> bool: + return self._dbus_helper.proxy.get_cached_property("CanPlay").get_boolean() + + @Property(bool, "readable", default_value=False) + def can_pause(self) -> bool: + return self._dbus_helper.proxy.get_cached_property("CanPause").get_boolean() + + @Property(bool, "readable", default_value=False) + def can_seek(self) -> bool: + return self._dbus_helper.proxy.get_cached_property("CanSeek").get_boolean() + + @Property(bool, "readable", default_value=False) + def can_control(self) -> bool: + return self._dbus_helper.proxy.get_cached_property("CanControl").get_boolean() + + def __init__(self, bus_name: str, **kwargs): + super().__init__(**kwargs) + self.bus_name: str = bus_name + self._dbus_helper: GioDBusHelper | None = None + + # Ahoy! + self.do_register() + + def do_register(self) -> None: # the bus id + self._dbus_helper = GioDBusHelper( + self.bus_name, + MPRIS_MEDIAPLAYER_BUS_PATH, + MPRIS_MEDIAPLAYER_PLAYER_BUS_NAME, + Gio.BusType.SESSION, + ) + + if not self._dbus_helper.proxy: + return + + bulk_connect( + self._dbus_helper.proxy, + { + "g-properties-changed": self._do_handle_properties_changed, + "g-signal": self._do_handle_signal_changed, + "notify::g-name-owner": self._on_name_owner_change, + }, + ) + # Update all properties to start + self.update_all_properties() + + def update_all_properties(self): + def on_update_finish(proxy, task): + try: + self._do_handle_properties_changed( + self._dbus_helper.proxy, proxy.call_finish(task)[0], "" + ) + except Exception: + logger.error(f"[MPRIS-{self.bus_name}] Failed to retrieve properties") + + self._dbus_helper.proxy.call( + "org.freedesktop.DBus.Properties.GetAll", + GLib.Variant.new_tuple( + GLib.Variant.new_string("org.mpris.MediaPlayer2.Player") + ), + Gio.DBusCallFlags.NONE, + -1, + None, + on_update_finish, + ) # User data + + def _do_handle_properties_changed( + self, proxy: Gio.DBusProxy, changed_properties, invalidated_properties: str + ): + for prop_name in set( + [ + snake_case_to_kebab_case(pascal_case_to_snake_case(x)) + for x in changed_properties.keys() + ] + ).intersection([prop.name for prop in self.get_properties()]): + self.notifier(prop_name) + + if prop_name == "metadata": + for sub_prop in ["arturl", "album", "artist", "length", "title"]: + self.notifier(sub_prop) + + def notifier(self, prop): + self.notify(prop) + self.changed() + + def _do_handle_signal_changed( + self, + proxy: Gio.DBusProxy, + sender_name: str, + signal_name: str, + params: tuple[GLib.Variant], + ): + # Only One Signal for Mpris + if signal_name == "Seeked": + self.seeked(params[0]) + + def _proxy_call(self, method_name: str, parameter: Optional[GLib.Variant]): + self._dbus_helper.proxy.call( + method_name, + parameter, + Gio.DBusCallFlags.NONE, + self._dbus_helper.proxy.get_default_timeout(), + None, + self._do_method_callback, + pascal_case_to_snake_case(method_name), + ) + + def _do_method_callback(self, _, res: Gio.AsyncResult, user_data): + try: + self._dbus_helper.proxy.call_finish(res) + except Exception as e: + print(e) + logger.error( + f"[MPRIS-{self.bus_name}] Failed to invoke method: {user_data}" + ) + + def _on_name_owner_change(self, proxy: Gio.DBusProxy, _): + if not self._dbus_helper.proxy.get_name_owner(): + # proxy is automatically destroyed + self.closed(True) + + # Methods + def next(self): + self._proxy_call("Next", None) if self.can_go_next else None + + def previous(self): + self._proxy_call("Previous", None) if self.can_go_previous else None + + def pause(self): + self._proxy_call("Pause", None) if self.can_pause else None + + def play_pause(self): + ( + self._proxy_call("PlayPause", None) + if self.can_pause + else logger.error( + f"[MPRIS-{self.bus_name}] `play_pause` is not supported by this player" + ) + ) + + def stop(self): + ( + self._proxy_call("Stop", None) + if self.can_control + else logger.error( + f"[MPRIS-{self.bus_name}] `stop` is not supported by this player" + ) + ) + + def play(self): + self._proxy_call("Play", None) if self.can_play else None + + def seek(self, time_in_us: int): + ( + self._proxy_call("Seek", GLib.Variant.new_int64(time_in_us)) + if self.can_seek + else None + ) + + # No need for SetPositition, that is handled by the setter + + # Ignoring OpenUri + + +class MprisPlayerManager(Service): + @Signal + def player_appeared(self, player: MprisPlayer) -> MprisPlayer: + logger.info(f"[MPRIS] Found Player: {player.bus_name}") + self._players[player.bus_name] = player + self.notify("players") + return player + + @Signal + def player_vanished(self, bus_name: str) -> str: + logger.info(f"[MPRIS] Lost Player: {bus_name}") + self._players.pop(bus_name) + self.notify("players") + return bus_name + + @Property(dict[str, MprisPlayer], "readable") + def players(self): + return self._players + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._players: dict[str, MprisPlayer] = {} + self._dbus_helper: GioDBusHelper | None = None + self._subscription_id: int | None = None + + # ahoy + self.do_register() + + def do_register(self): + self._dbus_helper = GioDBusHelper( + "org.freedesktop.DBus", + "/org/freedesktop/DBus", + "org.freedesktop.DBus", + Gio.BusType.SESSION, + ) + + self._subscription_id = self._dbus_helper.listen_signal( + "NameOwnerChanged", + self.on_name_owner_change, + "org.freedesktop.DBus", + ) + + # Defer player discovery to avoid blocking initialization + GLib.idle_add(self._get_available_players) + + def destroy(self): + """Unsubscribe bus signal and clear players.""" + try: + if self._subscription_id is not None and self._dbus_helper is not None: + self._dbus_helper.unsubscribe_signal(self._subscription_id) + self._subscription_id = None + except Exception: + pass + + try: + # notify vanish for all remaining players + for bus_name in list(self._players.keys()): + try: + self.player_vanished(bus_name) + except Exception: + pass + self._players.clear() + except Exception: + pass + + def _list_names_callback( + self, + proxy: Gio.DBusProxy, + res: Gio.AsyncResult, + ): + try: + reply = proxy.call_finish(res) + for player in filter( + lambda x: x.startswith(MPRIS_MEDIAPLAYER_BUS_NAME), + reply.get_child_value(0), # type: ignore + ): + self.do_handle_new_player(player) + + except Exception: + logger.error("[MPRIS MANAGER] Failed to ListNames") + + def _get_available_players(self): + self._dbus_helper.proxy.call( + "ListNames", + GLib.Variant("()", ()), + Gio.DBusCallFlags.NONE, + -1, + None, + self._list_names_callback, + ) + + def on_name_owner_change( + self, + conn, + sender_name: str, + object_path: str, + interface_name: str, + signal_name: str, + user_data, + *params, + ) -> None: + name, old_owner, new_owner = user_data + if not name.startswith(MPRIS_MEDIAPLAYER_BUS_NAME): + return + + if old_owner == "" and new_owner != "": + self.do_handle_new_player(name) + + def do_handle_new_player(self, bus_name: str): + if bus_name in self._players.keys(): + return + + player = MprisPlayer(bus_name) + player.connect( + "closed", + lambda *_: self.player_vanished(bus_name), + ) + self.player_appeared(player) diff --git a/services/network.py b/src/services/network.py similarity index 98% rename from services/network.py rename to src/services/network.py index 619e81d5..07a7350a 100644 --- a/services/network.py +++ b/src/services/network.py @@ -1,9 +1,15 @@ -from gi.repository import NM, GLib +from typing import Optional + import gi -from typing import List, Optional from fabric.core.service import Property, Service, Signal -from fabric.utils import bulk_connect, get_enum_member_name, snake_case_to_kebab_case -from loguru import logger +from fabric.utils import ( + GLib, + bulk_connect, + get_enum_member_name, + logger, + snake_case_to_kebab_case, +) +from gi.repository import NM gi.require_version("NM", "1.0") # Ensure the correct version is loaded @@ -333,7 +339,9 @@ def __init__(self, client: NetworkClient, device: NM.DeviceWifi, **kwargs): bulk_connect( self._device, { - "notify::active-access-point": lambda *args: self.on_access_point_activated(), + "notify::active-access-point": lambda *args: ( + self.on_access_point_activated() + ), "access-point-added": lambda _, ap: self.on_access_point_added(ap=ap), "access-point-removed": lambda _, ap: self.on_access_point_removed( ap=ap diff --git a/src/services/screencapture.py b/src/services/screencapture.py new file mode 100644 index 00000000..8f6cc58a --- /dev/null +++ b/src/services/screencapture.py @@ -0,0 +1,409 @@ +import json +from datetime import datetime +from pathlib import Path + +from fabric import Service, Signal +from fabric.core.service import Property +from fabric.utils import ( + Gio, + exec_shell_command, + exec_shell_command_async, + logger, + time, +) + +from utils.functions import is_app_running, run_command, kill_process + + +class ScreenCapture(Service): + """Service for screen capture and recording functionality""" + + @Signal + def screenshot_taken(self, path: str) -> None: + """Signal emitted when screenshot is taken.""" + + @Signal + def recording_started(self, path: str) -> None: + """Signal emitted when recording starts.""" + + @Signal + def recording_stopped(self, path: str) -> None: + """Signal emitted when recording stops.""" + + _instance = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.home = Path.home() + self.screenshots_dir = self.home / "Pictures" / "Screenshots" + self.recordings_dir = self.home / "Videos" / "Recordings" + self.recording_file = Path("/tmp/recording.txt") + self.recording_start_time_file = Path("/tmp/recording_start_time.txt") + + self.screenshots_dir.mkdir(parents=True, exist_ok=True) + self.recordings_dir.mkdir(parents=True, exist_ok=True) + + def notify_send(self, title, message, icon=None, actions=None): + cmd = ["notify-send", "-a", "Modus"] + + if icon: + cmd.extend(["-i", str(icon)]) + + if actions: + for action in actions: + cmd.extend(["-A", f"{action}={action}"]) + + cmd.extend([title, message]) + return run_command(cmd) + + def send_screenshot_notification(self, file_path=None): + cmd = ["notify-send"] + cmd.extend( + [ + "-A", + "files=Show in Files", + "-A", + "view=View", + "-A", + "edit=Edit", + "-i", + "camera-photo-symbolic", + "-a", + "Fabric Screenshot Utility", + "-h", + f"STRING:image-path:{file_path}", + "Screenshot Saved", + f"Saved Screenshot at {file_path}", + ] + if file_path + else ["Screenshot Sent to Clipboard"] + ) + + proc: Gio.Subprocess = Gio.Subprocess.new(cmd, Gio.SubprocessFlags.STDOUT_PIPE) + + def do_callback(process: Gio.Subprocess, task: Gio.Task): + try: + _, stdout, stderr = process.communicate_utf8_finish(task) + except Exception: + logger.error( + f"[SCREENSHOT] Failed read notification action with error {stderr}" + ) + return + + match stdout.strip("\n"): + case "files": + exec_shell_command_async(f"xdg-open {self.screenshots_dir}") + case "view": + exec_shell_command_async(f"xdg-open {file_path}") + case "edit": + exec_shell_command_async(f"satty -f {file_path}") + + proc.communicate_utf8_async(None, None, do_callback) + self.screenshot_taken(file_path) + + def send_recording_notification(self, file_path): + cmd = ["notify-send"] + cmd.extend( + [ + "-A", + "files=Show in Files", + "-A", + "view=View", + "-i", + "camera-video-symbolic", + "-a", + "Fabric Screenshot Utility", + "Screencast Saved", + f"Saved Screencast at {file_path}", + ] + ) + + proc: Gio.Subprocess = Gio.Subprocess.new(cmd, Gio.SubprocessFlags.STDOUT_PIPE) + + def do_callback(process: Gio.Subprocess, task: Gio.Task): + try: + _, stdout, stderr = process.communicate_utf8_finish(task) + except Exception: + logger.error( + f"[SCREENCAST] Failed read notification action with error {stderr}" + ) + return + + match stdout.strip("\n"): + case "files": + exec_shell_command_async(f"xdg-open {self.recordings_dir}") + case "view": + exec_shell_command_async(f"xdg-open {file_path}") + + proc.communicate_utf8_async(None, None, do_callback) + self.recording_stopped(file_path) + + def check_wf_recorder(self): + if not is_app_running("wf-recorder"): + return False + + kill_process("wf-recorder") + + if self.recording_file.exists(): + recording_file = self.recording_file.read_text().strip() + self.send_recording_notification(recording_file) + + if self.recording_start_time_file.exists(): + self.recording_start_time_file.unlink() + + return True + + def record_video(self, output_file, *args): + cmd = [ + "wf-recorder", + *args, + "-f", + str(output_file), + "-c", + "libvpx-vp9", + "--pixel-format", + "yuv420p", + "-F", + "eq=brightness=0.12:contrast=1.1", + ] + return run_command(cmd) + + def record_video_noaudio(self, output_file, *args): + cmd = [ + "wf-recorder", + *args, + "-f", + str(output_file), + "-c", + "libvpx-vp9", + "--pixel-format", + "yuv420p", + "-F", + "eq=brightness=0.12:contrast=1.1", + "--no-audio", + ] + return run_command(cmd) + + def _get_active_monitor(self): + """Get the name of the currently focused monitor.""" + try: + monitors_json = exec_shell_command("hyprctl monitors -j") + if monitors_json: + monitors = json.loads(monitors_json) + for monitor in monitors: + if monitor.get("focused"): + return monitor.get("name") + except Exception as e: + logger.error(f"[SCREENSHOT] Failed to get active monitor: {e}") + return "eDP-1" # Fallback + + def screenshot(self, target="region"): + """ + Take a screenshot. + :param target: 'region', 'active', 'output', 'both', or a specific display name like 'eDP-1' + """ + timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + screenshot_path = self.screenshots_dir / f"{timestamp}.png" + + if target == "both": + # Special case for multiple displays, similar to the shell script + temp_edp = self.screenshots_dir / f"{timestamp}_eDP-1.png" + temp_hdmi = self.screenshots_dir / f"{timestamp}_HDMI-A-1.png" + + cmd = f"grim -c -o eDP-1 {temp_edp} && grim -c -o HDMI-A-1 {temp_hdmi} && montage {temp_edp} {temp_hdmi} -tile 2x1 -geometry +0+0 {screenshot_path} && rm {temp_edp} {temp_hdmi}" + exec_shell_command_async( + cmd, lambda *_: self.send_screenshot_notification(str(screenshot_path)) + ) + return True + + command = [ + "hyprshot", + "-s", + "-o", + str(self.screenshots_dir), + "-f", + f"{timestamp}.png", + ] + + if target == "region": + command.extend(["-m", "region"]) + elif target == "active": + active_monitor = self._get_active_monitor() + command.extend(["-m", "output", "-m", active_monitor]) + elif target == "output": + command.extend(["-m", "output"]) + else: + # Specific display name + command.extend(["-m", "output", "-m", target]) + + command.append("-- ls") + + try: + exec_shell_command_async( + " ".join(command), + self._after_screenshot, + ) + except Exception as e: + logger.error(f"Screenshot failed: {e}") + return False + + return True + + def _after_screenshot(self, *_): + try: + screenshot_files = list(self.screenshots_dir.glob("*.png")) + if screenshot_files: + latest_file = max(screenshot_files, key=lambda f: f.stat().st_mtime) + self.send_screenshot_notification(file_path=str(latest_file)) + time.sleep(0.2) + else: + # No file found after command: likely user cancelled the selection + self.notify_send( + "Screenshot cancelled", + "Selection was cancelled", + icon="camera-photo-symbolic", + ) + except Exception as e: + logger.error(f"Screenshot notification failed: {e}") + + def record(self, target="selection", no_audio=False, mode="standard"): + """ + Start recording. + :param target: 'selection', 'eDP-1', 'HDMI-A-1', etc. + :param no_audio: bool + :param mode: 'standard', 'hq', 'gif' + """ + if self.is_recording: + logger.error( + "[SCREENRECORD] Another instance of wf-recorder is already running" + ) + return False + + timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + ext = "mp4" if mode == "hq" else "gif" if mode == "gif" else "mkv" + output_file = self.recordings_dir / f"{timestamp}.{ext}" + + self._current_recording_path = str(output_file) + self.recording_file.write_text(str(output_file)) + self.recording_start_time_file.write_text(str(int(time.time()))) + + area = "" + if target == "selection": + geometry = exec_shell_command("slurp") + if not geometry or not str(geometry).strip(): + self.notify_send( + "Recording cancelled", + "Selection was cancelled", + icon="camera-video-symbolic", + ) + return False + area = f"-g '{geometry}'" + elif target == "active": + active_monitor = self._get_active_monitor() + area = f"-o {active_monitor}" + else: + area = f"-o {target}" + + if mode == "gif": + # GIF optimized recording (lower fps, temp mkv then convert) + temp_video = f"/tmp/gif_recording_{int(time.time())}.mkv" + self.recording_file.write_text(temp_video) # Update tracking to temp file + command = f"wf-recorder -f {temp_video} -c libvpx-vp9 -r 15 --pixel-format yuv420p --no-audio {area}" + + def after_gif_recording(*_): + if Path(temp_video).exists(): + self.notify_send("Converting to GIF", "Processing recording...") + conv_cmd = f"ffmpeg -i {temp_video} -vf 'fps=15,scale=iw:-1:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=128:stats_mode=diff[p];[s1][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle' -loop 0 {output_file}" + exec_shell_command_async( + conv_cmd, + lambda *_: ( + Path(temp_video).unlink(), + self.send_recording_notification(str(output_file)), + ), + ) + + exec_shell_command_async(command, after_gif_recording) + elif mode == "hq": + # High quality preset + preset_flags = "-c h264_vaapi -p 'preset=slow' -p 'crf=18' -r 60 -b 8000000 -B 192000 -g 30" + if no_audio: + preset_flags += " --no-audio" + command = f"wf-recorder --file={output_file} --pixel-format yuv420p {preset_flags} {area}" + exec_shell_command_async(command) + else: + # Standard mode + audio_flag = "--no-audio" if no_audio else "" + command = f"wf-recorder --file={output_file} --pixel-format yuv420p {audio_flag} {area}" + exec_shell_command_async(command) + + self.recording_started(str(output_file)) + return True + + def stop_recording(self): + kill_process("wf-recorder") + if hasattr(self, "_current_recording_path"): + self.recording_stopped(self._current_recording_path) + return True + + def convert(self, format_type, file_path=None): + """ + Convert video to specified format. + :param format_type: 'webm', 'iphone', 'youtube', 'gif' + :param file_path: Optional path to specific file + """ + if not file_path: + # Find latest recording + files = list(self.recordings_dir.glob("*.[mkv|mp4]*")) + if not files: + self.notify_send("Conversion Error", "No recordings found to convert") + return False + file_path = str(max(files, key=lambda f: f.stat().st_mtime)) + + file_path_obj = Path(file_path) + if not file_path_obj.exists(): + self.notify_send("Conversion Error", f"File not found: {file_path}") + return False + + output_path = file_path_obj.with_suffix(f".{format_type}") + if format_type == "iphone": + output_path = file_path_obj.with_name(f"{file_path_obj.stem}-iphone.mp4") + elif format_type == "youtube": + output_path = file_path_obj.with_name(f"{file_path_obj.stem}-youtube.mp4") + + self.notify_send( + f"Converting to {format_type.upper()}", f"Processing: {file_path_obj.name}" + ) + + if format_type == "webm": + cmd = f"ffmpeg -y -i {file_path} -c:v libvpx -b:v 1M -c:a libvorbis {output_path}" + elif format_type == "iphone": + cmd = f"ffmpeg -y -i {file_path} -vcodec h264 -acodec aac {output_path}" + elif format_type == "youtube": + cmd = f"ffmpeg -y -i {file_path} -c:v libx264 -profile:v high -preset slow -crf 18 -pix_fmt yuv420p -c:a aac -b:a 384k -movflags +faststart {output_path}" + elif format_type == "gif": + cmd = f"ffmpeg -i {file_path} -vf 'fps=15,scale=800:-1:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=256:stats_mode=diff[p];[s1][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle' -loop 0 {output_path}" + else: + return False + + def on_done(*_): + self.notify_send( + f"{format_type.upper()} Conversion Success", + f"Saved to {output_path.name}", + ) + if format_type == "gif": + exec_shell_command_async(f"wl-copy < {output_path}") + + exec_shell_command_async(cmd, on_done) + return True + + @Property(bool, "readable", default_value=False) + def is_recording(self): + return is_app_running("wf-recorder") or is_app_running("gpu-screen-recorder") + + +screen_capture_service = ScreenCapture() diff --git a/services/todo.py b/src/services/todo.py similarity index 95% rename from services/todo.py rename to src/services/todo.py index 876505e8..97365f8a 100644 --- a/services/todo.py +++ b/src/services/todo.py @@ -8,7 +8,7 @@ from fabric.core.service import Property, Service # Local imports -import config.data as data +import shared.data as data class TodoService(Service): @@ -164,6 +164,12 @@ def get_stats(self) -> dict: } -# Global service instance -todo_service = TodoService() +# Global service instance getter +_todo_service = None + +def get_todo_service() -> TodoService: + global _todo_service + if _todo_service is None: + _todo_service = TodoService() + return _todo_service diff --git a/src/shared/__init__.py b/src/shared/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/config/data.py b/src/shared/data.py similarity index 55% rename from config/data.py rename to src/shared/data.py index e4aa977f..429635ef 100644 --- a/config/data.py +++ b/src/shared/data.py @@ -1,51 +1,17 @@ import json -import os -import gi -from fabric.utils.helpers import get_relative_path -from gi.repository import Gdk, GLib +from fabric.utils import Gdk, GLib, get_relative_path, os -gi.require_version("Gtk", "3.0") +from utils.functions import parse_timeout_string -APP_NAME = "modus" +APP_NAME = "modus1" APP_NAME_CAP = "Modus" -def parse_timeout_string(timeout_str): - """ - Parse timeout string in format like '5s', '10m', '30s' etc. - Returns timeout in milliseconds. - """ - if not timeout_str or not isinstance(timeout_str, str): - return 5000 - - timeout_str = timeout_str.strip().lower() - - if timeout_str.endswith("s"): - try: - seconds = int(timeout_str[:-1]) - return seconds * 1000 - except ValueError: - return 5000 - elif timeout_str.endswith("m"): - try: - minutes = int(timeout_str[:-1]) - return minutes * 60 * 1000 - except ValueError: - return 5000 - else: - try: - seconds = int(timeout_str) - return seconds * 1000 - except ValueError: - return 5000 - - CACHE_DIR = str(GLib.get_user_cache_dir()) + f"/{APP_NAME}" - USERNAME = os.getlogin() HOSTNAME = os.uname().nodename -HOME_DIR = os.path.expanduser("~") +HOME_DIR = GLib.get_home_dir() CONFIG_DIR = os.path.expanduser(f"~/.config/{APP_NAME}") @@ -55,22 +21,29 @@ def parse_timeout_string(timeout_str): WALLPAPERS_DIR_DEFAULT = get_relative_path("../assets/wallpapers_example/") -CONFIG_FILE = get_relative_path("../config/assets/config.json") +CONFIG_FILE = get_relative_path("../../config/config.json") MATUGEN_STATE_FILE = os.path.join(CONFIG_DIR, "matugen") def load_config(): """Load the configuration from config.json""" - config = {} + try: + from services.config import start_config_service - if os.path.exists(CONFIG_FILE): - try: - with open(CONFIG_FILE, "r") as f: - config = json.load(f) - except Exception as e: - print(f"Error loading config: {e}") + service = start_config_service() + return service.get_all() + except ImportError: + # Fallback to direct file loading + config = {} - return config + if os.path.exists(CONFIG_FILE): + try: + with open(CONFIG_FILE, "r") as f: + config = json.load(f) + except Exception as e: + print(f"Error loading config: {e}") + + return config if os.path.exists(CONFIG_FILE): @@ -79,7 +52,6 @@ def load_config(): wallpapers_dir_from_config = config.get("wallpapers_dir", WALLPAPERS_DIR_DEFAULT) WALLPAPERS_DIR = os.path.expanduser(wallpapers_dir_from_config) DOCK_POSITION = config.get("dock_position", "Bottom") - TERMINAL_COMMAND = config.get("terminal_command", "kitty -e") DOCK_ENABLED = config.get("dock_enabled", True) DOCK_AUTO_HIDE = config.get("dock_auto_hide", True) DOCK_ALWAYS_OCCLUDED = config.get("dock_always_occluded", False) @@ -99,15 +71,27 @@ def load_config(): "notification_limited_apps_history", ["Spotify"] ) + PANEL_COMPONENTS_VISIBILITY = { + "imac_button": config.get("imac_button", True), + "systray": config.get("systray", True), + "control_center": config.get("control_center", True), + "search": config.get("search", True), + "global_menu": config.get("global_menu", True), + "network": config.get("network", True), + "battery": config.get("battery", True), + "notification_center": config.get("notification_center", True), + "workspace_indicator": config.get("workspace_indicator", True), + "bluetooth": config.get("bluetooth", True), + "date_time": config.get("date_time", True), + } + else: WALLPAPERS_DIR = WALLPAPERS_DIR_DEFAULT DOCK_POSITION = "Bottom" DOCK_ENABLED = True DOCK_ALWAYS_OCCLUDED = False DOCK_AUTO_HIDE = True - TERMINAL_COMMAND = "kitty -e" - DOCK_THEME = "Pills" - DOCK_ICON_SIZE = 60 + DOCK_ICON_SIZE = 52 WINDOW_SWITCHER_ITEMS_PER_ROW = 10 HIDE_SPECIAL_WORKSPACE = True DOCK_HIDE_SPECIAL_WORKSPACE_APPS = True @@ -116,3 +100,17 @@ def load_config(): NOTIFICATION_TIMEOUT = parse_timeout_string(NOTIFICATION_TIMEOUT_STR) NOTIFICATION_IGNORED_APPS_HISTORY = ["Hyprshot"] NOTIFICATION_LIMITED_APPS_HISTORY = ["Spotify"] + + PANEL_COMPONENTS_VISIBILITY = { + "imac_button": True, + "systray": True, + "control_center": True, + "search": True, + "global_menu": True, + "network": True, + "battery": True, + "notification_center": True, + "workspace_indicator": True, + "bluetooth": True, + "date_time": True, + } diff --git a/modules/about.py b/src/shared/dialogs/about.py similarity index 76% rename from modules/about.py rename to src/shared/dialogs/about.py index 46c7f1cb..0c41648a 100644 --- a/modules/about.py +++ b/src/shared/dialogs/about.py @@ -1,16 +1,10 @@ -import re import subprocess -import os -import gi +from fabric.utils import GdkPixbuf, Gtk, get_relative_path, os, re -gi.require_version("GdkPixbuf", "2.0") -gi.require_version("Gtk", "3.0") -from gi.repository import GdkPixbuf, Gtk # type: ignore - -from fabric.utils.helpers import get_relative_path -from utils.roam import modus_service +from utils.functions import escape_markup_text from utils.icon_resolver import IconResolver +from utils.utils import setup_cursor_hover def read_dmi(field): @@ -106,58 +100,79 @@ def get_app_info(wmclass): "/usr/local/share/applications", ] + # Search for desktop files for path in desktop_paths: if not os.path.exists(path): continue - # Try exact match first - exact_matches = [ - f for f in os.listdir(path) if f.lower() == f"{wmclass.lower()}.desktop" - ] - - # Then try starts with - startswith_matches = [ - f - for f in os.listdir(path) - if f.startswith(wmclass.lower()) and f.endswith(".desktop") - ] - - # Finally try contains - contains_matches = [ - f - for f in os.listdir(path) - if wmclass.lower() in f.lower() and f.endswith(".desktop") - ] - - # Process matches in order of preference - for matches in [exact_matches, startswith_matches, contains_matches]: - for filename in matches: - desktop_file = os.path.join(path, filename) + search_terms = [wmclass] + if "." in wmclass: + search_terms.append(wmclass.split(".")[-1]) + + # Add lowercase versions + search_terms = list(dict.fromkeys([s.lower() for s in search_terms])) + + files = os.listdir(path) + desktop_file = None + + # 1. Try exact matches with .desktop suffix + for term in search_terms: + if f"{term}.desktop" in files: + desktop_file = os.path.join(path, f"{term}.desktop") + break + + # 2. Try prefix matches + if not desktop_file: + for term in search_terms: + matches = [ + f for f in files if f.startswith(term) and f.endswith(".desktop") + ] + if matches: + desktop_file = os.path.join(path, matches[0]) + break + + # 3. Try content searching (if no file match found yet) + if not desktop_file: + for f in files: + if not f.endswith(".desktop"): + continue try: - with open(desktop_file, "r", encoding="utf-8") as f: - content = f.read() - - name = wmclass.title() - version = "" - comment = "" - icon = wmclass.lower() - exec_cmd = "" - categories = "" - - # Parse desktop file - in_desktop_entry = False - for line in content.split("\n"): - line = line.strip() - if line == "[Desktop Entry]": - in_desktop_entry = True - continue - elif line.startswith("[") and line.endswith("]"): - in_desktop_entry = False - continue - - if not in_desktop_entry or "=" not in line: - continue + full_path = os.path.join(path, f) + with open(full_path, "r", encoding="utf-8") as df: + content = df.read() + if ( + f"StartupWMClass={wmclass}" in content + or f"Exec={wmclass}" in content + ): + desktop_file = full_path + break + except Exception: + continue + if desktop_file: + try: + with open(desktop_file, "r", encoding="utf-8") as f: + content = f.read() + + name = wmclass.title() + version = "" + comment = "" + icon = wmclass.lower() + exec_cmd = "" + categories = "" + + # Parse desktop file + current_section = None + for line in content.split("\n"): + line = line.strip() + if not line or line.startswith("#"): + continue + + if line.startswith("[") and line.endswith("]"): + current_section = line + continue + + if current_section == "[Desktop Entry]" and "=" in line: key, value = line.split("=", 1) if key == "Name": name = value @@ -166,7 +181,6 @@ def get_app_info(wmclass): elif key == "Comment": comment = value elif key == "GenericName" and not comment: - # Use GenericName as fallback description comment = value elif key == "Icon": icon = value @@ -175,21 +189,20 @@ def get_app_info(wmclass): elif key == "Categories": categories = value - # Get executable location - location = get_executable_path(exec_cmd) - - return { - "name": name, - "version": version, - "comment": comment, - "icon": icon, - "exec": exec_cmd, - "location": location or "", - "categories": categories, - "desktop_file": desktop_file, - } - except Exception: - continue + location = get_executable_path(exec_cmd) + + return { + "name": name, + "version": version, + "comment": comment, + "icon": icon, + "exec": exec_cmd, + "location": location or "", + "categories": categories, + "desktop_file": desktop_file, + } + except Exception: + continue # Fallback: try to find executable in PATH location = "" @@ -241,29 +254,32 @@ def setup_ui(self): # App Icon logo_box = Gtk.Box(halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER) + logo = None + try: - # Use IconResolver's get_icon_pixbuf method like other parts of the project + # 1. Try resolving via IconResolver icon_pixbuf = self.icon_resolver.get_icon_pixbuf(app_info["icon"], 128) if icon_pixbuf: logo = Gtk.Image.new_from_pixbuf(icon_pixbuf) - else: - raise Exception("Icon pixbuf not found") except Exception: - # Fallback: try direct file path if it's an absolute path + pass + + if not logo: try: - if app_info["icon"].startswith("/") and os.path.exists( - app_info["icon"] - ): + # 2. Try direct file path + icon_path = app_info["icon"] + if icon_path.startswith("/") and os.path.exists(icon_path): pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( - app_info["icon"], 128, 128, preserve_aspect_ratio=True + icon_path, 128, 128, True ) logo = Gtk.Image.new_from_pixbuf(pixbuf) - else: - raise Exception("Direct path failed") except Exception: - # Final fallback: emoji - logo = Gtk.Label() - logo.set_markup("<span size='72000'>๐Ÿ“ฑ</span>") + pass + + if not logo: + # 3. Final fallback + logo = Gtk.Label() + logo.set_markup("<span size='70000'>๐Ÿ“ฆ</span>") logo_box.pack_start(logo, False, False, 0) logo_box.set_margin_bottom(15) @@ -271,7 +287,7 @@ def setup_ui(self): # App Name app_name_label = Gtk.Label() app_name_label.set_markup( - f"<b><span size='18000'>{app_info['name']}</span></b>" + f"<b><span size='18000'>{escape_markup_text(app_info['name'])}</span></b>" ) app_name_label.set_halign(Gtk.Align.CENTER) app_name_label.set_margin_bottom(5) @@ -295,7 +311,9 @@ def setup_ui(self): desc_frame.set_shadow_type(Gtk.ShadowType.IN) description_label = Gtk.Label() - description_label.set_markup(f"<i><b>{app_info['comment']}</b></i>") + description_label.set_markup( + f"<i><b>{escape_markup_text(app_info['comment'])}</b></i>" + ) description_label.set_justify(Gtk.Justification.CENTER) description_label.set_halign(Gtk.Align.CENTER) description_label.set_line_wrap(True) @@ -345,7 +363,11 @@ def make_info_row(label_text, value_text, row): label.set_markup(f"<b>{label_text}:</b>") value = Gtk.Label() - value.set_markup(f'<span foreground="#727272">{value_text}</span>') + value.set_markup( + f"<span foreground='#727272'>{ + escape_markup_text(str(value_text)) + }</span>" + ) value.set_halign(Gtk.Align.START) value.set_line_wrap(True) value.set_max_width_chars(30) @@ -409,6 +431,7 @@ def toggle(self, b): class About(Gtk.Window): def __init__(self): super().__init__(title="About Menu") + self.set_wmclass("modus-about", "Modus") self.set_default_size(300, 550) self.set_size_request(300, 500) self.set_resizable(False) @@ -425,7 +448,7 @@ def __init__(self): # Logo logo_box = Gtk.Box(halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER) pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( - get_relative_path("../config/assets/icons/misc/imac.svg"), + get_relative_path("../../assets/icons/misc/imac.svg"), 158, 108, preserve_aspect_ratio=True, @@ -438,7 +461,9 @@ def __init__(self): name_label = Gtk.Label() name_label.set_margin_top(30) name_label.set_markup( - f"<b><span size='16000'>{read_dmi('product_name')}</span></b>" + f"<b><span size='16000'>{ + escape_markup_text(read_dmi('product_name')) + }</span></b>" ) vendor_label = Gtk.Label(label=read_dmi("sys_vendor")) @@ -504,6 +529,7 @@ def make_label(text, align_end=False, name=None): button_box = Gtk.Box(halign=Gtk.Align.CENTER) button_box.set_margin_top(20) more_info_button = Gtk.Button(label="More Info...", name="more-info-button") + setup_cursor_hover(more_info_button, "pointer") more_info_button.connect("clicked", self.open_more_info) button_box.pack_start(more_info_button, False, False, 0) @@ -530,8 +556,19 @@ def open_more_info(self, button): # TODO: Implement the logic to open more information pass - def toggle(self, b): + def toggle(self, b=None): if self.get_visible(): self.hide() else: self.show_all() + self.present() + + +_about_window = None + + +def get_about_window(): + global _about_window + if _about_window is None: + _about_window = About() + return _about_window diff --git a/widgets/wifi_password_dialog.py b/src/shared/dialogs/wifi_password_dialog.py similarity index 98% rename from widgets/wifi_password_dialog.py rename to src/shared/dialogs/wifi_password_dialog.py index 9d53586e..566b3deb 100644 --- a/widgets/wifi_password_dialog.py +++ b/src/shared/dialogs/wifi_password_dialog.py @@ -1,18 +1,11 @@ -import gi -from gi.repository import Gdk, GLib - +from fabric.utils import Gdk, GLib from fabric.widgets.box import Box from fabric.widgets.button import Button from fabric.widgets.entry import Entry from fabric.widgets.image import Image from fabric.widgets.label import Label - -# from widgets.wayland import WaylandWindow as Window - from fabric.widgets.window import Window -gi.require_version("Gtk", "3.0") - class WiFiPasswordDialog(Window): def __init__( @@ -280,7 +273,7 @@ def _focus_and_select_password(self): self.password_entry.grab_focus() self.password_entry.select_region(0, -1) return False - except: + except Exception: return False def get_password(self): diff --git a/styles/about.css b/src/shared/styles/about.css similarity index 97% rename from styles/about.css rename to src/shared/styles/about.css index 77ace66b..709515b4 100644 --- a/styles/about.css +++ b/src/shared/styles/about.css @@ -68,7 +68,6 @@ } #more-info-button:hover { background-color: rgb(72, 130, 255); - background-color: #2369ff; } #more-info-button label { diff --git a/styles/battery-widget.css b/src/shared/styles/battery-widget.css similarity index 93% rename from styles/battery-widget.css rename to src/shared/styles/battery-widget.css index 763cfe46..27035c35 100644 --- a/styles/battery-widget.css +++ b/src/shared/styles/battery-widget.css @@ -1,3 +1,11 @@ +#battery-window { + margin: 6px; + /* background-color: alpha(#fff, 0.09); */ + border: 1px solid alpha(#111, 0.3); + box-shadow: inset 0 0 200px 0 alpha(#111, 0.3); + border-radius: 8px; +} + #battery-widget { background: rgba(0, 0, 0, 0); padding-left: 5px; diff --git a/src/shared/styles/constant.css b/src/shared/styles/constant.css new file mode 100644 index 00000000..0d4bfe4c --- /dev/null +++ b/src/shared/styles/constant.css @@ -0,0 +1,6 @@ +@define-color bg alpha(#fff, 0.07); +@define-color hover alpha(#fff, 0.1); + +@define-color border var(--outline); +@define-color active alpha(#fff, 0.9); +@define-color urgent alpha(var(--on-error), 0.7); diff --git a/styles/controlcenter.css b/src/shared/styles/controlcenter.css similarity index 96% rename from styles/controlcenter.css rename to src/shared/styles/controlcenter.css index a60738d2..863966f8 100644 --- a/styles/controlcenter.css +++ b/src/shared/styles/controlcenter.css @@ -3,6 +3,7 @@ font-family: "SF Pro Rounded"; font-weight: bold; } + #control-center-menu { background-color: transparent; border-radius: 12px; @@ -32,6 +33,7 @@ font-size: 16px; font-weight: bold; } + #control-center-widgets { background-color: alpha(#010101, 0.01); box-shadow: inset 0 0 0 1px alpha(#aaa, 0.4); @@ -39,8 +41,9 @@ border-radius: 12px; padding: 0.5rem; } -#focus-widget { -} + +#focus-widget {} + #wb-widget, #brightness-menu { background-color: alpha(#000, 0.08); @@ -48,6 +51,7 @@ border: 1px solid alpha(#111, 0.4); border-radius: 12px; } + /* Widgets */ #wifi-widget, #bluetooth-widget, @@ -301,9 +305,11 @@ margin-top: -10px; /* border: 1px solid alpha(#fff, 1); */ } + #volume-widget-slider { /* margin-bottom: 2px; */ } + #volume-widget-icon { font-size: 22px; margin-top: -35px; @@ -344,6 +350,7 @@ /* min-height: 40px; */ /* margin: -9px; */ } + #control-center-menu scale { background-color: transparent; margin-top: 10px; @@ -377,7 +384,7 @@ /* Per-app volume control styles */ #per-app-volume-control { min-height: 140px; - min-width: 350px; + min-width: 328px; /* background-color: alpha(#000, 0.3); */ /* background-color: alpha(#999, 0.1); */ /* box-shadow: inset 0 0 200px 0 alpha(#111, 0.3); */ @@ -392,6 +399,7 @@ min-height: 120px; min-width: 120px; } + #back-button { padding: 8px 12px; background-color: alpha(#fff, 0.1); @@ -402,6 +410,7 @@ font-weight: 500; transition: background-color 0.2s ease; } + #back-button:hover { background-color: alpha(#fff, 0.18); } @@ -617,8 +626,10 @@ /* macOS-style expanded player - more compact */ #macos-outer-player-box { - min-height: 80px; /* Reduced height */ - min-width: 380px; /* Slightly wider to accommodate expanded track info */ + min-height: 80px; + /* Reduced height */ + min-width: 380px; + /* Slightly wider to accommodate expanded track info */ /* margin: 10px; */ padding: 10px; /* background-color: alpha(#000, 0.4); */ @@ -633,6 +644,7 @@ min-height: 70px; border-radius: 9px; } + #macos-album-image { min-width: 70px; min-height: 70px; @@ -651,6 +663,7 @@ background-size: cover; box-shadow: 0 2px 8px alpha(#000, 0.3); } + #macos-album-image image { min-width: 70px; min-height: 70px; @@ -686,6 +699,7 @@ color: alpha(#999, 0.8); margin-bottom: 8px; } + /* macOS seek bar styling */ #macos-seek-bar { background-color: transparent; @@ -735,17 +749,20 @@ /* macOS control buttons - more compact */ #macos-button-box { - margin-top: -1px; /* Reduced top margin */ + margin-top: -1px; + /* Reduced top margin */ } #macos-control-button { background: transparent; border: none; border-radius: 50%; - min-width: 28px; /* Slightly smaller */ + min-width: 28px; + /* Slightly smaller */ opacity: 0.7; min-height: 28px; - padding: 5px; /* Reduced padding */ + padding: 5px; + /* Reduced padding */ color: #ffffff; transition: all 0.15s ease; } @@ -758,10 +775,12 @@ background: transparent; border: none; border-radius: 50%; - min-width: 36px; /* Slightly smaller */ + min-width: 36px; + /* Slightly smaller */ opacity: 0.7; min-height: 36px; - padding: 7px; /* Reduced padding */ + padding: 7px; + /* Reduced padding */ color: #ffffff; transition: all 0.15s ease; } @@ -772,16 +791,19 @@ /* macOS player switcher dots - compact horizontal layout */ #macos-stack-buttons-box { - margin: 2px 5px; /* Minimal margins */ + margin: 2px 5px; + /* Minimal margins */ } .macos-switcher-dot { background: alpha(#fff, 0.3); /* border: none; */ border-radius: 50%; - min-width: 12px; /* Smaller dots */ + min-width: 12px; + /* Smaller dots */ min-height: 12px; - margin: 2px; /* Reduced margins */ + margin: 2px; + /* Reduced margins */ /* transition: all 0.2s ease; */ } @@ -791,7 +813,8 @@ .macos-switcher-dot.active { background: #ffffff; - box-shadow: 0 0 0 1px alpha(#fff, 0.3); /* Smaller glow */ + box-shadow: 0 0 0 1px alpha(#fff, 0.3); + /* Smaller glow */ } /* Per app-volume-item */ @@ -801,9 +824,11 @@ padding-right: 14px; border-bottom: 1px solid alpha(#fff, 0.1); } + #app-icon { margin-top: 8px; } + .app-name-compact { font-size: 14px; font-weight: bold; @@ -914,6 +939,7 @@ #wifi-other-button:hover { background-color: rgba(255, 255, 255, 0.1); } + #wifi-other-button:last-child { margin-bottom: 3px; } @@ -922,6 +948,7 @@ /* background-color: #000; */ border-radius: 8px; } + .device-slot:hover { background-color: rgba(255, 255, 255, 0.05); } @@ -935,6 +962,7 @@ font-size: 12px; font-weight: bold; } + #app-volume-header { font-size: 16px; padding-left: 5px; @@ -949,6 +977,7 @@ padding: 2px; /* margin-right: 8px; */ } + .wifi-icon-box-connected { background-color: #007aff; border-radius: 50%; @@ -967,6 +996,7 @@ font-family: "SF Pro Rounded"; font-weight: bold; } + .status-label { font-size: 11px; font-weight: 500; @@ -981,8 +1011,9 @@ #app-control-box { min-width: 100px; } + #caffeine-widget, #flight-widget { padding: 0px 0 5px 0; min-width: 50px; -} +} \ No newline at end of file diff --git a/styles/dock.css b/src/shared/styles/dock.css similarity index 57% rename from styles/dock.css rename to src/shared/styles/dock.css index 24eb0f75..639cf068 100644 --- a/styles/dock.css +++ b/src/shared/styles/dock.css @@ -1,27 +1,28 @@ #dock { - background-color: alpha(#fff, 0.07); - padding: 4px 4px; - margin: 4px 4px; - border: none; - border-radius: 16px; - transition: all 0.2s cubic-bezier(0.165, 0.84, 0.44, 1); + background-color: @bg; + padding: 4px 4px; + margin: 4px 4px; + border: none; + border-radius: 16px; + transition: all 0.2s cubic-bezier(0.165, 0.84, 0.44, 1); } #dock.shown { - transition: all 0.2s cubic-bezier(0.165, 0.84, 0.44, 1); + transition: all 0.2s cubic-bezier(0.165, 0.84, 0.44, 1); } #dock_item_main_container { - transition: all 0.2s cubic-bezier(0.165, 0.84, 0.44, 1); + transition: all 0.2s cubic-bezier(0.165, 0.84, 0.44, 1); } #dock_item.shown:hover #dock_item_main_container { - margin-top: -12px; + margin-top: -12px; } #dock_item.shown.semi_hovered #dock_item_main_container { - margin-top: -7px; + margin-top: -7px; } + /**/ /* #dock_item.shown.semi_hovered #dock_item_indicator { */ /* margin-top: 11px; */ @@ -40,8 +41,8 @@ /* } */ #dock_item.shown.activated #dock_item_icon { - opacity: 1; - /* background-color: #01458e; */ + opacity: 1; + /* background-color: #01458e; */ } /* #dock_item.shown.activated #dock_item_indicator { */ @@ -51,9 +52,9 @@ /* } */ #dock_item_icon { - border-radius: 13px; - opacity: 0.7; - transition: all 0.2s cubic-bezier(0.25, 0.1, 0.25, 1); + border-radius: 13px; + opacity: 0.7; + transition: all 0.2s cubic-bezier(0.25, 0.1, 0.25, 1); } /* #dock_item_indicator { */ @@ -63,31 +64,31 @@ /* } */ #dock_item { - transition: margin 0.25s cubic-bezier(0.25, 0.1, 0.25, 1); + transition: margin 0.25s cubic-bezier(0.25, 0.1, 0.25, 1); } #dock_item.shown { - padding-left: 4px; - opacity: 1; + padding-left: 4px; + opacity: 1; } #dock_item.shown:first-child { - padding-left: 0px; + padding-left: 0px; } .dock_separator { - transition: all 0.25s cubic-bezier(0.25, 0.1, 0.25, 1); - min-width: 1.5px; - min-height: 50px; - margin: 0px 4px; - border-radius: 2px; - background-color: #2b5ea7; + transition: all 0.25s cubic-bezier(0.25, 0.1, 0.25, 1); + min-width: 1.5px; + min-height: 50px; + margin: 0px 4px; + border-radius: 2px; + background-color: #2b5ea7; } .dock_separator.hidden { - min-width: 0px; - background-color: transparent; - margin: 0px; + min-width: 0px; + background-color: transparent; + margin: 0px; } /* #dock_item.shown:not(.activated) { */ @@ -95,4 +96,4 @@ /* min-height: 0px; */ /* margin-top: 0px; */ /* background-color: transparent; */ -/* } */ +/* } */ \ No newline at end of file diff --git a/styles/dropdown.css b/src/shared/styles/dropdown.css similarity index 100% rename from styles/dropdown.css rename to src/shared/styles/dropdown.css diff --git a/src/shared/styles/launcher.css b/src/shared/styles/launcher.css new file mode 100644 index 00000000..236d78d0 --- /dev/null +++ b/src/shared/styles/launcher.css @@ -0,0 +1,230 @@ +#launcher { + background-color: alpha(#000, 0.3); + padding: 0; + border-radius: 12px; + min-width: 640px; + border: 1px solid rgba(255, 255, 255, 0.1); +} + +/* #launcher-search { */ +/* color: #000; */ +/* } */ +#header_box { + padding: 0px 20px 0 20px; + color: #000; +} + +#close-button, +#config-button { + background-color: transparent; + border-radius: 6px; + padding: 6px; + transition: all 0.15s ease; +} + +#close-button:hover, +#close-button:focus, +#config-button:hover, +#config-button:focus { + background-color: rgba(255, 255, 255, 0.1); + border-radius: 6px; +} + +#close-button.focused, +#config-button.focused { + background-color: rgba(0, 122, 255, 0.2); + border-radius: 6px; + box-shadow: 0 0 0 2px rgba(0, 122, 255, 0.4); +} + +#close-button.focused #close-label { + color: #007aff; +} + +#config-button.focused #config-label { + color: #007aff; +} + +#close-button:active { + background-color: rgba(255, 255, 255, 0.2); + border-radius: 6px; +} + +#close-label { + color: rgba(255, 255, 255, 0.7); + font-size: 18px; +} + +#close-button:active #close-label { + color: rgba(255, 255, 255, 0.9); +} + +#config-button:active { + background-color: rgba(255, 255, 255, 0.2); + border-radius: 6px; +} + +#config-label { + color: rgba(255, 255, 255, 0.7); + font-size: 18px; +} + +#config-button:active #config-label { + color: rgba(255, 255, 255, 0.9); +} + +#launcher-icon-label { + font-size: 20px; + padding: 6px; + color: rgba(255, 255, 255, 0.8); +} + +#launcher-search { + font-weight: 400; + font-size: 36px; + background-color: transparent; + color: rgba(255, 255, 255, 0.95); + border: none; + border-radius: 0; + padding: 12px 0; + margin: 0; +} + +#launcher-search:focus { + background-color: transparent; + box-shadow: none; + border: none; +} + +#launcher-search selection { + color: white; + background-color: rgba(0, 122, 255, 0.8); +} + +#launcher-results-scroll { + margin: 0; + border-radius: 0; + background: transparent; + padding: 0 20px 20px 20px; +} + +#launcher-results-scroll scrollbar { + border-radius: 0; + background-color: transparent; + padding: 0; + margin: 0; + min-width: 0; +} + +#launcher-results-scroll scrollbar slider { + border-radius: 2px; + min-width: 4px; + min-height: 20px; + background-color: rgba(255, 255, 255, 0.3); + margin: 0; +} + +#launcher-results-scroll scrollbar:hover slider { + background-color: rgba(255, 255, 255, 0.5); +} + +#launcher-results { + background: transparent; + margin-top: 8px; +} + +#launcher-result-item { + border-radius: 8px; + padding: 12px 16px; + margin: 2px 0; + min-height: 64px; + transition: all 0.15s ease; + background: transparent; +} + +@keyframes loadSlot { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + +#launcher-result-item:focus, +#launcher-result-item:selected, +#launcher-result-item:hover, +#launcher-result-item.selected { + border-radius: 8px; + background-color: rgba(0, 122, 255, 0.8); + padding: 12px 16px; + margin: 2px 0; +} + +#launcher-result-item.selected #result-item-title { + color: white; + font-weight: 600; +} + +#launcher-result-item.selected #result-item-subtitle { + color: rgba(255, 255, 255, 0.8); +} + +#launcher-result-item.selected #result-item-plugin { + color: rgba(255, 255, 255, 0.6); +} + +#result-item-main { + min-height: 56px; +} + +#launcher-result-item { + min-height: 56px; +} + +#result-item-icon { + min-width: 56px; + min-height: 56px; + margin-right: 16px; + border-radius: 12px; +} + +#result-item-title { + font-size: 18px; + font-weight: 500; + color: rgba(255, 255, 255, 0.95); + margin-top: 4px; + margin-bottom: 2px; +} + +#result-item-subtitle { + font-size: 14px; + color: rgba(255, 255, 255, 0.6); + margin-bottom: 4px; +} + +#result-item-plugin { + font-size: 12px; + color: rgba(255, 255, 255, 0.4); + font-style: normal; + margin-bottom: 4px; + opacity: 1; + font-weight: 400; +} + +#network-password-entry { + border: 1px solid rgba(255, 255, 255, 0.2); + background: rgba(255, 255, 255, 0.05); + color: rgba(255, 255, 255, 0.9); + padding: 12px; + border-radius: 8px; + margin-bottom: 8px; + font-size: 14px; +} + +#network-password-entry:focus { + border: 1px solid rgba(0, 122, 255, 0.6); + background: rgba(255, 255, 255, 0.1); + box-shadow: 0 0 0 3px rgba(0, 122, 255, 0.2); +} diff --git a/styles/lock.css b/src/shared/styles/lock.css similarity index 98% rename from styles/lock.css rename to src/shared/styles/lock.css index 00fe279f..930f614e 100644 --- a/styles/lock.css +++ b/src/shared/styles/lock.css @@ -50,6 +50,7 @@ #face-icon image { margin-bottom: 10px; + border-radius: 99px; } #face-icon { padding-bottom: 10px; diff --git a/src/shared/styles/main.css b/src/shared/styles/main.css new file mode 100644 index 00000000..e4abf1b4 --- /dev/null +++ b/src/shared/styles/main.css @@ -0,0 +1,27 @@ +@import url("./colors.css"); +@import url("./constant.css"); +@import url("./panel.css"); +@import url("./dock.css"); +@import url("./switcher.css"); +@import url("./osd.css"); +@import url("./controlcenter.css"); +@import url("./notification.css"); +@import url("./dropdown.css"); +@import url("./about.css"); +@import url("./notification-center.css"); +@import url("./player.css"); +@import url("./widgets.css"); +@import url("./tray.css"); +@import url("./battery-widget.css"); +@import url("./lock.css"); +@import url("./todo.css"); +@import url("./launcher.css"); +@import url("./switcher.css"); +@import url("./settings.css"); + +* { + all: unset; + color: var(--foreground); + font-size: unset; + font-family: "SF Pro Rounded"; +} \ No newline at end of file diff --git a/styles/notification-center.css b/src/shared/styles/notification-center.css similarity index 100% rename from styles/notification-center.css rename to src/shared/styles/notification-center.css diff --git a/styles/notification.css b/src/shared/styles/notification.css similarity index 95% rename from styles/notification.css rename to src/shared/styles/notification.css index 35a7db00..04d4d463 100644 --- a/styles/notification.css +++ b/src/shared/styles/notification.css @@ -34,7 +34,11 @@ color: var(--shadow); } -#notification-image image { +#notification-icon { + border-radius: 16px; +} + +#notification-image { border-radius: 16px; color: var(--on-surface); } diff --git a/src/shared/styles/osd.css b/src/shared/styles/osd.css new file mode 100644 index 00000000..4c4d8eb2 --- /dev/null +++ b/src/shared/styles/osd.css @@ -0,0 +1,37 @@ +#osd-container { + background-color: alpha(#fff, 0.09); + padding: 12px 20px; + margin: 70px; + min-width: 180px; + min-height: 200px; + border-radius: 16px; +} + +#osd-container scale { + min-width: 180px; +} + +#osd-container trough { + background: var(--surface); + min-height: 15px; + margin-right: 4px; + transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); + border-radius: 16px; +} + +#osd-container trough highlight { + border-radius: 100px; + background: var(--primary); + transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +#osd-container.muted trough highlight, +#osd-container.muted slider, +#osd-container scale.muted trough highlight, +#osd-container scale.muted slider { + background-color: var(--surface-bright); +} + +#osd-image.muted { + color: var(--outline); +} \ No newline at end of file diff --git a/src/shared/styles/panel.css b/src/shared/styles/panel.css new file mode 100644 index 00000000..997e1536 --- /dev/null +++ b/src/shared/styles/panel.css @@ -0,0 +1,73 @@ +#panel { + background-color: @bg; + margin: -4px 0; + transition: all 120ms ease-in-out; +} + +#modules-left, +#modules-right { + margin: 0 5px; + padding: 2px 0; +} + +#panel-button:hover, +#panel-button.active, +#battery-button:hover, +#battery-button.active, +#network-button:hover, +#network-button.active, +#bt-button:hover, +#bt-button.active, +#tray-button:hover, +#tray-button.active, +#global-menu:hover, +#global-menu.active, +#workspaces>button:hover { + background-color: @hover; +} + +#panel-button, +#battery-button, +#network-button, +#bt-button, +#tray-button { + margin: 5px 0; + padding: 0 2px; + border-radius: 8px; +} + +#menubar label, +#battery-label, +#date-time { + font-weight: 500; +} + +#global-menu { + margin: 5px 2px; + padding: 0 5px; + border-radius: 5px; +} + +#date-time { + margin: 0 4px; +} + +#workspaces>button { + margin: 6px 0; + padding: 0 16px; + border-radius: 8px; + min-width: 12px; + border: 1px solid @border; +} + +#workspaces>button.active { + background-color: @active; +} + +#workspaces>button.active label { + color: var(--shadow); +} + +#workspaces>button.urgent { + background-color: @urgent; +} diff --git a/src/shared/styles/password-dialog.css b/src/shared/styles/password-dialog.css new file mode 100644 index 00000000..62230274 --- /dev/null +++ b/src/shared/styles/password-dialog.css @@ -0,0 +1,132 @@ +#wifi-password-dialog { + background-color: transparent; +} + +#wifi-dialog-background { + background-color: alpha(#fff, 0.05); + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.1); + padding: 20px; + min-width: 450px; +} + +#wifi-dialog-title-container { + /* margin-bottom: 12px; */ +} + +#wifi-dialog-icon { + color: #007aff; + margin-right: 4px; +} + +#wifi-dialog-title { + color: #ffffff; + font-size: 14px; + font-weight: 500; +} + +#wifi-dialog-error { + color: #ff4444; + font-size: 12px; + font-weight: 500; + /* margin-bottom: 8px; */ +} + +#wifi-dialog-password-container { + margin: 5px 0; +} + +#wifi-dialog-password-label { + color: #ffffff; + font-size: 13px; + font-weight: 500; + margin-bottom: 4px; +} + +#wifi-dialog-password-entry { + background-color: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 6px; + padding: 8px 12px; + color: #ffffff; + font-size: 13px; +} + +#wifi-dialog-password-entry:focus { + border-color: #007aff; + box-shadow: 0 0 0 2px rgba(0, 122, 255, 0.3); + background-color: rgba(255, 255, 255, 0.15); +} + +#wifi-dialog-show-password-box { + margin-top: 4px; +} + +#wifi-dialog-show-password-button { + background-color: transparent; + border: none; + padding: 4px; + border-radius: 4px; + min-width: 24px; + min-height: 24px; +} + +#wifi-dialog-show-password-button:hover { + background-color: alpha(#fff, 0.1); +} + +#wifi-dialog-show-password-button image { + color: #ffffff; +} + +#wifi-dialog-show-password-label { + color: #ffffff; + font-size: 12px; +} + +#wifi-dialog-button-box { + margin-top: -15px; +} + +#wifi-dialog-cancel-button, +#wifi-dialog-join-button { + border-radius: 6px; + font-size: 13px; + font-weight: 500; + min-width: 80px; + min-height: 30px; +} + +#wifi-dialog-cancel-button { + background-color: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + color: #ffffff; +} + +#wifi-dialog-cancel-button:hover { + background-color: rgba(255, 255, 255, 0.15); +} + +#wifi-dialog-join-button { + background-color: #007aff; + border: 1px solid #007aff; + color: #ffffff; +} + +#wifi-dialog-join-button:hover { + background-color: #0056cc; + border-color: #0056cc; +} + +#wifi-dialog-join-button.disabled { + opacity: 0.5; + background-color: #555555; + border-color: #555555; + color: #aaaaaa; +} + +#wifi-dialog-join-button.disabled:hover { + background-color: #555555; + border-color: #555555; + color: #aaaaaa; +} diff --git a/styles/player.css b/src/shared/styles/player.css similarity index 99% rename from styles/player.css rename to src/shared/styles/player.css index 1a9b6513..437084b4 100644 --- a/styles/player.css +++ b/src/shared/styles/player.css @@ -40,7 +40,7 @@ } #outer-no-player-box-c { min-height: 55px; - min-width: 310px; + min-width: 345px; border-radius: 8px; /* margin: 0 6px; */ /* background-color: alpha(#000, 0.3); */ diff --git a/src/shared/styles/settings.css b/src/shared/styles/settings.css new file mode 100644 index 00000000..4f61e87b --- /dev/null +++ b/src/shared/styles/settings.css @@ -0,0 +1,146 @@ +#settings-window { + background-color: var(--background); + border-radius: 12px; + border: 1px solid var(--border); + min-width: 850px; + min-height: 600px; +} + +#settings-main-box { + padding: 0; +} + +#settings-sidebar { + background-color: rgba(0, 0, 0, 0.05); + padding: 20px 10px; + min-width: 200px; + border-right: 1px solid var(--border); +} + +#settings-sidebar-button { + padding: 10px 15px; + border-radius: 8px; + margin-bottom: 2px; +} + +#settings-sidebar-button:hover { + background-color: rgba(255, 255, 255, 0.05); +} + +#settings-sidebar-button Label { + font-size: 14px; + font-weight: 500; +} + +#settings-sidebar-button.active { + background-color: var(--accent); +} + +#settings-sidebar-button.active Label { + color: var(--accent-foreground); +} + +#settings-content-wrapper { + padding: 30px; +} + +#settings-page-title { + font-size: 24px; + font-weight: 700; + margin-bottom: 20px; + color: var(--foreground); +} + +#settings-row { + padding: 12px 15px; + border-radius: 8px; + background-color: rgba(255, 255, 255, 0.03); + margin-bottom: 2px; +} + +#settings-row-label { + font-size: 15px; + font-weight: 500; +} + +#settings-row-description { + font-size: 12px; + color: var(--foreground-dim); +} + +#settings-switch { + background-color: rgba(255, 255, 255, 0.1); + padding: 6px 12px; + border-radius: 20px; + min-width: 50px; + transition: all 0.2s ease; +} + +#settings-switch.active { + background-color: var(--accent); + color: var(--accent-foreground); +} + +#settings-entry { + background-color: rgba(255, 255, 255, 0.05); + padding: 8px 12px; + border-radius: 6px; + border: 1px solid var(--border); + min-width: 200px; + color: var(--foreground); +} + +#settings-entry:focus { + border-color: var(--accent); +} + +#settings-combo { + background-color: rgba(255, 255, 255, 0.05); + padding: 4px 8px; + border-radius: 6px; + border: 1px solid var(--border); + min-width: 150px; + color: var(--foreground); +} + +#settings-combo:hover { + background-color: rgba(255, 255, 255, 0.1); +} + +#settings-list-tags { + margin-bottom: 5px; +} + +#settings-list-tag { + background-color: rgba(255, 255, 255, 0.08); + border-radius: 15px; + padding: 4px 10px; + margin-right: 5px; + margin-bottom: 5px; +} + +#settings-list-tag:hover { + background-color: rgba(255, 255, 255, 0.15); +} + +#settings-list-tag Label { + font-size: 13px; +} + +#settings-list-tag-close { + color: var(--foreground-dim); + font-size: 10px; + margin-left: 3px; +} + +#settings-list-tag-close:hover { + color: var(--accent); +} + +#settings-list-entry { + background-color: rgba(255, 255, 255, 0.03); + border: 1px dashed var(--border); + padding: 6px 12px; + border-radius: 6px; + font-size: 13px; +} \ No newline at end of file diff --git a/src/shared/styles/switcher.css b/src/shared/styles/switcher.css new file mode 100644 index 00000000..8691d055 --- /dev/null +++ b/src/shared/styles/switcher.css @@ -0,0 +1,48 @@ +#application-switcher-container { + background-color: var(--shadow); + border-radius: 20px; + padding: 24px; +} + +#application-switcher-view { + padding: 0px; + margin-bottom: 12px; +} + +#switcher-button { + padding: 12px; + min-width: 100px; + transition: all 0.2s ease; +} + +#window-button { + background-color: transparent; + border-radius: 12px; +} + +#window-button.active { + background-color: var(--surface); +} + +.switcher-selection-label { + color: var(--foreground); + font-size: 16px; + font-weight: 600; + margin-top: 8px; +} + +.switcher-workspace-label { + color: var(--foreground); + opacity: 0.6; + font-size: 12px; + margin-top: 4px; +} + +#switcher-icon-box { + border-radius: 12px; + padding: 4px; +} + +#window-row { + padding: 0px; +} \ No newline at end of file diff --git a/src/shared/styles/todo.css b/src/shared/styles/todo.css new file mode 100644 index 00000000..e69de29b diff --git a/styles/tray.css b/src/shared/styles/tray.css similarity index 100% rename from styles/tray.css rename to src/shared/styles/tray.css diff --git a/styles/widgets.css b/src/shared/styles/widgets.css similarity index 70% rename from styles/widgets.css rename to src/shared/styles/widgets.css index 8656200e..acd217c8 100644 --- a/styles/widgets.css +++ b/src/shared/styles/widgets.css @@ -1,41 +1,89 @@ -/* Weather Widget CSS */ #weather-container { - background: linear-gradient(to bottom, #202020, #141414); margin: 5px 5px 20px 5px; min-width: 170px; min-height: 170px; - border-radius: 16px; - transition: background 0.8s ease-in-out; + border-radius: 18px; + transition: all 0.5s ease-in-out; + background: #202020; } -#weather-widget label { - margin-left: 12px; +/* Base Weather Layout */ +#weather-widget { + padding: 12px 14px; } #city { - margin-top: 10px; - color: var(--blue-dim); + color: rgba(255, 255, 255, 0.85); font-weight: 600; - font-size: 20px; + font-size: 18px; } + #temperature { - font-size: 50px; - font-weight: 400; + font-size: 64px; + font-weight: 600; + color: white; + margin-top: -5px; + margin-bottom: -5px; } #condition-emoji { - margin-top: 5px; - font-size: 16px; + margin-top: -2px; } #condition { - font-size: 15px; + font-size: 16px; font-weight: 600; + color: white; } + #feels-like { - font-weight: 600; + font-weight: 500; font-size: 15px; + color: rgba(255, 255, 255, 0.8); +} + +/* Weather Condition Gradients */ +.weather-clear { + background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); +} + +.weather-mostly-clear { + background: linear-gradient(135deg, #439cfb 0%, #6bc1f9 100%); +} + +.weather-partly-cloudy { + background: linear-gradient(135deg, #6a85b6 0%, #bac8e0 100%); +} + +.weather-overcast { + background: linear-gradient(135deg, #485563 0%, #29323c 100%); +} + +.weather-fog { + background: linear-gradient(135deg, #757f9a 0%, #d7dde8 100%); +} + +.weather-light-rain { + background: linear-gradient(135deg, #4b6cb7 0%, #182848 100%); +} + +.weather-rain { + background: linear-gradient(135deg, #2b5876 0%, #4e4376 100%); +} + +.weather-heavy-rain { + background: linear-gradient(135deg, #141e30 0%, #243b55 100%); +} + +.weather-snow, +.weather-heavy-snow { + background: linear-gradient(135deg, #83a4d4 0%, #b6fbff 100%); } + +.weather-storm { + background: linear-gradient(135deg, #0f2027 0%, #203a43 50%, #2c5364 100%); +} + /* # */ /* Calendar Widget */ @@ -119,6 +167,7 @@ #date-widget label { margin-top: 10px; } + #day label, #month label { color: var(--foreground); @@ -136,6 +185,7 @@ font-size: 90px; font-weight: 600; } + #day label { color: alpha(var(--blue-dim), 0.9); } @@ -156,14 +206,17 @@ min-height: 170px; border-radius: 16px; } + #ram-progress { margin-left: 12px; color: var(--foreground); } + #progress-label { font-weight: 600; font-size: 13px; } + #progress { border: solid 8px var(--blue-dim); color: alpha(#aaaaaa, 0.7); @@ -213,6 +266,7 @@ font-size: 14px; font-weight: 600; } + #component-name { font-size: 10px; -} +} \ No newline at end of file diff --git a/src/shared/widgets/animator.py b/src/shared/widgets/animator.py new file mode 100644 index 00000000..96b8a945 --- /dev/null +++ b/src/shared/widgets/animator.py @@ -0,0 +1,263 @@ +# Author: Yousef EL-Darsh +# License (SPDX): AGPL-3.0-or-later + +from functools import cache +from typing import Protocol, cast + +from fabric.core.service import Property, Service, Signal +from fabric.utils import GLib, Gtk, clamp + + +@cache +def lerp(start: float, end: float, progress: float) -> float: + return start + (end - start) * progress + + +@cache +def steps(n: int, progress: float, start_jump: bool = False) -> float: + if start_jump: + return min(int(progress * n), n - 1) / (n - 1) if n > 1 else 0.0 + return min(int(progress * n + 1e-10), n) / n + + +@cache +def cubic_bezier( + x1: float, y1: float, x2: float, y2: float, progress: float, epsilon=1e-6 +) -> float: + # implementation yanked off of the internet, don't blame me about anything. + # Fast-path boundaries to avoid overshoot and unnecessary work + if progress <= 0.0 or progress >= 1.0: + return clamp(progress, 0.0, 1.0) + + t_guess = progress + for _ in range(8): + t = t_guess + t_sq = t * t + omt = 1.0 - t + omt_sq = omt * omt + + x = 3 * x1 * omt_sq * t + 3 * x2 * omt * t_sq + t * t_sq + dx = 3 * x1 * omt_sq + 6 * (x2 - x1) * omt * t + 3 * (1 - x2) * t_sq + + if abs(dx) < epsilon: + break + + delta = (x - progress) / dx + t_guess -= delta + t_guess = clamp(t_guess, 0.0, 1.0) + + if abs(delta) < epsilon: + break + + t = clamp(t_guess, 0.0, 1.0) + t_sq = t * t + omt = 1.0 - t + return 3 * y1 * omt * omt * t + 3 * y2 * omt * t_sq + t * t_sq + + +def ease_linear(progress: float) -> float: + return cubic_bezier(1, 1, 0, 0, progress) + + +def ease_in(progress: float) -> float: + return cubic_bezier(0.4, 0, 1, 1, progress) + + +def ease_out(progress: float) -> float: + return cubic_bezier(0, 0, 0.2, 1, progress) + + +def ease_in_out(progress: float) -> float: + return cubic_bezier(0.4, 0, 0.2, 1, progress) + + +class TimingFunctionCallback(Protocol): + def __call__(self, progress: float, *args, **kwargs) -> float: ... + + +class Animator(Service): + """ + An animator is a simple way for animating a value on + a set timeline based on a given timing function + """ + + @Signal + def finished(self) -> None: ... + + @Property(TimingFunctionCallback, "read-write") + def timing_function(self) -> TimingFunctionCallback: + return self._timing_function + + @timing_function.setter + def timing_function(self, value: TimingFunctionCallback): + self._timing_function = value + return + + @Property(float, "read-write") + def duration(self): + return self._duration + + @duration.setter + def duration(self, value: float): + if value <= 0.0: + raise ValueError("duration can't be smaller than or equal to 0.0") + + self._duration = value + return + + @Property(float, "read-write") + def value(self): + return self._value + + @value.setter + def value(self, value: float): + self._value = value + return + + @Property(float, "read-write") + def max_value(self): + return self._max_value + + @max_value.setter + def max_value(self, value: float): + self._max_value = value + return + + @Property(float, "read-write") + def min_value(self): + return self._min_value + + @min_value.setter + def min_value(self, value: float): + self._min_value = value + return + + @Property(bool, "read-write", default_value=False) + def playing(self): + return self._playing + + @playing.setter + def playing(self, value: bool): # this setter is intended for internal usage only + self._playing = value + return + + @Property(bool, "read-write", default_value=False) + def repeat(self): + return self._repeat + + @repeat.setter + def repeat(self, value: bool): + self._repeat = value + return + + def __init__( + self, + duration: float = 0.8, + timing_function: TimingFunctionCallback = ease_linear, + value: float = 0.0, + min_value: float = 0.0, + max_value: float = 1.0, + repeat: bool = False, + tick_widget: Gtk.Widget | None = None, + tick_interval: int = 16, + **kwargs, + ): + super().__init__(**kwargs) + self._playing = False + self._value = value + self._min_value = 0.0 + self._max_value = 1.0 + self._repeat = False + self._duration = 0.8 + self._timing_function = timing_function + self._tick_widget = tick_widget + self._tick_interval = tick_interval + + self.timing_function = timing_function + self.repeat = repeat + self.duration = duration + self.min_value = min_value + self.max_value = max_value + self.value = value + self.playing = False + + self._start_time = None + self._tick_handler = None + self._timeline_pos = 0.0 + + def do_get_time_now(self): + return GLib.get_monotonic_time() / 1_000_000 + + def do_update_value(self, delta_time: float): + if not self._playing: + return + + elapsed_time = delta_time - cast(float, self._start_time) + + self._timeline_pos = min(1.0, elapsed_time / self._duration) + + self.value = lerp( + self._min_value, + self._max_value, + self._timing_function(progress=self._timeline_pos), + ) + + if not self._timeline_pos >= 1.0: + return + + if not self._repeat: + # all done.. + self.value = self._max_value + self.finished() + self.pause() + return + + self._start_time = delta_time + self._timeline_pos = 0.0 + return + + def do_handle_tick(self, *_): + current_time = self.do_get_time_now() + self.do_update_value(current_time) + return True + + def do_remove_tick_handlers(self): + if not self._tick_handler: + return + + if self._tick_widget: + self._tick_widget.remove_tick_callback(self._tick_handler) + else: + GLib.source_remove(self._tick_handler) + self._tick_handler = None + return + + def play(self): + if self._playing: + return + + self.playing = True + self._start_time = self.do_get_time_now() + + if self._tick_handler: + return + + if self._tick_widget: + self._tick_handler = self._tick_widget.add_tick_callback( + self.do_handle_tick + ) + return + + self._tick_handler = GLib.timeout_add(self._tick_interval, self.do_handle_tick) + return + + def pause(self): + self.playing = False + return self.do_remove_tick_handlers() + + def stop(self): + if not self._tick_handler: + self._timeline_pos = 0 + self.playing = False + return + return self.do_remove_tick_handlers() diff --git a/widgets/circle_image.py b/src/shared/widgets/circle_image.py similarity index 96% rename from widgets/circle_image.py rename to src/shared/widgets/circle_image.py index aefd7e08..8488219c 100644 --- a/widgets/circle_image.py +++ b/src/shared/widgets/circle_image.py @@ -1,14 +1,8 @@ -import math from typing import Literal - -import cairo -import gi from fabric.core.service import Property +from fabric.utils import Gdk, GdkPixbuf, Gtk, cairo, math # noqa: E402 from fabric.widgets.widget import Widget -gi.require_version("Gtk", "3.0") -from gi.repository import Gdk, GdkPixbuf, Gtk # noqa: E402 - class CircleImage(Gtk.DrawingArea, Widget): """A widget that displays an image in a circular shape with a 1:1 aspect ratio.""" diff --git a/src/shared/widgets/clipping_box.py b/src/shared/widgets/clipping_box.py new file mode 100644 index 00000000..dc143061 --- /dev/null +++ b/src/shared/widgets/clipping_box.py @@ -0,0 +1,46 @@ +import math +from typing import cast + +import cairo +from fabric.widgets.box import Box + + +class ClippingBox(Box): + """A regular `Box` that replicates the CSS behaviour of `overflow: hidden` because GTK failed at it. + + NOTE: use instead of the old `CustomImage` snippet. + """ + + @staticmethod + def render_shape(cr: cairo.Context, width: int, height: int, radius: int = 0): + cr.move_to(radius, 0) + cr.line_to(width - radius, 0) + cr.arc(width - radius, radius, radius, -(math.pi / 2), 0) + cr.line_to(width, height - radius) + cr.arc(width - radius, height - radius, radius, 0, (math.pi / 2)) + cr.line_to(radius, height) + cr.arc(radius, height - radius, radius, (math.pi / 2), math.pi) + cr.line_to(0, radius) + cr.arc(radius, radius, radius, math.pi, (3 * (math.pi / 2))) + + return cr.close_path() + + def do_draw(self, cr: cairo.Context): + cr.save() + ClippingBox.render_shape( + cr, + self.get_allocated_width(), + self.get_allocated_height(), + cast( + int, + self.get_style_context().get_property( + "border-radius", self.get_state_flags() + ), + ), + ) + cr.clip() + + Box.do_draw(self, cr) + + cr.restore() + return True diff --git a/widgets/custom_image.py b/src/shared/widgets/custom_image.py similarity index 97% rename from widgets/custom_image.py rename to src/shared/widgets/custom_image.py index 1aae7f6a..a62612e5 100644 --- a/widgets/custom_image.py +++ b/src/shared/widgets/custom_image.py @@ -2,7 +2,7 @@ from typing import cast import cairo -from gi.repository import Gtk +from fabric.utils import Gtk from fabric.widgets.image import Image diff --git a/src/shared/widgets/customrevealer.py b/src/shared/widgets/customrevealer.py new file mode 100644 index 00000000..2ca10a39 --- /dev/null +++ b/src/shared/widgets/customrevealer.py @@ -0,0 +1,188 @@ +from fabric.utils import Gtk, math + +from shared.widgets.animator import Animator + + +# macOS-style easing functions for natural motion +def ease_out_quint(progress): + return 1 - math.pow(1 - progress, 5) + + +def ease_in_cubic(progress): + return progress * progress * progress + + +class SlideRevealer(Gtk.Overlay): + def __init__(self, child: Gtk.Widget, direction="right", duration=350, size=None): + super().__init__() + + self.child = child + self.direction = direction + self.duration = duration + self.fixed_size = size + self._revealed = False + self._cached_dimensions = None + + self.animator = Animator( + duration=duration / 1000.0, + timing_function=ease_out_quint, + tick_interval=8, # 120 FPS for smoothness + tick_widget=self, + ) + self.animator.connect("notify::value", self._on_animator_value_changed) + self.animator.connect("finished", self._on_animator_finished) + + self._fixed = Gtk.Fixed() + self._fixed.set_has_window(False) + self._fixed.add(child) + self.add_overlay(self._fixed) + + if self.fixed_size: + self.set_size_request(self.fixed_size[0], self.fixed_size[1]) + child.hide() + self.show_all() + else: + child.connect("size-allocate", self._on_size_allocate) + child.hide() + self.show_all() + + def _on_size_allocate(self, _widget, allocation): + if not self.fixed_size: + current_req = self.get_size_request() + if ( + current_req[0] != allocation.width + or current_req[1] != allocation.height + ): + self.set_size_request(allocation.width, allocation.height) + + def set_reveal_child(self, reveal: bool): + if reveal: + self.reveal() + else: + self.hide() + + def reveal(self): + if self._revealed and not self.animator.playing: + return + self._revealed = True + + if self.get_realized(): + self._start_animation(show=True) + else: + + def on_realize(*_): + self._start_animation(show=True) + self.disconnect_by_func(on_realize) + + self.connect("realize", on_realize) + + def hide(self): + if not self._revealed and not self.animator.playing: + return + self._revealed = False + self._start_animation(show=False) + + def _start_animation(self, show: bool): + self.animator.stop() + + self._cached_dimensions = self._get_dimensions() + if self._cached_dimensions[0] == 0 or self._cached_dimensions[1] == 0: + return + + self._show_animation = show + + # Configure animator based on direction + if show: + self.child.show() + self.animator.timing_function = ease_out_quint + # We animate the 'progress' from 0 to 1 + self.animator.min_value = 0.0 + self.animator.max_value = 1.0 + else: + self.animator.timing_function = ease_in_cubic + self.animator.min_value = 0.0 + self.animator.max_value = 1.0 + + self.animator.play() + + def _on_animator_value_changed(self, animator, _pspec): + if not self._cached_dimensions: + return + + progress = animator.value + x, y = self._get_position_at_progress_cached(progress) + + # Round to nearest pixel for actual positioning + pixel_x, pixel_y = int(round(x)), int(round(y)) + self._fixed.move(self.child, pixel_x, pixel_y) + self.queue_draw() + + def _on_animator_finished(self, _animator): + self._cached_dimensions = None + if not self._revealed: + self.child.hide() + + def _get_container_for_redraw(self): + return self + + def _get_dimensions(self): + if self.fixed_size: + return self.fixed_size + else: + alloc = self.child.get_allocation() + return alloc.width, alloc.height + + def _get_offscreen_pos_cached(self): + w, h = self._cached_dimensions + if self.direction == "left": + return -w, 0 + elif self.direction == "right": + return w, 0 + elif self.direction == "top": + return 0, -h + elif self.direction == "bottom": + return 0, h + return 0, 0 + + def _get_position_at_progress_cached(self, progress): + w, h = self._cached_dimensions + if self._show_animation: + # Showing animation: slide from offscreen to onscreen (0,0) + if self.direction == "left": + return -w + w * progress, 0.0 + elif self.direction == "right": + return w - w * progress, 0.0 + elif self.direction == "top": + return 0.0, -h + h * progress + elif self.direction == "bottom": + return 0.0, h - h * progress + else: + # Hiding animation: slide from onscreen (0,0) to offscreen + if self.direction == "left": + return -w * progress, 0.0 # Slide left (negative x) + elif self.direction == "right": + return w * progress, 0.0 # Slide right (positive x) + elif self.direction == "top": + return 0.0, -h * progress # Slide up (negative y) + elif self.direction == "bottom": + return 0.0, h * progress # Slide down (positive y) + return 0.0, 0.0 + + def set_slide_direction(self, direction): + self.direction = direction + + def is_revealed(self): + return self._revealed + + def is_animating(self): + return self.animator.playing + + def get_child_revealed(self): + return self._revealed + + def stop_animation(self): + self.animator.stop() + + def destroy(self): + self.stop_animation() + super().destroy() diff --git a/modules/controlcenter/battery.py b/src/shared/window/battery_widget.py similarity index 87% rename from modules/controlcenter/battery.py rename to src/shared/window/battery_widget.py index 068de6e3..beba25a3 100644 --- a/modules/controlcenter/battery.py +++ b/src/shared/window/battery_widget.py @@ -1,17 +1,15 @@ -import subprocess - -from fabric.utils import get_relative_path +from fabric.utils import GLib from fabric.widgets.box import Box from fabric.widgets.button import Button from fabric.widgets.centerbox import CenterBox from fabric.widgets.image import Image -from fabric.widgets.box import Box from fabric.widgets.label import Label from fabric.widgets.separator import Separator -from fabric.widgets.svg import Svg -from gi.repository import GLib +from services.gamemode import check_gamemode, toggle_gamemode -from services.battery import Battery +from services.battery import BatteryService, DeviceState +from utils.functions import clear_children, format_duration +from utils.utils import svg_file class EnergyModeButton(Box): @@ -20,7 +18,7 @@ def __init__( profile_name: str, display_name: str, icon_name: str, - battery_service: Battery, + battery_service: BatteryService, parent, **kwargs, ): @@ -29,13 +27,8 @@ def __init__( self.battery_service = battery_service self.parent = parent - self.mode_icon_svg = Svg( - # icon_name=f"battery-{icon_name}-symbolic", - svg_file=get_relative_path( - f"../../config/assets/icons/power_modes/battery-{icon_name}.svg" - ), - size=24, - ) + self.mode_icon_svg = svg_file(f"power_modes/battery-{icon_name}.svg", size=24) + self.mode_icon = Box( children=[self.mode_icon_svg], name="energy-mode-icon", @@ -67,7 +60,7 @@ def __init__( self.update_state() def on_clicked(self, *args): - success = self.battery_service.change_power_profile(self.profile_name) + success = self.battery_service.set_power_profile(self.profile_name) if success: # Update all profile buttons in parent self.parent.update_energy_mode_buttons() @@ -79,7 +72,8 @@ def _reset_icon_state(self): return False # Remove timeout def update_state(self): - is_active = self.battery_service.power_profile == self.profile_name + current_profile = self.battery_service.get_power_profile() + is_active = current_profile == self.profile_name if is_active: self.mode_icon.add_style_class("connected") else: @@ -124,9 +118,7 @@ def __init__(self, parent, **kwargs): def on_clicked(self, *args): try: - script_path = get_relative_path("../../scripts/gamemode.sh") - subprocess.run([script_path], check=False) - + toggle_gamemode() GLib.timeout_add(500, lambda: self.update_state()) except Exception as e: print(f"Failed to toggle game mode: {e}") @@ -138,19 +130,13 @@ def _reset_icon_state(self): def update_state(self): try: - script_path = get_relative_path("../../scripts/gamemode.sh") - result = subprocess.run( - [script_path, "check"], capture_output=True, text=True, check=False - ) - is_active = result.stdout.strip() == "t" - + is_active = check_gamemode() == "t" if is_active: self.game_icon.add_style_class("connected") else: self.game_icon.remove_style_class("connected") except Exception as e: print(f"Failed to check game mode status: {e}") - # Default to inactive state on error self.game_icon.remove_style_class("connected") return False # Remove timeout if called from GLib.timeout_add @@ -167,7 +153,7 @@ def __init__(self, parent, **kwargs): self.set_size_request(354, -1) self.parent = parent - self.battery_service = Battery() + self.battery_service = BatteryService() self.energy_mode_buttons = [] self.battery_widget = Box( @@ -264,7 +250,7 @@ def __init__(self, parent, **kwargs): self.add(self.battery_widget) self.battery_service.connect("changed", self.on_battery_changed) - self.battery_service.connect("profile_changed", self.on_profile_changed) + self.battery_service.connect("power_profile_changed", self.on_profile_changed) # Initialize display self.update_battery_info() @@ -282,7 +268,7 @@ def create_energy_mode_buttons(self): self.energy_mode_buttons.clear() # Get available profiles - available_profiles = self.battery_service.available_profiles + available_profiles = self.battery_service.get_available_power_profiles() or [] if not available_profiles: no_profiles_label = Label( @@ -337,30 +323,35 @@ def update_energy_mode_buttons(self): def create_game_mode_button(self): # Clear existing game mode button if any - for child in list(self.game_mode_container.get_children()): - child.destroy() + clear_children(self.game_mode_container) # Create game mode button self.game_mode_button = GameModeButton(parent=self) self.game_mode_container.add(self.game_mode_button) + def _format_time(self, seconds: int) -> str: + return format_duration(seconds) + def update_battery_info(self): - if not self.battery_service.is_present: + is_present = bool(self.battery_service.get_property("IsPresent")) + if not is_present: self.battery_percentage_label.set_label("No Battery") self.power_source_label.set_label("Power Source: Not Present") self.charging_time_label.set_label("") return # Update percentage in header - percentage = self.battery_service.percentage + percentage = int(self.battery_service.get_property("Percentage") or 0) self.battery_percentage_label.set_label(f"{percentage}%") # Update power source and charging info - state = self.battery_service.state + state_code = self.battery_service.get_property("State") + state = DeviceState.get(state_code, "UNKNOWN") if state in ["CHARGING", "PENDING_CHARGE"]: self.power_source_label.set_label("Power Source: Power Adapter") - time_to_full = self.battery_service.time_to_full + seconds_to_full = int(self.battery_service.get_property("TimeToFull") or 0) + time_to_full = self._format_time(seconds_to_full) if time_to_full != "N/A" and time_to_full != "0m": self.charging_time_label.set_label( f"{time_to_full} until fully charged" @@ -372,7 +363,10 @@ def update_battery_info(self): self.charging_time_label.set_label("Fully Charged") elif state in ["DISCHARGING", "PENDING_DISCHARGE"]: self.power_source_label.set_label("Power Source: Battery") - time_to_empty = self.battery_service.time_to_empty + seconds_to_empty = int( + self.battery_service.get_property("TimeToEmpty") or 0 + ) + time_to_empty = self._format_time(seconds_to_empty) if time_to_empty != "N/A" and not time_to_empty.startswith( "4553h" ): # Filter out unrealistic times @@ -389,5 +383,5 @@ def update_battery_info(self): def on_battery_changed(self, *args): self.update_battery_info() - def on_profile_changed(self, service, new_profile): + def on_profile_changed(self, service, *args): self.update_energy_mode_buttons() diff --git a/widgets/dropdown.py b/src/shared/window/dropdown.py similarity index 88% rename from widgets/dropdown.py rename to src/shared/window/dropdown.py index cb159c9e..2b838873 100644 --- a/widgets/dropdown.py +++ b/src/shared/window/dropdown.py @@ -2,7 +2,7 @@ from fabric.widgets.centerbox import CenterBox from fabric.widgets.eventbox import EventBox from utils.roam import modus_service -from widgets.popup_window import PopupWindow +from shared.window.popup_window import PopupWindow dropdowns = [] @@ -51,7 +51,6 @@ def __init__(self, dropdown_children=None, dropdown_id=None, **kwargs): ) self.children = [self.event_box] - self.connect("button-press-event", self.hide_dropdown) self.add_keybinding("Escape", self.hide_dropdown) def toggle_dropdown(self, button, parent=None): @@ -89,3 +88,16 @@ def on_cursor_leave(self, *_): return self.set_visible(False) modus_service.dropdowns_hide = not modus_service.dropdowns_hide + + def destroy(self): + """Clean up resources and global references""" + global dropdowns + if self in dropdowns: + dropdowns.remove(self) + + try: + modus_service.disconnect_by_func(self.hide_dropdown) + except Exception: + pass + + super().destroy() diff --git a/src/shared/window/mousecapture.py b/src/shared/window/mousecapture.py new file mode 100644 index 00000000..5ef51816 --- /dev/null +++ b/src/shared/window/mousecapture.py @@ -0,0 +1,298 @@ +from fabric.utils import Any, Gdk, cairo, GLib +from fabric.widgets.eventbox import EventBox +from fabric.widgets.wayland import WaylandWindow as Window +from fabric.widgets.widget import Widget +from gi.repository import GtkLayerShell # type: ignore + +from utils.monitors import HyprlandWithMonitors +from utils.roam import modus_service + + +class MouseCapture(Window): + """A background overlay that captures outside clicks without blocking child window interactions""" + + def __init__(self, layer: str, child_window: Window, **kwargs): + super().__init__( + layer=layer, # Use the passed layer + anchor="top bottom left right", + exclusivity="auto", + title="modus", + name="MouseCapture", + keyboard_mode="none", # Don't steal keyboard + all_visible=False, + visible=False, + **kwargs, + ) + + GtkLayerShell.set_exclusive_zone(self, -1) + + self.child_window = child_window + + # Ensure child window is on overlay layer to be above this capture + if hasattr(self.child_window, "layer"): + self.child_window.layer = "overlay" + + if hasattr(self.child_window, "_init_mousecapture"): + self.child_window._init_mousecapture(self) + + # Create transparent event box that captures clicks + self.event_box = EventBox( + events=[ + "button-press-event", + "button-release-event", + "pointer-motion-mask", # Listen for mouse movement + "enter-notify-mask", + ], + all_visible=True, + ) + self.event_box.connect("button-press-event", self.on_overlay_click) + self.event_box.connect("motion-notify-event", self.on_mouse_motion) + self.event_box.connect("enter-notify-event", self.on_mouse_motion) + self.children = [self.event_box] + + self._hyprland = HyprlandWithMonitors() + self._cursor_pointer = Gdk.Cursor.new_from_name( + Gdk.Display.get_default(), "pointer" + ) + self._cursor_default = None # Resets to default arrow + + # Make the overlay transparent + self.set_app_paintable(True) + self.connect("draw", self.on_draw) + self.connect("size-allocate", lambda *_: self.update_input_region()) + self.connect("map", lambda *_: self.update_input_region()) + self.connect("notify::visible", lambda *_: self.update_input_region()) + + # Add escape key binding to child window + if hasattr(self.child_window, "add_keybinding"): + self.child_window.add_keybinding("Escape", self.hide_child_window) + + def on_draw(self, _widget, cr): + """Make overlay transparent""" + cr.set_source_rgba(0, 0, 0, 0) # Fully transparent + cr.set_operator(cairo.OPERATOR_SOURCE) + cr.paint() + return False + + def _get_child_window_bounds(self) -> tuple[int, int, int, int]: + """Calculates absolute screen bounds for the child window using Gdk origin""" + try: + window = self.child_window.get_window() + if not window: + # Fallback to allocation if window isn't mapped yet + alloc = self.child_window.get_allocation() + return 0, 0, alloc.width, alloc.height + + # get_origin provides absolute screen coordinates + success, x, y = window.get_origin() + if not success: + alloc = self.child_window.get_allocation() + return 0, 0, alloc.width, alloc.height + + alloc = self.child_window.get_allocation() + return x, y, alloc.width, alloc.height + + except Exception as e: + print(f"Error calculating child window bounds: {e}") + alloc = self.child_window.get_allocation() + return 0, 0, alloc.width, alloc.height + + def _get_widget_absolute_bounds(self, widget: Widget) -> tuple[int, int, int, int]: + """Calculates absolute screen bounds for a widget in another window""" + try: + toplevel = widget.get_toplevel() + if not toplevel or not toplevel.get_window(): + return 0, 0, 0, 0 + + # Get the origin of the window containing the widget + success, wx, wy = toplevel.get_window().get_origin() + if not success: + return 0, 0, 0, 0 + + # Get relative position within toplevel + alloc = widget.get_allocation() + rx, ry = widget.translate_coordinates(toplevel, 0, 0) or (0, 0) + + return wx + rx, wy + ry, alloc.width, alloc.height + except Exception as e: + print(f"Error calculating widget absolute bounds: {e}") + return 0, 0, 0, 0 + + def update_input_region(self): + """Punches holes in the input region for the trigger button and child window""" + window = self.get_window() + if not window: + return + + try: + # Full screen region + alloc = self.get_allocation() + region = cairo.Region(cairo.RectangleInt(0, 0, alloc.width, alloc.height)) + + # Punch hole for trigger button (relative to this overlay window) + pointing_widget = getattr(self.child_window, "_pointing_widget", None) + if pointing_widget: + # get_widget_absolute_bounds returns screen-absolute coords. + # Since overlay is full screen on its monitor, we just need to subtract its monitor's X/Y + px, py, pw, ph = self._get_widget_absolute_bounds(pointing_widget) + monitor = self._hyprland.display.get_monitor_at_window(window) + if monitor: + mx, my = monitor.get_geometry().x, monitor.get_geometry().y + region.subtract(cairo.RectangleInt(px - mx, py - my, pw, ph)) + + # Punch hole for child window + cx, cy, cw, ch = self._get_child_window_bounds() + monitor = self._hyprland.display.get_monitor_at_window(window) + if monitor: + mx, my = monitor.get_geometry().x, monitor.get_geometry().y + region.subtract(cairo.RectangleInt(cx - mx, cy - my, cw, ch)) + + window.input_shape_combine_region(region, 0, 0) + except Exception as e: + print(f"Error updating input region: {e}") + + def on_mouse_motion(self, _widget, event): + """Update cursor feedback based on position""" + # (This is mostly redundant now with input regions but kept for extra safety) + if not self.child_window.is_visible(): + return False + + return False + + def on_overlay_click(self, _widget, event): + """Handle overlay clicks - check if click is outside child window""" + if not self.child_window.is_visible(): + return False + + # Support double and triple clicks too + if event.type not in [ + Gdk.EventType.BUTTON_PRESS, + Gdk.EventType._2BUTTON_PRESS, + Gdk.EventType._3BUTTON_PRESS, + ]: + return False + + # Use window-local coordinates for robust detection + click_x = event.x + click_y = event.y + + # Get window position relative to monitor for absolute -> local conversion + window = self.get_window() + mx, my = 0, 0 + if window: + monitor = self._hyprland.display.get_monitor_at_window(window) + if monitor: + geom = monitor.get_geometry() + mx, my = geom.x, geom.y + + # Check if click is on the "trigger" (pointing) widget + pointing_widget = getattr(self.child_window, "_pointing_widget", None) + if pointing_widget: + px_abs, py_abs, pw, ph = self._get_widget_absolute_bounds(pointing_widget) + px, py = px_abs - mx, py_abs - my + if px <= click_x <= px + pw and py <= click_y <= py + ph: + # Clicked the button that opened us - hide and consume + self.hide_child_window() + return True + + # Get child window bounds (monitor-relative) + cx_abs, cy_abs, cw, ch = self._get_child_window_bounds() + cx, cy = cx_abs - mx, cy_abs - my + + # Check if click is inside child window bounds + inside_child = cx <= click_x <= cx + cw and cy <= click_y <= cy + ch + + if not inside_child: + # Click is outside child window - hide it immediately + # No delay to avoid race conditions with toggle buttons + self.hide_child_window() + return True # Consume the event + + # Click is inside child window - don't consume event + return False + + def show_child_window(self, widget: Widget = None, event: Any = None) -> None: + self.set_child_window_visible(True) + + def hide_child_window(self, widget: Widget = None, event: Any = None) -> None: + + self.set_child_window_visible(False) + + def set_child_window_visible(self, visible: bool) -> None: + if visible: + # Connect to child window size changes to keep input region in sync + self._size_handler = self.child_window.connect( + "size-allocate", lambda *_: self.update_input_region() + ) + self.child_window.show() + self.show() + # Force update with a small delay to ensure window is mapped + GLib.timeout_add(50, self.update_input_region) + else: + if hasattr(self, "_size_handler") and self._size_handler: + self.child_window.disconnect(self._size_handler) + self._size_handler = None + self.child_window.hide() + self.hide() + + # Update styling on the trigger button + pointing_widget = getattr(self.child_window, "_pointing_widget", None) + if pointing_widget: + if visible: + pointing_widget.add_style_class("active") + else: + pointing_widget.remove_style_class("active") + + if hasattr(self.child_window, "_set_mousecapture"): + self.child_window._set_mousecapture(visible) + + def toggle_mousecapture(self, *_) -> None: + if self.is_visible(): + self.set_child_window_visible(False) + else: + self.set_child_window_visible(True) + + +class DropDownMouseCapture(MouseCapture): + """A specialized MouseCapture for dropdown menus with service integration""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + modus_service.connect("dropdowns-hide-changed", self.dropdowns_hide_changed) + + def hide_child_window(self, widget: Widget = None, event: Any = None) -> None: + """Hide child window and update dropdown service state""" + # Update service state before hiding to prevent conflicts + if hasattr(self.child_window, "id"): + if str(modus_service.current_dropdown) == str(self.child_window.id): + modus_service.current_dropdown = None + super().hide_child_window(widget, event) + + def dropdowns_hide_changed(self, widget: Widget = None, event: Any = None) -> None: + """Handle dropdown service hide changes""" + if hasattr(self.child_window, "id"): + if modus_service.current_dropdown == self.child_window.id: + return + return self.hide_child_window(widget, event) + + def destroy(self): + """Clean up signal connections""" + try: + modus_service.disconnect_by_func(self.dropdowns_hide_changed) + except Exception: + pass + super().destroy() + + +def add_destroy_to_mousecapture(): + # Base MouseCapture + def mc_destroy(self): + # Child window should be destroyed by its owner, but we should null references + self.child_window = None + Window.destroy(self) + + MouseCapture.destroy = mc_destroy + + +# Apply destroy to base class +add_destroy_to_mousecapture() diff --git a/widgets/popup_window.py b/src/shared/window/popup_window.py similarity index 95% rename from widgets/popup_window.py rename to src/shared/window/popup_window.py index d6d600ce..be1df90f 100644 --- a/widgets/popup_window.py +++ b/src/shared/window/popup_window.py @@ -1,11 +1,10 @@ import contextlib -import gi # type: ignore -from gi.repository import Gdk, Gtk, GtkLayerShell # type: ignore -from widgets.wayland import WaylandWindow -from utils.monitors import HyprlandWithMonitors +from fabric.utils import Gdk, Gtk +from fabric.widgets.wayland import WaylandWindow +from gi.repository import GtkLayerShell -gi.require_version("GtkLayerShell", "0.1") +from utils.monitors import HyprlandWithMonitors class PopupWindow(WaylandWindow): @@ -114,7 +113,10 @@ def do_reposition(self, move_axe: str): monitor = self._hyprland.display.get_monitor(current_monitor_id) monitor_geometry = monitor.get_geometry() monitor_x, monitor_y = monitor_geometry.x, monitor_geometry.y - monitor_width, monitor_height = monitor_geometry.width, monitor_geometry.height + monitor_width, monitor_height = ( + monitor_geometry.width, + monitor_geometry.height, + ) else: # Fallback to default screen screen = Gdk.Screen.get_default() @@ -136,8 +138,7 @@ def do_reposition(self, move_axe: str): if self._is_centered: # Calculate centered position with boundary checking centered_x = ( - (monitor_width / 2 - self._parent.get_allocated_width() / 2) - - width / 2 + (monitor_width / 2 - self._parent.get_allocated_width() / 2) - width / 2 ) + coords_centered[0] # Apply boundary checking only if enabled diff --git a/services/__init__.py b/src/utils/__init__.py similarity index 100% rename from services/__init__.py rename to src/utils/__init__.py diff --git a/utils/app_name_resolver.py b/src/utils/app_name_resolver.py similarity index 88% rename from utils/app_name_resolver.py rename to src/utils/app_name_resolver.py index 13b76dff..6757cc32 100644 --- a/utils/app_name_resolver.py +++ b/src/utils/app_name_resolver.py @@ -70,6 +70,7 @@ def format_app_name(self, title, wmclass, update=False): if update: modus_service.current_active_app_name = name + modus_service.current_active_wm_class = wmclass return name @@ -78,16 +79,11 @@ def format_app_name(self, title, wmclass, update=False): def format_window(title, wmclass): - # Handle the case when HyprlandActiveWindow passes "unknown" instead of empty strings - if (not title or title == "unknown") and (not wmclass or wmclass == "unknown"): - return "Finder" - - # Clean up "unknown" values + # Clean up "unknown" values to ensure they are treated as empty if title == "unknown": title = "" if wmclass == "unknown": wmclass = "" - - name = app_name_resolver.format_app_name(title, wmclass, True) - return name + # Always call format_app_name with update=True to keep service state in sync + return app_name_resolver.format_app_name(title, wmclass, True) diff --git a/src/utils/constants.py b/src/utils/constants.py new file mode 100644 index 00000000..db8a21a4 --- /dev/null +++ b/src/utils/constants.py @@ -0,0 +1,28 @@ +DEFAULT = { + "wallpapers_dir": "~/Pictures/Wallpapers/", + "dock_enabled": True, + "dock_auto_hide": True, + "dock_always_occluded": False, + "dock_icon_size": 52, + "window_switcher_items_per_row": 10, + "hide_special_workspace": True, + "dock_hide_special_workspace_apps": True, + "notification_timeout": "5s", + "notification_ignored_apps": ["Hyprshot"], + "notification_limited_apps_history": ["Spotify"], + "imac_button": True, + "systray": True, + "control_center": True, + "search": True, + "global_menu": True, + "network": True, + "battery": True, + "notification_center": True, + "workspace_indicator": True, + "bluetooth": True, + "date_time": True, + "keyboard_layouts": ["us", "np"], + "window_switcher": True, + "osd": True, + "systray_ignore": ["blueman", "network"], +} diff --git a/utils/conversion.py b/src/utils/conversion.py similarity index 90% rename from utils/conversion.py rename to src/utils/conversion.py index c39b4da9..650e6600 100644 --- a/utils/conversion.py +++ b/src/utils/conversion.py @@ -3,7 +3,7 @@ from concurrent.futures import ThreadPoolExecutor from typing import Dict, Optional, Tuple -import requests +import httpx class CurrencyCache: @@ -79,17 +79,33 @@ def _fetch_rates_background(self, from_code: str): """Fetch exchange rates in background thread.""" try: url = f"https://www.floatrates.com/daily/{from_code}.json" - response = requests.get(url, timeout=self._request_timeout) - + if self._request_timeout is None: + self._request_timeout = 5 + response = httpx.get( + url, + timeout=10, + follow_redirects=True, + headers={ + "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" + }, + ) if response.status_code == 200: - rates_data = response.json() - current_time = time.time() - - with self._cache_lock: - self._cache[from_code] = { - "rates": rates_data, - "timestamp": current_time, - } + try: + rates_data = response.json() + current_time = time.time() + with self._cache_lock: + self._cache[from_code] = { + "rates": rates_data, + "timestamp": current_time, + } + except Exception as je: + print(f"Failed to parse JSON for {from_code}: {je}") + else: + print( + f"Background fetch for {from_code} returned status { + response.status_code + }" + ) except Exception as e: print(f"Background currency fetch failed for {from_code}: {e}") @@ -417,6 +433,18 @@ def __init__(self): "mm2": 1e-6, } + self.CURRENCY_SYMBOLS: dict[str, str] = { + "$": "USD", + "โ‚ฌ": "EUR", + "ยฃ": "GBP", + "ยฅ": "JPY", + "โ‚น": "INR", + "C$": "CAD", + "A$": "AUD", + "โ‚ฃ": "CHF", + "ๅ…ƒ": "CNY", + } + # We no longer use currency_converter here. @@ -523,11 +551,24 @@ def _convert_currency_via_floatrates( return value url = f"https://www.floatrates.com/daily/{from_lower}.json" - resp = requests.get(url, timeout=5) - if resp.status_code != 200: - raise ValueError(f"Error getting data from floatrates for {from_code}") - - data = resp.json() + try: + resp = httpx.get( + url, + timeout=10, + follow_redirects=True, + headers={ + "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" + }, + ) + if resp.status_code != 200: + raise ValueError( + f"Error getting data from floatrates for {from_code}: Status { + resp.status_code + }" + ) + data = resp.json() + except Exception as e: + raise ValueError(f"Error getting data from floatrates for {from_code}: {e}") if to_lower not in data: raise ValueError( f"Target currency '{to_code}' not found in floatrates response for '{ @@ -578,9 +619,12 @@ def parse_input_and_convert(self, input: str): def clean_type(self, type: str) -> str: """ + If it's currency symbol, convert to code. If it's currency (3 letters), convert to uppercase. If it ends in 's' (and is not 'celsius'), remove the 's' for other units.""" + if type in self.units.CURRENCY_SYMBOLS: + return self.units.CURRENCY_SYMBOLS[type] if len(type) == 3 and type.isalpha(): return type.upper() if type.endswith("s") and type.lower() != "celsius": diff --git a/src/utils/dbus_helper.py b/src/utils/dbus_helper.py new file mode 100644 index 00000000..60ca14ee --- /dev/null +++ b/src/utils/dbus_helper.py @@ -0,0 +1,83 @@ +from fabric.utils import Gio, GLib + + +class GioDBusHelper: + """A helper class for interacting with D-Bus using the Gio library.""" + + def __init__( + self, + bus_name, + object_path, + interface_name, + bus_type=Gio.BusType.SYSTEM, + ): + self.bus = Gio.bus_get_sync(bus_type, None) + + self.bus_name = bus_name + self.object_path = object_path + self.proxy = Gio.DBusProxy.new_sync( + self.bus, + Gio.DBusProxyFlags.NONE, + None, + bus_name, + object_path, + interface_name, + None, + ) + + def call_method( + self, + bus_name, + object_path, + interface_name, + method_name, + parameters=None, + timeout=-1, + ): + if parameters is None: + parameters = GLib.Variant("()", ()) + result = self.bus.call_sync( + bus_name, + object_path, + interface_name, + method_name, + parameters, + None, + Gio.DBusCallFlags.NONE, + timeout, + None, + ) + return result.unpack() + + def listen_signal( + self, member, callback, interface_name="org.freedesktop.DBus.Properties" + ): + """Register a signal listener (conn, sender, path, iface, signal, parameters). Returns subscription id.""" + return self.bus.signal_subscribe( + self.bus_name, + interface_name, + member, + self.object_path, + arg0=None, + flags=Gio.DBusSignalFlags.NONE, + callback=callback, + ) + + def unsubscribe_signal(self, subscription_id: int): + """Unsubscribe a previously registered signal using its subscription id.""" + try: + self.bus.signal_unsubscribe(subscription_id) + except Exception: + pass + + def set_property(self, interface_name, property_name, value_variant): + """Sets a D-Bus property using the standard D-Bus Properties interface.""" + return self.call_method( + bus_name=self.bus_name, + object_path=self.object_path, + interface_name="org.freedesktop.DBus.Properties", + method_name="Set", + parameters=GLib.Variant( + "(ssv)", (interface_name, property_name, value_variant) + ), + ) diff --git a/src/utils/debounce.py b/src/utils/debounce.py new file mode 100644 index 00000000..058febc3 --- /dev/null +++ b/src/utils/debounce.py @@ -0,0 +1,70 @@ +import asyncio +import functools +import typing as t + +from fabric.utils import GLib + + +def debounce(delay: float) -> t.Callable[..., t.Any]: + def decorator(func: t.Callable[..., t.Any]) -> t.Callable[..., t.Any]: + task: asyncio.Task[None] | None = None + + @functools.wraps(func) + async def wrapper(*args: t.Any, **kwargs: t.Any) -> None: + nonlocal task + + if task is not None and not task.done(): + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + async def delayed_call() -> None: + await asyncio.sleep(delay) + await func(*args, **kwargs) + + task = asyncio.create_task(delayed_call()) + + return wrapper + + return decorator + + +def sync_debounce( + delay: int, min_n_times: int = 0, immediate: bool = False +) -> t.Callable[..., t.Any]: + def decorator(func: t.Callable[..., t.Any]) -> t.Callable[..., t.Any]: + n_times = 0 + timeout_id: int | None = None + last_args: tuple[t.Any, ...] = () + last_kwargs: dict[str, t.Any] = {} + + def call_func() -> bool: + nonlocal timeout_id, n_times + if n_times > min_n_times: + func(*last_args, **last_kwargs) + n_times = 0 + timeout_id = None + return False + + def wrapper(*args: t.Any, **kwargs: t.Any) -> None: + nonlocal timeout_id, last_args, last_kwargs, n_times + + last_args = args + last_kwargs = kwargs + + if timeout_id is not None: + GLib.source_remove(timeout_id) + + if not immediate or n_times < min_n_times: + n_times += 1 + timeout_id = GLib.timeout_add(delay, call_func) + else: + n_times = 0 + timeout_id = None + func(*last_args, **last_kwargs) + + return functools.wraps(func)(wrapper) + + return decorator diff --git a/src/utils/functions.py b/src/utils/functions.py new file mode 100644 index 00000000..b90eb447 --- /dev/null +++ b/src/utils/functions.py @@ -0,0 +1,286 @@ +import ctypes +import html +import json +import os +import subprocess +import threading +from typing import Dict, NamedTuple, Optional, TypeVar + +from fabric.utils import ( + exec_shell_command, + exec_shell_command_async, + logger, +) + +T = TypeVar("T") + + +def set_process_name(name: str): + libc = ctypes.CDLL("libc.so.6") + libc.prctl(15, name.encode("utf-8"), 0, 0, 0) # 15 = PR_SET_NAME + + +def parse_timeout_string(timeout_str): + """ + Parse timeout string in format like '5s', '10m', '30s' etc. + Returns timeout in milliseconds. + """ + if not timeout_str or not isinstance(timeout_str, str): + return 5000 + + timeout_str = timeout_str.strip().lower() + + if timeout_str.endswith("s"): + try: + seconds = int(timeout_str[:-1]) + return seconds * 1000 + except ValueError: + return 5000 + elif timeout_str.endswith("m"): + try: + minutes = int(timeout_str[:-1]) + return minutes * 60 * 1000 + except ValueError: + return 5000 + else: + try: + seconds = int(timeout_str) + return seconds * 1000 + except ValueError: + return 5000 + + +# Threading helper functions +def thread(target, *args, **kwargs) -> threading.Thread: + """ + Simply run the given function in a thread. + The provided args and kwargs will be passed to the function. + """ + th = threading.Thread(target=target, args=args, kwargs=kwargs, daemon=True) + th.start() + return th + + +def run_in_thread(func): + """ + Decorator to run the decorated function in a thread. + """ + + def wrapper(*args, **kwargs): + return thread(func, *args, **kwargs) + + return wrapper + + +def write_json_file(data: Dict, path: str): + try: + with open(path, "w") as f: + json.dump(data, f, indent=4, ensure_ascii=False) + except Exception as e: + logger.warning(f"Failed to write json: {e}") + + +def read_json_file(file_path: str) -> Optional[Dict]: + if not os.path.exists(file_path): + logger.error(f"JSON file {file_path} does not exist.") + return None + + with open(file_path, "r") as file: + try: + return json.load(file) + except json.JSONDecodeError as e: + logger.error(f"Failed to read JSON file {file_path}: {e}") + return None + + +def get_wifi_icon_for_strength(strength: int) -> str: + """ + Get the appropriate WiFi icon based on signal strength. + + Args: + strength: Signal strength from 0-100 + + Returns: + Relative path to the appropriate WiFi icon + """ + if strength >= 80: + icon_name = "network-wireless-100.svg" + elif strength >= 60: + icon_name = "network-wireless-80.svg" + elif strength >= 40: + icon_name = "network-wireless-60.svg" + elif strength >= 20: + icon_name = "network-wireless-40.svg" + elif strength > 0: + icon_name = "network-wireless-20.svg" + else: + icon_name = "network-wireless-0.svg" + + return f"wifi/{icon_name}" + + +def get_wifi_connecting_icon() -> str: + """ + Get the WiFi connecting icon path. + + Returns: + Relative path to the WiFi connecting icon + """ + return "wifi/wifi-connecting.svg" + + +def is_special_workspace_id(ws_id) -> bool: + """ + Check if a workspace ID represents a special workspace. + + Args: + ws_id: Workspace ID (can be int, string, or other types) + + Returns: + True if the workspace is special, False otherwise + """ + try: + # Convert to int if it's a string + workspace_id = int(ws_id) + # Special workspaces have negative IDs + return workspace_id < 0 + except (ValueError, TypeError): + # If it's a string, check if it starts with "special:" + if isinstance(ws_id, str) and ws_id.startswith("special:"): + return True + return False + + +def is_special_workspace(client: dict) -> bool: + """ + Check if a client is in a special workspace. + + Args: + client: Client data dictionary from Hyprland + + Returns: + True if the client is in a special workspace, False otherwise + """ + if "workspace" not in client: + return False + + workspace = client["workspace"] + + # Check workspace name first + if "name" in workspace: + workspace_name = str(workspace["name"]) + # Special workspaces typically start with "special:" or have negative IDs + if workspace_name.startswith("special:"): + return True + + # Check workspace ID + if "id" in workspace: + workspace_id = workspace["id"] + # Special workspaces have negative IDs + if workspace_id < 0: + return True + + return False + + +def escape_markup_text(text: str) -> str: + return html.escape(text.replace("\n", " ")) + + +# Function to toggle a shell command +def toggle_command(command: str, full_command: str): + if is_app_running(command): + kill_process(command) + else: + subprocess.Popen( + full_command.split(" "), + stdin=subprocess.DEVNULL, # No input stream + stdout=subprocess.DEVNULL, # Optionally discard the output + stderr=subprocess.DEVNULL, # Optionally discard the error output + start_new_session=True, # This prevents the process from being killed + ) + + +# Function to execute a shell command asynchronously +def kill_process(process_name: str): + exec_shell_command_async(f"pkill {process_name}", lambda *_: None) + + +# Function to check if an app is running +def is_app_running(app_name: str) -> bool: + return len(exec_shell_command(f"pidof {app_name}")) != 0 + + +# General utilities +def format_duration(seconds: int) -> str: + """ + Convert a duration in seconds to a compact human-friendly string. + + Examples: + 0 -> "N/A" + 59 -> "0m" + 61 -> "1m" + 3600 -> "1h 0m" + """ + try: + total = int(seconds) + except (TypeError, ValueError): + return "N/A" + + if total <= 0: + return "N/A" + + hours = total // 3600 + minutes = (total % 3600) // 60 + if hours > 0: + return f"{hours}h {minutes}m" + return f"{minutes}m" + + +def clear_children(container) -> None: + """ + Destroy all children of a GTK container-like widget which exposes get_children(). + """ + try: + for child in list(container.get_children()): + child.destroy() + except Exception as e: + logger.warning(f"clear_children failed: {e}") + + +class CommandResult(NamedTuple): + returncode: int + stdout: bytes | str + stderr: bytes | str + + +def run_command( + args: list[str], + timeout: float | None = None, + cwd: Optional[str] = None, + env: Optional[Dict[str, str]] = None, + *, + input: bytes | str | None = None, + text: bool = True, +) -> CommandResult: + try: + result = subprocess.run( + args, + capture_output=True, + text=text, + timeout=timeout, + cwd=cwd, + env=env, + input=input, + ) + return CommandResult(result.returncode, result.stdout, result.stderr) + except subprocess.TimeoutExpired as e: + return CommandResult( + -1, + (e.stdout or b"" if not text else e.stdout or ""), + (e.stderr or (b"timeout" if not text else "timeout")), + ) + except FileNotFoundError as e: + return CommandResult(127, "", str(e)) + except Exception as e: + return CommandResult(1, "", str(e)) diff --git a/src/utils/icon_resolver.py b/src/utils/icon_resolver.py new file mode 100644 index 00000000..ae3a0c88 --- /dev/null +++ b/src/utils/icon_resolver.py @@ -0,0 +1,166 @@ +import json + +from fabric.utils import GLib, Gtk, logger, os + +import shared.data as data + +ICON_CACHE_FILE = data.CACHE_DIR + "/icons.json" +if not os.path.exists(data.CACHE_DIR): + os.makedirs(data.CACHE_DIR) + + +class IconResolver: + def __init__( + self, default_applicaiton_icon: str = "application-x-executable-symbolic" + ): + if os.path.exists(ICON_CACHE_FILE): + with open(ICON_CACHE_FILE) as f: + try: + self._icon_dict = json.load(f) + except json.JSONDecodeError: + logger.info("[ICONS] Cache file does not exist or is corrupted") + self._icon_dict = {} + else: + self._icon_dict = {} + + self.default_applicaiton_icon = default_applicaiton_icon + + def get_icon_name(self, app_id: str): + if app_id in self._icon_dict: + return self._icon_dict[app_id] + new_icon = self._compositor_find_icon(app_id) + logger.info( + f"[ICONS] found new icon: '{new_icon}' for app id: '{app_id}', storing..." + ) + self._store_new_icon(app_id, new_icon) + return new_icon + + def get_icon_pixbuf(self, app_id: str, size: int = 16): + icon_theme = Gtk.IconTheme.get_default() + icon_name = self.get_icon_name(app_id) + try: + # Try to load the resolved icon. + return icon_theme.load_icon(icon_name, size, Gtk.IconLookupFlags.FORCE_SIZE) + except GLib.Error as primary_error: + logger.warning( + f"Warning: Icon '{icon_name}' not found in theme. Error: {primary_error}" + ) + try: + # Fallback to the default application icon. + return icon_theme.load_icon( + self.default_applicaiton_icon, size, Gtk.IconLookupFlags.FORCE_SIZE + ) + except GLib.Error as fallback_error: + logger.error( + f"Error: Fallback icon '{self.default_applicaiton_icon}' also not found. Error: {fallback_error}" + ) + return None + + def _store_new_icon(self, app_id: str, icon: str): + self._icon_dict[app_id] = icon + with open(ICON_CACHE_FILE, "w") as f: + json.dump(self._icon_dict, f) + + def _get_icon_from_desktop_file(self, desktop_file_path: str): + # Retrieve the icon specified in the [Desktop Entry] section. + try: + with open(desktop_file_path) as f: + in_desktop_entry = False + for line in f: + line = line.strip() + if line == "[Desktop Entry]": + in_desktop_entry = True + elif line.startswith("[") and line.endswith("]"): + in_desktop_entry = False + + if in_desktop_entry and line.startswith("Icon="): + return line[5:].strip() + except Exception as e: + logger.error(f"[ICONS] Error reading desktop file {desktop_file_path}: {e}") + + return self.default_applicaiton_icon + + def _get_desktop_file(self, app_id: str) -> str | None: + if not app_id: + return None + + search_dirs = [os.path.join(GLib.get_user_data_dir(), "applications")] + [ + os.path.join(d, "applications") for d in GLib.get_system_data_dirs() + ] + + # Normalize search IDs (full ID and parts for Reverse DNS or decorated names) + search_ids = [app_id.lower()] + for sep in [".", "_", "-"]: + if sep in app_id: + parts = app_id.split(sep) + for part in parts: + if part and len(part) > 2 and part.lower() not in search_ids: + search_ids.append(part.lower()) + + # 1. Try exact filename matches first (highest priority) + for data_dir in search_dirs: + if not os.path.exists(data_dir): + continue + + files = os.listdir(data_dir) + for sid in search_ids: + target = f"{sid}.desktop" + for f in files: + if f.lower() == target: + return os.path.join(data_dir, f) + + # Try basename match without .desktop + for f in files: + basename = f[:-8] if f.lower().endswith(".desktop") else f + if basename.lower() == sid: + return os.path.join(data_dir, f) + + # 2. Try matching StartupWMClass or Name inside desktop files (more expensive) + for data_dir in search_dirs: + if not os.path.exists(data_dir): + continue + + for f in os.listdir(data_dir): + if not f.endswith(".desktop"): + continue + path = os.path.join(data_dir, f) + try: + with open(path, "r", errors="ignore") as file: + for line in file: + line = line.strip() + if line.startswith("StartupWMClass="): + wm_class = line[15:].strip() + if wm_class.lower() == app_id.lower(): + return path + elif line.startswith("Name="): + name = line[5:].strip().lower() + if any(sid == name for sid in search_ids): + return path + except Exception: + continue + + return None + + def _compositor_find_icon(self, app_id: str): + if not app_id: + return self.default_applicaiton_icon + + icon_theme = Gtk.IconTheme.get_default() + + # Try direct icon name match + if icon_theme.has_icon(app_id): + return app_id + + # Try with common suffixes + for suffix in ["-desktop", "-symbolic"]: + if icon_theme.has_icon(app_id + suffix): + return app_id + suffix + + # Try finding desktop file + desktop_file = self._get_desktop_file(app_id) + if desktop_file: + icon = self._get_icon_from_desktop_file(desktop_file) + if icon and icon_theme.has_icon(icon): + return icon + + return self.default_applicaiton_icon diff --git a/utils/inhibit.py b/src/utils/inhibit.py similarity index 95% rename from utils/inhibit.py rename to src/utils/inhibit.py index 6b5a1064..9899483c 100644 --- a/utils/inhibit.py +++ b/src/utils/inhibit.py @@ -6,10 +6,14 @@ import subprocess import sys from dataclasses import dataclass +from pathlib import Path + +# Add the project root to sys.path to allow absolute imports from 'utils' +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + from signal import SIGINT, SIGTERM, signal from threading import Event, Timer -import setproctitle from pywayland.client.display import Display from pywayland.protocol.idle_inhibit_unstable_v1.zwp_idle_inhibit_manager_v1 import ( ZwpIdleInhibitManagerV1, @@ -18,6 +22,8 @@ from pywayland.protocol.wayland.wl_registry import WlRegistryProxy from pywayland.protocol.wayland.wl_surface import WlSurface +from utils.functions import set_process_name + @dataclass class GlobalRegistry: @@ -182,5 +188,5 @@ def shutdown() -> None: if __name__ == "__main__": - setproctitle.setproctitle("modus-inhibit") + set_process_name("modus-inhibit") main() diff --git a/utils/monitors.py b/src/utils/monitors.py similarity index 98% rename from utils/monitors.py rename to src/utils/monitors.py index 01b706e1..6a2eb6ed 100644 --- a/utils/monitors.py +++ b/src/utils/monitors.py @@ -1,10 +1,11 @@ import json +import time import warnings +from functools import lru_cache from typing import Dict -import time + from fabric.hyprland import Hyprland -from gi.repository import Gdk -from functools import lru_cache +from fabric.utils import Gdk warnings.filterwarnings("ignore", category=DeprecationWarning) diff --git a/utils/occlusion.py b/src/utils/occlusion.py similarity index 99% rename from utils/occlusion.py rename to src/utils/occlusion.py index b81ee751..a7b86020 100644 --- a/utils/occlusion.py +++ b/src/utils/occlusion.py @@ -1,7 +1,7 @@ import json import subprocess -import config.data as data +import shared.data as data def get_current_workspace(): diff --git a/utils/roam.py b/src/utils/roam.py similarity index 86% rename from utils/roam.py rename to src/utils/roam.py index 4127d987..20a791ed 100644 --- a/utils/roam.py +++ b/src/utils/roam.py @@ -1,8 +1,11 @@ -from loguru import logger - from fabric.audio import Audio +from fabric.utils import logger -from services.modus import ModusService, notification_service as notification_service_instance +# ruff: noqa: I001 +from services.modus import ( + ModusService, + notification_service as notification_service_instance, +) global modus_service try: diff --git a/src/utils/utils.py b/src/utils/utils.py new file mode 100644 index 00000000..44baf23c --- /dev/null +++ b/src/utils/utils.py @@ -0,0 +1,156 @@ +from pathlib import Path +from typing import Literal + +from fabric.utils import Gdk, bulk_connect, exec_shell_command, exec_shell_command_async +from fabric.widgets.scale import Scale, ScaleMark +from fabric.widgets.svg import Svg + +from shared.widgets.animator import Animator + + +class AnimatedScale(Scale): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.animator = None + + def animate_value(self, value: float): + if not self.animator: + self.animator = Animator( + bezier_curve=(0.34, 1.56, 0.64, 1.0), + duration=0.8, + min_value=self.min_value, + max_value=self.value, + tick_widget=self, + notify_value=lambda p, *_: self.set_value(p.value), + ) + self.animator.pause() + self.animator.min_value = self.value + self.animator.max_value = value + self.animator.play() + + +def is_app_running(app_name: str) -> bool: + return len(exec_shell_command(f"pidof {app_name}")) != 0 + + +# Function to set up cursor hover +def setup_cursor_hover( + widget, cursor_name: Literal["pointer", "crosshair", "grab"] = "pointer" +): + display = Gdk.Display.get_default() + cursor = Gdk.Cursor.new_from_name(display, cursor_name) + + def on_enter_notify_event(widget, _): + widget.get_window().set_cursor(cursor) + + def on_leave_notify_event(widget, _): + # Restore default cursor when leaving the widget's area + widget.get_window().set_cursor(None) + + bulk_connect( + widget, + { + "enter-notify-event": on_enter_notify_event, + "leave-notify-event": on_leave_notify_event, + }, + ) + + +# Create a scale widget +def create_scale( + name, + marks=None, + value=0, + min_value: float = 0, + max_value: float = 100, + increments=(1, 1), + curve=(0.34, 1.56, 0.64, 1.0), + orientation="h", + h_expand=True, + h_align="center", + style_classes="", + duration=0.8, + **kwargs, +) -> AnimatedScale: + if marks is None: + marks = (ScaleMark(value=i) for i in range(1, 100, 10)) + + return AnimatedScale( + name=name, + marks=marks, + value=value, + min_value=min_value, + max_value=max_value, + increments=increments, + orientation=orientation, + curve=curve, + h_expand=h_expand, + h_align=h_align, + duration=duration, + style_classes=style_classes, + **kwargs, + ) + + +# Base path to your SVG assets (resolve from repository root) +BASE_SVG_PATH = (Path(__file__).resolve().parent.parent / "assets" / "icons").resolve() + + +def svg_file(relative_path: str, **kwargs) -> Svg: + """ + Return a fresh Svg widget for a given relative path. + + Avoids reusing the same widget instance across multiple containers, which + can cause GTK warnings. Example: + svg_file("misc/logo.svg", size=16) + """ + + def _resolve_path(path_like): + s = str(path_like) + marker = "/src/assets/icons/" + if marker in s: + rel = s.split(marker, 1)[1] + return (BASE_SVG_PATH / rel).resolve() + p = Path(s) + if p.is_absolute(): + return p + return (BASE_SVG_PATH / p).resolve() + + full_path = _resolve_path(relative_path) + svg = Svg(svg_file=str(full_path), **kwargs) + + original_set_from_file = svg.set_from_file + + def _set_from_file_resolved(file_path): + return original_set_from_file(str(_resolve_path(file_path))) + + svg.set_from_file = _set_from_file_resolved # type: ignore[attr-defined] + + # Provide a convenience alias for dynamic updates + def _dynamic_file(file_path): + _set_from_file_resolved(file_path) + return svg + + svg.dynamic_file = _dynamic_file # type: ignore[attr-defined] + + # Convenience method to update style and return the same instance + def _dynamic_style( + style: str, + *, + compiled: bool = True, + append: bool = False, + add_brackets: bool = True, + ): + svg.set_style( + style, compiled=compiled, append=append, add_brackets=add_brackets + ) + return svg + + svg.dynamic_style = _dynamic_style # type: ignore[attr-defined] + + return svg + + +def generate_colors_from_wallpaper(image_path: str) -> bool: + cmd = f'matugen image "{image_path}" --source-color-index 0' + return bool(exec_shell_command_async(cmd)) diff --git a/src/window/__init__.py b/src/window/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/window/controlcenter/__init__.py b/src/window/controlcenter/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/window/controlcenter/battery.py b/src/window/controlcenter/battery.py new file mode 100644 index 00000000..e5c28b04 --- /dev/null +++ b/src/window/controlcenter/battery.py @@ -0,0 +1,650 @@ +import subprocess + +from fabric.bluetooth import BluetoothClient, BluetoothDevice +from fabric.utils import Gdk, GLib, Gtk, exec_shell_command, logger +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.centerbox import CenterBox +from fabric.widgets.image import Image +from fabric.widgets.label import Label +from fabric.widgets.revealer import Revealer +from fabric.widgets.scrolledwindow import ScrolledWindow +from fabric.widgets.separator import Separator + +from utils.utils import svg_file + + +def get_battery_icon_file( + percentage: int, is_charging: bool, icon_path: str = "" +) -> str: + """ + Get the battery icon file path based on percentage and charging status. + + Args: + percentage: Battery percentage (0-100) + is_charging: Whether the device is charging + icon_path: Base path for icons (unused, kept for compatibility) + + Returns: + Relative path to the battery icon file + """ + clamped = max(0, min(100, percentage)) + step = (clamped // 10) * 10 + filename = f"battery-{step:03d}{'-charging' if is_charging else ''}.svg" + return f"battery/{filename}" + + +def set_bluetooth_enabled_with_fallback(client, enabled: bool): + try: + # Try fabric bluetooth first + client.set_enabled(enabled) + except Exception as e: + logger.warning(f"Fabric bluetooth set_enabled({enabled}) failed: {e}") + + # Fallback to rfkill to unblock/block bluetooth + if enabled: + command = "rfkill unblock bluetooth" + else: + command = "rfkill block bluetooth" + + result = exec_shell_command(command) + if result is False: + logger.error(f"rfkill fallback failed: command '{command}' returned False") + elif isinstance(result, str) and result.strip(): + # If result is a non-empty string, it might be an error message + logger.warning(f"rfkill command output: {result.strip()}") + else: + logger.info(f"rfkill fallback succeeded: {command}") + + +class BluetoothDeviceSlot(CenterBox): + def __init__(self, device: BluetoothDevice, **kwargs): + super().__init__(h_expand=True, name="device-button", **kwargs) + self.device = device + self._destroyed = False + self._signal_ids = [] + self._signal_ids.append(self.device.connect("changed", self.on_changed)) + self._signal_ids.append( + self.device.connect( + "notify::closed", lambda *_: self.device.closed and self.destroy() + ) + ) + + self.styles = [ + "connected" if self.device.connected else "", + "paired" if self.device.paired else "", + ] + + self.dimage = Image( + icon_name=device.icon_name + "-symbolic", # type: ignore + size=5, + name="device-icon", + style_classes=" ".join(self.styles), + ) + + self.device_button = Button( + on_clicked=lambda *_: self.toggle_connecting(), + child=Box( + orientation="h", + h_expand=True, + children=[self.dimage, Label(label=device.name)], + ), # type: ignore + ) + self.start_children = [self.device_button] + + # # Add battery info if available + if hasattr(device, "battery_percentage") and device.battery_percentage > 0: + battery_box = Box(orientation="h", spacing=4) + + # Create battery icon + battery_icon = svg_file( + get_battery_icon_file( + device.battery_percentage, + False, # Not charging for bluetooth devices + ) + ) + + # Create battery percentage label + battery_label = Label( + label=f"{device.battery_percentage:.0f}%", name="battery-label" + ) + + battery_box.children = [battery_icon, battery_label] + self.end_children = [battery_box] + + self.device_button.connect("enter-notify-event", self.on_button_enter) + self.device_button.connect("leave-notify-event", self.on_button_leave) + self.device.emit("changed") # to update display status + + def destroy(self): + """Clean up device signal connections to prevent memory leaks.""" + if self._destroyed: + return + self._destroyed = True + for sig_id in self._signal_ids: + try: + self.device.disconnect(sig_id) + except Exception: + pass + self._signal_ids.clear() + self.device = None + super().destroy() + + def on_button_enter(self, widget, event): + self.add_style_class("button-hovered") + + def on_button_leave(self, widget, event): + self.remove_style_class("button-hovered") + + def toggle_connecting(self): + self.device.emit("changed") + self.device.set_connecting(not self.device.connected) + + def on_changed(self, *_): + if self._destroyed or self.device is None: + return + try: + # Update connection and pairing status + new_styles = [ + "connected" if self.device.connected else "", + "paired" if self.device.paired else "", + ] + + self.styles = new_styles + self.dimage.set_property("style-classes", " ".join(self.styles)) + except Exception: + return + + # Update battery info if available + if ( + hasattr(self.device, "battery_percentage") + and self.device.battery_percentage > 0 + ): + if not self.end_children: # Add battery display if not already present + battery_box = Box(orientation="h", spacing=4) + + # Create battery icon + battery_icon = svg_file( + get_battery_icon_file( + self.device.battery_percentage, + False, # Not charging for bluetooth devices + ) + ) + + # Create battery percentage label + battery_label = Label( + label=f"{self.device.battery_percentage:.0f}%", name="battery-label" + ) + + battery_box.children = [battery_icon, battery_label] + self.end_children = [battery_box] + else: # Update existing battery display + battery_box = self.end_children[0] + if hasattr(battery_box, "children") and len(battery_box.children) >= 2: + battery_icon = battery_box.children[0] + battery_label = battery_box.children[1] + + # Update battery icon + battery_icon.dynamic_file( + get_battery_icon_file( + self.device.battery_percentage, + False, # Not charging for bluetooth devices + ) + ) + + # Update battery percentage + battery_label.set_label(f"{self.device.battery_percentage:.0f}%") + elif self.end_children: # Remove battery display if no longer available + self.end_children = [] + + return + + +class BluetoothConnections(Box): + def __init__( + self, parent, show_hidden_devices: bool = False, show_back_button=True, **kwargs + ): + super().__init__( + spacing=8, + orientation="vertical", + name="bluetooth-connections", + **kwargs, + ) + + self.parent = parent + self.show_hidden_devices = show_hidden_devices + self.is_scanning = False # Track scanning state + self.refresh_timer = None # Timer for periodic device refresh + self._update_in_progress = False # Prevent concurrent updates + self._destroyed = False # Track if widget is destroyed + self._client_signal_ids = [] # Track BluetoothClient signal IDs + + self.client = BluetoothClient(on_device_added=self.on_device_added) + + # Create pull-to-refresh indicator + self.refresh_indicator = Label( + name="bluetooth-refresh-indicator", + label="โ†“ Pull to scan for devices", + h_align="center", + visible=False, + style="color: #fff; font-size: 12px; padding: 5px;", + ) + + # Create title with optional back button + title_children = [] + if show_back_button: + title_children.append( + Button( + image=Image(icon_name="back", size=10), + on_clicked=lambda *_: self.parent.close_bluetooth(), + ) + ) + title_children.append(Label("Bluetooth", name="bluetooth-title")) + + self.title = Box( + orientation="h", + children=title_children, + ) + + self.toggle_button = Gtk.Switch(visible=True, name="toggle-button") + + # Safely set initial state + self.toggle_button.set_active(self.client.enabled) + self.toggle_button.connect( + "notify::active", + lambda *_: set_bluetooth_enabled_with_fallback( + self.client, self.toggle_button.get_active() + ), + ) + + # Connect client signals โ€” track IDs for disconnect on destroy + self._client_signal_ids.append( + self.client.connect( + "notify::enabled", + lambda *_: self.toggle_button.set_active(self.client.enabled), + ) + ) + self._client_signal_ids.append( + self.client.connect("notify::scanning", lambda *_: self.update_scan_label()) + ) + + # Connect to device changes + self._client_signal_ids.append( + self.client.connect("device-added", self.update_devices) + ) + self._client_signal_ids.append( + self.client.connect("device-removed", self.update_devices) + ) + + # Connect to additional signals for better real-time monitoring + self._client_signal_ids.append( + self.client.connect("changed", self.on_client_changed) + ) + + # Create Devices section + self.paired_devices_label = Label( + label="Devices", h_align="start", name="networks-title" + ) + self.paired_devices = Box( + spacing=4, orientation="vertical", name="known-networks" + ) + + # Create "No devices available" message + self.no_devices_label = Label( + label="No devices available", + h_align="center", + name="no-networks-label", + visible=False, + ) + + # Create Other Devices section with clickable title + self.other_devices_button = Button( + child=Label("Other Devices", h_align="start"), + name="wifi-other-button", + on_clicked=self.toggle_other_devices, + ) + self.other_devices = Box(spacing=4, orientation="vertical") + + # Create scrolled window for other devices + self.other_devices_scrolled = ScrolledWindow( + min_content_size=(303, 150), + child=self.other_devices, + overlay_scroll=True, + ) + + # Add pull-to-refresh functionality to scrolled window + self.setup_pull_to_refresh() + + # Create revealer for Other Devices section + self.other_devices_revealer = Revealer( + child=self.other_devices_scrolled, + transition_type="slide-down", + transition_duration=100, + child_revealed=False, + ) + + # Create More Settings button (same style as Other Devices button) + self.more_settings_button = Button( + child=Label("More Settings", h_align="start"), + name="wifi-other-button", + on_clicked=self.open_bluetooth_settings, + ) + + self.children = [ + CenterBox( + start_children=self.title, + end_children=self.toggle_button, + name="bluetooth-widget-top", + ), + self.refresh_indicator, + Separator(orientation="h", name="separator"), + self.paired_devices_label, + self.paired_devices, + self.no_devices_label, + Separator(orientation="h", name="separator"), + self.other_devices_button, + self.other_devices_revealer, + Separator(orientation="h", name="separator"), + self.more_settings_button, + ] + + # Connect cleanup on destroy + self.connect("destroy", self.on_destroy) + + self.client.notify("scanning") + self.client.notify("enabled") + + # Initial device update + self.update_devices() + + # Start periodic device monitoring for real-time updates + self.start_device_monitoring() + + def toggle_other_devices(self, *_): + """Toggle the visibility of other devices section""" + current_state = self.other_devices_revealer.child_revealed + self.other_devices_revealer.child_revealed = not current_state + + # Handle scanning based on section visibility + if self.client: + if self.other_devices_revealer.child_revealed: + # Start scanning when revealing other devices section + if not self.client.scanning: + self.client.toggle_scan() + # Also force an immediate device refresh to catch any missed connections + self.force_device_refresh() + else: + # Stop scanning when hiding other devices section + if self.client.scanning: + self.client.toggle_scan() + + def open_bluetooth_settings(self, *_): + """Open Blueman bluetooth manager""" + try: + subprocess.Popen(["blueman-manager"], start_new_session=True) + if self.parent and hasattr(self.parent, "hide_controlcenter"): + self.parent.hide_controlcenter() + except FileNotFoundError: + pass + except Exception: + pass + + def update_scan_label(self): + """Update scanning state appearance""" + if self.client.scanning: + # Show scanning feedback in refresh indicator + self.refresh_indicator.set_label("Scanning for devices...") + self.refresh_indicator.set_visible(True) + self.refresh_indicator.add_style_class("scanning") + else: + # Hide scanning feedback + self.refresh_indicator.set_visible(False) + self.refresh_indicator.remove_style_class("scanning") + + def update_devices(self, *_): + """Update the list of available devices""" + # Prevent concurrent updates and check if destroyed + if self._update_in_progress or self._destroyed or not self.client: + return + + self._update_in_progress = True + + try: + # Store current device addresses to detect changes + current_paired_addresses = { + child.device.address + for child in self.paired_devices.get_children() + if hasattr(child, "device") + } + current_other_addresses = { + child.device.address + for child in self.other_devices.get_children() + if hasattr(child, "device") + } + + # Get current devices safely + devices = self.client.devices + paired_devices = [] + other_devices = [] + new_paired_addresses = set() + new_other_addresses = set() + + for device in devices: + try: + if device.name and device.name != "Unknown": + # Categorize devices: paired devices go to "Devices (Paired)" + # All others go to "Other Devices" + if device.paired: + paired_devices.append(device) + new_paired_addresses.add(device.address) + else: + other_devices.append(device) + new_other_addresses.add(device.address) + except Exception: + continue + + # Check if we need to update (devices added/removed) + paired_changed = current_paired_addresses != new_paired_addresses + other_changed = current_other_addresses != new_other_addresses + + # Only rebuild if something actually changed + if paired_changed or other_changed: + # Clear existing devices safely + for child in list(self.paired_devices.get_children()): + if not self._destroyed: + child.destroy() + for child in list(self.other_devices.get_children()): + if not self._destroyed: + child.destroy() + + # Add paired devices + for device in paired_devices: + if not self._destroyed: + device_slot = BluetoothDeviceSlot(device) + self.paired_devices.add(device_slot) + + # Add other devices + for device in other_devices: + if not self._destroyed: + device_slot = BluetoothDeviceSlot(device) + self.other_devices.add(device_slot) + + # Show/hide sections based on available devices + if not self._destroyed: + has_paired_devices = len(paired_devices) > 0 + has_other_devices = len(other_devices) > 0 + has_any_devices = has_paired_devices or has_other_devices + + # Show paired devices section only if there are paired devices + self.paired_devices_label.set_visible(has_paired_devices) + self.paired_devices.set_visible(has_paired_devices) + + # Show "No devices available" message if no devices at all + self.no_devices_label.set_visible(not has_any_devices) + + # Always show the other devices button, regardless of available devices + self.other_devices_button.set_visible(True) # Always visible + + except Exception: + pass + finally: + self._update_in_progress = False + + def start_device_monitoring(self): + """Start periodic monitoring for device changes""" + # Monitor for device changes every 5 seconds (less aggressive) + # This helps catch devices that connect from external sources + self.refresh_timer = GLib.timeout_add_seconds(5, self.periodic_device_refresh) + + def stop_device_monitoring(self): + """Stop periodic monitoring""" + if self.refresh_timer: + GLib.source_remove(self.refresh_timer) + self.refresh_timer = None + + def periodic_device_refresh(self): + """Periodically refresh device list to catch external connections""" + # If destroyed, remove the GLib source by returning False + if self._destroyed: + self.refresh_timer = None + return False + + # Skip if update in progress or client not available/enabled + if self._update_in_progress or not self.client or not self.client.enabled: + return True # Continue monitoring + + try: + self.update_devices() + except Exception: + pass + + return True # Continue monitoring + + def force_device_refresh(self): + """Force an immediate refresh of the device list""" + if self._update_in_progress or self._destroyed: + return + + try: + # Simply trigger update_devices which has its own safety checks + # Avoid forcing signal emissions to prevent race conditions + self.update_devices() + except Exception: + pass + + def on_client_changed(self, *_): + """Handle when the bluetooth client state changes""" + # Update devices when client state changes + self.update_devices() + + def on_destroy(self, widget): + """Cleanup when widget is destroyed""" + # Mark as destroyed to prevent further updates + self._destroyed = True + # Stop monitoring timer + self.stop_device_monitoring() + # Disconnect all BluetoothClient signals + for sig_id in self._client_signal_ids: + try: + self.client.disconnect(sig_id) + except Exception: + pass + self._client_signal_ids.clear() + # Collapse other devices revealer + try: + self.other_devices_revealer.child_revealed = False + except Exception: + pass + + def close_bluetooth(self): + """Called when Bluetooth panel is being closed""" + # Collapse the other devices section when closing + self.other_devices_revealer.child_revealed = False + + def setup_pull_to_refresh(self): + """Setup pull-to-refresh gesture for the scrolled window""" + # Get the scrolled window's vertical adjustment + self.vadjustment = self.other_devices_scrolled.get_vadjustment() + + # Track gesture state + self.pull_start_y = 0 + self.is_pulling = False + self.pull_threshold = 50 # pixels to trigger refresh + + # Connect to scroll events + self.other_devices_scrolled.connect("scroll-event", self.on_scroll_event) + self.other_devices_scrolled.connect("button-press-event", self.on_button_press) + self.other_devices_scrolled.connect( + "button-release-event", self.on_button_release + ) + self.other_devices_scrolled.connect( + "motion-notify-event", self.on_motion_notify + ) + + # Enable events + self.other_devices_scrolled.set_events( + Gdk.EventMask.SCROLL_MASK + | Gdk.EventMask.BUTTON_PRESS_MASK + | Gdk.EventMask.BUTTON_RELEASE_MASK + | Gdk.EventMask.POINTER_MOTION_MASK + ) + + def on_scroll_event(self, widget, event): + """Handle scroll events for pull-to-refresh""" + # Only handle pull-to-refresh when at the top + if self.vadjustment.get_value() <= 0: + if event.direction == Gdk.ScrollDirection.UP: + # Scrolling up at the top - toggle scan and force refresh + self.client.toggle_scan() + self.force_device_refresh() + return True # Consume the event + return False # Let normal scrolling continue + + def on_button_press(self, widget, event): + """Handle button press for touch/drag gestures""" + if self.vadjustment.get_value() <= 0: + self.pull_start_y = event.y + self.is_pulling = True + return False + + def on_button_release(self, widget, event): + """Handle button release for touch/drag gestures""" + if self.is_pulling: + pull_distance = event.y - self.pull_start_y + if pull_distance > self.pull_threshold: + # Toggle scan and force refresh + self.client.toggle_scan() + self.force_device_refresh() + # Hide refresh indicator + self.refresh_indicator.set_visible(False) + self.refresh_indicator.remove_style_class("ready-to-refresh") + self.is_pulling = False + return False + + def on_motion_notify(self, widget, event): + """Handle motion events for visual feedback during pull""" + if self.is_pulling and self.vadjustment.get_value() <= 0: + pull_distance = event.y - self.pull_start_y + if pull_distance > 0: + # Show refresh indicator when pulling down + self.refresh_indicator.set_visible(True) + if pull_distance >= self.pull_threshold: + if self.client.scanning: + self.refresh_indicator.set_label("โ†‘ Release to stop scanning") + else: + self.refresh_indicator.set_label("โ†‘ Release to scan") + self.refresh_indicator.add_style_class("ready-to-refresh") + else: + if self.client.scanning: + self.refresh_indicator.set_label("โ†“ Pull to stop scanning") + else: + self.refresh_indicator.set_label("โ†“ Pull to scan for devices") + self.refresh_indicator.remove_style_class("ready-to-refresh") + else: + self.refresh_indicator.set_visible(False) + return False + + def on_device_added(self, client: BluetoothClient, address: str): + """Handle when a new device is added""" + # Update the device list when devices are added + self.update_devices() diff --git a/modules/controlcenter/bluetooth.py b/src/window/controlcenter/bluetooth.py similarity index 83% rename from modules/controlcenter/bluetooth.py rename to src/window/controlcenter/bluetooth.py index 08346aa6..031eed41 100644 --- a/modules/controlcenter/bluetooth.py +++ b/src/window/controlcenter/bluetooth.py @@ -1,8 +1,7 @@ import subprocess -import gi from fabric.bluetooth import BluetoothClient, BluetoothDevice -from fabric.utils import get_relative_path +from fabric.utils import Gdk, GLib, Gtk, exec_shell_command, logger from fabric.widgets.box import Box from fabric.widgets.button import Button from fabric.widgets.centerbox import CenterBox @@ -11,14 +10,28 @@ from fabric.widgets.revealer import Revealer from fabric.widgets.scrolledwindow import ScrolledWindow from fabric.widgets.separator import Separator -from fabric.widgets.svg import Svg -from gi.repository import Gdk, GLib, Gtk -from loguru import logger -from services.battery import Battery +from utils.utils import svg_file -gi.require_version("Gtk", "3.0") -gi.require_version("Gdk", "3.0") + +def get_battery_icon_file( + percentage: int, is_charging: bool, icon_path: str = "" +) -> str: + """ + Get the battery icon file path based on percentage and charging status. + + Args: + percentage: Battery percentage (0-100) + is_charging: Whether the device is charging + icon_path: Base path for icons (unused, kept for compatibility) + + Returns: + Relative path to the battery icon file + """ + clamped = max(0, min(100, percentage)) + step = int((clamped // 10) * 10) + filename = f"battery-{step:03d}{'-charging' if is_charging else ''}.svg" + return f"battery/{filename}" def set_bluetooth_enabled_with_fallback(client, enabled: bool): @@ -27,28 +40,34 @@ def set_bluetooth_enabled_with_fallback(client, enabled: bool): client.set_enabled(enabled) except Exception as e: logger.warning(f"Fabric bluetooth set_enabled({enabled}) failed: {e}") + # Fallback to rfkill to unblock/block bluetooth if enabled: - command = ["rfkill", "unblock", "bluetooth"] + command = "rfkill unblock bluetooth" else: - command = ["rfkill", "block", "bluetooth"] - - try: - # Execute the rfkill command - subprocess.run( - command, capture_output=True, text=True, timeout=10, check=True - ) - except Exception as subprocess_error: - logger.error(f"rfkill fallback failed with exception: {subprocess_error}") + command = "rfkill block bluetooth" + + result = exec_shell_command(command) + if result is False: + logger.error(f"rfkill fallback failed: command '{command}' returned False") + elif isinstance(result, str) and result.strip(): + # If result is a non-empty string, it might be an error message + logger.warning(f"rfkill command output: {result.strip()}") + else: + logger.info(f"rfkill fallback succeeded: {command}") class BluetoothDeviceSlot(CenterBox): def __init__(self, device: BluetoothDevice, **kwargs): super().__init__(h_expand=True, name="device-button", **kwargs) self.device = device - self.device.connect("changed", self.on_changed) - self.device.connect( - "notify::closed", lambda *_: self.device.closed and self.destroy() + self._destroyed = False + self._signal_ids = [] + self._signal_ids.append(self.device.connect("changed", self.on_changed)) + self._signal_ids.append( + self.device.connect( + "notify::closed", lambda *_: self.device.closed and self.destroy() + ) ) self.styles = [ @@ -73,21 +92,16 @@ def __init__(self, device: BluetoothDevice, **kwargs): ) self.start_children = [self.device_button] - # Add battery info if available + # # Add battery info if available if hasattr(device, "battery_percentage") and device.battery_percentage > 0: battery_box = Box(orientation="h", spacing=4) # Create battery icon - battery_icon = Svg( - size=16, - svg_file=get_relative_path( - Battery.get_battery_icon_file( - device.battery_percentage, - False, # Not charging for bluetooth devices - "../../config/assets/icons/", - ) - ), - name="battery-icon", + battery_icon = svg_file( + get_battery_icon_file( + device.battery_percentage, + False, # Not charging for bluetooth devices + ) ) # Create battery percentage label @@ -102,6 +116,20 @@ def __init__(self, device: BluetoothDevice, **kwargs): self.device_button.connect("leave-notify-event", self.on_button_leave) self.device.emit("changed") # to update display status + def destroy(self): + """Clean up device signal connections to prevent memory leaks.""" + if self._destroyed: + return + self._destroyed = True + for sig_id in self._signal_ids: + try: + self.device.disconnect(sig_id) + except Exception: + pass + self._signal_ids.clear() + self.device = None + super().destroy() + def on_button_enter(self, widget, event): self.add_style_class("button-hovered") @@ -113,11 +141,13 @@ def toggle_connecting(self): self.device.set_connecting(not self.device.connected) def on_changed(self, *_): + if self._destroyed or self.device is None: + return try: # Update connection and pairing status new_styles = [ - "connected" if self.device.connected else "", - "paired" if self.device.paired else "", + "connected" if getattr(self.device, "connected", False) else "", + "paired" if getattr(self.device, "paired", False) else "", ] self.styles = new_styles @@ -134,16 +164,11 @@ def on_changed(self, *_): battery_box = Box(orientation="h", spacing=4) # Create battery icon - battery_icon = Svg( - size=16, - svg_file=get_relative_path( - Battery.get_battery_icon_file( - self.device.battery_percentage, - False, # Not charging for bluetooth devices - "../../config/assets/icons/", - ) - ), - name="battery-icon", + battery_icon = svg_file( + get_battery_icon_file( + self.device.battery_percentage, + False, # Not charging for bluetooth devices + ) ) # Create battery percentage label @@ -160,13 +185,10 @@ def on_changed(self, *_): battery_label = battery_box.children[1] # Update battery icon - battery_icon.set_from_file( - get_relative_path( - Battery.get_battery_icon_file( - self.device.battery_percentage, - False, # Not charging for bluetooth devices - "../../config/assets/icons/", - ) + battery_icon.dynamic_file( + get_battery_icon_file( + self.device.battery_percentage, + False, # Not charging for bluetooth devices ) ) @@ -195,6 +217,7 @@ def __init__( self.refresh_timer = None # Timer for periodic device refresh self._update_in_progress = False # Prevent concurrent updates self._destroyed = False # Track if widget is destroyed + self._client_signal_ids = [] # Track BluetoothClient signal IDs self.client = BluetoothClient(on_device_added=self.on_device_added) @@ -234,19 +257,29 @@ def __init__( ), ) - # Connect client signals - self.client.connect( - "notify::enabled", - lambda *_: self.toggle_button.set_active(self.client.enabled), + # Connect client signals โ€” track IDs for disconnect on destroy + self._client_signal_ids.append( + self.client.connect( + "notify::enabled", + lambda *_: self.toggle_button.set_active(self.client.enabled), + ) + ) + self._client_signal_ids.append( + self.client.connect("notify::scanning", lambda *_: self.update_scan_label()) ) - self.client.connect("notify::scanning", lambda *_: self.update_scan_label()) # Connect to device changes - self.client.connect("device-added", self.update_devices) - self.client.connect("device-removed", self.update_devices) + self._client_signal_ids.append( + self.client.connect("device-added", self.update_devices) + ) + self._client_signal_ids.append( + self.client.connect("device-removed", self.update_devices) + ) # Connect to additional signals for better real-time monitoring - self.client.connect("changed", self.on_client_changed) + self._client_signal_ids.append( + self.client.connect("changed", self.on_client_changed) + ) # Create Devices section self.paired_devices_label = Label( @@ -471,20 +504,17 @@ def stop_device_monitoring(self): def periodic_device_refresh(self): """Periodically refresh device list to catch external connections""" - # Skip if update in progress, destroyed, or client not available - if ( - self._update_in_progress - or self._destroyed - or not self.client - or not self.client.enabled - ): + # If destroyed, remove the GLib source by returning False + if self._destroyed: + self.refresh_timer = None + return False + + # Skip if update in progress or client not available/enabled + if self._update_in_progress or not self.client or not self.client.enabled: return True # Continue monitoring try: - # Simple check - just trigger update_devices which has its own safety checks - # Don't force signal emissions as that can cause race conditions self.update_devices() - except Exception: pass @@ -511,13 +541,20 @@ def on_destroy(self, widget): """Cleanup when widget is destroyed""" # Mark as destroyed to prevent further updates self._destroyed = True - # Stop monitoring + # Stop monitoring timer self.stop_device_monitoring() - # Make sure other devices revealer is collapsed when closing + # Disconnect all BluetoothClient signals + for sig_id in self._client_signal_ids: + try: + self.client.disconnect(sig_id) + except Exception: + pass + self._client_signal_ids.clear() + # Collapse other devices revealer try: self.other_devices_revealer.child_revealed = False - except: - pass # Widget might already be destroyed + except Exception: + pass def close_bluetooth(self): """Called when Bluetooth panel is being closed""" diff --git a/modules/controlcenter/expanded_player.py b/src/window/controlcenter/expanded_player.py similarity index 80% rename from modules/controlcenter/expanded_player.py rename to src/window/controlcenter/expanded_player.py index 99d0ad4c..73ffe0cd 100644 --- a/modules/controlcenter/expanded_player.py +++ b/src/window/controlcenter/expanded_player.py @@ -1,32 +1,38 @@ # Standard library imports -import os +import gc import re import tempfile -import urllib.parse -import urllib.request import threading +import urllib.parse +import httpx import weakref -from typing import List, Optional, Dict, Set -import gc - -# Fabric imports -from fabric.widgets.scale import Scale -from widgets.wayland import WaylandWindow as Window -from fabric.widgets.button import Button -from fabric.widgets.label import Label +from typing import Dict, List, Optional + +from fabric.utils import ( + bulk_connect, + cooldown, + invoke_repeater, + GLib, + GObject, + logger, + os, +) from fabric.widgets.box import Box -from fabric.utils import bulk_connect, invoke_repeater, cooldown -from fabric.utils.helpers import get_relative_path +from fabric.widgets.button import Button from fabric.widgets.image import Image +from fabric.widgets.label import Label from fabric.widgets.overlay import Overlay + +# Fabric imports +from fabric.widgets.scale import Scale from fabric.widgets.stack import Stack -from fabric.widgets.svg import Svg -from gi.repository import GLib, GObject -from loguru import logger +from fabric.widgets.wayland import WaylandWindow as Window + +import shared.data as data # Local imports from services.mpris import MprisPlayer, MprisPlayerManager -import config.data as data +from utils.utils import svg_file CACHE_DIR = f"{data.CACHE_DIR}/media" @@ -46,18 +52,18 @@ def get_shared_mpris_manager(): def cleanup_artwork_cache(): - """Clean up artwork cache to prevent memory leaks.""" + """Clean up artwork cache immediately when size limit is reached.""" global _artwork_cache if len(_artwork_cache) > _max_artwork_cache_size: - # Keep only the most recent items + # Keep only the most recent items immediately items = list(_artwork_cache.items()) _artwork_cache = dict(items[-_max_artwork_cache_size:]) - # Force garbage collection + # Immediate garbage collection gc.collect() def cleanup_old_cache_files(): - """Clean up old artwork cache files aggressively to save memory.""" + """Clean up old artwork cache files immediately (no lazy loading).""" try: if not os.path.exists(CACHE_DIR): return @@ -92,7 +98,7 @@ def get_artwork_cached(url: str) -> Optional[str]: if url in _artwork_cache: return _artwork_cache[url] - # Clean cache if too large + # Clean cache immediately if too large (no lazy loading) cleanup_artwork_cache() try: @@ -117,26 +123,17 @@ def get_artwork_cached(url: str) -> Optional[str]: return cache_file # Download and cache - urllib.request.urlretrieve(url, cache_file) - _artwork_cache[url] = cache_file - return cache_file + response = httpx.get(url, timeout=10, follow_redirects=True) + if response.status_code == 200: + with open(cache_file, "wb") as f: + f.write(response.content) + _artwork_cache[url] = cache_file + return cache_file except Exception as e: logger.warning(f"Failed to cache artwork from {url}: {e}") return None - for filename in os.listdir(CACHE_DIR): - filepath = os.path.join(CACHE_DIR, filename) - try: - if os.path.isfile(filepath): - file_mtime = os.path.getmtime(filepath) - if file_mtime < six_hours_ago: - os.unlink(filepath) - except Exception: - pass # Ignore individual file errors - except Exception: - pass # Ignore all errors in cleanup - class EmbeddedExpandedPlayer(Box): """Embedded expanded player widget for use inside control center.""" @@ -162,15 +159,6 @@ def __init__(self, control_center, **kwargs): # Create expanded player content self.player_content = PlayerBoxStack(self.mpris_manager) - # Add escape key binding for navigation back - self._keybinding_added = False - try: - if hasattr(self.control_center, "add_keybinding"): - self.control_center.add_keybinding("Escape", self._on_back_clicked) - self._keybinding_added = True - except Exception: - pass # Ignore if keybinding fails - self.children = [ Box( orientation="horizontal", @@ -199,6 +187,20 @@ def refresh(self): # This will automatically update as MPRIS players change pass + def suspend(self): + """Suspend updates when control center is hidden""" + if hasattr(self, "player_stack") and self.player_stack: + for child in self.player_stack.get_children(): + if hasattr(child, "suspend"): + child.suspend() + + def resume(self): + """Resume updates when control center is shown""" + if hasattr(self, "player_stack") and self.player_stack: + for child in self.player_stack.get_children(): + if hasattr(child, "resume"): + child.resume() + def destroy(self): """Clean up resources and prevent memory leaks""" logger.debug("๐Ÿ—‘๏ธ EmbeddedExpandedPlayer cleanup starting") @@ -221,14 +223,6 @@ def destroy(self): except Exception as e: logger.warning(f"Failed to destroy child widget: {e}") - # Clean up keybinding if it was added - if hasattr(self, "_keybinding_added") and self._keybinding_added: - try: - if hasattr(self.control_center, "remove_keybinding"): - self.control_center.remove_keybinding("Escape") - except Exception as e: - logger.warning(f"Failed to remove keybinding: {e}") - # Aggressively clean global caches global _widget_cache, _artwork_cache _widget_cache.clear() @@ -250,35 +244,12 @@ def destroy(self): finally: super().destroy() - def _periodic_cleanup(self): - """Light cleanup for EmbeddedExpandedPlayer reuse""" - try: - logger.debug("๐Ÿงน EmbeddedExpandedPlayer light cleanup starting") - - # Only clean up the player content lightly - if hasattr(self, "player_content") and hasattr( - self.player_content, "_periodic_cleanup" - ): - self.player_content._periodic_cleanup() - - # Clean global caches but don't destroy core functionality - global _artwork_cache - _artwork_cache.clear() - - # Light garbage collection - gc.collect() - - logger.debug("๐Ÿงน EmbeddedExpandedPlayer light cleanup completed") - - except Exception as e: - logger.warning(f"Error during EmbeddedExpandedPlayer light cleanup: {e}") - class PlayerBoxStack(Box): """Memory-optimized widget that displays current player information.""" def __init__(self, mpris_manager: MprisPlayerManager, **kwargs): - # Clean up old cache files on startup + # Clean up old cache files immediately (no lazy loading) cleanup_old_cache_files() # The player stack with memory-efficient settings @@ -319,63 +290,16 @@ def __init__(self, mpris_manager: MprisPlayerManager, **kwargs): self._signal_connections.append((self.mpris_manager, handler_id)) # Process existing players - for player in self.mpris_manager.players: # type: ignore - logger.info( - f"[PLAYER MANAGER] player found: {player.get_property('player-name')}" - ) + for player in self.mpris_manager.players.values(): # type: ignore + logger.info(f"[PLAYER MANAGER] player found: {player.player_name}") self.on_new_player(self.mpris_manager, player) - # Schedule periodic memory cleanup and store source ID - self._cleanup_source_id = GLib.timeout_add_seconds( - 300, self._periodic_cleanup - ) # Every 5 minutes - - def _periodic_cleanup(self): - """Light cleanup for widget reuse - preserve functionality.""" - try: - logger.debug("Starting light expanded player cleanup for reuse") - - # Clean artwork cache to reduce memory - cleanup_artwork_cache() - - # Reset visual state but preserve connections and core functionality - # Only clear the player widgets that can be recreated - for widget in list(self._player_widgets.values()): - try: - if widget and hasattr(widget, "get_parent") and widget.get_parent(): - # Only remove from parent, don't destroy (let GTK handle it) - widget.get_parent().remove(widget) - except Exception: - pass - # Don't clear the _player_widgets dict - just let them be recreated - - # Reset stack to show no_media_box without destroying children - try: - if hasattr(self, "player_stack") and hasattr(self, "no_media_box"): - self.player_stack.set_visible_child(self.no_media_box) - self.current_stack_pos = 0 - except Exception: - pass - - # Light garbage collection - gc.collect() - - logger.debug("Light expanded player cleanup completed") - - except Exception as e: - logger.warning(f"Error during light cleanup: {e}") - - return True # Continue timer + # No periodic cleanup - immediate cleanup when needed def destroy(self): """Clean up resources when the widget is destroyed.""" try: - # Cancel any pending cleanup timer - if hasattr(self, "_cleanup_source_id") and self._cleanup_source_id: - try: - GLib.source_remove(self._cleanup_source_id) - except Exception: - pass # Timer may have already been removed + # No periodic cleanup timer to cancel (removed lazy loading) # Disconnect all signal connections for obj, handler_id in self._signal_connections: @@ -576,7 +500,7 @@ def on_new_player(self, mpris_manager, player): self.set_visible(True) - new_player_box = PlayerBox(player=MprisPlayer(player), player_stack=self) + new_player_box = PlayerBox(player=player, player_stack=self) self.player_stack.children = [ *self.player_stack.children, new_player_box, @@ -602,7 +526,7 @@ def on_lost_player(self, mpris_manager, player_name): for player_box in players: if ( hasattr(player_box, "player") - and player_box.player.player_name == player_name + and player_box.player.bus_name == player_name ): player_box_to_remove = player_box break @@ -714,6 +638,8 @@ def __init__(self, player: MprisPlayer, player_stack=None, **kwargs): self.current_download_thread = None # Track current download thread self._download_cancelled = False # Flag to cancel downloads self._signal_connections = [] # Track signal connections + self._property_bindings = [] # Track GObject property bindings + self._seekbar_signal_ids = [] # Track seek_bar widget signal IDs # Use same CSS background approach as small player for consistency self.album_cover = Box( @@ -786,10 +712,18 @@ def __init__(self, player: MprisPlayer, player_stack=None, **kwargs): size=1, h_expand=True, ) - self.seek_bar.connect("value-changed", self._on_scale_value_changed) - self.seek_bar.connect("button-press-event", self._on_seek_start) - self.seek_bar.connect("button-release-event", self._on_seek_end) - self.player.bind("can-seek", "sensitive", self.seek_bar) + self._seekbar_signal_ids.append( + self.seek_bar.connect("value-changed", self._on_scale_value_changed) + ) + self._seekbar_signal_ids.append( + self.seek_bar.connect("button-press-event", self._on_seek_start) + ) + self._seekbar_signal_ids.append( + self.seek_bar.connect("button-release-event", self._on_seek_end) + ) + self._property_bindings.append( + self.player.bind_property("can_seek", self.seek_bar, "sensitive") + ) # Position and length labels for seek bar self.position_label = Label( @@ -862,33 +796,45 @@ def __init__(self, player: MprisPlayer, player_stack=None, **kwargs): ], ) - # Bind player properties - self.player.bind_property( - "title", - self.track_title, - "label", - GObject.BindingFlags.DEFAULT, - lambda _, x: ( - re.sub(r"\r?\n", " ", x) if x != "" and x is not None else "No Title" - ), # type: ignore + # Bind player properties โ€” track bindings so we can unbind on destroy + self._property_bindings.append( + self.player.bind_property( + "title", + self.track_title, + "label", + GObject.BindingFlags.DEFAULT, + lambda _, x: ( + re.sub(r"\r?\n", " ", x) + if x != "" and x is not None + else "No Title" + ), # type: ignore + ) ) - self.player.bind_property( - "artist", - self.track_artist, - "label", - GObject.BindingFlags.DEFAULT, - lambda _, x: ( - re.sub(r"\r?\n", " ", x) if x != "" and x is not None else "No Artist" - ), # type: ignore + self._property_bindings.append( + self.player.bind_property( + "artist", + self.track_artist, + "label", + GObject.BindingFlags.DEFAULT, + lambda _, x: ( + re.sub(r"\r?\n", " ", ", ".join(x)) + if isinstance(x, list) and len(x) > 0 + else "No Artist" + ), # type: ignore + ) ) - self.player.bind_property( - "album", - self.track_album, - "label", - GObject.BindingFlags.DEFAULT, - lambda _, x: ( - re.sub(r"\r?\n", " ", x) if x != "" and x is not None else "No Album" - ), # type: ignore + self._property_bindings.append( + self.player.bind_property( + "album", + self.track_album, + "label", + GObject.BindingFlags.DEFAULT, + lambda _, x: ( + re.sub(r"\r?\n", " ", x) + if x != "" and x is not None + else "No Album" + ), # type: ignore + ) ) # Player switcher buttons box (compact, minimal space) @@ -904,21 +850,9 @@ def __init__(self, player: MprisPlayer, player_stack=None, **kwargs): self.stack_buttons_box.hide() # Initially hidden # Create SVG icons from player directory - self.skip_next_icon = Svg( - name="btn", - style_classes=["control-buttons"], - svg_file=get_relative_path("../../config/assets/icons/player/fwd.svg"), - ) - self.skip_prev_icon = Svg( - name="btn", - style_classes=["control-buttons"], - svg_file=get_relative_path("../../config/assets/icons/player/Rewind.svg"), - ) - self.play_pause_icon = Svg( - name="btn", - style_classes=["control-buttons"], - svg_file=get_relative_path("../../config/assets/icons/player/Pause.svg"), - ) + self.skip_next_icon = svg_file("player/fwd.svg", size=22) + self.skip_prev_icon = svg_file("player/Rewind.svg", size=22) + self.play_pause_icon = svg_file("player/Pause.svg", size=22) self.play_pause_button = Button( style_classes=["control-buttons"], @@ -927,7 +861,9 @@ def __init__(self, player: MprisPlayer, player_stack=None, **kwargs): on_clicked=self.player.play_pause, ) - self.player.bind_property("can_pause", self.play_pause_button, "sensitive") + self._property_bindings.append( + self.player.bind_property("can_pause", self.play_pause_button, "sensitive") + ) self.next_button = Button( style_classes=["control-buttons"], @@ -935,7 +871,9 @@ def __init__(self, player: MprisPlayer, player_stack=None, **kwargs): child=self.skip_next_icon, on_clicked=self._on_player_next, ) - self.player.bind_property("can_go_next", self.next_button, "sensitive") + self._property_bindings.append( + self.player.bind_property("can_go_next", self.next_button, "sensitive") + ) self.prev_button = Button( name="macos-control-button", @@ -943,6 +881,9 @@ def __init__(self, player: MprisPlayer, player_stack=None, **kwargs): style_classes=["control-buttons"], on_clicked=self._on_player_prev, ) + self._property_bindings.append( + self.player.bind_property("can_go_previous", self.prev_button, "sensitive") + ) self.button_box.children = ( self.prev_button, self.play_pause_button, @@ -989,7 +930,7 @@ def __init__(self, player: MprisPlayer, player_stack=None, **kwargs): connections = bulk_connect( self.player, { - "exit": self._on_player_exit, + "closed": self._on_player_exit, "notify::playback-status": self._on_playback_change, "notify::metadata": self._on_metadata, }, @@ -998,6 +939,12 @@ def __init__(self, player: MprisPlayer, player_stack=None, **kwargs): for handler_id in connections: self._signal_connections.append((self.player, handler_id)) + # Start seek bar timer immediately for live updates (regardless of playback status) + self._seekbar_timer_id = invoke_repeater(1000, self._move_seekbar) + + # Connect to realize signal to initialize seek bar properly + self.seek_bar.connect("realize", self._on_seek_bar_realized) + def destroy(self): """Clean up all resources when the widget is destroyed.""" # Set exit flag FIRST to stop any running timers @@ -1006,24 +953,42 @@ def destroy(self): # Cancel any ongoing downloads immediately self._download_cancelled = True - # Cancel seek bar timer + # Cancel seek bar timer โ€” critical: prevents the repeater from holding + # a reference to this widget after destruction if self._seekbar_timer_id: try: - from gi.repository import GLib - GLib.source_remove(self._seekbar_timer_id) - except: + except Exception: pass self._seekbar_timer_id = None # Wait for download thread to finish (with timeout) if self.current_download_thread and self.current_download_thread.is_alive(): try: - self.current_download_thread.join(timeout=1.0) # 1 second timeout + self.current_download_thread.join(timeout=1.0) except Exception: pass + self.current_download_thread = None - # Disconnect all signal connections + # Unbind all GObject property bindings โ€” these keep the player alive + # if not explicitly unbound + for binding in self._property_bindings: + try: + binding.unbind() + except Exception: + pass + self._property_bindings.clear() + + # Disconnect seek_bar widget signals + for sig_id in self._seekbar_signal_ids: + try: + if hasattr(self, "seek_bar") and self.seek_bar: + self.seek_bar.disconnect(sig_id) + except Exception: + pass + self._seekbar_signal_ids.clear() + + # Disconnect all player/mpris signal connections for obj, handler_id in self._signal_connections: try: obj.disconnect(handler_id) @@ -1031,7 +996,7 @@ def destroy(self): logger.warning(f"Failed to disconnect signal: {e}") self._signal_connections.clear() - # Clean up temp files aggressively + # Clean up temp artwork files self._cleanup_temp_files() # Clear image references @@ -1041,6 +1006,10 @@ def destroy(self): except Exception: pass + # Drop strong references to avoid reference cycles + self.player = None + self.player_stack = None + super().destroy() def __del__(self): @@ -1116,29 +1085,60 @@ def length_str(self, length): return f"{minutes}:{seconds:02d}" def _on_metadata(self, *_): + if self.exit or self.player is None: + return self._set_image() duration = self.player.length - if duration: + if duration is None: + duration = 0 + + if duration and duration > 0: self.length_label.set_label(self.length_str(duration)) # Clamp duration to avoid 32-bit integer overflow in the scale widget max_int32 = 2147483647 # 2^31 - 1 safe_duration = min(max_int32, duration) - self.seek_bar.set_range(0, safe_duration) - # Cancel existing timer before starting a new one + # Only set range if seek bar is ready + if self.seek_bar.get_realized(): + self.seek_bar.set_range(0, safe_duration) + else: + self.length_label.set_label("0:00") + if self.seek_bar.get_realized(): + self.seek_bar.set_range(0, 100) + + # Restart timer to ensure it's running with updated metadata if self._seekbar_timer_id: try: - from gi.repository import GLib - GLib.source_remove(self._seekbar_timer_id) - except: + except Exception: pass self._seekbar_timer_id = None # Start new timer and store its ID self._seekbar_timer_id = invoke_repeater(1000, self._move_seekbar) + def suspend(self): + """Stop timer and other active updates when hidden""" + if self._seekbar_timer_id: + try: + GLib.source_remove(self._seekbar_timer_id) + except Exception: + pass + self._seekbar_timer_id = None + logger.debug( + f"[PlayerBox] Suspended timer for {self.player.player_name if self.player else 'unknown'}" + ) + + def resume(self): + """Restart timer and updates when shown""" + if self.exit or self.player is None: + return + + if not self._seekbar_timer_id: + self._seekbar_timer_id = invoke_repeater(1000, self._move_seekbar) + logger.debug(f"[PlayerBox] Resumed timer for {self.player.player_name}") + def _cleanup_temp_files(self): """Clean up temporary artwork files.""" for temp_file in self.temp_artwork_files: @@ -1155,23 +1155,27 @@ def _on_player_exit(self, _, value): self.destroy() def _on_player_next(self, *_): - self.player.next() + if self.player is not None: + self.player.next() def _on_player_prev(self, *_): - self.player.previous() + if self.player is not None: + self.player.previous() def _on_playback_change(self, player, status): - status = player.get_property("playback-status") + if self.exit or self.player is None: + return + status = player.playback_status - if status == "paused": - self.play_pause_icon.set_from_file( - get_relative_path("../../config/assets/icons/player/play.svg") - ) + if status == "Paused": + self.play_pause_icon.dynamic_file("player/play.svg") + # Keep timer running for live updates even when paused - if status == "playing": - self.play_pause_icon.set_from_file( - get_relative_path("../../config/assets/icons/player/Pause.svg") - ) + if status == "Playing": + self.play_pause_icon.dynamic_file("player/Pause.svg") + # Ensure timer is running for live updates + if not self._seekbar_timer_id: + self._seekbar_timer_id = invoke_repeater(1000, self._move_seekbar) def _update_image(self, image_path): if image_path and os.path.isfile(image_path): @@ -1245,10 +1249,13 @@ def _download_and_set_artwork(self, arturl): parsed = urllib.parse.urlparse(arturl) suffix = os.path.splitext(parsed.path)[1] or ".png" - with urllib.request.urlopen(arturl, timeout=10) as response: # Add timeout - if self._download_cancelled: - return - data = response.read() + response = httpx.get(arturl, timeout=10, follow_redirects=True) + if self._download_cancelled: + return + if response.status_code == 200: + data = response.content + else: + return # Check one more time if cancelled if self._download_cancelled: @@ -1284,27 +1291,49 @@ def _download_and_set_artwork(self, arturl): return None def _move_seekbar(self, *_): - if self.player is None or self.exit or self._user_seeking: - return True # Continue the timer but don't update while user is seeking + # Stop the timer if the widget is destroyed or exiting + if self.exit: + self._seekbar_timer_id = None + return False # Remove the GLib source + + if self.player is None: + self._seekbar_timer_id = None + return False # Player gone, stop timer + + if self._user_seeking: + return True # Continue timer but skip update while user drags # Additional safety checks to prevent GTK errors if not hasattr(self, "seek_bar") or self.seek_bar is None: + self._seekbar_timer_id = None return False # Stop the timer try: + # Check if seek bar is realized and has a valid adjustment + if not self.seek_bar.get_realized(): + return True # Continue timer, widget not ready yet + position = self.player.position + if position is None: + position = 0 + self.position_label.set_label(self.length_str(position)) - # Only update seek bar if user is not currently seeking - if not self._user_seeking: - # Clamp position to avoid 32-bit integer overflow - max_int32 = 2147483647 # 2^31 - 1 - safe_position = min(max_int32, position) if position else 0 + # Clamp position to avoid 32-bit integer overflow + max_int32 = 2147483647 # 2^31 - 1 + safe_position = min(max_int32, position) if position else 0 + + # Only set value if seek bar has a valid range + if ( + self.seek_bar.get_adjustment() + and self.seek_bar.get_adjustment().get_upper() > 0 + ): self.seek_bar.set_value(safe_position) except Exception as e: # If any error occurs (widget destroyed, etc), stop the timer logger.warning(f"Seek bar update failed, stopping timer: {e}") + self._seekbar_timer_id = None return False return True @@ -1319,6 +1348,24 @@ def _on_seek_end(self, widget, event): self._user_seeking = False return False + def _on_seek_bar_realized(self, widget): + """Initialize seek bar when it's realized""" + try: + if self.exit or self.player is None: + return + duration = self.player.length + if duration is None: + duration = 0 + + if duration and duration > 0: + max_int32 = 2147483647 # 2^31 - 1 + safe_duration = min(max_int32, duration) + self.seek_bar.set_range(0, safe_duration) + else: + self.seek_bar.set_range(0, 100) + except Exception as e: + logger.warning(f"Failed to initialize seek bar: {e}") + def _on_scale_value_changed(self, scale: Scale): """Handle seek bar value changes - only when user is seeking""" if self.player and not self.exit and self._user_seeking: diff --git a/modules/controlcenter/main.py b/src/window/controlcenter/main.py similarity index 61% rename from modules/controlcenter/main.py rename to src/window/controlcenter/main.py index cfa00bcd..4012b5a2 100644 --- a/modules/controlcenter/main.py +++ b/src/window/controlcenter/main.py @@ -1,32 +1,26 @@ import subprocess -from fabric.utils import idle_add -from fabric.utils.helpers import ( - get_relative_path, -) +from fabric.utils import Gdk, GLib, get_relative_path, idle_add, logger from fabric.widgets.box import Box from fabric.widgets.button import Button from fabric.widgets.centerbox import CenterBox from fabric.widgets.label import Label from fabric.widgets.scale import Scale -from fabric.widgets.svg import Svg -from gi.repository import Gdk, GLib -from loguru import logger +from fabric.widgets.wayland import WaylandWindow as Window -from modules.controlcenter.bluetooth import ( - BluetoothConnections, - set_bluetooth_enabled_with_fallback, -) -from modules.controlcenter.expanded_player import EmbeddedExpandedPlayer -from modules.controlcenter.nightlight import create_night_light_widget -from modules.controlcenter.per_app_volume import PerAppVolumeControl -from modules.controlcenter.player import PlayerBoxStack -from modules.controlcenter.wifi import WifiConnections from services.brightness import Brightness -from services.mpris import MprisPlayerManager from services.network import NetworkClient from utils.roam import audio_service, modus_service -from widgets.wayland import WaylandWindow as Window +from utils.utils import svg_file +from window.controlcenter.bluetooth import ( + BluetoothConnections, + set_bluetooth_enabled_with_fallback, +) +from window.controlcenter.expanded_player import EmbeddedExpandedPlayer +from window.controlcenter.nightlight import create_night_light_widget +from window.controlcenter.per_app_volume import PerAppVolumeControl +from window.controlcenter.player import PlayerBoxStack, get_shared_mpris_manager +from window.controlcenter.wifi import WifiConnections brightness_service = Brightness.get_initial() @@ -53,10 +47,10 @@ def __init__(self, **kwargs): self.caffeine_mode = False self._caffeine_process = None - # Lazy loading flags - self._music_initialized = False + # Loading flags - music and expanded player are no longer lazy loaded self._per_app_volume_initialized = False - self._expanded_player_initialized = False + self._signals_connected = False # Track if signals are connected + self._resources_initialized = False # Track if resources are initialized # Store references for cleanup - initialize all as None self._signal_connections = [] @@ -65,33 +59,22 @@ def __init__(self, **kwargs): self._expanded_player_widget = None self._mpris_manager = None # Shared MPRIS manager instance - # Initialize network service for WiFi toggle - self.network_service = NetworkClient() + # Initialize network service for WiFi toggle - lazy load + self.network_service = None self.wifi_service = None - # Initialize flight mode and caffeine states - self._check_initial_states() - - # Wait for network service to be ready + # Initialize flight mode and caffeine states - lazy load + self.caffeine_mode = False + self.flight_mode = False + # Add keybinding immediately (this is fast) self.add_keybinding("Escape", self.hide_controlcenter) volume = 100 - wlan = modus_service.sc("wlan-changed", self.wlan_changed) - bluetooth = modus_service.sc("bluetooth-changed", self.bluetooth_changed) - music = modus_service.sc("music-changed", self.audio_changed) - - self.network_service.connect("wifi-device-added", self.on_network_ready) - # Store signal connections for cleanup - self._signal_connections.extend( - [ - audio_service.connect("changed", self.audio_changed), - audio_service.connect("changed", self.volume_changed), - modus_service.connect("dont-disturb-changed", self.dnd_changed), - ] - ) + # Get initial values and connect signals - store connection IDs for cleanup + wlan = modus_service.wlan if modus_service.wlan else "No Connection" + bluetooth = modus_service.bluetooth if modus_service.bluetooth else "Off" - print(wlan) self.wlan_label = Label( label=wlan, name="wifi-widget-label", @@ -124,8 +107,6 @@ def __init__(self, **kwargs): size=30, h_expand=True, ) - self.volume_scale.connect("change-value", self.set_volume) - self.volume_scale.connect("scroll-event", self.on_volume_scroll) current_brightness = brightness_service.screen_brightness brightness_percentage = ( @@ -146,43 +127,29 @@ def __init__(self, **kwargs): # Only connect brightness controls if brightness service is available if brightness_service.max_screen > 0: - self.brightness_scale.connect("change-value", self.set_brightness) - self.brightness_scale.connect("scroll-event", self.on_brightness_scroll) - self._signal_connections.append( - brightness_service.connect("screen", self.brightness_changed) - ) + pass else: # Disable brightness scale if no backlight device available self.brightness_scale.set_sensitive(False) - # Create placeholder music widget - lazy load content when needed - self.music_widget = Box( - name="music-widget", - h_align="start", - children=[], # Empty initially - ) + self._mpris_manager = get_shared_mpris_manager() + self.music_widget = PlayerBoxStack(self._mpris_manager, control_center=self) self.has_bluetooth_open = False self.has_wifi_open = False self.has_per_app_volume_open = False self.has_expanded_player_open = False - self.bluetooth_svg = Svg( - name="bluetooth-icon", - svg_file=get_relative_path( - "../../config/assets/icons/applets/bluetooth.svg" + self.bluetooth_svg = svg_file( + ( + "applets/bluetooth.svg" if bluetooth != "disabled" - else "../../config/assets/icons/applets/bluetooth-off.svg" + else "applets/bluetooth-off.svg" ), size=42, ) - self.wifi_svg = Svg( - name="wifi-icon", - svg_file=get_relative_path( - "../../config/assets/icons/applets/wifi.svg" - if wlan != "No Connection" - else "../../config/assets/icons/applets/wifi-off.svg" - ), + self.wifi_svg = svg_file( + "applets/wifi.svg" if wlan != "No Connection" else "applets/wifi-off.svg", size=42, ) @@ -244,13 +211,8 @@ def __init__(self, **kwargs): ], ) - self.focus_icon = Svg( - name="focus-icon", - svg_file=get_relative_path( - "../../config/assets/icons/applets/dnd.svg" - if self.focus_mode - else "../../config/assets/icons/applets/dnd-off.svg" - ), + self.focus_icon = svg_file( + "applets/dnd.svg" if self.focus_mode else "applets/dnd-off.svg", size=42, ) @@ -287,13 +249,8 @@ def __init__(self, **kwargs): on_clicked=self.set_dont_disturb, ) - self.flight_icon = Svg( - name="flight-icon", - svg_file=get_relative_path( - "../../config/assets/icons/applets/flight-on.svg" - if self.flight_mode - else "../../config/assets/icons/applets/flight-off.svg" - ), + self.flight_icon = svg_file( + "applets/flight-on.svg" if self.flight_mode else "applets/flight-off.svg", size=42, ) @@ -318,12 +275,11 @@ def __init__(self, **kwargs): on_clicked=self.toggle_flight_mode, ) - self.caffeine_icon = Svg( - name="caffeine-icon", - svg_file=get_relative_path( - "../../config/assets/icons/applets/caffeine-on.svg" + self.caffeine_icon = svg_file( + ( + "applets/caffeine-on.svg" if self.caffeine_mode - else "../../config/assets/icons/applets/caffeine-off.svg" + else "applets/caffeine-off.svg" ), size=42, ) @@ -350,16 +306,6 @@ def __init__(self, **kwargs): style_classes="title-widget", h_align="center", ), - # Box( - # orientation="vertical", - # v_expand=True, - # v_align="center", - # h_align="center", - # h_expand=True, - # children=[ - # # self.caffeine_status_label, - # ], - # ), ], ), on_clicked=self.toggle_caffeine, @@ -368,7 +314,7 @@ def __init__(self, **kwargs): # Create night light widget self.night_light_widget = create_night_light_widget(self) - # Create main widgets directly without XML + # Create main widgets directly without XML - defer heavy operations self.widgets = Box( orientation="vertical", h_expand=True, @@ -476,12 +422,10 @@ def __init__(self, **kwargs): Button( name="per-app-volume-button", size=(36, 36), - child=Svg( - svg_file=get_relative_path( - "../../config/assets/icons/player/audio-switcher.svg" - ), + child=svg_file( + "player/audio-switcher.svg", name="per-app-volume-icon", - sidze=32, + size=32, ), on_clicked=self.open_per_app_volume, ), @@ -501,18 +445,32 @@ def __init__(self, **kwargs): self.has_bluetooth_open = False self.has_wifi_open = False - # Lazy-loaded widgets - create placeholders + # Create expanded player widgets immediately (no lazy loading) + self._expanded_player_widget = EmbeddedExpandedPlayer(self) + self.expanded_player_widgets = Box( + orientation="vertical", + h_expand=True, + name="control-center-widgets", + children=[ + self._expanded_player_widget, + ], + ) + + # Lazy-loaded widgets - create placeholders for others self.bluetooth_widgets = None self.wifi_widgets = None self.per_app_volume_widgets = None - self.expanded_player_widgets = None # Create main content boxes self.center_box = CenterBox(start_children=[self.widgets]) self.bluetooth_center_box = None self.wifi_center_box = None self.per_app_volume_center_box = None - self.expanded_player_center_box = None + # Create expanded player center box immediately + self.expanded_player_center_box = CenterBox( + start_children=[self.expanded_player_widgets] + ) + self.expanded_player_center_box.set_size_request(300, -1) # Create revealers for crossfade transitions @@ -527,70 +485,142 @@ def __init__(self, **kwargs): self.connect("notify::visible", self._on_visibility_changed) def _on_visibility_changed(self, widget, param): - """Handle visibility changes for memory management""" + """Handle visibility changes for resource management""" if not self.get_visible(): - self._cleanup_when_hidden() + # Suspend player updates + if hasattr(self, "music_widget") and hasattr(self.music_widget, "suspend"): + self.music_widget.suspend() + if hasattr(self, "_expanded_player_widget") and hasattr( + self._expanded_player_widget, "suspend" + ): + self._expanded_player_widget.suspend() + + # Just disconnect signals and reset state flags - don't destroy widgets + self._disconnect_signals_when_hidden() + else: + self._initialize_resources() + + # Resume player updates + if hasattr(self, "music_widget") and hasattr(self.music_widget, "resume"): + self.music_widget.resume() + if hasattr(self, "_expanded_player_widget") and hasattr( + self._expanded_player_widget, "resume" + ): + self._expanded_player_widget.resume() + + def _initialize_resources(self): + """Initialize resources and connect signals when the control center becomes visible.""" + if self._resources_initialized: + return + + try: + logger.debug("Initializing control center resources...") + + # Initialize network service lazily + if self.network_service is None: + self.network_service = NetworkClient() + # Connect network signal + self.network_service.connect("wifi-device-added", self.on_network_ready) + + # Check initial states lazily (only when needed) + self._check_initial_states() + + # Store signal connections as (obj, handler_id) tuples for proper cleanup + self._signal_connections.extend( + [ + ( + audio_service, + audio_service.connect("changed", self.audio_changed), + ), + ( + audio_service, + audio_service.connect("changed", self.volume_changed), + ), + ( + modus_service, + modus_service.connect("wlan-changed", self.wlan_changed), + ), + ( + modus_service, + modus_service.connect( + "bluetooth-changed", self.bluetooth_changed + ), + ), + ( + modus_service, + modus_service.connect("dont-disturb-changed", self.dnd_changed), + ), + ] + ) + + # Connect brightness controls if brightness service is available + if brightness_service.max_screen > 0: + self.brightness_scale.connect("change-value", self.set_brightness) + self.brightness_scale.connect("scroll-event", self.on_brightness_scroll) + self._signal_connections.append( + ( + brightness_service, + brightness_service.connect("screen", self.brightness_changed), + ) + ) - def _cleanup_when_hidden(self): - """Aggressively clean up resources when widget is hidden to reduce memory usage""" + # Connect volume scale signals + self.volume_scale.connect("change-value", self.set_volume) + self.volume_scale.connect("scroll-event", self.on_volume_scroll) + + # Mark signals as connected + self._signals_connected = True + self._resources_initialized = True + + logger.debug("Control center resources initialized successfully") + + except Exception as e: + logger.error(f"Failed to initialize control center resources: {e}") + # Reset flags on failure + self._signals_connected = False + self._resources_initialized = False + + def _disconnect_signals_when_hidden(self): + """Disconnect signals when hidden to reduce resource usage, but keep widgets intact""" try: - # Clean up music widget content if it exists - if self._music_widget_content: - # Remove from the parent container - current_children = list(self.music_widget.children) - if self._music_widget_content in current_children: - current_children.remove(self._music_widget_content) - self.music_widget.children = current_children - - # Trigger periodic cleanup before destroying - if hasattr(self._music_widget_content, "_periodic_cleanup"): - self._music_widget_content._periodic_cleanup() - - # Properly destroy the music widget content + # Actually disconnect the signals we tracked + for obj, handler_id in self._signal_connections: try: - self._music_widget_content.destroy() + obj.disconnect(handler_id) except Exception as e: - logger.warning(f"Failed to destroy music widget content: {e}") - self._music_widget_content = None + logger.warning(f"Failed to disconnect signal: {e}") + self._signal_connections.clear() - # Clean up shared MPRIS manager when hidden to free memory - if self._mpris_manager: + # Disconnect direct scale signals (connected in _initialize_resources) + if hasattr(self, "volume_scale"): try: - self._mpris_manager.destroy() - except Exception as e: - logger.warning( - f"Failed to destroy MPRIS manager during cleanup: {e}" - ) - self._mpris_manager = None + self.volume_scale.disconnect_by_func(self.set_volume) + self.volume_scale.disconnect_by_func(self.on_volume_scroll) + except Exception: + pass + if hasattr(self, "brightness_scale"): + try: + self.brightness_scale.disconnect_by_func(self.set_brightness) + self.brightness_scale.disconnect_by_func(self.on_brightness_scroll) + except Exception: + pass - # Reset initialization flags to force recreation next time - self._music_initialized = False + self._signals_connected = False + self._resources_initialized = False - # Force garbage collection - import gc + # Reset state flags + self.has_bluetooth_open = False + self.has_wifi_open = False + self.has_per_app_volume_open = False + self.has_expanded_player_open = False - gc.collect() + # Reset current view + self.current_view = "main" - logger.debug("Control center aggressive cleanup completed") + logger.debug("Control center signals disconnected while hidden") except Exception as e: - logger.warning(f"Control center cleanup failed: {e}") - - def _ensure_music_widget(self): - """Lazy load music widget content - reuse MPRIS manager""" - if not self._music_initialized: - # Create shared MPRIS manager if it doesn't exist - if self._mpris_manager is None: - self._mpris_manager = MprisPlayerManager() - - self._music_widget_content = PlayerBoxStack( - self._mpris_manager, control_center=self - ) - # Add to the music widget's children list - current_children = list(self.music_widget.children) - current_children.append(self._music_widget_content) - self.music_widget.children = current_children - self._music_initialized = True + logger.warning(f"Control center signal disconnection failed: {e}") def _ensure_bluetooth_widgets(self): """Lazy load bluetooth widgets""" @@ -599,24 +629,7 @@ def _ensure_bluetooth_widgets(self): orientation="vertical", h_expand=True, v_expand=True, - children=[ - self.bluetooth_man, - # Box( - # orientation="horizontal", - # name="top-widget", - # h_expand=True, - # children=[ - # Box( - # orientation="vertical", - # name="wb-widget", - # style_classes="menu", - # spacing=5, - # children=[ - # ], - # ), - # ], - # ), - ], + children=[self.bluetooth_man], ) self.bluetooth_center_box = Box( h_expand=True, v_expand=True, children=[self.bluetooth_widgets] @@ -658,33 +671,18 @@ def _ensure_per_app_volume_widgets(self): ) self.per_app_volume_center_box.set_size_request(300, -1) - def _ensure_expanded_player_widgets(self): - """Lazy load expanded player widgets""" - if self.expanded_player_widgets is None: - if self._expanded_player_widget is None: - self._expanded_player_widget = EmbeddedExpandedPlayer(self) - - self.expanded_player_widgets = Box( - orientation="vertical", - h_expand=True, - name="control-center-widgets", - children=[ - self._expanded_player_widget, - ], - ) - self.expanded_player_center_box = CenterBox( - start_children=[self.expanded_player_widgets] - ) - self.expanded_player_center_box.set_size_request(300, -1) - def _check_initial_states(self): - # Check if caffeine is already running + # Check if caffeine is already running - use faster method try: + # Use faster check with timeout result = subprocess.run( - ["pgrep", "-f", "modus-inhibit"], capture_output=True, text=True + ["pgrep", "-f", "modus-inhibit"], + capture_output=True, + text=True, + timeout=0.5, # Add timeout to prevent hanging ) self.caffeine_mode = bool(result.stdout.strip()) - except Exception: + except (subprocess.TimeoutExpired, Exception): self.caffeine_mode = False # Flight mode starts as False (normal mode) @@ -699,12 +697,8 @@ def _update_initial_labels(self): def set_dont_disturb(self, *_): self.focus_mode = not self.focus_mode modus_service.dont_disturb = self.focus_mode - self.focus_icon.set_from_file( - get_relative_path( - "../../config/assets/icons/applets/dnd.svg" - if self.focus_mode - else "../../config/assets/icons/applets/dnd-off.svg" - ) + self.focus_icon.dynamic_file( + "applets/dnd.svg" if self.focus_mode else "applets/dnd-off.svg" ) self.focus_status_label.set_label("On" if self.focus_mode else "Off") @@ -730,12 +724,10 @@ def toggle_flight_mode(self, *_): self.bluetooth_man.client.set_enabled(True) # Update icon - self.flight_icon.set_from_file( - get_relative_path( - "../../config/assets/icons/applets/flight-on.svg" - if self.flight_mode - else "../../config/assets/icons/applets/flight-off.svg" - ) + self.flight_icon.dynamic_file( + "applets/flight-on.svg" + if self.flight_mode + else "applets/flight-off.svg" ) except Exception as e: @@ -744,30 +736,26 @@ def toggle_flight_mode(self, *_): def toggle_caffeine(self, *_): try: if self.caffeine_mode: - # Turn off caffeine inhibit_script = get_relative_path("../../utils/inhibit.py") subprocess.run(["python3", inhibit_script, "off"], check=False) self.caffeine_mode = False if self._caffeine_process: try: self._caffeine_process.terminate() - except: + except Exception: pass self._caffeine_process = None else: - # Turn on caffeine inhibit_script = get_relative_path("../../utils/inhibit.py") self._caffeine_process = subprocess.Popen( ["python3", inhibit_script, "on"], start_new_session=True ) self.caffeine_mode = True - self.caffeine_icon.set_from_file( - get_relative_path( - "../../config/assets/icons/applets/caffeine-on.svg" - if self.caffeine_mode - else "../../config/assets/icons/applets/caffeine-off.svg" - ) + self.caffeine_icon.dynamic_file( + "applets/caffeine-on.svg" + if self.caffeine_mode + else "applets/caffeine-off.svg" ) self.caffeine_status_label.set_label("On" if self.caffeine_mode else "Off") @@ -775,18 +763,22 @@ def toggle_caffeine(self, *_): logger.warning(f"Failed to toggle caffeine: {e}") def set_volume(self, _, __, volume): + if not self._signals_connected: + return self._updating_volume = True audio_service.speaker.volume = round(volume) self._updating_volume = False def set_brightness(self, _, __, brightness): + if not self._signals_connected: + return self._updating_brightness = True brightness_value = int((brightness / 100) * brightness_service.max_screen) brightness_service.screen_brightness = brightness_value self._updating_brightness = False def brightness_changed(self, _, brightness_value): - if self._updating_brightness: + if not self._signals_connected or self._updating_brightness: return if brightness_service.max_screen > 0: @@ -799,6 +791,8 @@ def brightness_changed(self, _, brightness_value): ) def on_volume_scroll(self, widget, event): + if not self._signals_connected: + return False current_value = self.volume_scale.get_value() scroll_step = 5 if event.direction == Gdk.ScrollDirection.UP: @@ -812,6 +806,8 @@ def on_volume_scroll(self, widget, event): return True def on_brightness_scroll(self, widget, event): + if not self._signals_connected: + return False current_value = self.brightness_scale.get_value() scroll_step = 5 if event.direction == Gdk.ScrollDirection.UP: @@ -825,8 +821,9 @@ def on_brightness_scroll(self, widget, event): return True def toggle_bluetooth(self, *_): + if not self._resources_initialized: + return try: - # Access the bluetooth client from the bluetooth manager if hasattr(self, "bluetooth_man") and hasattr(self.bluetooth_man, "client"): current_state = self.bluetooth_man.client.enabled set_bluetooth_enabled_with_fallback( @@ -838,32 +835,26 @@ def toggle_bluetooth(self, *_): logger.warning(f"Failed to toggle bluetooth: {e}") def on_network_ready(self, *_): - """Called when network service is ready""" self.wifi_service = self.network_service.wifi_device if self.wifi_service: - # Connect to WiFi state changes to update icon self.wifi_service.connect("notify::wireless-enabled", self.update_wifi_icon) def update_wifi_icon(self, *_): - """Update WiFi icon based on current state""" try: if self.wifi_service and hasattr(self, "wifi_svg"): is_enabled = self.wifi_service.wireless_enabled - icon_file = ( - "../../config/assets/icons/applets/wifi.svg" - if is_enabled - else "../../config/assets/icons/applets/wifi-off.svg" + self.wifi_svg.dynamic_file( + "applets/wifi.svg" if is_enabled else "applets/wifi-off.svg" ) - self.wifi_svg.set_from_file(get_relative_path(icon_file)) except Exception as e: logger.warning(f"Failed to update WiFi icon: {e}") def toggle_wifi(self, *_): - """Toggle wifi on/off""" + if not self._resources_initialized: + return try: if self.wifi_service: self.wifi_service.toggle_wifi() - # Update icon immediately after toggle GLib.timeout_add(100, self.update_wifi_icon) else: logger.warning("WiFi device not available for toggling") @@ -874,11 +865,15 @@ def set_children(self, children): self.children = children def open_bluetooth(self, *_): + if not self._resources_initialized: + return self._ensure_bluetooth_widgets() idle_add(lambda *_: self.set_children(self.bluetooth_center_box)) self.has_bluetooth_open = True def open_wifi(self, *_): + if not self._resources_initialized: + return self._ensure_wifi_widgets() idle_add(lambda *_: self.set_children(self.wifi_center_box)) self.has_wifi_open = True @@ -898,6 +893,8 @@ def close_wifi(self, *_): self.has_wifi_open = False def open_per_app_volume(self, *_): + if not self._resources_initialized: + return self._ensure_per_app_volume_widgets() if self.current_view == "expanded_player": # If coming from expanded player, use crossfade @@ -923,33 +920,30 @@ def close_per_app_volume(self, *_): self.has_per_app_volume_open = False def open_expanded_player(self, *_): - self._ensure_expanded_player_widgets() + if not self._resources_initialized: + return self._crossfade_to_view("expanded_player") self.has_expanded_player_open = True - # Refresh the player when opening if self._expanded_player_widget: self._expanded_player_widget.refresh() def close_expanded_player(self, *_): + # Just hide the expanded player widget, don't destroy it self._crossfade_to_view("main") self.has_expanded_player_open = False def _crossfade_to_view(self, view_name): """Handle transitions between views""" if view_name == "expanded_player": - # Show expanded player - self._ensure_expanded_player_widgets() idle_add(lambda *_: self.set_children(self.expanded_player_center_box)) self.current_view = "expanded_player" elif view_name == "main": - # Show main view idle_add(lambda *_: self.set_children(self.center_box)) self.current_view = "main" def _set_mousecapture(self, visible: bool): if visible: - # Lazy load music widget when becoming visible - self._ensure_music_widget() + GLib.idle_add(self._initialize_resources) self.set_visible(visible) if not visible: @@ -962,7 +956,7 @@ def volume_changed( self, _, ): - if self._updating_volume: + if not self._signals_connected or self._updating_volume: return GLib.idle_add( @@ -970,12 +964,10 @@ def volume_changed( ) def wlan_changed(self, _, wlan): - self.wifi_svg.set_from_file( - get_relative_path( - "../../config/assets/icons/applets/wifi.svg" - if wlan != "No Connection" - else "../../config/assets/icons/applets/wifi-off.svg" - ) + if not self._signals_connected: + return + self.wifi_svg.dynamic_file( + "applets/wifi.svg" if wlan != "No Connection" else "applets/wifi-off.svg" ) if wlan != "No Connection": if wlan.startswith("connected:"): @@ -995,12 +987,12 @@ def wlan_changed(self, _, wlan): GLib.idle_add(lambda: self.wlan_label.set_property("label", wlan)) def bluetooth_changed(self, _, bluetooth): - self.bluetooth_svg.set_from_file( - get_relative_path( - "../../config/assets/icons/applets/bluetooth.svg" - if bluetooth != "disabled" - else "../../config/assets/icons/applets/bluetooth-off.svg" - ) + if not self._signals_connected: + return + self.bluetooth_svg.dynamic_file( + "applets/bluetooth.svg" + if bluetooth != "disabled" + else "applets/bluetooth-off.svg" ) if bluetooth != "disabled": if bluetooth.startswith("connected:"): @@ -1018,61 +1010,219 @@ def bluetooth_changed(self, _, bluetooth): GLib.idle_add(lambda: self.bluetooth_label.set_label("Off")) def audio_changed(self, *_): + if not self._signals_connected: + return pass def dnd_changed(self, _, dnd_state): + if not self._signals_connected: + return self.focus_mode = dnd_state - self.focus_icon.set_from_file( - get_relative_path( - "../../config/assets/icons/applets/dnd.svg" - if self.focus_mode - else "../../config/assets/icons/applets/dnd-off.svg" - ) + self.focus_icon.dynamic_file( + "applets/dnd.svg" if self.focus_mode else "applets/dnd-off.svg" ) self.focus_status_label.set_label("On" if self.focus_mode else "Off") def _init_mousecapture(self, mousecapture): self._mousecapture_parent = mousecapture - def hide_controlcenter(self, *_): - self._mousecapture_parent.toggle_mousecapture() - self.set_visible(False) + def _disconnect_all_signals(self): + """Disconnect all signal connections to prevent memory leaks""" + try: + # _signal_connections stores (obj, handler_id) tuples + for obj, handler_id in self._signal_connections: + try: + obj.disconnect(handler_id) + except Exception as e: + logger.warning(f"Failed to disconnect signal: {e}") + self._signal_connections.clear() - def destroy(self): - """Clean up resources when widget is destroyed""" - # Disconnect all signal connections - for connection in self._signal_connections: - try: - connection.disconnect() - except: - pass + # Disconnect scale widget signals (connected directly, not tracked) + if hasattr(self, "volume_scale") and self.volume_scale: + try: + self.volume_scale.disconnect_by_func(self.set_volume) + except Exception: + pass + try: + self.volume_scale.disconnect_by_func(self.on_volume_scroll) + except Exception: + pass - # Clean up caffeine process - if self._caffeine_process: + if hasattr(self, "brightness_scale") and self.brightness_scale: + try: + self.brightness_scale.disconnect_by_func(self.set_brightness) + except Exception: + pass + try: + self.brightness_scale.disconnect_by_func(self.on_brightness_scroll) + except Exception: + pass + + # Disconnect the visibility change signal on self try: - self._caffeine_process.terminate() - except: + self.disconnect_by_func(self._on_visibility_changed) + except Exception: pass - self._caffeine_process = None - - # Clean up heavy components - if hasattr(self, "wifi_man") and self.wifi_man: - self.wifi_man.destroy() - if hasattr(self, "bluetooth_man") and self.bluetooth_man: - self.bluetooth_man.destroy() - if self._music_widget_content: - self._music_widget_content.destroy() - if self._per_app_volume_widget: - self._per_app_volume_widget.destroy() - if self._expanded_player_widget: - self._expanded_player_widget.destroy() - # Clean up shared MPRIS manager - if self._mpris_manager: - try: - self._mpris_manager.destroy() - except Exception as e: - logger.warning(f"Failed to destroy MPRIS manager: {e}") - self._mpris_manager = None + self._signals_connected = False + logger.debug("All signals disconnected successfully") + + except Exception as e: + logger.warning(f"Signal disconnection failed: {e}") - super().destroy() + def _cleanup_managers(self): + """Clean up all manager instances""" + try: + if hasattr(self, "wifi_man") and self.wifi_man: + try: + self.wifi_man.destroy() + except Exception as e: + logger.warning(f"Failed to destroy WiFi manager: {e}") + self.wifi_man = None + + if hasattr(self, "bluetooth_man") and self.bluetooth_man: + try: + self.bluetooth_man.destroy() + except Exception as e: + logger.warning(f"Failed to destroy Bluetooth manager: {e}") + self.bluetooth_man = None + + if hasattr(self, "network_service") and self.network_service: + try: + self.network_service.destroy() + except Exception as e: + logger.warning(f"Failed to destroy network service: {e}") + self.network_service = None + + if hasattr(self, "wifi_service") and self.wifi_service: + try: + self.wifi_service.disconnect_by_func(self.update_wifi_icon) + except Exception as e: + logger.warning(f"Failed to disconnect WiFi service: {e}") + self.wifi_service = None + + self._resources_initialized = False + + logger.debug("All managers cleaned up successfully") + + except Exception as e: + logger.warning(f"Manager cleanup failed: {e}") + + def _cleanup_widgets(self): + try: + # Clean up main widgets + if hasattr(self, "widgets") and self.widgets: + try: + self.widgets.destroy() + except Exception as e: + logger.warning(f"Failed to destroy main widgets: {e}") + self.widgets = None + + # Clean up center box + if hasattr(self, "center_box") and self.center_box: + try: + self.center_box.destroy() + except Exception as e: + logger.warning(f"Failed to destroy center box: {e}") + self.center_box = None + + # Clean up individual widgets + widget_attrs = [ + "wlan_widget", + "bluetooth_widget", + "focus_widget", + "flight_widget", + "caffeine_widget", + "night_light_widget", + "volume_scale", + "brightness_scale", + "wlan_label", + "bluetooth_label", + "focus_status_label", + "caffeine_status_label", + "wlan_svg", + "bluetooth_svg", + "focus_icon", + "flight_icon", + "caffeine_icon", + ] + + for attr in widget_attrs: + if hasattr(self, attr) and getattr(self, attr): + try: + widget = getattr(self, attr) + if hasattr(widget, "destroy"): + widget.destroy() + except Exception as e: + logger.warning(f"Failed to destroy {attr}: {e}") + setattr(self, attr, None) + + logger.debug("All widgets cleaned up successfully") + + except Exception as e: + logger.warning(f"Widget cleanup failed: {e}") + + def _cleanup_processes(self): + """Clean up any running processes""" + try: + # Clean up caffeine process + if self._caffeine_process: + try: + self._caffeine_process.terminate() + self._caffeine_process.wait(timeout=1) + except Exception as e: + logger.warning(f"Failed to terminate caffeine process: {e}") + finally: + self._caffeine_process = None + + logger.debug("All processes cleaned up successfully") + + except Exception as e: + logger.warning(f"Process cleanup failed: {e}") + + def _complete_cleanup(self): + """Perform complete cleanup of all resources""" + try: + logger.debug("Starting complete cleanup...") + self._disconnect_all_signals() + self._cleanup_managers() + self._cleanup_widgets() + self._cleanup_processes() + self._disconnect_signals_when_hidden() + + # Force garbage collection + import gc + + gc.collect() + + logger.debug("Complete cleanup finished successfully") + + except Exception as e: + logger.error(f"Complete cleanup failed: {e}") + + def hide_controlcenter(self, *_): + try: + # Just disconnect signals when hiding, don't destroy widgets + self._disconnect_signals_when_hidden() + + # Hide the control center + if hasattr(self, "_mousecapture_parent"): + self._mousecapture_parent.toggle_mousecapture() + self.set_visible(False) + + except Exception as e: + logger.error(f"Failed to hide control center: {e}") + # Still try to hide even if cleanup fails + self.set_visible(False) + + def destroy(self): + try: + self._complete_cleanup() + super().destroy() + logger.debug("Control center destroyed successfully") + except Exception as e: + logger.error(f"Failed to destroy control center: {e}") + try: + super().destroy() + except Exception: + pass diff --git a/modules/controlcenter/nightlight.py b/src/window/controlcenter/nightlight.py similarity index 85% rename from modules/controlcenter/nightlight.py rename to src/window/controlcenter/nightlight.py index c5414c2e..d1ab0d4b 100644 --- a/modules/controlcenter/nightlight.py +++ b/src/window/controlcenter/nightlight.py @@ -1,15 +1,11 @@ -# Standard library imports import subprocess -# Fabric imports -from fabric.utils.helpers import get_relative_path +from fabric.utils import logger from fabric.widgets.box import Box from fabric.widgets.button import Button from fabric.widgets.label import Label -from fabric.widgets.svg import Svg -# Local imports -from loguru import logger +from utils.utils import svg_file class NightLightControl: @@ -73,12 +69,11 @@ def create_night_light_widget(control_center): night_light = NightLightControl() # Create icon - night_light_icon = Svg( - name="nightlight-icon", - svg_file=get_relative_path( - "../../config/assets/icons/applets/redshift-status-on.svg" + night_light_icon = svg_file( + ( + "applets/redshift-status-on.svg" if night_light.is_active - else "../../config/assets/icons/applets/redshift-status-off.svg" + else "applets/redshift-status-off.svg" ), size=42, ) @@ -97,12 +92,10 @@ def toggle_night_light(*_): """Toggle night light and update UI""" if night_light.toggle(): # Update icon - night_light_icon.set_from_file( - get_relative_path( - "../../config/assets/icons/applets/redshift-status-on.svg" - if night_light.is_active - else "../../config/assets/icons/applets/redshift-status-off.svg" - ) + night_light_icon.dynamic_file( + "applets/redshift-status-on.svg" + if night_light.is_active + else "applets/redshift-status-off.svg" ) # Update status label night_light_status_label.set_label("On" if night_light.is_active else "Off") @@ -143,4 +136,3 @@ def toggle_night_light(*_): ) return night_light_widget - diff --git a/modules/controlcenter/per_app_volume.py b/src/window/controlcenter/per_app_volume.py similarity index 92% rename from modules/controlcenter/per_app_volume.py rename to src/window/controlcenter/per_app_volume.py index 08ba18a5..af63e82f 100644 --- a/modules/controlcenter/per_app_volume.py +++ b/src/window/controlcenter/per_app_volume.py @@ -1,13 +1,10 @@ -# Standard library imports -from gi.repository import GLib - -# Fabric imports +from fabric.utils import GLib from fabric.widgets.box import Box from fabric.widgets.button import Button +from fabric.widgets.image import Image from fabric.widgets.label import Label from fabric.widgets.scale import Scale from fabric.widgets.scrolledwindow import ScrolledWindow -from fabric.widgets.image import Image from fabric.widgets.separator import Separator # Local imports @@ -29,6 +26,8 @@ def __init__(self, control_center, **kwargs): self._updating_volumes = set() self._app_widgets = {} self._signal_connections = [] + self._destroyed = False + self._refresh_timer = None # Header with back button self.header = Box( @@ -83,6 +82,9 @@ def __init__(self, control_center, **kwargs): def _auto_refresh(self): """Auto-refresh the application list every 2 seconds""" + if self._destroyed: + self._refresh_timer = None + return False # Remove GLib source self._populate_apps() return True # Continue the timer @@ -117,7 +119,6 @@ def _get_app_icon(self, app): "visual studio code": "vscode", "telegram": "telegram-desktop", "pulse": "audio-card", - "zen": "zen-browser", "pipewire": "audio-card", "alsa": "audio-card", "sink": "audio-speakers", @@ -169,7 +170,12 @@ def _format_app_name(self, name): def _populate_apps(self): """Populate the widget with current audio applications""" - # Clear existing widgets + # Clear and DESTROY existing widgets to prevent memory leaks + for child in list(self.apps_container.get_children()): + try: + child.destroy() + except Exception: + pass self.apps_container.children = [] self._app_widgets.clear() @@ -310,7 +316,8 @@ def _set_app_volume(self, app, volume_value): def _on_stream_changed(self, *_): """Handle when audio streams are added or removed""" - GLib.idle_add(self._populate_apps) + if not self._destroyed: + GLib.idle_add(self._populate_apps) def refresh(self): """Manually refresh the application list""" @@ -318,19 +325,27 @@ def refresh(self): def destroy(self): """Clean up resources""" - if hasattr(self, "_refresh_timer"): - GLib.source_remove(self._refresh_timer) + self._destroyed = True + + # Stop the auto-refresh timer + if self._refresh_timer is not None: + try: + GLib.source_remove(self._refresh_timer) + except Exception: + pass + self._refresh_timer = None - # Disconnect fabric audio service signals + # Disconnect audio service signals if audio_service: for connection in self._signal_connections: try: audio_service.disconnect(connection) - except: + except Exception: pass self._signal_connections.clear() self._app_widgets.clear() self._updating_volumes.clear() + self.control_center = None super().destroy() diff --git a/modules/controlcenter/player.py b/src/window/controlcenter/player.py similarity index 60% rename from modules/controlcenter/player.py rename to src/window/controlcenter/player.py index d5a6fd4c..14cfa2d4 100644 --- a/modules/controlcenter/player.py +++ b/src/window/controlcenter/player.py @@ -1,30 +1,50 @@ -import os import re -import tempfile -import urllib.parse -import urllib.request +import time from typing import List -import threading -from fabric.utils import ( - bulk_connect, -) -from fabric.utils.helpers import get_relative_path from fabric.widgets.box import Box from fabric.widgets.button import Button +from fabric.widgets.centerbox import CenterBox from fabric.widgets.image import Image from fabric.widgets.label import Label from fabric.widgets.overlay import Overlay from fabric.widgets.stack import Stack -from fabric.widgets.svg import Svg -from gi.repository import GLib, GObject -from fabric.widgets.centerbox import CenterBox -from loguru import logger +from fabric.utils import GLib, GObject, Gio, logger, bulk_connect, get_relative_path, os + +import shared.data as data from services.mpris import MprisPlayer, MprisPlayerManager -import config.data as data +from utils.utils import svg_file CACHE_DIR = f"{data.CACHE_DIR}/media" +MEDIA_CACHE = CACHE_DIR +if not os.path.exists(CACHE_DIR): + os.makedirs(CACHE_DIR) +if not os.path.exists(MEDIA_CACHE): + os.makedirs(MEDIA_CACHE) + +# Global shared MPRIS manager +_shared_mpris_manager = None + + +def get_shared_mpris_manager(): + """Get shared MPRIS manager instance to reduce memory usage.""" + global _shared_mpris_manager + if _shared_mpris_manager is None: + # Create MPRIS manager immediately but don't wait for DBus discovery + _shared_mpris_manager = MprisPlayerManager() + return _shared_mpris_manager + + +def cleanup_shared_mpris_manager(): + """Clean up the shared MPRIS manager to free memory.""" + global _shared_mpris_manager + if _shared_mpris_manager is not None: + try: + _shared_mpris_manager.destroy() + except Exception as e: + logger.warning(f"Failed to destroy shared MPRIS manager: {e}") + _shared_mpris_manager = None def cleanup_old_cache_files(): @@ -33,8 +53,6 @@ def cleanup_old_cache_files(): if not os.path.exists(CACHE_DIR): return - import time - current_time = time.time() one_day_ago = current_time - (24 * 60 * 60) # 24 hours cache_files = [] @@ -85,10 +103,10 @@ class PlayerBoxStack(Box): """A widget that displays the current player information.""" def __init__( - self, mpris_manager: MprisPlayerManager, control_center=None, **kwargs + self, mpris_manager: MprisPlayerManager = None, control_center=None, **kwargs ): - # Clean up old cache files on startup - cleanup_old_cache_files() + # Defer cache cleanup to avoid blocking startup + GLib.idle_add(cleanup_old_cache_files) # The player stack self.player_stack = Stack( @@ -114,25 +132,96 @@ def __init__( self.player_stack.children = [self.no_media_box] self.set_visible(True) + # Use shared MPRIS manager if none provided self.mpris_manager = mpris_manager - # Track connections for cleanup - store (object, handler_id) tuples - connections = bulk_connect( - self.mpris_manager, - { - "player-appeared": self.on_new_player, - "player-vanished": self.on_lost_player, - }, - ) - # Store as (object, handler_id) tuples - for handler_id in connections: - self._signal_connections.append((self.mpris_manager, handler_id)) + # Initialize MPRIS manager if provided, otherwise defer + if self.mpris_manager is not None: + self._init_mpris_manager() + else: + # Will be initialized later via _init_mpris_manager() + pass + + # Connect to visibility changes for cleanup + self.connect("notify::visible", self._on_visibility_changed) + + def _on_visibility_changed(self, widget, param): + """Monitor visibility changes to debug hiding issues.""" + is_visible = self.get_visible() + logger.debug(f"[PlayerBoxStack] Visibility changed to: {is_visible}") - for player in self.mpris_manager.players: # type: ignore - logger.info( - f"[PLAYER MANAGER] player found: {player.get_property('player-name')}", + if not is_visible: + # Check if we should be visible + current_children = self.player_stack.get_children() + if len(current_children) > 0: + logger.warning( + f"[PlayerBoxStack] Widget was hidden but has {len(current_children)} children - forcing visibility" + ) + GLib.idle_add(self.set_visible, True) + + def set_visible(self, visible: bool) -> None: + """Override set_visible to ensure the widget is never hidden when it should show content.""" + # Always ensure the widget is visible if we have content to show + if not visible: + current_children = self.player_stack.get_children() + if len(current_children) > 0: + # Don't allow hiding if we have content + logger.debug( + "[PlayerBoxStack] Preventing widget from being hidden - has content" + ) + return + + super().set_visible(visible) + + def get_visible(self) -> bool: + """Override get_visible to ensure the widget always reports as visible when it has content.""" + # Check if we have content to show + current_children = self.player_stack.get_children() + if len(current_children) > 0: + # If we have content, we should always be visible + return True + + # Otherwise, use the parent's visibility state + return super().get_visible() + + def _init_mpris_manager(self): + """Initialize MPRIS manager and connect signals""" + if self.mpris_manager is None: + return + + try: + # Track connections for cleanup - store (object, handler_id) tuples + connections = bulk_connect( + self.mpris_manager, + { + "player-appeared": self.on_new_player, + "player-vanished": self.on_lost_player, + }, ) - self.on_new_player(self.mpris_manager, player) + # Store as (object, handler_id) tuples + for handler_id in connections: + self._signal_connections.append((self.mpris_manager, handler_id)) + + # Process existing players asynchronously to avoid blocking + def process_existing_players(): + try: + for player in self.mpris_manager.players.values(): # type: ignore + self.on_new_player(self.mpris_manager, player) + logger.debug("PlayerBoxStack initialized successfully") + except Exception as e: + logger.error(f"Failed to process existing players: {e}") + + # Use idle_add to process players asynchronously + GLib.idle_add(process_existing_players) + + # Also check the playing state after a short delay to ensure consistency + GLib.timeout_add(1000, self._check_and_update_playing_state) + + # Ensure the widget is always visible + GLib.timeout_add(500, self._ensure_visibility) + + except Exception as e: + logger.error(f"Failed to initialize PlayerBoxStack signals: {e}") def destroy(self): """Clean up resources when the widget is destroyed.""" @@ -160,8 +249,30 @@ def destroy(self): except Exception: pass + # Clean up shared MPRIS manager if this is the last instance + if ( + hasattr(self, "mpris_manager") + and self.mpris_manager == _shared_mpris_manager + ): + # Only cleanup if no other widgets are using it + cleanup_shared_mpris_manager() + super().destroy() + def suspend(self): + """Suspend all child players when hidden""" + for child in self.player_stack.get_children(): + if hasattr(child, "suspend"): + child.suspend() + logger.debug("[PlayerBoxStack] Suspended child players") + + def resume(self): + """Resume all child players when shown""" + for child in self.player_stack.get_children(): + if hasattr(child, "resume"): + child.resume() + logger.debug("[PlayerBoxStack] Resumed child players") + def _periodic_cleanup(self): """Enhanced cleanup for reuse - clean internal state and free memory""" try: @@ -191,18 +302,12 @@ def _periodic_cleanup(self): # Clean up old cache files more aggressively cleanup_old_cache_files() - # Force garbage collection - import gc - - gc.collect() - - logger.debug("PlayerBoxStack enhanced cleanup completed") except Exception as e: logger.warning(f"PlayerBoxStack enhanced cleanup failed: {e}") def _create_no_media_box(self): """Create a placeholder box for when no media is playing.""" - fallback_cover_path = f"{data.HOME_DIR}/.current.wall" + fallback_cover_path = get_relative_path("../../assets/icons/music.svg") # Album cover with fallback image album_cover = Box(style_classes="album-image-c") @@ -305,7 +410,7 @@ def _find_playing_player_index(self): for i, player_box in enumerate(players): if ( hasattr(player_box, "player") - and player_box.player.playback_status == "playing" + and str(player_box.player.playback_status).lower() == "playing" ): return i return None @@ -314,28 +419,104 @@ def _switch_to_playing_player(self): """Switch to the currently playing player if one exists.""" playing_index = self._find_playing_player_index() if playing_index is not None and playing_index != self.current_stack_pos: - logger.info( - f"[PlayerBoxStack] Auto-switching to playing player at index { - playing_index - }" - ) self.on_player_clicked_by_index(playing_index) + def _check_and_update_playing_state(self): + """Check if any players are playing and update the display accordingly.""" + players: List[PlayerBox] = self.player_stack.get_children() + has_playing_player = False + + # Check if any player is currently playing + for player_box in players: + if ( + hasattr(player_box, "player") + and str(player_box.player.playback_status).lower() == "playing" + ): + has_playing_player = True + break + + # If no players are playing, show the no media box + if not has_playing_player: + # Check if we already have the no media box visible + current_children = self.player_stack.get_children() + if len(current_children) == 1 and current_children[0] == self.no_media_box: + # Already showing no media, no need to change + return + + # Hide all player boxes and show no media + for player_box in players: + if hasattr(player_box, "destroy"): + try: + player_box.destroy() + except Exception as e: + logger.warning(f"Failed to destroy player box: {e}") + + # Clear player buttons + self.player_buttons.clear() + + # Show the no media box + self.player_stack.children = [self.no_media_box] + self.current_stack_pos = 0 + + # Ensure the widget is visible + self.set_visible(True) + self.player_stack.set_visible_child(self.no_media_box) + + logger.debug( + "[PlayerBoxStack] No players playing, showing 'No media playing'" + ) + + def _ensure_visibility(self): + """Ensure the widget is always visible and showing appropriate content.""" + try: + # Always ensure the widget is visible + self.set_visible(True) + + # Check current state + current_children = self.player_stack.get_children() + + # If no children, show no media box + if len(current_children) == 0: + self.player_stack.children = [self.no_media_box] + self.current_stack_pos = 0 + logger.debug("[PlayerBoxStack] No children found, showing no media box") + + # If only no_media_box is visible, ensure it's the active child + elif ( + len(current_children) == 1 and current_children[0] == self.no_media_box + ): + self.player_stack.set_visible_child(self.no_media_box) + logger.debug("[PlayerBoxStack] Ensuring no media box is visible") + + # Force the widget to be visible and show content + self.set_visible(True) + self.player_stack.set_visible(True) + + # If we have the no media box, make sure it's visible + if self.no_media_box in current_children: + self.no_media_box.set_visible(True) + + logger.debug( + f"[PlayerBoxStack] Current state: {len(current_children)} children, visible: {self.get_visible()}, stack_visible: {self.player_stack.get_visible()}" + ) + + except Exception as e: + logger.error(f"[PlayerBoxStack] Error in _ensure_visibility: {e}") + def on_player_playback_changed(self, player_box, status): """Called when a player's playback status changes.""" + status = str(status).lower() if isinstance(status, str) else status if status == "playing": # Find this player's index and switch to it players: List[PlayerBox] = self.player_stack.get_children() for i, pb in enumerate(players): if pb == player_box: if i != self.current_stack_pos: - logger.info( - f"[PlayerBoxStack] Switching to playing player: { - player_box.player.player_name - }" - ) self.on_player_clicked_by_index(i) break + elif status in ["paused", "stopped"]: + # Check if any other players are playing + self._check_and_update_playing_state() def on_player_clicked(self, type): # unset active from prev active button @@ -357,11 +538,6 @@ def on_player_clicked(self, type): # set new active button if self.player_buttons and self.current_stack_pos < len(self.player_buttons): - print( - f"[PlayerBoxStack] Switching to player at index { - self.current_stack_pos - }" - ) self.player_buttons[self.current_stack_pos].add_style_class("active") self.player_stack.set_visible_child( self.player_stack.get_children()[self.current_stack_pos], @@ -389,11 +565,15 @@ def on_player_clicked_by_index(self, index): self._update_all_player_buttons() def on_new_player(self, mpris_manager, player): - player_name = player.props.player_name # if player_name in self.config.get("ignore", []): # return + # Show all players, but we'll handle their playback status in the UI + # if player.playback_status != "playing": + # logger.debug(f"[PlayerBoxStack] Player {player_name} is not playing (status: {player.playback_status}), skipping display") + # return + # Remove the no media box if it's the only child if ( len(self.player_stack.get_children()) == 1 @@ -405,7 +585,7 @@ def on_new_player(self, mpris_manager, player): self.set_visible(True) new_player_box = PlayerBox( - player=MprisPlayer(player), + player=player, player_stack=self, control_center=self.control_center, ) @@ -415,9 +595,6 @@ def on_new_player(self, mpris_manager, player): ] self.make_new_player_button(self.player_stack.get_children()[-1]) - logger.info( - f"[PLAYER MANAGER] adding new player: {player.get_property('player-name')}", - ) if self.player_buttons and self.current_stack_pos < len(self.player_buttons): self.player_buttons[self.current_stack_pos].set_style_classes(["active"]) @@ -427,9 +604,8 @@ def on_new_player(self, mpris_manager, player): # Check if this new player is playing and switch to it self._switch_to_playing_player() - def on_lost_player(self, mpris_manager, player_name): + def on_lost_player(self, mpris_manager, bus_name): # the playerBox is automatically removed from mprisbox children on being removed - logger.info(f"[PLAYER_MANAGER] Player Removed {player_name}") players: List[PlayerBox] = self.player_stack.get_children() # Find and properly destroy the player box @@ -437,7 +613,7 @@ def on_lost_player(self, mpris_manager, player_name): for player_box in players: if ( hasattr(player_box, "player") - and player_box.player.player_name == player_name + and getattr(player_box.player, "bus_name", None) == bus_name ): player_box_to_remove = player_box break @@ -510,11 +686,7 @@ def cleanup_button(*_): def _update_all_player_buttons(self): """Update all player boxes with the current button state""" players: List[PlayerBox] = self.player_stack.get_children() - logger.info( - f"[PlayerBoxStack] Updating buttons for {len(players)} players, { - len(self.player_buttons) - } buttons" - ) + for player_box in players: if hasattr(player_box, "update_buttons"): player_box.update_buttons(self.player_buttons, len(players) > 1) @@ -540,7 +712,7 @@ def __init__( self.player: MprisPlayer = player self.player_stack = player_stack self.control_center = control_center - self.fallback_cover_path = f"{data.HOME_DIR}/.current.wall" + self.cover_path = get_relative_path("../../assets/icons/music.svg") # Add controls_box attribute early for compatibility # Temporary placeholder @@ -552,24 +724,28 @@ def __init__( # State self.exit = False self.skipped = False + self._signal_connections = [] # Track signal connections + self._property_bindings = [] # Track GObject property bindings for unbind on destroy # Memory management self.temp_artwork_files = [] # Track temp files for cleanup - self.current_download_thread = None # Track current download thread self._download_cancelled = False # Flag to cancel downloads - self._signal_connections = [] # Track signal connections + # Album art with existing Box widget self.album_cover = Box(style_classes="album-image-c") - self.album_cover.set_style( - f"background-image:url('{self.fallback_cover_path}')" - ) + self.album_cover.set_style(f"background-image:url('{self.cover_path}')") self.image_stack = Box( h_align="start", v_align="center", name="player-image-stack", ) - self.image_stack.children = [*self.image_stack.children, self.album_cover] + self.image_stack.children = [self.album_cover] + + # Connect to arturl changes โ€” track so we can disconnect on destroy + self._signal_connections.append( + (self.player, self.player.connect("notify::arturl", self.set_image)) + ) self.app_icon = Box( children=Image( @@ -585,6 +761,7 @@ def __init__( self.app_icon, ], ) + # Track Info self.track_title = Label( @@ -606,23 +783,27 @@ def __init__( visible=True, ) - self.player.bind_property( - "title", - self.track_title, - "label", - GObject.BindingFlags.DEFAULT, - lambda _, x: ( - re.sub(r"\r?\n", " ", x) if x != "" and x is not None else "No Title" - ), # type: ignore + self._property_bindings.append( + self.player.bind_property( + "title", + self.track_title, + "label", + GObject.BindingFlags.DEFAULT, + lambda _, x: ( + re.sub(r"\r?\n", " ", x) + if x != "" and x is not None + else "No Title" + ), # type: ignore + ) ) - self.player.bind_property( - "artist", - self.track_artist, - "label", - GObject.BindingFlags.DEFAULT, - lambda _, x: ( - re.sub(r"\r?\n", " ", x) if x != "" and x is not None else "No Artist" - ), # type: ignore + self._property_bindings.append( + self.player.bind_property( + "artist", + self.track_artist, + "label", + GObject.BindingFlags.DEFAULT, + lambda _, x: ", ".join(x) if x and isinstance(x, list) else "No Artist", # type: ignore + ) ) self.track_info = Box( @@ -645,16 +826,8 @@ def __init__( ) # Create SVG icons with consistent sizing - self.skip_next_icon = Svg( - name="control-buttons", - size=(22, 22), - svg_file=get_relative_path("../../config/assets/icons/player/fwd.svg"), - ) - self.play_pause_icon = Svg( - name="control-buttons", - size=(22, 22), - svg_file=get_relative_path("../../config/assets/icons/player/Pause.svg"), - ) + self.skip_next_icon = svg_file("player/fwd.svg", size=22) + self.play_pause_icon = svg_file("player/Pause.svg", size=22) # Fixed size buttons to prevent layout shifts self.play_pause_button = Button( @@ -664,16 +837,18 @@ def __init__( ) # Set consistent button size - self.player.bind_property("can_pause", self.play_pause_button, "sensitive") + self._property_bindings.append( + self.player.bind_property("can_pause", self.play_pause_button, "sensitive") + ) self.next_button = Button( name="player-button", child=self.skip_next_icon, on_clicked=self._on_player_next, ) - # Set consistent button size - # self.next_button.set_size_request(32, 32) - self.player.bind_property("can_go_next", self.next_button, "sensitive") + self._property_bindings.append( + self.player.bind_property("can_go_next", self.next_button, "sensitive") + ) self.button_box.children = ( self.play_pause_button, @@ -709,15 +884,9 @@ def __init__( spacing=5, name="outer-player-box-c", h_expand=True, - # style="background-color:#fff", on_clicked=self._on_outer_box_clicked, h_align="start", - # children=[ child=self.inner_box, - # self.inner_box, - # self.player_info_box, - # self.image, - # ], ) self.box = CenterBox( @@ -726,12 +895,12 @@ def __init__( h_align="center", start_children=[ self.outer_box, - # self.stack_buttons_box, ], end_children=[ self.button_box, ], ) + self.box.set_size_request(352, -1) self.children = [ *self.children, @@ -742,7 +911,7 @@ def __init__( connections = bulk_connect( self.player, { - "exit": self._on_player_exit, + "closed": self._on_player_exit, "notify::playback-status": self._on_playback_change, "notify::metadata": self._on_metadata, }, @@ -751,12 +920,54 @@ def __init__( for handler_id in connections: self._signal_connections.append((self.player, handler_id)) + def suspend(self): + """Disconnect signals when hidden""" + self.exit = True + for obj, handler_id in self._signal_connections: + try: + obj.disconnect(handler_id) + except Exception: + pass + self._signal_connections.clear() + logger.debug( + f"[PlayerBox] Suspended signals for {self.player.player_name if self.player else 'unknown'}" + ) + + def resume(self): + """Reconnect signals when shown""" + if self.player is None: + return + self.exit = False + if not self._signal_connections: + connections = bulk_connect( + self.player, + { + "closed": self._on_player_exit, + "notify::playback-status": self._on_playback_change, + "notify::metadata": self._on_metadata, + }, + ) + for handler_id in connections: + self._signal_connections.append((self.player, handler_id)) + logger.debug(f"[PlayerBox] Resumed signals for {self.player.player_name}") + def destroy(self): """Clean up all resources when the widget is destroyed.""" - # Cancel any ongoing downloads + # Set exit flag first โ€” stops all callbacks from doing work + self.exit = True + + # Cancel any ongoing async artwork downloads self._download_cancelled = True - # Disconnect all signal connections + # Unbind all GObject property bindings โ€” these keep self.player alive + for binding in self._property_bindings: + try: + binding.unbind() + except Exception: + pass + self._property_bindings.clear() + + # Disconnect all signal connections (includes arturl notify) for obj, handler_id in self._signal_connections: try: obj.disconnect(handler_id) @@ -764,9 +975,14 @@ def destroy(self): logger.warning(f"Failed to disconnect signal: {e}") self._signal_connections.clear() - # Clean up temp files + # Clean up temp artwork files self._cleanup_temp_files() + # Drop strong references to avoid reference cycles + self.player = None + self.player_stack = None + self.control_center = None + super().destroy() def __del__(self): @@ -797,16 +1013,15 @@ def _on_outer_box_clicked(self, *_): self.control_center.open_expanded_player() except Exception as e: logger.warning(f"Failed to handle outer box click: {e}") - import traceback - - logger.error(f"Full traceback: {traceback.format_exc()}") def update_buttons(self, player_buttons, show_buttons): # """Update the stack switcher buttons in this player box""" pass def _on_metadata(self, *_): - self._set_image() + if self.exit or self.player is None: + return + self.set_image() def _cleanup_temp_files(self): """Clean up temporary artwork files.""" @@ -820,135 +1035,87 @@ def _cleanup_temp_files(self): def _on_player_exit(self, _, value): self.exit = value - self._cleanup_temp_files() # Clean up temp files before destroying + self._cleanup_temp_files() self.destroy() def _on_player_next(self, *_): - self.player.next() + if self.player is not None: + self.player.next() def _on_player_prev(self, *_): - self.player.previous() + if self.player is not None: + self.player.previous() def _on_playback_change(self, player, status): + if self.exit or self.player is None: + return status = player.get_property("playback-status") + status_l = str(status).lower() if isinstance(status, str) else status - if status == "paused": - self.play_pause_icon.set_from_file( - get_relative_path("../../config/assets/icons/player/play.svg") - ) - - if status == "playing": - self.play_pause_icon.set_from_file( - get_relative_path("../../config/assets/icons/player/Pause.svg") - ) - # Notify the player stack that this player started playing - if self.player_stack and hasattr( - self.player_stack, "on_player_playback_changed" - ): - self.player_stack.on_player_playback_changed(self, status) + if status_l == "paused": + self.play_pause_icon.dynamic_file("player/play.svg") - def _update_image(self, image_path): - if image_path and os.path.isfile(image_path): - self.album_cover.set_style(f"background-image:url('{image_path}')") - else: - self.album_cover.set_style( - f"background-image:url('{self.fallback_cover_path}')" - ) - - def _set_image(self, *_): - art_url = self.player.arturl + if status_l == "playing": + self.play_pause_icon.dynamic_file("player/Pause.svg") - parsed = urllib.parse.urlparse(art_url) - if parsed.scheme == "file": - local_arturl = urllib.parse.unquote(parsed.path) - self._update_image(local_arturl) - elif parsed.scheme in ("http", "https"): - # Cancel any existing download to prevent memory buildup - self._download_cancelled = True - - # Use threading.Thread instead of GLib.Thread for better control - if self.current_download_thread and self.current_download_thread.is_alive(): - # Thread will check _download_cancelled flag and exit early - pass - - self._download_cancelled = False - self.current_download_thread = threading.Thread( - target=self._download_and_set_artwork, - args=(art_url,), - daemon=True, # Dies with main thread - ) - self.current_download_thread.start() - else: - self._update_image(art_url) - - def _download_and_set_artwork(self, arturl): - """ - Download the artwork from the given URL asynchronously and update the cover - using GLib.idle_add to ensure UI updates occur on the main thread. - """ - local_arturl = self.fallback_cover_path - temp_file_path = None + # Notify the player stack about playback status changes + if self.player_stack and hasattr( + self.player_stack, "on_player_playback_changed" + ): + self.player_stack.on_player_playback_changed(self, status_l) + def img_callback(self, source: Gio.File, result: Gio.AsyncResult): try: - # Check if download was cancelled - if self._download_cancelled: + # Guard: widget may have been destroyed before the async copy finished + if self.exit: return + if os.path.isfile(self.cover_path): + self.update_image() + except ValueError: + logger.error("[PLAYER] Failed to grab artUrl") - # Clean up old temp files first (keep only last 1 to reduce memory) - if len(self.temp_artwork_files) > 1: - old_files = self.temp_artwork_files[:-1] - for old_file in old_files: - try: - if os.path.exists(old_file): - os.unlink(old_file) - except Exception: - pass - self.temp_artwork_files = self.temp_artwork_files[-1:] - - # Check again if cancelled - if self._download_cancelled: - return + def update_image(self): + if self.exit: + return + self.album_cover.set_style(f"background-image:url('{self.cover_path}')") - # Download artwork - parsed = urllib.parse.urlparse(arturl) - suffix = os.path.splitext(parsed.path)[1] or ".png" + def set_image(self, *args): + if self.exit or self.player is None: + return + url = self.player.arturl - with urllib.request.urlopen(arturl, timeout=10) as response: # Add timeout - if self._download_cancelled: - return - data = response.read() + if url is None or url == "": + return - # Check one more time if cancelled - if self._download_cancelled: - return + new_cover_path = ( + ( + MEDIA_CACHE + + "/" + # type: ignore + + GLib.compute_checksum_for_string(GLib.ChecksumType.SHA1, url, -1) + ) + if "file://" != url[0:7] + else url[7:] + ) - # Create temp file in cache directory instead of system temp - os.makedirs(CACHE_DIR, exist_ok=True) - with tempfile.NamedTemporaryFile( - delete=False, suffix=suffix, dir=CACHE_DIR - ) as temp_file: - temp_file.write(data) - temp_file_path = temp_file.name - local_arturl = temp_file_path + if new_cover_path == self.cover_path: + self.update_image() + return - # Track temp file for cleanup - if temp_file_path and not self._download_cancelled: - self.temp_artwork_files.append(temp_file_path) + self.cover_path = new_cover_path - except Exception as e: - if not self._download_cancelled: - logger.warning(f"Failed to download artwork from {arturl}: {e}") - # Clean up failed temp file - if temp_file_path and os.path.exists(temp_file_path): - try: - os.unlink(temp_file_path) - except Exception: - pass + if os.path.exists(self.cover_path): + self.update_image() return - # Only update UI if not cancelled - if not self._download_cancelled: - GLib.idle_add(self._update_image, local_arturl) + Gio.File.new_for_uri(uri=url).copy_async( + Gio.File.new_for_path(self.cover_path), + Gio.FileCopyFlags.OVERWRITE, + GLib.PRIORITY_DEFAULT, + None, + None, + self.img_callback, + ) def close_bluetooth(self, *args): """Placeholder method for compatibility""" diff --git a/modules/controlcenter/wifi.py b/src/window/controlcenter/wifi.py similarity index 92% rename from modules/controlcenter/wifi.py rename to src/window/controlcenter/wifi.py index 6cb612d8..929a8f33 100644 --- a/modules/controlcenter/wifi.py +++ b/src/window/controlcenter/wifi.py @@ -1,22 +1,20 @@ -from widgets.wifi_password_dialog import WiFiPasswordDialog -from services.network import NetworkClient -from fabric.widgets.scrolledwindow import ScrolledWindow -from fabric.widgets.revealer import Revealer -from fabric.widgets.label import Label -from fabric.widgets.image import Image -from fabric.widgets.centerbox import CenterBox -from fabric.widgets.button import Button +import subprocess + +from fabric.utils import Gdk, GLib, Gtk from fabric.widgets.box import Box -from fabric.widgets.svg import Svg -from fabric.utils import get_relative_path -from gi.repository import Gdk, GLib, Gtk +from fabric.widgets.button import Button +from fabric.widgets.centerbox import CenterBox +from fabric.widgets.image import Image +from fabric.widgets.label import Label +from fabric.widgets.revealer import Revealer +from fabric.widgets.scrolledwindow import ScrolledWindow from fabric.widgets.separator import Separator -from utils.functions import get_wifi_icon_for_strength, get_wifi_connecting_icon -import gi -import subprocess -gi.require_version("Gtk", "3.0") -gi.require_version("Gdk", "3.0") +from services.network import NetworkClient +from utils.functions import get_wifi_connecting_icon, get_wifi_icon_for_strength +from utils.utils import svg_file +from shared.dialogs.wifi_password_dialog import WiFiPasswordDialog +from gi.repository import NM class WifiNetworkSlot(Box): @@ -42,11 +40,7 @@ def __init__(self, access_point, wifi_service, parent=None, **kwargs): # Create connection status indicator using dynamic WiFi icon based on signal strength wifi_icon_path = get_wifi_icon_for_strength(self.strength) - self.dimage = Svg( - svg_file=wifi_icon_path, - size=28, - name="device-icon", - ) + self.dimage = svg_file(wifi_icon_path, size=28) self.wifi_icon_box = Box( children=[self.dimage], style_classes=["wifi-icon-box"], @@ -209,7 +203,6 @@ def on_connection_result(success, message): if success: self.is_connected = True # Remove connecting state after a short delay - from gi.repository import GLib GLib.timeout_add(500, lambda: self._reset_connect_state()) @@ -269,9 +262,17 @@ def __init__(self, parent, show_back_button=True, **kwargs): self.refresh_timer = None # Timer for periodic network refresh self._update_in_progress = False # Prevent concurrent updates self._destroyed = False # Track if widget is destroyed - - # Wait for network service to be ready - self.network_service.connect("wifi-device-added", self.on_network_ready) + self._signal_ids = [] # Track all service signal IDs for cleanup + + # Wait for network service to be ready โ€” track signal ID + self._signal_ids.append( + ( + self.network_service, + self.network_service.connect( + "wifi-device-added", self.on_network_ready + ), + ) + ) # Create pull-to-refresh indicator self.refresh_indicator = Label( @@ -394,13 +395,33 @@ def on_network_ready(self, *_): self.toggle_button.set_active(self.wifi_service.wireless_enabled) self.toggle_button.connect("notify::active", self.on_toggle_changed) - # Connect to WiFi service signals - self.wifi_service.connect( - "notify::wireless-enabled", self.on_wifi_enabled_changed + # Connect to WiFi service signals โ€” track IDs for cleanup + self._signal_ids.append( + ( + self.wifi_service, + self.wifi_service.connect( + "notify::wireless-enabled", self.on_wifi_enabled_changed + ), + ) + ) + self._signal_ids.append( + ( + self.wifi_service, + self.wifi_service.connect("changed", self.update_networks), + ) + ) + self._signal_ids.append( + ( + self.wifi_service, + self.wifi_service.connect("ap-added", self.update_networks), + ) + ) + self._signal_ids.append( + ( + self.wifi_service, + self.wifi_service.connect("ap-removed", self.update_networks), + ) ) - self.wifi_service.connect("changed", self.update_networks) - self.wifi_service.connect("ap-added", self.update_networks) - self.wifi_service.connect("ap-removed", self.update_networks) # Initial network update self.update_networks() @@ -551,8 +572,6 @@ def _is_saved_network(self, access_point): # Compare SSIDs connection_ssid_bytes = wifi_setting.get_ssid() if connection_ssid_bytes: - from gi.repository import NM - connection_ssid = NM.utils_ssid_to_utf8( connection_ssid_bytes.get_data() ) @@ -590,17 +609,20 @@ def stop_network_monitoring(self): def periodic_network_refresh(self): """Periodically refresh network list to catch external connections""" - # Skip if update in progress, destroyed, or wifi service not available + # If destroyed, remove the GLib source by returning False + if self._destroyed: + self.refresh_timer = None + return False + + # Skip if update in progress or wifi not available/enabled if ( self._update_in_progress - or self._destroyed or not self.wifi_service or not self.wifi_service.wireless_enabled ): return True # Continue monitoring try: - # Simple check - just trigger update_networks which has its own safety checks self.update_networks() except Exception: pass @@ -701,13 +723,23 @@ def on_destroy(self, widget): """Cleanup when widget is destroyed""" # Mark as destroyed to prevent further updates self._destroyed = True - # Stop monitoring + # Stop monitoring timer self.stop_network_monitoring() - # Make sure other networks revealer is collapsed when closing + # Disconnect all tracked service signals + for obj, sig_id in self._signal_ids: + try: + obj.disconnect(sig_id) + except Exception: + pass + self._signal_ids.clear() + # Drop strong references + self.wifi_service = None + self.network_service = None + # Collapse other networks revealer try: self.other_networks_revealer.child_revealed = False - except: - pass # Widget might already be destroyed + except Exception: + pass def close_wifi(self): """Called when WiFi panel is being closed""" diff --git a/src/window/desktop/__init__.py b/src/window/desktop/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/window/desktop/constants.py b/src/window/desktop/constants.py new file mode 100644 index 00000000..38545c24 --- /dev/null +++ b/src/window/desktop/constants.py @@ -0,0 +1,159 @@ +import datetime + +WEATHER_UPDATE_INTERVAL = 600 # 10 minutes +WEATHER_CACHE_TIMEOUT = 1800 # 30 minutes +SYSTEM_UPDATE_INTERVAL = 60000 # 1 minute instead of 1 second +CALENDAR_UPDATE_INTERVAL = int( + ( + ( + datetime.datetime.combine( + datetime.date.today() + datetime.timedelta(days=1), datetime.time.min + ) + - datetime.datetime.now() + ).total_seconds() + ) + * 1000 +) # Calculate time till midnight +LOCATION_CACHE_TIMEOUT = 604800 # 7 days (extended from 24h) + + +WEATHER_GRADIENT_MAP = { + # Clear/Sunny conditions - bright blue to lighter blue + 0: "weather-clear", # Clear sky + 1: "weather-mostly-clear", # Mainly clear + # Cloudy conditions - grey gradients + 2: "weather-partly-cloudy", # Partly cloudy + 3: "weather-overcast", # Overcast + # Fog conditions - muted grey/blue + 45: "weather-fog", # Fog + 48: "weather-fog", # Depositing rime fog + # Light rain/drizzle - blue-grey gradients + 51: "weather-light-rain", # Light drizzle + 53: "weather-rain", # Moderate drizzle + 55: "weather-rain", # Dense drizzle + 61: "weather-light-rain", # Slight rain + 80: "weather-light-rain", # Slight rain showers + # Heavy rain - darker blue-grey + 63: "weather-heavy-rain", # Moderate rain + 65: "weather-heavy-rain", # Heavy rain + 81: "weather-heavy-rain", # Moderate rain showers + 82: "weather-storm", # Violent rain showers + # Snow conditions - blue-white gradients + 56: "weather-snow", # Light freezing drizzle + 57: "weather-snow", # Dense freezing drizzle + 66: "weather-snow", # Light freezing rain + 67: "weather-snow", # Heavy freezing rain + 71: "weather-snow", # Slight snow fall + 73: "weather-heavy-snow", # Moderate snow fall + 75: "weather-heavy-snow", # Heavy snow fall + 77: "weather-snow", # Snow grains + 85: "weather-snow", # Slight snow showers + 86: "weather-heavy-snow", # Heavy snow showers + # Storm conditions - dark dramatic gradients + 95: "weather-storm", # Thunderstorm + 96: "weather-storm", # Thunderstorm with slight hail + 99: "weather-storm", # Thunderstorm with heavy hail +} + +# Weather condition to emoji mapping +WEATHER_EMOJI_MAP = { + 0: "โ˜€๏ธ", # Clear sky + 1: "๐ŸŒค๏ธ", # Mainly clear + 2: "โ›…", # Partly cloudy + 3: "โ˜๏ธ", # Overcast + 45: "๐ŸŒซ๏ธ", # Fog + 48: "๐ŸŒซ๏ธ", # Depositing rime fog + 51: "๐ŸŒฆ๏ธ", # Light drizzle + 53: "๐ŸŒง๏ธ", # Moderate drizzle + 55: "๐ŸŒง๏ธ", # Dense drizzle + 56: "๐ŸŒจ๏ธ", # Light freezing drizzle + 57: "๐ŸŒจ๏ธ", # Dense freezing drizzle + 61: "๐ŸŒฆ๏ธ", # Slight rain + 63: "๐ŸŒง๏ธ", # Moderate rain + 65: "๐ŸŒง๏ธ", # Heavy rain + 66: "๐ŸŒจ๏ธ", # Light freezing rain + 67: "๐ŸŒจ๏ธ", # Heavy freezing rain + 71: "๐ŸŒจ๏ธ", # Slight snow fall + 73: "โ„๏ธ", # Moderate snow fall + 75: "โ„๏ธ", # Heavy snow fall + 77: "๐ŸŒจ๏ธ", # Snow grains + 80: "๐ŸŒฆ๏ธ", # Slight rain showers + 81: "๐ŸŒง๏ธ", # Moderate rain showers + 82: "โ›ˆ๏ธ", # Violent rain showers + 85: "๐ŸŒจ๏ธ", # Slight snow showers + 86: "โ„๏ธ", # Heavy snow showers + 95: "โ›ˆ๏ธ", # Thunderstorm + 96: "โ›ˆ๏ธ", # Thunderstorm with slight hail + 99: "โ›ˆ๏ธ", # Thunderstorm with heavy hail +} + +# Weather condition to SVG icon mapping +WEATHER_ICON_MAP = { + 0: "weather-clear", + 1: "weather-few-clouds", + 2: "weather-clouds", + 3: "weather-overcast", + 45: "weather-fog", + 48: "weather-fog", + 51: "weather-showers-scattered", + 53: "weather-showers", + 55: "weather-showers", + 61: "weather-showers-scattered", + 80: "weather-showers-scattered", + 63: "weather-showers", + 65: "weather-showers", + 81: "weather-showers", + 82: "weather-storm", + 56: "weather-freezing-rain", + 57: "weather-freezing-rain", + 66: "weather-freezing-rain", + 67: "weather-freezing-rain", + 71: "weather-snow", + 73: "weather-snow", + 75: "weather-snow", + 77: "weather-hail", + 85: "weather-snow-scattered", + 86: "weather-snow-scattered", + 95: "weather-storm", + 96: "weather-storm", + 99: "weather-storm", +} + +# Weather condition descriptions +WEATHER_DESC_MAP = { + 0: "Clear sky", + 1: "Mainly clear", + 2: "Partly cloudy", + 3: "Overcast", + 45: "Fog", + 48: "Depositing rime fog", + 51: "Light drizzle", + 53: "Moderate drizzle", + 55: "Dense drizzle", + 56: "Light freezing drizzle", + 57: "Dense freezing drizzle", + 61: "Slight rain", + 63: "Moderate rain", + 65: "Heavy rain", + 66: "Light freezing rain", + 67: "Heavy freezing rain", + 71: "Slight snow", + 73: "Moderate snow", + 75: "Heavy snow", + 77: "Snow grains", + 80: "Light rain showers", + 81: "Moderate rain showers", + 82: "Violent rain showers", + 85: "Slight snow showers", + 86: "Heavy snow showers", + 95: "Thunderstorm", + 96: "Thunderstorm with hail", + 99: "Thunderstorm with heavy hail", +} + +# Location APIs in order of preference (fastest first) +LOCATION_APIS = [ + "https://ipapi.co/json/", # Fastest, 200ms average + "http://ip-api.com/json/", # Fast fallback, 150ms average + "https://ipinfo.io/json", # Original fallback +] diff --git a/src/window/desktop/widget.py b/src/window/desktop/widget.py new file mode 100644 index 00000000..a25f5234 --- /dev/null +++ b/src/window/desktop/widget.py @@ -0,0 +1,697 @@ +import calendar +import datetime +import urllib.parse +import httpx +from concurrent.futures import ThreadPoolExecutor +from typing import Any, Dict, List, Optional, Tuple + +import psutil +from fabric.utils import GLib, invoke_repeater, time +from fabric.widgets.box import Box +from fabric.widgets.circularprogressbar import CircularProgressBar +from fabric.widgets.datetime import DateTime +from fabric.widgets.label import Label +from fabric.widgets.overlay import Overlay +from fabric.widgets.wayland import WaylandWindow as Window + +from shared.data import load_config +from utils.debounce import sync_debounce +from utils.utils import svg_file +from window.desktop.constants import ( + CALENDAR_UPDATE_INTERVAL, + LOCATION_APIS, + LOCATION_CACHE_TIMEOUT, + SYSTEM_UPDATE_INTERVAL, + WEATHER_CACHE_TIMEOUT, + WEATHER_DESC_MAP, + WEATHER_GRADIENT_MAP, + WEATHER_ICON_MAP, + WEATHER_UPDATE_INTERVAL, +) + +# Thread pool for async operations +executor = ThreadPoolExecutor(max_workers=4) + +# Global cache for weather data +_weather_cache: Dict[str, Tuple[Any, float]] = {} +_location_cache: Dict[str, Tuple[float, float, float]] = {} + + +def http_get_json( + url: str, timeout: int = 3, headers: Optional[Dict] = None +) -> Optional[Dict]: + """Helper to perform GET requests and return JSON using httpx.""" + try: + # Some APIs require a User-Agent or they will return 403 Forbidden + default_headers = {"User-Agent": "Modus-Desktop/1.0"} + if headers: + default_headers.update(headers) + response = httpx.get( + url, headers=default_headers, timeout=timeout, follow_redirects=True + ) + if response.status_code == 200: + return response.json() + except Exception as e: + print(f"HTTP Request to {url} failed: {e}") + return None + + +def get_location() -> str: + """Get current location from config or multiple IP geolocation APIs with fallback.""" + # Try to get location from config first + try: + config = load_config() + manual_location = config.get("weather_location") + if manual_location: + return manual_location + except Exception: + pass + + # Fallback to IP geolocation APIs + for api_url in LOCATION_APIS: + data = http_get_json(api_url, timeout=2) + if data: + city = data.get("city", "") + if city: + return city + + print("All location APIs failed") + return "" + + +def get_coordinates(city: str) -> Optional[Tuple[float, float]]: + """Get coordinates for a city using Nominatim geocoding API.""" + cache_key = city.lower() + current_time = time.time() + + if cache_key in _location_cache: + lat, lon, timestamp = _location_cache[cache_key] + if current_time - timestamp < LOCATION_CACHE_TIMEOUT: + return lat, lon + + encoded_city = urllib.parse.quote(city) + url = f"https://nominatim.openstreetmap.org/search?q={encoded_city}&format=json&limit=1" + + data = http_get_json(url, timeout=3, headers={"User-Agent": "Modus-Desktop/1.0"}) + + if data and isinstance(data, list) and len(data) > 0: + try: + lat = float(data[0]["lat"]) + lon = float(data[0]["lon"]) + _location_cache[cache_key] = (lat, lon, current_time) + return lat, lon + except (ValueError, KeyError): + pass + + return None + + +def get_weather_data(lat: float, lon: float) -> Optional[Dict[str, Any]]: + """Fetch weather data from Open-Meteo API.""" + url = ( + f"https://api.open-meteo.com/v1/forecast?" + f"latitude={lat}&longitude={lon}" + f"¤t_weather=true" + f"&daily=temperature_2m_max,temperature_2m_min" + f"&timezone=auto" + f"&forecast_days=1" + ) + return http_get_json(url, timeout=3) + + +def format_weather_data(weather_data: Dict[str, Any], city: str) -> List[str]: + """Format weather data into the expected format.""" + try: + current = weather_data["current_weather"] + daily = weather_data["daily"] + + weather_code = current["weathercode"] + is_day = current.get("is_day", 1) # Default to day if missing + + base_icon = WEATHER_ICON_MAP.get(weather_code, "weather-none-available") + + # Determine day/night variant if applicable + icon_name = base_icon + if not is_day: + night_variant = f"{base_icon}-night" + + # Fast check if night variant exists using predefined list or checking the path + # Since we know the variants from earlier, let's just optimistically build it + # then logic in svg_file will handle resolution seamlessly (fallbacks can be tricky, but we assume it's correct) + if base_icon in [ + "weather-clear", + "weather-clouds", + "weather-few-clouds", + "weather-overcast", + "weather-showers", + "weather-showers-scattered", + "weather-snow", + "weather-snow-scattered", + "weather-storm", + ]: + icon_name = night_variant + + condition = WEATHER_DESC_MAP.get(weather_code, "Unknown") + gradient_class = WEATHER_GRADIENT_MAP.get(weather_code, "weather-clear") + + temp = f"{round(current['temperature'])}ยฐ" + max_temp = f"{round(daily['temperature_2m_max'][0])}ยฐ" + min_temp = f"{round(daily['temperature_2m_min'][0])}ยฐ" + + return [icon_name, temp, condition, city, max_temp, min_temp, gradient_class] + except (KeyError, IndexError, TypeError) as e: + print(f"Error formatting weather data: {e}") + return None + + +def get_weather(callback): + """Fetch weather data asynchronously.""" + + def fetch_weather(): + location = get_location() + if not location: + return GLib.idle_add(callback, None) + + cache_key = location.lower() + current_time = time.time() + + if cache_key in _weather_cache: + cached_data, timestamp = _weather_cache[cache_key] + if current_time - timestamp < WEATHER_CACHE_TIMEOUT: + return GLib.idle_add(callback, cached_data) + + coords = get_coordinates(location) + if not coords: + return GLib.idle_add(callback, None) + + lat, lon = coords + weather_data = get_weather_data(lat, lon) + if not weather_data: + return GLib.idle_add(callback, None) + + formatted_data = format_weather_data(weather_data, location) + if formatted_data: + _weather_cache[cache_key] = (formatted_data, current_time) + GLib.idle_add(callback, formatted_data) + else: + GLib.idle_add(callback, None) + + executor.submit(fetch_weather) + + +def update_weather(widget): + """Update weather widget with new data.""" + + def perform_fetch(): + get_weather(lambda weather_info: update_widget(widget, weather_info)) + + debounced_perform_fetch = sync_debounce(1000, immediate=True)(perform_fetch) + + def fetch_and_update(): + debounced_perform_fetch() + return True + + def initial_fetch(): + debounced_perform_fetch() + return False + + # Trigger first fetch immediately - MUST return False to not loop in idle + GLib.idle_add(initial_fetch) + + return GLib.timeout_add_seconds(WEATHER_UPDATE_INTERVAL, fetch_and_update) + + +def update_widget(widget, weather_info): + """Update widget labels with weather information.""" + if weather_info: + widget.weatherinfo = weather_info + widget.update_labels(weather_info) + + +class Weather(Box): + def __init__(self, parent, **kwargs): + super().__init__( + name="weather-widget", + h_expand=True, + v_expand=True, + justification="right", + orientation="v", + all_visible=False, + **kwargs, + ) + self.parent = parent + self.weatherinfo = None + self._weather_timer_id = None + self._create_labels() + self._layout_labels() + self._weather_timer_id = update_weather(self) + + def _create_labels(self): + self.header = Box(orientation="h", h_expand=True) + self.body = Box(orientation="v", v_expand=True, valign="end") + + self.city = Label( + name="city", + label="Loading...", + justification="left", + h_align="start", + max_chars_width=15, + ellipsization="end", + ) + self.temperature = Label(name="temperature", label="--ยฐ", h_align="start") + self.condition_em = svg_file( + "weather/weather-none-available.svg", size=(35, 35), name="condition-emoji" + ) + self.condition = Label( + name="condition", + label="Loading...", + max_chars_width=18, + ellipsization="end", + h_align="start", + ) + self.feels_like = Label(name="feels-like", label="L:-- H:--", h_align="start") + + def _layout_labels(self): + # Header: City (left) and Icon (right) + self.header.add(self.city) + self.header.pack_end(self.condition_em, False, False, 0) + + # Body: Temp, Condition, High/Low + self.body.add(self.temperature) + self.body.add(self.condition) + self.body.add(self.feels_like) + + self.add(self.header) + self.add(self.body) + + def update_labels(self, weather_info: List[str]): + if not weather_info or len(weather_info) != 7: + return + icon_name, temp, condition, location, maxtemp, mintemp, gradient_class = ( + weather_info + ) + maxmin = f"L:{mintemp} H:{maxtemp}" + + self.city.set_label(location) + self.temperature.set_label(temp) + + # update SVG File + self.condition_em.dynamic_file(f"weather/{icon_name}.svg") + + self.condition.set_label(condition) + self.feels_like.set_label(maxmin) + + # Apply gradient class to container + if hasattr(self, "parent") and self.parent: + # Remove old weather classes + for cls in self.parent.get_style_context().list_classes(): + if cls.startswith("weather-"): + self.parent.remove_style_class(cls) + # Add new one + self.parent.add_style_class(gradient_class) + + self.parent.set_visible(True) + + def destroy(self): + """Cleanup weather update timer""" + if self._weather_timer_id: + GLib.source_remove(self._weather_timer_id) + self._weather_timer_id = None + super().destroy() + + +class WeatherContainer(Box): + def __init__(self, **kwargs): + super().__init__( + orientation="v", + name="weather-container", + v_expand=True, + v_align="center", + size=(170, 170), + visible=True, + h_align="center", + children=[Weather(self)], + **kwargs, + ) + + +class Date(Box): + def __init__(self, **kwargs): + super().__init__( + name="date-widget", + h_expand=True, + v_expand=True, + justification="center", + h_align="center", + v_align="start", + orientation="v", + **kwargs, + ) + self.top = Box(orientation="h", name="date-top", h_expand=True) + date_interval = 10000 + self.dateone = DateTime(formatters=["%a"], interval=date_interval, name="day") + self.datetwo = DateTime(formatters=["%b"], interval=date_interval, name="month") + self.datethree = DateTime( + formatters=["%-d"], interval=date_interval, name="date" + ) + self.top.add(self.dateone) + self.top.add(self.datetwo) + self.add(self.top) + self.add(self.datethree) + + +class DateContainer(Box): + def __init__(self, **kwargs): + super().__init__( + orientation="v", + name="date-container", + v_expand=True, + size=(170, 170), + v_align="center", + h_align="center", + children=[Date()], + **kwargs, + ) + + +class Calendar(Box): + def __init__(self, **kwargs): + calendar.setfirstweekday(6) + super().__init__( + name="calendar-widget", + h_expand=True, + v_expand=True, + orientation="v", + **kwargs, + ) + self._update_current_date() + self._create_header() + self._create_days_header() + self._create_calendar_grid() + self.add(self.month_label) + self.add(self.days_header) + self.add(self.calendar_grid) + self._calendar_timer_id = invoke_repeater( + CALENDAR_UPDATE_INTERVAL, self.update_calendar_if_needed + ) + + def destroy(self): + """Cleanup calendar update timer""" + if hasattr(self, "_calendar_timer_id") and self._calendar_timer_id: + GLib.source_remove(self._calendar_timer_id) + self._calendar_timer_id = None + super().destroy() + + def _update_current_date(self): + now = datetime.datetime.now() + self.current_month, self.current_year, self.current_day = ( + now.month, + now.year, + now.day, + ) + + def _create_header(self): + self.month_label = Label( + name="calendar-month", + label=calendar.month_name[self.current_month], + h_align="start", + justification="left", + ) + + def _create_days_header(self): + self.days_header = Box( + name="calendar-days-header", orientation="h", h_expand=True, spacing=2 + ) + for i, day_name in enumerate(["S", "M", "T", "W", "T", "F", "S"]): + self.days_header.add( + Label( + name="calendar-day-header-weekend" + if i in (0, 6) + else "calendar-day-header", + label=day_name, + h_align="center", + h_expand=True, + ) + ) + + def _create_calendar_grid(self): + self.calendar_grid = Box(name="calendar-grid", orientation="v", spacing=1) + self.update_calendar() + + def update_calendar_if_needed(self) -> bool: + now = datetime.datetime.now() + if (now.month, now.year, now.day) != ( + self.current_month, + self.current_year, + self.current_day, + ): + self._update_current_date() + self.update_calendar() + return True + + def update_calendar(self): + for child in self.calendar_grid.get_children(): + self.calendar_grid.remove(child) + self.month_label.set_label(calendar.month_name[self.current_month]) + cal = calendar.monthcalendar(self.current_year, self.current_month) + for week in cal: + week_box = Box(orientation="h", spacing=2, h_expand=True) + for i, day in enumerate(week): + if day == 0: + label = Label( + name="calendar-day-empty", + label="", + h_align="center", + h_expand=True, + ) + else: + is_today = ( + day == self.current_day + and self.current_month == datetime.datetime.now().month + ) + name = ( + "calendar-day-today" + if is_today + else ("calendar-day-weekend" if i in (0, 6) else "calendar-day") + ) + label = Label( + name=name, label=str(day), h_align="center", h_expand=True + ) + week_box.add(label) + self.calendar_grid.add(week_box) + + +class CalendarContainer(Box): + def __init__(self, **kwargs): + super().__init__( + orientation="v", + name="calendar-box-widget", + v_expand=True, + size=(170, 170), + v_align="center", + h_align="center", + children=[Calendar()], + **kwargs, + ) + + +class SystemInfoBase(Box): + @staticmethod + def create_progress_bar(name: str = "progress-bar", size: int = 80, **kwargs): + return CircularProgressBar( + name=name, + start_angle=270, + end_angle=630, + min_value=0, + max_value=100, + size=size, + **kwargs, + ) + + def __init__(self, name: str, **kwargs): + super().__init__( + layer="bottom", + title="sysinfo", + name=name, + visible=True, + size=(170, 170), + h_expand=True, + v_expand=True, + all_visible=True, + **kwargs, + ) + self.progress = self.create_progress_bar(name="progress") + self.main_label = Label( + label="0%\nLoading", justification="center", name="progress-label" + ) + self.info_container = Box( + name="info-container", orientation="v", spacing=2, h_align="center" + ) + self.add( + Box( + name="progress-bar-container", + h_expand=True, + v_expand=True, + orientation="v", + spacing=12, + h_align="center", + v_align="center", + children=[ + Box( + children=[ + Overlay( + child=self.progress, + tooltip_text="", + overlays=self.main_label, + ) + ] + ), + Box( + h_align="center", + justification="centre", + orientation="v", + children=[self.info_container], + ), + ], + ) + ) + + def start_updates(self): + self._system_timer_id = invoke_repeater(SYSTEM_UPDATE_INTERVAL, self.update) + + def destroy(self): + """Cleanup system info update timer""" + if hasattr(self, "_system_timer_id") and self._system_timer_id: + GLib.source_remove(self._system_timer_id) + self._system_timer_id = None + super().destroy() + + def create_info_line( + self, indicator_name: str, info_text: str, value_text: str + ) -> Box: + line = Box( + orientation="h", + spacing=4, + h_align="start", + children=[ + Label(label="โ– ", name=indicator_name), + Label(label=info_text, name="info-text"), + Label(label=value_text, name="info-value"), + ], + ) + line.value_label = line.get_children()[2] + return line + + def update(self) -> bool: + raise NotImplementedError + + +class RamInfo(SystemInfoBase): + def __init__(self, **kwargs): + super().__init__("info-box-widget", **kwargs) + self.used_line = self.create_info_line("used-color-indicator", "Used", "0.0GB") + self.free_line = self.create_info_line("free-color-indicator", "Free", "0.0GB") + self.info_container.add(self.used_line) + self.info_container.add(self.free_line) + self.start_updates() + + def update(self) -> bool: + try: + mem = psutil.virtual_memory() + self.main_label.set_label(f" {round(mem.percent):<2} %\nRAM") + self.used_line.value_label.set_label(f"{round(mem.used / (1024**3), 1)}GB") + self.free_line.value_label.set_label( + f"{round(mem.available / (1024**3), 1)}GB" + ) + GLib.idle_add(self.progress.set_value, mem.percent) + except Exception as e: + print(f"Error: {e}") + return True + + +class CpuInfo(SystemInfoBase): + def __init__(self, **kwargs): + super().__init__("info-box-widget", **kwargs) + self.temp_value = Label(label="0ยฐC", name="info-value") + self.info_container.add( + Box( + orientation="h", + spacing=4, + h_align="start", + children=[Label(label="Temp", name="info-text"), self.temp_value], + ) + ) + self.start_updates() + + def get_cpu_temp(self) -> Optional[float]: + try: + temps = psutil.sensors_temperatures() + for name, entries in temps.items(): + if any(s in name.lower() for s in ["coretemp", "k10temp", "cpu"]): + for entry in entries: + if any( + p in (entry.label or "").lower() + for p in ["package id 0", "core 0", ""] + ): + return round(entry.current, 1) + except Exception: + pass + return None + + def update(self) -> bool: + try: + cpu = psutil.cpu_percent() + self.main_label.set_label(f" {round(cpu):<2} %\nCPU") + temp = self.get_cpu_temp() + self.temp_value.set_label(f"{temp}ยฐC" if temp else "N/A") + GLib.idle_add(self.progress.set_value, cpu) + except Exception as e: + print(f"Error: {e}") + return True + + +class Deskwidgets: + """Desktop widgets manager - handles all desktop widgets.""" + + config = load_config() + + def __init__(self): + # This class now just manages other windows instead of being one itself + + # Create separate independent windows as attributes + self.top_left = Window( + anchor="top left", + title="modus-widgets-topleft", + exclusivity="none", + orientation="h", + layer="bottom", + visible=False, # Start hidden until content ready + child=Box( + name="desktop-widgets-container", + children=[ + DateContainer(), + WeatherContainer(), + CalendarContainer(), + ], + ), + ) + + self.bottom_left = Window( + anchor="bottom right", + title="modus-widgets-bottomright", + orientation="h", + layer="bottom", + exclusivity="none", + visible=False, # Start hidden until content ready + child=Box( + name="desktop-widgets-container", + children=[ + CpuInfo(), + RamInfo(), + ], + ), + ) + + # Show widgets after initialization is complete + self.top_left.set_visible(True) + self.bottom_left.set_visible(True) diff --git a/modules/dock.py b/src/window/dock.py similarity index 57% rename from modules/dock.py rename to src/window/dock.py index 0c836f43..fa117b43 100644 --- a/modules/dock.py +++ b/src/window/dock.py @@ -1,9 +1,16 @@ import json -import os -import re import subprocess -from fabric.utils.helpers import get_desktop_applications, get_relative_path +from fabric.utils import ( + GLib, + Gtk, + get_desktop_applications, + get_relative_path, + logger, + os, + random, + re, +) from fabric.widgets.box import Box from fabric.widgets.button import Button from fabric.widgets.eventbox import EventBox @@ -11,18 +18,21 @@ from fabric.widgets.label import Label from fabric.widgets.overlay import Overlay from fabric.widgets.revealer import Revealer -from gi.repository import GLib, Gtk -from loguru import logger +from fabric.widgets.wayland import WaylandWindow as Window -import config.data as data +from services.config import config, on_config_change from services.modus import modus_service -from utils.functions import read_json_file, write_json_file, is_special_workspace_id +from utils.functions import ( + clear_children, + is_special_workspace_id, + read_json_file, + write_json_file, +) from utils.icon_resolver import IconResolver from utils.occlusion import check_occlusion -from widgets.wayland import WaylandWindow as Window # Pinned apps file -PINNED_APPS_FILE = get_relative_path("../config/assets/dock.json") +PINNED_APPS_FILE = get_relative_path("../../config/dock.json") class AppBar(Box): @@ -34,15 +44,10 @@ def __init__(self, parent: Window): self.pinned_items_pos = [] self._parent = parent - # Set orientation based on dock position - orientation = ( - "vertical" if data.DOCK_POSITION in ["Left", "Right"] else "horizontal" - ) - super().__init__( spacing=0, name="dock", - orientation=orientation, + orientation="horizontal", children=[], ) self.icon_resolver = IconResolver() @@ -74,12 +79,56 @@ def update_running_apps(): logger.error(f"[AppBar] Error updating apps: {e}") return True - GLib.timeout_add(250, update_running_apps) + self._app_monitor_timer_id = GLib.timeout_add(250, update_running_apps) GLib.idle_add(self.update_dock_apps) + def update_icon_size(self): + """Update all icons in the dock to the current config size""" + desktop_apps = get_desktop_applications(include_hidden=False) + + # Update pinned apps + for app_id, button in self.pinned_buttons.items(): + if hasattr(button, "icon_image"): + if app_id == "trash" or getattr(button, "is_trash", False): + icon_size = config().get("dock_icon_size", 52) + pixbuf = self.icon_resolver.get_icon_pixbuf("user-trash", icon_size) + button.icon_image.set_from_pixbuf(pixbuf) + else: + # Find app data to get icon + app_data = next( + ( + a + for a in self.pinned_apps + if self._matches_app_identifier(a, app_id) + ), + app_id, + ) + app = self._find_desktop_app(app_id, desktop_apps) + pixbuf = self._get_app_icon(app_data, app) + button.icon_image.set_from_pixbuf(pixbuf) + + # Update running apps + for addr, button in self.client_buttons.items(): + if hasattr(button, "icon_image"): + app_class = getattr(button, "app_class", "") + app = self._find_desktop_app(app_class, desktop_apps) + pixbuf = self._get_app_icon(app_class, app) + button.icon_image.set_from_pixbuf(pixbuf) + + def destroy(self): + """Clean up timers and children""" + if hasattr(self, "_app_monitor_timer_id") and self._app_monitor_timer_id: + GLib.source_remove(self._app_monitor_timer_id) + self._app_monitor_timer_id = None + + # Destroy context menu + if self.menu: + self.menu.destroy() + + super().destroy() + def _populate_pinned_apps(self): - for child in self.pinned_apps_container.get_children(): - self.pinned_apps_container.remove(child) + clear_children(self.pinned_apps_container) self.pinned_buttons = {} self.pinned_items_pos = [] @@ -96,36 +145,25 @@ def _populate_pinned_apps(self): self._create_trash_button() def _create_pinned_button(self, app_data, desktop_apps): - if isinstance(app_data, dict): - app_identifier = app_data.get("name", "") or app_data.get( - "window_class", "" + app = self._find_desktop_app(app_data, desktop_apps) + app_identifier = self._get_app_identifier(app_data) + display_name = ( + app.display_name + if app + else ( + app_data.get("display_name", app_identifier) + if isinstance(app_data, dict) + else app_identifier ) - display_name = app_data.get("display_name", app_identifier) - app = self._find_desktop_app_from_data(app_data, desktop_apps) - - if app: - icon_pixbuf = app.get_icon_pixbuf(data.DOCK_ICON_SIZE) - else: - icon_name = app_data.get("window_class", "") or app_data.get("name", "") - icon_pixbuf = self.icon_resolver.get_icon_pixbuf( - icon_name, data.DOCK_ICON_SIZE - ) - else: - app_identifier = app_data - app = self._find_desktop_app_by_id(app_data, desktop_apps) - if not app: - return - - display_name = app.display_name or app.name - icon_pixbuf = app.get_icon_pixbuf(data.DOCK_ICON_SIZE) + ) - pinned_image = Image(name="dock_item_icon") - pinned_image.set_from_pixbuf(icon_pixbuf) + icon_pixbuf = self._get_app_icon(app_data, app) + icon_image = Image(name="dock_item_icon", pixbuf=icon_pixbuf) main_container = Box( name="dock_item_main_container", orientation="v", - children=[pinned_image], + children=[icon_image], ) pinned_button = Button( @@ -135,34 +173,46 @@ def _create_pinned_button(self, app_data, desktop_apps): on_button_press_event=lambda _, event: self._handle_pinned_app_click( event, app_data ), - on_enter_notify_event=lambda *_: self._handle_item_hovered( - pinned_button, True + on_enter_notify_event=lambda *_: self._set_item_hover_state( + pinned_button, True, pinned=True ), - on_leave_notify_event=lambda *_: self._handle_item_unhovered( - pinned_button, True + on_leave_notify_event=lambda *_: self._set_item_hover_state( + pinned_button, False, pinned=True ), ) pinned_button.add_style_class("shown") + pinned_button.icon_image = icon_image self.pinned_buttons[app_identifier] = pinned_button self.pinned_apps_container.add(pinned_button) self.pinned_items_pos.append(pinned_button) - def _create_trash_button(self): - """Create a trash button that opens the trash in file manager""" - # Get trash icon - trash_icon_pixbuf = self.icon_resolver.get_icon_pixbuf( - "user-trash", data.DOCK_ICON_SIZE + def _get_app_icon(self, app_data, app=None): + icon_size = config().get("dock_icon_size", 52) + if app: + return app.get_icon_pixbuf(icon_size) + + icon_name = "" + if isinstance(app_data, dict): + icon_name = app_data.get("window_class") or app_data.get("name") + elif isinstance(app_data, str): + icon_name = app_data + + return self.icon_resolver.get_icon_pixbuf( + icon_name or "application-x-executable", icon_size ) - trash_image = Image(name="dock_item_icon") - trash_image.set_from_pixbuf(trash_icon_pixbuf) + def _create_trash_button(self): + """Create a trash button that opens the trash in file manager""" + icon_size = config().get("dock_icon_size", 52) + trash_icon_pixbuf = self.icon_resolver.get_icon_pixbuf("user-trash", icon_size) + trash_icon_image = Image(name="dock_item_icon", pixbuf=trash_icon_pixbuf) main_container = Box( name="dock_item_main_container", orientation="v", - children=[trash_image], + children=[trash_icon_image], ) trash_button = Button( @@ -170,67 +220,113 @@ def _create_trash_button(self): child=main_container, tooltip_text="Trash", on_button_press_event=lambda _, event: self._handle_trash_click(event), - on_enter_notify_event=lambda *_: self._handle_item_hovered( - trash_button, True + on_enter_notify_event=lambda *_: self._set_item_hover_state( + trash_button, True, pinned=True ), - on_leave_notify_event=lambda *_: self._handle_item_unhovered( - trash_button, True + on_leave_notify_event=lambda *_: self._set_item_hover_state( + trash_button, False, pinned=True ), ) trash_button.add_style_class("shown") trash_button.is_trash = True + trash_button.icon_image = trash_icon_image self.pinned_buttons["trash"] = trash_button self.pinned_apps_container.add(trash_button) self.pinned_items_pos.append(trash_button) - def _find_desktop_app_from_data(self, app_data: dict, desktop_apps): + def _find_desktop_app(self, app_info, desktop_apps): + """Find a desktop app from either a string ID or a data dictionary""" + if not app_info: + return None + + # Extract normalized search terms + search_terms = [] + if isinstance(app_info, dict): + search_terms = [ + app_info.get("window_class"), + app_info.get("name"), + app_info.get("executable"), + ] + else: + search_terms = [app_info] + + search_terms = [s.lower() for s in search_terms if s and isinstance(s, str)] + if not search_terms: + return None + + # Handle Reverse DNS or decorated names (e.g., org.gnome.Nautilus, float_kitty) + expanded_terms = list(search_terms) + for term in search_terms: + # Handle dots (Reverse DNS) + if "." in term: + parts = term.split(".") + if parts[-1] and parts[-1] not in expanded_terms: + expanded_terms.append(parts[-1]) + + # Handle underscores and hyphens (decorated names) + for sep in ["_", "-"]: + if sep in term: + for part in term.split(sep): + if len(part) > 2 and part not in expanded_terms: + expanded_terms.append(part) + + # Priority 1: Exact matches for window_class for app in desktop_apps: - if ( - ( - app_data.get("name") - and app.name - and app.name.lower() == app_data["name"].lower() - ) - or ( - app_data.get("window_class") - and hasattr(app, "window_class") - and app.window_class - and app.window_class.lower() == app_data["window_class"].lower() - ) - or ( - app_data.get("executable") - and app.executable - and ( - app.executable.lower() == app_data["executable"].lower() - or os.path.basename(app.executable).lower() - == os.path.basename(app_data["executable"]).lower() - ) - ) - ): + app_class = getattr(app, "window_class", None) + if app_class and app_class.lower() in search_terms: return app - return None - def _find_desktop_app_by_id(self, app_id: str, desktop_apps): + # Priority 2: Exact matches for app name or display name for app in desktop_apps: - if ( - (app.name and app.name.lower() == app_id.lower()) - or (app.display_name and app.display_name.lower() == app_id.lower()) - or ( - hasattr(app, "window_class") - and app.window_class - and app.window_class.lower() == app_id.lower() - ) - or ( - app.executable - and ( - app.executable.lower() == app_id.lower() - or os.path.basename(app.executable).lower() == app_id.lower() - ) - ) - ): + app_names = [ + getattr(app, "name", None), + getattr(app, "display_name", None), + ] + app_names = [n.lower() for n in app_names if n] + if any(name in expanded_terms for name in app_names): return app + + # Priority 3: Exact matches for executable basename + for app in desktop_apps: + if app.executable: + exe_base = os.path.basename(app.executable).lower() + if exe_base in expanded_terms: + # If this is a common terminal or shell, only match if the name also matches + # This prevents Neovim (which uses 'kitty' as its executable wrapper) from overriding Kitty + common_wrappers = [ + "kitty", + "bash", + "sh", + "zsh", + "python", + "python3", + ] + if exe_base in common_wrappers: + app_names = [ + getattr(app, "name", ""), + getattr(app, "display_name", ""), + ] + if not any(exe_base in n.lower() for n in app_names if n): + continue + return app + + # Priority 4: Fuzzy name matching (containment) + for app in desktop_apps: + app_terms = [ + getattr(app, "name", ""), + getattr(app, "display_name", ""), + getattr(app, "window_class", ""), + ] + app_terms = [t.lower() for t in app_terms if t] + + for term in expanded_terms: + if len(term) < 3: + continue + if any(term in app_term for app_term in app_terms): + return app + return None def show_menu(self, app_id: str, client=None, instance_address=None): @@ -272,13 +368,12 @@ def _close_running_app(self, instance_address): logger.error(f"[AppBar] Error closing window: {e}") def _handle_pinned_app_click(self, event, app_data): + app_identifier = self._get_app_identifier(app_data) if event.button == 1: # Left click - launch app - self._launch_app_data(app_data) + self._launch_app(app_data) elif event.button == 2: # Middle click - unpin app - app_identifier = self._get_app_identifier(app_data) self._unpin_app(app_identifier) elif event.button == 3: # Right click - show context menu - app_identifier = self._get_app_identifier(app_data) self.show_menu(app_identifier) self.menu.popup_at_pointer(event) @@ -309,97 +404,60 @@ def _handle_trash_click(self, event): except Exception as e: logger.error(f"[AppBar] Error opening trash: {e}") - def _handle_item_hovered(self, item, pinned=False): - if pinned: - try: - index = self.pinned_items_pos.index(item) - if index > 0: - self.pinned_items_pos[index - 1].add_style_class("semi_hovered") - if index < len(self.pinned_items_pos) - 1: - self.pinned_items_pos[index + 1].add_style_class("semi_hovered") - except ValueError: - pass - else: - try: - index = self.running_items_pos.index(item) - if index > 0: - self.running_items_pos[index - 1].add_style_class("semi_hovered") - if index < len(self.running_items_pos) - 1: - self.running_items_pos[index + 1].add_style_class("semi_hovered") - except ValueError: - pass - - def _handle_item_unhovered(self, item, pinned=False): - if pinned: - try: - index = self.pinned_items_pos.index(item) - if index > 0: - self.pinned_items_pos[index - 1].remove_style_class("semi_hovered") - if index < len(self.pinned_items_pos) - 1: - self.pinned_items_pos[index + 1].remove_style_class("semi_hovered") - except ValueError: - pass - else: - try: - index = self.running_items_pos.index(item) - if index > 0: - self.running_items_pos[index - 1].remove_style_class("semi_hovered") - if index < len(self.running_items_pos) - 1: - self.running_items_pos[index + 1].remove_style_class("semi_hovered") - except ValueError: - pass - - def _get_app_identifier(self, app_data): - if isinstance(app_data, dict): - return app_data.get("name", "") or app_data.get("window_class", "") - return app_data + def _set_item_hover_state(self, item, is_hovered, pinned=False): + if is_hovered: + self._parent.on_hover_enter() - def _launch_app_data(self, app_data): + items_list = self.pinned_items_pos if pinned else self.running_items_pos try: - desktop_apps = get_desktop_applications(include_hidden=False) + index = items_list.index(item) + style_class = "semi_hovered" + action = "add" if is_hovered else "remove" + + if index > 0: + getattr(items_list[index - 1], f"{action}_style_class")(style_class) + if index < len(items_list) - 1: + getattr(items_list[index + 1], f"{action}_style_class")(style_class) + except (ValueError, IndexError): + pass + + def _launch_app(self, app_info): + """Launch an app using its desktop app instance or a data dictionary""" + try: + command_line = "" + if hasattr(app_info, "command_line"): + command_line = app_info.command_line + elif isinstance(app_info, dict): + command_line = app_info.get("command_line") or app_info.get( + "executable" + ) + elif isinstance(app_info, str): + command_line = app_info - if isinstance(app_data, dict): - app = self._find_desktop_app_from_data(app_data, desktop_apps) - if app: - self._launch_app(app) - else: - self._launch_app_from_data(app_data) - else: - app = self._find_desktop_app_by_id(app_data, desktop_apps) + if not command_line: + # Fallback to desktop app search if it's just a string class + desktop_apps = get_desktop_applications(include_hidden=False) + app = self._find_desktop_app(app_info, desktop_apps) if app: - self._launch_app(app) - except Exception as e: - logger.error(f"[AppBar] Failed to launch app: {e}") + command_line = app.command_line - def _launch_app(self, app): - try: - cleaned_command = re.sub(r"%\w+", "", app.command_line).strip() - final_command = f"hyprctl dispatch exec 'uwsm app -- {cleaned_command}'" - subprocess.Popen(final_command, shell=True) - except Exception: - try: - app.launch() - except Exception as fallback_error: - logger.error(f"[AppBar] Failed to launch app: {fallback_error}") - - def _launch_app_from_data(self, app_data): - try: - command_line = app_data.get("command_line", "") if command_line: cleaned_command = re.sub(r"%\w+", "", command_line).strip() final_command = f"hyprctl dispatch exec 'uwsm app -- {cleaned_command}'" subprocess.Popen(final_command, shell=True) - elif app_data.get("executable"): - final_command = ( - f"hyprctl dispatch exec 'uwsm app -- {app_data['executable']}'" - ) - subprocess.Popen(final_command, shell=True) + elif hasattr(app_info, "launch"): + app_info.launch() else: logger.error( - f"[AppBar] No command or executable found for app: {app_data}" + f"[AppBar] Could not determine launch command for: {app_info}" ) except Exception as e: - logger.error(f"[AppBar] Failed to launch app from data: {e}") + logger.error(f"[AppBar] Failed to launch app: {e}") + + def _get_app_identifier(self, app_data): + if isinstance(app_data, dict): + return app_data.get("name", "") or app_data.get("window_class", "") + return app_data def _pin_app(self, app_class: str): if self._is_app_pinned(app_class): @@ -407,25 +465,17 @@ def _pin_app(self, app_class: str): try: desktop_apps = get_desktop_applications(include_hidden=False) - app = self._find_desktop_app_by_id(app_class, desktop_apps) - - if app: - app_data = { - "name": app.name, - "display_name": app.display_name or app.name, - "window_class": getattr(app, "window_class", None) or app_class, - "executable": app.executable, - "command_line": app.command_line, - } - else: - app_data = { - "name": app_class, - "display_name": app_class, - "window_class": app_class, - "executable": app_class, - "command_line": app_class, - } - + app = self._find_desktop_app(app_class, desktop_apps) + + app_data = { + "name": app.name if app else app_class, + "display_name": (app.display_name or app.name) if app else app_class, + "window_class": (getattr(app, "window_class", None) or app_class) + if app + else app_class, + "executable": app.executable if app else app_class, + "command_line": app.command_line if app else app_class, + } self.pinned_apps.append(app_data) except Exception: self.pinned_apps.append(app_class) @@ -489,12 +539,16 @@ def update_dock_apps(self): try: clients = self.get_clients() focused_window = self.get_focused_window() - focused_address = focused_window.get("address", "") if focused_window else "" + focused_address = ( + focused_window.get("address", "") if focused_window else "" + ) current_instance_ids = set() for client in clients: - if client.get("hidden", False) or not self._should_show_app_instance(client): + if client.get("hidden", False) or not self._should_show_app_instance( + client + ): continue instance_address = client.get("address", "") @@ -519,7 +573,7 @@ def update_dock_apps(self): self._update_separator_visibility() self._cleanup_removed_instances(current_instance_ids) - + except Exception as e: logger.error(f"[AppBar] Error in update_dock_apps: {e}") @@ -550,8 +604,11 @@ def _cleanup_removed_instances(self, current_instance_ids): ] # Clean up removed and orphaned buttons - for instance_id in buttons_to_remove + [k for k, v in self.client_buttons.items() - if not hasattr(v, 'instance_address') or not v.get_parent()]: + for instance_id in buttons_to_remove + [ + k + for k, v in self.client_buttons.items() + if not hasattr(v, "instance_address") or not v.get_parent() + ]: if instance_id in self.client_buttons: button = self.client_buttons.pop(instance_id) try: @@ -571,15 +628,8 @@ def create_instance_button(self, instance_address, client, app_class): try: desktop_apps = get_desktop_applications(include_hidden=False) - desktop_app = self._find_desktop_app_by_id(app_class, desktop_apps) - - if desktop_app: - pixbuf = desktop_app.get_icon_pixbuf(data.DOCK_ICON_SIZE) - else: - pixbuf = self.icon_resolver.get_icon_pixbuf( - app_class, data.DOCK_ICON_SIZE - ) - + app = self._find_desktop_app(app_class, desktop_apps) + pixbuf = self._get_app_icon(app_class, app) client_image.set_from_pixbuf(pixbuf) except Exception as e: logger.warning(f"[AppBar] Could not load icon for {app_class}: {e}") @@ -616,11 +666,11 @@ def create_instance_button(self, instance_address, client, app_class): on_button_press_event=lambda widget, event: self.handle_instance_click( widget, event ), - on_enter_notify_event=lambda *_: self._handle_item_hovered( - client_button, False + on_enter_notify_event=lambda *_: self._set_item_hover_state( + client_button, True, pinned=False ), - on_leave_notify_event=lambda *_: self._handle_item_unhovered( - client_button, False + on_leave_notify_event=lambda *_: self._set_item_hover_state( + client_button, False, pinned=False ), ) @@ -628,14 +678,17 @@ def create_instance_button(self, instance_address, client, app_class): client_button.client_data = client client_button.app_class = app_class client_button.workspace_label = workspace_label + client_button.icon_image = client_image client_button.add_style_class("shown") self.client_buttons[instance_address] = client_button self.running_apps_container.add(client_button) self.running_items_pos.append(client_button) - + except Exception as e: - logger.error(f"[AppBar] Error creating instance button for {app_class}: {e}") + logger.error( + f"[AppBar] Error creating instance button for {app_class}: {e}" + ) def _get_workspace_id(self, client): workspace_data = client.get("workspace", {}) @@ -645,18 +698,14 @@ def _get_workspace_id(self, client): return workspace_data return None - def _is_special_workspace_id(self, ws_id): - return is_special_workspace_id(ws_id) - def _should_show_app_instance(self, client): - if not data.DOCK_HIDE_SPECIAL_WORKSPACE_APPS: + if not config().get("dock_hide_special_workspace_apps", True): return True workspace_id = self._get_workspace_id(client) - if workspace_id is None: - return True - - return not self._is_special_workspace_id(workspace_id) + return ( + False if workspace_id is None else not is_special_workspace_id(workspace_id) + ) def update_instance_button(self, instance_address, client, app_class): if instance_address not in self.client_buttons: @@ -675,27 +724,30 @@ def update_instance_button(self, instance_address, client, app_class): existing_label = getattr(button, "workspace_label", None) container = button.get_child() - if hasattr(container, "get_children"): - children = container.get_children() - if children: - image_overlay = children[0] - if isinstance(image_overlay, Overlay): - # Remove existing workspace label - if existing_label and existing_label.get_parent(): - image_overlay.remove_overlay(existing_label) - - # Add new workspace label if needed - if workspace_id is not None: - new_label = Label( - label=str(workspace_id), - name="workspace-indicator", - h_align="end", - v_align="end", - ) - image_overlay.add_overlay(new_label) - button.workspace_label = new_label - else: - button.workspace_label = None + if not hasattr(container, "get_children"): + return + + children = container.get_children() + if not children or not isinstance(children[0], Overlay): + return + + image_overlay = children[0] + # Remove existing workspace label + if existing_label and existing_label.get_parent(): + image_overlay.remove_overlay(existing_label) + + # Add new workspace label if needed + if workspace_id is not None: + new_label = Label( + label=str(workspace_id), + name="workspace-indicator", + h_align="end", + v_align="end", + ) + image_overlay.add_overlay(new_label) + button.workspace_label = new_label + else: + button.workspace_label = None def handle_instance_click(self, button_widget, event): instance_address = getattr(button_widget, "instance_address", None) @@ -736,23 +788,13 @@ def _update_separator_visibility(self): class Dock(Window): def __init__(self): - if not data.DOCK_ENABLED: - anchor = self._get_anchor_from_position() - super().__init__(layer="top", title="dock", anchor=anchor) - self.children = Box() # Empty dock if disabled - return - - anchor = self._get_anchor_from_position() - super().__init__(layer="top", anchor=anchor) + super().__init__(layer="top", anchor="bottom center", title="modus-dock") self.app_bar = AppBar(self) - - transition_type = self._get_transition_type() - self.revealer = Revealer( child=Box(children=[self.app_bar], style="padding: 20px 50px 5px 50px;"), transition_duration=200, - transition_type=transition_type, + transition_type="slide-up", ) self.children = EventBox( @@ -762,64 +804,86 @@ def __init__(self): on_leave_notify_event=lambda *_: self.on_hover_leave(), ) - self.revealer.set_reveal_child(True) - self.app_bar.add_style_class("shown") + self._update_visibility() self.dock_height = 100 self.is_hovered = False - self.hide_timeout_id = None + self.hide_ticket = 0 - # Only setup occlusion monitoring if auto-hide is enabled - if data.DOCK_AUTO_HIDE: + # Subscribe to config changes for hot-reloading + on_config_change(self._on_config_change) + + if config().get("dock_auto_hide", True): self.setup_occlusion_monitoring() + def _on_config_change(self, new_config, old_config): + # Handle dock visibility + if config().has_changed("dock_enabled", old_config): + self._update_visibility() + + # Handle icon size changes + if config().has_changed("dock_icon_size", old_config): + self.app_bar.update_icon_size() + + # Handle auto-hide changes + if config().has_changed("dock_auto_hide", old_config): + if new_config.get("dock_auto_hide", True): + self.setup_occlusion_monitoring() + else: + if hasattr(self, "_occlusion_timer_id") and self._occlusion_timer_id: + GLib.source_remove(self._occlusion_timer_id) + self._occlusion_timer_id = None + self.revealer.set_reveal_child(True) + self.app_bar.add_style_class("shown") + + # Handle special workspace apps visibility + if config().has_changed("dock_hide_special_workspace_apps", old_config): + self.app_bar.update_dock_apps() + + def _update_visibility(self): + enabled = config().get("dock_enabled", True) + if enabled: + self.show() + self.revealer.set_reveal_child(True) + self.app_bar.add_style_class("shown") + else: + self.hide() + def on_hover_enter(self): self.is_hovered = True - if self.hide_timeout_id: - GLib.source_remove(self.hide_timeout_id) - self.hide_timeout_id = None + self.hide_ticket = random.getrandbits(32) self.revealer.set_reveal_child(True) self.app_bar.add_style_class("shown") def on_hover_leave(self): self.is_hovered = False - # Add small delay before potential hiding to prevent rapid show/hide cycles - if self.hide_timeout_id: - GLib.source_remove(self.hide_timeout_id) - self.hide_timeout_id = GLib.timeout_add(100, lambda: None) - - def _get_anchor_from_position(self): - if data.DOCK_POSITION == "Left": - return "left center" - elif data.DOCK_POSITION == "Right": - return "right center" - else: # Bottom (default) - return "bottom center" - - def _get_transition_type(self): - if data.DOCK_POSITION == "Left": - return "slide-right" - elif data.DOCK_POSITION == "Right": - return "slide-left" - else: # Bottom (default) - return "slide-up" - - def _get_occlusion_position(self): - if data.DOCK_POSITION == "Left": - return ("left", self.dock_height) - elif data.DOCK_POSITION == "Right": - return ("right", self.dock_height) - else: # Bottom (default) - return ("bottom", self.dock_height) + self.hide_ticket = random.getrandbits(32) + + def delayed_hide(ticket): + if ticket == self.hide_ticket and not self.is_hovered: + if config().get("dock_auto_hide", True): + is_occluded = config().get( + "dock_always_occluded", False + ) or check_occlusion(("bottom", self.dock_height)) + if is_occluded: + self.revealer.set_reveal_child(False) + self.app_bar.remove_style_class("shown") + return False + + GLib.timeout_add(500, delayed_hide, self.hide_ticket) def setup_occlusion_monitoring(self): + # Remove existing timer if any + if hasattr(self, "_occlusion_timer_id") and self._occlusion_timer_id: + GLib.source_remove(self._occlusion_timer_id) + self._occlusion_timer_id = None + def check_dock_occlusion(): try: - if data.DOCK_ALWAYS_OCCLUDED: + if config().get("dock_always_occluded", False): is_occluded = True else: - occlusion_position = self._get_occlusion_position() - is_occluded = check_occlusion(occlusion_position) + is_occluded = check_occlusion(("bottom", self.dock_height)) if ( is_occluded @@ -840,4 +904,15 @@ def check_dock_occlusion(): return True - GLib.timeout_add(300, check_dock_occlusion) + self._occlusion_timer_id = GLib.timeout_add(300, check_dock_occlusion) + + def destroy(self): + """Clean up timers and children""" + if hasattr(self, "_occlusion_timer_id") and self._occlusion_timer_id: + GLib.source_remove(self._occlusion_timer_id) + self._occlusion_timer_id = None + + if hasattr(self, "app_bar") and self.app_bar: + self.app_bar.destroy() + + super().destroy() diff --git a/modules/launcher/__init__.py b/src/window/launcher/__init__.py similarity index 100% rename from modules/launcher/__init__.py rename to src/window/launcher/__init__.py diff --git a/modules/launcher/main.py b/src/window/launcher/main.py similarity index 98% rename from modules/launcher/main.py rename to src/window/launcher/main.py index 30ada343..df6cdc23 100644 --- a/modules/launcher/main.py +++ b/src/window/launcher/main.py @@ -1,16 +1,16 @@ from typing import List, Optional, Tuple -from gi.repository import Gdk, GLib - from fabric.core.service import Property +from fabric.utils import Gdk, GLib from fabric.widgets.box import Box from fabric.widgets.entry import Entry -from modules.launcher.plugin_manager import PluginManager -from modules.launcher.result import Result -from modules.launcher.result_item import ResultItem -from modules.launcher.trigger_config import TriggerConfig from fabric.widgets.scrolledwindow import ScrolledWindow -from widgets.wayland import WaylandWindow as Window +from fabric.widgets.wayland import WaylandWindow as Window + +from window.launcher.plugin_manager import PluginManager +from window.launcher.result import Result +from window.launcher.result_item import ResultItem +from window.launcher.trigger_config import TriggerConfig # Constants SEARCH_DEBOUNCE_MS = 50 @@ -720,7 +720,10 @@ def _update_results_display(self): # Clear existing results for child in self.results_box.get_children(): - self.results_box.remove(child) + if isinstance(child, ResultItem): + child.destroy() + else: + self.results_box.remove(child) # Add new results for i, result in enumerate(self.results): @@ -790,7 +793,10 @@ def _clear_results(self): self.results = [] self.selected_index = 0 for child in self.results_box.get_children(): - self.results_box.remove(child) + if isinstance(child, ResultItem): + child.destroy() + else: + self.results_box.remove(child) # Keep the results scroll visible even when empty def _handle_escape_key(self) -> bool: @@ -847,8 +853,11 @@ def check_trigger_after_backspace(): if should_exit_trigger: self.triggered_plugin = None self.active_trigger = "" - self._clear_results() - # Don't clear the text - let the user's edit stand + + # Trigger a search with the new text instead of just clearing results + # This ensures we show applications if empty, or normal search results if not + self.query = new_text + GLib.timeout_add(50, self._perform_search, new_text) # If we're still in trigger mode but the text changed, update the search elif self.triggered_plugin and new_text != current_text: @@ -1040,7 +1049,7 @@ def _find_focused_entry_in_widget(self, widget): return widget # Also check if this is the only Entry in the widget (likely to be the target) return widget - except: + except Exception: # If focus checking fails, assume this Entry should handle the event return widget @@ -1207,7 +1216,7 @@ def _scroll_to_widget(self, widget): allocation = selected_child.get_allocation() item_height = allocation.height if allocation.height > 0 else 68 item_y = allocation.y - except: + except Exception: # Fallback to estimation item_height = DEFAULT_ITEM_HEIGHT item_y = self.selected_index * item_height diff --git a/modules/launcher/plugin_base.py b/src/window/launcher/plugin_base.py similarity index 99% rename from modules/launcher/plugin_base.py rename to src/window/launcher/plugin_base.py index 13bd1fcd..f0898ecf 100644 --- a/modules/launcher/plugin_base.py +++ b/src/window/launcher/plugin_base.py @@ -1,7 +1,7 @@ from abc import ABC, abstractmethod from typing import List -from modules.launcher.result import Result +from window.launcher.result import Result class PluginBase(ABC): diff --git a/modules/launcher/plugin_manager.py b/src/window/launcher/plugin_manager.py similarity index 92% rename from modules/launcher/plugin_manager.py rename to src/window/launcher/plugin_manager.py index caf002e9..5692375c 100644 --- a/modules/launcher/plugin_manager.py +++ b/src/window/launcher/plugin_manager.py @@ -3,7 +3,7 @@ import os from typing import Dict, List, Type -from modules.launcher.plugin_base import PluginBase +from window.launcher.plugin_base import PluginBase class PluginManager: @@ -47,16 +47,16 @@ def _load_plugin_from_file(self, plugins_dir: str, plugin_name: str): try: plugin_path = os.path.join(plugins_dir, f"{plugin_name}.py") spec = importlib.util.spec_from_file_location( - f"modules.launcher.plugins.{plugin_name}", plugin_path + f"window.launcher.plugins.{plugin_name}", plugin_path ) if spec and spec.loader: module = importlib.util.module_from_spec(spec) - # Add the module to sys.modules to support relative imports + # Add the module to sys.window to support relative imports import sys - sys.modules[f"modules.launcher.plugins.{plugin_name}"] = module + sys.modules[f"window.launcher.plugins.{plugin_name}"] = module spec.loader.exec_module(module) @@ -79,7 +79,6 @@ def _activate_default_plugins(self): default_plugins = [ "applications", "calculator", - "system", "clipboard", "power", "caffeine", @@ -87,12 +86,6 @@ def _activate_default_plugins(self): "emoji", "wallpaper", "websearch", - "reminders", - "otp", - "password", - "bookmarks", - "bash_scripts", - "tmux", ] for plugin_name in default_plugins: diff --git a/modules/launcher/plugins/__init__.py b/src/window/launcher/plugins/__init__.py similarity index 100% rename from modules/launcher/plugins/__init__.py rename to src/window/launcher/plugins/__init__.py diff --git a/modules/launcher/plugins/applications.py b/src/window/launcher/plugins/applications.py similarity index 95% rename from modules/launcher/plugins/applications.py rename to src/window/launcher/plugins/applications.py index 832d75b8..fa272a8d 100644 --- a/modules/launcher/plugins/applications.py +++ b/src/window/launcher/plugins/applications.py @@ -1,13 +1,12 @@ import json -import re -from typing import List import subprocess +from typing import List + +from fabric.utils import DesktopApp, get_desktop_applications, get_relative_path, re -from fabric.utils import DesktopApp -from fabric.utils.helpers import get_desktop_applications, get_relative_path -from modules.launcher.plugin_base import PluginBase -from modules.launcher.result import Result from utils.roam import modus_service +from window.launcher.plugin_base import PluginBase +from window.launcher.result import Result class ApplicationsPlugin(PluginBase): @@ -24,7 +23,7 @@ def cleanup(self): def _pin_application(self, app): """Pin an application to the dock.""" - config_path = get_relative_path("../../../config/assets/dock.json") + config_path = get_relative_path("../../../../config/dock.json") try: with open(config_path, "r") as file: pinned_apps = json.load(file) diff --git a/modules/launcher/plugins/caffeine.py b/src/window/launcher/plugins/caffeine.py similarity index 96% rename from modules/launcher/plugins/caffeine.py rename to src/window/launcher/plugins/caffeine.py index decde979..0df855a8 100644 --- a/modules/launcher/plugins/caffeine.py +++ b/src/window/launcher/plugins/caffeine.py @@ -2,15 +2,11 @@ from threading import Timer from typing import List -import gi +from fabric.utils import exec_shell_command_async, get_relative_path -import config.data as data -from fabric.utils import get_relative_path -from fabric.utils.helpers import exec_shell_command_async -from modules.launcher.plugin_base import PluginBase -from modules.launcher.result import Result - -gi.require_version("Gtk", "3.0") +import shared.data as data +from window.launcher.plugin_base import PluginBase +from window.launcher.result import Result class CaffeinePlugin(PluginBase): diff --git a/modules/launcher/plugins/calculator.py b/src/window/launcher/plugins/calculator.py similarity index 92% rename from modules/launcher/plugins/calculator.py rename to src/window/launcher/plugins/calculator.py index f97dddbe..342c4937 100644 --- a/modules/launcher/plugins/calculator.py +++ b/src/window/launcher/plugins/calculator.py @@ -4,9 +4,9 @@ import time from typing import List -from modules.launcher.plugin_base import PluginBase -from modules.launcher.result import Result from utils.conversion import Conversion +from window.launcher.plugin_base import PluginBase +from window.launcher.result import Result class CalculatorPlugin(PluginBase): @@ -48,7 +48,7 @@ def __init__(self): self.expression_pattern = re.compile(r"[\d+\-*/^()=]") self.number_pattern = re.compile(r"\d") self.conversion_pattern = re.compile( - r"(\d+(?:\.\d+)?)\s*([a-zA-Z]+)\s*(?:to|in|=)\s*([a-zA-Z]+)" + r"(\d+(?:\.\d+)?)\s*([^0-9\s]+)\s*(?:to|in|=)\s*([^0-9\s]+)" ) # Cache for conversion results @@ -89,7 +89,11 @@ def query(self, query: str) -> List[Result]: subtitle = f"{value} {from_unit} = {result:.6g} {to_unit}" else: # Use the conversion utility - result = self.converter.convert(value, from_unit, to_unit) + from_unit_clean = self.converter.clean_type(from_unit) + to_unit_clean = self.converter.clean_type(to_unit) + result = self.converter.convert( + value, from_unit_clean, to_unit_clean + ) # Cache the result self._conversion_cache[cache_key] = result subtitle = f"{value} {from_unit} = {result:.6g} {to_unit}" diff --git a/modules/launcher/plugins/clipboard.py b/src/window/launcher/plugins/clipboard.py similarity index 98% rename from modules/launcher/plugins/clipboard.py rename to src/window/launcher/plugins/clipboard.py index e332d193..7db592f2 100644 --- a/modules/launcher/plugins/clipboard.py +++ b/src/window/launcher/plugins/clipboard.py @@ -1,16 +1,14 @@ -import os import subprocess import sys import tempfile import threading -import time from concurrent.futures import ThreadPoolExecutor from typing import Dict, List, Optional -from gi.repository import GdkPixbuf, GLib +from fabric.utils import GdkPixbuf, GLib, os, time -from modules.launcher.plugin_base import PluginBase -from modules.launcher.result import Result +from window.launcher.plugin_base import PluginBase +from window.launcher.result import Result class ClipboardPlugin(PluginBase): @@ -85,7 +83,6 @@ def invalidate_cache(self): def _force_launcher_refresh(self): """Force the launcher to refresh its results.""" try: - from gi.repository import GLib def trigger_refresh(): try: diff --git a/src/window/launcher/plugins/colorpicker.py b/src/window/launcher/plugins/colorpicker.py new file mode 100644 index 00000000..424ad9c9 --- /dev/null +++ b/src/window/launcher/plugins/colorpicker.py @@ -0,0 +1,146 @@ +import os +import re +import subprocess +from typing import List + +from fabric.utils import exec_shell_command, exec_shell_command_async, logger +from shared.data import APP_NAME +from window.launcher.plugin_base import PluginBase +from window.launcher.result import Result + + +class ColorPickerPlugin(PluginBase): + """ + Plugin for picking colors from the screen using hyprpicker. + """ + + def __init__(self): + super().__init__() + self.display_name = "Color Picker" + self.description = "Pick colors from the screen (HEX, RGB, HSV)" + self.tmp_icon_path = "/tmp/color.png" + + self.options = [ + {"name": "Pick HEX", "arg": "hex", "icon": "color-select-symbolic"}, + {"name": "Pick RGB", "arg": "rgb", "icon": "color-select-symbolic"}, + {"name": "Pick HSV", "arg": "hsv", "icon": "color-select-symbolic"}, + ] + + def initialize(self): + """Initialize the color picker plugin.""" + self.set_triggers(["color", "cp"]) + + def cleanup(self): + """Cleanup resources.""" + if os.path.exists(self.tmp_icon_path): + try: + os.remove(self.tmp_icon_path) + except OSError: + pass + + def query(self, query_string: str) -> List[Result]: + """Process a search query and return results.""" + q = query_string.strip().lower() + + results = [] + + # Filter options based on query + filtered = ( + self.options + if not q + else [o for o in self.options if q in o["name"].lower() or q == o["arg"]] + ) + + for option in filtered: + mode = option["arg"] + results.append( + Result( + title=f"Color Picker: {option['name']}", + subtitle=f"Pick and copy {mode.upper()} color to clipboard", + icon_name=option["icon"], + action=lambda m=mode: self._execute_pick(m), + relevance=1.0, + plugin_name=self.display_name, + ) + ) + + return results + + def _execute_pick(self, mode: str): + """Run hyprpicker, process output, copy to clipboard and notify.""" + try: + # Run hyprpicker + # -n: do not print a newline + # -f: format + raw = exec_shell_command(f"hyprpicker -n -f {mode}") + text = raw if isinstance(raw, str) else "" + + color = self._sanitize_color_output(mode, text) + if not color: + logger.warning( + f"[ColorPicker] Failed to parse color from output: {text}" + ) + return + + # Copy to clipboard + self._copy_to_clipboard(color) + + # Generate preview and notify + self._send_notification(mode, color) + + except Exception as e: + logger.error(f"[ColorPicker] Error during pick: {e}") + + def _sanitize_color_output(self, mode: str, text: str) -> str: + """Extract color value from hyprpicker output.""" + joined = " ".join(ln.strip() for ln in text.splitlines() if ln.strip()) + + if mode == "hex": + matches = re.findall(r"#?[0-9A-Fa-f]{6,8}", joined) + if not matches: + return "" + val = matches[-1] + return val if val.startswith("#") else f"#{val}" + + if mode in ("rgb", "hsv"): + # Matches "r, g, b" or "h, s, v" + matches = re.findall( + r"\d+(?:\.\d+)?\s*,\s*\d+(?:\.\d+)?\s*,\s*\d+(?:\.\d+)?", joined + ) + return matches[-1] if matches else "" + + return "" + + def _copy_to_clipboard(self, text: str): + """Copy text to clipboard using wl-copy and cliphist.""" + try: + subprocess.run(["wl-copy"], input=text.encode(), check=True) + subprocess.run(["cliphist", "store"], input=text.encode(), check=True) + except subprocess.CalledProcessError: + pass + + def _send_notification(self, mode: str, color: str): + """Create a color preview icon and send a notification.""" + # CSS-like color specification for ImageMagick + color_spec = ( + color + if mode == "hex" + else (f"rgb({color})" if mode == "rgb" else f"hsv({color})") + ) + + # Cleanup existing tmp icon + if os.path.exists(self.tmp_icon_path): + try: + os.remove(self.tmp_icon_path) + except OSError: + pass + + # Use magick to create a 64x64 color block + # Then send notification and cleanup + cmd = ( + f"magick -size 64x64 xc:'{color_spec}' {self.tmp_icon_path} >/dev/null 2>&1; " + f"notify-send 'Color Picked' '{mode.upper()}: {color}' -i {self.tmp_icon_path} -a '{APP_NAME}' -e; " + f"rm -f {self.tmp_icon_path}" + ) + + exec_shell_command_async(["bash", "-lc", cmd]) diff --git a/src/window/launcher/plugins/emoji.py b/src/window/launcher/plugins/emoji.py new file mode 100644 index 00000000..9b0fa8bc --- /dev/null +++ b/src/window/launcher/plugins/emoji.py @@ -0,0 +1,291 @@ +import json +import subprocess +from collections import OrderedDict +from typing import Dict, List + +from fabric.utils import os, time, GLib, logger + +import shared.data as data +from window.launcher.plugin_base import PluginBase +from window.launcher.result import Result + + +class EmojiPlugin(PluginBase): + """ + Plugin for searching and copying emojis. + """ + + def __init__(self): + super().__init__() + self.name = "emoji" + self.display_name = "Emoji" + self.description = "Search and copy emojis" + self.emoji_data = {} + + # Language detection (default to en) + self.lang = os.getenv("LANG", "en").split("_")[0].split(".")[0] + if not self.lang: + self.lang = "en" + + # Paths + self.state_dir = os.path.join(GLib.get_user_state_dir(), data.APP_NAME) + self.emoji_path = os.path.join(self.state_dir, f"emojis_{self.lang}.json") + + # Use cache directory for recent emojis + self.recent_emoji_path = os.path.join(data.CACHE_DIR, "recent_emoji.json") + self.recent_emojis = OrderedDict() + self.max_recent_emojis = 20 + self._is_downloading = False + + def initialize(self): + """Initialize the emoji plugin.""" + self.set_triggers(["em"]) + self._load_recent_emojis() + + # Check if local data exists, if not trigger download + if not os.path.exists(self.emoji_path): + self._download_emoji_data() + else: + self._load_emoji_data() + + def cleanup(self): + """Cleanup the emoji plugin.""" + pass + + def _download_emoji_data(self, force=False): + """Download emoji annotations from CLDR repository.""" + if self._is_downloading and not force: + return + + os.makedirs(self.state_dir, exist_ok=True) + url = ( + "https://raw.githubusercontent.com/unicode-org/cldr-json/main/" + f"cldr-json/cldr-annotations-full/annotations/{self.lang}/annotations.json" + ) + + self._is_downloading = True + + def on_done(*args): + self._is_downloading = False + if os.path.exists(self.emoji_path): + self._load_emoji_data() + logger.info(f"[Emoji] Data updated for language: {self.lang}") + else: + logger.error(f"[Emoji] Download failed for URL: {url}") + + logger.info(f"[Emoji] Starting download: {url}") + from fabric.utils import exec_shell_command_async + + exec_shell_command_async(f"curl -L '{url}' -o '{self.emoji_path}'", on_done) + + def _load_emoji_data(self): + """Load emoji data from JSON file and parse CLDR structure.""" + try: + if os.path.exists(self.emoji_path): + with open(self.emoji_path, "r", encoding="utf-8") as f: + raw_data = json.load(f) + self.emoji_data = self._parse_cldr_json(raw_data) + else: + logger.warning(f"[Emoji] Local data missing: {self.emoji_path}") + except Exception as e: + logger.error(f"[Emoji] Error loading emoji data: {e}") + + def _parse_cldr_json(self, raw_data: Dict) -> Dict: + """Parse CLDR annotations JSON into a flatter format for searching.""" + try: + # Structure matches individual file: annotations -> annotations + annotations_container = raw_data.get("annotations", {}) + raw_annotations = annotations_container.get("annotations", {}) + + if not raw_annotations: + # Fallback to the nested structure just in case it's a full bundle + main = raw_data.get("main", {}) + lang_data = main.get(self.lang, main.get("en", {})) + annotations_container = lang_data.get("annotations", {}) + raw_annotations = annotations_container.get("annotations", {}) + + parsed = {} + for emoji, info in raw_annotations.items(): + # Extract TTL (name) + tts = info.get("tts", [""]) + name = tts[0] if isinstance(tts, list) else tts + + # Extract keywords + keywords = info.get("default", []) + if isinstance(keywords, str): + keywords = [keywords] + + parsed[emoji] = { + "name": name, + "keywords": keywords, + } + return parsed + except Exception as e: + logger.error(f"[Emoji] CLDR parsing error: {e}") + return {} + + def _load_recent_emojis(self): + """Load recently used emojis from JSON file.""" + try: + if os.path.exists(self.recent_emoji_path): + with open(self.recent_emoji_path, "r", encoding="utf-8") as f: + recent_data = json.load(f) + # Convert to OrderedDict to maintain order + self.recent_emojis = OrderedDict(recent_data) + else: + # Create empty recent emojis file + self.recent_emojis = OrderedDict() + self._save_recent_emojis() + except Exception as e: + print(f"Error loading recent emoji data: {e}") + self.recent_emojis = OrderedDict() + + def _save_recent_emojis(self): + """Save recently used emojis to JSON file.""" + try: + # Ensure the cache directory exists + os.makedirs(data.CACHE_DIR, exist_ok=True) + + with open(self.recent_emoji_path, "w", encoding="utf-8") as f: + json.dump(dict(self.recent_emojis), f, ensure_ascii=False, indent=2) + except Exception as e: + print(f"Error saving recent emoji data: {e}") + + def _add_to_recent(self, emoji: str): + """Add an emoji to the recent list.""" + # Remove if already exists (to move it to front) + if emoji in self.recent_emojis: + del self.recent_emojis[emoji] + + # Add to front with current timestamp + self.recent_emojis[emoji] = time.time() + + # Keep only the most recent emojis + while len(self.recent_emojis) > self.max_recent_emojis: + # Remove the oldest item + self.recent_emojis.popitem(last=False) + + # Save to file + self._save_recent_emojis() + + def _copy_to_clipboard(self, emoji: str): + """Copy emoji to clipboard and track usage.""" + try: + # Try Wayland first + try: + subprocess.run(["wl-copy"], input=emoji.encode(), check=True) + except subprocess.SubprocessError: + # Fall back to X11 + subprocess.run( + ["xclip", "-selection", "clipboard"], + input=emoji.encode(), + check=True, + ) + + # Track this emoji as recently used + self._add_to_recent(emoji) + + except Exception as e: + print(f"Failed to copy to clipboard: {e}") + + def query(self, query_string: str) -> List[Result]: + """Search emojis based on query.""" + results = [] + query = query_string.lower().strip() + + # Check if we need to download/reload data + if not self.emoji_data and not self._is_downloading: + if not os.path.exists(self.emoji_path): + self._download_emoji_data() + else: + self._load_emoji_data() + + # Handle update command + if query == "updatejson": + self._download_emoji_data(force=True) + return [ + Result( + title="Updating Emoji Database...", + subtitle=f"Downloading latest annotations for {self.lang}", + icon_name="view-refresh-symbolic", + relevance=1.0, + plugin_name=self.display_name, + ) + ] + + # If data is not yet loaded and not downloading, try loading it + if not self.emoji_data and not self._is_downloading: + self._load_emoji_data() + + # Show status if downloading + if self._is_downloading: + results.append( + Result( + title="Downloading Emoji Data...", + subtitle="Results will appear once finished", + icon_name="view-refresh-symbolic", + relevance=0.1, + plugin_name=self.display_name, + ) + ) + + # If no query, show recently used emojis + if not query: + if self.recent_emojis: + for emoji in reversed(list(self.recent_emojis.keys())): + if emoji in self.emoji_data: + info = self.emoji_data[emoji] + results.append(self._create_emoji_result(emoji, info, 1.0)) + return results + + # Search by name, keywords, or the emoji itself + for emoji, info in self.emoji_data.items(): + relevance = 0 + name = info.get("name", "").lower() + keywords = info.get("keywords", []) + + # Exact match with emoji + if query == emoji: + relevance = 1.0 + # Exact match with name + elif query == name: + relevance = 0.95 + # Starts with name + elif name.startswith(query): + relevance = 0.9 + # Contains name + elif query in name: + relevance = 0.8 + # In keywords + elif any(query == k.lower() for k in keywords): + relevance = 0.7 + elif any(query in k.lower() for k in keywords): + relevance = 0.6 + + if relevance > 0: + results.append(self._create_emoji_result(emoji, info, relevance)) + + # Sort by relevance and limit + results.sort(key=lambda x: x.relevance, reverse=True) + return results[:20] + + def _create_emoji_result(self, emoji: str, info: Dict, relevance: float) -> Result: + """Create a Result object for an emoji.""" + name = info.get("name", "").capitalize() + keywords = ", ".join(info.get("keywords", [])[:3]) + + is_recent = emoji in self.recent_emojis + subtitle = f"{keywords}" + (" โ€ข Recent" if is_recent else "") + + # Use larger font size for emojis to make them visible + icon_markup = f"<span size='xx-large'>{emoji}</span>" + + return Result( + title=name, + subtitle=subtitle, + icon_markup=icon_markup, + action=lambda e=emoji: self._copy_to_clipboard(e), + relevance=relevance, + plugin_name=self.display_name, + data={"emoji": emoji, "name": name, "recent": is_recent}, + ) diff --git a/modules/launcher/plugins/power.py b/src/window/launcher/plugins/power.py similarity index 97% rename from modules/launcher/plugins/power.py rename to src/window/launcher/plugins/power.py index 38b9cd86..d6c2dd55 100644 --- a/modules/launcher/plugins/power.py +++ b/src/window/launcher/plugins/power.py @@ -1,8 +1,9 @@ from typing import List from fabric.utils import exec_shell_command_async -from modules.launcher.plugin_base import PluginBase -from modules.launcher.result import Result + +from window.launcher.plugin_base import PluginBase +from window.launcher.result import Result class PowerPlugin(PluginBase): diff --git a/src/window/launcher/plugins/screencapture.py b/src/window/launcher/plugins/screencapture.py new file mode 100644 index 00000000..d746813e --- /dev/null +++ b/src/window/launcher/plugins/screencapture.py @@ -0,0 +1,442 @@ +from typing import List +from services.screencapture import screen_capture_service +from window.launcher.plugin_base import PluginBase +from window.launcher.result import Result + + +class ScreencapturePlugin(PluginBase): + """ + Plugin for taking screenshots and screen recordings using screen-capture.sh script. + """ + + def __init__(self): + super().__init__() + self.display_name = "Screencapture" + self.description = "Take screenshots and screen recordings" + + def initialize(self): + """Initialize the screencapture plugin.""" + self.set_triggers(["sc"]) + + def cleanup(self): + """Cleanup the screencapture plugin.""" + pass + + def get_commands(self): + """Return available commands for this plugin.""" + return { + # Screenshot commands + "screenshot": "Take a screenshot of the main display", + "screenshot-region": "Take a screenshot of selected region", + "screenshot-both": "Take a screenshot of both displays", + "screenshot-active": "Take a screenshot of the active display", + # Recording commands (with audio) + "record": "Start recording main display with audio", + "record-region": "Start recording selected region with audio", + "record-active": "Start recording active display with audio", + # Recording overrides + "record-noaudio": "Start recording main display without audio", + "record-hq": "Start high-quality recording (eDP-1)", + "record-gif": "Start GIF recording (eDP-1)", + # Control commands + "stop": "Stop current recording", + # Conversion commands + "convert-webm": "Convert latest MKV recording to WebM format", + "convert-iphone": "Convert latest recordings for iPhone", + "convert-youtube": "Convert latest recordings for YouTube", + "convert-gif": "Convert latest recordings to GIF", + # File conversion + "convert-file": "Convert specific file (Usage: convert-file [type] [path])", + } + + def _activate_default_plugins(self): + """No action needed for script cleanup.""" + pass + + def _is_recording(self): + """Check if recording is currently active.""" + return screen_capture_service.is_recording + + def _get_command_result(self, command: str) -> Result: + """Get a Result object for a specific command.""" + # Map aliases manually if needed + if command == "ss": + command = "screenshot" + if command == "rec": + command = "record" + if command == "conv": + command = "convert" + + command_info = { + # Screenshot commands + "screenshot": ( + "Take Screenshot (eDP-1)", + "Capture the main display", + "camera-photo-symbolic", + lambda: screen_capture_service.screenshot("eDP-1"), + ), + "screenshot-region": ( + "Take Region Screenshot", + "Capture a selected region", + "camera-photo-symbolic", + lambda: screen_capture_service.screenshot("region"), + ), + "screenshot-both": ( + "Take Screenshot (Both Displays)", + "Capture both displays combined", + "video-joined-displays-symbolic", + lambda: screen_capture_service.screenshot("both"), + ), + "screenshot-active": ( + "Take Screenshot (Active)", + "Capture the active display", + "camera-photo-symbolic", + lambda: screen_capture_service.screenshot("active"), + ), + # Recording commands (with audio) + "record": ( + "Start Recording (eDP-1)", + "Record the main display with audio", + "media-record-symbolic", + lambda: screen_capture_service.record("eDP-1"), + ), + "record-region": ( + "Start Region Recording", + "Record a selected region with audio", + "media-record-symbolic", + lambda: screen_capture_service.record("selection"), + ), + "record-active": ( + "Start Recording (Active)", + "Record the active display with audio", + "media-record-symbolic", + lambda: screen_capture_service.record("active"), + ), + # Recording overrides + "record-noaudio": ( + "Start Recording No Audio (eDP-1)", + "Record without audio", + "media-record-symbolic", + lambda: screen_capture_service.record("eDP-1", no_audio=True), + ), + "record-hq": ( + "Start HQ Recording (eDP-1)", + "High-quality recording for YouTube", + "media-record-symbolic", + lambda: screen_capture_service.record("eDP-1", mode="hq"), + ), + "record-gif": ( + "Start GIF Recording (eDP-1)", + "Record as optimized GIF", + "media-record-symbolic", + lambda: screen_capture_service.record("eDP-1", mode="gif"), + ), + # Control commands + "stop": ( + "Stop Recording", + "Stop the current screen recording", + "media-playback-stop-symbolic", + lambda: screen_capture_service.stop_recording(), + ), + # Conversion commands + "convert-webm": ( + "Convert Latest to WebM", + "Convert latest MKV recording to WebM format", + "video-x-generic-symbolic", + lambda: screen_capture_service.convert("webm"), + ), + "convert-iphone": ( + "Convert Latest for iPhone", + "Convert latest MKV recording for iPhone", + "video-x-generic-symbolic", + lambda: screen_capture_service.convert("iphone"), + ), + "convert-youtube": ( + "Convert Latest for YouTube", + "Convert latest recording for YouTube", + "video-x-generic-symbolic", + lambda: screen_capture_service.convert("youtube"), + ), + "convert-gif": ( + "Convert Latest to GIF", + "Convert latest recording to GIF", + "image-x-generic-symbolic", + lambda: screen_capture_service.convert("gif"), + ), + } + + if command in command_info: + title, subtitle, icon, action = command_info[command] + if action is not None: # Regular command + return Result( + title=title, + subtitle=subtitle, + icon_name=icon, + action=action, + relevance=1.0, + plugin_name=self.display_name, + ) + else: # File-based command, show instruction + return Result( + title=title, + subtitle=subtitle, + icon_name=icon, + action=lambda: None, # No action for instruction + relevance=1.0, + plugin_name=self.display_name, + ) + + return None + + def query(self, query_string: str) -> List[Result]: + """Search for screencapture actions based on query.""" + # Import here to avoid circular imports + + # Clean the query string + query = query_string.strip().lower() + + results = [] + + # Parse query for file-based commands + query_parts = query.split() + if len(query_parts) >= 3 and query_parts[0] == "convert-file": + format_type = query_parts[1] + file_param = " ".join(query_parts[2:]) + if format_type in ["webm", "iphone", "youtube", "gif"]: + return [ + Result( + title=f"Convert {file_param} to {format_type.upper()}", + subtitle=f"Convert specified file to {format_type} format", + icon_name=( + "video-x-generic-symbolic" + if format_type != "gif" + else "image-x-generic-symbolic" + ), + action=lambda fp=file_param, ft=format_type: ( + screen_capture_service.convert(ft, fp) + ), + relevance=1.0, + plugin_name=self.display_name, + ) + ] + + # Check if query matches a command and return it as a result + command_result = self._get_command_result(query) + if command_result: + return [command_result] + + # Check recording status + is_recording = self._is_recording() + + # If recording is active, show stop button first with highest relevance + if is_recording: + results.append( + Result( + title="Stop Recording", + subtitle="Stop the current screen recording", + icon_name="media-playback-stop-symbolic", + action=lambda: screen_capture_service.stop_recording(), + relevance=2.0, # Highest relevance to appear at top + plugin_name=self.display_name, + ) + ) + + # Screenshot actions + results.extend( + [ + Result( + title="Take Screenshot", + subtitle="Capture the entire screen (eDP-1)", + icon_name="camera-photo-symbolic", + action=lambda: screen_capture_service.screenshot("eDP-1"), + relevance=1.0, + plugin_name=self.display_name, + ), + Result( + title="Take Region Screenshot", + subtitle="Capture a selected region", + icon_name="camera-photo-symbolic", + action=lambda: screen_capture_service.screenshot("region"), + relevance=0.9, + plugin_name=self.display_name, + ), + Result( + title="Take Screenshot (Both Displays)", + subtitle="Capture both displays combined", + icon_name="video-joined-displays-symbolic", + action=lambda: screen_capture_service.screenshot("both"), + relevance=0.8, + plugin_name=self.display_name, + ), + Result( + title="Take Screenshot (HDMI-A-1)", + subtitle="Capture HDMI display", + icon_name="video-display-symbolic", + action=lambda: screen_capture_service.screenshot("HDMI-A-1"), + relevance=0.7, + plugin_name=self.display_name, + ), + ] + ) + + # Standard recording actions + results.extend( + [ + Result( + title="Start Recording (eDP-1)", + subtitle="Record the main display with audio", + icon_name="media-record-symbolic", + action=lambda: screen_capture_service.record("eDP-1"), + relevance=0.7, + plugin_name=self.display_name, + ), + Result( + title="Start Region Recording", + subtitle="Record a selected region", + icon_name="media-record-symbolic", + action=lambda: screen_capture_service.record("selection"), + relevance=0.6, + plugin_name=self.display_name, + ), + Result( + title="Start Recording (HDMI-A-1)", + subtitle="Record HDMI display with audio", + icon_name="media-record-symbolic", + action=lambda: screen_capture_service.record("HDMI-A-1"), + relevance=0.5, + plugin_name=self.display_name, + ), + ] + ) + + # No-audio recording actions + results.extend( + [ + Result( + title="Start Recording No Audio (eDP-1)", + subtitle="Record the main display without audio", + icon_name="media-record-symbolic", + action=lambda: screen_capture_service.record( + "eDP-1", no_audio=True + ), + relevance=0.65, + plugin_name=self.display_name, + ), + Result( + title="Start Region Recording No Audio", + subtitle="Record a selected region without audio", + icon_name="media-record-symbolic", + action=lambda: screen_capture_service.record( + "selection", no_audio=True + ), + relevance=0.55, + plugin_name=self.display_name, + ), + Result( + title="Start Recording No Audio (HDMI-A-1)", + subtitle="Record HDMI display without audio", + icon_name="media-record-symbolic", + action=lambda: screen_capture_service.record( + "HDMI-A-1", no_audio=True + ), + relevance=0.45, + plugin_name=self.display_name, + ), + ] + ) + + # High-quality recording actions + results.extend( + [ + Result( + title="Start HQ Recording (eDP-1)", + subtitle="High-quality recording for YouTube", + icon_name="media-record-symbolic", + action=lambda: screen_capture_service.record("eDP-1", mode="hq"), + relevance=0.4, + plugin_name=self.display_name, + ), + Result( + title="Start HQ Region Recording", + subtitle="High-quality region recording", + icon_name="media-record-symbolic", + action=lambda: screen_capture_service.record( + "selection", mode="hq" + ), + relevance=0.3, + plugin_name=self.display_name, + ), + Result( + title="Start HQ Recording (HDMI-A-1)", + subtitle="High-quality HDMI recording", + icon_name="media-record-symbolic", + action=lambda: screen_capture_service.record("HDMI-A-1", mode="hq"), + relevance=0.2, + plugin_name=self.display_name, + ), + ] + ) + + # GIF recording actions + results.extend( + [ + Result( + title="Start GIF Recording (eDP-1)", + subtitle="Record as optimized GIF", + icon_name="media-record-symbolic", + action=lambda: screen_capture_service.record("eDP-1", mode="gif"), + relevance=0.1, + plugin_name=self.display_name, + ), + Result( + title="Start GIF Region Recording", + subtitle="Record selected region as GIF", + icon_name="media-record-symbolic", + action=lambda: screen_capture_service.record( + "selection", mode="gif" + ), + relevance=0.05, + plugin_name=self.display_name, + ), + ] + ) + + # Conversion actions + results.extend( + [ + Result( + title="Convert Latest to WebM", + subtitle="Convert latest MKV recording to WebM format", + icon_name="video-x-generic-symbolic", + action=lambda: screen_capture_service.convert("webm"), + relevance=0.01, + plugin_name=self.display_name, + ), + Result( + title="Convert Latest for iPhone", + subtitle="Convert latest MKV recording for iPhone compatibility", + icon_name="video-x-generic-symbolic", + action=lambda: screen_capture_service.convert("iphone"), + relevance=0.01, + plugin_name=self.display_name, + ), + Result( + title="Convert Latest for YouTube", + subtitle="Convert latest recording for YouTube upload", + icon_name="video-x-generic-symbolic", + action=lambda: screen_capture_service.convert("youtube"), + relevance=0.01, + plugin_name=self.display_name, + ), + Result( + title="Convert Latest to GIF", + subtitle="Convert latest recording to GIF format", + icon_name="image-x-generic-symbolic", + action=lambda: screen_capture_service.convert("gif"), + relevance=0.01, + plugin_name=self.display_name, + ), + ] + ) + + return results diff --git a/modules/launcher/plugins/wallpaper.py b/src/window/launcher/plugins/wallpaper.py similarity index 97% rename from modules/launcher/plugins/wallpaper.py rename to src/window/launcher/plugins/wallpaper.py index 3d4aa28f..ce482dd3 100644 --- a/modules/launcher/plugins/wallpaper.py +++ b/src/window/launcher/plugins/wallpaper.py @@ -1,21 +1,23 @@ import colorsys import hashlib import json -import os -import random -import re import threading -import time from typing import Dict, List, Optional -from loguru import logger -from gi.repository import GdkPixbuf -from PIL import Image +from fabric.utils import ( + GdkPixbuf, + GLib, + exec_shell_command_async, + logger, + os, + random, + re, + time, +) -import config.data as data -from fabric.utils.helpers import exec_shell_command_async -from modules.launcher.plugin_base import PluginBase -from modules.launcher.result import Result +import shared.data as data +from window.launcher.plugin_base import PluginBase +from window.launcher.result import Result class WallpaperPlugin(PluginBase): @@ -124,7 +126,6 @@ def _clear_launcher_query(self): """Clear the launcher search query and reset to trigger.""" try: # Try to access the launcher through the fabric Application - from gi.repository import GLib from fabric import Application @@ -179,10 +180,10 @@ def _create_thumbnail(self, filename: str) -> str: if not os.path.exists(cache_path): try: - with Image.open(full_path) as img: - # Use faster thumbnail creation with smaller size for better performance - img.thumbnail((32, 32), Image.Resampling.LANCZOS) - img.save(cache_path, "PNG", optimize=True) + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( + full_path, 32, 32, True + ) + pixbuf.savev(cache_path, "png", [], []) except Exception as e: logger.error(f"Error creating thumbnail for {filename}: {e}") return None @@ -289,7 +290,7 @@ def _set_wallpaper(self, filename: str, scheme: str = None): # Always set the wallpaper image exec_shell_command_async( - f'swww img "{ + f'awww img "{ full_path }" -t outer --transition-duration 1.5 --transition-step 255 --transition-fps 60 -f Nearest' ) @@ -297,7 +298,9 @@ def _set_wallpaper(self, filename: str, scheme: str = None): # If Matugen is enabled, also apply the color scheme matugen_enabled = self._get_matugen_state() if matugen_enabled: - exec_shell_command_async(f'matugen image "{full_path}" -t {scheme}') + exec_shell_command_async( + f'matugen image "{full_path}" -t {scheme} --source-color-index 0' + ) def _set_random_wallpaper(self): """Set a random wallpaper.""" @@ -314,11 +317,11 @@ def _set_random_wallpaper(self): ) return filename - def _hsl_to_rgb_hex(self, h: float, s: float = 1.0, l: float = 0.5) -> str: + def _hsl_to_rgb_hex(self, h: float, s: float = 1.0, l_val: float = 0.5) -> str: """Convert HSL color value to RGB HEX string.""" # colorsys uses HLS, not HSL, and expects values between 0.0 and 1.0 hue = h / 360.0 - r, g, b = colorsys.hls_to_rgb(hue, l, s) # Note the order: H, L, S + r, g, b = colorsys.hls_to_rgb(hue, l_val, s) # Note the order: H, L, S r_int, g_int, b_int = int(r * 255), int(g * 255), int(b * 255) return f"#{r_int:02X}{g_int:02X}{b_int:02X}" @@ -374,7 +377,7 @@ def _set_current_scheme(self, scheme: str): if os.path.exists(wallpaper_path): # Apply the new scheme to current wallpaper exec_shell_command_async( - f'matugen image "{wallpaper_path}" -t {scheme}' + f'matugen image "{wallpaper_path}" -t {scheme} --source-color-index 0' ) # Send notification @@ -413,7 +416,9 @@ def _apply_hex_color(self, hex_color: str, scheme: str = None): if scheme is None: scheme = self._get_current_scheme() - exec_shell_command_async(f'matugen color hex "{hex_color}" -t {scheme}') + exec_shell_command_async( + f'matugen color hex "{hex_color}" -t {scheme} --source-color-index 0' + ) def _apply_hex_color_direct(self, hex_color: str, scheme: str = None): """Apply hex color using matugen (following example_wallpapers.py pattern).""" @@ -423,7 +428,9 @@ def _apply_hex_color_direct(self, hex_color: str, scheme: str = None): if scheme is None: scheme = self._get_current_scheme() - exec_shell_command_async(f'matugen color hex "{hex_color}" -t {scheme}') + exec_shell_command_async( + f'matugen color hex "{hex_color}" -t {scheme} --source-color-index 0' + ) # Send notification exec_shell_command_async( @@ -539,8 +546,8 @@ def query(self, query_string: str) -> List[Result]: status_text }", icon_name="color-picker-symbolic", - action=lambda c=hex_color, s=scheme: self._apply_hex_color_direct( - c, s + action=lambda c=hex_color, s=scheme: ( + self._apply_hex_color_direct(c, s) ), relevance=1.0, plugin_name=self.display_name, diff --git a/modules/launcher/plugins/websearch.py b/src/window/launcher/plugins/websearch.py similarity index 99% rename from modules/launcher/plugins/websearch.py rename to src/window/launcher/plugins/websearch.py index 80f992b8..d48b3582 100644 --- a/modules/launcher/plugins/websearch.py +++ b/src/window/launcher/plugins/websearch.py @@ -2,8 +2,8 @@ import urllib.parse from typing import List -from modules.launcher.plugin_base import PluginBase -from modules.launcher.result import Result +from window.launcher.plugin_base import PluginBase +from window.launcher.result import Result class WebSearchPlugin(PluginBase): diff --git a/modules/launcher/result.py b/src/window/launcher/result.py similarity index 97% rename from modules/launcher/result.py rename to src/window/launcher/result.py index fce35507..860225b0 100644 --- a/modules/launcher/result.py +++ b/src/window/launcher/result.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from typing import Any, Callable, Optional -from gi.repository import GdkPixbuf, Gtk +from fabric.utils import GdkPixbuf, Gtk @dataclass diff --git a/modules/launcher/result_item.py b/src/window/launcher/result_item.py similarity index 98% rename from modules/launcher/result_item.py rename to src/window/launcher/result_item.py index baaaa43c..8576f247 100644 --- a/modules/launcher/result_item.py +++ b/src/window/launcher/result_item.py @@ -1,12 +1,10 @@ -import gi from fabric.core.service import Signal from fabric.widgets.box import Box from fabric.widgets.eventbox import EventBox from fabric.widgets.image import Image from fabric.widgets.label import Label -from modules.launcher.result import Result -gi.require_version("Gtk", "3.0") +from window.launcher.result import Result class ResultItem(EventBox): diff --git a/modules/launcher/trigger_config.py b/src/window/launcher/trigger_config.py similarity index 96% rename from modules/launcher/trigger_config.py rename to src/window/launcher/trigger_config.py index 3a568c4d..bbf77e2b 100644 --- a/modules/launcher/trigger_config.py +++ b/src/window/launcher/trigger_config.py @@ -8,7 +8,7 @@ class TriggerConfig: def __init__(self, config_path: str = None): if config_path is None: - config_path = get_relative_path("../../config/assets/launcher.json") + config_path = get_relative_path("../../../config/launcher.json") self.config_path = config_path diff --git a/src/window/notification/__init__.py b/src/window/notification/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/src/window/notification/__init__.py @@ -0,0 +1 @@ + diff --git a/modules/notification/notification.py b/src/window/notification/notification.py similarity index 80% rename from modules/notification/notification.py rename to src/window/notification/notification.py index 77be7469..e5d6cab4 100644 --- a/modules/notification/notification.py +++ b/src/window/notification/notification.py @@ -1,113 +1,62 @@ -import os import hashlib import time -import uuid -from fabric.utils import get_relative_path -from gi.repository import Gdk, GdkPixbuf, GLib, Gtk # type: ignore -from loguru import logger - -import config.data as data -from .unified_cache import ( - get_unified_cache_key, - save_to_cache, - get_from_cache, - cleanup_cache, - get_fallback_icon, - ensure_cache_dir -) from fabric.notifications import ( Notification, NotificationAction, NotificationCloseReason, ) +from fabric.utils import Gdk, GdkPixbuf, GLib, Gtk, logger, os from fabric.widgets.box import Box from fabric.widgets.button import Button from fabric.widgets.centerbox import CenterBox from fabric.widgets.eventbox import EventBox from fabric.widgets.image import Image from fabric.widgets.label import Label -from utils.roam import modus_service -from utils.functions import escape_markup_text -from widgets.custom_image import CustomImage -from widgets.customrevealer import SlideRevealer -from widgets.wayland import WaylandWindow as Window +from fabric.widgets.wayland import WaylandWindow as Window + +import shared.data as data +from services.config import get_config, on_config_change from services.modus import notification_service +from shared.widgets.clipping_box import ClippingBox +from shared.widgets.custom_image import CustomImage +from shared.widgets.customrevealer import SlideRevealer +from utils.functions import escape_markup_text, parse_timeout_string +from utils.roam import modus_service -NOTIFICATION_WIDTH = 360 -NOTIFICATION_IMAGE_SIZE = 48 +# ruff: noqa: I001 +from window.notification.unified_cache import ( + UNIFIED_NOTIFICATION_CACHE_DIR as _SHARED_NOTIFICATION_CACHE_DIR, + cleanup_cache as _unified_cleanup_cache, + cleanup_old_cache_files as _unified_cleanup_old_cache_files, + ensure_cache_dir as _ensure_unified_cache_dir, + get_fallback_icon as _shared_fallback_icon, + get_from_cache as _unified_get_from_cache, + get_unified_cache_key, + save_to_cache as _unified_save_to_cache, +) NOTIFICATION_WIDTH = 360 NOTIFICATION_IMAGE_SIZE = 48 -# Unified notification cache directory (for both app icons and notification images) -UNIFIED_NOTIFICATION_CACHE_DIR = os.path.join(data.CACHE_DIR, "notifications") - -# Backward compatibility constants -NOTIFICATION_ICON_CACHE_DIR = UNIFIED_NOTIFICATION_CACHE_DIR -NOTIFICATION_IMAGE_CACHE_DIR = UNIFIED_NOTIFICATION_CACHE_DIR +# Use shared unified cache directory from unified_cache +NOTIFICATION_ICON_CACHE_DIR = _SHARED_NOTIFICATION_CACHE_DIR +NOTIFICATION_IMAGE_CACHE_DIR = _SHARED_NOTIFICATION_CACHE_DIR def ensure_notification_cache_dirs(): """Ensure unified notification cache directory exists""" - os.makedirs(UNIFIED_NOTIFICATION_CACHE_DIR, exist_ok=True) + _ensure_unified_cache_dir() def cleanup_old_cache_files(): """Clean up old notification cache files (older than 7 days)""" try: - if not os.path.exists(UNIFIED_NOTIFICATION_CACHE_DIR): - return - - current_time = time.time() - week_ago = current_time - (7 * 24 * 60 * 60) # 7 days - - for filename in os.listdir(UNIFIED_NOTIFICATION_CACHE_DIR): - filepath = os.path.join(UNIFIED_NOTIFICATION_CACHE_DIR, filename) - try: - if os.path.isfile(filepath): - file_mtime = os.path.getmtime(filepath) - if file_mtime < week_ago: - os.unlink(filepath) - logger.debug(f"Cleaned up old notification cache: {filename}") - except Exception as e: - logger.warning(f"Failed to cleanup cache file {filename}: {e}") + _unified_cleanup_old_cache_files() except Exception as e: logger.warning(f"Failed to cleanup notification cache: {e}") -def get_unified_cache_key(source_data, size=None, app_name=None): - """Generate a unified cache key that works for both app icons and notification images""" - try: - if hasattr(source_data, "get_pixels"): - # For pixbuf data - use hash of pixel data for deterministic caching - try: - pixel_data = source_data.get_pixels() - image_hash = hashlib.md5(pixel_data).hexdigest()[:8] - return image_hash - except Exception: - # Fallback to timestamp if pixel data fails - return str(int(time.time()))[:8] - elif isinstance(source_data, str): - # For file paths - create hash-based name - if source_data.startswith("file://"): - source_data = source_data[7:] - - # Create hash from file path and size - hash_input = source_data - if size: - hash_input += f"_{size[0]}x{size[1]}" - - return hashlib.md5(hash_input.encode()).hexdigest()[:8] - else: - # Fallback to timestamp - return str(int(time.time()))[:8] - except Exception: - # Ultimate fallback - return str(int(time.time()))[:8] - - -# Backward compatibility get_cache_key = get_unified_cache_key @@ -115,15 +64,11 @@ def save_pixbuf_to_cache(pixbuf, cache_key, cache_dir): """Save a pixbuf to the specified cache directory""" try: ensure_notification_cache_dirs() - cache_path = os.path.join(cache_dir, f"{cache_key}.png") - - # Don't overwrite existing cache - if os.path.exists(cache_path): - return cache_path - - pixbuf.savev(cache_path, "png", [], []) - logger.debug(f"Cached notification icon: {cache_key}") - return cache_path + # Delegate to unified cache; ignore returned key for compatibility + result = _unified_save_to_cache(pixbuf, cache_key) + if result and result[0]: + return result[0] + return None except Exception as e: logger.warning(f"Failed to cache notification icon: {e}") return None @@ -135,13 +80,9 @@ def get_cached_pixbuf(cache_key, fallback_size=(48, 48), cache_dir=None): cache_dir = NOTIFICATION_ICON_CACHE_DIR try: - cache_path = os.path.join(cache_dir, f"{cache_key}.png") - if os.path.exists(cache_path): - logger.debug(f"Using cached notification icon: {cache_key}") - logger.debug(f"Using cached notification icon: {cache_key}") - return GdkPixbuf.Pixbuf.new_from_file_at_scale( - cache_path, fallback_size[0], fallback_size[1], True - ) + pixbuf = _unified_get_from_cache(cache_key, fallback_size) + if pixbuf: + return pixbuf except Exception as e: logger.warning(f"Failed to load cached notification icon: {e}") return None @@ -220,26 +161,11 @@ def load_and_cache_local_icon(file_path, cache_key, size): def load_and_cache_theme_icon(icon_name, cache_key, size): """Load an icon from the current theme and cache it""" # For simplicity, just use fallback since theme icon loading is complex in GTK4 - logger.debug(f"Using fallback for theme icon {icon_name}") return get_fallback_notification_icon(size) def get_fallback_notification_icon(size=(48, 48)): - """Get the fallback notification icon""" - try: - fallback_path = get_relative_path("../../config/assets/icons/notification.png") - return GdkPixbuf.Pixbuf.new_from_file_at_scale( - fallback_path, size[0], size[1], True - ) - except Exception as e: - logger.warning(f"Failed to load fallback notification icon: {e}") - # Create a simple colored rectangle as ultimate fallback - try: - return GdkPixbuf.Pixbuf.new( - GdkPixbuf.Colorspace.RGB, True, 8, size[0], size[1] - ) - except: - return None + return _shared_fallback_icon(size) def get_notification_image_cache_key(notification_id, image_pixbuf): @@ -256,11 +182,13 @@ def get_notification_image_cache_key(notification_id, image_pixbuf): try: width = image_pixbuf.get_width() height = image_pixbuf.get_height() - dimension_hash = hashlib.md5(f"{width}x{height}".encode()).hexdigest()[:8] + dimension_hash = hashlib.md5( + f"{width}x{height}".encode() + ).hexdigest()[:8] return dimension_hash except Exception: pass - + # Fallback to timestamp for invalid pixbufs return str(int(time.time()))[:8] except Exception: @@ -275,23 +203,16 @@ def cache_notification_image(notification_id, image_pixbuf, size=(64, 64)): # Generate deterministic cache key based on image content cache_key = get_notification_image_cache_key(notification_id, image_pixbuf) - cache_path = os.path.join(NOTIFICATION_IMAGE_CACHE_DIR, f"{cache_key}.png") - - # Check if already cached to avoid redundant work - if os.path.exists(cache_path): - logger.debug(f"Image cache hit - already exists: {cache_key}") - return cache_path, cache_key - - # Try to scale and save the image try: - scaled_pixbuf = image_pixbuf.scale_simple( - size[0], size[1], GdkPixbuf.InterpType.BILINEAR - ) - scaled_pixbuf.savev(cache_path, "png", [], []) - logger.debug(f"Generated and cached notification image: {cache_key}") - return cache_path, cache_key + # Let unified cache handle scaling and saving + saved_path, _ = _unified_save_to_cache(image_pixbuf, cache_key, size) + if saved_path: + return saved_path, cache_key + return None, None except Exception as scale_error: - logger.debug(f"Failed to cache image (temp file likely gone): {scale_error}") + logger.debug( + f"Failed to cache image (temp file likely gone): {scale_error}" + ) return None, None except Exception as e: @@ -302,9 +223,7 @@ def cache_notification_image(notification_id, image_pixbuf, size=(64, 64)): def get_cached_notification_image(cache_key): """Get a cached notification image or return None if not found""" try: - cache_path = os.path.join(NOTIFICATION_IMAGE_CACHE_DIR, f"{cache_key}.png") - if os.path.exists(cache_path): - return GdkPixbuf.Pixbuf.new_from_file(cache_path) + return _unified_get_from_cache(cache_key) except Exception as e: logger.warning(f"Failed to load cached notification image: {e}") return None @@ -314,23 +233,7 @@ def cleanup_notification_image_cache(cache_key=None): """Clean up notification image cache - specific key or all""" try: ensure_notification_cache_dirs() - - if cache_key: - # Remove specific cached image - cache_path = os.path.join(NOTIFICATION_IMAGE_CACHE_DIR, f"{cache_key}.png") - if os.path.exists(cache_path): - os.unlink(cache_path) - logger.debug(f"Cleaned up cached notification image: {cache_key}") - else: - # Remove all cached images - for filename in os.listdir(NOTIFICATION_IMAGE_CACHE_DIR): - if filename.endswith(".png"): - filepath = os.path.join(NOTIFICATION_IMAGE_CACHE_DIR, filename) - try: - os.unlink(filepath) - logger.debug(f"Cleaned up cached notification image: {filename}") - except Exception as e: - logger.warning(f"Failed to cleanup cache file {filename}: {e}") + _unified_cleanup_cache(cache_key) except Exception as e: logger.warning(f"Failed to cleanup notification image cache: {e}") @@ -342,18 +245,13 @@ def cleanup_notification_specific_caches( try: # Clean up notification image cache if notification_image_cache_key: - cleanup_notification_image_cache(notification_image_cache_key) + _unified_cleanup_cache(notification_image_cache_key) # Clean up app icon cache for this specific source (only 35x35 version) if app_icon_source: # Only clean 35x35 version since we only cache this size now cache_key_35 = get_unified_cache_key(app_icon_source, (35, 35)) - cache_path_35 = os.path.join( - NOTIFICATION_ICON_CACHE_DIR, f"{cache_key_35}.png" - ) - if os.path.exists(cache_path_35): - os.unlink(cache_path_35) - logger.debug(f"Cleaned up cached app icon (35x35): {cache_key_35}") + _unified_cleanup_cache(cache_key_35) except Exception as e: logger.warning(f"Failed to cleanup notification specific caches: {e}") @@ -362,22 +260,7 @@ def cleanup_notification_specific_caches( def cleanup_all_notification_caches(): """Clean up ALL notification caches (icons and images)""" try: - # Clean icon cache - if os.path.exists(NOTIFICATION_ICON_CACHE_DIR): - for filename in os.listdir(NOTIFICATION_ICON_CACHE_DIR): - if filename.endswith(".png"): - filepath = os.path.join(NOTIFICATION_ICON_CACHE_DIR, filename) - try: - os.unlink(filepath) - logger.debug(f"Cleaned up cached notification icon: {filename}") - except Exception as e: - logger.warning( - f"Failed to cleanup icon cache file {filename}: {e}" - ) - - # Clean image cache - cleanup_notification_image_cache() - logger.info("Cleaned up all notification caches") + _unified_cleanup_cache() except Exception as e: logger.warning(f"Failed to cleanup all notification caches: {e}") @@ -400,28 +283,18 @@ def verify_cache_persistence(): if f.endswith(".png") ] - logger.info( - f"Cache persistence check: {len(icon_cache_files)} icons, { - len(image_cache_files) - } images cached" - ) - # Test loading a few cached items to verify they work for cache_file in icon_cache_files[:2]: # Test first 2 icon files try: cache_path = os.path.join(NOTIFICATION_ICON_CACHE_DIR, cache_file) - test_pixbuf = GdkPixbuf.Pixbuf.new_from_file(cache_path) - if test_pixbuf: - logger.debug(f"Successfully verified cached icon: {cache_file}") + GdkPixbuf.Pixbuf.new_from_file(cache_path) except Exception as e: logger.warning(f"Failed to load cached icon {cache_file}: {e}") for cache_file in image_cache_files[:2]: # Test first 2 image files try: cache_path = os.path.join(NOTIFICATION_IMAGE_CACHE_DIR, cache_file) - test_pixbuf = GdkPixbuf.Pixbuf.new_from_file(cache_path) - if test_pixbuf: - logger.debug(f"Successfully verified cached image: {cache_file}") + GdkPixbuf.Pixbuf.new_from_file(cache_path) except Exception as e: logger.warning(f"Failed to load cached image {cache_file}: {e}") @@ -452,11 +325,7 @@ def migrate_persistent_notifications(): if hasattr(notification, "app_icon") and notification.app_icon: cache_notification_icon(notification.app_icon, (35, 35)) migrated_count += 1 - logger.debug( - f"Migrated notification for { - notification.app_name - } to use cached app icon" - ) + except Exception as cache_error: logger.debug( f"Failed to cache app icon for {notification.app_name}: { @@ -464,11 +333,6 @@ def migrate_persistent_notifications(): }" ) - if migrated_count > 0: - logger.info( - f"Migrated {migrated_count} persistent notifications to use cached assets" - ) - except Exception as e: logger.warning(f"Failed to migrate persistent notifications: {e}") @@ -502,7 +366,9 @@ def preload_notification_assets(notification): # Cache notification image if available if hasattr(notification, "image_pixbuf") and notification.image_pixbuf: - cache_notification_image(notification.id, notification.image_pixbuf, (35, 35)) + cache_notification_image( + notification.id, notification.image_pixbuf, (35, 35) + ) except Exception as e: logger.warning(f"Failed to preload notification assets: {e}") @@ -551,11 +417,15 @@ class NotificationWidget(Box): def __init__( self, notification: Notification, - timeout_ms=data.NOTIFICATION_TIMEOUT, + timeout_ms=None, show_close_button=True, name="notification", **kwargs, ): + # Get current timeout from config manager if not provided + if timeout_ms is None: + timeout_ms = self._get_current_notification_timeout() + self.show_close_button = show_close_button self.close_button = None self._is_hovered = False @@ -600,22 +470,28 @@ def create_header(self, notification): header_icon_pixbuf = cached_app_icon_pixbuf.scale_simple( 24, 24, GdkPixbuf.InterpType.BILINEAR ) - app_icon = CustomImage(pixbuf=header_icon_pixbuf) - app_icon.set_name("notification-icon") + app_icon = ClippingBox( + name="notification-icon", + children=Image(pixbuf=header_icon_pixbuf), + ) else: # Fallback to theme icon if caching fails completely - app_icon = Image( + app_icon = ClippingBox( name="notification-icon", - icon_name="notifications", - icon_size=24, + children=Image( + icon_name="notifications", + icon_size=24, + ), ) except Exception as e: logger.warning(f"Failed to load cached header icon: {e}") # Ultimate fallback - app_icon = Image( + app_icon = ClippingBox( name="notification-icon", - icon_name="notifications", - icon_size=24, + children=Image( + icon_name="notifications", + icon_size=24, + ), ) return CenterBox( @@ -643,11 +519,9 @@ def create_content(self, notification): name="notification-content", spacing=8, children=[ - Box( + ClippingBox( name="notification-image", - children=CustomImage( - pixbuf=self._get_notification_pixbuf(notification) - ), + children=Image(pixbuf=self._get_notification_pixbuf(notification)), ), Box( name="notification-text", @@ -660,7 +534,9 @@ def create_content(self, notification): children=[ Label( name="notification-summary", - markup=escape_markup_text(notification.summary.replace("\n", " ")), + markup=escape_markup_text( + notification.summary.replace("\n", " ") + ), h_align="start", max_chars_width=40, ellipsization="end", @@ -675,7 +551,9 @@ def create_content(self, notification): ), ( Label( - markup=escape_markup_text(notification.body.replace("\n", " ")), + markup=escape_markup_text( + notification.body.replace("\n", " ") + ), h_align="start", max_chars_width=45, ellipsization="end", @@ -734,7 +612,6 @@ def get_pixbuf(self, icon_path, width, height): icon_path = icon_path[7:] if not os.path.exists(icon_path): - logger.warning(f"Icon path does not exist: {icon_path}") return get_fallback_notification_icon((width, height)) try: @@ -755,7 +632,9 @@ def _get_notification_pixbuf(self, notification): # Try to get cached notification image first if hasattr(notification, "image_pixbuf") and notification.image_pixbuf: try: - cache_key = get_notification_image_cache_key(notification_id, notification.image_pixbuf) + cache_key = get_notification_image_cache_key( + notification_id, notification.image_pixbuf + ) cached_image = get_cached_notification_image(cache_key) if cached_image: return cached_image @@ -825,9 +704,8 @@ def destroy(self): self, "notification_image_cache_key", None ), ) - logger.debug(f"Cleaned up caches for manually dismissed notification") else: - logger.debug(f"Preserved caches for timeout/auto-dismissed notification") + logger.debug("Preserved caches for timeout/auto-dismissed notification") super().destroy() # @staticmethod @@ -845,6 +723,20 @@ def unhover_button(self, button): # Don't resume timeout here since the notification itself might still be hovered self.set_pointer_cursor(button, "arrow") + @staticmethod + def _get_current_notification_timeout(): + """Get the current notification timeout from config manager.""" + try: + timeout_str = get_config( + "notification_timeout", data.NOTIFICATION_TIMEOUT_STR + ) + from shared.data import parse_timeout_string + + return parse_timeout_string(timeout_str) + except Exception as e: + logger.warning(f"Failed to get notification timeout from config: {e}") + return data.NOTIFICATION_TIMEOUT + class NotificationRevealer(SlideRevealer): def __init__( @@ -879,6 +771,7 @@ def __init__( # Animation state self._animation_in_progress = False self._spring_timer_id = None + self._anim_timeout_id = None self._css_provider = None # Wrap notification in EventBox for swipe detection @@ -899,8 +792,8 @@ def __init__( smooth_revealer_animation(self) - # Connect our own handler that manages the slide animation - self.notification.connect("closed", self.on_resolved) + # Connect our own handler that manages the slide animation - track ID for cleanup + self._closed_handler_id = self.notification.connect("closed", self.on_resolved) self._animation_in_progress = True @@ -928,8 +821,6 @@ def _apply_transform(self, offset_x, opacity, scale): if style_context: # Use CSS provider for smooth transforms if not hasattr(self, "_css_provider") or not self._css_provider: - from gi.repository import Gtk - self._css_provider = Gtk.CssProvider() style_context.add_provider( self._css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION @@ -972,7 +863,7 @@ def animate_step(): self.notif_box._should_cleanup_cache = True try: self.notification.close("dismissed-by-user") - except: + except Exception: pass return False @@ -1034,12 +925,37 @@ def on_resolved( self.hide() # Consistent timing for smooth transitions timeout_duration = self.duration + 50 - GLib.timeout_add(timeout_duration, lambda: self._on_animation_complete(True)) + self._anim_timeout_id = GLib.timeout_add( + timeout_duration, lambda: self._on_animation_complete(True) + ) def destroy(self): # Clean up CSS provider and timers if self._spring_timer_id: GLib.source_remove(self._spring_timer_id) + self._spring_timer_id = None + if self._anim_timeout_id: + GLib.source_remove(self._anim_timeout_id) + self._anim_timeout_id = None + + # Disconnect notification signal + if hasattr(self, "_closed_handler_id") and self._closed_handler_id: + try: + self.notification.disconnect(self._closed_handler_id) + except Exception: + pass + self._closed_handler_id = 0 + + # Clean up CSS provider from style context + if self._css_provider: + try: + style_context = self.notif_box.get_style_context() + if style_context: + style_context.remove_provider(self._css_provider) + except Exception: + pass + self._css_provider = None + super().destroy() @@ -1052,6 +968,17 @@ class NotificationState: class ModusNoti(Window): def __init__(self): + # Local config state mirroring services.config usage + self._current_config = { + "notification_timeout": data.NOTIFICATION_TIMEOUT_STR, + "notification_ignored_apps": data.NOTIFICATION_IGNORED_APPS_HISTORY, + "notification_limited_apps_history": data.NOTIFICATION_LIMITED_APPS_HISTORY, + } + + # Subscribe to config changes and apply initial config + on_config_change(self._on_config_changed) + self._apply_initial_config() + self._server = notification_service self.notifications = Box( @@ -1076,7 +1003,8 @@ def __init__(self): self.DEBOUNCE_DELAY = 50 # Prevent rapid fire notifications self._server.connect("notification-added", self.on_new_notification) - super().__init__( + Window.__init__( + self, anchor="top right", child=self.notifications, layer="overlay", @@ -1088,7 +1016,7 @@ def __init__(self): def on_new_notification(self, fabric_notif, id): notification: Notification = fabric_notif.get_notification_from_id(id) - + # Check if notification still exists (might have been removed already) if not notification: return @@ -1097,11 +1025,15 @@ def on_new_notification(self, fabric_notif, id): # Notification is already cached by the service, just don't show popup return + # Check if notification should be ignored based on current config + if self._should_ignore_notification(notification): + return + # Preload assets immediately for optimal caching and display performance preload_notification_assets(notification) # Implement smart queue management for smooth transitions - current_time = GLib.get_monotonic_time() / 1000 + GLib.get_monotonic_time() / 1000 # If queue is getting full, remove oldest notifications smoothly if len(self.notification_queue) >= self.MAX_QUEUE_SIZE: @@ -1110,7 +1042,7 @@ def on_new_notification(self, fabric_notif, id): oldest = self.notification_queue.pop(0) try: oldest.close("dismissed-by-user") - except: + except Exception: pass # Add new notification to queue @@ -1161,7 +1093,7 @@ def _start_smooth_transition(self): # Force close current notification with smooth animation try: self.current_notification.notification.close("expired") - except: + except Exception: pass def _show_next_notification(self): @@ -1172,14 +1104,14 @@ def _show_next_notification(self): return notification = self.notification_queue.pop(0) - + # Check if notification is still valid (might have been removed) - if not notification or not hasattr(notification, 'app_icon'): + if not notification or not hasattr(notification, "app_icon"): # Skip invalid notifications and try next one if self.notification_queue: self._show_next_notification() return - + self.notification_state = NotificationState.SHOWING new_box = NotificationRevealer( @@ -1194,7 +1126,7 @@ def _show_next_notification(self): for child in list(self.notifications.children): try: self.notifications.remove(child) - except: + except Exception: pass self.notifications.children = [new_box] @@ -1226,7 +1158,7 @@ def _on_notification_finished(self, notification_box): try: if notification_box in self.notifications.children: self.notifications.remove(notification_box) - except: + except Exception: pass # Reset state @@ -1259,7 +1191,7 @@ def clear_notification_queue(self): for notification in list(self.notification_queue): try: notification.close("dismissed-by-user") - except: + except Exception: pass self.notification_queue.clear() @@ -1279,3 +1211,80 @@ def clear_notification_queue(self): def get_queue_length(self): return len(self.notification_queue) + + def _should_ignore_notification(self, notification): + """Check if notification should be ignored based on current config.""" + try: + ignored_apps = self._current_config.get("notification_ignored_apps", []) + app_name = getattr(notification, "app_name", "") + + # Check if app name is in ignored list + if app_name in ignored_apps: + return True + + return False + except Exception as e: + logger.warning(f"Failed to check if notification should be ignored: {e}") + return False + + def update_config(self, new_config): + """Update notification configuration dynamically.""" + try: + # Handle notification timeout changes + if "notification_timeout" in new_config: + timeout_str = new_config["notification_timeout"] + # Parse the timeout string to milliseconds + + parse_timeout_string(timeout_str) + + # Handle ignored apps changes + if "notification_ignored_apps" in new_config: + new_config["notification_ignored_apps"] + + # Handle limited apps history changes + if "notification_limited_apps_history" in new_config: + new_config["notification_limited_apps_history"] + + # Update the current config + self._current_config.update(new_config) + + except Exception as e: + logger.error(f"[ModusNoti] Failed to update config: {e}") + + def _apply_initial_config(self): + try: + timeout_val = get_config( + "notification_timeout", data.NOTIFICATION_TIMEOUT_STR + ) + ignored_apps = get_config( + "notification_ignored_apps", data.NOTIFICATION_IGNORED_APPS_HISTORY + ) + limited_apps = get_config( + "notification_limited_apps_history", + data.NOTIFICATION_LIMITED_APPS_HISTORY, + ) + initial = { + "notification_timeout": timeout_val, + "notification_ignored_apps": ignored_apps, + "notification_limited_apps_history": limited_apps, + } + # Only apply if different + self.update_config(initial) + except Exception as e: + logger.error(f"[ModusNoti] Failed to apply initial config: {e}") + + def _on_config_changed(self, new_config: dict, old_config: dict): + try: + changes = {} + for key in ( + "notification_timeout", + "notification_ignored_apps", + "notification_limited_apps_history", + ): + if key in new_config and new_config.get(key) != old_config.get(key): + changes[key] = new_config.get(key) + + if changes: + self.update_config(changes) + except Exception as e: + logger.error(f"[ModusNoti] Error handling config change: {e}") diff --git a/modules/notification/notification_center.py b/src/window/notification/notification_center.py similarity index 89% rename from modules/notification/notification_center.py rename to src/window/notification/notification_center.py index 3aa77b08..e5193899 100644 --- a/modules/notification/notification_center.py +++ b/src/window/notification/notification_center.py @@ -1,32 +1,35 @@ from collections import defaultdict -import time +from fabric.utils import GLib, logger from fabric.widgets.box import Box from fabric.widgets.button import Button from fabric.widgets.centerbox import CenterBox from fabric.widgets.eventbox import EventBox +from fabric.widgets.image import Image from fabric.widgets.label import Label from fabric.widgets.revealer import Revealer from fabric.widgets.scrolledwindow import ScrolledWindow -from gi.repository import GLib, GdkPixbuf -from loguru import logger +from fabric.widgets.wayland import WaylandWindow as Window -from modules.notification.notification import ( +import shared.data as data +from services.modus import notification_service +from shared.widgets.clipping_box import ClippingBox +from shared.widgets.custom_image import CustomImage +from utils.functions import escape_markup_text +from window.notification.notification import ( NotificationWidget, cache_notification_icon, - cache_notification_image, - get_cached_notification_image, - get_notification_image_cache_key, - preload_notification_assets, cleanup_all_notification_caches, cleanup_notification_specific_caches, - get_fallback_notification_icon, + get_cached_notification_image, + preload_notification_assets, +) +from window.notification.unified_cache import ( + get_fallback_icon as get_fallback_notification_icon, +) +from window.notification.unified_cache import ( + get_from_cache, ) -from services.modus import notification_service -from utils.functions import escape_markup_text -from widgets.custom_image import CustomImage -from widgets.wayland import WaylandWindow as Window -from config import data class ExpandableNotificationGroup(Box): @@ -95,9 +98,9 @@ def create_collapsed_state(self): name="stack-main-notification", spacing=8, children=[ - Box( + ClippingBox( name="notification-image", - children=CustomImage( + children=Image( pixbuf=self._get_notification_pixbuf_for_group( latest_notification ) @@ -123,9 +126,11 @@ def create_collapsed_state(self): ), Label( name="notification-body", - markup=escape_markup_text(latest_notification._notification.summary.replace( - "\n", " " - )), + markup=escape_markup_text( + latest_notification._notification.summary.replace( + "\n", " " + ) + ), max_chars_width=25, h_align="start", ellipsization="end", @@ -142,8 +147,10 @@ def create_collapsed_state(self): icon_name="close-symbolic", icon_size=18 ), visible=True, - on_clicked=lambda *_: self._close_single_notification_and_stop_propagation( - latest_notification + on_clicked=lambda *_: ( + self._close_single_notification_and_stop_propagation( + latest_notification + ) ), ), Label( @@ -166,7 +173,7 @@ def create_collapsed_state(self): def _get_notification_pixbuf_for_group(self, cached_notification): """Get notification pixbuf using cached image key - fallback to app icon""" notification = cached_notification._notification - notification_id = getattr(notification, "id", None) + getattr(notification, "id", None) # First try to get cached notification image using stored cache key if ( @@ -182,9 +189,6 @@ def _get_notification_pixbuf_for_group(self, cached_notification): notification_image_cache_key ) if cached_image: - logger.debug( - f"Using cached notification image: {notification_image_cache_key}" - ) return cached_image except Exception as e: logger.debug(f"Failed to load cached notification image: {e}") @@ -199,11 +203,8 @@ def _get_notification_pixbuf_for_group(self, cached_notification): ) if app_icon_cache_key: try: - from modules.notification.unified_cache import get_from_cache - cached_app_icon = get_from_cache(app_icon_cache_key, (35, 35)) if cached_app_icon: - # logger.debug(f"Using cached app icon: {app_icon_cache_key}") return cached_app_icon except Exception as e: logger.debug(f"Failed to load cached app icon: {e}") @@ -214,15 +215,11 @@ def _get_notification_pixbuf_for_group(self, cached_notification): if app_icon_source: cached_app_icon = cache_notification_icon(app_icon_source, (35, 35)) if cached_app_icon: - logger.debug( - f"Using directly cached app icon for: {app_icon_source}" - ) return cached_app_icon except Exception as e: logger.debug(f"Failed to get directly cached app icon: {e}") # Ultimate fallback - logger.debug("Using fallback notification icon") return get_fallback_notification_icon((35, 35)) def create_expanded_state(self): @@ -333,7 +330,6 @@ def expand(self, *args): # Small delay then animate notifications sliding down GLib.timeout_add(50, lambda: self.notifications_revealer.set_reveal_child(True)) - logger.debug(f"Expanded notification group: {self.app_name}") def collapse(self, *args): """Collapse with header sliding up, notifications crossfading, then sliding up""" @@ -351,8 +347,6 @@ def collapse(self, *args): 260, lambda: self.notifications_revealer.set_reveal_child(False) ) - logger.debug(f"Collapsed notification group: {self.app_name}") - def _show_collapsed_midway(self): """Show collapsed state and hide expanded container to prevent deformation""" self.collapsed_eventbox.set_visible(True) @@ -372,10 +366,6 @@ def close_all(self, *args): cache_metadata = getattr(notification, "cache_metadata", {}) # Clean up caches using stored metadata - from modules.notification.notification import ( - cleanup_notification_specific_caches, - ) - cleanup_notification_specific_caches( app_icon_source=notification._notification.app_icon, notification_image_cache_key=cache_metadata.get( @@ -383,10 +373,6 @@ def close_all(self, *args): ), ) - logger.debug( - f"Cleaned up caches for notification ID: {notification._notification.id}" - ) - notification_service.remove_cached_notification(notification.cache_id) except Exception as e: logger.error( @@ -400,10 +386,6 @@ def _close_single_notification(self, notification): cache_metadata = getattr(notification, "cache_metadata", {}) # Clean up caches using stored metadata - from modules.notification.notification import ( - cleanup_notification_specific_caches, - ) - cleanup_notification_specific_caches( app_icon_source=notification._notification.app_icon, notification_image_cache_key=cache_metadata.get( @@ -411,12 +393,7 @@ def _close_single_notification(self, notification): ), ) - logger.debug( - f"Cleaned up caches for notification ID: {notification._notification.id}" - ) - notification_service.remove_cached_notification(notification.cache_id) - logger.debug(f"Closed single notification: {notification.cache_id}") except Exception as e: logger.error( f"Error removing single notification {notification.cache_id}: {e}" @@ -463,7 +440,7 @@ def __init__(self, notification, **kwargs): def _get_notification_pixbuf(self, notification): """Get notification pixbuf using cached image key - fallback to app icon""" - notification_id = getattr(notification, "id", None) + getattr(notification, "id", None) # First try to get cached notification image using stored cache key if self.cache_metadata: @@ -476,9 +453,6 @@ def _get_notification_pixbuf(self, notification): notification_image_cache_key ) if cached_image: - logger.debug( - f"Using cached notification image: {notification_image_cache_key}" - ) return cached_image except Exception as e: logger.debug(f"Failed to load cached notification image: {e}") @@ -488,11 +462,8 @@ def _get_notification_pixbuf(self, notification): app_icon_cache_key = self.cache_metadata.get("app_icon_cache_key") if app_icon_cache_key: try: - from modules.notification.unified_cache import get_from_cache - cached_app_icon = get_from_cache(app_icon_cache_key, (35, 35)) if cached_app_icon: - # logger.debug(f"Using cached app icon: {app_icon_cache_key}") return cached_app_icon except Exception as e: logger.debug(f"Failed to load cached app icon: {e}") @@ -503,15 +474,11 @@ def _get_notification_pixbuf(self, notification): if app_icon_source: cached_app_icon = cache_notification_icon(app_icon_source, (35, 35)) if cached_app_icon: - logger.debug( - f"Using directly cached app icon for: {app_icon_source}" - ) return cached_app_icon except Exception as e: logger.debug(f"Failed to get directly cached app icon: {e}") # Ultimate fallback - logger.debug("Using fallback notification icon") return get_fallback_notification_icon((35, 35)) def create_content(self, notification): @@ -537,11 +504,9 @@ def create_content(self, notification): name="notification-content", spacing=8, children=[ - Box( + ClippingBox( name="notification-image", - children=CustomImage( - pixbuf=self._get_notification_pixbuf(notification) - ), + children=Image(pixbuf=self._get_notification_pixbuf(notification)), ), Box( name="notification-text", @@ -554,7 +519,9 @@ def create_content(self, notification): children=[ Label( name="notification-summary", - markup=escape_markup_text(notification.summary.replace("\n", " ")), + markup=escape_markup_text( + notification.summary.replace("\n", " ") + ), h_align="start", max_chars_width=25, ellipsization="end", @@ -563,7 +530,9 @@ def create_content(self, notification): ), ( Label( - markup=escape_markup_text(notification.body.replace("\n", " ")), + markup=escape_markup_text( + notification.body.replace("\n", " ") + ), h_align="start", max_chars_width=35, ellipsization="end", @@ -598,10 +567,6 @@ def _on_close_clicked(self, *args): cache_metadata = self.cache_metadata # Clean up caches using stored metadata - from modules.notification.notification import ( - cleanup_notification_specific_caches, - ) - cleanup_notification_specific_caches( app_icon_source=self.notification.app_icon, notification_image_cache_key=cache_metadata.get( @@ -609,10 +574,6 @@ def _on_close_clicked(self, *args): ), ) - logger.debug( - f"Cleaned up caches for notification center ID: {self.notification.id}" - ) - notification_service.remove_cached_notification(self.notification_id) except Exception as e: logger.error(f"Error removing notification {self.notification_id}: {e}") @@ -700,7 +661,6 @@ def __init__(self): self._rebuild_notification_groups() self.add_keybinding("Escape", self._on_escape_pressed) - self.connect("destroy", self._on_destroy) def _rebuild_notification_groups(self): """Rebuild notification groups from scratch with enhanced asset preloading and debugging""" @@ -716,7 +676,7 @@ def _rebuild_notification_groups(self): rebuild_count = 0 for cached_notification in notification_service.cached_notifications: app_name = cached_notification._notification.app_name - notification_id = getattr(cached_notification._notification, "id", None) + getattr(cached_notification._notification, "id", None) # Skip ignored apps during rebuild if app_name in data.NOTIFICATION_IGNORED_APPS_HISTORY: @@ -728,12 +688,6 @@ def _rebuild_notification_groups(self): self.notification_groups[app_name].append(cached_notification) rebuild_count += 1 - logger.info( - f"Rebuilt {rebuild_count} notifications into { - len(self.notification_groups) - } groups" - ) - # Create group widgets and handle limited apps for app_name, notifications in self.notification_groups.items(): # Sort notifications by ID (highest ID first - newest notifications) @@ -813,7 +767,6 @@ def on_notification_added(self, service, cached_notification): self.notifications_box.pack_start(group_widget, False, False, 0) group_widget.show_all() - logger.debug(f"Added notification to group {app_name}") except Exception as e: logger.error(f"Error adding notification to group: {e}") @@ -864,7 +817,6 @@ def on_notification_removed(self, service, cached_notification): ), ) - logger.debug(f"Removed notification from group {app_name}") except Exception as e: logger.error(f"Error removing notification from group: {e}") @@ -878,7 +830,6 @@ def on_clear_all(self, service): cleanup_all_notification_caches() for child in self.notifications_box.get_children(): child.destroy() - logger.debug("Cleared all notification groups and remaining cached images") except Exception as e: logger.error(f"Error clearing notification groups: {e}") @@ -916,8 +867,3 @@ def _set_mousecapture(self, visible): self.main_revealer.set_reveal_child(True) else: self.main_revealer.set_reveal_child(False) - logger.debug(f"Notification center visibility set to: {visible}") - - def _on_destroy(self, *_): - # Signals will be automatically disconnected when the object is destroyed - pass diff --git a/modules/notification/unified_cache.py b/src/window/notification/unified_cache.py similarity index 88% rename from modules/notification/unified_cache.py rename to src/window/notification/unified_cache.py index 0bd673b3..d690d894 100644 --- a/modules/notification/unified_cache.py +++ b/src/window/notification/unified_cache.py @@ -1,13 +1,10 @@ -import os import hashlib import time import uuid -from fabric.utils import get_relative_path -from gi.repository import GdkPixbuf -from loguru import logger +from fabric.utils import GdkPixbuf, get_relative_path, logger, os -import config.data as data +import shared.data as data # Unified notification cache directory (for both app icons and notification images) UNIFIED_NOTIFICATION_CACHE_DIR = os.path.join(data.CACHE_DIR, "notifications") @@ -67,7 +64,6 @@ def save_to_cache(pixbuf, cache_key, size=None): ) pixbuf.savev(cache_path, "png", [], []) - logger.debug(f"Cached notification asset: {cache_key}") return cache_path, cache_key except Exception as e: logger.warning(f"Failed to cache notification asset: {e}") @@ -103,7 +99,6 @@ def cleanup_cache(cache_key=None): ) if os.path.exists(cache_path): os.unlink(cache_path) - logger.debug(f"Cleaned up cached asset: {cache_key}") else: # Remove all cached assets for filename in os.listdir(UNIFIED_NOTIFICATION_CACHE_DIR): @@ -111,7 +106,6 @@ def cleanup_cache(cache_key=None): filepath = os.path.join(UNIFIED_NOTIFICATION_CACHE_DIR, filename) try: os.unlink(filepath) - logger.debug(f"Cleaned up cached asset: {filename}") except Exception as e: logger.warning(f"Failed to cleanup cache file {filename}: {e}") except Exception as e: @@ -134,7 +128,6 @@ def cleanup_old_cache_files(): file_mtime = os.path.getmtime(filepath) if file_mtime < week_ago: os.unlink(filepath) - logger.debug(f"Cleaned up old cache: {filename}") except Exception as e: logger.warning(f"Failed to cleanup cache file {filename}: {e}") except Exception as e: @@ -153,15 +146,11 @@ def verify_cache_persistence(): if f.endswith(".png") ] - logger.info(f"Cache persistence check: {len(cache_files)} assets cached") - # Test loading a few cached items to verify they work for cache_file in cache_files[:2]: # Test first 2 files try: cache_path = os.path.join(UNIFIED_NOTIFICATION_CACHE_DIR, cache_file) - test_pixbuf = GdkPixbuf.Pixbuf.new_from_file(cache_path) - if test_pixbuf: - logger.debug(f"Successfully verified cached asset: {cache_file}") + GdkPixbuf.Pixbuf.new_from_file(cache_path) except Exception as e: logger.warning(f"Failed to load cached asset {cache_file}: {e}") @@ -175,7 +164,7 @@ def verify_cache_persistence(): def get_fallback_icon(size=(48, 48)): """Get the fallback notification icon""" try: - fallback_path = get_relative_path("../../config/assets/icons/notification.png") + fallback_path = get_relative_path("../../assets/icons/notification.png") return GdkPixbuf.Pixbuf.new_from_file_at_scale( fallback_path, size[0], size[1], True ) @@ -186,7 +175,7 @@ def get_fallback_icon(size=(48, 48)): return GdkPixbuf.Pixbuf.new( GdkPixbuf.Colorspace.RGB, True, 8, size[0], size[1] ) - except: + except Exception: return None @@ -194,4 +183,3 @@ def get_fallback_icon(size=(48, 48)): ensure_cache_dir() cleanup_old_cache_files() verify_cache_persistence() - diff --git a/src/window/osd/__init__.py b/src/window/osd/__init__.py new file mode 100644 index 00000000..d65876c7 --- /dev/null +++ b/src/window/osd/__init__.py @@ -0,0 +1,8 @@ +from .main import OSDWindow # noqa: F401 +from .components import ( # noqa: F401 + AudioOSDContainer, + BrightnessOSDContainer, + MicrophoneOSDContainer, + CapsLockOSDContainer, + KeyboardLayoutOSDContainer, +) diff --git a/src/window/osd/components/__init__.py b/src/window/osd/components/__init__.py new file mode 100644 index 00000000..0a7ca48f --- /dev/null +++ b/src/window/osd/components/__init__.py @@ -0,0 +1,5 @@ +from .audio import AudioOSDContainer # noqa: F401 +from .brightness import BrightnessOSDContainer # noqa: F401 +from .microphone import MicrophoneOSDContainer # noqa: F401 +from .capslock import CapsLockOSDContainer # noqa: F401 +from .keyboard_layout import KeyboardLayoutOSDContainer # noqa: F401 diff --git a/src/window/osd/components/animated_scale.py b/src/window/osd/components/animated_scale.py new file mode 100644 index 00000000..46b8868d --- /dev/null +++ b/src/window/osd/components/animated_scale.py @@ -0,0 +1,30 @@ +from functools import partial + +from fabric.widgets.scale import Scale + +from shared.widgets.animator import Animator, cubic_bezier + + +class AnimatedScale(Scale): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.animator = ( + Animator( + duration=0.8, + timing_function=partial(cubic_bezier, 0.34, 1.56, 0.64, 1.0), + min_value=self.min_value, + max_value=self.value, + tick_widget=self, + notify_value=lambda anim: self.set_value(anim.value), + ) + .build() + .play() + .unwrap() + ) + + def animate_value(self, value: float): + if self.animator: + self.animator.pause() + self.animator.min_value = self.value + self.animator.max_value = value + self.animator.play() diff --git a/src/window/osd/components/audio.py b/src/window/osd/components/audio.py new file mode 100644 index 00000000..049ac127 --- /dev/null +++ b/src/window/osd/components/audio.py @@ -0,0 +1,110 @@ +import math +from fabric.audio import Audio +from utils.utils import svg_file +from fabric.widgets.scale import ScaleMark +from .animated_scale import AnimatedScale +from .base import BaseOSDContainer + + +class AudioOSDContainer(BaseOSDContainer): + def __init__(self, window, **kwargs): + super().__init__(window, **kwargs) + self.audio = Audio() + self._last_volume = None + self._last_muted = None + self._setup_specific_components() + self._connect_specific_signals() + + def _setup_specific_components(self): + self.osd_window_image = svg_file( + "volume/audio-volume.svg", + size=(100, 100), + name="osd-image", + h_align="center", + v_align="center", + h_expand=True, + v_expand=True, + ) + self.scale = AnimatedScale( + marks=(ScaleMark(value=i) for i in range(1, 100, 10)), + value=70, + min_value=0, + max_value=100, + increments=(1, 1), + orientation="h", + ) + self.add(self.osd_window_image) + self.add(self.scale) + + def _connect_specific_signals(self): + self.audio.connect("notify::speaker", self._on_speaker_changed) + if self.audio.speaker: + self._connect_speaker_signals() + + def _connect_speaker_signals(self): + if self.audio.speaker: + self.audio.speaker.connect("changed", self._on_speaker_stream_changed) + self._sync_with_audio() + + def _on_speaker_changed(self, *_): + self._connect_speaker_signals() + self.update() + + def _on_speaker_stream_changed(self, *_): + if self.audio.speaker: + current_volume = round(self.audio.speaker.volume) + current_muted = self.audio.speaker.muted + if self._last_volume != current_volume or self._last_muted != current_muted: + self._last_volume = current_volume + self._last_muted = current_muted + self.update() + + def _sync_with_audio(self): + if self.audio.speaker: + self._last_volume = round(self.audio.speaker.volume) + self._last_muted = self.audio.speaker.muted + self.scale.set_value(self._last_volume) + self._update_display() + + def _update_display(self): + if not self.audio.speaker: + return + + volume = ( + self._last_volume + if self._last_volume is not None + else round(self.audio.speaker.volume) + ) + muted = ( + self._last_muted + if self._last_muted is not None + else self.audio.speaker.muted + ) + + display_volume = 0 if (volume == 0 or muted) else volume + level = ( + 0 if display_volume == 0 else min(int(math.ceil(display_volume / 33)), 3) + ) + + self.osd_window_image.set_from_file(f"volume/audio-volume-{level}.svg") + + if muted or volume == 0: + self.scale.add_style_class("muted") + else: + self.scale.remove_style_class("muted") + + self.scale.animate_value(volume) + + def update(self, *_): + self._update_display() + super().update() + + def destroy(self): + """Disconnect signals from Audio service""" + try: + self.audio.disconnect_by_func(self._on_speaker_changed) + if self.audio.speaker: + self.audio.speaker.disconnect_by_func(self._on_speaker_stream_changed) + except Exception: + pass + super().destroy() diff --git a/src/window/osd/components/base.py b/src/window/osd/components/base.py new file mode 100644 index 00000000..20034ebe --- /dev/null +++ b/src/window/osd/components/base.py @@ -0,0 +1,149 @@ +from abc import abstractmethod +from fabric.utils import Gdk, GLib, invoke_repeater, remove_handler, time +from fabric.widgets.box import Box +from fabric.widgets.wayland import WaylandWindow as Window + + +class BaseOSDContainer(Box): + MAX_VISIBLE_TIME = 6.0 + COOLDOWN_MS = 200 + + def __init__(self, window: Window, **kwargs): + super().__init__(**kwargs, orientation="v", spacing=12, name="osd-container") + self.last_handler: int = 0 + self.window = window + self.osd = None + self.show_timestamp: float = 0.0 + self.watchdog_handler: int = 0 + self._update_in_progress = False + self._is_hovered = False + self._last_trigger_time: float = 0.0 + + GLib.idle_add(self._setup_window_signals) + + def _setup_window_signals(self): + self.window.add_events( + Gdk.EventMask.ENTER_NOTIFY_MASK | Gdk.EventMask.LEAVE_NOTIFY_MASK + ) + self.window.connect("enter-notify-event", self._on_enter_notify) + self.window.connect("leave-notify-event", self._on_leave_notify) + return False + + def destroy(self): + self.cleanup_all_handlers() + super().destroy() + + def _on_enter_notify(self, widget, event): + if event.detail != Gdk.NotifyType.INFERIOR: + self._is_hovered = True + + def _on_leave_notify(self, widget, event): + if event.detail != Gdk.NotifyType.INFERIOR: + self._is_hovered = False + + def is_hovered(self): + return self._is_hovered + + def remove_last_handler(self): + if self.last_handler: + try: + remove_handler(self.last_handler) + except Exception: + pass + self.last_handler = 0 + + def remove_watchdog_handler(self): + if self.watchdog_handler: + try: + GLib.source_remove(self.watchdog_handler) + except Exception: + pass + self.watchdog_handler = 0 + + def cleanup_all_handlers(self): + self.remove_last_handler() + self.remove_watchdog_handler() + self.show_timestamp = 0.0 + self._update_in_progress = False + + def hide_window(self): + if self.osd and hasattr(self.osd, "revealer"): + self.osd.revealer.set_reveal_child(False) + GLib.timeout_add(150, lambda: self.window.hide() or False) + else: + self.window.hide() + + def watchdog_force_hide(self, *_): + if not self.window.get_visible(): + self.cleanup_all_handlers() + return False + + elapsed = time.time() - self.show_timestamp + if elapsed >= self.MAX_VISIBLE_TIME: + if not self.is_hovered(): + self.hide_window() + self.cleanup_all_handlers() + return False + else: + return True + return True + + def update(self, *_): + if self._update_in_progress: + return + + current_time = time.time() + time_since_last_trigger = (current_time - self._last_trigger_time) * 1000 + + if time_since_last_trigger < self.COOLDOWN_MS: + return + + self._last_trigger_time = current_time + self._update_in_progress = True + + self.cleanup_all_handlers() + self.show_timestamp = time.time() + + if self.osd: + self.osd.show_container(self) + + self.window.show() + + self.focus() + self.last_handler = invoke_repeater(1700, self.unfocus, initial_call=False) + self.watchdog_handler = GLib.timeout_add(1000, self.watchdog_force_hide) + self._update_in_progress = False + + def focus(self, *_): + self.add_style_class("focused") + return False + + def unfocus(self, *_): + if not self.window.get_visible(): + self.cleanup_all_handlers() + return False + + self.remove_style_class("focused") + self.remove_last_handler() + self.last_handler = invoke_repeater(1700, self.unpop, initial_call=False) + return False + + def unpop(self, *_): + if not self.window.get_visible(): + self.cleanup_all_handlers() + return False + + if not self.is_hovered(): + self.hide_window() + self.cleanup_all_handlers() + else: + self.last_handler = invoke_repeater(500, self.unpop, initial_call=False) + return False + + @abstractmethod + def _setup_specific_components(self): + pass + + @abstractmethod + def _connect_specific_signals(self): + pass diff --git a/src/window/osd/components/brightness.py b/src/window/osd/components/brightness.py new file mode 100644 index 00000000..8499d723 --- /dev/null +++ b/src/window/osd/components/brightness.py @@ -0,0 +1,69 @@ +import math +from services.brightness import Brightness +from utils.utils import svg_file +from fabric.widgets.scale import ScaleMark +from .animated_scale import AnimatedScale +from .base import BaseOSDContainer + + +class BrightnessOSDContainer(BaseOSDContainer): + def __init__(self, window, **kwargs): + super().__init__(window, **kwargs) + self.brightness_service = Brightness.get_initial() + self._setup_specific_components() + self._connect_specific_signals() + + def _setup_specific_components(self): + self.osd_window_image = svg_file( + "brightness/brightness.svg", + size=(100, 100), + name="osd-image", + h_align="center", + v_align="center", + h_expand=True, + v_expand=True, + ) + self.scale = AnimatedScale( + marks=(ScaleMark(value=i) for i in range(0, 101, 10)), + value=70, + min_value=0, + max_value=100, + increments=(1, 1), + orientation="h", + ) + self.add(self.osd_window_image) + self.add(self.scale) + + def _connect_specific_signals(self): + self.brightness_service.connect("screen", self._on_screen_brightness_changed) + + def _on_screen_brightness_changed(self, _sender, value, *_args): + self.update() + + def _get_normalized_brightness(self): + return ( + self.brightness_service.screen_brightness + / self.brightness_service.max_screen + ) * 100 + + def _update_display(self): + normalized = self._get_normalized_brightness() + level = 0 if normalized == 0 else min(int(math.ceil(normalized / 33)), 3) + + self.osd_window_image.set_from_file(f"brightness/brightness-{level}.svg") + + self.scale.animate_value(normalized) + + def update(self, *_): + self._update_display() + super().update() + + def destroy(self): + """Disconnect signals from Brightness service""" + try: + self.brightness_service.disconnect_by_func( + self._on_screen_brightness_changed + ) + except Exception: + pass + super().destroy() diff --git a/src/window/osd/components/capslock.py b/src/window/osd/components/capslock.py new file mode 100644 index 00000000..febea10a --- /dev/null +++ b/src/window/osd/components/capslock.py @@ -0,0 +1,55 @@ +from fabric.widgets.label import Label +from utils.utils import svg_file +from .base import BaseOSDContainer +from services.capslock import CapsLock + + +class CapsLockOSDContainer(BaseOSDContainer): + def __init__(self, window, **kwargs): + super().__init__(window, **kwargs) + self.capslock = CapsLock.get_initial() + self._setup_specific_components() + self._connect_specific_signals() + + def _setup_specific_components(self): + self.osd_image = svg_file( + "misc/caps-lock.svg", + size=(100, 100), + name="osd-image", + h_align="center", + v_align="center", + h_expand=True, + v_expand=True, + ) + self.label = Label(name="osd-label") + self.add(self.osd_image) + self._update_display(self.capslock.is_on) + + def _connect_specific_signals(self): + self.capslock.connect("state_changed", self._on_caps_lock_state_changed) + + def _on_caps_lock_state_changed(self, _, is_on: bool): + self._update_display(is_on) + self.update() + + def _update_display(self, is_on: bool): + icon_path = "caps-lock.svg" if is_on else "caps-lock-off.svg" + self.osd_image.set_from_file(f"misc/{icon_path}") + if is_on: + self.add_style_class("capslock-on") + self.remove_style_class("capslock-off") + else: + self.add_style_class("capslock-off") + self.remove_style_class("capslock-on") + + def update(self, *_): + # We don't need a separate display update here as it's handled in the signal + super().update() + + def destroy(self): + """Disconnect signals from CapsLock service""" + try: + self.capslock.disconnect_by_func(self._on_caps_lock_state_changed) + except Exception: + pass + super().destroy() diff --git a/src/window/osd/components/keyboard_layout.py b/src/window/osd/components/keyboard_layout.py new file mode 100644 index 00000000..b4f9b8e7 --- /dev/null +++ b/src/window/osd/components/keyboard_layout.py @@ -0,0 +1,47 @@ +from fabric.widgets.label import Label +from utils.utils import svg_file +from .base import BaseOSDContainer +from services.keyboard_layout import KeyboardLayout + + +class KeyboardLayoutOSDContainer(BaseOSDContainer): + def __init__(self, window, **kwargs): + super().__init__(window, **kwargs) + self.keyboard_layout = KeyboardLayout.get_initial() + self._setup_specific_components() + self._connect_specific_signals() + + def _setup_specific_components(self): + self.osd_image = svg_file( + "misc/keyboard-layout.svg", + size=(100, 100), + name="osd-image", + h_align="center", + v_align="center", + h_expand=True, + v_expand=True, + ) + self.label = Label(name="osd-label") + self.add(self.osd_image) + self.add(self.label) + self.label.set_text(self.keyboard_layout.current_layout) + + def _connect_specific_signals(self): + self.keyboard_layout.connect("layout_changed", self._on_layout_changed) + + def _on_layout_changed(self, service, layout: str): + if not self.is_hovered(): + self.label.set_text(layout) + self.update() + + def update(self, *_): + self.label.set_text(self.keyboard_layout.current_layout) + super().update() + + def destroy(self): + """Disconnect signals from KeyboardLayout service""" + try: + self.keyboard_layout.disconnect_by_func(self._on_layout_changed) + except Exception: + pass + super().destroy() diff --git a/src/window/osd/components/microphone.py b/src/window/osd/components/microphone.py new file mode 100644 index 00000000..461d6d92 --- /dev/null +++ b/src/window/osd/components/microphone.py @@ -0,0 +1,112 @@ +import math +from fabric.audio import Audio +from utils.utils import svg_file +from fabric.widgets.scale import ScaleMark +from .animated_scale import AnimatedScale +from .base import BaseOSDContainer + + +class MicrophoneOSDContainer(BaseOSDContainer): + def __init__(self, window, **kwargs): + super().__init__(window, **kwargs) + self.audio = Audio() + self._last_volume = None + self._last_muted = None + self._setup_specific_components() + self._connect_specific_signals() + + def _setup_specific_components(self): + self.osd_window_image = svg_file( + "mic/microphone.svg", + size=(100, 100), + name="osd-image", + h_align="center", + v_align="center", + h_expand=True, + v_expand=True, + ) + self.scale = AnimatedScale( + marks=(ScaleMark(value=i) for i in range(1, 100, 10)), + value=70, + min_value=0, + max_value=100, + increments=(1, 1), + orientation="h", + ) + self.add(self.osd_window_image) + self.add(self.scale) + + def _connect_specific_signals(self): + self.audio.connect("notify::microphone", self._on_microphone_changed) + if self.audio.microphone: + self._connect_microphone_signals() + + def _connect_microphone_signals(self): + if self.audio.microphone: + self.audio.microphone.connect("changed", self._on_microphone_stream_changed) + self._sync_with_audio() + + def _on_microphone_changed(self, *_): + self._connect_microphone_signals() + self.update() + + def _on_microphone_stream_changed(self, *_): + if self.audio.microphone: + current_volume = round(self.audio.microphone.volume) + current_muted = self.audio.microphone.muted + if self._last_volume != current_volume or self._last_muted != current_muted: + self._last_volume = current_volume + self._last_muted = current_muted + self.update() + + def _sync_with_audio(self): + if self.audio.microphone: + self._last_volume = round(self.audio.microphone.volume) + self._last_muted = self.audio.microphone.muted + self.scale.set_value(self._last_volume) + self._update_display() + + def _update_display(self): + if not self.audio.microphone: + return + + volume = ( + self._last_volume + if self._last_volume is not None + else round(self.audio.microphone.volume) + ) + muted = ( + self._last_muted + if self._last_muted is not None + else self.audio.microphone.muted + ) + + display_volume = 0 if (volume == 0 or muted) else volume + level = ( + 0 if display_volume == 0 else min(int(math.ceil(display_volume / 33)), 3) + ) + + self.osd_window_image.set_from_file(f"mic/microphone-{level}.svg") + + if muted or volume == 0: + self.scale.add_style_class("muted") + else: + self.scale.remove_style_class("muted") + + self.scale.animate_value(volume) + + def update(self, *_): + self._update_display() + super().update() + + def destroy(self): + """Disconnect signals from Audio service""" + try: + self.audio.disconnect_by_func(self._on_microphone_changed) + if self.audio.microphone: + self.audio.microphone.disconnect_by_func( + self._on_microphone_stream_changed + ) + except Exception: + pass + super().destroy() diff --git a/src/window/osd/main.py b/src/window/osd/main.py new file mode 100644 index 00000000..9c1fa4b3 --- /dev/null +++ b/src/window/osd/main.py @@ -0,0 +1,83 @@ +from fabric.widgets.box import Box +from fabric.widgets.revealer import Revealer +from fabric.widgets.wayland import WaylandWindow as Window +from .components import ( + AudioOSDContainer, + BrightnessOSDContainer, + MicrophoneOSDContainer, + CapsLockOSDContainer, + KeyboardLayoutOSDContainer, +) + + +class OSD(Box): + def __init__(self, window: Window, **kwargs): + super().__init__(name="osd", **kwargs) + self.window = window + + self.revealer = Revealer( + transition_type="slide-up", + transition_duration=100, + child_revealed=False, + ) + self.children = [self.revealer] + + self.containers = { + "audio": AudioOSDContainer(window), + "brightness": BrightnessOSDContainer(window), + "microphone": MicrophoneOSDContainer(window), + "capslock": CapsLockOSDContainer(window), + "kb_layout": KeyboardLayoutOSDContainer(window), + } + + # Set the OSD reference in containers + for container in self.containers.values(): + container.osd = self + + self.current_container = None + + def show_container(self, container): + if self.current_container != container: + self.revealer.children = [container] + self.current_container = container + self.revealer.set_reveal_child(True) + + def show_audio_osd(self): + self.show_container(self.containers.get("audio")) + + def show_brightness_osd(self): + self.show_container(self.containers.get("brightness")) + + def show_microphone_osd(self): + self.show_container(self.containers.get("microphone")) + + def show_capslock_osd(self): + self.show_container(self.containers.get("capslock")) + + def show_kb_layout_osd(self): + self.show_container(self.containers.get("kb_layout")) + + +class OSDWindow(Window): + def __init__(self, **kwargs): + super().__init__( + name="osd-window", + title="modus-osd", + anchor="bottom", + margin="0px 0px 40px 0px", + visible=False, + all_visible=False, + pass_through=True, + layer="overlay", + **kwargs, + ) + self.osd = OSD(window=self) + self.add(self.osd) + + +if __name__ == "__main__": + from fabric import Application + + osd_window = OSDWindow() + app = Application("modus-osd", osd_window) + app.run() diff --git a/src/window/panel/components/__init__.py b/src/window/panel/components/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/window/panel/components/enhanced_system_tray.py b/src/window/panel/components/enhanced_system_tray.py new file mode 100644 index 00000000..f542e39c --- /dev/null +++ b/src/window/panel/components/enhanced_system_tray.py @@ -0,0 +1,106 @@ +from pathlib import Path +import weakref + +from fabric.system_tray.widgets import SystemTrayItem +from fabric.utils import GdkPixbuf, GLib, logger + +from services.config import get_config, on_config_change + +# Track active tray items for hot-reloading +_tracked_items = weakref.WeakSet() + + +def should_hide(item) -> bool: + """Check if a tray item should be hidden based on config.""" + ignore_list = get_config("systray_ignore", []) + if not ignore_list: + return False + + title = item.title if item.title else "" + identifier = item.identifier if item.identifier else "" + + # Check both title and identifier (case-insensitive for convenience) + for pattern in ignore_list: + p = pattern.lower() + if p in title.lower() or p in identifier.lower(): + return True + return False + + +def patched_do_update_properties(self, *_): + # Determine the icon name to use + item = self._item + icon_name = item.icon_name + attention_icon_name = item.attention_icon_name + + # Log item info for user debugging + logger.info( + f"[SysTray] Item: title='{getattr(item, 'title', '')}', identifier='{getattr(item, 'identifier', '')}'" + ) + + # Visibility check + is_hidden = should_hide(item) + self.set_visible(not is_hidden) + + if is_hidden: + return + + if item.status == "NeedsAttention" and attention_icon_name: + preferred_icon_name = attention_icon_name + else: + preferred_icon_name = icon_name + + # 1. Try the standard fabric/GTK implementation first + pixbuf = item.get_preferred_icon_pixbuf(self._icon_size) + + # 2. If standard lookup fails, try your specific absolute path logic + if pixbuf is None and preferred_icon_name: + path = Path(preferred_icon_name) + if path.is_absolute() and path.exists(): + try: + target_size = self._icon_size if self._icon_size else 24 + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( + str(path), + target_size, + target_size, + True, # Preserve aspect ratio + ) + except GLib.GError: + pixbuf = None + + # Apply the resulting pixbuf or the fallback "missing" icon + if pixbuf: + self._image.set_from_pixbuf(pixbuf) + else: + self._image.set_from_icon_name("image-missing", self._icon_size) + + tooltip = item.tooltip + self.set_tooltip_markup( + tooltip.description + or tooltip.title + or (item.title.title() if item.title else None) + or "Unknown" + ) + + +def apply_enhanced_system_tray(): + # Patch __init__ to track instances + original_init = SystemTrayItem.__init__ + + def patched_init(self, *args, **kwargs): + original_init(self, *args, **kwargs) + _tracked_items.add(self) + + SystemTrayItem.__init__ = patched_init + + # Replace the method on the class so all tray items use this logic + SystemTrayItem.do_update_properties = patched_do_update_properties + + # Setup hot-reload listener + def on_reload(new_config, old_config): + if new_config.get("systray_ignore") != old_config.get("systray_ignore"): + logger.info("[SysTray] Config changed, updating tray item visibility") + for item_widget in _tracked_items: + item_widget.do_update_properties() + + on_config_change(on_reload) diff --git a/src/window/panel/components/globalmenu.py b/src/window/panel/components/globalmenu.py new file mode 100644 index 00000000..b0f0cd87 --- /dev/null +++ b/src/window/panel/components/globalmenu.py @@ -0,0 +1,372 @@ +from fabric.hyprland.widgets import HyprlandActiveWindow as ActiveWindow +from fabric.utils import FormattedString, exec_shell_command_async +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.centerbox import CenterBox +from fabric.widgets.label import Label + +from shared.dialogs.about import AboutApp, get_about_window +from shared.window.dropdown import ModusDropdown, dropdown_divider, dropdowns +from shared.window.mousecapture import DropDownMouseCapture +from utils.app_name_resolver import format_window +from utils.roam import modus_service +from utils.utils import setup_cursor_hover +from window.settings.main import get_settings_window + + +def has_active_window(): + return ( + modus_service.current_active_app_name + and modus_service.current_active_app_name != "Finder" + ) + + +def create_menu_button(label, on_clicked=None): + button_kwargs = { + "label": label, + "name": "global-menu", + } + + if on_clicked is not None: + button_kwargs["on_clicked"] = on_clicked + + button = Button(**button_kwargs) + setup_cursor_hover(button, "pointer") + return button + + +def create_dropdown_with_capture(dropdown_id, parent, dropdown_children, layer="top"): + dropdown = ModusDropdown( + dropdown_id=dropdown_id, + parent=parent, + dropdown_children=dropdown_children, + ) + mouse_capture = DropDownMouseCapture(layer=layer, child_window=dropdown) + return mouse_capture + + +def manage_button_style_classes(buttons, active_button=None, style_class="active"): + for button in buttons: + if button: # Check if button exists (some might be None) + button.remove_style_class(style_class) + + if active_button: + active_button.add_style_class(style_class) + + +def show_about_app(_=None): + if not has_active_window(): + return + + app_name = modus_service.current_active_app_name + wmclass = getattr(modus_service, "current_active_wm_class", "") + about_window = AboutApp(app_name=app_name, wmclass=wmclass) + about_window.toggle(None) + + +def on_click_subthread(button, on_clicked, on_click): + if on_clicked: + on_clicked(button) + else: + exec_shell_command_async(on_click, lambda *_: None) + + for dropdown in dropdowns: + if dropdown.is_visible(): + dropdown.hide_via_mousecapture() + break + + +def dropdown_option( + label: str = "", + keybind: str = "", + on_click='echo "ModusPanelDropdown Action"', + on_clicked=None, +): + btn = Button( + child=CenterBox( + start_children=[ + Label(label=label, h_align="start", name="dropdown-option-label"), + ], + end_children=[ + Label(label=keybind, h_align="end", name="dropdown-option-keybind") + ], + orientation="horizontal", + h_align="fill", + h_expand=True, + v_expand=True, + ), + name="dropdown-option", + h_align="fill", + on_clicked=lambda button: on_click_subthread(button, on_clicked, on_click), + h_expand=True, + v_expand=True, + ) + setup_cursor_hover(btn, "pointer") + return btn + + +class SystemDropdown(ModusDropdown): + def __init__(self, parent, **kwargs): + super().__init__( + dropdown_id="os-menu", + parent=parent, + dropdown_children=[ + dropdown_option( + "About this PC", on_clicked=lambda _: get_about_window().toggle() + ), + dropdown_divider("---------------------"), + dropdown_option( + "System Settings...", + on_clicked=lambda _: get_settings_window().toggle(), + ), + dropdown_divider("---------------------"), + dropdown_option("Force Quit", "", "hyprctl kill"), + dropdown_divider("---------------------"), + dropdown_option("Sleep", "", "systemctl suspend"), + dropdown_option("Restart", "", "systemctl reboot"), + dropdown_option("Shut Down", "", "shutdown now"), + dropdown_divider("---------------------"), + dropdown_option("Lock Screen", "๓ฐ˜ณ L", "hyprlock"), + ], + **kwargs, + ) + + +class GlobalMenuDropdowns: + def __init__(self, parent): + self.parent = parent + + self.system_dropdown = SystemDropdown(parent=parent) + self.menu_button_dropdown = DropDownMouseCapture( + layer="top", child_window=self.system_dropdown + ) + self.menu_button = Button( + label="Modus", + name="global-menu", + on_clicked=lambda _: self.menu_button_dropdown.toggle_mousecapture(), + ) + self.menu_button_dropdown.child_window.set_pointing_to(self.menu_button) + + self.global_title_menu_about = dropdown_option( + f"About {modus_service.current_active_app_name}", + on_clicked=show_about_app, + ) + self.global_menu_title = create_dropdown_with_capture( + "global-menu-title", + parent, + [self.global_title_menu_about], + ) + + self.global_menu_view = create_dropdown_with_capture( + "global-menu-view", + parent, + [ + dropdown_option( + "Enter Full Screen", + on_click="hyprctl dispatch fullscreen", + ), + ], + ) + self.global_menu_window = create_dropdown_with_capture( + "global-menu-window", + parent, + [ + dropdown_option( + "Zoom In", + "๓ฐ‰ +", + on_click="hyprctl -q keyword cursor:zoom_factor $(hyprctl getoption cursor:zoom_factor -j | jq '.float * 1.1')", + ), + dropdown_option( + "Zoom Out", + "๓ฐ‰ -", + on_click="hyprctl -q keyword cursor:zoom_factor $(hyprctl getoption cursor:zoom_factor -j | jq '(.float * 0.9) | if . < 1 then 1 else . end')", + ), + dropdown_divider("---------------------"), + dropdown_option( + "Move Window to Left", + on_click="hyprctl dispatch movewindow l", + ), + dropdown_option( + "Move Window to Right", + on_click="hyprctl dispatch movewindow r", + ), + dropdown_option( + "Cycle Through Windows", + on_click="hyprctl dispatch cyclenext", + ), + dropdown_divider("---------------------"), + dropdown_option("Float", on_click="hyprctl dispatch togglefloating"), + dropdown_option("Quit", on_click="hyprctl dispatch killactive"), + dropdown_option("Pseudo", on_click="hyprctl dispatch pseudo"), + dropdown_option( + "Toggle Split", on_click="hyprctl dispatch togglesplit" + ), + dropdown_option("Center", on_click="hyprctl dispatch centerwindow"), + dropdown_option("Group", on_click="hyprctl dispatch togglegroup"), + dropdown_option( + "Pin", + on_clicked=lambda _: exec_shell_command_async( + "bash ~/.config/scripts/winpin.sh", lambda *_: None + ), + ), + ], + ) + + self.global_menu_help = create_dropdown_with_capture( + "global-menu-help", + parent, + [ + dropdown_option( + "Modus", + on_click="xdg-open https://github.com/S4NKALP/Modus/issues", + ), + dropdown_divider("---------------------"), + dropdown_option( + "Hyprland Wiki", on_click="xdg-open https://wiki.hyprland.org/" + ), + ], + ) + + modus_service.connect( + "current-active-app-name-changed", self._on_active_app_changed + ) + + self.global_menu_button_title = Button( + child=ActiveWindow( + formatter=FormattedString( + "{ format_window(win_title, win_class) }", + format_window=format_window, + ) + ), + name="global-menu", + on_clicked=self._on_title_button_clicked, + ) + + self.global_menu_title.child_window.set_pointing_to( + self.global_menu_button_title + ) + # File, Edit and Go buttons are placeholders - no dropdowns implemented yet + self.global_menu_button_file = create_menu_button("File") + self.global_menu_button_edit = create_menu_button("Edit") + self.global_menu_button_go = create_menu_button("Go") + + self.global_menu_button_view = create_menu_button( + "View", + lambda _: self.global_menu_view.toggle_mousecapture(), + ) + self.global_menu_view.child_window.set_pointing_to(self.global_menu_button_view) + self.global_menu_button_window = create_menu_button( + "Window", + lambda _: self.global_menu_window.toggle_mousecapture(), + ) + self.global_menu_window.child_window.set_pointing_to( + self.global_menu_button_window + ) + self.global_menu_button_help = create_menu_button( + "Help", + lambda _: self.global_menu_help.toggle_mousecapture(), + ) + self.global_menu_help.child_window.set_pointing_to(self.global_menu_button_help) + + self.all_menu_buttons = [ + self.menu_button, + self.global_menu_button_title, + self.global_menu_button_file, + self.global_menu_button_edit, + self.global_menu_button_view, + self.global_menu_button_go, + self.global_menu_button_window, + self.global_menu_button_help, + ] + + self.dropdown_button_map = { + "os-menu": self.menu_button, + "global-menu-title": self.global_menu_button_title, + "global-menu-file": self.global_menu_button_file, + "global-menu-edit": self.global_menu_button_edit, + "global-menu-view": self.global_menu_button_view, + "global-menu-go": self.global_menu_button_go, + "global-menu-window": self.global_menu_button_window, + "global-menu-help": self.global_menu_button_help, + } + + modus_service.connect("current-dropdown-changed", self.changed_dropdown) + modus_service.connect("dropdowns-hide-changed", self.hide_dropdowns) + + def _on_title_button_clicked(self, _): + if has_active_window(): + self.global_menu_title.toggle_mousecapture() + + def _on_active_app_changed(self, _, value): + self.global_title_menu_about.set_property("label", f"About {value}") + + def hide_dropdowns(self, *_): + manage_button_style_classes(self.all_menu_buttons) + + def changed_dropdown(self, _, dropdown_id): + active_button = self.dropdown_button_map.get(dropdown_id) + manage_button_style_classes(self.all_menu_buttons, active_button) + + def destroy(self): + """Clean up all dropdowns and signal connections""" + try: + modus_service.disconnect_by_func(self._on_active_app_changed) + modus_service.disconnect_by_func(self.changed_dropdown) + modus_service.disconnect_by_func(self.hide_dropdowns) + except Exception: + pass + + # Destroy all dropdown captures + dropdown_captures = [ + getattr(self, "menu_button_dropdown", None), + getattr(self, "global_menu_title", None), + getattr(self, "global_menu_view", None), + getattr(self, "global_menu_window", None), + getattr(self, "global_menu_help", None), + ] + for capture in dropdown_captures: + try: + if capture and hasattr(capture, "destroy"): + capture.destroy() + except Exception: + pass + + super().destroy() + + +class GlobalMenu(Box): + def __init__(self, parent_window=None, **kwargs): + if parent_window is None: + parent_window = kwargs.pop("parent_window", None) + + super().__init__( + name="globalmenu", orientation="horizontal", spacing=0, **kwargs + ) + + self.dropdown_system = GlobalMenuDropdowns(parent=parent_window) + + self.children = [ + self.dropdown_system.global_menu_button_title, + self.dropdown_system.global_menu_button_file, + self.dropdown_system.global_menu_button_edit, + self.dropdown_system.global_menu_button_view, + self.dropdown_system.global_menu_button_go, + self.dropdown_system.global_menu_button_window, + self.dropdown_system.global_menu_button_help, + ] + + def show_system_dropdown(self, imac_button): + self.dropdown_system.menu_button_dropdown.child_window.set_pointing_to( + imac_button + ) + mouse_capture = self.dropdown_system.menu_button_dropdown + mouse_capture.set_child_window_visible(not mouse_capture.is_visible()) + + def destroy(self): + """Clean up the global menu and its dropdowns""" + if hasattr(self, "dropdown_system") and self.dropdown_system: + try: + self.dropdown_system.destroy() + except Exception: + pass + super().destroy() diff --git a/modules/panel/components/indicators.py b/src/window/panel/components/indicators.py similarity index 51% rename from modules/panel/components/indicators.py rename to src/window/panel/components/indicators.py index 7e8e277f..34a8f2a3 100644 --- a/modules/panel/components/indicators.py +++ b/src/window/panel/components/indicators.py @@ -1,19 +1,59 @@ from fabric.bluetooth import BluetoothClient -from fabric.utils import get_relative_path from fabric.widgets.box import Box from fabric.widgets.button import Button from fabric.widgets.label import Label -from fabric.widgets.svg import Svg +from fabric.widgets.wayland import WaylandWindow as Window -from modules.controlcenter.battery import BatteryControl -from modules.controlcenter.bluetooth import BluetoothConnections -from modules.controlcenter.wifi import WifiConnections -from services.battery import Battery +from services.battery import BatteryService, DeviceState from services.network import NetworkClient +from shared.window.battery_widget import BatteryControl +from shared.window.mousecapture import DropDownMouseCapture +from utils.functions import format_duration, get_wifi_icon_for_strength from utils.roam import modus_service -from utils.functions import get_wifi_icon_for_strength, get_wifi_connecting_icon -from widgets.mousecapture import DropDownMouseCapture -from widgets.wayland import WaylandWindow as Window +from utils.utils import setup_cursor_hover, svg_file +from window.controlcenter.bluetooth import BluetoothConnections +from window.controlcenter.wifi import WifiConnections + + +def create_control_window(name_prefix): + return Window( + layer="overlay", + title="modus", + anchor="top right", + margin="2px 10px 0px 0px", + exclusivity="auto", + keyboard_mode="on-demand", + name=f"{name_prefix}-window", + visible=False, + ) + + +def create_mouse_capture(window): + return DropDownMouseCapture(layer="top", child_window=window) + + +def setup_control_center( + show_window, name_prefix, widget_class, parent, **widget_kwargs +): + if not show_window: + return None, None, None + + window = create_control_window(name_prefix) + widget = widget_class(parent, **widget_kwargs) + window.children = [widget] + mouse_capture = create_mouse_capture(window) + + return window, widget, mouse_capture + + +def handle_indicator_click(mouse_capture): + if mouse_capture: + mouse_capture.toggle_mousecapture() + + +def hide_control_center(mouse_capture): + if mouse_capture: + mouse_capture.hide_child_window() class BluetoothIndicator(Box): @@ -22,13 +62,7 @@ def __init__(self, show_window=True, **kwargs): self.show_window = show_window self.bluetooth = BluetoothClient() - self.bt_icon = Svg( - name="bt-icon", - size=22, - svg_file=get_relative_path( - "../../../config/assets/icons/applets/bluetooth-clear.svg" - ), - ) + self.bt_icon = svg_file("applets/bluetooth-clear.svg", size=22) self.bt_button = Button( name="bt-button", child=self.bt_icon, on_clicked=self.on_bluetooth_clicked @@ -36,30 +70,23 @@ def __init__(self, show_window=True, **kwargs): self.add(self.bt_button) - # Create Bluetooth control center widget only if show_window is True - if self.show_window: - self.bluetooth_window = Window( - layer="overlay", - title="modus", - anchor="top right", - margin="2px 10px 0px 0px", - exclusivity="auto", - keyboard_mode="on-demand", - name="bluetooth-control-window", - visible=False, - ) - - self.bluetooth_widget = BluetoothConnections(self, show_back_button=False) - self.bluetooth_window.children = [self.bluetooth_widget] - - # Create mouse capture for Bluetooth widget - self.bluetooth_mousecapture = DropDownMouseCapture( - layer="top", child_window=self.bluetooth_window - ) - else: - self.bluetooth_window = None - self.bluetooth_widget = None - self.bluetooth_mousecapture = None + # Set pointer cursor on hover + setup_cursor_hover(self.bt_button, "pointer") + + # Setup control center using shared function + ( + self.bluetooth_window, + self.bluetooth_widget, + self.bluetooth_mousecapture, + ) = setup_control_center( + self.show_window, + "bluetooth", + BluetoothConnections, + self, + show_back_button=False, + ) + if self.bluetooth_window: + self.bluetooth_window._pointing_widget = self.bt_button modus_service.connect("bluetooth-changed", self.on_bluetooth_changed) self.bluetooth.connect("changed", self.on_bluetooth_direct_changed) @@ -71,26 +98,14 @@ def __init__(self, show_window=True, **kwargs): def update_state(self): if not self.bluetooth.enabled: - self.bt_icon.set_from_file( - get_relative_path( - "../../../config/assets/icons/applets/bluetooth-off-clear.svg" - ) - ) + self.bt_icon.dynamic_file("applets/bluetooth-off-clear.svg") tooltip = "Bluetooth disabled" else: connected_devices = self.bluetooth.connected_devices if connected_devices: - self.bt_icon.set_from_file( - get_relative_path( - "../../../config/assets/icons/applets/bluetooth-clear.svg" - ) - ) + self.bt_icon.dynamic_file("applets/bluetooth-clear.svg") if len(connected_devices) >= 1: - self.bt_icon.set_from_file( - get_relative_path( - "../../../config/assets/icons/applets/bluetooth-paired.svg" - ) - ) + self.bt_icon.dynamic_file("applets/bluetooth-paired.svg") device = connected_devices[0] tooltip = f"Connected to {device.alias}" if device.battery_percentage > 0: @@ -98,11 +113,7 @@ def update_state(self): else: tooltip = f"Connected to {len(connected_devices)} devices" else: - self.bt_icon.set_from_file( - get_relative_path( - "../../../config/assets/icons/applets/bluetooth-clear.svg" - ) - ) + self.bt_icon.dynamic_file("applets/bluetooth-clear.svg") tooltip = "No devices connected" self.bt_button.set_tooltip_text(tooltip) @@ -144,19 +155,13 @@ def update_modus_service_bluetooth_state(self): modus_service.bluetooth = bluetooth_state def on_bluetooth_clicked(self, *args): - """Handle Bluetooth indicator click""" - if self.show_window and self.bluetooth_mousecapture: - self.bluetooth_mousecapture.toggle_mousecapture() + handle_indicator_click(self.bluetooth_mousecapture) def close_bluetooth(self, *args): - """Close Bluetooth control center""" - if self.show_window and self.bluetooth_mousecapture: - self.bluetooth_mousecapture.hide_child_window() + hide_control_center(self.bluetooth_mousecapture) def hide_controlcenter(self, *args): - """Hide Bluetooth control center""" - if self.show_window and self.bluetooth_mousecapture: - self.bluetooth_mousecapture.hide_child_window() + hide_control_center(self.bluetooth_mousecapture) class NetworkIndicator(Box): @@ -166,13 +171,7 @@ def __init__(self, show_window=True, **kwargs): self.network_service = NetworkClient() - self.network_icon = Svg( - name="network-icon", - size=22, - svg_file=get_relative_path( - "../../../config/assets/icons/applets/wifi-clear.svg" - ), - ) + self.network_icon = svg_file("applets/wifi-clear.svg", size=22) self.network_button = Button( name="network-button", @@ -182,30 +181,23 @@ def __init__(self, show_window=True, **kwargs): self.add(self.network_button) - # Create WiFi control center widget only if show_window is True - if self.show_window: - self.wifi_window = Window( - layer="overlay", - title="modus", - anchor="top right", - margin="2px 10px 0px 0px", - exclusivity="auto", - keyboard_mode="on-demand", - name="wifi-control-window", - visible=False, - ) - - self.wifi_widget = WifiConnections(self, show_back_button=False) - self.wifi_window.children = [self.wifi_widget] - - # Create mouse capture for WiFi widget - self.wifi_mousecapture = DropDownMouseCapture( - layer="top", child_window=self.wifi_window - ) - else: - self.wifi_window = None - self.wifi_widget = None - self.wifi_mousecapture = None + # Set pointer cursor on hover + setup_cursor_hover(self.network_button, "pointer") + + # Setup control center using shared function + ( + self.wifi_window, + self.wifi_widget, + self.wifi_mousecapture, + ) = setup_control_center( + self.show_window, + "wifi", + WifiConnections, + self, + show_back_button=False, + ) + if self.wifi_window: + self.wifi_window._pointing_widget = self.network_button modus_service.connect("wlan-changed", self.on_wlan_changed) self.network_service.connect("wifi-device-added", self.on_wifi_device_added) @@ -230,7 +222,6 @@ def on_wifi_device_added(self, *args): self.update_state() def on_ethernet_device_added(self, *args): - """Called when Ethernet device is added""" if self.network_service.ethernet_device: self.network_service.ethernet_device.connect( "changed", self.on_network_direct_changed @@ -249,7 +240,6 @@ def on_network_changed(self, *args): def update_modus_service_wlan_state(self): wlan_state = "disconnected" - # Check WiFi first (prioritize WiFi over Ethernet) if self.network_service.wifi_device: wifi = self.network_service.wifi_device if not wifi.wireless_enabled: @@ -315,25 +305,17 @@ def update_state(self): icon_file = "network-wired-offline.svg" tooltip = "Ethernet disconnected" - self.network_icon.set_from_file( - get_relative_path(f"../../../config/assets/icons/applets/{icon_file}") - ) + self.network_icon.dynamic_file(f"applets/{icon_file}") self.network_button.set_tooltip_text(tooltip) def on_wifi_clicked(self, *args): - """Handle WiFi indicator click""" - if self.show_window and self.wifi_mousecapture: - self.wifi_mousecapture.toggle_mousecapture() + handle_indicator_click(self.wifi_mousecapture) def close_wifi(self, *args): - """Close WiFi control center""" - if self.show_window and self.wifi_mousecapture: - self.wifi_mousecapture.hide_child_window() + hide_control_center(self.wifi_mousecapture) def hide_controlcenter(self, *args): - """Hide WiFi control center""" - if self.show_window and self.wifi_mousecapture: - self.wifi_mousecapture.hide_child_window() + hide_control_center(self.wifi_mousecapture) class BatteryIndicator(Box): @@ -341,15 +323,9 @@ def __init__(self, show_window=True, **kwargs): super().__init__(name="battery-indicator", orientation="h", **kwargs) self.show_window = show_window - self.battery_service = Battery() + self.battery_service = BatteryService() - self.battery_icon = Svg( - name="battery-icon", - size=23, - svg_file=get_relative_path( - "../../../config/assets/icons/battery/battery-100.svg" - ), - ) + self.battery_icon = svg_file("battery/battery-100.svg", size=23) self.battery_button = Button( name="battery-button", @@ -362,30 +338,22 @@ def __init__(self, show_window=True, **kwargs): self.add(self.battery_label) self.add(self.battery_button) - # Create Battery control center widget only if show_window is True - if self.show_window: - self.battery_window = Window( - layer="top", - title="modus", - anchor="top right", - margin="2px 10px 0px 0px", - exclusivity="auto", - keyboard_mode="on-demand", - name="battery-control-window", - visible=False, - ) - - self.battery_widget = BatteryControl(self, show_back_button=False) - self.battery_window.children = [self.battery_widget] - - # Create mouse capture for Battery widget - self.battery_mousecapture = DropDownMouseCapture( - layer="top", child_window=self.battery_window - ) - else: - self.battery_window = None - self.battery_widget = None - self.battery_mousecapture = None + # Set pointer cursor on hover + setup_cursor_hover(self.battery_button, "pointer") + + ( + self.battery_window, + self.battery_widget, + self.battery_mousecapture, + ) = setup_control_center( + self.show_window, + "battery", + BatteryControl, + self, + show_back_button=False, + ) + if self.battery_window: + self.battery_window._pointing_widget = self.battery_button modus_service.connect("battery-changed", self.on_battery_changed) self.battery_service.connect("changed", self.on_battery_direct_changed) @@ -400,21 +368,46 @@ def on_battery_direct_changed(self, *args): self.update_modus_service_battery_state() self.update_state() + def _get_percentage(self): + return int(self.battery_service.get_property("Percentage") or 0) + + def _get_state(self): + state_value = self.battery_service.get_property("State") + return DeviceState.get(state_value, "UNKNOWN") + + def _is_present(self): + return bool(self.battery_service.get_property("IsPresent")) + + def _get_time_to_empty(self): + return int(self.battery_service.get_property("TimeToEmpty") or 0) + + def _get_time_to_full(self): + return int(self.battery_service.get_property("TimeToFull") or 0) + + def _format_time(self, seconds: int) -> str: + return format_duration(seconds) + + def _get_battery_icon_file(self, percentage: int, is_charging: bool) -> str: + clamped = max(0, min(100, percentage)) + step = (clamped // 10) * 10 + filename = f"battery-{step:03d}{'-charging' if is_charging else ''}.svg" + return f"battery/{filename}" + def update_modus_service_battery_state(self): - if not self.battery_service.is_present: + if not self._is_present(): battery_state = "not_present" else: - percentage = self.battery_service.percentage - state = self.battery_service.state.lower() + percentage = self._get_percentage() + state_str = self._get_state().lower() - battery_state = f"{state}:{percentage}%" + battery_state = f"{state_str}:{percentage}%" - if state == "discharging": - time_to_empty = self.battery_service.time_to_empty + if state_str == "discharging": + time_to_empty = self._format_time(self._get_time_to_empty()) if time_to_empty != "N/A": battery_state += f":{time_to_empty}" - elif state == "charging": - time_to_full = self.battery_service.time_to_full + elif state_str == "charging": + time_to_full = self._format_time(self._get_time_to_full()) if time_to_full != "N/A": battery_state += f":{time_to_full}" @@ -425,11 +418,11 @@ def get_battery_tooltip(self, percentage, state): if state == "CHARGING": tooltip += " (Charging)" - time_to_full = self.battery_service.time_to_full + time_to_full = self._format_time(self._get_time_to_full()) if time_to_full != "N/A": tooltip += f" - {time_to_full} until full" elif state == "DISCHARGING": - time_to_empty = self.battery_service.time_to_empty + time_to_empty = self._format_time(self._get_time_to_empty()) if time_to_empty != "N/A": tooltip += f" - {time_to_empty} remaining" elif state == "FULLY_CHARGED": @@ -438,40 +431,129 @@ def get_battery_tooltip(self, percentage, state): return tooltip def update_state(self): - if not self.battery_service.is_present: - # Hide the entire battery component when no battery is present + if not self._is_present(): + print("[Battery] No battery detected, hiding indicator") self.set_visible(False) return else: - # Show the battery component when battery is present self.set_visible(True) - percentage = self.battery_service.percentage - state = self.battery_service.state + percentage = self._get_percentage() + state = self._get_state() is_charging = state in ["CHARGING", "FULLY_CHARGED"] - icon_file = Battery.get_battery_icon_file( - percentage, is_charging, base_path="../../../config/assets/icons/" - ) + icon_file = self._get_battery_icon_file(percentage, is_charging) tooltip = self.get_battery_tooltip(percentage, state) percentage_text = f"{percentage}%" - # Update icon, tooltip, and percentage label - self.battery_icon.set_from_file(get_relative_path(icon_file)) + self.battery_icon.dynamic_file(icon_file) self.battery_button.set_tooltip_text(tooltip) self.battery_label.set_label(percentage_text) def on_battery_clicked(self, *args): - """Handle Battery indicator click""" - if self.show_window and self.battery_mousecapture: - self.battery_mousecapture.toggle_mousecapture() + handle_indicator_click(self.battery_mousecapture) def close_battery(self, *args): - """Close Battery control center""" - if self.show_window and self.battery_mousecapture: - self.battery_mousecapture.hide_child_window() + hide_control_center(self.battery_mousecapture) def hide_controlcenter(self, *args): - """Hide Battery control center""" - if self.show_window and self.battery_mousecapture: - self.battery_mousecapture.hide_child_window() + hide_control_center(self.battery_mousecapture) + + def destroy(self): + """Clean up resources when the indicator is destroyed.""" + # Disconnect signals + try: + modus_service.disconnect_by_func(self.on_battery_changed) + if hasattr(self, "battery_service") and self.battery_service: + self.battery_service.disconnect_by_func(self.on_battery_direct_changed) + except Exception: + pass + + # Destroy window and widget + try: + if hasattr(self, "battery_widget") and self.battery_widget: + self.battery_widget.destroy() + if hasattr(self, "battery_window") and self.battery_window: + self.battery_window.destroy() + if hasattr(self, "battery_mousecapture") and self.battery_mousecapture: + self.battery_mousecapture.destroy() + except Exception: + pass + + super().destroy() + + +# Add destroy methods to other classes in this file as well +def add_destroy_to_indicators(): + # BluetoothIndicator + + def bt_destroy(self): + try: + modus_service.disconnect_by_func(self.on_bluetooth_changed) + if hasattr(self, "bluetooth") and self.bluetooth: + self.bluetooth.disconnect_by_func(self.on_bluetooth_direct_changed) + self.bluetooth.disconnect_by_func(self.on_device_added) + self.bluetooth.disconnect_by_func(self.on_device_removed) + except Exception: + pass + try: + if hasattr(self, "bluetooth_widget") and self.bluetooth_widget: + self.bluetooth_widget.destroy() + if hasattr(self, "bluetooth_window") and self.bluetooth_window: + self.bluetooth_window.destroy() + if hasattr(self, "bluetooth_mousecapture") and self.bluetooth_mousecapture: + self.bluetooth_mousecapture.destroy() + except Exception: + pass + Box.destroy(self) + + BluetoothIndicator.destroy = bt_destroy + + # NetworkIndicator + def net_destroy(self): + try: + modus_service.disconnect_by_func(self.on_wlan_changed) + if hasattr(self, "network_service") and self.network_service: + self.network_service.disconnect_by_func(self.on_wifi_device_added) + self.network_service.disconnect_by_func(self.on_ethernet_device_added) + self.network_service.disconnect_by_func(self.on_network_changed) + + # Also disconnect from the devices themselves if they exist + if ( + hasattr(self.network_service, "wifi_device") + and self.network_service.wifi_device + ): + try: + self.network_service.wifi_device.disconnect_by_func( + self.on_network_direct_changed + ) + except Exception: + pass + if ( + hasattr(self.network_service, "ethernet_device") + and self.network_service.ethernet_device + ): + try: + self.network_service.ethernet_device.disconnect_by_func( + self.on_network_direct_changed + ) + except Exception: + pass + except Exception: + pass + try: + if hasattr(self, "wifi_widget") and self.wifi_widget: + self.wifi_widget.destroy() + if hasattr(self, "wifi_window") and self.wifi_window: + self.wifi_window.destroy() + if hasattr(self, "wifi_mousecapture") and self.wifi_mousecapture: + self.wifi_mousecapture.destroy() + except Exception: + pass + Box.destroy(self) + + NetworkIndicator.destroy = net_destroy + + +# Apply the destroy methods +add_destroy_to_indicators() diff --git a/modules/panel/components/recording_indicator.py b/src/window/panel/components/recording_indicator.py similarity index 79% rename from modules/panel/components/recording_indicator.py rename to src/window/panel/components/recording_indicator.py index ab318e36..241346d4 100644 --- a/modules/panel/components/recording_indicator.py +++ b/src/window/panel/components/recording_indicator.py @@ -1,20 +1,16 @@ -import os -import subprocess -import time - -from fabric.utils import get_relative_path +from fabric.utils import GLib, os, time from fabric.widgets.box import Box from fabric.widgets.button import Button from fabric.widgets.label import Label -from fabric.widgets.svg import Svg -from gi.repository import GLib +from services.screencapture import screen_capture_service + +from utils.utils import setup_cursor_hover, svg_file class RecordingIndicator(Button): def __init__(self, **kwargs): super().__init__(name="panel-button", visible=True, **kwargs) - self.script_path = get_relative_path("../../../scripts/screen-capture.sh") self.recording_start_time = None self.last_process_check = 0 self.process_check_interval = 1.0 @@ -24,13 +20,8 @@ def __init__(self, **kwargs): self.timer_timeout_id = None self.status_timeout_id = None - self.recording_icon = Svg( - name="indicators-icon", - size=24, - svg_file=get_relative_path( - "../../../config/assets/icons/misc/media-record.svg" - ), - ) + self.recording_icon = svg_file("misc/media-record.svg", size=24) + self.time_label = Label( name="recording-time-label", markup="00:00", @@ -47,9 +38,19 @@ def __init__(self, **kwargs): self.add(self.recording_box) + # Prevent container.show_all() from forcing this visible when no recording + try: + self.set_no_show_all(True) + except Exception: + pass + self.connect("clicked", self.on_stop_recording) + try: + setup_cursor_hover(self, "pointer") + except Exception: + pass self.connect("button-press-event", self.on_button_press) - self.hide() + self.set_visible(False) GLib.timeout_add(100, self._delayed_init) @@ -58,22 +59,7 @@ def on_button_press(self, *args): return False def is_recorder_running(self): - # add more process names if needed - recorder_processes = ["wf-recorder", "gpu-screen-recorder"] - - try: - for proc in recorder_processes: - result = subprocess.run( - ["pgrep", "-x", proc], - capture_output=True, - text=True, - timeout=1, - ) - if result.returncode == 0: - return True # Found a running recorder process - return False # None found running - except Exception: - return False + return screen_capture_service.is_recording def check_recording_status(self): current_time = time.time() @@ -95,9 +81,9 @@ def check_recording_status(self): self.update_timer_display() else: - if self.get_visible(): - self.set_visible(False) - self.cleanup_recording_state() + # Ensure it stays hidden when not recording + self.set_visible(False) + self.cleanup_recording_state() except Exception as e: print(f"[DEBUG] Error checking recording status: {e}") @@ -169,18 +155,8 @@ def on_stop_recording(self, *args): try: self.set_visible(False) self.cleanup_recording_state() + screen_capture_service.stop_recording() - def send_stop_command(): - try: - subprocess.Popen( - [self.script_path, "record", "stop"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - except Exception as e: - print(f"[DEBUG] Error sending stop command: {e}") - - GLib.idle_add(send_stop_command) GLib.timeout_add(500, self._verify_recording_stopped) GLib.timeout_add(1500, self._verify_recording_stopped) GLib.timeout_add(3000, self._verify_recording_stopped) diff --git a/src/window/panel/components/workspace.py b/src/window/panel/components/workspace.py new file mode 100644 index 00000000..fffe298c --- /dev/null +++ b/src/window/panel/components/workspace.py @@ -0,0 +1,93 @@ +from fabric.hyprland.widgets import HyprlandWorkspaces, WorkspaceButton +from fabric.widgets.box import Box + +from services.config import get_config, on_config_change +from utils.functions import is_special_workspace_id +from utils.utils import setup_cursor_hover + +# TODO: Support multi-monitor setups + + +class WorkspaceIndicator(Box): + def __init__(self, **kwargs): + Box.__init__( + self, name="workspace-indicator", orientation="h", spacing=4, **kwargs + ) + self._current_config = {"hide_special_workspace": True} + on_config_change(self._on_config_changed) + + self.workspaces = HyprlandWorkspaces( + name="workspaces", + spacing=4, + buttons_factory=self._get_button_factory(), + ) + + self.add(self.workspaces) + self.show_all() + + self._apply_initial_config() + + def _apply_initial_config(self): + new_value = get_config("hide_special_workspace", True) + if new_value != self._current_config["hide_special_workspace"]: + self._current_config["hide_special_workspace"] = new_value + self.update_config({"hide_special_workspace": new_value}) + + def _on_config_changed(self, new_config: dict, old_config: dict): + if "hide_special_workspace" in new_config and new_config.get( + "hide_special_workspace" + ) != old_config.get("hide_special_workspace"): + self._current_config["hide_special_workspace"] = new_config.get( + "hide_special_workspace", True + ) + self.update_config( + { + "hide_special_workspace": self._current_config[ + "hide_special_workspace" + ] + } + ) + + def _get_button_factory(self): + if self._current_config.get("hide_special_workspace", True): + return self._create_filtered_button + else: + return lambda ws_id: self._create_button_with_hover(ws_id) + + def _create_filtered_button(self, ws_id): + if self._current_config.get( + "hide_special_workspace", True + ) and is_special_workspace_id(ws_id): + return None + return self._create_button_with_hover(ws_id) + + def _create_button_with_hover(self, ws_id): + button = WorkspaceButton(id=ws_id, label=str(ws_id)) + setup_cursor_hover(button, "pointer") + return button + + def update_config(self, new_config: dict): + if "hide_special_workspace" in new_config: + button_factory = self._get_button_factory() + if hasattr(self, "workspaces") and self.workspaces: + self.workspaces.destroy() + + self.workspaces = HyprlandWorkspaces( + name="workspaces", + spacing=4, + buttons_factory=button_factory, + ) + self.add(self.workspaces) + self.workspaces.show_all() + + def destroy(self): + try: + from services.config import _config_handlers + + if self._on_config_changed in _config_handlers: + _config_handlers.remove(self._on_config_changed) + except Exception: + pass + if hasattr(self, "workspaces") and self.workspaces: + self.workspaces.destroy() + Box.destroy(self) diff --git a/src/window/panel/main.py b/src/window/panel/main.py new file mode 100644 index 00000000..04e4d94a --- /dev/null +++ b/src/window/panel/main.py @@ -0,0 +1,346 @@ +from fabric.system_tray.widgets import SystemTray +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.centerbox import CenterBox +from fabric.widgets.datetime import DateTime +from fabric.widgets.revealer import Revealer +from fabric.widgets.wayland import WaylandWindow as Window + +from services.config import on_config_change, get_config_all +from services.modus import notification_service +from shared.window.mousecapture import MouseCapture +from utils.roam import modus_service +from utils.utils import setup_cursor_hover, svg_file +from window.controlcenter.main import ModusControlCenter +from window.notification.notification_center import NotificationCenter +from window.panel.components.enhanced_system_tray import apply_enhanced_system_tray +from window.panel.components.globalmenu import GlobalMenu +from window.panel.components.indicators import ( + BatteryIndicator, + BluetoothIndicator, + NetworkIndicator, +) +from window.panel.components.recording_indicator import RecordingIndicator +from window.panel.components.workspace import WorkspaceIndicator + +apply_enhanced_system_tray() + + +class Panel(Window): + def __init__(self, **kwargs): + super().__init__( + name="bar", + title="modus", + layer="top", + anchor="left top right", + exclusivity="auto", + visible=False, + all_visible=False, + ) + self.globalmenu = GlobalMenu(parent_window=self) + + self.imac = Button( + name="panel-button", + child=svg_file("misc/logo.svg", size=18), + on_clicked=lambda *_: self.globalmenu.show_system_dropdown((self.imac)), + ) + setup_cursor_hover(self.imac, "pointer") + + self.tray = SystemTray(name="panel-button", spacing=4, icon_size=20) + + self.tray_revealer = Revealer( + name="tray-revealer", + child=self.tray, + child_revealed=False, + transition_type="slide-left", + transition_duration=300, + ) + + self.chevron_button = Button( + name="panel-button", + child=svg_file("misc/chevron-right.svg", size=16), + on_clicked=self.toggle_tray, + ) + setup_cursor_hover(self.chevron_button, "pointer") + + # Hide tray elements if empty + self.tray.connect("add", self._update_tray_visibility) + self.tray.connect("remove", self._update_tray_visibility) + + self.indicators = Box( + name="indicators", + orientation="h", + spacing=4, + ) + + self.search = Button( + name="panel-button", child=svg_file("misc/search.svg", size=22) + ) + setup_cursor_hover(self.search, "pointer") + + self.control_center = MouseCapture( + layer="top", child_window=ModusControlCenter() + ) + + self.control_center_btn = Button( + name="panel-button", + child=svg_file("misc/control-center.svg", size=22), + on_clicked=self.control_center.toggle_mousecapture, + ) + setup_cursor_hover(self.control_center_btn, "pointer") + self.control_center.child_window._pointing_widget = self.control_center_btn + + self.notification_center = MouseCapture( + layer="overlay", child_window=NotificationCenter() + ) + + self.notification_icon = svg_file( + "notifications/notification-inactive.svg", size=22 + ) + + self.notification_center_btn = Button( + name="panel-button", + child=self.notification_icon, + on_clicked=self.on_notification_icon_clicked, + ) + setup_cursor_hover(self.notification_center_btn, "pointer") + self.notification_center.child_window._pointing_widget = ( + self.notification_center_btn + ) + + self.datetime_btn = Button( + name="panel-button", + child=DateTime(name="date-time", formatters=["%a %-d %b %I:%M %P"]), + ) + setup_cursor_hover(self.datetime_btn, "pointer") + + self.workspace_indicator = WorkspaceIndicator() + self.recording_indicator = RecordingIndicator() + + # Create persistent indicators + self.battery_indicator = BatteryIndicator() + self.network_indicator = NetworkIndicator() + self.bluetooth_indicator = BluetoothIndicator() + + self.indicators = Box( + name="indicators", + orientation="h", + spacing=4, + children=[ + self.battery_indicator, + self.network_indicator, + self.bluetooth_indicator, + ], + ) + + # Create boxes and mount + self.left_box = Box(name="window-left") + self.center_box = Box(name="window-center", children=self.recording_indicator) + self.right_box = Box(name="window-right", spacing=4, orientation="h") + + self.children = CenterBox( + name="panel", + start_children=self.left_box, + center_children=self.center_box, + end_children=self.right_box, + ) + + # Connect to DND state changes for notification icon + modus_service.connect("dont-disturb-changed", self.on_dnd_changed) + + # Connect to notification service for icon state updates + notification_service.connect( + "notify::count", self.on_notification_count_changed + ) + + # Set initial notification icon state + self.update_notification_icon() + + # Initial layout build + self._rebuild_layout_from_config(get_config_all()) + + # Live updates + on_config_change(self._on_config_changed) + + self._update_tray_visibility() + self.show() + + def on_dnd_changed(self, _, dnd_state): + self.update_notification_icon() # Update notification icon when DND changes + + def on_notification_count_changed(self, service, *args): + self.update_notification_icon() + + def on_notification_icon_clicked(self, *args): + count = notification_service.count + if count > 0: + # Only open notification center if there are notifications + self.notification_center.toggle_mousecapture() + # Do nothing if no notifications + + def update_notification_icon(self): + count = notification_service.count + dnd_enabled = modus_service.dont_disturb + + if dnd_enabled: + # DND is enabled - show disabled icon + icon_file = "notification-disabled.svg" + elif count > 0: + # Has notifications - show active icon + icon_file = "notification-active.svg" + else: + # No notifications - show inactive icon + icon_file = "notification-inactive.svg" + + self.notification_icon.dynamic_file(f"notifications/{icon_file}") + + def _rebuild_layout_from_config(self, config_data=None): + if config_data is None: + config_data = get_config_all() + + print(f"[Panel] Rebuilding layout with config: {config_data}") + + # Update indicator visibility directly + battery_visible = config_data.get("battery", True) + network_visible = config_data.get("network", True) + bluetooth_visible = config_data.get("bluetooth", True) + + self.battery_indicator.set_visible(battery_visible) + self.network_indicator.set_visible(network_visible) + self.bluetooth_indicator.set_visible(bluetooth_visible) + + # Ensure they update their internal state + if battery_visible: + self.battery_indicator.update_state() + if network_visible: + self.network_indicator.update_state() + if bluetooth_visible: + self.bluetooth_indicator.update_state() + + # Left + left_children = [] + if config_data.get("imac_button", True): + left_children.append(self.imac) + if config_data.get("global_menu", True): + left_children.append(self.globalmenu) + + for child in left_children: + child.show() + self.left_box.children = left_children + + # Right + right_children = [] + if config_data.get("workspace_indicator", True): + right_children.append(self.workspace_indicator) + + if config_data.get("systray", True): + right_children.extend([self.tray_revealer, self.chevron_button]) + + right_children.append(self.indicators) + + if config_data.get("search", True): + right_children.append(self.search) + if config_data.get("control_center", True): + right_children.append(self.control_center_btn) + if config_data.get("date_time", True): + right_children.append(self.datetime_btn) + if config_data.get("notification_center", True): + right_children.append(self.notification_center_btn) + + for child in right_children: + child.show() + self.right_box.children = right_children + + self.indicators.show() + self._update_tray_visibility() + self.show() + + # Force a layout recalculation + self.queue_resize() + + print( + f"[Panel] Battery visibility state: {self.battery_indicator.get_visible()}" + ) + + def _on_config_changed(self, new_config, old_config): + print("[Panel] Config changed, checking keys...") + keys = { + "imac_button", + "global_menu", + "workspace_indicator", + "systray", + "battery", + "network", + "bluetooth", + "search", + "control_center", + "date_time", + "notification_center", + } + changed_keys = [k for k in keys if new_config.get(k) != old_config.get(k)] + if changed_keys: + print(f"[Panel] Rebuilding due to changes in: {changed_keys}") + self._rebuild_layout_from_config(new_config) + + self._update_tray_visibility() + + def _update_tray_visibility(self, *_): + # We check if there are any visible children in the tray + visible_children = [ + child for child in self.tray.get_children() if child.get_visible() + ] + has_items = len(visible_children) > 0 + + self.tray_revealer.set_visible(has_items) + self.chevron_button.set_visible(has_items) + + if not has_items: + # Reset state if it becomes hidden + self.tray_revealer.child_revealed = False + self.chevron_button.get_child().dynamic_file("misc/chevron-right.svg") + # Add extra spacing between workspace indicator and indicators when tray is hidden + self.indicators.set_margin_left(10) + else: + self.indicators.set_margin_left(0) + + def toggle_tray(self, *_): + current_state = self.tray_revealer.child_revealed + self.tray_revealer.child_revealed = not current_state + + if self.tray_revealer.child_revealed: + self.chevron_button.get_child().dynamic_file("misc/chevron-left.svg") + else: + self.chevron_button.get_child().dynamic_file("misc/chevron-right.svg") + + def destroy(self): + """Clean up all signals and components""" + try: + modus_service.disconnect_by_func(self.on_dnd_changed) + notification_service.disconnect_by_func(self.on_notification_count_changed) + from services.config import _config_handlers + + if self._on_config_changed in _config_handlers: + _config_handlers.remove(self._on_config_changed) + except Exception: + pass + + # Destroy components + for component in [ + self.globalmenu, + self.workspace_indicator, + self.recording_indicator, + self.indicators, + ]: + try: + component.destroy() + except Exception: + pass + + # Destroy MouseCapture windows + for mc in [self.control_center, self.notification_center]: + try: + mc.destroy() + except Exception: + pass + + super().destroy() diff --git a/src/window/settings/main.py b/src/window/settings/main.py new file mode 100644 index 00000000..b3569dd1 --- /dev/null +++ b/src/window/settings/main.py @@ -0,0 +1,489 @@ +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.centerbox import CenterBox +from fabric.widgets.entry import Entry +from fabric.widgets.label import Label +from fabric.widgets.scrolledwindow import ScrolledWindow +from fabric.widgets.stack import Stack +from fabric.utils import Gtk, GLib, Gdk + +from services.config import config, on_config_change +from utils.utils import setup_cursor_hover, svg_file + + +class SettingsRow(CenterBox): + def __init__(self, label: str, child=None, description: str = None, **kwargs): + label_widget = Label(label=label, name="settings-row-label", h_align="start") + + start_children = [label_widget] + if description: + description_widget = Label( + label=description, name="settings-row-description", h_align="start" + ) + start_children = [ + Box( + orientation="v", + spacing=2, + children=[label_widget, description_widget], + ) + ] + + super().__init__( + name="settings-row", + start_children=start_children, + end_children=[child] if child else [], + **kwargs, + ) + self.label_widget = label_widget + if description: + self.description_widget = description_widget + + +class SettingsSwitch(Button): + def __init__(self, config_key: str, **kwargs): + self.config_key = config_key + self.active = config().get(config_key, False) + + super().__init__(name="settings-switch", on_clicked=self.toggle, **kwargs) + self._update_style() + setup_cursor_hover(self, "pointer") + + # Sync with external config changes + on_config_change(self._on_config_change) + + def _on_config_change(self, new_config, old_config): + if config().has_changed(self.config_key, old_config): + self.active = new_config.get(self.config_key, False) + self._update_style() + + def toggle(self, *args): + self.active = not self.active + config().set(self.config_key, self.active) + config().save() + self._update_style() + + def _update_style(self): + if self.active: + self.add_style_class("active") + self.set_label("On") + else: + self.remove_style_class("active") + self.set_label("Off") + + +class SettingsEntry(Entry): + def __init__(self, config_key: str, **kwargs): + self.config_key = config_key + initial_value = str(config().get(config_key, "")) + + super().__init__( + name="settings-entry", + text=initial_value, + on_activate=self.on_change, + **kwargs, + ) + + # Sync with external config changes + on_config_change(self._on_config_change) + + def _on_config_change(self, new_config, old_config): + if config().has_changed(self.config_key, old_config): + new_val = str(new_config.get(self.config_key, "")) + if self.get_text() != new_val: + self.set_text(new_val) + + def on_change(self, entry, *args): + value = entry.get_text() + # Try to convert to int if possible + try: + if value.isdigit(): + value = int(value) + except Exception: + pass + + config().set(self.config_key, value) + config().save() + + +class SettingsComboBox(Gtk.ComboBoxText): + def __init__(self, config_key: str, options: list, **kwargs): + super().__init__(**kwargs) + self.set_name("settings-combo") + self.set_halign(Gtk.Align.END) + self.config_key = config_key + self.options = options + + for opt in options: + self.append_text(opt) + + self._update_from_config() + self.connect("changed", self.on_change) + + # Sync with external config changes + on_config_change(self._on_config_change) + + def _update_from_config(self): + current_val = str(config().get(self.config_key, "")) + if current_val in self.options: + self.set_active(self.options.index(current_val)) + + def _on_config_change(self, new_config, old_config): + if config().has_changed(self.config_key, old_config): + self._update_from_config() + + def on_change(self, combo): + value = combo.get_active_text() + if value: + config().set(self.config_key, value) + config().save() + + +class SettingsList(Box): + """A list of tags that can be added or removed""" + + def __init__(self, config_key: str, **kwargs): + super().__init__(name="settings-list", orientation="v", spacing=5, **kwargs) + self.config_key = config_key + + self.list_box = Box(spacing=5, name="settings-list-tags") + self.entry = Entry( + name="settings-list-entry", + placeholder="Add new item...", + on_activate=self.on_add, + ) + + self.add(self.list_box) + self.add(self.entry) + + self._update_list() + on_config_change(self._on_config_change) + + def _on_config_change(self, new_config, old_config): + if config().has_changed(self.config_key, old_config): + self._update_list() + + def _update_list(self): + items = config().get(self.config_key, []) + if not isinstance(items, list): + items = [] + + # Clear existing tags + self.list_box.children = [] + + for item in items: + tag = Button( + name="settings-list-tag", + child=Box( + spacing=5, + children=[ + Label(label=item), + Label(label="โœ•", name="settings-list-tag-close"), + ], + ), + on_clicked=lambda *_, i=item: self.on_remove(i), + ) + setup_cursor_hover(tag, "pointer") + self.list_box.pack_start(tag, False, False, 0) + + self.list_box.show_all() + + def on_add(self, entry): + val = entry.get_text().strip() + if not val: + return + + items = config().get(self.config_key, []) + if val not in items: + items.append(val) + config().set(self.config_key, items) + config().save() + + entry.set_text("") + self._update_list() + + def on_remove(self, item): + items = config().get(self.config_key, []) + if item in items: + items.remove(item) + config().set(self.config_key, items) + config().save() + self._update_list() + + +class SettingsPage(ScrolledWindow): + def __init__(self, title: str, rows: list, **kwargs): + container = Box( + name="settings-page-container", + orientation="v", + spacing=10, + h_expand=True, + v_expand=True, + children=[ + Label(label=title, name="settings-page-title", h_align="start"), + Box(orientation="v", spacing=1, children=rows, h_expand=True), + ], + ) + super().__init__( + name="settings-page", + h_expand=True, + v_expand=True, + propagate_width=True, + propagate_height=True, + min_content_width=600, + min_content_height=500, + **kwargs, + ) + self.add(container) + self.show_all() + self._container = container + + +class SettingsWindow(Gtk.Window): + def __init__(self, **kwargs): + GLib.set_prgname("modus-settings") + super().__init__(title="Modus Settings", **kwargs) + self.set_name("settings-window") + self.set_wmclass("modus-settings", "Modus") + self.set_default_size(850, 600) + self.set_resizable(False) + self.set_type_hint(Gtk.Window.get_type_hint(self) | Gdk.WindowTypeHint.DIALOG) + self.set_position(Gtk.WindowPosition.CENTER) + self.set_visible(False) + + # Reset global singleton on destroy + self.connect("destroy", self._on_window_destroy) + + self.stack = Stack( + name="settings-stack", + transition_type="none", + h_expand=True, + v_expand=True, + ) + + self.pages = { + "general": self._create_general_page(), + "dock": self._create_dock_page(), + "panel": self._create_panel_page(), + "notifications": self._create_notifications_page(), + } + + for name, page in self.pages.items(): + self.stack.add_named(page, name) + + self.sidebar_buttons = {} + self.sidebar_buttons["general"] = self._create_sidebar_button( + "General", "general", "misc/logo.svg" + ) + self.sidebar_buttons["dock"] = self._create_sidebar_button( + "Dock", "dock", "misc/control.svg" + ) + self.sidebar_buttons["panel"] = self._create_sidebar_button( + "Panel", "panel", "misc/control-center.svg" + ) + self.sidebar_buttons["notifications"] = self._create_sidebar_button( + "Notifications", "notifications", "notifications/notification-active.svg" + ) + + self.set_page("general") + + self.sidebar = Box( + name="settings-sidebar", + orientation="v", + spacing=5, + children=list(self.sidebar_buttons.values()), + ) + + self.main_box = Box( + name="settings-main-box", + orientation="h", + h_expand=True, + v_expand=True, + children=[ + self.sidebar, + Box( + name="settings-content-wrapper", + children=[self.stack], + h_expand=True, + v_expand=True, + ), + ], + ) + + self.add(self.main_box) + + def _create_sidebar_button(self, label, page_name, icon_name): + btn = Button( + name="settings-sidebar-button", + child=Box( + orientation="h", + spacing=10, + children=[svg_file(icon_name, size=16), Label(label=label)], + ), + on_clicked=lambda *_: self.set_page(page_name), + ) + setup_cursor_hover(btn, "pointer") + return btn + + def set_page(self, page_name): + if page_name not in self.pages: + return + self.stack.set_visible_child(self.pages[page_name]) + for name, btn in self.sidebar_buttons.items(): + if name == page_name: + btn.add_style_class("active") + else: + btn.remove_style_class("active") + + def _create_general_page(self): + return SettingsPage( + "General Settings", + [ + SettingsRow( + "Wallpapers Directory", + SettingsEntry("wallpapers_dir"), + "Path to your wallpaper collection", + ), + SettingsRow( + "Weather Location", + SettingsEntry("weather_location"), + "City name for weather updates", + ), + SettingsRow( + "Keyboard Layouts", + SettingsList("keyboard_layouts"), + "Manage active keyboard input languages", + ), + SettingsRow( + "Window Switcher", + SettingsSwitch("window_switcher"), + "Enable the Alt-Tab window switcher", + ), + SettingsRow( + "Items Per Row", + SettingsEntry("window_switcher_items_per_row"), + "Maximum items in window switcher row", + ), + SettingsRow( + "Hide Special Workspace", + SettingsSwitch("hide_special_workspace"), + "Don't show special workspace in indicators", + ), + SettingsRow( + "OSD", + SettingsSwitch("osd"), + "On-screen display for volume/brightness", + ), + ], + ) + + def _create_dock_page(self): + return SettingsPage( + "Dock Settings", + [ + SettingsRow( + "Enabled", + SettingsSwitch("dock_enabled"), + "Show the application dock", + ), + SettingsRow( + "Auto Hide", + SettingsSwitch("dock_auto_hide"), + "Hide dock when not in use", + ), + SettingsRow( + "Always Occluded", + SettingsSwitch("dock_always_occluded"), + "Keep dock behind other windows", + ), + SettingsRow( + "Icon Size", + SettingsEntry("dock_icon_size"), + "Size of dock icons in pixels", + ), + SettingsRow( + "Hide Special Apps", + SettingsSwitch("dock_hide_special_workspace_apps"), + "Hide apps from special workspace in dock", + ), + ], + ) + + def _create_panel_page(self): + # panel visibility settings + rows = [] + panel_settings = [ + ("iMac Button", "imac_button"), + ("Global Menu", "global_menu"), + ("Systray", "systray"), + ("Control Center", "control_center"), + ("Search", "search"), + ("Network", "network"), + ("Battery", "battery"), + ("Bluetooth", "bluetooth"), + ("Date & Time", "date_time"), + ("Workspace Indicator", "workspace_indicator"), + ("Notification Center", "notification_center"), + ] + for label, key in panel_settings: + rows.append(SettingsRow(label, SettingsSwitch(key))) + + rows.append( + SettingsRow( + "Systray Ignore", + SettingsList("systray_ignore"), + "Icons to hide from the system tray", + ) + ) + + return SettingsPage("Panel Settings", rows) + + def _create_notifications_page(self): + return SettingsPage( + "Notification Settings", + [ + SettingsRow( + "Timeout", + SettingsEntry("notification_timeout"), + "How long notifications stay on screen (e.g. 5s)", + ), + SettingsRow( + "Ignored Apps", + SettingsList("notification_ignored_apps"), + "Apps that won't show notifications", + ), + SettingsRow( + "Limited History", + SettingsList("notification_limited_apps_history"), + "Apps with only the latest notification shown", + ), + ], + ) + + def _on_window_destroy(self, *args): + global _settings_window + _settings_window = None + + def toggle(self): + if not self.get_visible(): + self.show_all() + self.present() + + # Re-sync current page + current_page = "general" + for name, btn in self.sidebar_buttons.items(): + if "active" in btn.get_style_context().list_classes(): + current_page = name + break + self.set_page(current_page) + else: + self.hide() + + +_settings_window = None + + +def get_settings_window(): + global _settings_window + if _settings_window is None: + _settings_window = SettingsWindow() + return _settings_window diff --git a/modules/switcher.py b/src/window/switcher.py similarity index 55% rename from modules/switcher.py rename to src/window/switcher.py index 564c54a7..f8a86e20 100644 --- a/modules/switcher.py +++ b/src/window/switcher.py @@ -1,20 +1,16 @@ import json -import gi -from gi.repository import Gdk, Glace - -import config.data as data from fabric.hyprland.widgets import get_hyprland_connection +from fabric.utils import Gdk from fabric.widgets.box import Box from fabric.widgets.eventbox import EventBox from fabric.widgets.image import Image from fabric.widgets.label import Label -from utils.icon_resolver import IconResolver -from utils.occlusion import get_screen_dimensions -from utils.functions import is_special_workspace -from widgets.wayland import WaylandWindow as Window +from fabric.widgets.wayland import WaylandWindow as Window -gi.require_version("Glace", "0.1") +from services.config import config, on_config_change +from utils.functions import is_special_workspace +from utils.icon_resolver import IconResolver class ApplicationSwitcher(Window): @@ -35,21 +31,10 @@ def __init__(self, **kwargs): self.windows = [] self.current_index = 0 self.tab_pressed = False - self.items_per_row = data.WINDOW_SWITCHER_ITEMS_PER_ROW - self.icon_size = 64 + self.icon_size = 96 - # Initialize Glace manager for window previews - self._manager = Glace.Manager() - - # Calculate preview size based on screen ratio - # Formula: screen_ratio = a:b, width = x, height = (x*b)/a - screen_width, screen_height = get_screen_dimensions() - preview_width = 150 # Base width - preview_height = int((preview_width * screen_height) / screen_width) - self.preview_size = [preview_width, preview_height] - - self.glace_clients = {} # Map window addresses to Glace clients - self.window_previews = {} # Map window addresses to preview images + # Subscribe to config changes + on_config_change(self._on_config_changed) container = Box( name="application-switcher-container", @@ -62,22 +47,49 @@ def __init__(self, **kwargs): self.view = Box( name="application-switcher-view", - orientation="v", - spacing=12, + orientation="h", + spacing=16, h_align="center", v_align="center", ) container.add(self.view) + + self.selection_label = Label( + name="switcher-selection-label", + label="", + h_align="center", + v_align="center", + style_classes=["switcher-selection-label"], + ) + container.add(self.selection_label) + + self.workspace_label = Label( + name="switcher-workspace-label", + label="", + h_align="center", + v_align="center", + style_classes=["switcher-workspace-label"], + ) + container.add(self.workspace_label) + self.connect("key-press-event", self.on_key_press) self.connect("key-release-event", self.on_key_release) - # Connect to Glace manager signals to track clients - self._manager.connect("client-added", self._on_glace_client_added) - self._manager.connect("client-removed", self._on_glace_client_removed) - self.show_all() self.hide() + @property + def items_per_row(self): + return config().get("window_switcher_items_per_row", 10) + + def _on_config_changed(self, new_config, old_config): + # Refresh windows if settings that affect display change + if config().has_changed( + "hide_special_workspace", old_config + ) or config().has_changed("window_switcher_items_per_row", old_config): + if self.get_visible(): + self.update_windows() + def show_switcher(self) -> None: self.update_windows() if not self.windows: @@ -91,114 +103,27 @@ def hide_switcher(self) -> None: self.hide() self.ungrab_keyboard() - def _on_glace_client_added(self, _, client): - """Handle when a Glace client is added""" - try: - # Map the client by its window address for later lookup - # We'll need to match this with Hyprland window data - client_id = client.get_id() - self.glace_clients[client_id] = client - except Exception as e: - print(f"Error adding Glace client: {e}") - - def _on_glace_client_removed(self, _, client): - """Handle when a Glace client is removed""" - try: - client_id = client.get_id() - if client_id in self.glace_clients: - del self.glace_clients[client_id] - except Exception as e: - print(f"Error removing Glace client: {e}") - - def _find_glace_client_for_window(self, window): - """Find the corresponding Glace client for a Hyprland window""" - try: - window_class = window.get("class", "").lower() - window_title = window.get("title", "") - - # Try to match by app_id/class and title - for _, client in self.glace_clients.items(): - try: - client_app_id = client.get_app_id() - client_title = client.get_title() - - if ( - client_app_id - and client_app_id.lower() == window_class - and client_title - and client_title == window_title - ): - return client - except Exception: - continue - - # Fallback: try to match by class only - for _, client in self.glace_clients.items(): - try: - client_app_id = client.get_app_id() - if client_app_id and client_app_id.lower() == window_class: - return client - except Exception: - continue - - except Exception as e: - print(f"Error finding Glace client: {e}") - - return None - - def create_preview_for_window(self, window): - """Create a preview image for a specific window""" - glace_client = self._find_glace_client_for_window(window) - - # Create a placeholder image first - preview_image = Image() - - if glace_client: - - def capture_callback(pbuf, _): - try: - scaled_pixbuf = pbuf.scale_simple( - self.preview_size[0], - self.preview_size[1], - 2, # GdkPixbuf.InterpType.BILINEAR - ) - preview_image.set_from_pixbuf(scaled_pixbuf) - except Exception as e: - print(f"Error setting preview image: {e}") - - try: - self._manager.capture_client( - client=glace_client, - overlay_cursor=False, - callback=capture_callback, - user_data=None, - ) - except Exception as e: - print(f"Error capturing client preview: {e}") - # Fallback to icon if preview fails - self._set_fallback_icon(preview_image, window) - else: - # Use icon as fallback if no Glace client found - self._set_fallback_icon(preview_image, window) - - return preview_image - - def _set_fallback_icon(self, image_widget, window): - """Set a fallback icon when preview is not available""" + def create_icon_for_window(self, window): + """Create an icon image for a specific window""" class_name = window.get("class", "").lower() icon_img = self.icon_resolver.get_icon_pixbuf(class_name, self.icon_size) if not icon_img: icon_img = self.icon_resolver.get_icon_pixbuf( "application-x-executable-symbolic", self.icon_size ) - image_widget.set_from_pixbuf(icon_img) + + icon_image = Image() + if icon_img: + icon_image.set_from_pixbuf(icon_img) + + return icon_image def _is_special_workspace(self, client): return is_special_workspace(client) def update_windows(self) -> None: for child in self.view.get_children(): - self.view.remove(child) + child.destroy() try: clients_data = self.conn.send_command("j/clients").reply @@ -208,14 +133,13 @@ def update_windows(self) -> None: # Filter out hidden windows and optionally special workspace windows filtered_windows = [] + hide_special = config().get("hide_special_workspace", True) + for c in clients: if c.get("hidden", False): continue # Skip clients in special workspaces if the setting is enabled - if ( - data.DOCK_HIDE_SPECIAL_WORKSPACE_APPS - and self._is_special_workspace(c) - ): + if hide_special and self._is_special_workspace(c): continue filtered_windows.append(c) @@ -233,41 +157,39 @@ def update_windows(self) -> None: self.current_index = i break - current_row = Box( - name="window-row", - orientation="h", - spacing=12, - h_align="center", - v_align="center", - ) - self.view.add(current_row) + # Create vertical container for rows + rows_box = Box(orientation="v", spacing=16) + self.view.add(rows_box) + + current_row = None + items_per_row = self.items_per_row for i, window in enumerate(self.windows): - title = window.get("title", "") + if i % items_per_row == 0: + current_row = Box( + name="window-row", + orientation="h", + spacing=16, + h_align="center", + v_align="center", + ) + rows_box.add(current_row) - # Create preview image for this window - preview_image = self.create_preview_for_window(window) + # Create icon image for this window + icon_image = self.create_icon_for_window(window) button_content = Box( name="switcher-button", orientation="v", - spacing=4, h_align="center", v_align="center", children=[ Box( - name="switcher-preview-box", + name="switcher-icon-box", style_classes=["window-basic", "sleek-border"], - children=[preview_image], - h_align="center", - v_align="center", - ), - Label( - label=title[:15] + "..." if len(title) > 15 else title, + children=[icon_image], h_align="center", v_align="center", - max_width_chars=15, - ellipsize="end", ), ], ) @@ -279,16 +201,6 @@ def update_windows(self) -> None: ) current_row.add(event_box) - if (i + 1) % self.items_per_row == 0 and i + 1 < len(self.windows): - current_row = Box( - name="window-row", - orientation="h", - spacing=12, - h_align="center", - v_align="center", - ) - self.view.add(current_row) - self.view.show_all() self.update_selection() except Exception as e: @@ -366,13 +278,30 @@ def on_key_release(self, _, event): return False def update_selection(self): - for row in self.view.get_children(): - for i, child in enumerate(row.get_children()): - index = self.view.get_children().index(row) * self.items_per_row + i - if index == self.current_index: - child.add_style_class("active") - else: - child.remove_style_class("active") + # Flatten all buttons from all rows to find them by index + all_buttons = [] + rows_box = self.view.get_children()[0] if self.view.get_children() else None + if rows_box: + for row in rows_box.get_children(): + all_buttons.extend(row.get_children()) + + for i, child in enumerate(all_buttons): + if i == self.current_index: + child.add_style_class("active") + # Update selection label + current_window = self.windows[self.current_index] + app_class = current_window.get("class", "Unknown") + # capitalize if it's all lowercase + if app_class.islower(): + app_class = app_class.capitalize() + self.selection_label.set_label(app_class) + + # Update workspace label + workspace = current_window.get("workspace", {}) + workspace_name = workspace.get("name", "Unknown") + self.workspace_label.set_label(f"Workspace {workspace_name}") + else: + child.remove_style_class("active") def activate_selected(self): if not self.windows or self.current_index >= len(self.windows): diff --git a/src/window/todo/__init__.py b/src/window/todo/__init__.py new file mode 100644 index 00000000..c9af2aa8 --- /dev/null +++ b/src/window/todo/__init__.py @@ -0,0 +1 @@ +# Todo module diff --git a/modules/todo/todo_widget.py b/src/window/todo/todo_widget.py similarity index 88% rename from modules/todo/todo_widget.py rename to src/window/todo/todo_widget.py index a19cb96d..a487ae56 100644 --- a/modules/todo/todo_widget.py +++ b/src/window/todo/todo_widget.py @@ -1,21 +1,16 @@ -# Standard library imports from datetime import datetime -# Fabric imports -from fabric.utils import get_relative_path +from fabric.utils import GLib from fabric.widgets.box import Box from fabric.widgets.button import Button -from fabric.widgets.centerbox import CenterBox from fabric.widgets.entry import Entry from fabric.widgets.label import Label from fabric.widgets.scrolledwindow import ScrolledWindow -from fabric.widgets.svg import Svg -from gi.repository import GLib +from fabric.widgets.wayland import WaylandWindow as Window -# Local imports -from services.todo import todo_service -from widgets.mousecapture import MouseCapture -from widgets.wayland import WaylandWindow as Window +from services.todo import get_todo_service +from shared.window.mousecapture import MouseCapture +from utils.utils import svg_file class TodoItem(Box): @@ -44,12 +39,10 @@ def _build_ui(self): if self.todo_data["completed"] else "checkbox-uncheck.svg" ) - self.checkbox_icon = Svg( + self.checkbox_icon = svg_file( + "todo/" + checkbox_icon, name="todo-checkbox-icon", size=24, - svg_file=get_relative_path( - "../../config/assets/icons/todo/" + checkbox_icon - ), ) self.checkbox = Button( name="todo-checkbox", @@ -109,10 +102,10 @@ def _build_ui(self): # ) # # Edit button - using SVG icon - self.edit_icon = Svg( + self.edit_icon = svg_file( + "todo/edit.svg", name="todo-edit-icon", size=12, - svg_file=get_relative_path("../../config/assets/icons/todo/edit.svg"), ) self.edit_button = Button( name="todo-edit", @@ -121,12 +114,10 @@ def _build_ui(self): ) # Delete button - using SVG icon - self.delete_icon = Svg( + self.delete_icon = svg_file( + "todo/delete-symbolic.svg", name="todo-delete-icon", size=12, - svg_file=get_relative_path( - "../../config/assets/icons/todo/delete-symbolic.svg" - ), ) self.delete_button = Button( name="todo-delete", @@ -154,14 +145,14 @@ def _build_ui(self): def _toggle_completion(self, *_): """Toggle todo completion status""" - todo_service.toggle_todo(self.todo_data["id"]) + get_todo_service().toggle_todo(self.todo_data["id"]) def _cycle_priority(self, *_): """Cycle through priority levels""" priorities = ["low", "medium", "high"] current_index = priorities.index(self.todo_data["priority"]) new_priority = priorities[(current_index + 1) % len(priorities)] - todo_service.set_priority(self.todo_data["id"], new_priority) + get_todo_service().set_priority(self.todo_data["id"], new_priority) def _start_edit(self, *_): """Start editing the todo text""" @@ -184,7 +175,7 @@ def _save_edit(self, *_): new_text = self.text_entry.get_text().strip() if new_text: - todo_service.edit_todo(self.todo_data["id"], new_text) + get_todo_service().edit_todo(self.todo_data["id"], new_text) self._cancel_edit() @@ -199,7 +190,7 @@ def _cancel_edit(self): def _delete_todo(self, *_): """Delete this todo""" - todo_service.delete_todo(self.todo_data["id"]) + get_todo_service().delete_todo(self.todo_data["id"]) def update_from_data(self, todo_data): """Update the widget from new todo data""" @@ -209,12 +200,10 @@ def update_from_data(self, todo_data): checkbox_icon = ( "checkbox-check.svg" if todo_data["completed"] else "checkbox-uncheck.svg" ) - new_checkbox_icon = Svg( + new_checkbox_icon = svg_file( + "todo/" + checkbox_icon, name="todo-checkbox-icon", size=20, - svg_file=get_relative_path( - "../../config/assets/icons/todo/" + checkbox_icon - ), ) self.checkbox.set_child(new_checkbox_icon) self.checkbox_icon = new_checkbox_icon @@ -253,7 +242,7 @@ def __init__(self, **kwargs): self.todo_items = {} # Maps todo IDs to TodoItem widgets # Register callback with todo service - todo_service.add_callback(self._on_todo_event) + get_todo_service().add_callback(self._on_todo_event) self._build_ui() self._refresh_todos() @@ -294,12 +283,10 @@ def _build_ui(self): self.new_todo_entry.connect("activate", self._add_todo) # Add button - using SVG icon - self.add_icon = Svg( + self.add_icon = svg_file( + "todo/plus-symbolic.svg", name="add-todo-icon", size=12, - svg_file=get_relative_path( - "../../config/assets/icons/todo/plus-symbolic.svg" - ), ) self.add_button = Button( name="add-todo-button", @@ -364,12 +351,12 @@ def _add_todo(self, *_): """Add a new todo""" text = self.new_todo_entry.get_text().strip() if text: - todo_service.add_todo(text) + get_todo_service().add_todo(text) self.new_todo_entry.set_text("") def _clear_completed(self, *_): """Clear all completed todos""" - todo_service.clear_completed() + get_todo_service().clear_completed() def _on_todo_event(self, event_type, data=None): """Handle todo service events via callback""" @@ -393,7 +380,7 @@ def _refresh_todos(self, *_): self.todos_container.children = [] # Get all todos - todos = todo_service.todos + todos = get_todo_service().todos # Sort todos: incomplete first, then by priority, then by creation date def sort_key(todo): @@ -419,7 +406,7 @@ def sort_key(todo): def _update_stats(self): """Update the statistics display""" - stats = todo_service.get_stats() + stats = get_todo_service().get_stats() stats_text = f"{stats['pending']} pending, {stats['completed']} completed" self.stats_label.set_label(stats_text) @@ -440,7 +427,7 @@ def _init_mousecapture(self, mousecapture): def destroy(self): """Clean up when destroyed""" # Remove callback from todo service - todo_service.remove_callback(self._on_todo_event) + get_todo_service().remove_callback(self._on_todo_event) super().destroy() diff --git a/start.py b/start.py new file mode 100644 index 00000000..ab3308b6 --- /dev/null +++ b/start.py @@ -0,0 +1,27 @@ +import os +import sys + +# Ensure the 'src' directory is in the python path +# This allows 'import main' and other src-relative imports to work from the root +src_path = os.path.join(os.path.dirname(__file__), "src") +if src_path not in sys.path: + sys.path.insert(0, src_path) + + +def run_app(): + from main import main as app_main + + app_main() + + +def run_lock(): + from lock import main as lock_main + + lock_main() + + +if __name__ == "__main__": + if len(sys.argv) > 1 and sys.argv[1] == "lock": + run_lock() + else: + run_app() diff --git a/styles/colors.css b/styles/colors.css deleted file mode 100644 index 1a391d3d..00000000 --- a/styles/colors.css +++ /dev/null @@ -1,32 +0,0 @@ -:vars { - --foreground: #e4e1e9; - --background: #131318; - --cursor: #e4e1e9; - --primary: #bec2ff; - --on-primary: #262b60; - --secondary: #c5c4dd; - --on-secondary: #2e2f42; - --tertiary: #e7b9d5; - --on-tertiary: #45263c; - --surface: #131318; - --surface-bright: #39393f; - --error: #ffb4ab; - --error-dim: #ff8678; - --on-error: #690005; - --error-container: #93000a; - --outline: #91909a; - --shadow: #000000; - --red: #ffb2b9; - --red-dim: #ff7f8b; - --green: #95d5a7; - --green-dim: #70c789; - --yellow: #b8cf84; - --yellow-dim: #a3c15f; - --blue: #bec2ff; - --blue-dim: #8b92ff; - --magenta: #e4b7f3; - --magenta-dim: #d48bec; - --cyan: #82d3e2; - --cyan-dim: #59c4d8; - --white: #82d3e0; -} diff --git a/styles/launcher.css b/styles/launcher.css deleted file mode 100644 index 24382a44..00000000 --- a/styles/launcher.css +++ /dev/null @@ -1,230 +0,0 @@ -#launcher { - background-color: alpha(#000, 0.3); - padding: 0; - border-radius: 12px; - min-width: 640px; - border: 1px solid rgba(255, 255, 255, 0.1); -} - -/* #launcher-search { */ -/* color: #000; */ -/* } */ -#header_box { - padding: 0px 20px 0 20px; - color: #000; -} - -#close-button, -#config-button { - background-color: transparent; - border-radius: 6px; - padding: 6px; - transition: all 0.15s ease; -} - -#close-button:hover, -#close-button:focus, -#config-button:hover, -#config-button:focus { - background-color: rgba(255, 255, 255, 0.1); - border-radius: 6px; -} - -#close-button.focused, -#config-button.focused { - background-color: rgba(0, 122, 255, 0.2); - border-radius: 6px; - box-shadow: 0 0 0 2px rgba(0, 122, 255, 0.4); -} - -#close-button.focused #close-label { - color: #007aff; -} - -#config-button.focused #config-label { - color: #007aff; -} - -#close-button:active { - background-color: rgba(255, 255, 255, 0.2); - border-radius: 6px; -} - -#close-label { - color: rgba(255, 255, 255, 0.7); - font-size: 18px; -} - -#close-button:active #close-label { - color: rgba(255, 255, 255, 0.9); -} - -#config-button:active { - background-color: rgba(255, 255, 255, 0.2); - border-radius: 6px; -} - -#config-label { - color: rgba(255, 255, 255, 0.7); - font-size: 18px; -} - -#config-button:active #config-label { - color: rgba(255, 255, 255, 0.9); -} - -#launcher-icon-label { - font-size: 20px; - padding: 6px; - color: rgba(255, 255, 255, 0.8); -} - -#launcher-search { - font-weight: 400; - font-size: 36px; - background-color: transparent; - color: rgba(255, 255, 255, 0.95); - border: none; - border-radius: 0; - padding: 12px 0; - margin: 0; -} - -#launcher-search:focus { - background-color: transparent; - box-shadow: none; - border: none; -} - -#launcher-search selection { - color: white; - background-color: rgba(0, 122, 255, 0.8); -} - -#launcher-results-scroll { - margin: 0; - border-radius: 0; - background: transparent; - padding: 0 20px 20px 20px; -} - -#launcher-results-scroll scrollbar { - border-radius: 0; - background-color: transparent; - padding: 0; - margin: 0; - min-width: 0; -} - -#launcher-results-scroll scrollbar slider { - border-radius: 2px; - min-width: 4px; - min-height: 20px; - background-color: rgba(255, 255, 255, 0.3); - margin: 0; -} - -#launcher-results-scroll scrollbar:hover slider { - background-color: rgba(255, 255, 255, 0.5); -} - -#launcher-results { - background: transparent; - margin-top: 8px; -} - -#launcher-result-item { - border-radius: 8px; - padding: 12px 16px; - margin: 2px 0; - min-height: 64px; - transition: all 0.15s ease; - background: transparent; -} - -@keyframes loadSlot { - 0% { - opacity: 0; - } - - 100% { - opacity: 1; - } -} - -#launcher-result-item:focus, -#launcher-result-item:selected, -#launcher-result-item:hover, -#launcher-result-item.selected { - border-radius: 8px; - background-color: rgba(0, 122, 255, 0.8); - padding: 12px 16px; - margin: 2px 0; -} - -#launcher-result-item.selected #result-item-title { - color: white; - font-weight: 600; -} - -#launcher-result-item.selected #result-item-subtitle { - color: rgba(255, 255, 255, 0.8); -} - -#launcher-result-item.selected #result-item-plugin { - color: rgba(255, 255, 255, 0.6); -} - -#result-item-main { - min-height: 56px; -} - -#launcher-result-item { - min-height: 56px; -} - -#result-item-icon { - min-width: 56px; - min-height: 56px; - margin-right: 16px; - border-radius: 12px; -} - -#result-item-title { - font-size: 18px; - font-weight: 500; - color: rgba(255, 255, 255, 0.95); - margin-top: 4px; - margin-bottom: 2px; -} - -#result-item-subtitle { - font-size: 14px; - color: rgba(255, 255, 255, 0.6); - margin-bottom: 4px; -} - -#result-item-plugin { - font-size: 12px; - color: rgba(255, 255, 255, 0.4); - font-style: normal; - margin-bottom: 4px; - opacity: 1; - font-weight: 400; -} - -#network-password-entry { - border: 1px solid rgba(255, 255, 255, 0.2); - background: rgba(255, 255, 255, 0.05); - color: rgba(255, 255, 255, 0.9); - padding: 12px; - border-radius: 8px; - margin-bottom: 8px; - font-size: 14px; -} - -#network-password-entry:focus { - border: 1px solid rgba(0, 122, 255, 0.6); - background: rgba(255, 255, 255, 0.1); - box-shadow: 0 0 0 3px rgba(0, 122, 255, 0.2); -} diff --git a/styles/osd.css b/styles/osd.css deleted file mode 100644 index c4c2082e..00000000 --- a/styles/osd.css +++ /dev/null @@ -1,38 +0,0 @@ -#osd { - background-color: alpha(#fff, 0.09); - padding: 12px 20px; - margin: 70px; - min-height: 200px; - border-radius: 16px; -} - -#osd scale { - min-width: 180px; -} - -#osd trough { - background: var(--surface); - min-height: 15px; - margin-right: 4px; - transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); - border-radius: 16px; -} - -#osd trough highlight { - border-radius: 100px; - background: var(--primary); - transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); -} - -#osd.muted trough highlight, -#osd.muted slider, -#osd scale.muted trough highlight, -#osd scale.muted slider { - background-color: var(--surface-bright); -} - -#brighntess-icons.muted, -#vol-icon.muted, -#mic-icon.muted { - color: var(--outline); -} diff --git a/styles/panel.css b/styles/panel.css deleted file mode 100644 index 218c3585..00000000 --- a/styles/panel.css +++ /dev/null @@ -1,147 +0,0 @@ -#panel { - background-color: alpha(#fff, 0.07); - - /* border-bottom: 1px solid alpha(#010101, 0.025); */ - margin-top: -4px; - margin-bottom: -4px; - transition: all 120ms ease-in-out; -} - -#panel-icon { - font-size: 18px; -} -#global-title-button { - font-weight: bold; -} - -#panel-button { - margin: 0 4px 0 4px; - min-width: 20px; - min-height: 20px; -} - -#panel-button:hover { - background-color: alpha(#fff, 0.1); -} - -#menubar { - margin: 4px 0; -} - -#menubar label { - font-size: 13px; - /* font-weight: 400; */ -} -#battery-label { - font-weight: 500; - margin-right: 1px; -} -#battery-button { - padding-right: 3px; - padding-left: 3px; - border-radius: 8px; -} - -#battery-button:hover { - background-color: alpha(#fff, 0.1); -} - -#network-button { - padding-right: 3px; - padding-left: 3px; - border-radius: 8px; -} - -#network-button:hover { - background-color: alpha(#fff, 0.1); -} - -#bt-button { - padding-right: 3px; - padding-left: 3px; - border-radius: 8px; -} - -#bt-button:hover { - background-color: alpha(#fff, 0.1); -} - -#tray-button { - border-radius: 8px; - margin: 0 4px 0 0px; - min-width: 20px; - min-height: 20px; -} - -#tray-button:hover { - background-color: alpha(#fff, 0.1); -} - -#date-time { - margin: 0 10px; - font-weight: 500; -} - -#modules-left, -#modules-right { - margin: 0 5px; - padding: 3px 0; -} - -.button { - border-radius: 5px; - padding: 0 5px; - margin: 0 2.5px; - background: radial-gradient(alpha(#aaa, 0) 0%, transparent, transparent); - transition: all 100ms ease-in-out; -} - -.button:hover { - background: radial-gradient(alpha(#aaa, 0.2) 100%, transparent, transparent); -} - -#modus-button label { - font-size: 17px; - margin: 0 10px; -} - -#workspace-indicator { - margin: 0 4px; -} - -#workspaces label { - font-family: "SF Pro Rounded"; - color: white; /* fully transparent text */ -} -#workspaces > button { - padding-top: 0px; - padding-right: 16px; - font-family: "SF Pro Rounded"; - padding-left: 16px; - - margin: 6px 0px; - min-width: 12px; - border: 1px solid var(--outline); - border-radius: 8px; -} - -#workspaces > button:hover { - background-color: alpha(#fff, 0.1); -} - -#workspaces > button.active { - background-color: alpha(#fff, 0.9); - /* font-weight: bold; */ -} - -#workspaces > button.active label { - color: alpha(#000, 1); - font-family: "SF Pro Rounded"; -} -#workspaces > button.urgent { - background-color: alpha(var(--on-error), 0.7); -} - -#workspaces > button.empty { - background-color: transparent; -} diff --git a/styles/switcher.css b/styles/switcher.css deleted file mode 100644 index 53022819..00000000 --- a/styles/switcher.css +++ /dev/null @@ -1,38 +0,0 @@ -#application-switcher-container { - background: var(--shadow); - border-radius: 16px; -} - -#application-switcher-view { - min-height: 120px; - padding: 4px; -} - -#switcher-button { - padding: 4px; - min-width: 100px; -} - -#window-button { - background-color: var(--surface); - border-radius: 4px; -} - -#switcher-button:hover { - background-color: var(--surface); -} - -#window-button.active { - background-color: var(--surface-bright); - border: 3px solid var(--surface-bright); -} - -#switcher-button label { - color: var(--foreground); - font-size: 10px; - margin-top: 4px; -} - -#window-row { - padding: 18px; -} diff --git a/styles/todo.css b/styles/todo.css deleted file mode 100644 index a65ddf40..00000000 --- a/styles/todo.css +++ /dev/null @@ -1,258 +0,0 @@ -/* Todo List Styles - matching control center design */ - -#todo-list-window { - /* background-color: alpha(#fff, 0.05); */ - border-radius: 12px; - box-shadow: none; - margin: 0; -} - -#todo-main-container { - box-shadow: inset 0 0 0 1px alpha(#aaa, 0.4); - border-radius: 12px; - padding: 12px; /* Increased padding */ - min-height: 500px; /* Ensure minimum height */ - min-width: 350px; /* Ensure minimum width */ -} - -#todo-header { - margin-bottom: 8px; -} - -#todo-title { - font-size: 16px; - font-family: "SF Pro Rounded"; - font-weight: bold; - color: #ffffff; -} - -#todo-stats { - font-size: 12px; - font-weight: 500; - color: #999; - margin-top: -2px; -} - -/* Add new todo section */ -#todo-add-section { - box-shadow: inset 0 0 0 0.5px alpha(#aaa, 0.4); - border: 1px solid alpha(#111, 0.4); - border-radius: 12px; - padding: 1rem; - margin-bottom: 8px; - min-height: 40px; /* Ensure minimum height */ -} - -#new-todo-entry { - background-color: rgba(255, 255, 255, 0.1); - border: 1px solid rgba(255, 255, 255, 0.2); - border-radius: 6px; - padding: 8px 12px; - color: #ffffff; - font-size: 13px; - font-family: "SF Pro Rounded"; -} - -#new-todo-entry:focus { - border-color: #007aff; - box-shadow: 0 0 0 2px rgba(0, 122, 255, 0.3); - background-color: rgba(255, 255, 255, 0.15); -} - -#add-todo-button { - /* background-color: #007aff; */ - /* border: 1px solid #007aff; */ - border-radius: 6px; - min-width: 20px; - min-height: 12px; - color: #ffffff; - font-size: 16px; -} - -#add-todo-button:hover { - /* background-color: #0056cc; */ - /* border-color: #0056cc; */ -} - -/* Todo items container */ -#todos-scrolled { - background-color: transparent; - min-height: 200px; /* Minimum height for content */ -} - -#todos-container { - padding: 4px; -} - -/* Individual todo items */ -#todo-item { - background-color: alpha(#000, 0.2); /* More visible background */ - border: 1px solid alpha(#111, 0.4); - border-radius: 12px; - padding: 12px; - margin: 4px 0; - min-height: 40px; /* Ensure minimum height */ - transition: background-color 0.2s ease; -} - -#todo-item:hover { - background-color: alpha(#fff, 0.12); -} - -/* Todo item controls */ -#todo-checkbox { - background-color: transparent; - border-radius: 50%; /* Circular appearance */ - min-width: 20px; - min-height: 20px; - margin-right: 8px; -} - -#todo-checkbox:hover { - border-color: #007aff; -} - -/* SVG icon styling for checkboxes and buttons */ -#todo-checkbox-icon { - color: #007aff; -} - -#todo-edit-icon, -#todo-delete-icon, -#add-todo-icon { - color: #ffffff; - opacity: 0.8; -} - -#todo-edit-icon:hover, -#todo-delete-icon:hover, -#add-todo-icon:hover { - opacity: 1; -} - -#todo-text { - font-size: 13px; - font-weight: 400; - color: #ffffff; - font-family: "SF Pro Rounded"; -} - -#todo-date { - font-size: 10px; - font-weight: 400; - color: #999999; - font-family: "SF Pro Rounded"; - margin-top: 2px; - opacity: 0.8; -} - -.todo-date-text { - color: #999999; - font-size: 10px; -} - -#todo-text-entry { - background-color: rgba(255, 255, 255, 0.1); - border: 1px solid rgba(255, 255, 255, 0.2); - border-radius: 4px; - padding: 4px 8px; - color: #ffffff; - font-size: 14px; - font-family: "SF Pro Rounded"; -} - -#todo-text-entry:focus { - border-color: #007aff; - box-shadow: 0 0 0 1px rgba(0, 122, 255, 0.3); - background-color: rgba(255, 255, 255, 0.15); -} - -/* Priority, edit, delete buttons */ -#todo-priority, -#todo-edit, -#todo-delete { - background-color: transparent; - border: none; - border-radius: 4px; - min-width: 20px; - min-height: 20px; - margin: 0 2px; - transition: background-color 0.2s ease; -} - -#todo-priority:hover, -#todo-edit:hover, -#todo-delete:hover { - background-color: alpha(#fff, 0.1); -} - -#todo-priority-label, -#todo-edit-label, -#todo-delete-label { - font-size: 12px; -} - -/* Clear completed button */ -#clear-completed-button { - background-color: alpha(#fff, 0.1); - border: 1px solid alpha(#999, 0.3); - border-radius: 8px; - padding: 8px 16px; - color: #999; - font-size: 12px; - font-weight: 500; - font-family: "SF Pro Rounded"; - margin-top: 8px; - transition: background-color 0.2s ease; -} - -#clear-completed-button:hover { - background-color: alpha(#fff, 0.15); - color: #ffffff; -} - -/* Completed todos styling */ -#todo-item.completed { - opacity: 0.6; -} - -#todo-item.completed #todo-text { - text-decoration: line-through; - color: #999; -} - -/* Priority styling */ -.priority-high #todo-priority-label { - color: #ff4444; -} - -.priority-medium #todo-priority-label { - color: #ffaa00; -} - -.priority-low #todo-priority-label { - color: #44ff44; -} - -/* Scrollbar styling to match control center */ -#todos-scrolled scrollbar { - background-color: transparent; - border-radius: 8px; - margin: 2px; - min-width: 8px; -} - -#todos-scrolled scrollbar slider { - background-color: alpha(#fff, 0.3); - border-radius: 4px; - min-width: 6px; - margin: 1px; -} - -#todos-scrolled scrollbar slider:hover { - background-color: alpha(#fff, 0.5); -} - -#todos-scrolled scrollbar slider:active { - background-color: alpha(#fff, 0.7); -} diff --git a/utils/__init__.py b/utils/__init__.py deleted file mode 100644 index 3f03f120..00000000 --- a/utils/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -""" -Modus services package. -Contains background services and utilities for the shell. -""" diff --git a/utils/animator.py b/utils/animator.py deleted file mode 100644 index 7dd063d4..00000000 --- a/utils/animator.py +++ /dev/null @@ -1,180 +0,0 @@ -from typing import cast - -from gi.repository import GLib, Gtk - -from fabric import Property, Service, Signal - - -class Animator(Service): - @Signal - def finished(self) -> None: ... - - @Property(tuple[float, float, float, float], "read-write") - def bezier_curve(self) -> tuple[float, float, float, float]: - return self._bezier_curve - - @bezier_curve.setter - def bezier_curve(self, value: tuple[float, float, float, float]): - self._bezier_curve = value - return - - @Property(float, "read-write") - def value(self): - return self._value - - @value.setter - def value(self, value: float): - self._value = value - return - - @Property(float, "read-write") - def max_value(self): - return self._max_value - - @max_value.setter - def max_value(self, value: float): - self._max_value = value - return - - @Property(float, "read-write") - def min_value(self): - return self._min_value - - @min_value.setter - def min_value(self, value: float): - self._min_value = value - return - - @Property(bool, "read-write", default_value=False) - def playing(self): - return self._playing - - @playing.setter - def playing(self, value: bool): - self._playing = value - return - - @Property(bool, "read-write", default_value=False) - def repeat(self): - return self._repeat - - @repeat.setter - def repeat(self, value: bool): - self._repeat = value - return - - def __init__( - self, - bezier_curve: tuple[float, float, float, float], - duration: float, - min_value: float = 0.0, - max_value: float = 1.0, - repeat: bool = False, - tick_widget: Gtk.Widget | None = None, - **kwargs, - ): - super().__init__(**kwargs) - self._bezier_curve = (1, 0, 1, 1) - self._duration = 5 - self._value = 0.0 - self._min_value = 0.0 - self._max_value = 1.0 - self._repeat = False - - self.bezier_curve = bezier_curve - self.duration = duration - self.value = min_value - self.min_value = min_value - self.max_value = max_value - self.repeat = repeat - - self.playing = False - self._start_time = None - self._tick_handler = None - self._timeline_pos = 0 - self._tick_widget = tick_widget - - def do_get_time_now(self): - return GLib.get_monotonic_time() / 1_000_000 - - def do_lerp(self, start: float, end: float, time: float) -> float: - return start + (end - start) * time - - def do_interpolate_cubic_bezier(self, time: float) -> float: - y_points = (0, self.bezier_curve[1], self.bezier_curve[3], 1) - return ( - (1 - time) ** 3 * y_points[0] - + 3 * (1 - time) ** 2 * time * y_points[1] - + 3 * (1 - time) * time**2 * y_points[2] - + time**3 * y_points[3] - ) - - def do_ease(self, time: float) -> float: - return self.do_lerp( - self.min_value, self.max_value, self.do_interpolate_cubic_bezier(time) - ) - - def do_update_value(self, delta_time: float): - if not self.playing: - return - - elapsed_time = delta_time - cast(float, self._start_time) - - self._timeline_pos = min(1, elapsed_time / self.duration) - - self.value = self.do_ease(self._timeline_pos) - - if not self._timeline_pos >= 1: - return - - if not self.repeat: - self.value = self.max_value - self.finished() - self.pause() - return - - self._start_time = delta_time - self._timeline_pos = 0 - return - - def do_handle_tick(self, *_): - current_time = self.do_get_time_now() - self.do_update_value(current_time) - return True - - def do_remove_tick_handlers(self): - if self._tick_handler: - if self._tick_widget: - self._tick_widget.remove_tick_callback(self._tick_handler) - else: - GLib.source_remove(self._tick_handler) - self._tick_handler = None - return - - def play(self): - if self.playing: - return - - self._start_time = self.do_get_time_now() - - if not self._tick_handler: - if self._tick_widget: - self._tick_handler = self._tick_widget.add_tick_callback( - self.do_handle_tick - ) - else: - self._tick_handler = GLib.timeout_add(16, self.do_handle_tick) - - self.playing = True - return - - def pause(self): - self.playing = False - return self.do_remove_tick_handlers() - - def stop(self): - if not self._tick_handler: - self._timeline_pos = 0 - self.playing = False - return - return self.do_remove_tick_handlers() diff --git a/utils/functions.py b/utils/functions.py deleted file mode 100644 index 7226ca91..00000000 --- a/utils/functions.py +++ /dev/null @@ -1,154 +0,0 @@ -import html -import json -import os -import threading -from typing import Dict, List, Optional - -from loguru import logger - -# Threading helper functions - - -def thread(target, *args, **kwargs) -> threading.Thread: - """ - Simply run the given function in a thread. - The provided args and kwargs will be passed to the function. - """ - th = threading.Thread(target=target, args=args, kwargs=kwargs, daemon=True) - th.start() - return th - - -def run_in_thread(func): - """ - Decorator to run the decorated function in a thread. - """ - - def wrapper(*args, **kwargs): - return thread(func, *args, **kwargs) - - return wrapper - - -@run_in_thread -def write_json_file(data: Dict, path: str): - try: - with open(path, "w") as f: - json.dump(data, f, indent=4, ensure_ascii=False) - except Exception as e: - logger.warning(f"Failed to write json: {e}") - - -def read_json_file(file_path: str) -> Optional[List]: - if not os.path.exists(file_path): - logger.error(f"JSON file {file_path} does not exist.") - return None - - with open(file_path, "r") as file: - try: - return json.load(file) - except json.JSONDecodeError as e: - logger.error(f"Failed to read JSON file {file_path}: {e}") - return None - - -def get_wifi_icon_for_strength(strength: int) -> str: - """ - Get the appropriate WiFi icon based on signal strength. - - Args: - strength: Signal strength from 0-100 - - Returns: - Absolute path to the appropriate WiFi icon - """ - # Get the current directory where this script is located - current_dir = os.path.dirname(os.path.abspath(__file__)) - # Get the project root (parent of utils directory) - project_root = os.path.dirname(current_dir) - - if strength >= 80: - icon_name = "network-wireless-100.svg" - elif strength >= 60: - icon_name = "network-wireless-80.svg" - elif strength >= 40: - icon_name = "network-wireless-60.svg" - elif strength >= 20: - icon_name = "network-wireless-40.svg" - elif strength > 0: - icon_name = "network-wireless-20.svg" - else: - icon_name = "network-wireless-0.svg" - - return os.path.join(project_root, "config", "assets", "icons", "wifi", icon_name) - - -def get_wifi_connecting_icon() -> str: - """ - Get the WiFi connecting icon path. - - Returns: - Absolute path to the WiFi connecting icon - """ - # Get the current directory where this script is located - current_dir = os.path.dirname(os.path.abspath(__file__)) - # Get the project root (parent of utils directory) - project_root = os.path.dirname(current_dir) - - return os.path.join( - project_root, "config", "assets", "icons", "wifi", "wifi-connecting.svg" - ) - - -# Function to check if a workspace ID is special -def is_special_workspace_id(ws_id) -> bool: - try: - # Convert to int if it's a string - workspace_id = int(ws_id) - # Special workspaces have negative IDs - return workspace_id < 0 - except (ValueError, TypeError): - # If it's a string, check if it starts with "special:" - if isinstance(ws_id, str) and ws_id.startswith("special:"): - return True - return False - - -# Function to check if a client is on a special workspace -def is_special_workspace(client: dict) -> bool: - if "workspace" not in client: - return False - - workspace = client["workspace"] - - # Check workspace name first - if "name" in workspace: - workspace_name = workspace["name"] - if is_special_workspace_id(workspace_name): - return True - - # Check workspace ID - if "id" in workspace: - workspace_id = workspace["id"] - if is_special_workspace_id(workspace_id): - return True - - return False - - -def escape_markup_text(text: str) -> str: - """ - Escape special characters in text to make it safe for Pango markup. - - Args: - text: Raw text that may contain special characters - - Returns: - Escaped text safe for use in Pango markup - """ - if not text or not isinstance(text, str): - return "" - - # Use html.escape to escape XML/HTML special characters - # This handles &, <, >, and quotes - return html.escape(text) diff --git a/utils/icon_resolver.py b/utils/icon_resolver.py deleted file mode 100644 index 58ac1e40..00000000 --- a/utils/icon_resolver.py +++ /dev/null @@ -1,106 +0,0 @@ -import json -import os -import re - -import gi - -gi.require_version("Gtk", "3.0") -from gi.repository import GLib, Gtk -from loguru import logger - -import config.data as data - -ICON_CACHE_FILE = data.CACHE_DIR + "/icons.json" -if not os.path.exists(data.CACHE_DIR): - os.makedirs(data.CACHE_DIR) - - -class IconResolver: - def __init__( - self, default_applicaiton_icon: str = "application-x-executable-symbolic" - ): - if os.path.exists(ICON_CACHE_FILE): - with open(ICON_CACHE_FILE) as f: - try: - self._icon_dict = json.load(f) - except json.JSONDecodeError: - logger.info("[ICONS] Cache file does not exist or is corrupted") - self._icon_dict = {} - else: - self._icon_dict = {} - - self.default_applicaiton_icon = default_applicaiton_icon - - def get_icon_name(self, app_id: str): - if app_id in self._icon_dict: - return self._icon_dict[app_id] - new_icon = self._compositor_find_icon(app_id) - logger.info( - f"[ICONS] found new icon: '{new_icon}' for app id: '{app_id}', storing..." - ) - self._store_new_icon(app_id, new_icon) - return new_icon - - def get_icon_pixbuf(self, app_id: str, size: int = 16): - icon_theme = Gtk.IconTheme.get_default() - icon_name = self.get_icon_name(app_id) - try: - # Try to load the resolved icon. - return icon_theme.load_icon(icon_name, size, Gtk.IconLookupFlags.FORCE_SIZE) - except GLib.Error as primary_error: - logger.warning( - f"Warning: Icon '{icon_name}' not found in theme. Error: {primary_error}" - ) - try: - # Fallback to the default application icon. - return icon_theme.load_icon( - self.default_applicaiton_icon, size, Gtk.IconLookupFlags.FORCE_SIZE - ) - except GLib.Error as fallback_error: - logger.error( - f"Error: Fallback icon '{self.default_applicaiton_icon}' also not found. Error: {fallback_error}" - ) - return None - - def _store_new_icon(self, app_id: str, icon: str): - self._icon_dict[app_id] = icon - with open(ICON_CACHE_FILE, "w") as f: - json.dump(self._icon_dict, f) - - def _get_icon_from_desktop_file(self, desktop_file_path: str): - # Retrieve the icon specified in the [Desktop Entry] section. - with open(desktop_file_path) as f: - for line in f.readlines(): - if "Icon=" in line: - return "".join(line[5:].split()) - return self.default_applicaiton_icon - - def _get_desktop_file(self, app_id: str) -> str | None: - data_dirs = GLib.get_system_data_dirs() - for data_dir in data_dirs: - data_dir = os.path.join(data_dir, "applications") - if os.path.exists(data_dir): - files = os.listdir(data_dir) - matching = [ - s for s in files if "".join(app_id.lower().split()) in s.lower() - ] - if matching: - return os.path.join(data_dir, matching[0]) - for word in list(filter(None, re.split(r"-|\.|_|\s", app_id))): - matching = [s for s in files if word.lower() in s.lower()] - if matching: - return os.path.join(data_dir, matching[0]) - return None - - def _compositor_find_icon(self, app_id: str): - icon_theme = Gtk.IconTheme.get_default() - if icon_theme.has_icon(app_id): - return app_id - if icon_theme.has_icon(app_id + "-desktop"): - return app_id + "-desktop" - desktop_file = self._get_desktop_file(app_id) - return ( - self._get_icon_from_desktop_file(desktop_file) - if desktop_file - else self.default_applicaiton_icon - ) diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..2fb5a1b3 --- /dev/null +++ b/uv.lock @@ -0,0 +1,361 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "certifi" +version = "2026.4.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "click" +version = "8.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "fabric" +version = "0.0.2" +source = { git = "https://github.com/Fabric-Development/fabric.git#23cab85fc61f1cdbb5f60db0027dc57026ba05b7" } +dependencies = [ + { name = "click" }, + { name = "loguru" }, + { name = "pycairo" }, + { name = "pygobject" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, +] + +[[package]] +name = "loguru" +version = "0.7.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "win32-setctime", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, +] + +[[package]] +name = "modus" +version = "0.2.0" +source = { editable = "." } +dependencies = [ + { name = "fabric" }, + { name = "httpx" }, + { name = "pam" }, + { name = "psutil" }, + { name = "pywayland" }, + { name = "six" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pyinstrument" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "fabric", git = "https://github.com/Fabric-Development/fabric.git" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "pam", specifier = ">=0.2.0" }, + { name = "psutil", specifier = ">=7.2.2" }, + { name = "pywayland", specifier = ">=0.4.18" }, + { name = "six", specifier = ">=1.17.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pyinstrument", specifier = ">=5.1.2" }, + { name = "ruff", specifier = ">=0.15.2" }, +] + +[[package]] +name = "pam" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-pam" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/25/74fcd42f6a76c7d06ca161c11a2455ddd4f68a76545241f7d3ea3354c6d6/pam-0.2.0-py3-none-any.whl", hash = "sha256:fcec42cee82e164b77fdf70c52117c668645c17d9756b3af66058816a5102401", size = 976, upload-time = "2020-11-11T23:11:01.769Z" }, +] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[package]] +name = "pycairo" +version = "1.29.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/d9/1728840a22a4ef8a8f479b9156aa2943cd98c3907accd3849fb0d5f82bfd/pycairo-1.29.0.tar.gz", hash = "sha256:f3f7fde97325cae80224c09f12564ef58d0d0f655da0e3b040f5807bd5bd3142", size = 665871, upload-time = "2025-11-11T19:13:01.584Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/21/3f477dc318dd4e84a5ae6301e67284199d7e5a2384f3063714041086b65d/pycairo-1.29.0-cp313-cp313-win32.whl", hash = "sha256:3eb382a4141591807073274522f7aecab9e8fa2f14feafd11ac03a13a58141d7", size = 750949, upload-time = "2025-11-11T19:12:12.198Z" }, + { url = "https://files.pythonhosted.org/packages/43/34/7d27a333c558d6ac16dbc12a35061d389735e99e494ee4effa4ec6d99bed/pycairo-1.29.0-cp313-cp313-win_amd64.whl", hash = "sha256:91114e4b3fbf4287c2b0788f83e1f566ce031bda49cf1c3c3c19c3e986e95c38", size = 844149, upload-time = "2025-11-11T19:12:19.171Z" }, + { url = "https://files.pythonhosted.org/packages/15/43/e782131e23df69e5c8e631a016ed84f94bbc4981bf6411079f57af730a23/pycairo-1.29.0-cp313-cp313-win_arm64.whl", hash = "sha256:09b7f69a5ff6881e151354ea092137b97b0b1f0b2ab4eb81c92a02cc4a08e335", size = 693595, upload-time = "2025-11-11T19:12:23.445Z" }, + { url = "https://files.pythonhosted.org/packages/2d/fa/87eaeeb9d53344c769839d7b2854db7ff2cd596211e00dd1b702eeb1838f/pycairo-1.29.0-cp314-cp314-win32.whl", hash = "sha256:69e2a7968a3fbb839736257bae153f547bca787113cc8d21e9e08ca4526e0b6b", size = 767198, upload-time = "2025-11-11T19:12:42.336Z" }, + { url = "https://files.pythonhosted.org/packages/3c/90/3564d0f64d0a00926ab863dc3c4a129b1065133128e96900772e1c4421f8/pycairo-1.29.0-cp314-cp314-win_amd64.whl", hash = "sha256:e91243437a21cc4c67c401eff4433eadc45745275fa3ade1a0d877e50ffb90da", size = 871579, upload-time = "2025-11-11T19:12:48.982Z" }, + { url = "https://files.pythonhosted.org/packages/5e/91/93632b6ba12ad69c61991e3208bde88486fdfc152be8cfdd13444e9bc650/pycairo-1.29.0-cp314-cp314-win_arm64.whl", hash = "sha256:b72200ea0e5f73ae4c788cd2028a750062221385eb0e6d8f1ecc714d0b4fdf82", size = 719537, upload-time = "2025-11-11T19:12:55.016Z" }, + { url = "https://files.pythonhosted.org/packages/93/23/37053c039f8d3b9b5017af9bc64d27b680c48a898d48b72e6d6583cf0155/pycairo-1.29.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5e45fce6185f553e79e4ef1722b8e98e6cde9900dbc48cb2637a9ccba86f627a", size = 874015, upload-time = "2025-11-11T19:12:28.47Z" }, + { url = "https://files.pythonhosted.org/packages/d7/54/123f6239685f5f3f2edc123f1e38d2eefacebee18cf3c532d2f4bd51d0ef/pycairo-1.29.0-cp314-cp314t-win_arm64.whl", hash = "sha256:caba0837a4b40d47c8dfb0f24cccc12c7831e3dd450837f2a356c75f21ce5a15", size = 721404, upload-time = "2025-11-11T19:12:36.919Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pygobject" +version = "3.50.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycairo" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2b/58/d34e67a79631177e3c08e7d02b5165147f590171f2cae6769502af5f7f7e/pygobject-3.50.0.tar.gz", hash = "sha256:4500ad3dbf331773d8dedf7212544c999a76fc96b63a91b3dcac1e5925a1d103", size = 1080367, upload-time = "2024-09-12T12:05:22.579Z" } + +[[package]] +name = "pyinstrument" +version = "5.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/32/7f/d3c4ef7c43f3294bd5a475dfa6f295a9fee5243c292d5c8122044fa83bcb/pyinstrument-5.1.2.tar.gz", hash = "sha256:af149d672da9493fa37334a1cc68f7b80c3e6cb9fd99b9e426c447db5c650bf0", size = 266889, upload-time = "2026-01-04T18:38:58.464Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/8e/b9aea969eec67c129652000446384d550a0df45c297adc9fd74da2f8482c/pyinstrument-5.1.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7b8bab2334bf1d4c9e92d61db574300b914b594588a6b6dd67c45450152dfc29", size = 131418, upload-time = "2026-01-04T18:37:58.642Z" }, + { url = "https://files.pythonhosted.org/packages/8f/62/76418eb29b5591f3e5500369a6777ce928135c3aa6ccdb0c861a9c6ca93b/pyinstrument-5.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:13dcc138a61298ef4994b7aebff509d2c06db89dfd6e2021f0b9cd96aaa44ec3", size = 124448, upload-time = "2026-01-04T18:37:59.95Z" }, + { url = "https://files.pythonhosted.org/packages/07/73/874bccc04bcf6f4babc3de1a9568e209e7e40998563974f5030b0fb4d3e0/pyinstrument-5.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8abd4a7ffa2e7f9e00039a5e549e8eebc80d7ca8d43f0fb51a50ff2b117ce4a", size = 149853, upload-time = "2026-01-04T18:38:01.405Z" }, + { url = "https://files.pythonhosted.org/packages/cf/85/268446c4388d77ff4abdeaff202356e1527b3ff9576f5587443a24980bec/pyinstrument-5.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eb3a05108edebc30f31e2c69c904576042f1158b2513ab80adc08f7848a7a8f0", size = 148641, upload-time = "2026-01-04T18:38:03.086Z" }, + { url = "https://files.pythonhosted.org/packages/fc/15/4f8dea3381483e68d00582a9b823a21a088acfe77a847a7991a1a8feed76/pyinstrument-5.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f70d588b53f3f35829d1d1ddfa05e07fcebf1434b3b1509d542ca317d8e9a2a5", size = 148674, upload-time = "2026-01-04T18:38:04.805Z" }, + { url = "https://files.pythonhosted.org/packages/fa/61/72c180454b6511d5b90166f8828e1bab3b45d0489952a1fe48c5c585233d/pyinstrument-5.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b007327e0d6a6a01d5064883dd27c19996f044ce7488d507826fee7884e6a32e", size = 148315, upload-time = "2026-01-04T18:38:06.114Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f0/4c27cebddf22a8840bd8b419366bb321ce41f921ca1893e309c932ab28bf/pyinstrument-5.1.2-cp313-cp313-win32.whl", hash = "sha256:9ba0e6b17a7e86c3dc02d208e4c25506e8f914d9964ae89449f1f37f0b70abc0", size = 125926, upload-time = "2026-01-04T18:38:07.507Z" }, + { url = "https://files.pythonhosted.org/packages/6c/20/6b1bee88ddef065b0df3a3ba4ba60ed8a9ca443d5cded7152a8a9750914f/pyinstrument-5.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:660d7fc486a839814db0b2f716bc13d8b99b9c780aaeb47f74a70a34adc02a7b", size = 126678, upload-time = "2026-01-04T18:38:08.826Z" }, + { url = "https://files.pythonhosted.org/packages/66/0f/7d5154c92904bdf25be067a7fe4cad4ba48919f16ccbb51bb953d9ae1a20/pyinstrument-5.1.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0baed297beee2bb9897e737bbd89e3b9d45a2fbbea9f1ad4e809007d780a9b1e", size = 131388, upload-time = "2026-01-04T18:38:10.491Z" }, + { url = "https://files.pythonhosted.org/packages/17/28/bf83231a3f951e11b4dfaf160e1eeba1ce29377eab30e3d2eb6ee22ff3ba/pyinstrument-5.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ebb910a32a45bde6c3fc30c578efc28a54517990e11e94b5e48a0d5479728568", size = 124456, upload-time = "2026-01-04T18:38:11.792Z" }, + { url = "https://files.pythonhosted.org/packages/ac/98/762cf10896d907268629e1db08a48f128984a53e8d92b99ea96f862597e5/pyinstrument-5.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bad403c157f9c6dba7f731a6fca5bfcd8ca2701a39bcc717dcc6e0b10055ffc4", size = 149594, upload-time = "2026-01-04T18:38:13.434Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1b/48580e16e623d89af58b89c552c95a2ae65f70a1f4fab1d97879f34791db/pyinstrument-5.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f456cabdb95fd343c798a7f2a56688b028f981522e283c5f59bd59195b66df5", size = 148339, upload-time = "2026-01-04T18:38:14.767Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/38157a8a6ec67789d8ee109fd09877ea3340df44e1a7add8f249e30a8ade/pyinstrument-5.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4e9c4dcc1f2c4a0cd6b576e3604abc37496a7868243c9a1443ad3b9db69d590f", size = 148485, upload-time = "2026-01-04T18:38:16.121Z" }, + { url = "https://files.pythonhosted.org/packages/4b/34/31ee72b19cfc48a82801024b5d653f07982154a11381a3ae65bbfdbf2c7b/pyinstrument-5.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:acf93b128328c6d80fdb85431068ac17508f0f7845e89505b0ea6130dead5ca6", size = 148106, upload-time = "2026-01-04T18:38:17.623Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b4/7ab20243187262d66ab062778b1ccac4ca55090752f32a83f603f4e5e3a2/pyinstrument-5.1.2-cp314-cp314-win32.whl", hash = "sha256:9c7f0167903ecff8b1d744f7e37b2bd4918e05a69cca724cb112f5ed59d1e41b", size = 126593, upload-time = "2026-01-04T18:38:18.968Z" }, + { url = "https://files.pythonhosted.org/packages/9e/a0/db6a8ae3182546227f5a043b1be29b8d5f98bf973e20d922981ef206de85/pyinstrument-5.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:ce3f6b1f9a2b5d74819ecc07d631eadececf915f551474a75ad65ac580ec5a0e", size = 127358, upload-time = "2026-01-04T18:38:20.28Z" }, + { url = "https://files.pythonhosted.org/packages/59/d2/719f439972b3f80e35fb5b1bcd888c3218d60dbc91957b99ffafd7ac9221/pyinstrument-5.1.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:af8651b239049accbeecd389d35823233f649446f76f47fd005316b05d08cef2", size = 132317, upload-time = "2026-01-04T18:38:21.669Z" }, + { url = "https://files.pythonhosted.org/packages/e2/1c/0ebfef69ae926665fae635424c5647411235c3689c9a9ad69fd68de6cae2/pyinstrument-5.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c6082f1c3e43e1d22834e91ba8975f0080186df4018a04b4dd29f9623c59df1d", size = 124917, upload-time = "2026-01-04T18:38:23.385Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ee/5599f769f515a0f1c97443edc7394fe2b9829bf39f404c046499c1a62378/pyinstrument-5.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c031eb066ddc16425e1e2f56aad5c1ce1e27b2432a70329e5385b85e812decee", size = 157407, upload-time = "2026-01-04T18:38:24.774Z" }, + { url = "https://files.pythonhosted.org/packages/fd/40/32aa865252288caef301237488ee309bd6701125888bf453d23ab764e357/pyinstrument-5.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f447ec391cad30667ba412dce41607aaa20d4a2496a7ab867e0c199f0fe3ae3d", size = 155068, upload-time = "2026-01-04T18:38:26.112Z" }, + { url = "https://files.pythonhosted.org/packages/91/68/0b56a1540fe1c357dfcda82d4f5b52c87fada5962cbf18703ea39ccbbe69/pyinstrument-5.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:50299bddfc1fe0039898f895b10ef12f9db08acffb4d85326fad589cda24d2ee", size = 155186, upload-time = "2026-01-04T18:38:27.914Z" }, + { url = "https://files.pythonhosted.org/packages/7a/48/7ef84abfc3e41148cf993095214f104e75ecff585e94c6e8be001e672573/pyinstrument-5.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a193ff08825ece115ececa136832acb14c491c77ab1e6b6a361905df8753d5c6", size = 153979, upload-time = "2026-01-04T18:38:29.236Z" }, + { url = "https://files.pythonhosted.org/packages/8f/cf/a28ad117d58b33c1d74bcdfbbcf1603b67346883800ac7d510cff8d3bcee/pyinstrument-5.1.2-cp314-cp314t-win32.whl", hash = "sha256:de887ba19e1057bd2d86e6584f17788516a890ae6fe1b7eed9927873f416b4d8", size = 127267, upload-time = "2026-01-04T18:38:30.619Z" }, + { url = "https://files.pythonhosted.org/packages/8e/97/03635143a12a5d941f545548b00f8ac39d35565321a2effb4154ed267338/pyinstrument-5.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:b6a71f5e7f53c86c9b476b30cf19509463a63581ef17ddbd8680fee37ae509db", size = 128164, upload-time = "2026-01-04T18:38:32.281Z" }, +] + +[[package]] +name = "python-pam" +version = "2.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/da/879f1c849e886b783239b8a4710daac73535ba2cfcf672ee4548543e3a74/python-pam-2.0.2.tar.gz", hash = "sha256:97235235ba9b82dbae8068d1099508455949b275f77273ca22fdbd8b1fb5d950", size = 11439, upload-time = "2022-03-18T00:32:09.856Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/2d/9fbb3bd686a474d76fbd0b79abdcc016f3da760b1d1c2048bf4c611a4939/python_pam-2.0.2-py3-none-any.whl", hash = "sha256:4ac51dd8953ac59aa45505882b565eef6a22e0423dcf25d63369902080416c20", size = 10658, upload-time = "2022-03-18T00:32:07.802Z" }, +] + +[[package]] +name = "pywayland" +version = "0.4.18" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/93/bc9b258dcb46aafc587438355a9170c3be05a6abb5cd55bf1d600d848fa0/pywayland-0.4.18.tar.gz", hash = "sha256:598ade02783aad05a328f663b51b694c5ab68bd5d1e0926c0da3c5212c566533", size = 247348, upload-time = "2024-07-27T19:24:38.008Z" } + +[[package]] +name = "ruff" +version = "0.15.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, + { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" }, + { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" }, + { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" }, + { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" }, + { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" }, + { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" }, + { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" }, + { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" }, + { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, + { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "win32-setctime" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, +] diff --git a/widgets/customrevealer.py b/widgets/customrevealer.py deleted file mode 100644 index bf47e52d..00000000 --- a/widgets/customrevealer.py +++ /dev/null @@ -1,320 +0,0 @@ -from gi.repository import GLib, Gtk -import gi -import math - -gi.require_version("Gtk", "3.0") - -# TODO: UsE BETTER APPROACH IF POSSIBLE - - -class AnimationManager: - _instance = None - _animating_widgets = set() - _timer_id = None - _containers_to_redraw = set() - - @classmethod - def get_instance(cls): - if cls._instance is None: - cls._instance = cls() - return cls._instance - - def add_widget(self, widget): - self._animating_widgets.add(widget) - if self._timer_id is None: - # Use 120 FPS for ultra-smooth animations like macOS - self._timer_id = GLib.timeout_add(8, self._animate_all) # 120 FPS - - - def remove_widget(self, widget): - self._animating_widgets.discard(widget) - if not self._animating_widgets and self._timer_id: - # Stop timer when no widgets are animating - GLib.source_remove(self._timer_id) - self._timer_id = None - # Clear any pending redraws - self._containers_to_redraw.clear() - - def _animate_all(self): - # Clear previous frame's redraw queue - self._containers_to_redraw.clear() - - widgets_to_remove = [] - - # Process all animations in a single frame - for widget in list(self._animating_widgets): - if not widget._calculate_position(): - widgets_to_remove.append(widget) - else: - container = widget._get_container_for_redraw() - if container: - self._containers_to_redraw.add(container) - - # Apply all position changes at once to prevent conflicts - for widget in self._animating_widgets: - widget._apply_position() - - # Batch redraw calls to minimize performance impact - for container in self._containers_to_redraw: - container.queue_draw() - - # Remove completed animations - for widget in widgets_to_remove: - self.remove_widget(widget) - - return len(self._animating_widgets) > 0 # Continue if widgets remain - - def get_active_widget_count(self): - """Return the number of currently animating widgets""" - return len(self._animating_widgets) - - def _get_optimal_interval(self): - """Keep consistent 120 FPS for macOS-like smoothness""" - return 8 # 120 FPS - - - def _start_timer(self): - interval = self._get_optimal_interval() - self._timer_id = GLib.timeout_add(interval, self._animate_all) - - def _adjust_frame_rate(self): - # No need to adjust frame rate anymore - keep it consistent - pass - - -class MacOSEasing: - """macOS-style easing functions for natural motion""" - - @staticmethod - def ease_out_expo(t): - """Exponential ease out - fast start, slow end""" - return 1 - math.pow(2, -10 * t) if t != 1 else 1 - - @staticmethod - def ease_in_out_quart(t): - """Quartic ease in-out for smooth acceleration/deceleration""" - return 8 * t * t * t * t if t < 0.5 else 1 - math.pow(-2 * t + 2, 4) / 2 - - @staticmethod - def ease_out_back(t): - """Back ease out for slight overshoot effect""" - c1 = 1.70158 - c3 = c1 + 1 - return 1 + c3 * math.pow(t - 1, 3) + c1 * math.pow(t - 1, 2) - - @staticmethod - def ease_out_cubic_bezier(t): - """Custom cubic bezier similar to macOS default (0.25, 0.1, 0.25, 1.0)""" - # Approximation of cubic-bezier(0.25, 0.1, 0.25, 1.0) - return t * t * t * (t * (6 * t - 15) + 10) - - @staticmethod - def ease_in_cubic(t): - """Cubic ease in for smooth acceleration""" - return t * t * t - - @staticmethod - def ease_out_quint(t): - """Quintic ease out for very smooth deceleration""" - return 1 - math.pow(1 - t, 5) - - -class SlideRevealer(Gtk.Overlay): - def __init__(self, child: Gtk.Widget, direction="right", duration=350, size=None): - super().__init__() - - self.child = child - self.direction = direction - self.duration = duration # Slightly faster for snappier feel - self.fixed_size = size - self._revealed = False - self._animating = False - self._start_time = None - self._show_animation = False - self._pending_position = None - self._current_position = (0.0, 0.0) # Use float for sub-pixel positioning - self._animation_id = None # Track individual animation instances - - self._fixed = Gtk.Fixed() - self._fixed.set_has_window(False) - self._fixed.add(child) - self.add_overlay(self._fixed) - - if self.fixed_size: - self.set_size_request(self.fixed_size[0], self.fixed_size[1]) - child.hide() - self.show_all() - else: - child.connect("size-allocate", self._on_size_allocate) - child.hide() - self.show_all() - - def _on_size_allocate(self, _widget, allocation): - if not self.fixed_size: - current_req = self.get_size_request() - if ( - current_req[0] != allocation.width - or current_req[1] != allocation.height - ): - self.set_size_request(allocation.width, allocation.height) - - def set_reveal_child(self, reveal: bool): - if reveal: - self.reveal() - else: - self.hide() - - def reveal(self): - if self._revealed and not self._animating: - return - self._revealed = True - - # Ensure widget is properly laid out before starting animation - if self.get_realized(): - self._start_animation(show=True) - else: - # Wait for widget to be realized - def on_realize(*_): - self._start_animation(show=True) - self.disconnect_by_func(on_realize) - self.connect("realize", on_realize) - - def hide(self): - if not self._revealed and not self._animating: - return - self._revealed = False - self._start_animation(show=False) - - def _start_animation(self, show: bool): - # Stop any existing animation for this widget - if self._animating: - AnimationManager.get_instance().remove_widget(self) - - self._cached_dimensions = self._get_dimensions() - if self._cached_dimensions[0] == 0 or self._cached_dimensions[1] == 0: - self._animating = False - return - - # Use high-precision monotonic time for smooth animations - self._start_time = GLib.get_monotonic_time() - self._animating = True - self._show_animation = show - self._animation_id = id(self) # Unique ID for this animation instance - - if show: - self.child.show() - pos = self._get_offscreen_pos_cached() - self._current_position = (float(pos[0]), float(pos[1])) - self._fixed.move(self.child, int(pos[0]), int(pos[1])) - - def start_with_dimensions(): - AnimationManager.get_instance().add_widget(self) - return False - - # Use idle_add to ensure layout is complete - GLib.idle_add(start_with_dimensions) - - def _calculate_position(self): - if not self._animating: - return False - - elapsed = (GLib.get_monotonic_time() - self._start_time) / 1000 - t = min(elapsed / self.duration, 1.0) - - # Use different easing functions for better smoothness - if self._show_animation: - # Use quintic ease out for very smooth revealing - eased = MacOSEasing.ease_out_quint(t) - else: - # Use cubic ease in for smooth hiding - eased = MacOSEasing.ease_in_cubic(t) - - self._pending_position = self._get_position_at_progress_cached(eased) - - if t >= 1.0: - self._animating = False - self._cached_dimensions = None - self._animation_id = None - if not self._show_animation: - GLib.idle_add(lambda: self.child.hide()) - return False - return True - - def _apply_position(self): - if self._pending_position: - x, y = self._pending_position - # Use sub-pixel positioning for smoother motion - self._current_position = (x, y) - # Round to nearest pixel for actual positioning - pixel_x, pixel_y = int(round(x)), int(round(y)) - self._fixed.move(self.child, pixel_x, pixel_y) - self._pending_position = None - - def _get_container_for_redraw(self): - return self - - def _get_dimensions(self): - if self.fixed_size: - return self.fixed_size - else: - alloc = self.child.get_allocation() - return alloc.width, alloc.height - - def _get_offscreen_pos_cached(self): - w, h = self._cached_dimensions - if self.direction == "left": - return -w, 0 - elif self.direction == "right": - return w, 0 - elif self.direction == "top": - return 0, -h - elif self.direction == "bottom": - return 0, h - return 0, 0 - - def _get_position_at_progress_cached(self, progress): - w, h = self._cached_dimensions - if self._show_animation: - # Showing animation: slide from offscreen to onscreen (0,0) - if self.direction == "left": - return -w + w * progress, 0.0 - elif self.direction == "right": - return w - w * progress, 0.0 - elif self.direction == "top": - return 0.0, -h + h * progress - elif self.direction == "bottom": - return 0.0, h - h * progress - else: - # Hiding animation: slide from onscreen (0,0) to offscreen - if self.direction == "left": - return -w * progress, 0.0 # Slide left (negative x) - elif self.direction == "right": - return w * progress, 0.0 # Slide right (positive x) - elif self.direction == "top": - return 0.0, -h * progress # Slide up (negative y) - elif self.direction == "bottom": - return 0.0, h * progress # Slide down (positive y) - return 0.0, 0.0 - - def set_slide_direction(self, direction): - self.direction = direction - - def is_revealed(self): - return self._revealed - - def is_animating(self): - return self._animating - - def get_child_revealed(self): - return self._revealed - - def stop_animation(self): - if self._animating: - AnimationManager.get_instance().remove_widget(self) - self._animating = False - self._cached_dimensions = None - self._animation_id = None - - def destroy(self): - self.stop_animation() - super().destroy() \ No newline at end of file diff --git a/widgets/mousecapture.py b/widgets/mousecapture.py deleted file mode 100644 index af432fc1..00000000 --- a/widgets/mousecapture.py +++ /dev/null @@ -1,142 +0,0 @@ -from typing import Any - -import cairo -from gi.repository import GLib, GtkLayerShell # type: ignore - -from fabric.widgets.eventbox import EventBox -from fabric.widgets.widget import Widget -from utils.roam import modus_service -from widgets.wayland import WaylandWindow as Window - - -class MouseCapture(Window): - """A background overlay that captures outside clicks without blocking child window interactions""" - - def __init__(self, layer: str, child_window: Window, **kwargs): - super().__init__( - layer="top", # Use top layer to capture events - anchor="top bottom left right", - exclusivity="auto", - title="modus", - name="MouseCapture", - keyboard_mode="none", # Don't steal keyboard - all_visible=False, - visible=False, - **kwargs, - ) - - GtkLayerShell.set_exclusive_zone(self, -1) - - self.child_window = child_window - - # Ensure child window is on overlay layer to be above this capture - if hasattr(self.child_window, "layer"): - self.child_window.layer = "overlay" - - if hasattr(self.child_window, "_init_mousecapture"): - self.child_window._init_mousecapture(self) - - # Create transparent event box that captures clicks - self.event_box = EventBox( - events=["button-press-event"], - all_visible=True, - ) - self.event_box.connect("button-press-event", self.on_overlay_click) - self.children = [self.event_box] - - # Make the overlay transparent - self.set_app_paintable(True) - self.connect("draw", self.on_draw) - - # Add escape key binding to child window - if hasattr(self.child_window, "add_keybinding"): - self.child_window.add_keybinding("Escape", self.hide_child_window) - - def on_draw(self, _widget, cr): - """Make overlay transparent""" - cr.set_source_rgba(0, 0, 0, 0) # Fully transparent - cr.set_operator(cairo.OPERATOR_SOURCE) - cr.paint() - return False - - def on_overlay_click(self, _widget, event): - """Handle overlay clicks - check if click is outside child window""" - if not self.child_window.is_visible(): - return False - - # Get click coordinates - click_x = event.x_root - click_y = event.y_root - - # Get child window bounds - try: - child_x, child_y = self.child_window.get_position() - child_allocation = self.child_window.get_allocation() - - # Check if click is inside child window bounds - inside_child = ( - child_x <= click_x <= child_x + child_allocation.width - and child_y <= click_y <= child_y + child_allocation.height - ) - - if not inside_child: - # Click is outside child window - hide it with delay - GLib.timeout_add( - 50, lambda: self.hide_child_window(None, None) or False - ) - return True # Consume the event - - except Exception as e: - print(f"Error checking click position: {e}") - # If we can't determine position, hide child window to be safe - GLib.timeout_add(50, lambda: self.hide_child_window(None, None) or False) - return True - - # Click is inside child window - don't consume event - return False - - def show_child_window(self, widget: Widget = None, event: Any = None) -> None: - self.set_child_window_visible(True) - - def hide_child_window(self, widget: Widget = None, event: Any = None) -> None: - self.set_child_window_visible(False) - - def set_child_window_visible(self, visible: bool) -> None: - if visible: - self.child_window.show() - self.show() - else: - self.child_window.hide() - self.hide() - - if hasattr(self.child_window, "_set_mousecapture"): - self.child_window._set_mousecapture(visible) - - def toggle_mousecapture(self, *_) -> None: - if self.is_visible(): - self.set_child_window_visible(False) - else: - self.set_child_window_visible(True) - - -class DropDownMouseCapture(MouseCapture): - """A specialized MouseCapture for dropdown menus with service integration""" - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - modus_service.connect("dropdowns-hide-changed", self.dropdowns_hide_changed) - - def hide_child_window(self, widget: Widget = None, event: Any = None) -> None: - """Hide child window and update dropdown service state""" - # Update service state before hiding to prevent conflicts - if hasattr(self.child_window, "id"): - if str(modus_service.current_dropdown) == str(self.child_window.id): - modus_service.current_dropdown = None - super().hide_child_window(widget, event) - - def dropdowns_hide_changed(self, widget: Widget = None, event: Any = None) -> None: - """Handle dropdown service hide changes""" - if hasattr(self.child_window, "id"): - if modus_service.current_dropdown == self.child_window.id: - return - return self.hide_child_window(widget, event) diff --git a/widgets/wayland.py b/widgets/wayland.py deleted file mode 100644 index b7c76aa9..00000000 --- a/widgets/wayland.py +++ /dev/null @@ -1,370 +0,0 @@ -import re -from collections.abc import Iterable -from enum import Enum -from typing import Literal, cast - -import cairo -import gi -from gi.repository import Gdk, GObject, Gtk -from loguru import logger - -from fabric.core.service import Property -from fabric.utils.helpers import extract_css_values, get_enum_member -from fabric.widgets.window import Window - -gi.require_version("Gtk", "3.0") - -try: - gi.require_version("GtkLayerShell", "0.1") - from gi.repository import GtkLayerShell -except: - raise ImportError( - "looks like we don't have gtk-layer-shell installed, make sure to install it first (as well as using wayland)" - ) - - -class WaylandWindowExclusivity(Enum): - NONE = 1 - NORMAL = 2 - AUTO = 3 - - -class Layer(GObject.GEnum): - BACKGROUND = 0 - BOTTOM = 1 - TOP = 2 - OVERLAY = 3 - ENTRY_NUMBER = 4 - - -class KeyboardMode(GObject.GEnum): - NONE = 0 - EXCLUSIVE = 1 - ON_DEMAND = 2 - ENTRY_NUMBER = 3 - - -class Edge(GObject.GEnum): - LEFT = 0 - RIGHT = 1 - TOP = 2 - BOTTOM = 3 - ENTRY_NUMBER = 4 - - -class WaylandWindow(Window): - @Property( - Layer, - flags="read-write", - default_value=Layer.TOP, - ) - def layer(self) -> Layer: # type: ignore - return self._layer # type: ignore - - @layer.setter - def layer( - self, - value: Literal["background", "bottom", "top", "overlay"] | Layer, - ) -> None: - self._layer = get_enum_member(Layer, value, default=Layer.TOP) - return GtkLayerShell.set_layer(self, self._layer) - - @Property(int, "read-write") - def monitor(self) -> int: - if not (monitor := cast(Gdk.Monitor, GtkLayerShell.get_monitor(self))): - return -1 - display = monitor.get_display() or Gdk.Display.get_default() - for i in range(0, display.get_n_monitors()): - if display.get_monitor(i) is monitor: - return i - return -1 - - @monitor.setter - def monitor(self, monitor: int | Gdk.Monitor) -> bool: - if isinstance(monitor, int): - display = Gdk.Display().get_default() - monitor = display.get_monitor(monitor) - return ( - (GtkLayerShell.set_monitor(self, monitor), True)[1] - if monitor is not None - else False - ) - - @Property(WaylandWindowExclusivity, "read-write") - def exclusivity(self) -> WaylandWindowExclusivity: - return self._exclusivity - - @exclusivity.setter - def exclusivity( - self, value: Literal["none", "normal", "auto"] | WaylandWindowExclusivity - ) -> None: - value = get_enum_member( - WaylandWindowExclusivity, value, default=WaylandWindowExclusivity.NONE - ) - self._exclusivity = value - match value: - case WaylandWindowExclusivity.NORMAL: - return GtkLayerShell.set_exclusive_zone(self, True) - case WaylandWindowExclusivity.AUTO: - return GtkLayerShell.auto_exclusive_zone_enable(self) - case _: - return GtkLayerShell.set_exclusive_zone(self, False) - - @Property(bool, "read-write", default_value=False) - def pass_through(self) -> bool: - return self._pass_through - - @pass_through.setter - def pass_through(self, pass_through: bool = False): - self._pass_through = pass_through - region = cairo.Region() if pass_through is True else None - self.input_shape_combine_region(region) - del region - return - - @Property( - KeyboardMode, - "read-write", - default_value=KeyboardMode.NONE, - ) - def keyboard_mode(self) -> KeyboardMode: - return self._keyboard_mode - - @keyboard_mode.setter - def keyboard_mode( - self, - value: ( - Literal[ - "none", - "exclusive", - "on-demand", - "entry-number", - ] - | KeyboardMode - ), - ): - self._keyboard_mode = get_enum_member( - KeyboardMode, value, default=KeyboardMode.NONE - ) - return GtkLayerShell.set_keyboard_mode(self, self._keyboard_mode) - - @Property(tuple[Edge, ...], "read-write") - def anchor(self): - return tuple( - x - for x in [ - Edge.TOP, - Edge.RIGHT, - Edge.BOTTOM, - Edge.LEFT, - ] - if GtkLayerShell.get_anchor(self, x) - ) - - @anchor.setter - def anchor(self, value: str | Iterable[Edge]) -> None: - self._anchor = value - if isinstance(value, (list, tuple)) and all( - isinstance(edge, Edge) for edge in value - ): - for edge in [ - Edge.TOP, - Edge.RIGHT, - Edge.BOTTOM, - Edge.LEFT, - ]: - if edge not in value: - GtkLayerShell.set_anchor(self, edge, False) - GtkLayerShell.set_anchor(self, edge, True) - return - elif isinstance(value, str): - for edge, anchored in WaylandWindow.extract_edges_from_string( - value - ).items(): - GtkLayerShell.set_anchor(self, edge, anchored) - - return - - @Property(tuple[int, ...], flags="read-write") - def margin(self) -> tuple[int, ...]: - return tuple( - GtkLayerShell.get_margin(self, x) - for x in [ - Edge.TOP, - Edge.RIGHT, - Edge.BOTTOM, - Edge.LEFT, - ] - ) - - @margin.setter - def margin(self, value: str | Iterable[int]) -> None: - for edge, mrgv in WaylandWindow.extract_margin(value).items(): - GtkLayerShell.set_margin(self, edge, mrgv) - return - - @Property(object, "read-write") - def keyboard_mode(self): - kb_mode = GtkLayerShell.get_keyboard_mode(self) - if GtkLayerShell.get_keyboard_interactivity(self): - kb_mode = KeyboardMode.EXCLUSIVE - return kb_mode - - @keyboard_mode.setter - def keyboard_mode( - self, - value: Literal["none", "exclusive", "on-demand"] | KeyboardMode, - ): - return GtkLayerShell.set_keyboard_mode( - self, - get_enum_member( - KeyboardMode, - value, - default=KeyboardMode.NONE, - ), - ) - - def __init__( - self, - layer: Literal["background", "bottom", "top", "overlay"] | Layer = Layer.TOP, - anchor: str = "", - margin: str | Iterable[int] = "0px 0px 0px 0px", - exclusivity: ( - Literal["auto", "normal", "none"] | WaylandWindowExclusivity - ) = WaylandWindowExclusivity.NONE, - keyboard_mode: ( - Literal["none", "exclusive", "on-demand"] | KeyboardMode - ) = KeyboardMode.NONE, - pass_through: bool = False, - monitor: int | Gdk.Monitor | None = None, - title: str = "fabric", - type: Literal["top-level", "popup"] | Gtk.WindowType = Gtk.WindowType.TOPLEVEL, - child: Gtk.Widget | None = None, - name: str | None = None, - visible: bool = True, - all_visible: bool = False, - style: str | None = None, - style_classes: Iterable[str] | str | None = None, - tooltip_text: str | None = None, - tooltip_markup: str | None = None, - h_align: ( - Literal["fill", "start", "end", "center", "baseline"] | Gtk.Align | None - ) = None, - v_align: ( - Literal["fill", "start", "end", "center", "baseline"] | Gtk.Align | None - ) = None, - h_expand: bool = False, - v_expand: bool = False, - size: Iterable[int] | int | None = None, - **kwargs, - ): - Window.__init__( - self, - title=title, - type=type, - child=child, - name=name, - visible=False, - all_visible=False, - style=style, - style_classes=style_classes, - tooltip_text=tooltip_text, - tooltip_markup=tooltip_markup, - h_align=h_align, - v_align=v_align, - h_expand=h_expand, - v_expand=v_expand, - size=size, - **kwargs, - ) - self._layer = Layer.ENTRY_NUMBER - self._keyboard_mode = KeyboardMode.NONE - self._anchor = anchor - self._exclusivity = WaylandWindowExclusivity.NONE - self._pass_through = pass_through - - GtkLayerShell.init_for_window(self) - GtkLayerShell.set_namespace(self, title) - self.connect( - "notify::title", - lambda *_: GtkLayerShell.set_namespace(self, self.get_title()), - ) - if monitor is not None: - self.monitor = monitor - self.layer = layer - self.anchor = anchor - self.margin = margin - self.keyboard_mode = keyboard_mode - self.exclusivity = exclusivity - self.pass_through = pass_through - ( - self.show_all() - if all_visible is True - else self.show() if visible is True else None - ) - - def steal_input(self) -> None: - return GtkLayerShell.set_keyboard_interactivity(self, True) - - def return_input(self) -> None: - return GtkLayerShell.set_keyboard_interactivity(self, False) - - # custom overrides - def show(self) -> None: - super().show() - return self.do_handle_post_show_request() - - def show_all(self) -> None: - super().show_all() - return self.do_handle_post_show_request() - - def do_handle_post_show_request(self) -> None: - if not self.get_children(): - logger.warning( - "[WaylandWindow] showing an empty window is not recommended, some compositors might freak out." - ) - self.pass_through = self._pass_through - return - - @staticmethod - def extract_anchor_values(string: str) -> tuple[str, ...]: - """ - extracts the geometry values from a given geometry string. - - :param string: the string containing the geometry values. - :type string: str - :return: a list of unique directions extracted from the geometry string. - :rtype: list - """ - direction_map = {"l": "left", "t": "top", "r": "right", "b": "bottom"} - pattern = re.compile(r"\b(left|right|top|bottom)\b", re.IGNORECASE) - matches = pattern.findall(string) - return tuple(set(tuple(direction_map[match.lower()[0]] for match in matches))) - - @staticmethod - def extract_edges_from_string(string: str) -> dict["Edge", bool]: - anchor_values = WaylandWindow.extract_anchor_values(string.lower()) - return { - Edge.TOP: "top" in anchor_values, - Edge.RIGHT: "right" in anchor_values, - Edge.BOTTOM: "bottom" in anchor_values, - Edge.LEFT: "left" in anchor_values, - } - - @staticmethod - def extract_margin(input: str | Iterable[int]) -> dict["Edge", int]: - margins = ( - extract_css_values(input.lower()) - if isinstance(input, str) - else ( - input - if isinstance(input, (tuple, list)) and len(input) == 4 - else (0, 0, 0, 0) - ) - ) - return { - Edge.TOP: margins[0], - Edge.RIGHT: margins[1], - Edge.BOTTOM: margins[2], - Edge.LEFT: margins[3], - }