.
================================================
FILE: README.md
================================================
[](https://github.com/S4NKALP/Modus/stargazers)
[](https://hyprland.org/)
[]()
[](https://discord.gg/tRFxkbQ3Zq)
Home Screen:
Lock Screen:
## Installation
> [!CAUTION]
> - You need a working installation of hyprland and knowledge of how it works
> - There may not be all packages in your system install them accordingly
```bash
git clone https://github.com/S4NKALP/Modus ~/.config/Modus
cd ~/.config/Modus
./install.sh
```
> [!TIP]
> ## Post Installation
> - Install recommended [Icon theme](https://github.com/vinceliuice/MacTahoe-icon-theme) , [GTK theme](https://github.com/vinceliuice/MacTahoe-gtk-theme) and [Cursor Theme](https://github.com/vinceliuice/MacTahoe-icon-theme/tree/main/cursors)
> - Check `config/hypr/modus.conf` edit it according to your device and copy it to your hyprland config
> - For Lock Screen Bind keys to `python lock.py`
Todo
## Manual Installation (WIP)
```bash
paru -S glace-git gtk-session-lock python-pyotp python-pillow python-ijson python-setproctitle apple-fonts cinnamon-desktop --needed
git clone https://github.com/S4NKALP/Modus ~/.config/Modus
cd ~/.config/Modus
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
pip install --no-deps git+https://github.com/Fabric-Development/fabric.git
```
- [x] Launcher
- [x] Lock Screen
- [x] Dock
- [x] Notification
- [x] Control Center
- [x] Music Player
- [x] Desktop Widgets
- [x] New Launcher (like Spotlight)
- [ ] Settings
- [x] ~~Magnifier hover effect on Dock~~
- [x] ~~New Application Switcher~~
- [x] Panel Widget
- [x] MacOS like Widget
- [x] Expandable Notification Centre
- [ ] Installation Script
- [ ] Proper Documentation
- [ ] Pomodoro Timer Widget
- [x] To-do List Widget
## Bug Fixes (the bug found till now)
- [x] WiFi
- [x] wifi off button looks bigger
- [x] Metadata Changes delay in Media Player
- [x] Active Window Title showing `Unknown` when no active window
- [x] Notification Escape Char Issue
## Team
- [SANKALP](https://github.com/S4NKALP/)
- [tr1x_em](https://github.com/tr1xem)
## Special Thanks
A big thank you to the following people for their incredible help with code and creative ideas. Your help made a real difference!
- [darsh](https://github.com/its-darsh): for creating Fabric, which made everything possible.
- [gummy bear album](https://github.com/muhchaudhary): for sharing fantastic code snippets that saved me time and effort.
- [axenide](https://github.com/Axenide): for the amazing config that not only inspired parts of mine but also provided some gems I couldn’t resist borrowing.
- [E3nviction](https://github.com/E3nviction/): for code snippets and ideas that were incredibly helpful.
I truly appreciate your support
================================================
FILE: config/assets/config.json
================================================
{
"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"]
}
================================================
FILE: config/assets/emoji.json
================================================
{
"😀": {
"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
}
}
================================================
FILE: config/assets/launcher.json
================================================
{
"launcher_config": {
"em": {
"icon": "emoji-people-symbolic",
"description": "Emoji - Search and copy emojis"
},
"clip": {
"icon": "app.getclipboard.Clipboard",
"description": "Clipboard - Search and manage clipboard history"
},
"=": {
"examples": [
"= 10*5",
"= 2^8",
"= sin(30)"
],
"icon": "calculator",
"description": "Quick Math - Fast mathematical expressions"
},
"app": {
"icon": "apps",
"description": "Applications - Launch installed applications"
},
"bin": {
"icon": "terminal",
"description": "Bins - Search and run executable binaries"
},
"power": {
"icon": "shutdown",
"description": "Power - System power management and session control"
},
"caffeine": {
"examples": [
"caffeine 30m",
"caffeine 1h",
"caffeine 2h",
"caffeine on"
],
"icon": "caffeine",
"description": "Caffeine - Prevent system from going idle"
},
"sc": {
"icon": "cs-screen",
"description": "Screencapture - Record screen and audio"
},
"wall": {
"icon": "daily-wallpaper",
"description": "Wallpapers - Set wallpapers, and tons of features"
},
"?": {
"icon": "search-symbolic",
"description": "Quick Web Search - Fast web search with question mark",
"examples": [
"? cats",
"? google cats",
"? youtube music"
]
},
"remind": {
"icon": "alarm-timer",
"description": "Reminders - Set time-based reminders with notifications"
},
"otp": {
"icon": "auth-otp-symbolic",
"description": "Manage TOTP codes and 2FA authentication"
},
"pass": {
"icon": "nextcloud-password-client",
"description": "Password Manager - Search and manage passwords"
},
"bm": {
"icon": "bookmark-add-symbolic",
"description": "Bookmarks - Search and manage bookmarks"
},
"script": {
"icon": "terminal",
"description": "Bash Scripts - Manage and execute bash scripts"
},
"bash": {
"icon": "terminal",
"description": "Bash Scripts - Manage and execute bash scripts"
},
"sh": {
"icon": "terminal",
"description": "Bash Scripts - Manage and execute bash scripts"
},
"tmux": {
"icon": "terminal",
"description": "Tmux Manager - Create, attach, rename, and kill tmux sessions"
}
},
"settings": {
"max_examples_shown": 2,
"default_icon": "apps",
"fallback_example_template": "{trigger} ",
"config_version": "1.0"
}
}
================================================
FILE: config/data.py
================================================
import json
import os
import gi
from fabric.utils.helpers import get_relative_path
from gi.repository import Gdk, GLib
gi.require_version("Gtk", "3.0")
APP_NAME = "modus"
APP_NAME_CAP = "Modus"
def parse_timeout_string(timeout_str):
"""
Parse timeout string in format like '5s', '10m', '30s' etc.
Returns timeout in milliseconds.
"""
if not timeout_str or not isinstance(timeout_str, str):
return 5000
timeout_str = timeout_str.strip().lower()
if timeout_str.endswith("s"):
try:
seconds = int(timeout_str[:-1])
return seconds * 1000
except ValueError:
return 5000
elif timeout_str.endswith("m"):
try:
minutes = int(timeout_str[:-1])
return minutes * 60 * 1000
except ValueError:
return 5000
else:
try:
seconds = int(timeout_str)
return seconds * 1000
except ValueError:
return 5000
CACHE_DIR = str(GLib.get_user_cache_dir()) + f"/{APP_NAME}"
USERNAME = os.getlogin()
HOSTNAME = os.uname().nodename
HOME_DIR = os.path.expanduser("~")
CONFIG_DIR = os.path.expanduser(f"~/.config/{APP_NAME}")
screen = Gdk.Screen.get_default()
CURRENT_WIDTH = screen.get_width()
CURRENT_HEIGHT = screen.get_height()
WALLPAPERS_DIR_DEFAULT = get_relative_path("../assets/wallpapers_example/")
CONFIG_FILE = get_relative_path("../config/assets/config.json")
MATUGEN_STATE_FILE = os.path.join(CONFIG_DIR, "matugen")
def load_config():
"""Load the configuration from config.json"""
config = {}
if os.path.exists(CONFIG_FILE):
try:
with open(CONFIG_FILE, "r") as f:
config = json.load(f)
except Exception as e:
print(f"Error loading config: {e}")
return config
if os.path.exists(CONFIG_FILE):
with open(CONFIG_FILE, "r") as f:
config = json.load(f)
wallpapers_dir_from_config = config.get("wallpapers_dir", WALLPAPERS_DIR_DEFAULT)
WALLPAPERS_DIR = os.path.expanduser(wallpapers_dir_from_config)
DOCK_POSITION = config.get("dock_position", "Bottom")
TERMINAL_COMMAND = config.get("terminal_command", "kitty -e")
DOCK_ENABLED = config.get("dock_enabled", True)
DOCK_AUTO_HIDE = config.get("dock_auto_hide", True)
DOCK_ALWAYS_OCCLUDED = config.get("dock_always_occluded", False)
DOCK_ICON_SIZE = config.get("dock_icon_size", 60)
WINDOW_SWITCHER_ITEMS_PER_ROW = config.get("window_switcher_items_per_row", 10)
HIDE_SPECIAL_WORKSPACE = config.get("hide_special_workspace", True)
DOCK_HIDE_SPECIAL_WORKSPACE_APPS = config.get(
"dock_hide_special_workspace_apps", True
)
NOTIFICATION_TIMEOUT_STR = config.get("notification_timeout", "5s")
NOTIFICATION_TIMEOUT = parse_timeout_string(NOTIFICATION_TIMEOUT_STR)
NOTIFICATION_IGNORED_APPS_HISTORY = config.get(
"notification_ignored_apps_history", ["Hyprshot"]
)
NOTIFICATION_LIMITED_APPS_HISTORY = config.get(
"notification_limited_apps_history", ["Spotify"]
)
else:
WALLPAPERS_DIR = WALLPAPERS_DIR_DEFAULT
DOCK_POSITION = "Bottom"
DOCK_ENABLED = True
DOCK_ALWAYS_OCCLUDED = False
DOCK_AUTO_HIDE = True
TERMINAL_COMMAND = "kitty -e"
DOCK_THEME = "Pills"
DOCK_ICON_SIZE = 60
WINDOW_SWITCHER_ITEMS_PER_ROW = 10
HIDE_SPECIAL_WORKSPACE = True
DOCK_HIDE_SPECIAL_WORKSPACE_APPS = True
NOTIFICATION_TIMEOUT_STR = "5s"
NOTIFICATION_TIMEOUT = parse_timeout_string(NOTIFICATION_TIMEOUT_STR)
NOTIFICATION_IGNORED_APPS_HISTORY = ["Hyprshot"]
NOTIFICATION_LIMITED_APPS_HISTORY = ["Spotify"]
================================================
FILE: config/hypr/modus.conf
================================================
# exec-once
exec-once = uwsm app -- python ~/.config/Modus/main.py
exec = pgrep -x "hypridle" > /dev/null || uwsm app -- hypridle
exec-once = uwsm app -- swww-daemon
exec-once = wl-paste --type text --watch cliphist store
exec-once = wl-paste --type image --watch cliphist store
# LAYER RULES FOR BLURS
layerrule = blur on, xray 0, blur_popups on, ignore_alpha 0, no_anim on, match:namespace ^modus-notifications$
layerrule = animation popin, match:namespace ^lockscreen$
layerrule = blur on, xray 0, blur_popups on, ignore_alpha 0, animation popin, match:namespace ^modus-launcher$
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)"
================================================
FILE: config/matugen/templates/hyprland-colors.conf
================================================
$wallpaper = {{image}}
$background = {{colors.background.default.hex_stripped}}
$foreground = {{colors.on_background.default.hex_stripped}}
$primary = {{colors.primary.default.hex_stripped}}
$secondary = {{colors.secondary.default.hex_stripped}}
$tertiary = {{colors.tertiary.default.hex_stripped}}
$surface = {{colors.surface.default.hex_stripped}}
$surface_bright = {{colors.surface_bright.default.hex_stripped}}
$outline = {{colors.outline.default.hex_stripped}}
$error = {{colors.error.default.hex_stripped | set_lightness: -20.0}}
$shadow = {{colors.shadow.default.hex_stripped}}
$red = {{colors.red.default.hex_stripped}}
$green = {{colors.green.default.hex_stripped}}
$yellow = {{colors.yellow.default.hex_stripped}}
$blue = {{colors.blue.default.hex_stripped}}
$magenta = {{colors.magenta.default.hex_stripped}}
$cyan = {{colors.cyan.default.hex_stripped}}
$white = {{colors.white.default.hex_stripped}}
$red_dim = {{colors.red.default.hex_stripped | set_lightness: -20.0}}
$green_dim = {{colors.green.default.hex_stripped | set_lightness: -20.0}}
$yellow_dim = {{colors.yellow.default.hex_stripped | set_lightness: -20.0}}
$blue_dim = {{colors.blue.default.hex_stripped | set_lightness: -20.0}}
$magenta_dim = {{colors.magenta.default.hex_stripped | set_lightness: -20.0}}
$cyan_dim = {{colors.cyan.default.hex_stripped | set_lightness: -20.0}}
================================================
FILE: config/matugen/templates/macos.css
================================================
:vars {
--foreground: {{colors.on_background.default.hex}};
--background: {{colors.background.default.hex}};
--cursor: {{colors.on_background.default.hex}};
--primary: {{colors.primary.default.hex}};
--on-primary: {{colors.on_primary.default.hex}};
--secondary: {{colors.secondary.default.hex}};
--on-secondary: {{colors.on_secondary.default.hex}};
--tertiary: {{colors.tertiary.default.hex}};
--on-tertiary: {{colors.on_tertiary.default.hex}};
--surface: {{colors.surface.default.hex}};
--surface-bright: {{colors.surface_bright.default.hex}};
--error: {{colors.error.default.hex}};
--error-dim: {{colors.error.default.hex | set_lightness: -10.0}};
--on-error: {{colors.on_error.default.hex}};
--error-container: {{colors.error_container.default.hex}};
--outline: {{colors.outline.default.hex}};
--shadow: {{colors.shadow.default.hex}};
--red: {{colors.red.default.hex}};
--red-dim: {{colors.red.default.hex | set_lightness: -10.0}};
--green: {{colors.green.default.hex}};
--green-dim: {{colors.green.default.hex | set_lightness: -10.0}};
--yellow: {{colors.yellow.default.hex}};
--yellow-dim: {{colors.yellow.default.hex | set_lightness: -10.0}};
--blue: {{colors.blue.default.hex}};
--blue-dim: {{colors.blue.default.hex | set_lightness: -10.0}};
--magenta: {{colors.magenta.default.hex}};
--magenta-dim: {{colors.magenta.default.hex | set_lightness: -10.0}};
--cyan: {{colors.cyan.default.hex}};
--cyan-dim: {{colors.cyan.default.hex | set_lightness: -10.0}};
--white: {{colors.white.default.hex}};
}
================================================
FILE: debug_memory.py
================================================
#!/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()
================================================
FILE: install.sh
================================================
#!/bin/bash
# ███╗ ███╗ ██████╗ ██████╗ ██╗ ██╗███████╗
# ████╗ ████║██╔═══██╗██╔══██╗██║ ██║██╔════╝
# ██╔████╔██║██║ ██║██║ ██║██║ ██║███████╗
# ██║╚██╔╝██║██║ ██║██║ ██║██║ ██║╚════██║
# ██║ ╚═╝ ██║╚██████╔╝██████╔╝╚██████╔╝███████║
# ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝
#
# A hackable shell for Hyprland
# Installation Script for Arch Linux
#
# Repository: https://github.com/S4NKALP/Modus --branch macos
# License: GPLv3
set -e
set -u
set -o pipefail
REPO_URL="https://github.com/S4NKALP/Modus.git"
INSTALL_DIR="$HOME/.config/Modus"
PACKAGES=(
python-fabric-git
fabric-cli-git
glace-git
cliphist
gnome-bluetooth-3.0
gobject-introspection
slurp
ffmpeg
hypridle
hyprsunset
hyprpicker
imagemagick
libnotify
matugen-bin
playerctl
python-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
apple-fonts
swappy
wl-clipboard
webp-pixbuf-loader
wf-recorder
acpi
brightnessctl
power-profiles-daemon
uwsm
cinnamon-desktop
)
# Colors and formatting
if [ -t 1 ]; then
GREEN=$(tput setaf 2)
YELLOW=$(tput setaf 3)
RED=$(tput setaf 1)
CYAN=$(tput setaf 6)
BLUE=$(tput setaf 4)
BOLD=$(tput bold)
DIM=$(tput dim)
RESET=$(tput sgr0)
else
GREEN="" YELLOW="" RED="" CYAN="" BLUE="" BOLD="" DIM="" RESET=""
fi
# Status symbols
ARROW="→"
CHECK="✔"
CROSS="✖"
INFO="ℹ"
WARN="⚠"
# Progress tracking
TOTAL_STEPS=7
CURRENT_STEP=0
# Function for progress indicator
progress() {
CURRENT_STEP=$((CURRENT_STEP + 1))
echo -e "\n${BOLD}${BLUE}[${CURRENT_STEP}/${TOTAL_STEPS}]${RESET} ${BOLD}$1${RESET}"
}
step() {
echo -e " ${CYAN}${ARROW}${RESET} $1"
}
success() {
echo -e " ${GREEN}${CHECK}${RESET} ${GREEN}$1${RESET}"
}
warn() {
echo -e " ${YELLOW}${WARN}${RESET} ${YELLOW}$1${RESET}"
}
error() {
echo -e "\n${RED}${CROSS}${RESET} ${RED}${BOLD}ERROR:${RESET} ${RED}$1${RESET}\n" >&2
}
info() {
echo -e " ${BLUE}${INFO}${RESET} ${DIM}$1${RESET}"
}
spinner() {
local pid=$1
local message=$2
local spin=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏')
local i=0
while kill -0 $pid 2>/dev/null; do
printf "\r ${CYAN}${spin[i]}${RESET} $message"
i=$(((i + 1) % 10))
sleep 0.1
done
printf "\r"
}
# Cleanup handler
cleanup() {
if [ -n "${SUDO_KEEPER_PID:-}" ]; then
kill $SUDO_KEEPER_PID 2>/dev/null || true
fi
}
trap cleanup EXIT INT TERM
# Header
clear
echo -e "${BOLD}${CYAN}"
cat << "EOF"
███╗ ███╗ ██████╗ ██████╗ ██╗ ██╗███████╗
████╗ ████║██╔═══██╗██╔══██╗██║ ██║██╔════╝
██╔████╔██║██║ ██║██║ ██║██║ ██║███████╗
██║╚██╔╝██║██║ ██║██║ ██║██║ ██║╚════██║
██║ ╚═╝ ██║╚██████╔╝██████╔╝╚██████╔╝███████║
╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝
EOF
echo -e "${RESET}"
echo -e "${BOLD} A hackable shell for Hyprland${RESET}"
echo -e "${DIM} Installation Script v1.0${RESET}\n"
# Pre-flight checks
progress "Pre-flight checks"
step "Checking operating system..."
if ! grep -qi "arch" /etc/os-release; then
error "This script requires Arch Linux or an Arch-based distribution"
exit 1
fi
success "Arch Linux detected"
step "Checking user permissions..."
if [ "$(id -u)" -eq 0 ]; then
error "Please run this script as a regular user, not as root"
exit 1
fi
success "Running as regular user"
step "Checking system requirements..."
if ! command -v git &>/dev/null; then
error "git is not installed. Please install it first: sudo pacman -S git"
exit 1
fi
success "All requirements met"
# Sudo authentication
progress "Requesting permissions"
info "Some packages require root privileges for installation"
echo ""
if ! sudo -v; then
error "Sudo authentication failed"
exit 1
fi
success "Permissions granted"
# Keep sudo alive
while true; do
sudo -n true
sleep 60
kill -0 "$$" || exit
done 2>/dev/null &
SUDO_KEEPER_PID=$!
# Show package list
progress "Package information"
info "Total packages to install: ${BOLD}${#PACKAGES[@]}${RESET}"
echo ""
read -rp " ${YELLOW}${INFO}${RESET} View full package list? (y/N): " view_packages
if [[ "$view_packages" =~ ^[Yy]$ ]]; then
echo ""
printf " ${DIM}• %s${RESET}\n" "${PACKAGES[@]}"
echo ""
fi
# Confirmation
read -rp " ${BOLD}Proceed with installation? (y/N):${RESET} " confirm
if [[ ! "$confirm" =~ ^[Yy]$ ]]; then
warn "Installation cancelled by user"
exit 0
fi
# AUR helper setup
progress "Setting up AUR helper"
aur_helper="yay"
if command -v paru &>/dev/null; then
aur_helper="paru"
success "Found paru"
elif command -v yay &>/dev/null; then
success "Found yay"
else
step "Installing yay-bin..."
tmpdir=$(mktemp -d)
(
git clone --quiet --depth=1 https://aur.archlinux.org/yay-bin.git "$tmpdir/yay-bin" 2>&1 | grep -v "Cloning into" || true
cd "$tmpdir/yay-bin"
makepkg -si --noconfirm >/dev/null 2>&1
) &
spinner $! "Building yay-bin..."
wait $! || {
error "Failed to install yay-bin"
rm -rf "$tmpdir"
exit 1
}
rm -rf "$tmpdir"
success "yay-bin installed successfully"
fi
# Repository setup
progress "Setting up Modus repository"
if [ -d "$INSTALL_DIR" ]; then
step "Updating existing repository..."
git -C "$INSTALL_DIR" pull --quiet 2>&1 | grep -v "Already up to date" || true
success "Repository updated"
else
step "Cloning repository..."
git clone --quiet "$REPO_URL" "$INSTALL_DIR" 2>&1 | grep -v "Cloning into" || true
success "Repository cloned"
fi
info "Location: ${INSTALL_DIR}"
# Package installation
progress "Installing packages"
step "Syncing package databases..."
$aur_helper -Syy --noconfirm >/dev/null 2>&1 || true
success "Database synced"
step "Installing required packages (this may take a while)..."
installed=0
failed=0
for pkg in "${PACKAGES[@]}"; do
if $aur_helper -S --needed --noconfirm "$pkg" >/dev/null 2>&1; then
installed=$((installed + 1))
else
failed=$((failed + 1))
warn "Failed to install: $pkg"
fi
printf "\r ${CYAN}${ARROW}${RESET} Progress: ${installed}/${#PACKAGES[@]} packages"
done
echo ""
if [ $failed -eq 0 ]; then
success "All packages installed successfully"
else
warn "$failed package(s) failed to install"
fi
# Update check
step "Checking for package updates..."
outdated=$($aur_helper -Qu 2>/dev/null | awk '{print $1}' || true)
to_update=()
for pkg in "${PACKAGES[@]}"; do
if echo "$outdated" | grep -q "^$pkg\$"; then
to_update+=("$pkg")
fi
done
if [ ${#to_update[@]} -gt 0 ]; then
step "Updating ${#to_update[@]} outdated package(s)..."
$aur_helper -S --noconfirm "${to_update[@]}" >/dev/null 2>&1 || true
success "Packages updated"
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"
MODUS_CONF_LINE="source= ~/.config/Modus/config/hypr/modus.conf"
if [ -f "$HYPR_CONFIG" ]; then
step "Checking Hyprland configuration..."
if grep -qF "$MODUS_CONF_LINE" "$HYPR_CONFIG"; then
success "Modus configuration already sourced"
else
step "Adding Modus configuration to Hyprland..."
echo "" >> "$HYPR_CONFIG"
echo "# Modus configuration" >> "$HYPR_CONFIG"
echo "$MODUS_CONF_LINE" >> "$HYPR_CONFIG"
success "Modus configuration added to Hyprland"
fi
else
warn "Hyprland config not found at $HYPR_CONFIG"
info "You may need to manually add: $MODUS_CONF_LINE"
fi
# Launch Modus
progress "Launching Modus"
step "Stopping existing instances..."
if killall modus 2>/dev/null; then
success "Stopped running instance"
sleep 1
else
info "No existing instance found"
fi
step "Starting Modus..."
if uwsm app -- python "$INSTALL_DIR/main.py" >/dev/null 2>&1 & then
disown
sleep 1
if pgrep -f "python.*main.py" >/dev/null; then
success "Modus is now running"
else
warn "Modus may not have started correctly"
fi
else
error "Failed to start Modus"
exit 1
fi
# Completion
echo ""
echo -e "${GREEN}${BOLD}╔════════════════════════════════════════╗${RESET}"
echo -e "${GREEN}${BOLD}║ ║${RESET}"
echo -e "${GREEN}${BOLD}║ Installation completed! 🎉 ║${RESET}"
echo -e "${GREEN}${BOLD}║ ║${RESET}"
echo -e "${GREEN}${BOLD}╚════════════════════════════════════════╝${RESET}"
echo ""
info "Modus is running in the background"
info "Config location: ${INSTALL_DIR}"
info "Repository: ${REPO_URL}"
echo ""
================================================
FILE: lock.py
================================================
from widgets.circle_image import CircleImage as Image
from modules.panel.components.indicators import (
BatteryIndicator,
BluetoothIndicator,
NetworkIndicator,
)
from gi.repository import (
Gdk, # pyright: ignore[reportMissingModuleSource]
GLib,
GtkSessionLock, # pyright: ignore[reportAttributeAccessIssue]
)
from fabric.widgets.window import Window
from fabric.widgets.label import Label
from fabric.widgets.entry import Entry
from fabric.widgets.datetime import DateTime
from fabric.widgets.centerbox import CenterBox
from fabric.widgets.box import Box
from fabric.utils import get_relative_path
from fabric import Application
import os
import getpass
import setproctitle
import gi
import pam
gi.require_version("Gdk", "3.0")
gi.require_version("Gtk", "3.0")
gi.require_version("GtkSessionLock", "0.1")
# from fabric.widgets.image import Image
# from widgets.wayland import WaylandWindow as Window
class IndicatorBox(Box):
def __init__(self, *args, **kwargs):
super().__init__(
h_align="end",
name="indicator-box",
spacing=5,
h_expand=True,
children=[
BatteryIndicator(show_window=False),
BluetoothIndicator(show_window=False),
NetworkIndicator(show_window=False),
],
)
class ContentBox(CenterBox):
def __init__(self, on_activate, *args, **kwargs):
self.password_entry = Entry(
placeholder="Enter Password",
name="password-entry",
h_align="center",
v_align="center",
visible=False,
password=True,
on_activate=on_activate,
)
face_icon = os.path.expanduser("~/.face.icon")
if not os.path.exists(face_icon):
face_icon = get_relative_path("./assets/default.png")
self.password_entry.set_property("xalign", 0.5)
self.username_label = Label(
label=f"{getpass.getuser().title()}",
name="username",
visible=True,
h_align="center",
v_align="center",
)
self.unlock_text = Label(
label="Touch ID or Enter Password",
name="unlock-text",
)
super().__init__(
name="content-box",
h_expand=True,
orientation="vertical",
v_expand=True,
start_children=[
IndicatorBox(),
DateTime(
formatters=["%A,%B %-d"],
interval=10000,
h_expand=False,
v_align="start",
v_expand=False,
name="lock-date",
),
DateTime(formatters=["%I:%M"], name="lock-clock"),
],
end_children=[
Box(
name="profile-box",
h_align="center",
h_expand=True,
v_expand=True,
v_align="end",
children=[
Image(
name="face-icon",
image_file=face_icon,
size=64,
),
],
),
Box(
name="container-box",
orientation="v",
v_align="end",
h_align="center",
h_expand=True,
v_expand=True,
children=[
self.username_label,
self.password_entry,
],
),
Box(
name="unlock-box",
v_align="end",
h_align="center",
h_expand=True,
v_expand=True,
children=[
self.unlock_text,
],
),
],
**kwargs,
)
class LockScreen(Window):
def __init__(self, lock: GtkSessionLock.Lock):
self._hide_timeout_id = None # prevent AttributeError
self.lock = lock
self.content = ContentBox(self.on_activate)
super().__init__(
title="lock",
visible=False,
all_visible=False,
name="lockscreen-bg",
anchor="center",
child=self.content,
)
self.content.password_entry.set_visible(False)
self.connect("key-press-event", self._on_keypress)
bg = os.path.expanduser("~/.current.wall")
if not os.path.exists(bg):
bg = get_relative_path("./assets/wallpapers_example/example-1.png")
self.set_style(f"background-image: url('{bg}');")
def _on_keypress(self, widget, event):
keyval = event.keyval
# ESC pressed → hide entry immediately
if keyval == Gdk.KEY_Escape and self.content.password_entry.get_visible():
self._hide_entry()
return
# Show entry if hidden
if not self.content.password_entry.get_visible():
self.content.username_label.set_visible(False)
self.content.password_entry.set_visible(True)
self.content.password_entry.grab_focus()
self._start_hide_timer()
else:
# Reset timer if already visible
self._restart_hide_timer()
def _start_hide_timer(self):
self._stop_hide_timer() # just in case
self._hide_timeout_id = GLib.timeout_add_seconds(5, self._hide_entry)
# 10 seconds of inactivity before hiding
def _restart_hide_timer(self):
self._start_hide_timer()
def _stop_hide_timer(self):
if self._hide_timeout_id:
GLib.source_remove(self._hide_timeout_id)
self._hide_timeout_id = None
def _hide_entry(self):
self._stop_hide_timer()
self.content.password_entry.set_visible(False)
self.content.username_label.set_visible(True)
return False # stop timeout
def on_activate(self, entry: Entry, *args):
if not pam.authenticate(getpass.getuser(), (entry.get_text() or "").strip()):
entry.set_text("")
entry.set_placeholder_text("Wrong Password")
return
self.lock.unlock_and_destroy()
self.destroy()
GLib.idle_add(app.quit) # schedules quit after unlock
def initialize():
lock = GtkSessionLock.prepare_lock()
lock.lock_lock()
lockscreen = LockScreen(lock)
lock.new_surface(
lockscreen,
Gdk.Display.get_default().get_monitor( # pyright: ignore[reportAttributeAccessIssue, reportOptionalMemberAccess]
0
),
)
lockscreen.show()
if __name__ == "__main__":
setproctitle.setproctitle("lockscreen")
initialize()
lockscreen = LockScreen(GtkSessionLock.Lock())
app = Application("lock", lockscreen)
def set_css():
app.set_stylesheet_from_file(
get_relative_path("main.css"),
)
app.set_css = set_css # pyright: ignore[reportAttributeAccessIssue]
app.set_css() # pyright: ignore[reportAttributeAccessIssue]
app.run()
================================================
FILE: main.css
================================================
@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;
}
================================================
FILE: main.py
================================================
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()
================================================
FILE: modules/about.py
================================================
import re
import subprocess
import os
import gi
gi.require_version("GdkPixbuf", "2.0")
gi.require_version("Gtk", "3.0")
from gi.repository import GdkPixbuf, Gtk # type: ignore
from fabric.utils.helpers import get_relative_path
from utils.roam import modus_service
from utils.icon_resolver import IconResolver
def read_dmi(field):
try:
with open(f"/sys/class/dmi/id/{field}") as f:
return f.read().strip()
except Exception:
return "Unknown"
def get_gpu_name():
try:
output = (
subprocess.check_output(
"lspci -nn | grep -i 'VGA compatible controller'", shell=True, text=True
)
.strip()
.split("\n")
)
def clean(line):
matches = re.findall(r"\[(.*?)\]", line)
if len(matches) >= 2:
return matches[1].strip()
desc = line.split(":", 2)[-1]
return desc.replace("Corporation", "").strip()
for line in output:
if any(vendor in line.lower() for vendor in ["nvidia", "amd", "radeon"]):
return clean(line)
if output:
return clean(output[0])
return "Unknown GPU"
except Exception:
return "Unknown GPU"
def get_executable_path(exec_string):
"""Extract and find the actual executable path from Exec field"""
if not exec_string:
return None
# Remove common exec modifiers and arguments
exec_parts = exec_string.split()
if not exec_parts:
return None
executable = exec_parts[0]
# Remove common prefixes
prefixes_to_remove = ["env", "bash", "sh", "/usr/bin/env"]
while executable in prefixes_to_remove and len(exec_parts) > 1:
exec_parts.pop(0)
executable = exec_parts[0]
# If it's already an absolute path, check if it exists
if executable.startswith("/"):
if os.path.exists(executable):
return executable
return None
# Search in PATH
try:
result = subprocess.run(["which", executable], capture_output=True, text=True)
if result.returncode == 0:
return result.stdout.strip()
except Exception:
pass
return None
def get_app_info(wmclass):
"""Get comprehensive application information from .desktop file"""
if not wmclass:
return {
"name": "Desktop",
"version": "",
"comment": "Desktop Environment",
"icon": "desktop",
"exec": "",
"location": "",
"categories": "",
"desktop_file": "",
}
desktop_paths = [
"/usr/share/applications",
"/var/lib/flatpak/exports/share/applications",
os.path.expanduser("~/.local/share/applications"),
"/usr/local/share/applications",
]
for path in desktop_paths:
if not os.path.exists(path):
continue
# Try exact match first
exact_matches = [
f for f in os.listdir(path) if f.lower() == f"{wmclass.lower()}.desktop"
]
# Then try starts with
startswith_matches = [
f
for f in os.listdir(path)
if f.startswith(wmclass.lower()) and f.endswith(".desktop")
]
# Finally try contains
contains_matches = [
f
for f in os.listdir(path)
if wmclass.lower() in f.lower() and f.endswith(".desktop")
]
# Process matches in order of preference
for matches in [exact_matches, startswith_matches, contains_matches]:
for filename in matches:
desktop_file = os.path.join(path, filename)
try:
with open(desktop_file, "r", encoding="utf-8") as f:
content = f.read()
name = wmclass.title()
version = ""
comment = ""
icon = wmclass.lower()
exec_cmd = ""
categories = ""
# Parse desktop file
in_desktop_entry = False
for line in content.split("\n"):
line = line.strip()
if line == "[Desktop Entry]":
in_desktop_entry = True
continue
elif line.startswith("[") and line.endswith("]"):
in_desktop_entry = False
continue
if not in_desktop_entry or "=" not in line:
continue
key, value = line.split("=", 1)
if key == "Name":
name = value
elif key == "Version":
version = value
elif key == "Comment":
comment = value
elif key == "GenericName" and not comment:
# Use GenericName as fallback description
comment = value
elif key == "Icon":
icon = value
elif key == "Exec":
exec_cmd = value
elif key == "Categories":
categories = value
# Get executable location
location = get_executable_path(exec_cmd)
return {
"name": name,
"version": version,
"comment": comment,
"icon": icon,
"exec": exec_cmd,
"location": location or "",
"categories": categories,
"desktop_file": desktop_file,
}
except Exception:
continue
# Fallback: try to find executable in PATH
location = ""
try:
result = subprocess.run(
["which", wmclass.lower()], capture_output=True, text=True
)
if result.returncode == 0:
location = result.stdout.strip()
except Exception:
pass
return {
"name": wmclass.title() if wmclass else "Unknown Application",
"version": "",
"comment": "",
"icon": wmclass.lower() if wmclass else "application-x-executable",
"exec": "",
"location": location,
"categories": "",
"desktop_file": "",
}
class AboutApp(Gtk.Window):
def __init__(self, app_name="Unknown Application", wmclass=""):
super().__init__(title=f"About {app_name}")
self.app_name = app_name
self.wmclass = wmclass
self.icon_resolver = IconResolver()
self.set_default_size(300, 500)
self.set_size_request(300, 480)
self.set_resizable(False)
self.set_title(f"About {app_name}")
self.set_name("about-app")
self.set_visible(False)
self.setup_ui()
def setup_ui(self):
app_info = get_app_info(self.wmclass)
main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
main_box.set_margin_top(20)
main_box.set_margin_bottom(20)
main_box.set_margin_start(15)
main_box.set_margin_end(15)
# App Icon
logo_box = Gtk.Box(halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER)
try:
# Use IconResolver's get_icon_pixbuf method like other parts of the project
icon_pixbuf = self.icon_resolver.get_icon_pixbuf(app_info["icon"], 128)
if icon_pixbuf:
logo = Gtk.Image.new_from_pixbuf(icon_pixbuf)
else:
raise Exception("Icon pixbuf not found")
except Exception:
# Fallback: try direct file path if it's an absolute path
try:
if app_info["icon"].startswith("/") and os.path.exists(
app_info["icon"]
):
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
app_info["icon"], 128, 128, preserve_aspect_ratio=True
)
logo = Gtk.Image.new_from_pixbuf(pixbuf)
else:
raise Exception("Direct path failed")
except Exception:
# Final fallback: emoji
logo = Gtk.Label()
logo.set_markup("📱 ")
logo_box.pack_start(logo, False, False, 0)
logo_box.set_margin_bottom(15)
# App Name
app_name_label = Gtk.Label()
app_name_label.set_markup(
f"{app_info['name']} "
)
app_name_label.set_halign(Gtk.Align.CENTER)
app_name_label.set_margin_bottom(5)
# Version (if available)
if app_info["version"]:
version_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
version_label = Gtk.Label(
label=f"Version {app_info.get('version', 'Unknown')}"
)
version_label.set_name("version-label")
version_label.set_halign(Gtk.Align.CENTER)
version_box.pack_start(version_label, False, False, 0)
version_box.set_margin_bottom(5)
# Description/Comment - Make it more prominent
description_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
if app_info["comment"]:
# Create a frame for the description to make it stand out
desc_frame = Gtk.Frame()
desc_frame.set_shadow_type(Gtk.ShadowType.IN)
description_label = Gtk.Label()
description_label.set_markup(f"{app_info['comment']} ")
description_label.set_justify(Gtk.Justification.CENTER)
description_label.set_halign(Gtk.Align.CENTER)
description_label.set_line_wrap(True)
description_label.set_max_width_chars(45)
description_label.set_margin_top(8)
description_label.set_margin_bottom(8)
description_label.set_margin_start(12)
description_label.set_margin_end(12)
desc_frame.add(description_label)
description_box.pack_start(desc_frame, False, False, 0)
else:
# Show a placeholder if no description is available
placeholder_label = Gtk.Label()
placeholder_label.set_markup(
"No description available "
)
placeholder_label.set_halign(Gtk.Align.CENTER)
description_box.pack_start(placeholder_label, False, False, 0)
description_box.set_margin_bottom(15)
# Information Grid
# Create a label with markup
info_frame = Gtk.Frame()
label = Gtk.Label()
label.set_markup("Application Information ")
label.set_margin_bottom(5)
info_frame.set_label_widget(label)
info_frame.set_label_align(0.5, 0.5)
info_grid = Gtk.Grid()
info_grid.set_row_spacing(8)
info_grid.set_column_spacing(15)
info_grid.set_margin_top(10)
info_grid.set_margin_bottom(10)
info_grid.set_margin_start(15)
info_grid.set_margin_end(15)
info_grid.set_valign(Gtk.Align.CENTER)
info_grid.set_halign(Gtk.Align.CENTER)
def make_info_row(label_text, value_text, row):
"""Create a row in the info grid"""
label = Gtk.Label(label=f"{label_text}:")
label.set_halign(Gtk.Align.END)
label.set_markup(f"{label_text}: ")
value = Gtk.Label()
value.set_markup(f'{value_text} ')
value.set_halign(Gtk.Align.START)
value.set_line_wrap(True)
value.set_max_width_chars(30)
value.set_name("info-value-label")
info_grid.attach(label, 0, row, 1, 1)
info_grid.attach(value, 1, row, 1, 1)
row = 0
# Application Name
make_info_row("Name", app_info["name"], row)
row += 1
# Version
if app_info["version"]:
make_info_row("Version", app_info["version"], row)
row += 1
# Executable Location
if app_info["location"]:
make_info_row("Location", app_info["location"], row)
row += 1
# Window Class
if self.wmclass:
make_info_row("Window Class", self.wmclass, row)
row += 1
# Categories
if app_info["categories"]:
categories = app_info["categories"].replace(";", ", ").strip(", ")
make_info_row("Categories", categories, row)
row += 1
# Desktop File
if app_info["desktop_file"]:
desktop_file_name = os.path.basename(app_info["desktop_file"])
make_info_row("Desktop File", desktop_file_name, row)
row += 1
info_frame.add(info_grid)
# Layout
main_box.pack_start(logo_box, False, False, 0)
main_box.pack_start(app_name_label, False, False, 0)
if app_info["version"]:
main_box.pack_start(version_box, False, False, 0)
main_box.pack_start(description_box, False, False, 0)
main_box.pack_start(info_frame, False, False, 0)
self.add(main_box)
def toggle(self, b):
if self.get_visible():
self.hide()
else:
self.show_all()
class About(Gtk.Window):
def __init__(self):
super().__init__(title="About Menu")
self.set_default_size(300, 550)
self.set_size_request(300, 500)
self.set_resizable(False)
self.set_title("About PC")
self.set_name("about-menu")
self.set_visible(False)
main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
main_box.set_margin_top(10)
main_box.set_margin_bottom(20)
main_box.set_margin_start(20)
main_box.set_margin_end(20)
# Logo
logo_box = Gtk.Box(halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER)
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
get_relative_path("../config/assets/icons/misc/imac.svg"),
158,
108,
preserve_aspect_ratio=True,
)
logo = Gtk.Image.new_from_pixbuf(pixbuf)
logo_box.pack_start(logo, False, False, 0)
logo_box.set_margin_top(60)
# Product Name & Vendor
name_label = Gtk.Label()
name_label.set_margin_top(30)
name_label.set_markup(
f"{read_dmi('product_name')} "
)
vendor_label = Gtk.Label(label=read_dmi("sys_vendor"))
vendor_label.set_name("vendor-label")
vendor_label.set_halign(Gtk.Align.CENTER)
# Info Grid
info_grid = Gtk.Grid()
info_grid.set_row_spacing(6)
info_grid.set_column_spacing(10)
info_grid.set_valign(Gtk.Align.CENTER)
info_grid.set_halign(Gtk.Align.FILL)
def make_label(text, align_end=False, name=None):
label = Gtk.Label(label=text)
label.set_halign(Gtk.Align.END if align_end else Gtk.Align.START)
if name:
label.set_name(name)
return label
# Info values
labels = [
(
"Kernel",
subprocess.run(
"uname -r", shell=True, capture_output=True, text=True
).stdout.strip(),
),
(
"CPU",
subprocess.run(
"lscpu | grep 'Model name:' | cut -d ':' -f2-",
shell=True,
capture_output=True,
text=True,
).stdout.strip(),
),
(
"Memory",
subprocess.run(
"free -h --giga | grep Mem | tr -s ' ' | cut -d ' ' -f 2",
shell=True,
capture_output=True,
text=True,
).stdout.strip(),
),
("GPU", get_gpu_name()),
(
"Uptime",
subprocess.run(
"uptime -p", shell=True, capture_output=True, text=True
).stdout.strip(),
),
]
for i, (title, value) in enumerate(labels):
title_label = make_label(title, align_end=True)
value_label = make_label(value, align_end=False, name="info-label")
info_grid.attach(title_label, 0, i, 1, 1)
info_grid.attach(value_label, 1, i, 1, 1)
# Button
button_box = Gtk.Box(halign=Gtk.Align.CENTER)
button_box.set_margin_top(20)
more_info_button = Gtk.Button(label="More Info...", name="more-info-button")
more_info_button.connect("clicked", self.open_more_info)
button_box.pack_start(more_info_button, False, False, 0)
# Info Footer
info = Gtk.Label(
label="™ and © 2025 Linux Inc.\nAll rights reserved.\n\n",
justify=Gtk.Justification.CENTER,
halign=Gtk.Align.CENTER,
)
info.set_name("info-label")
info.set_margin_top(10)
# Layout Order
main_box.pack_start(logo_box, False, False, 0)
main_box.pack_start(name_label, False, False, 0)
main_box.pack_start(vendor_label, False, False, 0)
main_box.pack_start(info_grid, False, False, 0)
main_box.pack_start(button_box, False, False, 0)
main_box.pack_start(info, False, False, 0)
self.add(main_box)
def open_more_info(self, button):
# TODO: Implement the logic to open more information
pass
def toggle(self, b):
if self.get_visible():
self.hide()
else:
self.show_all()
================================================
FILE: modules/controlcenter/battery.py
================================================
import subprocess
from fabric.utils import get_relative_path
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.centerbox import CenterBox
from fabric.widgets.image import Image
from fabric.widgets.box import Box
from fabric.widgets.label import Label
from fabric.widgets.separator import Separator
from fabric.widgets.svg import Svg
from gi.repository import GLib
from services.battery import Battery
class EnergyModeButton(Box):
def __init__(
self,
profile_name: str,
display_name: str,
icon_name: str,
battery_service: Battery,
parent,
**kwargs,
):
super().__init__(name="energy-mode-button", **kwargs)
self.profile_name = profile_name
self.battery_service = battery_service
self.parent = parent
self.mode_icon_svg = Svg(
# icon_name=f"battery-{icon_name}-symbolic",
svg_file=get_relative_path(
f"../../config/assets/icons/power_modes/battery-{icon_name}.svg"
),
size=24,
)
self.mode_icon = Box(
children=[self.mode_icon_svg],
name="energy-mode-icon",
style_classes="battery-profile-icon",
)
self.mode_label = Label(
label=display_name,
style_classes="battery-power-mode",
h_align="start",
h_expand=True,
)
start_box = Box(
orientation="horizontal",
spacing=4,
children=[self.mode_icon, self.mode_label],
)
self.button = Button(
child=start_box,
h_expand=True,
name="energy-mode-button-clickable",
on_clicked=self.on_clicked,
style_classes="battery-profile-button",
)
self.children = [self.button]
self.update_state()
def on_clicked(self, *args):
success = self.battery_service.change_power_profile(self.profile_name)
if success:
# Update all profile buttons in parent
self.parent.update_energy_mode_buttons()
# Reset icon state after short delay
GLib.timeout_add(300, lambda: self._reset_icon_state())
def _reset_icon_state(self):
return False # Remove timeout
def update_state(self):
is_active = self.battery_service.power_profile == self.profile_name
if is_active:
self.mode_icon.add_style_class("connected")
else:
self.mode_icon.remove_style_class("connected")
class GameModeButton(Box):
def __init__(self, parent, **kwargs):
super().__init__(name="energy-mode-button", h_expand=True, **kwargs)
self.parent = parent
self.game_icon = Image(
icon_name="applications-games-symbolic",
size=16,
name="game-mode-icon",
style_classes="battery-gamemode-icon",
)
self.game_label = Label(
label="Game Mode",
style_classes="gamemode-button",
h_align="start",
h_expand=True,
)
start_box = Box(
orientation="horizontal",
spacing=3,
children=[self.game_icon, self.game_label],
)
self.button = Button(
child=start_box,
name="game-mode-button-clickable",
on_clicked=self.on_clicked,
h_expand=True,
style_classes="battery-gamemode-button",
)
self.children = [self.button]
self.update_state()
def on_clicked(self, *args):
try:
script_path = get_relative_path("../../scripts/gamemode.sh")
subprocess.run([script_path], check=False)
GLib.timeout_add(500, lambda: self.update_state())
except Exception as e:
print(f"Failed to toggle game mode: {e}")
GLib.timeout_add(300, lambda: self._reset_icon_state())
def _reset_icon_state(self):
return False # Remove timeout
def update_state(self):
try:
script_path = get_relative_path("../../scripts/gamemode.sh")
result = subprocess.run(
[script_path, "check"], capture_output=True, text=True, check=False
)
is_active = result.stdout.strip() == "t"
if is_active:
self.game_icon.add_style_class("connected")
else:
self.game_icon.remove_style_class("connected")
except Exception as e:
print(f"Failed to check game mode status: {e}")
# Default to inactive state on error
self.game_icon.remove_style_class("connected")
return False # Remove timeout if called from GLib.timeout_add
class BatteryControl(Box):
def __init__(self, parent, **kwargs):
super().__init__(
spacing=12,
orientation="vertical",
name="control-center-widgets",
**kwargs,
)
self.set_size_request(354, -1)
self.parent = parent
self.battery_service = Battery()
self.energy_mode_buttons = []
self.battery_widget = Box(
name="battery-widget",
orientation="vertical",
style_classes="battery-status-section",
h_expand=True,
spacing=8,
)
self.battery_title = Label(
label="Battery", style_classes="battery-main-title", h_align="start"
)
self.battery_percentage_label = Label(
label="80%", style_classes="battery-percentage", h_align="end"
)
self.battery_header = CenterBox(
start_children=self.battery_title,
end_children=self.battery_percentage_label,
name="battery-header",
)
self.power_source_label = Label(
label="Power Source: Power Adapter",
style_classes="battery-power-source",
h_align="start",
)
self.charging_time_label = Label(
label="1h 4m until fully charged",
style_classes="battery-power-source",
h_align="start",
)
self.energy_mode_section = Box(
orientation="vertical", spacing=8, name="energy-mode-section"
)
self.energy_mode_title = Label(
label="Energy Mode", style_classes="battery-section-title", h_align="start"
)
self.energy_modes_container = Box(
orientation="vertical", spacing=4, name="energy-modes-container"
)
self.game_mode_section = Box(
orientation="vertical", spacing=8, name="game-mode-section"
)
self.game_mode_title = Label(
label="Game Mode", style_classes="battery-section-title", h_align="start"
)
self.game_mode_container = Box(
orientation="vertical", spacing=4, name="game-mode-container"
)
self.battery_settings_button = Button(
v_align="center",
child=Label(
label="Battery Settings",
h_align="start",
),
style_classes="battery-settings-button",
on_clicked=self.open_battery_settings,
)
self.battery_widget.add(self.battery_header)
self.battery_widget.add(self.power_source_label)
self.battery_widget.add(self.charging_time_label)
separator1 = Separator(orientation="h", name="separator")
self.battery_widget.add(separator1)
self.energy_mode_section.add(self.energy_mode_title)
self.energy_mode_section.add(self.energy_modes_container)
self.battery_widget.add(self.energy_mode_section)
separator2 = Separator(orientation="h", name="separator")
self.battery_widget.add(separator2)
self.game_mode_section.add(self.game_mode_title)
self.game_mode_section.add(self.game_mode_container)
self.battery_widget.add(self.game_mode_section)
separator3 = Separator(orientation="h", name="separator")
self.battery_widget.add(separator3)
self.battery_widget.add(self.battery_settings_button)
self.add(self.battery_widget)
self.battery_service.connect("changed", self.on_battery_changed)
self.battery_service.connect("profile_changed", self.on_profile_changed)
# Initialize display
self.update_battery_info()
self.create_energy_mode_buttons()
self.create_game_mode_button()
def open_battery_settings(self, *args):
# TODO: Implement to open Battery Settings
pass
def create_energy_mode_buttons(self):
# Clear existing buttons
for button in self.energy_mode_buttons:
button.destroy()
self.energy_mode_buttons.clear()
# Get available profiles
available_profiles = self.battery_service.available_profiles
if not available_profiles:
no_profiles_label = Label(
label="No energy modes available",
style_classes="battery-no-profiles",
h_align="start",
)
self.energy_modes_container.add(no_profiles_label)
return
# Define energy mode mappings with proper icon names
energy_mode_config = {
"balanced": {"display": "Automatic", "icon": "balanced"},
"power-saver": {"display": "Low Power", "icon": "power"},
"powersave": {"display": "Low Power", "icon": "power"},
"performance": {"display": "High Power", "icon": "performance"},
}
# Define the desired order for energy modes
desired_order = ["balanced", "power-saver", "powersave", "performance"]
# Create ordered list of available profiles
ordered_profiles = []
for profile_name in desired_order:
if profile_name in available_profiles:
ordered_profiles.append(profile_name)
# Add any remaining profiles not in the desired order
for profile in available_profiles:
if profile not in ordered_profiles:
ordered_profiles.append(profile)
# Create button for each available profile in the specified order
for profile in ordered_profiles:
config = energy_mode_config.get(
profile, {"display": profile.title(), "icon": "good"}
)
button = EnergyModeButton(
profile_name=profile,
display_name=config["display"],
icon_name=config["icon"],
battery_service=self.battery_service,
parent=self,
)
self.energy_mode_buttons.append(button)
self.energy_modes_container.add(button)
def update_energy_mode_buttons(self):
for button in self.energy_mode_buttons:
button.update_state()
def create_game_mode_button(self):
# Clear existing game mode button if any
for child in list(self.game_mode_container.get_children()):
child.destroy()
# Create game mode button
self.game_mode_button = GameModeButton(parent=self)
self.game_mode_container.add(self.game_mode_button)
def update_battery_info(self):
if not self.battery_service.is_present:
self.battery_percentage_label.set_label("No Battery")
self.power_source_label.set_label("Power Source: Not Present")
self.charging_time_label.set_label("")
return
# Update percentage in header
percentage = self.battery_service.percentage
self.battery_percentage_label.set_label(f"{percentage}%")
# Update power source and charging info
state = self.battery_service.state
if state in ["CHARGING", "PENDING_CHARGE"]:
self.power_source_label.set_label("Power Source: Power Adapter")
time_to_full = self.battery_service.time_to_full
if time_to_full != "N/A" and time_to_full != "0m":
self.charging_time_label.set_label(
f"{time_to_full} until fully charged"
)
else:
self.charging_time_label.set_label("Charging...")
elif state == "FULLY_CHARGED":
self.power_source_label.set_label("Power Source: Power Adapter")
self.charging_time_label.set_label("Fully Charged")
elif state in ["DISCHARGING", "PENDING_DISCHARGE"]:
self.power_source_label.set_label("Power Source: Battery")
time_to_empty = self.battery_service.time_to_empty
if time_to_empty != "N/A" and not time_to_empty.startswith(
"4553h"
): # Filter out unrealistic times
self.charging_time_label.set_label(f"{time_to_empty} remaining")
else:
self.charging_time_label.set_label("On Battery Power")
elif state == "EMPTY":
self.power_source_label.set_label("Power Source: Battery")
self.charging_time_label.set_label("Battery Empty")
else:
self.power_source_label.set_label("Power Source: Unknown")
self.charging_time_label.set_label("")
def on_battery_changed(self, *args):
self.update_battery_info()
def on_profile_changed(self, service, new_profile):
self.update_energy_mode_buttons()
================================================
FILE: modules/controlcenter/bluetooth.py
================================================
import subprocess
import gi
from fabric.bluetooth import BluetoothClient, BluetoothDevice
from fabric.utils import get_relative_path
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.centerbox import CenterBox
from fabric.widgets.image import Image
from fabric.widgets.label import Label
from fabric.widgets.revealer import Revealer
from fabric.widgets.scrolledwindow import ScrolledWindow
from fabric.widgets.separator import Separator
from fabric.widgets.svg import Svg
from gi.repository import Gdk, GLib, Gtk
from loguru import logger
from services.battery import Battery
gi.require_version("Gtk", "3.0")
gi.require_version("Gdk", "3.0")
def set_bluetooth_enabled_with_fallback(client, enabled: bool):
try:
# Try fabric bluetooth first
client.set_enabled(enabled)
except Exception as e:
logger.warning(f"Fabric bluetooth set_enabled({enabled}) failed: {e}")
# Fallback to rfkill to unblock/block bluetooth
if enabled:
command = ["rfkill", "unblock", "bluetooth"]
else:
command = ["rfkill", "block", "bluetooth"]
try:
# Execute the rfkill command
subprocess.run(
command, capture_output=True, text=True, timeout=10, check=True
)
except Exception as subprocess_error:
logger.error(f"rfkill fallback failed with exception: {subprocess_error}")
class BluetoothDeviceSlot(CenterBox):
def __init__(self, device: BluetoothDevice, **kwargs):
super().__init__(h_expand=True, name="device-button", **kwargs)
self.device = device
self.device.connect("changed", self.on_changed)
self.device.connect(
"notify::closed", lambda *_: self.device.closed and self.destroy()
)
self.styles = [
"connected" if self.device.connected else "",
"paired" if self.device.paired else "",
]
self.dimage = Image(
icon_name=device.icon_name + "-symbolic", # type: ignore
size=5,
name="device-icon",
style_classes=" ".join(self.styles),
)
self.device_button = Button(
on_clicked=lambda *_: self.toggle_connecting(),
child=Box(
orientation="h",
h_expand=True,
children=[self.dimage, Label(label=device.name)],
), # type: ignore
)
self.start_children = [self.device_button]
# Add battery info if available
if hasattr(device, "battery_percentage") and device.battery_percentage > 0:
battery_box = Box(orientation="h", spacing=4)
# Create battery icon
battery_icon = Svg(
size=16,
svg_file=get_relative_path(
Battery.get_battery_icon_file(
device.battery_percentage,
False, # Not charging for bluetooth devices
"../../config/assets/icons/",
)
),
name="battery-icon",
)
# Create battery percentage label
battery_label = Label(
label=f"{device.battery_percentage:.0f}%", name="battery-label"
)
battery_box.children = [battery_icon, battery_label]
self.end_children = [battery_box]
self.device_button.connect("enter-notify-event", self.on_button_enter)
self.device_button.connect("leave-notify-event", self.on_button_leave)
self.device.emit("changed") # to update display status
def on_button_enter(self, widget, event):
self.add_style_class("button-hovered")
def on_button_leave(self, widget, event):
self.remove_style_class("button-hovered")
def toggle_connecting(self):
self.device.emit("changed")
self.device.set_connecting(not self.device.connected)
def on_changed(self, *_):
try:
# Update connection and pairing status
new_styles = [
"connected" if self.device.connected else "",
"paired" if self.device.paired else "",
]
self.styles = new_styles
self.dimage.set_property("style-classes", " ".join(self.styles))
except Exception:
return
# Update battery info if available
if (
hasattr(self.device, "battery_percentage")
and self.device.battery_percentage > 0
):
if not self.end_children: # Add battery display if not already present
battery_box = Box(orientation="h", spacing=4)
# Create battery icon
battery_icon = Svg(
size=16,
svg_file=get_relative_path(
Battery.get_battery_icon_file(
self.device.battery_percentage,
False, # Not charging for bluetooth devices
"../../config/assets/icons/",
)
),
name="battery-icon",
)
# Create battery percentage label
battery_label = Label(
label=f"{self.device.battery_percentage:.0f}%", name="battery-label"
)
battery_box.children = [battery_icon, battery_label]
self.end_children = [battery_box]
else: # Update existing battery display
battery_box = self.end_children[0]
if hasattr(battery_box, "children") and len(battery_box.children) >= 2:
battery_icon = battery_box.children[0]
battery_label = battery_box.children[1]
# Update battery icon
battery_icon.set_from_file(
get_relative_path(
Battery.get_battery_icon_file(
self.device.battery_percentage,
False, # Not charging for bluetooth devices
"../../config/assets/icons/",
)
)
)
# Update battery percentage
battery_label.set_label(f"{self.device.battery_percentage:.0f}%")
elif self.end_children: # Remove battery display if no longer available
self.end_children = []
return
class BluetoothConnections(Box):
def __init__(
self, parent, show_hidden_devices: bool = False, show_back_button=True, **kwargs
):
super().__init__(
spacing=8,
orientation="vertical",
name="bluetooth-connections",
**kwargs,
)
self.parent = parent
self.show_hidden_devices = show_hidden_devices
self.is_scanning = False # Track scanning state
self.refresh_timer = None # Timer for periodic device refresh
self._update_in_progress = False # Prevent concurrent updates
self._destroyed = False # Track if widget is destroyed
self.client = BluetoothClient(on_device_added=self.on_device_added)
# Create pull-to-refresh indicator
self.refresh_indicator = Label(
name="bluetooth-refresh-indicator",
label="↓ Pull to scan for devices",
h_align="center",
visible=False,
style="color: #fff; font-size: 12px; padding: 5px;",
)
# Create title with optional back button
title_children = []
if show_back_button:
title_children.append(
Button(
image=Image(icon_name="back", size=10),
on_clicked=lambda *_: self.parent.close_bluetooth(),
)
)
title_children.append(Label("Bluetooth", name="bluetooth-title"))
self.title = Box(
orientation="h",
children=title_children,
)
self.toggle_button = Gtk.Switch(visible=True, name="toggle-button")
# Safely set initial state
self.toggle_button.set_active(self.client.enabled)
self.toggle_button.connect(
"notify::active",
lambda *_: set_bluetooth_enabled_with_fallback(
self.client, self.toggle_button.get_active()
),
)
# Connect client signals
self.client.connect(
"notify::enabled",
lambda *_: self.toggle_button.set_active(self.client.enabled),
)
self.client.connect("notify::scanning", lambda *_: self.update_scan_label())
# Connect to device changes
self.client.connect("device-added", self.update_devices)
self.client.connect("device-removed", self.update_devices)
# Connect to additional signals for better real-time monitoring
self.client.connect("changed", self.on_client_changed)
# Create Devices section
self.paired_devices_label = Label(
label="Devices", h_align="start", name="networks-title"
)
self.paired_devices = Box(
spacing=4, orientation="vertical", name="known-networks"
)
# Create "No devices available" message
self.no_devices_label = Label(
label="No devices available",
h_align="center",
name="no-networks-label",
visible=False,
)
# Create Other Devices section with clickable title
self.other_devices_button = Button(
child=Label("Other Devices", h_align="start"),
name="wifi-other-button",
on_clicked=self.toggle_other_devices,
)
self.other_devices = Box(spacing=4, orientation="vertical")
# Create scrolled window for other devices
self.other_devices_scrolled = ScrolledWindow(
min_content_size=(303, 150),
child=self.other_devices,
overlay_scroll=True,
)
# Add pull-to-refresh functionality to scrolled window
self.setup_pull_to_refresh()
# Create revealer for Other Devices section
self.other_devices_revealer = Revealer(
child=self.other_devices_scrolled,
transition_type="slide-down",
transition_duration=100,
child_revealed=False,
)
# Create More Settings button (same style as Other Devices button)
self.more_settings_button = Button(
child=Label("More Settings", h_align="start"),
name="wifi-other-button",
on_clicked=self.open_bluetooth_settings,
)
self.children = [
CenterBox(
start_children=self.title,
end_children=self.toggle_button,
name="bluetooth-widget-top",
),
self.refresh_indicator,
Separator(orientation="h", name="separator"),
self.paired_devices_label,
self.paired_devices,
self.no_devices_label,
Separator(orientation="h", name="separator"),
self.other_devices_button,
self.other_devices_revealer,
Separator(orientation="h", name="separator"),
self.more_settings_button,
]
# Connect cleanup on destroy
self.connect("destroy", self.on_destroy)
self.client.notify("scanning")
self.client.notify("enabled")
# Initial device update
self.update_devices()
# Start periodic device monitoring for real-time updates
self.start_device_monitoring()
def toggle_other_devices(self, *_):
"""Toggle the visibility of other devices section"""
current_state = self.other_devices_revealer.child_revealed
self.other_devices_revealer.child_revealed = not current_state
# Handle scanning based on section visibility
if self.client:
if self.other_devices_revealer.child_revealed:
# Start scanning when revealing other devices section
if not self.client.scanning:
self.client.toggle_scan()
# Also force an immediate device refresh to catch any missed connections
self.force_device_refresh()
else:
# Stop scanning when hiding other devices section
if self.client.scanning:
self.client.toggle_scan()
def open_bluetooth_settings(self, *_):
"""Open Blueman bluetooth manager"""
try:
subprocess.Popen(["blueman-manager"], start_new_session=True)
if self.parent and hasattr(self.parent, "hide_controlcenter"):
self.parent.hide_controlcenter()
except FileNotFoundError:
pass
except Exception:
pass
def update_scan_label(self):
"""Update scanning state appearance"""
if self.client.scanning:
# Show scanning feedback in refresh indicator
self.refresh_indicator.set_label("Scanning for devices...")
self.refresh_indicator.set_visible(True)
self.refresh_indicator.add_style_class("scanning")
else:
# Hide scanning feedback
self.refresh_indicator.set_visible(False)
self.refresh_indicator.remove_style_class("scanning")
def update_devices(self, *_):
"""Update the list of available devices"""
# Prevent concurrent updates and check if destroyed
if self._update_in_progress or self._destroyed or not self.client:
return
self._update_in_progress = True
try:
# Store current device addresses to detect changes
current_paired_addresses = {
child.device.address
for child in self.paired_devices.get_children()
if hasattr(child, "device")
}
current_other_addresses = {
child.device.address
for child in self.other_devices.get_children()
if hasattr(child, "device")
}
# Get current devices safely
devices = self.client.devices
paired_devices = []
other_devices = []
new_paired_addresses = set()
new_other_addresses = set()
for device in devices:
try:
if device.name and device.name != "Unknown":
# Categorize devices: paired devices go to "Devices (Paired)"
# All others go to "Other Devices"
if device.paired:
paired_devices.append(device)
new_paired_addresses.add(device.address)
else:
other_devices.append(device)
new_other_addresses.add(device.address)
except Exception:
continue
# Check if we need to update (devices added/removed)
paired_changed = current_paired_addresses != new_paired_addresses
other_changed = current_other_addresses != new_other_addresses
# Only rebuild if something actually changed
if paired_changed or other_changed:
# Clear existing devices safely
for child in list(self.paired_devices.get_children()):
if not self._destroyed:
child.destroy()
for child in list(self.other_devices.get_children()):
if not self._destroyed:
child.destroy()
# Add paired devices
for device in paired_devices:
if not self._destroyed:
device_slot = BluetoothDeviceSlot(device)
self.paired_devices.add(device_slot)
# Add other devices
for device in other_devices:
if not self._destroyed:
device_slot = BluetoothDeviceSlot(device)
self.other_devices.add(device_slot)
# Show/hide sections based on available devices
if not self._destroyed:
has_paired_devices = len(paired_devices) > 0
has_other_devices = len(other_devices) > 0
has_any_devices = has_paired_devices or has_other_devices
# Show paired devices section only if there are paired devices
self.paired_devices_label.set_visible(has_paired_devices)
self.paired_devices.set_visible(has_paired_devices)
# Show "No devices available" message if no devices at all
self.no_devices_label.set_visible(not has_any_devices)
# Always show the other devices button, regardless of available devices
self.other_devices_button.set_visible(True) # Always visible
except Exception:
pass
finally:
self._update_in_progress = False
def start_device_monitoring(self):
"""Start periodic monitoring for device changes"""
# Monitor for device changes every 5 seconds (less aggressive)
# This helps catch devices that connect from external sources
self.refresh_timer = GLib.timeout_add_seconds(5, self.periodic_device_refresh)
def stop_device_monitoring(self):
"""Stop periodic monitoring"""
if self.refresh_timer:
GLib.source_remove(self.refresh_timer)
self.refresh_timer = None
def periodic_device_refresh(self):
"""Periodically refresh device list to catch external connections"""
# Skip if update in progress, destroyed, or client not available
if (
self._update_in_progress
or self._destroyed
or not self.client
or not self.client.enabled
):
return True # Continue monitoring
try:
# Simple check - just trigger update_devices which has its own safety checks
# Don't force signal emissions as that can cause race conditions
self.update_devices()
except Exception:
pass
return True # Continue monitoring
def force_device_refresh(self):
"""Force an immediate refresh of the device list"""
if self._update_in_progress or self._destroyed:
return
try:
# Simply trigger update_devices which has its own safety checks
# Avoid forcing signal emissions to prevent race conditions
self.update_devices()
except Exception:
pass
def on_client_changed(self, *_):
"""Handle when the bluetooth client state changes"""
# Update devices when client state changes
self.update_devices()
def on_destroy(self, widget):
"""Cleanup when widget is destroyed"""
# Mark as destroyed to prevent further updates
self._destroyed = True
# Stop monitoring
self.stop_device_monitoring()
# Make sure other devices revealer is collapsed when closing
try:
self.other_devices_revealer.child_revealed = False
except:
pass # Widget might already be destroyed
def close_bluetooth(self):
"""Called when Bluetooth panel is being closed"""
# Collapse the other devices section when closing
self.other_devices_revealer.child_revealed = False
def setup_pull_to_refresh(self):
"""Setup pull-to-refresh gesture for the scrolled window"""
# Get the scrolled window's vertical adjustment
self.vadjustment = self.other_devices_scrolled.get_vadjustment()
# Track gesture state
self.pull_start_y = 0
self.is_pulling = False
self.pull_threshold = 50 # pixels to trigger refresh
# Connect to scroll events
self.other_devices_scrolled.connect("scroll-event", self.on_scroll_event)
self.other_devices_scrolled.connect("button-press-event", self.on_button_press)
self.other_devices_scrolled.connect(
"button-release-event", self.on_button_release
)
self.other_devices_scrolled.connect(
"motion-notify-event", self.on_motion_notify
)
# Enable events
self.other_devices_scrolled.set_events(
Gdk.EventMask.SCROLL_MASK
| Gdk.EventMask.BUTTON_PRESS_MASK
| Gdk.EventMask.BUTTON_RELEASE_MASK
| Gdk.EventMask.POINTER_MOTION_MASK
)
def on_scroll_event(self, widget, event):
"""Handle scroll events for pull-to-refresh"""
# Only handle pull-to-refresh when at the top
if self.vadjustment.get_value() <= 0:
if event.direction == Gdk.ScrollDirection.UP:
# Scrolling up at the top - toggle scan and force refresh
self.client.toggle_scan()
self.force_device_refresh()
return True # Consume the event
return False # Let normal scrolling continue
def on_button_press(self, widget, event):
"""Handle button press for touch/drag gestures"""
if self.vadjustment.get_value() <= 0:
self.pull_start_y = event.y
self.is_pulling = True
return False
def on_button_release(self, widget, event):
"""Handle button release for touch/drag gestures"""
if self.is_pulling:
pull_distance = event.y - self.pull_start_y
if pull_distance > self.pull_threshold:
# Toggle scan and force refresh
self.client.toggle_scan()
self.force_device_refresh()
# Hide refresh indicator
self.refresh_indicator.set_visible(False)
self.refresh_indicator.remove_style_class("ready-to-refresh")
self.is_pulling = False
return False
def on_motion_notify(self, widget, event):
"""Handle motion events for visual feedback during pull"""
if self.is_pulling and self.vadjustment.get_value() <= 0:
pull_distance = event.y - self.pull_start_y
if pull_distance > 0:
# Show refresh indicator when pulling down
self.refresh_indicator.set_visible(True)
if pull_distance >= self.pull_threshold:
if self.client.scanning:
self.refresh_indicator.set_label("↑ Release to stop scanning")
else:
self.refresh_indicator.set_label("↑ Release to scan")
self.refresh_indicator.add_style_class("ready-to-refresh")
else:
if self.client.scanning:
self.refresh_indicator.set_label("↓ Pull to stop scanning")
else:
self.refresh_indicator.set_label("↓ Pull to scan for devices")
self.refresh_indicator.remove_style_class("ready-to-refresh")
else:
self.refresh_indicator.set_visible(False)
return False
def on_device_added(self, client: BluetoothClient, address: str):
"""Handle when a new device is added"""
# Update the device list when devices are added
self.update_devices()
================================================
FILE: modules/controlcenter/expanded_player.py
================================================
# Standard library imports
import os
import re
import tempfile
import urllib.parse
import urllib.request
import threading
import weakref
from typing import List, Optional, Dict, Set
import gc
# Fabric imports
from fabric.widgets.scale import Scale
from widgets.wayland import WaylandWindow as Window
from fabric.widgets.button import Button
from fabric.widgets.label import Label
from fabric.widgets.box import Box
from fabric.utils import bulk_connect, invoke_repeater, cooldown
from fabric.utils.helpers import get_relative_path
from fabric.widgets.image import Image
from fabric.widgets.overlay import Overlay
from fabric.widgets.stack import Stack
from fabric.widgets.svg import Svg
from gi.repository import GLib, GObject
from loguru import logger
# Local imports
from services.mpris import MprisPlayer, MprisPlayerManager
import config.data as data
CACHE_DIR = f"{data.CACHE_DIR}/media"
# Global memory management
_shared_mpris_manager = None
_widget_cache = weakref.WeakValueDictionary()
_artwork_cache = {}
_max_artwork_cache_size = 10 # Limit artwork cache to prevent memory bloat
def get_shared_mpris_manager():
"""Get shared MPRIS manager instance to reduce memory usage."""
global _shared_mpris_manager
if _shared_mpris_manager is None:
_shared_mpris_manager = MprisPlayerManager()
return _shared_mpris_manager
def cleanup_artwork_cache():
"""Clean up artwork cache to prevent memory leaks."""
global _artwork_cache
if len(_artwork_cache) > _max_artwork_cache_size:
# Keep only the most recent items
items = list(_artwork_cache.items())
_artwork_cache = dict(items[-_max_artwork_cache_size:])
# Force garbage collection
gc.collect()
def cleanup_old_cache_files():
"""Clean up old artwork cache files aggressively to save memory."""
try:
if not os.path.exists(CACHE_DIR):
return
import time
current_time = time.time()
# Clean files older than 2 hours (more aggressive)
two_hours_ago = current_time - (2 * 60 * 60)
for filename in os.listdir(CACHE_DIR):
filepath = os.path.join(CACHE_DIR, filename)
try:
if os.path.isfile(filepath):
file_mod_time = os.path.getmtime(filepath)
if file_mod_time < two_hours_ago:
os.remove(filepath)
logger.debug(f"Cleaned up old cache file: {filename}")
except Exception as e:
logger.warning(f"Failed to clean up cache file {filename}: {e}")
except Exception as e:
logger.error(f"Error during cache cleanup: {e}")
def get_artwork_cached(url: str) -> Optional[str]:
"""Get artwork with memory-efficient caching."""
if not url:
return None
# Check memory cache first
if url in _artwork_cache:
return _artwork_cache[url]
# Clean cache if too large
cleanup_artwork_cache()
try:
# Create cache directory if it doesn't exist
os.makedirs(CACHE_DIR, exist_ok=True)
# Generate cache filename
safe_filename = re.sub(
r"[^\w\-_.]", "_", urllib.parse.urlparse(url).path.split("/")[-1]
)
if not safe_filename:
safe_filename = f"artwork_{hash(url)}"
cache_file = os.path.join(CACHE_DIR, safe_filename)
# Check if cached file exists and is recent (within 2 hours)
if os.path.exists(cache_file):
import time
if time.time() - os.path.getmtime(cache_file) < 7200: # 2 hours
_artwork_cache[url] = cache_file
return cache_file
# Download and cache
urllib.request.urlretrieve(url, cache_file)
_artwork_cache[url] = cache_file
return cache_file
except Exception as e:
logger.warning(f"Failed to cache artwork from {url}: {e}")
return None
for filename in os.listdir(CACHE_DIR):
filepath = os.path.join(CACHE_DIR, filename)
try:
if os.path.isfile(filepath):
file_mtime = os.path.getmtime(filepath)
if file_mtime < six_hours_ago:
os.unlink(filepath)
except Exception:
pass # Ignore individual file errors
except Exception:
pass # Ignore all errors in cleanup
class EmbeddedExpandedPlayer(Box):
"""Embedded expanded player widget for use inside control center."""
def __init__(self, control_center, **kwargs):
super().__init__(
orientation="vertical",
h_expand=True,
name="embedded-expanded-player",
**kwargs,
)
self.control_center = control_center
self.mpris_manager = get_shared_mpris_manager()
# Create back button (hidden in header)
self.back_button = Button(
name="back-button",
child=Label(label="← Back"),
on_clicked=self._on_back_clicked,
)
# Create expanded player content
self.player_content = PlayerBoxStack(self.mpris_manager)
# Add escape key binding for navigation back
self._keybinding_added = False
try:
if hasattr(self.control_center, "add_keybinding"):
self.control_center.add_keybinding("Escape", self._on_back_clicked)
self._keybinding_added = True
except Exception:
pass # Ignore if keybinding fails
self.children = [
Box(
orientation="horizontal",
h_expand=True,
style_classes="menu",
visible=False, # Hide header to remove title and back button
children=[
self.back_button,
Box(h_expand=True), # Spacer
Label(label="Now Playing", style_classes="title"),
Box(h_expand=True), # Spacer
],
),
self.player_content,
]
def _on_back_clicked(self, *_):
"""Handle back button click"""
if self.control_center and hasattr(
self.control_center, "close_expanded_player"
):
self.control_center.close_expanded_player()
def refresh(self):
"""Refresh the player content"""
# This will automatically update as MPRIS players change
pass
def destroy(self):
"""Clean up resources and prevent memory leaks"""
logger.debug("🗑️ EmbeddedExpandedPlayer cleanup starting")
try:
# Destroy player content (PlayerBoxStack)
if hasattr(self, "player_content") and hasattr(
self.player_content, "destroy"
):
self.player_content.destroy()
# Clean up back button
if hasattr(self, "back_button") and hasattr(self.back_button, "destroy"):
self.back_button.destroy()
# Clean up any other widgets we might have
for child in list(self.get_children()):
try:
child.destroy()
except Exception as e:
logger.warning(f"Failed to destroy child widget: {e}")
# Clean up keybinding if it was added
if hasattr(self, "_keybinding_added") and self._keybinding_added:
try:
if hasattr(self.control_center, "remove_keybinding"):
self.control_center.remove_keybinding("Escape")
except Exception as e:
logger.warning(f"Failed to remove keybinding: {e}")
# Aggressively clean global caches
global _widget_cache, _artwork_cache
_widget_cache.clear()
_artwork_cache.clear()
logger.debug("🗑️ Cleared global widget and artwork caches")
# Clear references
if hasattr(self, "control_center"):
self.control_center = None
if hasattr(self, "mpris_manager"):
self.mpris_manager = None
# Force garbage collection
gc.collect()
logger.debug("🗑️ EmbeddedExpandedPlayer cleanup completed")
except Exception as e:
logger.error(f"Error during EmbeddedExpandedPlayer cleanup: {e}")
finally:
super().destroy()
def _periodic_cleanup(self):
"""Light cleanup for EmbeddedExpandedPlayer reuse"""
try:
logger.debug("🧹 EmbeddedExpandedPlayer light cleanup starting")
# Only clean up the player content lightly
if hasattr(self, "player_content") and hasattr(
self.player_content, "_periodic_cleanup"
):
self.player_content._periodic_cleanup()
# Clean global caches but don't destroy core functionality
global _artwork_cache
_artwork_cache.clear()
# Light garbage collection
gc.collect()
logger.debug("🧹 EmbeddedExpandedPlayer light cleanup completed")
except Exception as e:
logger.warning(f"Error during EmbeddedExpandedPlayer light cleanup: {e}")
class PlayerBoxStack(Box):
"""Memory-optimized widget that displays current player information."""
def __init__(self, mpris_manager: MprisPlayerManager, **kwargs):
# Clean up old cache files on startup
cleanup_old_cache_files()
# The player stack with memory-efficient settings
self.player_stack = Stack(
name="player-stack",
# Disable transitions to reduce memory usage
transition_type="none",
)
self.current_stack_pos = 0
# Store player buttons - cleaned up via explicit removal
self.player_buttons: list[Button] = []
self._player_widgets: Dict[str, Box] = weakref.WeakValueDictionary()
# Track signal connections for cleanup using weak references
self._signal_connections = []
# Create a lightweight "No media playing" placeholder
self.no_media_box = self._create_no_media_box()
super().__init__(orientation="v", name="media", children=[self.player_stack])
# Show the no media box initially
self.player_stack.children = [self.no_media_box]
self.set_visible(True)
self.mpris_manager = mpris_manager
# Track connections for cleanup
connections = bulk_connect(
self.mpris_manager,
{
"player-appeared": self.on_new_player,
"player-vanished": self.on_lost_player,
},
)
for handler_id in connections:
self._signal_connections.append((self.mpris_manager, handler_id))
# Process existing players
for player in self.mpris_manager.players: # type: ignore
logger.info(
f"[PLAYER MANAGER] player found: {player.get_property('player-name')}"
)
self.on_new_player(self.mpris_manager, player)
# Schedule periodic memory cleanup and store source ID
self._cleanup_source_id = GLib.timeout_add_seconds(
300, self._periodic_cleanup
) # Every 5 minutes
def _periodic_cleanup(self):
"""Light cleanup for widget reuse - preserve functionality."""
try:
logger.debug("Starting light expanded player cleanup for reuse")
# Clean artwork cache to reduce memory
cleanup_artwork_cache()
# Reset visual state but preserve connections and core functionality
# Only clear the player widgets that can be recreated
for widget in list(self._player_widgets.values()):
try:
if widget and hasattr(widget, "get_parent") and widget.get_parent():
# Only remove from parent, don't destroy (let GTK handle it)
widget.get_parent().remove(widget)
except Exception:
pass
# Don't clear the _player_widgets dict - just let them be recreated
# Reset stack to show no_media_box without destroying children
try:
if hasattr(self, "player_stack") and hasattr(self, "no_media_box"):
self.player_stack.set_visible_child(self.no_media_box)
self.current_stack_pos = 0
except Exception:
pass
# Light garbage collection
gc.collect()
logger.debug("Light expanded player cleanup completed")
except Exception as e:
logger.warning(f"Error during light cleanup: {e}")
return True # Continue timer
def destroy(self):
"""Clean up resources when the widget is destroyed."""
try:
# Cancel any pending cleanup timer
if hasattr(self, "_cleanup_source_id") and self._cleanup_source_id:
try:
GLib.source_remove(self._cleanup_source_id)
except Exception:
pass # Timer may have already been removed
# Disconnect all signal connections
for obj, handler_id in self._signal_connections:
try:
if obj and hasattr(obj, "disconnect"):
obj.disconnect(handler_id)
except Exception as e:
logger.warning(f"Failed to disconnect signal: {e}")
self._signal_connections.clear()
# Clean up player widgets
for widget in list(self._player_widgets.values()):
try:
if widget and hasattr(widget, "destroy"):
widget.destroy()
except Exception:
pass
self._player_widgets.clear()
# Clean up player buttons explicitly
for button in list(self.player_buttons):
try:
if button and hasattr(button, "destroy"):
button.destroy()
except Exception:
pass
# Clean up stack children
for child in self.player_stack.get_children():
if hasattr(child, "destroy") and child != self.no_media_box:
try:
child.destroy()
except Exception:
pass
# Force final garbage collection
gc.collect()
except Exception as e:
logger.error(f"Error during PlayerBoxStack cleanup: {e}")
finally:
super().destroy()
def _create_no_media_box(self):
"""Create a placeholder box for when no media is playing."""
fallback_cover_path = f"{data.HOME_DIR}/.current.wall"
# Album cover with fallback image using Image widget
album_cover = Box(
name="macos-album-image-no",
)
album_cover.set_style(f"background-image:url('{fallback_cover_path}')")
image_stack = Box(h_align="start", v_align="center", name="player-image-stack")
image_stack.children = [album_cover]
# Track info showing "No media playing"
track_title = Label(
label="No media playing",
name="player-title-no",
justification="left",
max_chars_width=30,
ellipsization="end",
h_align="start",
)
track_artist = Label(
label="",
name="player-artist",
justification="left",
max_chars_width=12,
ellipsization="end",
h_align="start",
visible=False, # Hide artist and album when no media
)
track_album = Label(
label="",
name="player-album",
justification="left",
max_chars_width=12,
ellipsization="end",
h_align="start",
visible=False, # Hide artist and album when no media
)
track_info = Box(
name="track-info",
spacing=5,
orientation="v",
v_align="start",
h_align="start",
children=[track_title, track_artist, track_album],
)
# No control buttons for no media state - just an empty box
controls_box = Box(
name="player-controls",
visible=False, # Hide controls when no media
)
player_info_box = Box(
name="player-info-box",
v_align="center",
h_align="start",
orientation="v",
h_expand=True,
children=[track_info, controls_box],
)
inner_box = Box(
name="inner-player-box",
v_align="center",
h_align="start",
)
outer_box = Box(
name="outer-player-box",
h_align="start",
)
overlay_box = Overlay(
child=outer_box,
overlays=[
inner_box,
player_info_box,
image_stack,
],
)
no_media_box = Box(
h_align="center",
name="player-box",
h_expand=True,
children=[overlay_box],
)
return no_media_box
def on_player_clicked(self, type):
# unset active from prev active button
if self.player_buttons and self.current_stack_pos < len(self.player_buttons):
self.player_buttons[self.current_stack_pos].remove_style_class("active")
if type == "next":
self.current_stack_pos = (
self.current_stack_pos + 1
if self.current_stack_pos != len(self.player_stack.get_children()) - 1
else 0
)
elif type == "prev":
self.current_stack_pos = (
self.current_stack_pos - 1
if self.current_stack_pos != 0
else len(self.player_stack.get_children()) - 1
)
# set new active button
if self.player_buttons and self.current_stack_pos < len(self.player_buttons):
self.player_buttons[self.current_stack_pos].add_style_class("active")
self.player_stack.set_visible_child(
self.player_stack.get_children()[self.current_stack_pos],
)
def on_player_clicked_by_index(self, index):
"""Switch to player at given index"""
if 0 <= index < len(self.player_buttons):
# unset active from prev active button
if self.player_buttons and self.current_stack_pos < len(
self.player_buttons
):
self.player_buttons[self.current_stack_pos].remove_style_class("active")
# set new position
self.current_stack_pos = index
# set new active button
if self.player_buttons and self.current_stack_pos < len(
self.player_buttons
):
self.player_buttons[self.current_stack_pos].add_style_class("active")
self.player_stack.set_visible_child(
self.player_stack.get_children()[self.current_stack_pos],
)
# Update all player boxes with new button state
self._update_all_player_buttons()
def on_new_player(self, mpris_manager, player):
# if player_name in self.config.get("ignore", []):
# return
# Remove the no media box if it's the only child
if (
len(self.player_stack.get_children()) == 1
and self.player_stack.get_children()[0] == self.no_media_box
):
self.player_stack.children = []
self.current_stack_pos = 0
self.set_visible(True)
new_player_box = PlayerBox(player=MprisPlayer(player), player_stack=self)
self.player_stack.children = [
*self.player_stack.children,
new_player_box,
]
self.make_new_player_button(self.player_stack.get_children()[-1])
logger.info(
f"[PLAYER MANAGER] adding new player: {player.get_property('player-name')}",
)
if self.player_buttons and self.current_stack_pos < len(self.player_buttons):
self.player_buttons[self.current_stack_pos].set_style_classes(["active"])
# Update all player boxes with current button state
self._update_all_player_buttons()
def on_lost_player(self, mpris_manager, player_name):
# the playerBox is automatically removed from mprisbox children on being removed
logger.info(f"[PLAYER_MANAGER] Player Removed {player_name}")
players: List[PlayerBox] = self.player_stack.get_children()
# Find and properly destroy the player box
player_box_to_remove = None
for player_box in players:
if (
hasattr(player_box, "player")
and player_box.player.player_name == player_name
):
player_box_to_remove = player_box
break
if player_box_to_remove:
try:
player_box_to_remove.destroy()
except Exception as e:
logger.warning(f"Failed to destroy player box: {e}")
# Check if this was the last player
remaining_players = [
p for p in self.player_stack.get_children() if p != player_box_to_remove
]
if len(remaining_players) == 0:
# Show the no media box instead of hiding
self.player_stack.children = [self.no_media_box]
self.current_stack_pos = 0
self.player_buttons = [] # Clear player buttons
return
# Adjust current position if needed
if self.current_stack_pos >= len(self.player_stack.get_children()):
self.current_stack_pos = max(0, len(self.player_stack.get_children()) - 1)
# Set active button if we have buttons and a valid position
if self.player_buttons and self.current_stack_pos < len(self.player_buttons):
self.player_buttons[self.current_stack_pos].set_style_classes(["active"])
if self.player_stack.get_children():
self.player_stack.set_visible_child(
self.player_stack.get_children()[self.current_stack_pos],
)
# Update all player boxes with current button state
self._update_all_player_buttons()
def make_new_player_button(self, player_box):
new_button = Button(name="player-stack-button")
def on_player_button_click(button: Button):
if self.player_buttons and self.current_stack_pos < len(
self.player_buttons
):
self.player_buttons[self.current_stack_pos].remove_style_class("active")
if button in self.player_buttons:
self.current_stack_pos = self.player_buttons.index(button)
button.add_style_class("active")
self.player_stack.set_visible_child(player_box)
new_button.connect(
"clicked",
on_player_button_click,
)
self.player_buttons.append(new_button)
# This will automatically destroy our used button
def cleanup_button(*_):
try:
if new_button in self.player_buttons:
self.player_buttons.remove(new_button)
new_button.destroy()
except Exception as e:
logger.warning(f"Failed to cleanup button: {e}")
player_box.connect("destroy", cleanup_button)
def _update_all_player_buttons(self):
"""Update all player boxes with the current button state"""
players: List[PlayerBox] = self.player_stack.get_children()
logger.info(
f"[PlayerBoxStack] Updating buttons for {len(players)} players, {
len(self.player_buttons)
} buttons"
)
for player_box in players:
if hasattr(player_box, "update_buttons"):
player_box.update_buttons(self.player_buttons, len(players) > 1)
else:
logger.warning(
"[PlayerBoxStack] PlayerBox missing update_buttons method"
)
class PlayerBox(Box):
"""A widget that displays the current player information."""
def __init__(self, player: MprisPlayer, player_stack=None, **kwargs):
super().__init__(
h_align="center",
name="player-box",
**kwargs,
h_expand=True,
)
# Setup
self.player: MprisPlayer = player
self.player_stack = player_stack
self.fallback_cover_path = f"{data.HOME_DIR}/.current.wall"
self.icon_size = 15
# State
self.exit = False
self.skipped = False
self._user_seeking = False # Flag to prevent choppy seeking
self._seekbar_timer_id = None # Track timer ID for cleanup
# Memory management
self.temp_artwork_files = [] # Track temp files for cleanup
self.current_download_thread = None # Track current download thread
self._download_cancelled = False # Flag to cancel downloads
self._signal_connections = [] # Track signal connections
# Use same CSS background approach as small player for consistency
self.album_cover = Box(
name="macos-album-image",
)
self.album_cover.set_style(
f"background-image:url('{self.fallback_cover_path}')"
)
self.album_cover.set_size_request(70, 70)
self.image_stack = Box(
h_align="start", v_align="center", name="player-image-stack"
)
self.image_stack.children = [*self.image_stack.children, self.album_cover]
# Track Info
self.track_title = Label(
label="No Title",
name="macos-player-title",
justification="left",
max_chars_width=30,
ellipsization="end",
h_align="start",
h_expand=True,
)
self.track_artist = Label(
label="No Artist",
name="macos-player-artist",
justification="left",
max_chars_width=25,
ellipsization="end",
h_align="start",
h_expand=True,
visible=True,
)
self.track_album = Label(
label="No Album",
name="macos-player-album",
justification="left",
max_chars_width=25,
ellipsization="end",
h_align="start",
visible=True, # Hide artist and album when no media
)
self.app_icon = Box(
children=Image(
icon_name=self.player.player_name, name="player-app-icon", icon_size=20
),
h_align="end",
v_align="end",
tooltip_text=self.player.player_name, # type: ignore
)
self.image = Overlay(
child=self.image_stack,
overlays=[
self.app_icon,
],
)
# Seek bar should not update automatically during user interaction
self._user_seeking = False
self.seek_bar = Scale(
value=0,
min_value=0,
max_value=100,
increments=(1, 1),
name="expanded-seek-bar",
size=1,
h_expand=True,
)
self.seek_bar.connect("value-changed", self._on_scale_value_changed)
self.seek_bar.connect("button-press-event", self._on_seek_start)
self.seek_bar.connect("button-release-event", self._on_seek_end)
self.player.bind("can-seek", "sensitive", self.seek_bar)
# Position and length labels for seek bar
self.position_label = Label(
label="0:00",
name="macos-position-label",
justification="left",
h_align="start",
)
self.length_label = Label(
label="0:00",
name="macos-length-label",
justification="right",
h_align="end",
)
# Labels box for position and length below seek bar
self.labels_box = Box(
name="macos-labels-box",
orientation="h",
children=[
self.position_label,
Box(h_expand=True), # Spacer to push labels to ends
self.length_label,
],
)
# Seek bar with position labels below
self.seek_box = Box(
name="macos-seek-box",
orientation="v",
spacing=2,
children=[
self.seek_bar,
self.labels_box,
],
)
# Define buttons first
self.button_box = Box(
name="macos-button-box",
h_align="center",
h_expand=True,
spacing=10, # Reduced spacing for macOS look
)
# Define controls_box BEFORE using it in track_info
self.controls_box = Box(
name="macos-player-controls",
orientation="v",
h_expand=True,
spacing=6,
h_align="center",
children=[self.button_box],
)
# Track info with inline controls - expands to fill available space
self.track_info = Box(
name="macos-track-info",
spacing=4, # Reduced spacing
orientation="v",
v_align="start",
h_align="fill", # Fill all available horizontal space
h_expand=True, # Expand horizontally to take maximum space
v_expand=True, # Also expand vertically
children=[
self.track_title,
self.track_artist,
self.track_album,
],
)
# Bind player properties
self.player.bind_property(
"title",
self.track_title,
"label",
GObject.BindingFlags.DEFAULT,
lambda _, x: (
re.sub(r"\r?\n", " ", x) if x != "" and x is not None else "No Title"
), # type: ignore
)
self.player.bind_property(
"artist",
self.track_artist,
"label",
GObject.BindingFlags.DEFAULT,
lambda _, x: (
re.sub(r"\r?\n", " ", x) if x != "" and x is not None else "No Artist"
), # type: ignore
)
self.player.bind_property(
"album",
self.track_album,
"label",
GObject.BindingFlags.DEFAULT,
lambda _, x: (
re.sub(r"\r?\n", " ", x) if x != "" and x is not None else "No Album"
), # type: ignore
)
# Player switcher buttons box (compact, minimal space)
self.stack_buttons_box = Box(
h_expand=False, # Fixed width, don't expand
v_expand=True,
name="macos-stack-buttons-box",
spacing=4, # Reduced spacing
orientation="h", # Vertical layout for compactness
h_align="center",
v_align="end",
)
self.stack_buttons_box.hide() # Initially hidden
# Create SVG icons from player directory
self.skip_next_icon = Svg(
name="btn",
style_classes=["control-buttons"],
svg_file=get_relative_path("../../config/assets/icons/player/fwd.svg"),
)
self.skip_prev_icon = Svg(
name="btn",
style_classes=["control-buttons"],
svg_file=get_relative_path("../../config/assets/icons/player/Rewind.svg"),
)
self.play_pause_icon = Svg(
name="btn",
style_classes=["control-buttons"],
svg_file=get_relative_path("../../config/assets/icons/player/Pause.svg"),
)
self.play_pause_button = Button(
style_classes=["control-buttons"],
name="macos-play-button",
child=self.play_pause_icon,
on_clicked=self.player.play_pause,
)
self.player.bind_property("can_pause", self.play_pause_button, "sensitive")
self.next_button = Button(
style_classes=["control-buttons"],
name="macos-control-button",
child=self.skip_next_icon,
on_clicked=self._on_player_next,
)
self.player.bind_property("can_go_next", self.next_button, "sensitive")
self.prev_button = Button(
name="macos-control-button",
child=self.skip_prev_icon,
style_classes=["control-buttons"],
on_clicked=self._on_player_prev,
)
self.button_box.children = (
self.prev_button,
self.play_pause_button,
self.next_button,
)
self.box = Box(
orientation="horizontal",
children=[
self.image, # Album art on left (fixed width)
# Contains title, artist, seek bar AND controls (expands)
self.track_info,
],
)
self.inner_box = Box(
orientation="v",
h_expand=True,
h_align="fill", # Fill available space
children=[
self.box, # Track info and album art
self.seek_box, # Seek bar with position labels
self.controls_box, # Controls now inline with track info
],
)
# Compact macOS layout: album art on left, expanded track info+controls, minimal switcher
self.outer_box = Box(
name="macos-outer-player-box",
orientation="v",
spacing=10, # Reduced spacing between elements
h_expand=True,
v_expand=True,
v_align="center",
h_align="fill", # Fill available space
children=[
self.inner_box, # Track info and controls
# Compact switcher in corner (fixed width)
self.stack_buttons_box,
],
)
self.children = [*self.children, self.outer_box]
# Track signal connections for cleanup - store (object, handler_id) tuples
connections = bulk_connect(
self.player,
{
"exit": self._on_player_exit,
"notify::playback-status": self._on_playback_change,
"notify::metadata": self._on_metadata,
},
)
# Store as (object, handler_id) tuples
for handler_id in connections:
self._signal_connections.append((self.player, handler_id))
def destroy(self):
"""Clean up all resources when the widget is destroyed."""
# Set exit flag FIRST to stop any running timers
self.exit = True
# Cancel any ongoing downloads immediately
self._download_cancelled = True
# Cancel seek bar timer
if self._seekbar_timer_id:
try:
from gi.repository import GLib
GLib.source_remove(self._seekbar_timer_id)
except:
pass
self._seekbar_timer_id = None
# Wait for download thread to finish (with timeout)
if self.current_download_thread and self.current_download_thread.is_alive():
try:
self.current_download_thread.join(timeout=1.0) # 1 second timeout
except Exception:
pass
# Disconnect all signal connections
for obj, handler_id in self._signal_connections:
try:
obj.disconnect(handler_id)
except Exception as e:
logger.warning(f"Failed to disconnect signal: {e}")
self._signal_connections.clear()
# Clean up temp files aggressively
self._cleanup_temp_files()
# Clear image references
if hasattr(self, "album_cover_image"):
try:
self.album_cover_image.set_from_pixbuf(None)
except Exception:
pass
super().destroy()
def __del__(self):
"""Ensure cleanup happens even if player exits unexpectedly."""
try:
self._cleanup_temp_files()
except Exception:
pass # Ignore errors during cleanup in destructor
def update_buttons(self, player_buttons, show_buttons):
"""Update the stack switcher buttons in this player box"""
logger.info(
f"[PlayerBox] update_buttons called: show_buttons={
show_buttons
}, num_buttons={len(player_buttons)}"
)
# Clear existing buttons
for child in self.stack_buttons_box.get_children():
try:
child.destroy()
except Exception:
pass
if show_buttons and len(player_buttons) > 1:
logger.info(f"[PlayerBox] Creating {len(player_buttons)} stack buttons")
# Create macOS-style dot indicators for each player
for i, button in enumerate(player_buttons):
# Create a macOS-style dot button
dot_button = Button(
name="macos-player-switcher-dot",
style_classes=["macos-switcher-dot"],
)
# Set active state based on original button
if button.get_style_context().has_class("active"):
dot_button.add_style_class("active")
# Connect click handler to switch to this player
def make_click_handler(index):
return lambda *_: self.player_stack.on_player_clicked_by_index(
index
)
dot_button.connect("clicked", make_click_handler(i))
self.stack_buttons_box.children = [
*self.stack_buttons_box.children,
dot_button,
]
logger.info(f"[PlayerBox] Added dot button {i}")
self.stack_buttons_box.show_all()
logger.info("[PlayerBox] Stack buttons box shown")
else:
self.stack_buttons_box.hide()
logger.info("[PlayerBox] Stack buttons box hidden")
def length_str(self, length):
"""Convert length in microseconds to MM:SS or H:MM:SS format like real media players."""
if length is None or length <= 0:
return "0:00"
# Convert microseconds to seconds
length_seconds = length / 1000000
hours = int(length_seconds // 3600)
minutes = int((length_seconds % 3600) // 60)
seconds = int(length_seconds % 60)
if hours > 0:
return f"{hours}:{minutes:02d}:{seconds:02d}"
else:
return f"{minutes}:{seconds:02d}"
def _on_metadata(self, *_):
self._set_image()
duration = self.player.length
if duration:
self.length_label.set_label(self.length_str(duration))
# Clamp duration to avoid 32-bit integer overflow in the scale widget
max_int32 = 2147483647 # 2^31 - 1
safe_duration = min(max_int32, duration)
self.seek_bar.set_range(0, safe_duration)
# Cancel existing timer before starting a new one
if self._seekbar_timer_id:
try:
from gi.repository import GLib
GLib.source_remove(self._seekbar_timer_id)
except:
pass
self._seekbar_timer_id = None
# Start new timer and store its ID
self._seekbar_timer_id = invoke_repeater(1000, self._move_seekbar)
def _cleanup_temp_files(self):
"""Clean up temporary artwork files."""
for temp_file in self.temp_artwork_files:
try:
if os.path.exists(temp_file):
os.unlink(temp_file)
except Exception as e:
logger.warning(f"Failed to cleanup temp file {temp_file}: {e}")
self.temp_artwork_files.clear()
def _on_player_exit(self, _, value):
self.exit = value
self._cleanup_temp_files() # Clean up temp files before destroying
self.destroy()
def _on_player_next(self, *_):
self.player.next()
def _on_player_prev(self, *_):
self.player.previous()
def _on_playback_change(self, player, status):
status = player.get_property("playback-status")
if status == "paused":
self.play_pause_icon.set_from_file(
get_relative_path("../../config/assets/icons/player/play.svg")
)
if status == "playing":
self.play_pause_icon.set_from_file(
get_relative_path("../../config/assets/icons/player/Pause.svg")
)
def _update_image(self, image_path):
if image_path and os.path.isfile(image_path):
self.album_cover.set_style(f"background-image:url('{image_path}')")
else:
self.album_cover.set_style(
f"background-image:url('{self.fallback_cover_path}')"
)
def _set_image(self, *_):
art_url = self.player.arturl
# If no art URL or empty/None, use fallback
if not art_url:
self._update_image(None)
return
parsed = urllib.parse.urlparse(art_url)
if parsed.scheme == "file":
local_arturl = urllib.parse.unquote(parsed.path)
self._update_image(local_arturl)
elif parsed.scheme in ("http", "https"):
# Cancel any existing download to prevent memory buildup
self._download_cancelled = True
# Use threading.Thread instead of GLib.Thread for better control
if self.current_download_thread and self.current_download_thread.is_alive():
# Thread will check _download_cancelled flag and exit early
pass
self._download_cancelled = False
self.current_download_thread = threading.Thread(
target=self._download_and_set_artwork,
args=(art_url,),
daemon=True, # Dies with main thread
)
self.current_download_thread.start()
else:
print(art_url)
self._update_image(art_url)
def _download_and_set_artwork(self, arturl):
"""
Download the artwork from the given URL asynchronously and update the cover
using GLib.idle_add to ensure UI updates occur on the main thread.
"""
local_arturl = self.fallback_cover_path
temp_file_path = None
try:
# Check if download was cancelled
if self._download_cancelled:
return
# Clean up old temp files first (keep only last 1 to reduce memory)
if len(self.temp_artwork_files) > 1:
old_files = self.temp_artwork_files[:-1]
for old_file in old_files:
try:
if os.path.exists(old_file):
os.unlink(old_file)
except Exception:
pass
self.temp_artwork_files = self.temp_artwork_files[-1:]
# Check again if cancelled
if self._download_cancelled:
return
# Download artwork
parsed = urllib.parse.urlparse(arturl)
suffix = os.path.splitext(parsed.path)[1] or ".png"
with urllib.request.urlopen(arturl, timeout=10) as response: # Add timeout
if self._download_cancelled:
return
data = response.read()
# Check one more time if cancelled
if self._download_cancelled:
return
# Create temp file in cache directory instead of system temp
os.makedirs(CACHE_DIR, exist_ok=True)
with tempfile.NamedTemporaryFile(
delete=False, suffix=suffix, dir=CACHE_DIR
) as temp_file:
temp_file.write(data)
temp_file_path = temp_file.name
local_arturl = temp_file_path
# Track temp file for cleanup
if temp_file_path and not self._download_cancelled:
self.temp_artwork_files.append(temp_file_path)
except Exception as e:
if not self._download_cancelled:
logger.warning(f"Failed to download artwork from {arturl}: {e}")
# Clean up failed temp file
if temp_file_path and os.path.exists(temp_file_path):
try:
os.unlink(temp_file_path)
except Exception:
pass
return
# Only update UI if not cancelled
if not self._download_cancelled:
GLib.idle_add(self._update_image, local_arturl)
return None
def _move_seekbar(self, *_):
if self.player is None or self.exit or self._user_seeking:
return True # Continue the timer but don't update while user is seeking
# Additional safety checks to prevent GTK errors
if not hasattr(self, "seek_bar") or self.seek_bar is None:
return False # Stop the timer
try:
position = self.player.position
self.position_label.set_label(self.length_str(position))
# Only update seek bar if user is not currently seeking
if not self._user_seeking:
# Clamp position to avoid 32-bit integer overflow
max_int32 = 2147483647 # 2^31 - 1
safe_position = min(max_int32, position) if position else 0
self.seek_bar.set_value(safe_position)
except Exception as e:
# If any error occurs (widget destroyed, etc), stop the timer
logger.warning(f"Seek bar update failed, stopping timer: {e}")
return False
return True
def _on_seek_start(self, widget, event):
"""User started seeking - disable automatic updates"""
self._user_seeking = True
return False
def _on_seek_end(self, widget, event):
"""User finished seeking - re-enable automatic updates"""
self._user_seeking = False
return False
def _on_scale_value_changed(self, scale: Scale):
"""Handle seek bar value changes - only when user is seeking"""
if self.player and not self.exit and self._user_seeking:
try:
new_position = int(scale.get_value())
# Clamp to 32-bit signed integer range to avoid overflow
max_int32 = 2147483647 # 2^31 - 1
min_int32 = -2147483648 # -2^31
new_position = max(min_int32, min(max_int32, new_position))
self.player.position = new_position
self.position_label.set_label(self.length_str(new_position))
except Exception as e:
# If setting position fails, just update the label
try:
self.position_label.set_label(self.length_str(new_position))
except Exception:
logger.warning(f"Failed to update position label: {e}")
@cooldown(0.1)
def _on_scale_move(self, scale: Scale, event, pos: int):
try:
if not self.exit and self.player:
self.player.position = pos
self.position_label.set_label(self.length_str(pos))
self.seek_bar.set_value(pos)
except Exception as e:
logger.warning(f"Failed to update seek position: {e}")
class Thing(Box):
def __init__(self, **kwargs):
super().__init__(
name="thing",
size=(480, 160),
orientation="vertical",
spacing=0,
children=[
Label(
name="thing-label",
label="This is a thing",
style="font-size: 16px; padding: 10px;",
),
],
**kwargs,
)
class ExpandedPlayer(Window):
def __init__(self, **kwargs):
super().__init__(
name="expanded-player",
title="modus",
anchor="top right",
layer="top",
exclusivity="auto",
child=PlayerBoxStack(get_shared_mpris_manager()),
visible=False,
)
self.add_keybinding("Escape", self.set_child_visible(False))
def destroy(self):
"""Clean up resources when the window is destroyed."""
# Clean up the child PlayerBoxStack
if hasattr(self, "child") and hasattr(self.child, "destroy"):
try:
self.child.destroy()
except Exception as e:
logger.warning(f"Failed to destroy child PlayerBoxStack: {e}")
super().destroy()
def _init_mousecapture(self, mousecapture):
self._mousecapture_parent = mousecapture
def hide_controlcenter(self, *_):
# self._mousecapture_parent.toggle_mousecapture()
self.set_visible(False)
================================================
FILE: modules/controlcenter/main.py
================================================
import subprocess
from fabric.utils import idle_add
from fabric.utils.helpers import (
get_relative_path,
)
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.centerbox import CenterBox
from fabric.widgets.label import Label
from fabric.widgets.scale import Scale
from fabric.widgets.svg import Svg
from gi.repository import Gdk, GLib
from loguru import logger
from modules.controlcenter.bluetooth import (
BluetoothConnections,
set_bluetooth_enabled_with_fallback,
)
from modules.controlcenter.expanded_player import EmbeddedExpandedPlayer
from modules.controlcenter.nightlight import create_night_light_widget
from modules.controlcenter.per_app_volume import PerAppVolumeControl
from modules.controlcenter.player import PlayerBoxStack
from modules.controlcenter.wifi import WifiConnections
from services.brightness import Brightness
from services.mpris import MprisPlayerManager
from services.network import NetworkClient
from utils.roam import audio_service, modus_service
from widgets.wayland import WaylandWindow as Window
brightness_service = Brightness.get_initial()
class ModusControlCenter(Window):
def __init__(self, **kwargs):
super().__init__(
layer="top",
title="modus",
anchor="top right",
margin="2px 10px 0px 0px",
exclusivity="auto",
keyboard_mode="on-demand",
name="control-center-menu",
visible=False,
**kwargs,
)
self.focus_mode = modus_service.dont_disturb
self._updating_brightness = False
self._updating_volume = False
# Flight mode and caffeine states
self.flight_mode = False
self.caffeine_mode = False
self._caffeine_process = None
# Lazy loading flags
self._music_initialized = False
self._per_app_volume_initialized = False
self._expanded_player_initialized = False
# Store references for cleanup - initialize all as None
self._signal_connections = []
self._music_widget_content = None
self._per_app_volume_widget = None
self._expanded_player_widget = None
self._mpris_manager = None # Shared MPRIS manager instance
# Initialize network service for WiFi toggle
self.network_service = NetworkClient()
self.wifi_service = None
# Initialize flight mode and caffeine states
self._check_initial_states()
# Wait for network service to be ready
self.add_keybinding("Escape", self.hide_controlcenter)
volume = 100
wlan = modus_service.sc("wlan-changed", self.wlan_changed)
bluetooth = modus_service.sc("bluetooth-changed", self.bluetooth_changed)
music = modus_service.sc("music-changed", self.audio_changed)
self.network_service.connect("wifi-device-added", self.on_network_ready)
# Store signal connections for cleanup
self._signal_connections.extend(
[
audio_service.connect("changed", self.audio_changed),
audio_service.connect("changed", self.volume_changed),
modus_service.connect("dont-disturb-changed", self.dnd_changed),
]
)
print(wlan)
self.wlan_label = Label(
label=wlan,
name="wifi-widget-label",
max_chars_width=15,
h_align="start",
ellipsization="end",
)
if bluetooth != "disabled":
if bluetooth.startswith("connected:"):
parts = bluetooth.split(":")
bluetooth_display = parts[1] if len(parts) >= 2 else "Connected"
else:
bluetooth_display = "On"
else:
bluetooth_display = "Off"
self.bluetooth_label = Label(
label=bluetooth_display,
name="bluetooth-widget-label",
max_chars_width=15,
ellipsization="end",
h_align="start",
)
self.volume_scale = Scale(
value=volume,
min_value=0,
max_value=100,
increments=(5, 5),
name="volume-widget-slider",
size=30,
h_expand=True,
)
self.volume_scale.connect("change-value", self.set_volume)
self.volume_scale.connect("scroll-event", self.on_volume_scroll)
current_brightness = brightness_service.screen_brightness
brightness_percentage = (
int((current_brightness / brightness_service.max_screen) * 100)
if brightness_service.max_screen > 0
else 50
)
self.brightness_scale = Scale(
value=brightness_percentage,
min_value=0,
max_value=100,
increments=(5, 5),
name="brightness-widget-slider",
size=30,
h_expand=True,
)
# Only connect brightness controls if brightness service is available
if brightness_service.max_screen > 0:
self.brightness_scale.connect("change-value", self.set_brightness)
self.brightness_scale.connect("scroll-event", self.on_brightness_scroll)
self._signal_connections.append(
brightness_service.connect("screen", self.brightness_changed)
)
else:
# Disable brightness scale if no backlight device available
self.brightness_scale.set_sensitive(False)
# Create placeholder music widget - lazy load content when needed
self.music_widget = Box(
name="music-widget",
h_align="start",
children=[], # Empty initially
)
self.has_bluetooth_open = False
self.has_wifi_open = False
self.has_per_app_volume_open = False
self.has_expanded_player_open = False
self.bluetooth_svg = Svg(
name="bluetooth-icon",
svg_file=get_relative_path(
"../../config/assets/icons/applets/bluetooth.svg"
if bluetooth != "disabled"
else "../../config/assets/icons/applets/bluetooth-off.svg"
),
size=42,
)
self.wifi_svg = Svg(
name="wifi-icon",
svg_file=get_relative_path(
"../../config/assets/icons/applets/wifi.svg"
if wlan != "No Connection"
else "../../config/assets/icons/applets/wifi-off.svg"
),
size=42,
)
self.bluetooth_widget = Box(
name="bluetooth-widget",
orientation="h",
children=[
Button(
name="bluetooth-icon-button",
child=self.bluetooth_svg,
on_clicked=self.toggle_bluetooth,
),
Button(
name="bluetooth-info-button",
child=Box(
name="bluetooth-widget-info",
orientation="vertical",
children=[
Label(
name="bluetooth-widget-name",
label="Bluetooth",
style_classes="ct",
h_align="start",
),
self.bluetooth_label,
],
),
on_clicked=self.open_bluetooth,
),
],
)
self.wlan_widget = Box(
name="wifi-widget",
orientation="h",
children=[
Button(
name="wifi-icon-button",
child=self.wifi_svg,
on_clicked=self.toggle_wifi,
),
Button(
name="wifi-info-button",
child=Box(
name="wifi-widget-info",
orientation="vertical",
children=[
Label(
name="wifi-widget-name",
label="Wi-Fi",
style_classes="ct",
h_align="start",
),
self.wlan_label,
],
),
on_clicked=self.open_wifi,
),
],
)
self.focus_icon = Svg(
name="focus-icon",
svg_file=get_relative_path(
"../../config/assets/icons/applets/dnd.svg"
if self.focus_mode
else "../../config/assets/icons/applets/dnd-off.svg"
),
size=42,
)
self.focus_status_label = Label(
label="On" if self.focus_mode else "Off",
style_classes="status-label",
h_align="start",
)
self.focus_widget = Button(
name="focus-widget",
child=Box(
spacing=4,
orientation="h",
children=[
self.focus_icon,
Box(
v_expand=True,
h_expand=True,
v_align="center",
orientation="v",
h_align="start",
children=[
Label(
label="Focus",
style_classes="title-widget",
h_align="start",
),
self.focus_status_label,
],
),
],
),
on_clicked=self.set_dont_disturb,
)
self.flight_icon = Svg(
name="flight-icon",
svg_file=get_relative_path(
"../../config/assets/icons/applets/flight-on.svg"
if self.flight_mode
else "../../config/assets/icons/applets/flight-off.svg"
),
size=42,
)
self.flight_widget = Button(
name="flight-widget",
child=Box(
orientation="v",
h_expand=True,
v_expand=True,
spacing=8,
h_align="center",
v_align="center",
children=[
self.flight_icon,
Label(
label="Flight",
style_classes="title-widget",
h_align="center",
),
],
),
on_clicked=self.toggle_flight_mode,
)
self.caffeine_icon = Svg(
name="caffeine-icon",
svg_file=get_relative_path(
"../../config/assets/icons/applets/caffeine-on.svg"
if self.caffeine_mode
else "../../config/assets/icons/applets/caffeine-off.svg"
),
size=42,
)
self.caffeine_status_label = Label(
label="On" if self.caffeine_mode else "Off",
style_classes="status-label",
h_align="start",
)
self.caffeine_widget = Button(
name="caffeine-widget",
child=Box(
orientation="v",
spacing=8,
h_expand=True,
v_expand=True,
h_align="center",
v_align="center",
children=[
self.caffeine_icon,
Label(
label="Caffeine",
style_classes="title-widget",
h_align="center",
),
# Box(
# orientation="vertical",
# v_expand=True,
# v_align="center",
# h_align="center",
# h_expand=True,
# children=[
# # self.caffeine_status_label,
# ],
# ),
],
),
on_clicked=self.toggle_caffeine,
)
# Create night light widget
self.night_light_widget = create_night_light_widget(self)
# Create main widgets directly without XML
self.widgets = Box(
orientation="vertical",
h_expand=True,
name="control-center-widgets",
children=[
# Top section: Wi-Fi + Bluetooth on left, DND + Flight Mode + Caffeine on right
Box(
orientation="horizontal",
h_expand=True,
name="top-widget",
children=[
# Left side: Wi-Fi and Bluetooth
Box(
orientation="vertical",
name="wb-widget",
style_classes="menu",
spacing=5,
children=[
self.wlan_widget,
self.bluetooth_widget,
self.night_light_widget,
],
),
# Right side: DND, Flight Mode, and Caffeine
Box(
orientation="vertical",
h_expand=True,
name="right-side-widget",
children=[
# DND widget
Box(
orientation="vertical",
name="dnd-widget",
style_classes="menu",
children=[
self.focus_widget,
],
),
# Flight Mode and Caffeine row
Box(
orientation="horizontal",
name="flight-caffeine-row",
children=[
Box(
orientation="vertical",
name="flight-widget-container",
style_classes="menu",
h_expand=True,
v_expand=True,
children=[
self.flight_widget,
],
),
Box(
orientation="vertical",
name="caffeine-widget-container",
h_expand=True,
v_expand=True,
style_classes="menu",
children=[
self.caffeine_widget,
],
),
],
),
],
),
],
),
Box(
orientation="vertical",
name="brightness-widget",
style_classes="menu",
h_expand=True,
children=[
Label(label="Display", style_classes="title", h_align="start"),
self.brightness_scale,
Label(
label=" ", name="brightness-widget-icon", h_align="start"
),
],
),
Box(
orientation="vertical",
name="volume-widget",
style_classes="menu",
h_expand=True,
children=[
Label(label="Sound", style_classes="title", h_align="start"),
Box(
name="vol-box",
orientation="horizontal",
spacing=12,
v_expand=True,
children=[
Box(
name="vol-slider-box",
h_expand=True,
v_align="center",
v_expand=False,
children=[
self.volume_scale,
],
),
Button(
name="per-app-volume-button",
size=(36, 36),
child=Svg(
svg_file=get_relative_path(
"../../config/assets/icons/player/audio-switcher.svg"
),
name="per-app-volume-icon",
sidze=32,
),
on_clicked=self.open_per_app_volume,
),
],
),
Label(label=" ", name="volume-widget-icon", h_align="start"),
],
),
self.music_widget,
],
)
# Initialize managers directly like working version
self.wifi_man = WifiConnections(self)
self.bluetooth_man = BluetoothConnections(self)
self.has_bluetooth_open = False
self.has_wifi_open = False
# Lazy-loaded widgets - create placeholders
self.bluetooth_widgets = None
self.wifi_widgets = None
self.per_app_volume_widgets = None
self.expanded_player_widgets = None
# Create main content boxes
self.center_box = CenterBox(start_children=[self.widgets])
self.bluetooth_center_box = None
self.wifi_center_box = None
self.per_app_volume_center_box = None
self.expanded_player_center_box = None
# Create revealers for crossfade transitions
self.widgets.set_size_request(300, -1)
self.children = self.center_box
# Track current state for smooth transitions
self.current_view = "main" # main, expanded_player
# Connect to visibility changes for cleanup
self.connect("notify::visible", self._on_visibility_changed)
def _on_visibility_changed(self, widget, param):
"""Handle visibility changes for memory management"""
if not self.get_visible():
self._cleanup_when_hidden()
def _cleanup_when_hidden(self):
"""Aggressively clean up resources when widget is hidden to reduce memory usage"""
try:
# Clean up music widget content if it exists
if self._music_widget_content:
# Remove from the parent container
current_children = list(self.music_widget.children)
if self._music_widget_content in current_children:
current_children.remove(self._music_widget_content)
self.music_widget.children = current_children
# Trigger periodic cleanup before destroying
if hasattr(self._music_widget_content, "_periodic_cleanup"):
self._music_widget_content._periodic_cleanup()
# Properly destroy the music widget content
try:
self._music_widget_content.destroy()
except Exception as e:
logger.warning(f"Failed to destroy music widget content: {e}")
self._music_widget_content = None
# Clean up shared MPRIS manager when hidden to free memory
if self._mpris_manager:
try:
self._mpris_manager.destroy()
except Exception as e:
logger.warning(
f"Failed to destroy MPRIS manager during cleanup: {e}"
)
self._mpris_manager = None
# Reset initialization flags to force recreation next time
self._music_initialized = False
# Force garbage collection
import gc
gc.collect()
logger.debug("Control center aggressive cleanup completed")
except Exception as e:
logger.warning(f"Control center cleanup failed: {e}")
def _ensure_music_widget(self):
"""Lazy load music widget content - reuse MPRIS manager"""
if not self._music_initialized:
# Create shared MPRIS manager if it doesn't exist
if self._mpris_manager is None:
self._mpris_manager = MprisPlayerManager()
self._music_widget_content = PlayerBoxStack(
self._mpris_manager, control_center=self
)
# Add to the music widget's children list
current_children = list(self.music_widget.children)
current_children.append(self._music_widget_content)
self.music_widget.children = current_children
self._music_initialized = True
def _ensure_bluetooth_widgets(self):
"""Lazy load bluetooth widgets"""
if self.bluetooth_widgets is None:
self.bluetooth_widgets = Box(
orientation="vertical",
h_expand=True,
v_expand=True,
children=[
self.bluetooth_man,
# Box(
# orientation="horizontal",
# name="top-widget",
# h_expand=True,
# children=[
# Box(
# orientation="vertical",
# name="wb-widget",
# style_classes="menu",
# spacing=5,
# children=[
# ],
# ),
# ],
# ),
],
)
self.bluetooth_center_box = Box(
h_expand=True, v_expand=True, children=[self.bluetooth_widgets]
)
self.bluetooth_center_box.set_size_request(350, -1)
def _ensure_wifi_widgets(self):
"""Lazy load wifi widgets"""
if self.wifi_widgets is None:
self.wifi_widgets = Box(
orientation="vertical",
h_expand=True,
v_expand=True,
children=[
self.wifi_man,
],
)
self.wifi_center_box = Box(
h_expand=True, v_expand=True, children=[self.wifi_widgets]
)
self.wifi_center_box.set_size_request(350, -1)
def _ensure_per_app_volume_widgets(self):
"""Lazy load per-app volume widgets"""
if self.per_app_volume_widgets is None:
if self._per_app_volume_widget is None:
self._per_app_volume_widget = PerAppVolumeControl(self)
self.per_app_volume_widgets = Box(
orientation="vertical",
h_expand=True,
name="control-center-widgets",
children=[
self._per_app_volume_widget,
],
)
self.per_app_volume_center_box = CenterBox(
start_children=[self.per_app_volume_widgets]
)
self.per_app_volume_center_box.set_size_request(300, -1)
def _ensure_expanded_player_widgets(self):
"""Lazy load expanded player widgets"""
if self.expanded_player_widgets is None:
if self._expanded_player_widget is None:
self._expanded_player_widget = EmbeddedExpandedPlayer(self)
self.expanded_player_widgets = Box(
orientation="vertical",
h_expand=True,
name="control-center-widgets",
children=[
self._expanded_player_widget,
],
)
self.expanded_player_center_box = CenterBox(
start_children=[self.expanded_player_widgets]
)
self.expanded_player_center_box.set_size_request(300, -1)
def _check_initial_states(self):
# Check if caffeine is already running
try:
result = subprocess.run(
["pgrep", "-f", "modus-inhibit"], capture_output=True, text=True
)
self.caffeine_mode = bool(result.stdout.strip())
except Exception:
self.caffeine_mode = False
# Flight mode starts as False (normal mode)
self.flight_mode = False
# Update initial labels (will be set after widgets are created)
GLib.timeout_add(100, self._update_initial_labels)
def _update_initial_labels(self):
return False
def set_dont_disturb(self, *_):
self.focus_mode = not self.focus_mode
modus_service.dont_disturb = self.focus_mode
self.focus_icon.set_from_file(
get_relative_path(
"../../config/assets/icons/applets/dnd.svg"
if self.focus_mode
else "../../config/assets/icons/applets/dnd-off.svg"
)
)
self.focus_status_label.set_label("On" if self.focus_mode else "Off")
def toggle_flight_mode(self, *_):
try:
self.flight_mode = not self.flight_mode
if self.flight_mode:
# Turn off WiFi and Bluetooth
if self.wifi_service:
self.wifi_service.wireless_enabled = False
if hasattr(self, "bluetooth_man") and hasattr(
self.bluetooth_man, "client"
):
self.bluetooth_man.client.set_enabled(False)
else:
# Turn on WiFi and Bluetooth
if self.wifi_service:
self.wifi_service.wireless_enabled = True
if hasattr(self, "bluetooth_man") and hasattr(
self.bluetooth_man, "client"
):
self.bluetooth_man.client.set_enabled(True)
# Update icon
self.flight_icon.set_from_file(
get_relative_path(
"../../config/assets/icons/applets/flight-on.svg"
if self.flight_mode
else "../../config/assets/icons/applets/flight-off.svg"
)
)
except Exception as e:
logger.warning(f"Failed to toggle flight mode: {e}")
def toggle_caffeine(self, *_):
try:
if self.caffeine_mode:
# Turn off caffeine
inhibit_script = get_relative_path("../../utils/inhibit.py")
subprocess.run(["python3", inhibit_script, "off"], check=False)
self.caffeine_mode = False
if self._caffeine_process:
try:
self._caffeine_process.terminate()
except:
pass
self._caffeine_process = None
else:
# Turn on caffeine
inhibit_script = get_relative_path("../../utils/inhibit.py")
self._caffeine_process = subprocess.Popen(
["python3", inhibit_script, "on"], start_new_session=True
)
self.caffeine_mode = True
self.caffeine_icon.set_from_file(
get_relative_path(
"../../config/assets/icons/applets/caffeine-on.svg"
if self.caffeine_mode
else "../../config/assets/icons/applets/caffeine-off.svg"
)
)
self.caffeine_status_label.set_label("On" if self.caffeine_mode else "Off")
except Exception as e:
logger.warning(f"Failed to toggle caffeine: {e}")
def set_volume(self, _, __, volume):
self._updating_volume = True
audio_service.speaker.volume = round(volume)
self._updating_volume = False
def set_brightness(self, _, __, brightness):
self._updating_brightness = True
brightness_value = int((brightness / 100) * brightness_service.max_screen)
brightness_service.screen_brightness = brightness_value
self._updating_brightness = False
def brightness_changed(self, _, brightness_value):
if self._updating_brightness:
return
if brightness_service.max_screen > 0:
brightness_percentage = int(
(brightness_value / brightness_service.max_screen) * 100
)
GLib.idle_add(
lambda: self.brightness_scale.set_value(brightness_percentage)
)
def on_volume_scroll(self, widget, event):
current_value = self.volume_scale.get_value()
scroll_step = 5
if event.direction == Gdk.ScrollDirection.UP:
new_value = min(100, current_value + scroll_step)
elif event.direction == Gdk.ScrollDirection.DOWN:
new_value = max(0, current_value - scroll_step)
else:
return False
self.volume_scale.set_value(new_value)
return True
def on_brightness_scroll(self, widget, event):
current_value = self.brightness_scale.get_value()
scroll_step = 5
if event.direction == Gdk.ScrollDirection.UP:
new_value = min(100, current_value + scroll_step)
elif event.direction == Gdk.ScrollDirection.DOWN:
new_value = max(0, current_value - scroll_step)
else:
return False
self.brightness_scale.set_value(new_value)
return True
def toggle_bluetooth(self, *_):
try:
# Access the bluetooth client from the bluetooth manager
if hasattr(self, "bluetooth_man") and hasattr(self.bluetooth_man, "client"):
current_state = self.bluetooth_man.client.enabled
set_bluetooth_enabled_with_fallback(
self.bluetooth_man.client, not current_state
)
else:
logger.warning("Bluetooth client not available for toggling")
except Exception as e:
logger.warning(f"Failed to toggle bluetooth: {e}")
def on_network_ready(self, *_):
"""Called when network service is ready"""
self.wifi_service = self.network_service.wifi_device
if self.wifi_service:
# Connect to WiFi state changes to update icon
self.wifi_service.connect("notify::wireless-enabled", self.update_wifi_icon)
def update_wifi_icon(self, *_):
"""Update WiFi icon based on current state"""
try:
if self.wifi_service and hasattr(self, "wifi_svg"):
is_enabled = self.wifi_service.wireless_enabled
icon_file = (
"../../config/assets/icons/applets/wifi.svg"
if is_enabled
else "../../config/assets/icons/applets/wifi-off.svg"
)
self.wifi_svg.set_from_file(get_relative_path(icon_file))
except Exception as e:
logger.warning(f"Failed to update WiFi icon: {e}")
def toggle_wifi(self, *_):
"""Toggle wifi on/off"""
try:
if self.wifi_service:
self.wifi_service.toggle_wifi()
# Update icon immediately after toggle
GLib.timeout_add(100, self.update_wifi_icon)
else:
logger.warning("WiFi device not available for toggling")
except Exception as e:
logger.warning(f"Failed to toggle wifi: {e}")
def set_children(self, children):
self.children = children
def open_bluetooth(self, *_):
self._ensure_bluetooth_widgets()
idle_add(lambda *_: self.set_children(self.bluetooth_center_box))
self.has_bluetooth_open = True
def open_wifi(self, *_):
self._ensure_wifi_widgets()
idle_add(lambda *_: self.set_children(self.wifi_center_box))
self.has_wifi_open = True
def close_bluetooth(self, *_):
if self.current_view == "expanded_player":
self._crossfade_to_view("main")
else:
idle_add(lambda *_: self.set_children(self.center_box))
self.has_bluetooth_open = False
def close_wifi(self, *_):
if self.current_view == "expanded_player":
self._crossfade_to_view("main")
else:
idle_add(lambda *_: self.set_children(self.center_box))
self.has_wifi_open = False
def open_per_app_volume(self, *_):
self._ensure_per_app_volume_widgets()
if self.current_view == "expanded_player":
# If coming from expanded player, use crossfade
self._crossfade_to_view("main")
GLib.timeout_add(
250,
lambda: idle_add(
lambda *_: self.set_children(self.per_app_volume_center_box)
),
)
else:
idle_add(lambda *_: self.set_children(self.per_app_volume_center_box))
self.has_per_app_volume_open = True
# Refresh the app list when opening
if self._per_app_volume_widget:
self._per_app_volume_widget.refresh()
def close_per_app_volume(self, *_):
if self.current_view == "expanded_player":
self._crossfade_to_view("main")
else:
idle_add(lambda *_: self.set_children(self.center_box))
self.has_per_app_volume_open = False
def open_expanded_player(self, *_):
self._ensure_expanded_player_widgets()
self._crossfade_to_view("expanded_player")
self.has_expanded_player_open = True
# Refresh the player when opening
if self._expanded_player_widget:
self._expanded_player_widget.refresh()
def close_expanded_player(self, *_):
self._crossfade_to_view("main")
self.has_expanded_player_open = False
def _crossfade_to_view(self, view_name):
"""Handle transitions between views"""
if view_name == "expanded_player":
# Show expanded player
self._ensure_expanded_player_widgets()
idle_add(lambda *_: self.set_children(self.expanded_player_center_box))
self.current_view = "expanded_player"
elif view_name == "main":
# Show main view
idle_add(lambda *_: self.set_children(self.center_box))
self.current_view = "main"
def _set_mousecapture(self, visible: bool):
if visible:
# Lazy load music widget when becoming visible
self._ensure_music_widget()
self.set_visible(visible)
if not visible:
self.close_bluetooth()
self.close_wifi()
self.close_per_app_volume()
self.close_expanded_player()
def volume_changed(
self,
_,
):
if self._updating_volume:
return
GLib.idle_add(
lambda: self.volume_scale.set_value(int(audio_service.speaker.volume))
)
def wlan_changed(self, _, wlan):
self.wifi_svg.set_from_file(
get_relative_path(
"../../config/assets/icons/applets/wifi.svg"
if wlan != "No Connection"
else "../../config/assets/icons/applets/wifi-off.svg"
)
)
if wlan != "No Connection":
if wlan.startswith("connected:"):
parts = wlan.split(":")
if len(parts) >= 2:
wifi_name = parts[1]
GLib.idle_add(
lambda: self.wlan_label.set_property("label", wifi_name)
)
else:
GLib.idle_add(
lambda: self.wlan_label.set_property("label", "Connected")
)
else:
GLib.idle_add(lambda: self.wlan_label.set_property("label", wlan))
else:
GLib.idle_add(lambda: self.wlan_label.set_property("label", wlan))
def bluetooth_changed(self, _, bluetooth):
self.bluetooth_svg.set_from_file(
get_relative_path(
"../../config/assets/icons/applets/bluetooth.svg"
if bluetooth != "disabled"
else "../../config/assets/icons/applets/bluetooth-off.svg"
)
)
if bluetooth != "disabled":
if bluetooth.startswith("connected:"):
parts = bluetooth.split(":")
if len(parts) >= 2:
device_name = parts[1]
GLib.idle_add(lambda: self.bluetooth_label.set_label(device_name))
else:
GLib.idle_add(lambda: self.bluetooth_label.set_label("Connected"))
elif bluetooth == "enabled":
GLib.idle_add(lambda: self.bluetooth_label.set_label("On"))
else:
GLib.idle_add(lambda: self.bluetooth_label.set_label("On"))
else:
GLib.idle_add(lambda: self.bluetooth_label.set_label("Off"))
def audio_changed(self, *_):
pass
def dnd_changed(self, _, dnd_state):
self.focus_mode = dnd_state
self.focus_icon.set_from_file(
get_relative_path(
"../../config/assets/icons/applets/dnd.svg"
if self.focus_mode
else "../../config/assets/icons/applets/dnd-off.svg"
)
)
self.focus_status_label.set_label("On" if self.focus_mode else "Off")
def _init_mousecapture(self, mousecapture):
self._mousecapture_parent = mousecapture
def hide_controlcenter(self, *_):
self._mousecapture_parent.toggle_mousecapture()
self.set_visible(False)
def destroy(self):
"""Clean up resources when widget is destroyed"""
# Disconnect all signal connections
for connection in self._signal_connections:
try:
connection.disconnect()
except:
pass
# Clean up caffeine process
if self._caffeine_process:
try:
self._caffeine_process.terminate()
except:
pass
self._caffeine_process = None
# Clean up heavy components
if hasattr(self, "wifi_man") and self.wifi_man:
self.wifi_man.destroy()
if hasattr(self, "bluetooth_man") and self.bluetooth_man:
self.bluetooth_man.destroy()
if self._music_widget_content:
self._music_widget_content.destroy()
if self._per_app_volume_widget:
self._per_app_volume_widget.destroy()
if self._expanded_player_widget:
self._expanded_player_widget.destroy()
# Clean up shared MPRIS manager
if self._mpris_manager:
try:
self._mpris_manager.destroy()
except Exception as e:
logger.warning(f"Failed to destroy MPRIS manager: {e}")
self._mpris_manager = None
super().destroy()
================================================
FILE: modules/controlcenter/nightlight.py
================================================
# Standard library imports
import subprocess
# Fabric imports
from fabric.utils.helpers import get_relative_path
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.label import Label
from fabric.widgets.svg import Svg
# Local imports
from loguru import logger
class NightLightControl:
"""Control night light using hyprsunset"""
def __init__(self):
self.is_active = False
self._check_initial_state()
def _check_initial_state(self):
"""Check if hyprsunset is currently running"""
try:
result = subprocess.run(
["pgrep", "-f", "hyprsunset"], capture_output=True, text=True
)
self.is_active = bool(result.stdout.strip())
except Exception as e:
logger.warning(f"Failed to check hyprsunset status: {e}")
self.is_active = False
def toggle(self):
"""Toggle night light on/off"""
try:
if self.is_active:
# Turn off night light by killing hyprsunset
subprocess.run(["pkill", "hyprsunset"], check=False)
self.is_active = False
logger.debug("Night light turned off")
else:
# Turn on night light with default temperature (3000K)
subprocess.Popen(["hyprsunset", "-t", "4500"], start_new_session=True)
self.is_active = True
logger.debug("Night light turned on")
return True
except Exception as e:
logger.warning(f"Failed to toggle night light: {e}")
return False
def set_temperature(self, temperature: int):
"""Set night light temperature (1000-6500K)"""
try:
# Kill existing process
subprocess.run(["pkill", "hyprsunset"], check=False)
# Start with new temperature
subprocess.Popen(
["hyprsunset", "-t", str(temperature)], start_new_session=True
)
self.is_active = True
logger.debug(f"Night light temperature set to {temperature}K")
return True
except Exception as e:
logger.warning(f"Failed to set night light temperature: {e}")
return False
def create_night_light_widget(control_center):
"""Create night light widget for control center"""
# Initialize night light control
night_light = NightLightControl()
# Create icon
night_light_icon = Svg(
name="nightlight-icon",
svg_file=get_relative_path(
"../../config/assets/icons/applets/redshift-status-on.svg"
if night_light.is_active
else "../../config/assets/icons/applets/redshift-status-off.svg"
),
size=42,
)
# Create status label
night_light_status_label = Label(
label="On" if night_light.is_active else "Off",
name="nightlight-widget-label",
style_classes="status-label",
max_chars_width=15,
ellipsization="end",
h_align="start",
)
def toggle_night_light(*_):
"""Toggle night light and update UI"""
if night_light.toggle():
# Update icon
night_light_icon.set_from_file(
get_relative_path(
"../../config/assets/icons/applets/redshift-status-on.svg"
if night_light.is_active
else "../../config/assets/icons/applets/redshift-status-off.svg"
)
)
# Update status label
night_light_status_label.set_label("On" if night_light.is_active else "Off")
# Create widget box (similar to bluetooth_widget structure)
night_light_widget = Box(
name="nightlight-widget",
orientation="h",
h_expand=True,
children=[
Button(
name="nightlight-icon-button",
child=night_light_icon,
on_clicked=toggle_night_light,
),
Button(
name="nightlight-info-button",
child=Box(
name="nightlight-widget-info",
h_expand=True,
v_expand=True,
v_align="center",
h_align="start",
orientation="vertical",
children=[
Label(
name="nightlight-widget-name",
label="Night Light",
style_classes="ct",
h_align="start",
),
night_light_status_label,
],
),
on_clicked=toggle_night_light,
),
],
)
return night_light_widget
================================================
FILE: modules/controlcenter/per_app_volume.py
================================================
# Standard library imports
from gi.repository import GLib
# Fabric imports
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.label import Label
from fabric.widgets.scale import Scale
from fabric.widgets.scrolledwindow import ScrolledWindow
from fabric.widgets.image import Image
from fabric.widgets.separator import Separator
# Local imports
from utils.roam import audio_service
class PerAppVolumeControl(Box):
"""Per-application volume control widget"""
def __init__(self, control_center, **kwargs):
super().__init__(
orientation="vertical",
name="per-app-volume-control",
spacing=5,
**kwargs,
)
self.control_center = control_center
self._updating_volumes = set()
self._app_widgets = {}
self._signal_connections = []
# Header with back button
self.header = Box(
orientation="horizontal",
name="per-app-volume-header",
style_classes="menu-header",
children=[
Button(
image=Image(icon_name="back", size=10),
on_clicked=lambda *_: self.control_center.close_per_app_volume(),
),
Label("App Volume", name="app-volume-header"),
],
)
# Scrollable container for app volume controls
self.apps_container = Box(
orientation="vertical",
name="apps-scrolled-container",
spacing=2,
)
self.scrolled_window = ScrolledWindow(
name="apps-scrolled-window",
child=self.apps_container,
size=(300, 500),
)
# Add escape key binding for navigation back
try:
if hasattr(self.control_center, "add_keybinding"):
self.control_center.add_keybinding("Escape", self._go_back)
except Exception:
pass # Ignore if keybinding fails
self.children = [self.header, self.scrolled_window]
# Connect to audio service changes
if audio_service:
self._signal_connections.append(
audio_service.connect("stream-added", self._on_stream_changed)
)
self._signal_connections.append(
audio_service.connect("stream-removed", self._on_stream_changed)
)
# Initial population
self._populate_apps()
# Set up auto-refresh timer for audio streams
self._refresh_timer = GLib.timeout_add_seconds(2, self._auto_refresh)
def _auto_refresh(self):
"""Auto-refresh the application list every 2 seconds"""
self._populate_apps()
return True # Continue the timer
def _go_back(self, *_):
"""Return to main control center view"""
self.control_center.close_per_app_volume()
def _get_app_icon(self, app):
"""Get icon for application"""
# Don't use fabric's generic audio icon, use description/name for better detection
fabric_icon = getattr(app, "icon_name", "")
if fabric_icon and fabric_icon != "audio":
return fabric_icon
# Use description first (more accurate), then name
app_description = getattr(app, "description", "").lower()
app_name = app.name.lower()
# Check description first as it's more reliable
search_text = app_description if app_description else app_name
icon_mapping = {
"spotify": "spotify",
"firefox": "firefox",
"chromium": "chromium-browser",
"chrome": "google-chrome",
"vlc": "vlc",
"discord": "discord",
"steam": "steam",
"zen": "zen-browser",
"code": "vscode",
"visual studio code": "vscode",
"telegram": "telegram-desktop",
"pulse": "audio-card",
"zen": "zen-browser",
"pipewire": "audio-card",
"alsa": "audio-card",
"sink": "audio-speakers",
"source": "audio-input-microphone",
"youtube": "youtube",
"music": "rhythmbox",
"media": "multimedia-player",
}
# Check if any key in mapping is contained in search text
for key, icon in icon_mapping.items():
if key in search_text:
return icon
# Also check app name as fallback
if search_text != app_name:
for key, icon in icon_mapping.items():
if key in app_name:
return icon
# Default audio icon for unknown apps
return "audio-volume-high"
def _format_app_name(self, name):
"""Format application name with proper capitalization"""
if not name:
return "Unknown"
# Handle common app names specially
special_names = {
"spotify": "Spotify",
"firefox": "Firefox",
"chromium": "Chromium",
"chrome": "Chrome",
"vlc": "VLC",
"discord": "Discord",
"steam": "Steam",
"zen": "Zen Browser",
"code": "VS Code",
"telegram": "Telegram",
}
name_lower = name.lower()
if name_lower in special_names:
return special_names[name_lower]
# Default: capitalize first letter
return name.capitalize()
def _populate_apps(self):
"""Populate the widget with current audio applications"""
# Clear existing widgets
self.apps_container.children = []
self._app_widgets.clear()
# Use fabric audio service for applications
if not audio_service:
self._show_no_apps_message()
return
applications = getattr(audio_service, "applications", [])
if applications:
for i, app in enumerate(applications):
if hasattr(app, "name") and hasattr(app, "volume"):
app_widget = self._create_app_control(app)
self.apps_container.children = list(
self.apps_container.children
) + [app_widget]
# Add separator between apps (except for last one)
if i < len(applications) - 1:
separator = Separator(
orientation="horizontal",
style_classes="app-volume-separator",
)
self.apps_container.children = list(
self.apps_container.children
) + [separator]
self._app_widgets[app.name] = (app_widget, app)
else:
self._show_no_apps_message()
def _show_no_apps_message(self):
"""Show message when no apps are playing audio"""
message = Label(
label="No applications currently using audio",
style_classes="subtitle",
h_align="center",
v_align="center",
)
self.apps_container.children = [message]
def _create_app_control(self, app):
"""Create compact volume control for a single application"""
# Format app name (shorter for compact layout)
app_name = self._format_app_name(app.name)
# Get current volume from fabric audio service
current_volume = getattr(app, "volume", 0.0)
max_vol = getattr(audio_service, "max_volume", 100) if audio_service else 100
# Ensure volume is in valid range
volume_percent = max(0, min(current_volume, max_vol))
# App icon - use the same approach as expanded player
icon_name = self._get_app_icon(app)
app_icon = Image(
icon_name=icon_name,
name="app-icon",
icon_size=16,
style_classes="app-icon",
)
# App name label
name_label = Label(
label=app_name,
style_classes="app-name-compact",
justification="left",
h_align="start",
max_chars_width=10,
ellipsization="end",
)
# Volume scale (smaller and horizontal)
volume_scale = Scale(
value=volume_percent,
min_value=0,
max_value=max_vol,
increments=(1, 5),
name="compact-volume-slider",
size=20,
h_expand=True,
)
# Connect volume change handler
volume_scale.connect(
"change-value",
lambda scale, scroll_type, value, app=app: self._set_app_volume(app, value),
)
# Create horizontal compact layout
app_control = Box(
orientation="horizontal",
spacing=5,
h_expand=True,
style_classes="compact-app-volume-item",
children=[
Box(
orientation="h",
spacing=3,
v_expand=True,
v_align="center",
name="app-control-box",
children=[
app_icon,
name_label,
],
),
volume_scale,
],
)
# Add separator after the control
return app_control
def _set_app_volume(self, app, volume_value):
"""Set volume for a specific application using fabric audio service"""
if app.name in self._updating_volumes:
return
self._updating_volumes.add(app.name)
try:
# Get max volume from audio service
max_vol = (
getattr(audio_service, "max_volume", 100) if audio_service else 100
)
# Ensure volume is within bounds
volume_value = max(0, min(volume_value, max_vol))
# Set volume directly - fabric expects the actual volume value, not percentage
app.volume = volume_value
except Exception as e:
print(f"Error setting volume for {app.name}: {e}")
finally:
GLib.timeout_add(100, lambda: self._updating_volumes.discard(app.name))
def _on_stream_changed(self, *_):
"""Handle when audio streams are added or removed"""
GLib.idle_add(self._populate_apps)
def refresh(self):
"""Manually refresh the application list"""
self._populate_apps()
def destroy(self):
"""Clean up resources"""
if hasattr(self, "_refresh_timer"):
GLib.source_remove(self._refresh_timer)
# Disconnect fabric audio service signals
if audio_service:
for connection in self._signal_connections:
try:
audio_service.disconnect(connection)
except:
pass
self._signal_connections.clear()
self._app_widgets.clear()
self._updating_volumes.clear()
super().destroy()
================================================
FILE: modules/controlcenter/player.py
================================================
import os
import re
import tempfile
import urllib.parse
import urllib.request
from typing import List
import threading
from fabric.utils import (
bulk_connect,
)
from fabric.utils.helpers import get_relative_path
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.image import Image
from fabric.widgets.label import Label
from fabric.widgets.overlay import Overlay
from fabric.widgets.stack import Stack
from fabric.widgets.svg import Svg
from gi.repository import GLib, GObject
from fabric.widgets.centerbox import CenterBox
from loguru import logger
from services.mpris import MprisPlayer, MprisPlayerManager
import config.data as data
CACHE_DIR = f"{data.CACHE_DIR}/media"
def cleanup_old_cache_files():
"""Clean up old artwork cache files (older than 1 day) and limit total cache size."""
try:
if not os.path.exists(CACHE_DIR):
return
import time
current_time = time.time()
one_day_ago = current_time - (24 * 60 * 60) # 24 hours
cache_files = []
# Collect all cache files with their modification times
for filename in os.listdir(CACHE_DIR):
filepath = os.path.join(CACHE_DIR, filename)
try:
if os.path.isfile(filepath):
file_mtime = os.path.getmtime(filepath)
file_size = os.path.getsize(filepath)
cache_files.append((filepath, file_mtime, file_size))
except Exception:
pass # Ignore individual file errors
# Remove files older than 1 day
total_size = 0
recent_files = []
for filepath, file_mtime, file_size in cache_files:
if file_mtime < one_day_ago:
try:
os.unlink(filepath)
except Exception:
pass
else:
recent_files.append((filepath, file_mtime, file_size))
total_size += file_size
# If cache is still too large (>50MB), remove oldest files
MAX_CACHE_SIZE = 50 * 1024 * 1024 # 50MB
if total_size > MAX_CACHE_SIZE:
# Sort by modification time (oldest first)
recent_files.sort(key=lambda x: x[1])
for filepath, _, file_size in recent_files:
if total_size <= MAX_CACHE_SIZE:
break
try:
os.unlink(filepath)
total_size -= file_size
except Exception:
pass
except Exception:
pass # Ignore all errors in cleanup
class PlayerBoxStack(Box):
"""A widget that displays the current player information."""
def __init__(
self, mpris_manager: MprisPlayerManager, control_center=None, **kwargs
):
# Clean up old cache files on startup
cleanup_old_cache_files()
# The player stack
self.player_stack = Stack(
# transition_type="slide-left-right",
# transition_duration=500,
name="player-stack",
)
self.current_stack_pos = 0
self.control_center = control_center
# List to store player buttons
self.player_buttons: list[Button] = []
# Track signal connections for cleanup
self._signal_connections = []
# Create a "No media playing" placeholder
self.no_media_box = self._create_no_media_box()
super().__init__(orientation="v", name="media", children=[self.player_stack])
# Show the no media box initially
self.player_stack.children = [self.no_media_box]
self.set_visible(True)
self.mpris_manager = mpris_manager
# Track connections for cleanup - store (object, handler_id) tuples
connections = bulk_connect(
self.mpris_manager,
{
"player-appeared": self.on_new_player,
"player-vanished": self.on_lost_player,
},
)
# Store as (object, handler_id) tuples
for handler_id in connections:
self._signal_connections.append((self.mpris_manager, handler_id))
for player in self.mpris_manager.players: # type: ignore
logger.info(
f"[PLAYER MANAGER] player found: {player.get_property('player-name')}",
)
self.on_new_player(self.mpris_manager, player)
def destroy(self):
"""Clean up resources when the widget is destroyed."""
# Disconnect all signal connections
for obj, handler_id in self._signal_connections:
try:
obj.disconnect(handler_id)
except Exception as e:
logger.warning(f"Failed to disconnect signal: {e}")
self._signal_connections.clear()
# Clean up player buttons
for button in self.player_buttons:
try:
button.destroy()
except Exception:
pass
self.player_buttons.clear()
# Clean up player boxes
for child in self.player_stack.get_children():
if hasattr(child, "destroy") and child != self.no_media_box:
try:
child.destroy()
except Exception:
pass
super().destroy()
def _periodic_cleanup(self):
"""Enhanced cleanup for reuse - clean internal state and free memory"""
try:
# Destroy all player boxes properly to free their resources
current_children = list(self.player_stack.get_children())
for child in current_children:
if hasattr(child, "destroy") and child != self.no_media_box:
try:
child.destroy()
except Exception as e:
logger.warning(f"Failed to destroy player child: {e}")
# Reset to no media state
self.player_stack.children = [self.no_media_box]
# Clear player buttons
for button in self.player_buttons:
try:
button.destroy()
except Exception:
pass
self.player_buttons.clear()
# Reset stack position
self.current_stack_pos = 0
# Clean up old cache files more aggressively
cleanup_old_cache_files()
# Force garbage collection
import gc
gc.collect()
logger.debug("PlayerBoxStack enhanced cleanup completed")
except Exception as e:
logger.warning(f"PlayerBoxStack enhanced cleanup failed: {e}")
def _create_no_media_box(self):
"""Create a placeholder box for when no media is playing."""
fallback_cover_path = f"{data.HOME_DIR}/.current.wall"
# Album cover with fallback image
album_cover = Box(style_classes="album-image-c")
album_cover.set_style(f"background-image:url('{fallback_cover_path}')")
image_stack = Box(h_align="start", v_align="center", name="player-image-stack")
image_stack.children = [album_cover]
# Track info showing "No media playing"
track_title = Label(
label="No media playing",
name="player-title-c",
justification="left",
max_chars_width=25,
ellipsization="end",
h_align="start",
)
track_artist = Label(
label="",
name="player-artist-c",
justification="left",
max_chars_width=15,
ellipsization="end",
h_align="start",
visible=False, # Hide artist and album when no media
)
track_info = Box(
name="track-info",
# spacing=5,
h_expand=True,
orientation="v",
v_align="start",
h_align="start",
children=[track_title, track_artist],
)
# No control buttons for no media state - just an empty box
controls_box = Box(
name="player-controls",
visible=False, # Hide controls when no media
)
player_info_box = Box(
name="player-info-box-c",
h_expand=True,
v_align="center",
h_align="center",
orientation="v",
children=[track_info, controls_box],
)
inner_box = CenterBox(
name="inner-player-box",
start_children=[
image_stack,
],
center_children=[
player_info_box,
],
)
# resize the inner box
outer_box = Box(
spacing=5,
name="outer-no-player-box-c",
h_expand=True,
h_align="fill",
# children=[
v_expand=True,
children=inner_box,
# inner_box,
# player_info_box,
# image,
# ],
)
box = Box(
name="box-c",
orientation="h",
v_expand=True,
h_align="fill",
h_expand=True,
children=[
outer_box,
],
)
no_media_box = Box(
h_align="center",
name="player-box",
h_expand=True,
children=[box],
)
return no_media_box
def _find_playing_player_index(self):
"""Find the index of the currently playing player."""
players: List[PlayerBox] = self.player_stack.get_children()
for i, player_box in enumerate(players):
if (
hasattr(player_box, "player")
and player_box.player.playback_status == "playing"
):
return i
return None
def _switch_to_playing_player(self):
"""Switch to the currently playing player if one exists."""
playing_index = self._find_playing_player_index()
if playing_index is not None and playing_index != self.current_stack_pos:
logger.info(
f"[PlayerBoxStack] Auto-switching to playing player at index {
playing_index
}"
)
self.on_player_clicked_by_index(playing_index)
def on_player_playback_changed(self, player_box, status):
"""Called when a player's playback status changes."""
if status == "playing":
# Find this player's index and switch to it
players: List[PlayerBox] = self.player_stack.get_children()
for i, pb in enumerate(players):
if pb == player_box:
if i != self.current_stack_pos:
logger.info(
f"[PlayerBoxStack] Switching to playing player: {
player_box.player.player_name
}"
)
self.on_player_clicked_by_index(i)
break
def on_player_clicked(self, type):
# unset active from prev active button
if self.player_buttons and self.current_stack_pos < len(self.player_buttons):
self.player_buttons[self.current_stack_pos].remove_style_class("active")
if type == "next":
self.current_stack_pos = (
self.current_stack_pos + 1
if self.current_stack_pos != len(self.player_stack.get_children()) - 1
else 0
)
elif type == "prev":
self.current_stack_pos = (
self.current_stack_pos - 1
if self.current_stack_pos != 0
else len(self.player_stack.get_children()) - 1
)
# set new active button
if self.player_buttons and self.current_stack_pos < len(self.player_buttons):
print(
f"[PlayerBoxStack] Switching to player at index {
self.current_stack_pos
}"
)
self.player_buttons[self.current_stack_pos].add_style_class("active")
self.player_stack.set_visible_child(
self.player_stack.get_children()[self.current_stack_pos],
)
def on_player_clicked_by_index(self, index):
"""Switch to player at given index"""
if 0 <= index < len(self.player_buttons):
# unset active from prev active button
if self.player_buttons and self.current_stack_pos < len(
self.player_buttons
):
self.player_buttons[self.current_stack_pos].remove_style_class("active")
# set new position
self.current_stack_pos = index
# set new active button
if self.player_buttons and self.current_stack_pos < len(
self.player_buttons
):
self.player_buttons[self.current_stack_pos].add_style_class("active")
self.player_stack.set_visible_child(
self.player_stack.get_children()[self.current_stack_pos],
)
# Update all player boxes with new button state
self._update_all_player_buttons()
def on_new_player(self, mpris_manager, player):
player_name = player.props.player_name
# if player_name in self.config.get("ignore", []):
# return
# Remove the no media box if it's the only child
if (
len(self.player_stack.get_children()) == 1
and self.player_stack.get_children()[0] == self.no_media_box
):
self.player_stack.children = []
self.current_stack_pos = 0
self.set_visible(True)
new_player_box = PlayerBox(
player=MprisPlayer(player),
player_stack=self,
control_center=self.control_center,
)
self.player_stack.children = [
*self.player_stack.children,
new_player_box,
]
self.make_new_player_button(self.player_stack.get_children()[-1])
logger.info(
f"[PLAYER MANAGER] adding new player: {player.get_property('player-name')}",
)
if self.player_buttons and self.current_stack_pos < len(self.player_buttons):
self.player_buttons[self.current_stack_pos].set_style_classes(["active"])
# Update all player boxes with current button state
self._update_all_player_buttons()
# Check if this new player is playing and switch to it
self._switch_to_playing_player()
def on_lost_player(self, mpris_manager, player_name):
# the playerBox is automatically removed from mprisbox children on being removed
logger.info(f"[PLAYER_MANAGER] Player Removed {player_name}")
players: List[PlayerBox] = self.player_stack.get_children()
# Find and properly destroy the player box
player_box_to_remove = None
for player_box in players:
if (
hasattr(player_box, "player")
and player_box.player.player_name == player_name
):
player_box_to_remove = player_box
break
if player_box_to_remove:
try:
player_box_to_remove.destroy()
except Exception as e:
logger.warning(f"Failed to destroy player box: {e}")
# Check if this was the last player
remaining_players = [
p for p in self.player_stack.get_children() if p != player_box_to_remove
]
if len(remaining_players) == 0:
# Show the no media box instead of hiding
self.player_stack.children = [self.no_media_box]
self.current_stack_pos = 0
self.player_buttons = [] # Clear player buttons
return
# Adjust current position if needed
if self.current_stack_pos >= len(self.player_stack.get_children()):
self.current_stack_pos = max(0, len(self.player_stack.get_children()) - 1)
# Set active button if we have buttons and a valid position
if self.player_buttons and self.current_stack_pos < len(self.player_buttons):
self.player_buttons[self.current_stack_pos].set_style_classes(["active"])
if self.player_stack.get_children():
self.player_stack.set_visible_child(
self.player_stack.get_children()[self.current_stack_pos],
)
# Update all player boxes with current button state
self._update_all_player_buttons()
# After a player is removed, check if we should switch to a playing player
self._switch_to_playing_player()
def make_new_player_button(self, player_box):
new_button = Button(name="player-stack-button")
def on_player_button_click(button: Button):
if self.player_buttons and self.current_stack_pos < len(
self.player_buttons
):
self.player_buttons[self.current_stack_pos].remove_style_class("active")
if button in self.player_buttons:
self.current_stack_pos = self.player_buttons.index(button)
button.add_style_class("active")
self.player_stack.set_visible_child(player_box)
new_button.connect(
"clicked",
on_player_button_click,
)
self.player_buttons.append(new_button)
# This will automatically destroy our used button
def cleanup_button(*_):
try:
if new_button in self.player_buttons:
self.player_buttons.remove(new_button)
new_button.destroy()
except Exception as e:
logger.warning(f"Failed to cleanup button: {e}")
player_box.connect("destroy", cleanup_button)
def _update_all_player_buttons(self):
"""Update all player boxes with the current button state"""
players: List[PlayerBox] = self.player_stack.get_children()
logger.info(
f"[PlayerBoxStack] Updating buttons for {len(players)} players, {
len(self.player_buttons)
} buttons"
)
for player_box in players:
if hasattr(player_box, "update_buttons"):
player_box.update_buttons(self.player_buttons, len(players) > 1)
else:
logger.warning(
"[PlayerBoxStack] PlayerBox missing update_buttons method"
)
class PlayerBox(Box):
"""A widget that displays the current player information."""
def __init__(
self, player: MprisPlayer, player_stack=None, control_center=None, **kwargs
):
super().__init__(
h_align="center",
name="player-box",
**kwargs,
h_expand=True,
)
# Setup
self.player: MprisPlayer = player
self.player_stack = player_stack
self.control_center = control_center
self.fallback_cover_path = f"{data.HOME_DIR}/.current.wall"
# Add controls_box attribute early for compatibility
# Temporary placeholder
self.controls_box = Box(name="temp-controls-box")
self.image_size = 50
self.icon_size = 15
# State
self.exit = False
self.skipped = False
# Memory management
self.temp_artwork_files = [] # Track temp files for cleanup
self.current_download_thread = None # Track current download thread
self._download_cancelled = False # Flag to cancel downloads
self._signal_connections = [] # Track signal connections
self.album_cover = Box(style_classes="album-image-c")
self.album_cover.set_style(
f"background-image:url('{self.fallback_cover_path}')"
)
self.image_stack = Box(
h_align="start",
v_align="center",
name="player-image-stack",
)
self.image_stack.children = [*self.image_stack.children, self.album_cover]
self.app_icon = Box(
children=Image(
icon_name=self.player.player_name, name="player-app-icon", icon_size=20
),
h_align="end",
v_align="end",
tooltip_text=self.player.player_name, # type: ignore
)
self.image = Overlay(
child=self.image_stack,
overlays=[
self.app_icon,
],
)
# Track Info
self.track_title = Label(
label="No Title",
name="player-title-c",
justification="left",
max_chars_width=25,
ellipsization="end",
h_align="start",
)
self.track_artist = Label(
label="No Artist",
name="player-artist-c",
justification="left",
max_chars_width=23,
ellipsization="end",
h_align="start",
visible=True,
)
self.player.bind_property(
"title",
self.track_title,
"label",
GObject.BindingFlags.DEFAULT,
lambda _, x: (
re.sub(r"\r?\n", " ", x) if x != "" and x is not None else "No Title"
), # type: ignore
)
self.player.bind_property(
"artist",
self.track_artist,
"label",
GObject.BindingFlags.DEFAULT,
lambda _, x: (
re.sub(r"\r?\n", " ", x) if x != "" and x is not None else "No Artist"
), # type: ignore
)
self.track_info = Box(
name="track-info",
spacing=5,
orientation="v",
v_align="start",
h_align="start",
children=[
self.track_title,
self.track_artist,
],
)
# Buttons with fixed sizing for layout stability
self.button_box = Box(
name="button-box-c",
h_expand=False,
spacing=2,
)
# Create SVG icons with consistent sizing
self.skip_next_icon = Svg(
name="control-buttons",
size=(22, 22),
svg_file=get_relative_path("../../config/assets/icons/player/fwd.svg"),
)
self.play_pause_icon = Svg(
name="control-buttons",
size=(22, 22),
svg_file=get_relative_path("../../config/assets/icons/player/Pause.svg"),
)
# Fixed size buttons to prevent layout shifts
self.play_pause_button = Button(
name="player-button",
child=self.play_pause_icon,
on_clicked=self.player.play_pause,
)
# Set consistent button size
self.player.bind_property("can_pause", self.play_pause_button, "sensitive")
self.next_button = Button(
name="player-button",
child=self.skip_next_icon,
on_clicked=self._on_player_next,
)
# Set consistent button size
# self.next_button.set_size_request(32, 32)
self.player.bind_property("can_go_next", self.next_button, "sensitive")
self.button_box.children = (
self.play_pause_button,
self.next_button,
)
# Assign button_box to controls_box for compatibility
self.controls_box = self.button_box
self.player_info_box = Box(
name="player-info-box-c",
v_align="center",
h_expand=True,
h_align="start",
orientation="v",
children=[
self.track_info,
],
)
self.inner_box = Box(
name="inner-player-box",
h_expand=True,
v_align="center",
h_align="start",
children=[
self.image,
self.player_info_box,
],
)
# resize the inner box
self.outer_box = Button(
spacing=5,
name="outer-player-box-c",
h_expand=True,
# style="background-color:#fff",
on_clicked=self._on_outer_box_clicked,
h_align="start",
# children=[
child=self.inner_box,
# self.inner_box,
# self.player_info_box,
# self.image,
# ],
)
self.box = CenterBox(
name="box-c",
orientation="h",
h_align="center",
start_children=[
self.outer_box,
# self.stack_buttons_box,
],
end_children=[
self.button_box,
],
)
self.children = [
*self.children,
self.box,
]
# Track signal connections for cleanup - store (object, handler_id) tuples
connections = bulk_connect(
self.player,
{
"exit": self._on_player_exit,
"notify::playback-status": self._on_playback_change,
"notify::metadata": self._on_metadata,
},
)
# Store as (object, handler_id) tuples
for handler_id in connections:
self._signal_connections.append((self.player, handler_id))
def destroy(self):
"""Clean up all resources when the widget is destroyed."""
# Cancel any ongoing downloads
self._download_cancelled = True
# Disconnect all signal connections
for obj, handler_id in self._signal_connections:
try:
obj.disconnect(handler_id)
except Exception as e:
logger.warning(f"Failed to disconnect signal: {e}")
self._signal_connections.clear()
# Clean up temp files
self._cleanup_temp_files()
super().destroy()
def __del__(self):
"""Ensure cleanup happens even if player exits unexpectedly."""
try:
self._cleanup_temp_files()
except Exception:
pass # Ignore errors during cleanup in destructor
def _on_prev_button_click(self, *_):
"""Handle prev button click: open expanded player in control center"""
try:
# Open expanded player in control center instead of new window
if self.control_center and hasattr(
self.control_center, "open_expanded_player"
):
self.control_center.open_expanded_player()
except Exception as e:
logger.warning(f"Failed to handle prev button click: {e}")
def _on_outer_box_clicked(self, *_):
"""Handle outer box click with proper error handling."""
try:
# Open expanded player in control center instead of new window
if self.control_center and hasattr(
self.control_center, "open_expanded_player"
):
self.control_center.open_expanded_player()
except Exception as e:
logger.warning(f"Failed to handle outer box click: {e}")
import traceback
logger.error(f"Full traceback: {traceback.format_exc()}")
def update_buttons(self, player_buttons, show_buttons):
# """Update the stack switcher buttons in this player box"""
pass
def _on_metadata(self, *_):
self._set_image()
def _cleanup_temp_files(self):
"""Clean up temporary artwork files."""
for temp_file in self.temp_artwork_files:
try:
if os.path.exists(temp_file):
os.unlink(temp_file)
except Exception as e:
logger.warning(f"Failed to cleanup temp file {temp_file}: {e}")
self.temp_artwork_files.clear()
def _on_player_exit(self, _, value):
self.exit = value
self._cleanup_temp_files() # Clean up temp files before destroying
self.destroy()
def _on_player_next(self, *_):
self.player.next()
def _on_player_prev(self, *_):
self.player.previous()
def _on_playback_change(self, player, status):
status = player.get_property("playback-status")
if status == "paused":
self.play_pause_icon.set_from_file(
get_relative_path("../../config/assets/icons/player/play.svg")
)
if status == "playing":
self.play_pause_icon.set_from_file(
get_relative_path("../../config/assets/icons/player/Pause.svg")
)
# Notify the player stack that this player started playing
if self.player_stack and hasattr(
self.player_stack, "on_player_playback_changed"
):
self.player_stack.on_player_playback_changed(self, status)
def _update_image(self, image_path):
if image_path and os.path.isfile(image_path):
self.album_cover.set_style(f"background-image:url('{image_path}')")
else:
self.album_cover.set_style(
f"background-image:url('{self.fallback_cover_path}')"
)
def _set_image(self, *_):
art_url = self.player.arturl
parsed = urllib.parse.urlparse(art_url)
if parsed.scheme == "file":
local_arturl = urllib.parse.unquote(parsed.path)
self._update_image(local_arturl)
elif parsed.scheme in ("http", "https"):
# Cancel any existing download to prevent memory buildup
self._download_cancelled = True
# Use threading.Thread instead of GLib.Thread for better control
if self.current_download_thread and self.current_download_thread.is_alive():
# Thread will check _download_cancelled flag and exit early
pass
self._download_cancelled = False
self.current_download_thread = threading.Thread(
target=self._download_and_set_artwork,
args=(art_url,),
daemon=True, # Dies with main thread
)
self.current_download_thread.start()
else:
self._update_image(art_url)
def _download_and_set_artwork(self, arturl):
"""
Download the artwork from the given URL asynchronously and update the cover
using GLib.idle_add to ensure UI updates occur on the main thread.
"""
local_arturl = self.fallback_cover_path
temp_file_path = None
try:
# Check if download was cancelled
if self._download_cancelled:
return
# Clean up old temp files first (keep only last 1 to reduce memory)
if len(self.temp_artwork_files) > 1:
old_files = self.temp_artwork_files[:-1]
for old_file in old_files:
try:
if os.path.exists(old_file):
os.unlink(old_file)
except Exception:
pass
self.temp_artwork_files = self.temp_artwork_files[-1:]
# Check again if cancelled
if self._download_cancelled:
return
# Download artwork
parsed = urllib.parse.urlparse(arturl)
suffix = os.path.splitext(parsed.path)[1] or ".png"
with urllib.request.urlopen(arturl, timeout=10) as response: # Add timeout
if self._download_cancelled:
return
data = response.read()
# Check one more time if cancelled
if self._download_cancelled:
return
# Create temp file in cache directory instead of system temp
os.makedirs(CACHE_DIR, exist_ok=True)
with tempfile.NamedTemporaryFile(
delete=False, suffix=suffix, dir=CACHE_DIR
) as temp_file:
temp_file.write(data)
temp_file_path = temp_file.name
local_arturl = temp_file_path
# Track temp file for cleanup
if temp_file_path and not self._download_cancelled:
self.temp_artwork_files.append(temp_file_path)
except Exception as e:
if not self._download_cancelled:
logger.warning(f"Failed to download artwork from {arturl}: {e}")
# Clean up failed temp file
if temp_file_path and os.path.exists(temp_file_path):
try:
os.unlink(temp_file_path)
except Exception:
pass
return
# Only update UI if not cancelled
if not self._download_cancelled:
GLib.idle_add(self._update_image, local_arturl)
def close_bluetooth(self, *args):
"""Placeholder method for compatibility"""
pass
================================================
FILE: modules/controlcenter/wifi.py
================================================
from widgets.wifi_password_dialog import WiFiPasswordDialog
from services.network import NetworkClient
from fabric.widgets.scrolledwindow import ScrolledWindow
from fabric.widgets.revealer import Revealer
from fabric.widgets.label import Label
from fabric.widgets.image import Image
from fabric.widgets.centerbox import CenterBox
from fabric.widgets.button import Button
from fabric.widgets.box import Box
from fabric.widgets.svg import Svg
from fabric.utils import get_relative_path
from gi.repository import Gdk, GLib, Gtk
from fabric.widgets.separator import Separator
from utils.functions import get_wifi_icon_for_strength, get_wifi_connecting_icon
import gi
import subprocess
gi.require_version("Gtk", "3.0")
gi.require_version("Gdk", "3.0")
class WifiNetworkSlot(Box):
def __init__(self, access_point, wifi_service, parent=None, **kwargs):
super().__init__(name="wifi-network-slot", **kwargs)
self.access_point = access_point
self.wifi_service = wifi_service
self.parent = parent # Reference to control center
# Get network info from AccessPoint object
self.ssid = access_point.ssid
self.bssid = access_point.bssid
self.strength = access_point.strength
self.icon_name = access_point.icon
# Check if this network is currently connected
self.is_connected = access_point.is_active
# Initialize styles based on connection state
self.styles = [
"connected" if self.is_connected else "",
]
# Create connection status indicator using dynamic WiFi icon based on signal strength
wifi_icon_path = get_wifi_icon_for_strength(self.strength)
self.dimage = Svg(
svg_file=wifi_icon_path,
size=28,
name="device-icon",
)
self.wifi_icon_box = Box(
children=[self.dimage],
style_classes=["wifi-icon-box"],
)
# if self.is_connected:
# self.wifi_icon_box.remove_style_class("wifi-icon-box")
# self.wifi_icon_box.add_style_class("wifi-icon-box-connected")
#
# Set initial style classes
if self.is_connected:
self.dimage.add_style_class("connected")
self.network_label = Label(
label=self.ssid, name="wifi-network-name", h_align="start", h_expand=True
)
# Create lock icon for secured networks
self.lock_icon = None
if self.access_point.requires_password:
self.lock_icon = Image(
icon_name="changes-prevent-symbolic",
size=12,
name="wifi-lock-icon",
)
# Initialize password dialog
self.password_dialog = None
# Create the start section with WiFi icon and network name
start_box = Box(
orientation="h",
spacing=8,
)
start_box.children = [self.wifi_icon_box, self.network_label]
if self.lock_icon:
start_box.children.append(self.lock_icon)
# Create end section with lock icon if needed
self.children = [
Button(
child=start_box,
h_expand=True,
name="wifi-network-button",
on_clicked=lambda *_: self.toggle_connecting(),
)
]
# Emit initial change to update display
self.on_changed()
def toggle_connecting(self):
# Check if this network is currently connected
is_currently_connected = self.access_point.is_active
if is_currently_connected:
# Show disconnecting state using connecting icon
connecting_icon = get_wifi_connecting_icon()
self.dimage.set_from_file(connecting_icon)
self.dimage.add_style_class("disconnecting")
# Disconnect from network
self.wifi_service.disconnect_wifi()
self.is_connected = False
# Remove disconnecting state after a short delay to show feedback
GLib.timeout_add(500, lambda: self._reset_disconnect_state())
else:
# Try to connect - check if password is required
if self.access_point.requires_password:
# Show password dialog immediately
self._show_password_dialog()
else:
# Try to connect without password (for open networks)
connecting_icon = get_wifi_connecting_icon()
self.dimage.set_from_file(connecting_icon)
self.dimage.add_style_class("connecting")
def on_open_connection_result(success, message):
"""Handle the connection result for open networks"""
if success:
self.is_connected = True
# Remove connecting state after a short delay
GLib.timeout_add(500, lambda: self._reset_connect_state())
else:
# Connection failed
self._reset_connect_state()
# Update display after connection attempt
self.on_changed()
try:
self.wifi_service.connect_to_wifi(
self.access_point, callback=on_open_connection_result
)
except Exception:
# Handle any connection errors gracefully
self._reset_connect_state()
self.on_changed()
# Update display after connection attempt
self.on_changed()
def _reset_disconnect_state(self):
"""Reset visual state after disconnect operation"""
self.dimage.remove_style_class("disconnecting")
wifi_icon_path = get_wifi_icon_for_strength(self.strength)
self.dimage.set_from_file(wifi_icon_path)
self.on_changed()
return False # Remove timeout
def _reset_connect_state(self):
"""Reset visual state after connect operation"""
self.dimage.remove_style_class("connecting")
wifi_icon_path = get_wifi_icon_for_strength(self.strength)
self.dimage.set_from_file(wifi_icon_path)
self.on_changed()
return False # Remove timeout
def on_changed(self, *_):
# Check if this network is currently connected using the access point's is_active property
self.is_connected = self.access_point.is_active
self.styles = [
"connected" if self.is_connected else "",
]
# Update style classes for SVG widget
if self.is_connected:
self.wifi_icon_box.add_style_class("wifi-icon-box-connected")
else:
self.wifi_icon_box.remove_style_class("wifi-icon-box-connected")
return
def _show_password_dialog(self):
"""Show the WiFi password dialog"""
# Close the control center first
if self.parent and hasattr(self.parent, "hide_controlcenter"):
self.parent.hide_controlcenter()
# Create a new dialog each time to ensure clean state
if self.password_dialog:
self.password_dialog.destroy_dialog()
self.password_dialog = WiFiPasswordDialog(
ssid=self.ssid,
on_connect_callback=self._on_password_connect,
on_cancel_callback=self._on_password_cancel,
)
self.password_dialog.show_dialog()
def _on_password_connect(self, ssid, password):
"""Handle password dialog connect action"""
if password.strip():
# Show connecting state using connecting icon
connecting_icon = get_wifi_connecting_icon()
self.dimage.set_from_file(connecting_icon)
self.dimage.add_style_class("connecting")
# Try to connect with password using callback
def on_connection_result(success, message):
"""Handle the connection result"""
if success:
self.is_connected = True
# Remove connecting state after a short delay
from gi.repository import GLib
GLib.timeout_add(500, lambda: self._reset_connect_state())
# Clear any timeout in the password dialog
if (
self.password_dialog
and self.password_dialog.connection_timeout_id
):
GLib.source_remove(self.password_dialog.connection_timeout_id)
self.password_dialog.connection_timeout_id = None
self.password_dialog.is_connecting = False
else:
# Connection failed - show error in dialog
self._reset_connect_state()
if self.password_dialog:
self._show_connection_error(message)
# Update display after connection attempt
self.on_changed()
try:
self.wifi_service.connect_to_wifi(
self.access_point, password, callback=on_connection_result
)
except Exception:
# Handle any connection errors gracefully
self._reset_connect_state()
if self.password_dialog:
self._show_connection_error("Connection failed. Please try again.")
self.on_changed()
def _show_connection_error(self, message="Incorrect password. Please try again."):
"""Show connection error in a separate thread to prevent UI blocking"""
if self.password_dialog:
self.password_dialog.show_error(message)
return False # Don't repeat if called from GLib.timeout_add
def _on_password_cancel(self):
"""Handle password dialog cancel action"""
# Reset any connecting state
self._reset_connect_state()
class WifiConnections(Box):
def __init__(self, parent, show_back_button=True, **kwargs):
super().__init__(
spacing=8,
orientation="vertical",
name="wifi-connections",
**kwargs,
)
self.parent = parent
self.network_service = NetworkClient()
self.wifi_service = None
self.is_scanning = False # Track scanning state
self.refresh_timer = None # Timer for periodic network refresh
self._update_in_progress = False # Prevent concurrent updates
self._destroyed = False # Track if widget is destroyed
# Wait for network service to be ready
self.network_service.connect("wifi-device-added", self.on_network_ready)
# Create pull-to-refresh indicator
self.refresh_indicator = Label(
name="wifi-refresh-indicator",
label="↓ Pull to scan for networks",
h_align="center",
visible=False,
style="color: #fff; font-size: 12px; padding: 5px;",
)
# Create title with optional back button
title_children = []
if show_back_button:
title_children.append(
Button(
image=Image(icon_name="back", size=10),
on_clicked=lambda *_: self.parent.close_wifi(),
)
)
title_children.append(Label("Wi-Fi", name="wifi-title"))
self.title = Box(
orientation="h",
children=title_children,
)
self.toggle_button = Gtk.Switch(visible=True, name="toggle-button")
# Create Known Network section
self.known_networks_label = Label(
label="Known Network", h_align="start", name="networks-title"
)
self.known_networks = Box(
spacing=4, orientation="vertical", name="known-networks"
)
# Create "No networks available" message
self.no_networks_label = Label(
label="No networks available",
h_align="center",
name="no-networks-label",
visible=False,
)
# Create Other Networks section with clickable title
self.other_networks_button = Button(
child=Label("Other Networks", h_align="start"),
name="wifi-other-button",
on_clicked=self.toggle_other_networks,
)
self.other_networks = Box(spacing=4, orientation="vertical")
# Create scrolled window for other networks
self.other_networks_scrolled = ScrolledWindow(
min_content_size=(303, 150),
child=self.other_networks,
overlay_scroll=True,
)
# Add pull-to-refresh functionality to scrolled window
self.setup_pull_to_refresh()
# Create revealer for Other Networks section
self.other_networks_revealer = Revealer(
child=self.other_networks_scrolled,
transition_type="slide-down",
transition_duration=100,
child_revealed=False,
)
# Create More Settings button (same style as Other Networks button)
self.more_settings_button = Button(
child=Label("More Settings", h_align="start"),
name="wifi-other-button",
on_clicked=self.open_network_settings,
)
self.children = [
CenterBox(
start_children=self.title,
end_children=self.toggle_button,
name="wifi-widget-top",
),
self.refresh_indicator,
Separator(orientation="h", name="separator"),
self.known_networks_label,
self.known_networks,
self.no_networks_label,
Separator(orientation="h", name="separator"),
self.other_networks_button,
self.other_networks_revealer,
Separator(orientation="h", name="separator"),
self.more_settings_button,
]
# Connect cleanup on destroy
self.connect("destroy", self.on_destroy)
# Start periodic network monitoring for real-time updates
self.start_network_monitoring()
def toggle_other_networks(self, *_):
"""Toggle the visibility of other networks section"""
current_state = self.other_networks_revealer.child_revealed
self.other_networks_revealer.child_revealed = not current_state
# Update button text based on state
if self.other_networks_revealer.child_revealed:
# Trigger a scan when revealing other networks and force refresh
if self.wifi_service:
self.wifi_service.scan()
# Also force an immediate network refresh to catch any missed connections
self.force_network_refresh()
def on_network_ready(self, *_):
"""Called when network service is ready"""
self.wifi_service = self.network_service.wifi_device
if self.wifi_service:
# Set up WiFi toggle
self.toggle_button.set_active(self.wifi_service.wireless_enabled)
self.toggle_button.connect("notify::active", self.on_toggle_changed)
# Connect to WiFi service signals
self.wifi_service.connect(
"notify::wireless-enabled", self.on_wifi_enabled_changed
)
self.wifi_service.connect("changed", self.update_networks)
self.wifi_service.connect("ap-added", self.update_networks)
self.wifi_service.connect("ap-removed", self.update_networks)
# Initial network update
self.update_networks()
def on_toggle_changed(self, toggle_button, *_):
"""Handle WiFi toggle button changes"""
if self.wifi_service:
new_state = toggle_button.get_active()
self.wifi_service.wireless_enabled = new_state
def on_wifi_enabled_changed(self, *_):
"""Handle WiFi enabled state changes"""
if self.wifi_service:
self.toggle_button.set_active(self.wifi_service.wireless_enabled)
def open_network_settings(self, *_):
"""Open NetworkManager connection editor"""
try:
subprocess.Popen(["nm-connection-editor"], start_new_session=True)
if self.parent and hasattr(self.parent, "hide_controlcenter"):
self.parent.hide_controlcenter()
except FileNotFoundError:
pass
except Exception:
pass
def update_networks(self, *_):
"""Update the list of available networks"""
# Prevent concurrent updates and check if destroyed
if self._update_in_progress or self._destroyed or not self.wifi_service:
return
self._update_in_progress = True
try:
# Store current network SSIDs to detect changes
current_known_ssids = {
child.ssid
for child in self.known_networks.get_children()
if hasattr(child, "ssid")
}
current_other_ssids = {
child.ssid
for child in self.other_networks.get_children()
if hasattr(child, "ssid")
}
# Get current networks
access_points = self.wifi_service.access_points
known_networks = []
other_networks = []
new_known_ssids = set()
new_other_ssids = set()
for access_point in access_points:
try:
if access_point.ssid and access_point.ssid != "Unknown":
# Categorize networks: connected or saved networks go to "Known Network"
# All others go to "Other Networks"
if access_point.is_active or self._is_saved_network(
access_point
):
known_networks.append(access_point)
new_known_ssids.add(access_point.ssid)
else:
other_networks.append(access_point)
new_other_ssids.add(access_point.ssid)
except Exception:
continue
# Check if we need to update (networks added/removed)
known_changed = current_known_ssids != new_known_ssids
other_changed = current_other_ssids != new_other_ssids
# Only rebuild if something actually changed
if known_changed or other_changed:
# Clear existing networks safely
for child in list(self.known_networks.get_children()):
if not self._destroyed:
child.destroy()
for child in list(self.other_networks.get_children()):
if not self._destroyed:
child.destroy()
# Add known networks
for access_point in known_networks:
if not self._destroyed:
network_slot = WifiNetworkSlot(
access_point, self.wifi_service, parent=self.parent
)
self.known_networks.add(network_slot)
# Add other networks
for access_point in other_networks:
if not self._destroyed:
network_slot = WifiNetworkSlot(
access_point, self.wifi_service, parent=self.parent
)
self.other_networks.add(network_slot)
# Show/hide sections based on available networks
if not self._destroyed:
has_known_networks = len(known_networks) > 0
has_other_networks = len(other_networks) > 0
has_any_networks = has_known_networks or has_other_networks
# Show known networks section only if there are known networks
self.known_networks_label.set_visible(has_known_networks)
self.known_networks.set_visible(has_known_networks)
# Show "No networks available" message if no networks at all
self.no_networks_label.set_visible(not has_any_networks)
# Always show the other networks button, regardless of available networks
self.other_networks_button.set_visible(True) # Always visible
# Update all network connection states
self.refresh_network_states()
except Exception:
pass
finally:
self._update_in_progress = False
def _is_saved_network(self, access_point):
"""Check if a network is saved/known using NetworkManager connections"""
if not self.network_service or not self.network_service._client:
return False
try:
ssid = access_point.ssid
if not ssid or ssid == "Unknown":
return False
# Get all saved connections from NetworkManager
connections = self.network_service._client.get_connections()
for connection in connections:
# Check if this is a WiFi connection
if connection.get_connection_type() != "802-11-wireless":
continue
# Get the wireless setting
wifi_setting = connection.get_setting_wireless()
if not wifi_setting:
continue
# Compare SSIDs
connection_ssid_bytes = wifi_setting.get_ssid()
if connection_ssid_bytes:
from gi.repository import NM
connection_ssid = NM.utils_ssid_to_utf8(
connection_ssid_bytes.get_data()
)
if connection_ssid == ssid:
return True
except Exception:
pass
return False
def refresh_network_states(self, *_):
"""Refresh connection states for all network slots"""
# Refresh known networks
for child in self.known_networks.get_children():
if hasattr(child, "on_changed"):
child.on_changed()
# Refresh other networks
for child in self.other_networks.get_children():
if hasattr(child, "on_changed"):
child.on_changed()
def start_network_monitoring(self):
"""Start periodic monitoring for network changes"""
# Monitor for network changes every 5 seconds
# This helps catch networks that connect from external sources
self.refresh_timer = GLib.timeout_add_seconds(5, self.periodic_network_refresh)
def stop_network_monitoring(self):
"""Stop periodic monitoring"""
if self.refresh_timer:
GLib.source_remove(self.refresh_timer)
self.refresh_timer = None
def periodic_network_refresh(self):
"""Periodically refresh network list to catch external connections"""
# Skip if update in progress, destroyed, or wifi service not available
if (
self._update_in_progress
or self._destroyed
or not self.wifi_service
or not self.wifi_service.wireless_enabled
):
return True # Continue monitoring
try:
# Simple check - just trigger update_networks which has its own safety checks
self.update_networks()
except Exception:
pass
return True # Continue monitoring
def force_network_refresh(self):
"""Force an immediate refresh of the network list"""
if self._update_in_progress or self._destroyed:
return
try:
# Simply trigger update_networks which has its own safety checks
self.update_networks()
except Exception:
pass
def setup_pull_to_refresh(self):
"""Setup pull-to-refresh gesture for the scrolled window"""
# Get the scrolled window's vertical adjustment
self.vadjustment = self.other_networks_scrolled.get_vadjustment()
# Track gesture state
self.pull_start_y = 0
self.is_pulling = False
self.pull_threshold = 50 # pixels to trigger refresh
# Connect to scroll events
self.other_networks_scrolled.connect("scroll-event", self.on_scroll_event)
self.other_networks_scrolled.connect("button-press-event", self.on_button_press)
self.other_networks_scrolled.connect(
"button-release-event", self.on_button_release
)
self.other_networks_scrolled.connect(
"motion-notify-event", self.on_motion_notify
)
# Enable events
self.other_networks_scrolled.set_events(
Gdk.EventMask.SCROLL_MASK
| Gdk.EventMask.BUTTON_PRESS_MASK
| Gdk.EventMask.BUTTON_RELEASE_MASK
| Gdk.EventMask.POINTER_MOTION_MASK
)
def on_scroll_event(self, widget, event):
"""Handle scroll events for pull-to-refresh"""
# Only handle pull-to-refresh when at the top
if self.vadjustment.get_value() <= 0:
if event.direction == Gdk.ScrollDirection.UP:
# Scrolling up at the top - trigger scan and force refresh
if self.wifi_service:
self.wifi_service.scan()
self.force_network_refresh()
return True # Consume the event
return False # Let normal scrolling continue
def on_button_press(self, widget, event):
"""Handle button press for touch/drag gestures"""
if self.vadjustment.get_value() <= 0:
self.pull_start_y = event.y
self.is_pulling = True
return False
def on_button_release(self, widget, event):
"""Handle button release for touch/drag gestures"""
if self.is_pulling:
pull_distance = event.y - self.pull_start_y
if pull_distance > self.pull_threshold:
# Trigger scan and force refresh
if self.wifi_service:
self.wifi_service.scan()
self.force_network_refresh()
# Hide refresh indicator
self.refresh_indicator.set_visible(False)
self.refresh_indicator.remove_style_class("ready-to-refresh")
self.is_pulling = False
return False
def on_motion_notify(self, widget, event):
"""Handle motion events for visual feedback during pull"""
if self.is_pulling and self.vadjustment.get_value() <= 0:
pull_distance = event.y - self.pull_start_y
if pull_distance > 0:
# Show refresh indicator when pulling down
self.refresh_indicator.set_visible(True)
if pull_distance >= self.pull_threshold:
self.refresh_indicator.set_label("↑ Release to scan")
self.refresh_indicator.add_style_class("ready-to-refresh")
else:
self.refresh_indicator.set_label("↓ Pull to scan for networks")
self.refresh_indicator.remove_style_class("ready-to-refresh")
else:
self.refresh_indicator.set_visible(False)
return False
def on_destroy(self, widget):
"""Cleanup when widget is destroyed"""
# Mark as destroyed to prevent further updates
self._destroyed = True
# Stop monitoring
self.stop_network_monitoring()
# Make sure other networks revealer is collapsed when closing
try:
self.other_networks_revealer.child_revealed = False
except:
pass # Widget might already be destroyed
def close_wifi(self):
"""Called when WiFi panel is being closed"""
# Collapse the other networks section when closing
self.other_networks_revealer.child_revealed = False
================================================
FILE: modules/corners.py
================================================
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()
================================================
FILE: modules/dock.py
================================================
import json
import os
import re
import subprocess
from fabric.utils.helpers import get_desktop_applications, get_relative_path
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.eventbox import EventBox
from fabric.widgets.image import Image
from fabric.widgets.label import Label
from fabric.widgets.overlay import Overlay
from fabric.widgets.revealer import Revealer
from gi.repository import GLib, Gtk
from loguru import logger
import config.data as data
from services.modus import modus_service
from utils.functions import read_json_file, write_json_file, is_special_workspace_id
from utils.icon_resolver import IconResolver
from utils.occlusion import check_occlusion
from widgets.wayland import WaylandWindow as Window
# Pinned apps file
PINNED_APPS_FILE = get_relative_path("../config/assets/dock.json")
class AppBar(Box):
def __init__(self, parent: Window):
self.client_buttons = {} # For running app instances
self.pinned_buttons = {} # For pinned apps
# Position tracking for hover effects
self.running_items_pos = []
self.pinned_items_pos = []
self._parent = parent
# Set orientation based on dock position
orientation = (
"vertical" if data.DOCK_POSITION in ["Left", "Right"] else "horizontal"
)
super().__init__(
spacing=0,
name="dock",
orientation=orientation,
children=[],
)
self.icon_resolver = IconResolver()
self._hyprland_connection = modus_service._hyprland_connection
# Initialize GTK menu
self.menu = Gtk.Menu()
self.pinned_apps = read_json_file(PINNED_APPS_FILE) or []
self.pinned_apps_container = Box()
self.add(self.pinned_apps_container)
self.separator = Box(
v_align="center", style_classes=["hidden", "dock_separator"]
)
self.add(self.separator)
self.running_apps_container = Box(name="dock_container")
self.add(self.running_apps_container)
self._populate_pinned_apps()
self.setup_app_monitoring()
def setup_app_monitoring(self):
def update_running_apps():
try:
self.update_dock_apps()
except Exception as e:
logger.error(f"[AppBar] Error updating apps: {e}")
return True
GLib.timeout_add(250, update_running_apps)
GLib.idle_add(self.update_dock_apps)
def _populate_pinned_apps(self):
for child in self.pinned_apps_container.get_children():
self.pinned_apps_container.remove(child)
self.pinned_buttons = {}
self.pinned_items_pos = []
try:
desktop_apps = get_desktop_applications(include_hidden=False)
except Exception:
desktop_apps = []
for app_data in self.pinned_apps:
self._create_pinned_button(app_data, desktop_apps)
# Add trash icon at the end of pinned apps
self._create_trash_button()
def _create_pinned_button(self, app_data, desktop_apps):
if isinstance(app_data, dict):
app_identifier = app_data.get("name", "") or app_data.get(
"window_class", ""
)
display_name = app_data.get("display_name", app_identifier)
app = self._find_desktop_app_from_data(app_data, desktop_apps)
if app:
icon_pixbuf = app.get_icon_pixbuf(data.DOCK_ICON_SIZE)
else:
icon_name = app_data.get("window_class", "") or app_data.get("name", "")
icon_pixbuf = self.icon_resolver.get_icon_pixbuf(
icon_name, data.DOCK_ICON_SIZE
)
else:
app_identifier = app_data
app = self._find_desktop_app_by_id(app_data, desktop_apps)
if not app:
return
display_name = app.display_name or app.name
icon_pixbuf = app.get_icon_pixbuf(data.DOCK_ICON_SIZE)
pinned_image = Image(name="dock_item_icon")
pinned_image.set_from_pixbuf(icon_pixbuf)
main_container = Box(
name="dock_item_main_container",
orientation="v",
children=[pinned_image],
)
pinned_button = Button(
name="dock_item",
child=main_container,
tooltip_text=display_name,
on_button_press_event=lambda _, event: self._handle_pinned_app_click(
event, app_data
),
on_enter_notify_event=lambda *_: self._handle_item_hovered(
pinned_button, True
),
on_leave_notify_event=lambda *_: self._handle_item_unhovered(
pinned_button, True
),
)
pinned_button.add_style_class("shown")
self.pinned_buttons[app_identifier] = pinned_button
self.pinned_apps_container.add(pinned_button)
self.pinned_items_pos.append(pinned_button)
def _create_trash_button(self):
"""Create a trash button that opens the trash in file manager"""
# Get trash icon
trash_icon_pixbuf = self.icon_resolver.get_icon_pixbuf(
"user-trash", data.DOCK_ICON_SIZE
)
trash_image = Image(name="dock_item_icon")
trash_image.set_from_pixbuf(trash_icon_pixbuf)
main_container = Box(
name="dock_item_main_container",
orientation="v",
children=[trash_image],
)
trash_button = Button(
name="dock_item",
child=main_container,
tooltip_text="Trash",
on_button_press_event=lambda _, event: self._handle_trash_click(event),
on_enter_notify_event=lambda *_: self._handle_item_hovered(
trash_button, True
),
on_leave_notify_event=lambda *_: self._handle_item_unhovered(
trash_button, True
),
)
trash_button.add_style_class("shown")
trash_button.is_trash = True
self.pinned_buttons["trash"] = trash_button
self.pinned_apps_container.add(trash_button)
self.pinned_items_pos.append(trash_button)
def _find_desktop_app_from_data(self, app_data: dict, desktop_apps):
for app in desktop_apps:
if (
(
app_data.get("name")
and app.name
and app.name.lower() == app_data["name"].lower()
)
or (
app_data.get("window_class")
and hasattr(app, "window_class")
and app.window_class
and app.window_class.lower() == app_data["window_class"].lower()
)
or (
app_data.get("executable")
and app.executable
and (
app.executable.lower() == app_data["executable"].lower()
or os.path.basename(app.executable).lower()
== os.path.basename(app_data["executable"]).lower()
)
)
):
return app
return None
def _find_desktop_app_by_id(self, app_id: str, desktop_apps):
for app in desktop_apps:
if (
(app.name and app.name.lower() == app_id.lower())
or (app.display_name and app.display_name.lower() == app_id.lower())
or (
hasattr(app, "window_class")
and app.window_class
and app.window_class.lower() == app_id.lower()
)
or (
app.executable
and (
app.executable.lower() == app_id.lower()
or os.path.basename(app.executable).lower() == app_id.lower()
)
)
):
return app
return None
def show_menu(self, app_id: str, client=None, instance_address=None):
for item in self.menu.get_children():
self.menu.remove(item)
item.destroy()
if client or instance_address:
close_item = Gtk.MenuItem(label="Close")
if instance_address:
close_item.connect(
"activate", lambda *_: self._close_running_app(instance_address)
)
self.menu.add(close_item)
if app_id:
separator = Gtk.SeparatorMenuItem()
self.menu.add(separator)
if app_id:
is_pinned = self._is_app_pinned(app_id)
pin_item = Gtk.MenuItem(label="Unpin" if is_pinned else "Pin")
if is_pinned:
pin_item.connect("activate", lambda *_: self._unpin_app(app_id))
else:
pin_item.connect("activate", lambda *_: self._pin_app(app_id))
self.menu.add(pin_item)
self.menu.show_all()
def _close_running_app(self, instance_address):
try:
self._hyprland_connection.send_command(
f"dispatch closewindow address:{instance_address}"
)
except Exception as e:
logger.error(f"[AppBar] Error closing window: {e}")
def _handle_pinned_app_click(self, event, app_data):
if event.button == 1: # Left click - launch app
self._launch_app_data(app_data)
elif event.button == 2: # Middle click - unpin app
app_identifier = self._get_app_identifier(app_data)
self._unpin_app(app_identifier)
elif event.button == 3: # Right click - show context menu
app_identifier = self._get_app_identifier(app_data)
self.show_menu(app_identifier)
self.menu.popup_at_pointer(event)
def _handle_trash_click(self, event):
"""Handle trash button click to open trash in file manager"""
if event.button == 1: # Left click
try:
trash_path = os.path.expanduser("~/.local/share/Trash/files")
file_managers = [
"nautilus",
"dolphin",
"thunar",
"nemo",
"caja",
"pcmanfm",
]
for fm in file_managers:
try:
result = subprocess.run(
["which", fm], capture_output=True, text=True
)
if result.returncode == 0:
subprocess.Popen([fm, trash_path])
return
except Exception:
continue
except Exception as e:
logger.error(f"[AppBar] Error opening trash: {e}")
def _handle_item_hovered(self, item, pinned=False):
if pinned:
try:
index = self.pinned_items_pos.index(item)
if index > 0:
self.pinned_items_pos[index - 1].add_style_class("semi_hovered")
if index < len(self.pinned_items_pos) - 1:
self.pinned_items_pos[index + 1].add_style_class("semi_hovered")
except ValueError:
pass
else:
try:
index = self.running_items_pos.index(item)
if index > 0:
self.running_items_pos[index - 1].add_style_class("semi_hovered")
if index < len(self.running_items_pos) - 1:
self.running_items_pos[index + 1].add_style_class("semi_hovered")
except ValueError:
pass
def _handle_item_unhovered(self, item, pinned=False):
if pinned:
try:
index = self.pinned_items_pos.index(item)
if index > 0:
self.pinned_items_pos[index - 1].remove_style_class("semi_hovered")
if index < len(self.pinned_items_pos) - 1:
self.pinned_items_pos[index + 1].remove_style_class("semi_hovered")
except ValueError:
pass
else:
try:
index = self.running_items_pos.index(item)
if index > 0:
self.running_items_pos[index - 1].remove_style_class("semi_hovered")
if index < len(self.running_items_pos) - 1:
self.running_items_pos[index + 1].remove_style_class("semi_hovered")
except ValueError:
pass
def _get_app_identifier(self, app_data):
if isinstance(app_data, dict):
return app_data.get("name", "") or app_data.get("window_class", "")
return app_data
def _launch_app_data(self, app_data):
try:
desktop_apps = get_desktop_applications(include_hidden=False)
if isinstance(app_data, dict):
app = self._find_desktop_app_from_data(app_data, desktop_apps)
if app:
self._launch_app(app)
else:
self._launch_app_from_data(app_data)
else:
app = self._find_desktop_app_by_id(app_data, desktop_apps)
if app:
self._launch_app(app)
except Exception as e:
logger.error(f"[AppBar] Failed to launch app: {e}")
def _launch_app(self, app):
try:
cleaned_command = re.sub(r"%\w+", "", app.command_line).strip()
final_command = f"hyprctl dispatch exec 'uwsm app -- {cleaned_command}'"
subprocess.Popen(final_command, shell=True)
except Exception:
try:
app.launch()
except Exception as fallback_error:
logger.error(f"[AppBar] Failed to launch app: {fallback_error}")
def _launch_app_from_data(self, app_data):
try:
command_line = app_data.get("command_line", "")
if command_line:
cleaned_command = re.sub(r"%\w+", "", command_line).strip()
final_command = f"hyprctl dispatch exec 'uwsm app -- {cleaned_command}'"
subprocess.Popen(final_command, shell=True)
elif app_data.get("executable"):
final_command = (
f"hyprctl dispatch exec 'uwsm app -- {app_data['executable']}'"
)
subprocess.Popen(final_command, shell=True)
else:
logger.error(
f"[AppBar] No command or executable found for app: {app_data}"
)
except Exception as e:
logger.error(f"[AppBar] Failed to launch app from data: {e}")
def _pin_app(self, app_class: str):
if self._is_app_pinned(app_class):
return False
try:
desktop_apps = get_desktop_applications(include_hidden=False)
app = self._find_desktop_app_by_id(app_class, desktop_apps)
if app:
app_data = {
"name": app.name,
"display_name": app.display_name or app.name,
"window_class": getattr(app, "window_class", None) or app_class,
"executable": app.executable,
"command_line": app.command_line,
}
else:
app_data = {
"name": app_class,
"display_name": app_class,
"window_class": app_class,
"executable": app_class,
"command_line": app_class,
}
self.pinned_apps.append(app_data)
except Exception:
self.pinned_apps.append(app_class)
write_json_file(self.pinned_apps, PINNED_APPS_FILE)
self._populate_pinned_apps()
return True
def _unpin_app(self, app_identifier: str):
apps_to_remove = []
for i, pinned_app in enumerate(self.pinned_apps):
if self._matches_app_identifier(pinned_app, app_identifier):
apps_to_remove.append(i)
for i in reversed(apps_to_remove):
self.pinned_apps.pop(i)
if apps_to_remove:
write_json_file(self.pinned_apps, PINNED_APPS_FILE)
self._populate_pinned_apps()
return True
return False
def _matches_app_identifier(self, pinned_app, app_identifier):
if not app_identifier:
return False
if isinstance(pinned_app, dict):
window_class = pinned_app.get("window_class") or ""
name = pinned_app.get("name") or ""
return (
window_class.lower() == app_identifier.lower()
or name.lower() == app_identifier.lower()
)
return (
isinstance(pinned_app, str) and pinned_app.lower() == app_identifier.lower()
)
def get_clients(self):
try:
clients_data = self._hyprland_connection.send_command("j/clients").reply
if not clients_data:
return []
return json.loads(clients_data.decode("utf-8"))
except Exception as e:
logger.error(f"[AppBar] Error getting clients: {e}")
return []
def get_focused_window(self):
try:
active_data = self._hyprland_connection.send_command("j/activewindow").reply
if not active_data:
return None
return json.loads(active_data.decode("utf-8"))
except Exception as e:
logger.error(f"[AppBar] Error getting focused window: {e}")
return None
def update_dock_apps(self):
try:
clients = self.get_clients()
focused_window = self.get_focused_window()
focused_address = focused_window.get("address", "") if focused_window else ""
current_instance_ids = set()
for client in clients:
if client.get("hidden", False) or not self._should_show_app_instance(client):
continue
instance_address = client.get("address", "")
app_class = client.get("class", "") or client.get("title", "")
if not instance_address or not app_class:
continue
current_instance_ids.add(instance_address)
if instance_address not in self.client_buttons:
self.create_instance_button(instance_address, client, app_class)
else:
self.update_instance_button(instance_address, client, app_class)
button = self.client_buttons[instance_address]
if instance_address == focused_address:
button.add_style_class("activated")
else:
button.remove_style_class("activated")
self._update_pinned_apps_state(clients)
self._update_separator_visibility()
self._cleanup_removed_instances(current_instance_ids)
except Exception as e:
logger.error(f"[AppBar] Error in update_dock_apps: {e}")
def _update_pinned_apps_state(self, clients):
running_app_classes = {
client.get("class", "").lower() or client.get("title", "").lower()
for client in clients
if not client.get("hidden", False)
and (client.get("class") or client.get("title"))
and self._should_show_app_instance(client)
}
for app_identifier, button in self.pinned_buttons.items():
# Skip trash button as it's not a regular app
if app_identifier == "trash" or hasattr(button, "is_trash"):
continue
if app_identifier.lower() in running_app_classes:
button.add_style_class("instance")
else:
button.remove_style_class("instance")
def _cleanup_removed_instances(self, current_instance_ids):
buttons_to_remove = [
instance_id
for instance_id in self.client_buttons.keys()
if instance_id not in current_instance_ids
]
# Clean up removed and orphaned buttons
for instance_id in buttons_to_remove + [k for k, v in self.client_buttons.items()
if not hasattr(v, 'instance_address') or not v.get_parent()]:
if instance_id in self.client_buttons:
button = self.client_buttons.pop(instance_id)
try:
if button in self.running_items_pos:
self.running_items_pos.remove(button)
button.remove_style_class("shown")
button.remove_style_class("activated")
if button.get_parent():
button.get_parent().remove(button)
button.destroy()
except Exception as e:
logger.warning(f"[AppBar] Error during cleanup: {e}")
def create_instance_button(self, instance_address, client, app_class):
try:
client_image = Image(name="dock_item_icon")
try:
desktop_apps = get_desktop_applications(include_hidden=False)
desktop_app = self._find_desktop_app_by_id(app_class, desktop_apps)
if desktop_app:
pixbuf = desktop_app.get_icon_pixbuf(data.DOCK_ICON_SIZE)
else:
pixbuf = self.icon_resolver.get_icon_pixbuf(
app_class, data.DOCK_ICON_SIZE
)
client_image.set_from_pixbuf(pixbuf)
except Exception as e:
logger.warning(f"[AppBar] Could not load icon for {app_class}: {e}")
workspace_id = self._get_workspace_id(client)
workspace_label = None
if workspace_id is not None:
workspace_label = Label(
label=str(workspace_id),
name="workspace-indicator",
h_align="end",
v_align="end",
)
image_overlay = Overlay(name="dock-image-overlay", child=client_image)
if workspace_label:
image_overlay.add_overlay(workspace_label)
indicator = Box(name="dock_item_indicator", h_align="center")
main_container = Box(
name="dock_item_main_container",
orientation="v",
children=[image_overlay, indicator],
)
tooltip_text = client.get("title", app_class)
if tooltip_text != app_class:
tooltip_text = f"{app_class}: {tooltip_text}"
client_button = Button(
name="dock_item",
child=main_container,
tooltip_text=tooltip_text,
on_button_press_event=lambda widget, event: self.handle_instance_click(
widget, event
),
on_enter_notify_event=lambda *_: self._handle_item_hovered(
client_button, False
),
on_leave_notify_event=lambda *_: self._handle_item_unhovered(
client_button, False
),
)
client_button.instance_address = instance_address
client_button.client_data = client
client_button.app_class = app_class
client_button.workspace_label = workspace_label
client_button.add_style_class("shown")
self.client_buttons[instance_address] = client_button
self.running_apps_container.add(client_button)
self.running_items_pos.append(client_button)
except Exception as e:
logger.error(f"[AppBar] Error creating instance button for {app_class}: {e}")
def _get_workspace_id(self, client):
workspace_data = client.get("workspace", {})
if isinstance(workspace_data, dict):
return workspace_data.get("id")
elif isinstance(workspace_data, (int, str)):
return workspace_data
return None
def _is_special_workspace_id(self, ws_id):
return is_special_workspace_id(ws_id)
def _should_show_app_instance(self, client):
if not data.DOCK_HIDE_SPECIAL_WORKSPACE_APPS:
return True
workspace_id = self._get_workspace_id(client)
if workspace_id is None:
return True
return not self._is_special_workspace_id(workspace_id)
def update_instance_button(self, instance_address, client, app_class):
if instance_address not in self.client_buttons:
return
button = self.client_buttons[instance_address]
button.client_data = client
button.app_class = app_class
tooltip_text = client.get("title", app_class)
if tooltip_text != app_class:
tooltip_text = f"{app_class}: {tooltip_text}"
button.set_tooltip_text(tooltip_text)
workspace_id = self._get_workspace_id(client)
existing_label = getattr(button, "workspace_label", None)
container = button.get_child()
if hasattr(container, "get_children"):
children = container.get_children()
if children:
image_overlay = children[0]
if isinstance(image_overlay, Overlay):
# Remove existing workspace label
if existing_label and existing_label.get_parent():
image_overlay.remove_overlay(existing_label)
# Add new workspace label if needed
if workspace_id is not None:
new_label = Label(
label=str(workspace_id),
name="workspace-indicator",
h_align="end",
v_align="end",
)
image_overlay.add_overlay(new_label)
button.workspace_label = new_label
else:
button.workspace_label = None
def handle_instance_click(self, button_widget, event):
instance_address = getattr(button_widget, "instance_address", None)
app_class = getattr(button_widget, "app_class", None)
if event.button == 1: # Left click - focus window
if instance_address:
try:
self._hyprland_connection.send_command(
f"dispatch focuswindow address:{instance_address}"
)
except Exception as e:
logger.error(f"[AppBar] Error focusing window: {e}")
elif event.button == 2: # Middle click - pin/unpin app
if app_class and not self._is_app_pinned(app_class):
self._pin_app(app_class)
elif event.button == 3: # Right click - context menu
if app_class:
self.show_menu(app_class, instance_address=instance_address)
self.menu.popup_at_pointer(event)
def _is_app_pinned(self, app_class: str) -> bool:
return any(
self._matches_app_identifier(pinned_app, app_class)
for pinned_app in self.pinned_apps
)
def _update_separator_visibility(self):
has_pinned_apps = len(self.pinned_items_pos) > 0
has_running_apps = len(self.running_items_pos) > 0
if has_pinned_apps and has_running_apps:
self.separator.remove_style_class("hidden")
else:
self.separator.add_style_class("hidden")
class Dock(Window):
def __init__(self):
if not data.DOCK_ENABLED:
anchor = self._get_anchor_from_position()
super().__init__(layer="top", title="dock", anchor=anchor)
self.children = Box() # Empty dock if disabled
return
anchor = self._get_anchor_from_position()
super().__init__(layer="top", anchor=anchor)
self.app_bar = AppBar(self)
transition_type = self._get_transition_type()
self.revealer = Revealer(
child=Box(children=[self.app_bar], style="padding: 20px 50px 5px 50px;"),
transition_duration=200,
transition_type=transition_type,
)
self.children = EventBox(
events=["enter-notify", "leave-notify"],
child=Box(style="min-height: 1px", children=self.revealer),
on_enter_notify_event=lambda *_: self.on_hover_enter(),
on_leave_notify_event=lambda *_: self.on_hover_leave(),
)
self.revealer.set_reveal_child(True)
self.app_bar.add_style_class("shown")
self.dock_height = 100
self.is_hovered = False
self.hide_timeout_id = None
# Only setup occlusion monitoring if auto-hide is enabled
if data.DOCK_AUTO_HIDE:
self.setup_occlusion_monitoring()
def on_hover_enter(self):
self.is_hovered = True
if self.hide_timeout_id:
GLib.source_remove(self.hide_timeout_id)
self.hide_timeout_id = None
self.revealer.set_reveal_child(True)
self.app_bar.add_style_class("shown")
def on_hover_leave(self):
self.is_hovered = False
# Add small delay before potential hiding to prevent rapid show/hide cycles
if self.hide_timeout_id:
GLib.source_remove(self.hide_timeout_id)
self.hide_timeout_id = GLib.timeout_add(100, lambda: None)
def _get_anchor_from_position(self):
if data.DOCK_POSITION == "Left":
return "left center"
elif data.DOCK_POSITION == "Right":
return "right center"
else: # Bottom (default)
return "bottom center"
def _get_transition_type(self):
if data.DOCK_POSITION == "Left":
return "slide-right"
elif data.DOCK_POSITION == "Right":
return "slide-left"
else: # Bottom (default)
return "slide-up"
def _get_occlusion_position(self):
if data.DOCK_POSITION == "Left":
return ("left", self.dock_height)
elif data.DOCK_POSITION == "Right":
return ("right", self.dock_height)
else: # Bottom (default)
return ("bottom", self.dock_height)
def setup_occlusion_monitoring(self):
def check_dock_occlusion():
try:
if data.DOCK_ALWAYS_OCCLUDED:
is_occluded = True
else:
occlusion_position = self._get_occlusion_position()
is_occluded = check_occlusion(occlusion_position)
if (
is_occluded
and not self.is_hovered
and self.revealer.get_reveal_child()
):
self.revealer.set_reveal_child(False)
self.app_bar.remove_style_class("shown")
elif not is_occluded and not self.revealer.get_reveal_child():
self.revealer.set_reveal_child(True)
self.app_bar.add_style_class("shown")
elif is_occluded and self.is_hovered:
if not self.revealer.get_reveal_child():
self.revealer.set_reveal_child(True)
self.app_bar.add_style_class("shown")
except Exception as e:
logger.error(f"[Dock] Occlusion check error: {e}")
return True
GLib.timeout_add(300, check_dock_occlusion)
================================================
FILE: modules/launcher/__init__.py
================================================
"""
Plugin-based launcher module for Fabric.
Similar to Albert Launcher with extensible plugin system.
"""
from .main import Launcher
__all__ = ["Launcher"]
================================================
FILE: modules/launcher/main.py
================================================
from typing import List, Optional, Tuple
from gi.repository import Gdk, GLib
from fabric.core.service import Property
from fabric.widgets.box import Box
from fabric.widgets.entry import Entry
from modules.launcher.plugin_manager import PluginManager
from modules.launcher.result import Result
from modules.launcher.result_item import ResultItem
from modules.launcher.trigger_config import TriggerConfig
from fabric.widgets.scrolledwindow import ScrolledWindow
from widgets.wayland import WaylandWindow as Window
# Constants
SEARCH_DEBOUNCE_MS = 50
TRIGGER_SEARCH_DEBOUNCE_MS = 50
CURSOR_POSITION_DELAY_MS = 10
SCROLL_PADDING = 10
DEFAULT_ITEM_HEIGHT = 68
PAGE_NAVIGATION_STEP = 5
LAUNCHER_WIDTH = 640
LAUNCHER_HEIGHT = 400
class Launcher(Window):
"""
Main launcher window with search functionality and plugin system.
Spotlight-style interface.
"""
# Properties
query = Property(str, flags="read-write", default_value="")
visible = Property(bool, flags="read-write", default_value=False)
active_trigger = Property(str, flags="read-write", default_value="")
def __init__(self, **kwargs):
super().__init__(
name="launcher-window",
title="modus-launcher",
layer="top",
anchor="center",
exclusivity="none",
keyboard_mode="exclusive",
visible=False, # Start hidden until explicitly shown
**kwargs,
)
self._initializing = True
self._auto_adding_space = False
self._processing_backspace = False
self.plugin_manager = PluginManager()
self.trigger_config = TriggerConfig()
self.results: List[Result] = []
self.selected_index = 0
self.max_results = 5 # Show 4 applications by default instead of list
# Trigger system
self.triggered_plugin = None # Currently active triggered plugin
self.active_trigger = "" # Currently active trigger keyword
self.query = "" # Current search query
self.visible = False # Launcher visibility state
self.opened_with_trigger = False # Whether launcher was opened with a trigger
# Focus management
self.focus_mode = "search" # "search", "results"
# Mouse activity tracking
self._mouse_active = False
self._last_mouse_activity = 0
self._mouse_activity_timeout = 1000 # 1 second timeout
self._keyboard_used_recently = False
self._last_keyboard_activity = 0
self._keyboard_activity_timeout = 2000 # 2 second timeout
self._launcher_just_opened = False
self._mouse_interaction_delay = 500 # 500ms delay after opening
# Setup UI
main_box = Box(
name="launcher",
orientation="v",
spacing=0,
h_align="center",
v_align="center",
)
self.add(main_box)
# Search entry with Spotlight-style large text
self.search_entry = Entry(
name="launcher-search",
placeholder="Spotlight Search",
h_expand=True,
h_align="fill",
notify_text=lambda entry, *_: self._on_search_changed(entry),
)
self.search_entry.connect("changed", self._on_search_changed)
self.search_entry.connect("activate", self._on_entry_activate)
self.header_box = Box(
name="header_box",
spacing=10,
orientation="h",
children=[
self.search_entry,
],
)
main_box.add(self.header_box)
self.results_scroll = ScrolledWindow(
name="launcher-results-scroll",
h_scrollbar_policy="never",
min_content_size=(LAUNCHER_WIDTH, LAUNCHER_HEIGHT),
max_content_size=(LAUNCHER_WIDTH, LAUNCHER_HEIGHT),
propagate_width=False,
propagate_height=False,
)
self.results_box = Box(
name="launcher-results",
orientation="v",
spacing=0,
)
self.results_scroll.add(self.results_box)
main_box.add(self.results_scroll)
# Keep the results container visible initially
self.connect("key-press-event", self._on_key_press)
# Connect mouse events to track activity
self.results_scroll.connect("button-press-event", self._on_mouse_activity)
self.results_scroll.connect("motion-notify-event", self._on_mouse_activity)
self.results_scroll.connect("scroll-event", self._on_mouse_activity)
# Also track mouse activity on the main window for better coverage
self.connect("button-press-event", self._on_mouse_activity)
self.connect("motion-notify-event", self._on_mouse_activity)
self.hide()
# Mark initialization as complete
self._initializing = False
# Hide trigger suggestions at startup
self._clear_results()
def show_launcher(self, trigger_keyword: str = None, external: bool = False):
"""Show the launcher and focus the search entry, or execute command externally.
Args:
trigger_keyword: Optional trigger keyword to activate immediately (e.g., "google", "calc", "app")
external: If True, execute the command without showing the launcher UI
"""
# Reset mouse activity tracking when launcher is shown
self._mouse_active = False
self._last_mouse_activity = 0
self._keyboard_used_recently = False
self._last_keyboard_activity = 0
self._launcher_just_opened = True
# Schedule to allow mouse interactions after a delay
def enable_mouse_interactions():
self._launcher_just_opened = False
return False
GLib.timeout_add(self._mouse_interaction_delay, enable_mouse_interactions)
if external and trigger_keyword:
# Execute command externally without showing launcher
return self._execute_external_command(trigger_keyword)
self.show_all()
if trigger_keyword:
# Set flag to track that launcher was opened with a trigger keyword
self.opened_with_trigger = True
# Set the trigger keyword with a space and activate trigger mode
trigger_text = f"{trigger_keyword} "
self.search_entry.set_text(trigger_text)
# Detect and activate the trigger
triggered_plugin, detected_trigger = self._detect_trigger(trigger_text)
if triggered_plugin:
self.triggered_plugin = triggered_plugin
self.active_trigger = detected_trigger
# Query the plugin with empty string to show default options
try:
results = triggered_plugin.query("")
self.results = results
self.selected_index = 0
self._update_results_display()
except Exception as e:
print(
f"Error querying triggered plugin {triggered_plugin.name}: {e}"
)
self._clear_results()
else:
# Trigger not found, clear and show error or fallback
self.search_entry.set_text("")
self._clear_results()
else:
# Normal launcher opening - show applications
self.opened_with_trigger = False
self.search_entry.set_text("")
# Trigger initial search to show applications immediately
self._perform_search("")
# Reset focus mode to search
self.focus_mode = "search"
# Focus search entry without selecting text
if trigger_keyword:
# For trigger keywords, we want the cursor at the end
self.search_entry.grab_focus()
def position_cursor():
if hasattr(self.search_entry, "set_position"):
self.search_entry.set_position(-1) # Move caret to end
return False # Only run once
GLib.idle_add(position_cursor)
else:
# For normal opening, use our method that prevents text selection
self._focus_search_entry_without_selection()
self.visible = True
def _position_cursor_at_end(self, text_length: Optional[int] = None) -> None:
"""Position cursor at the end of search entry text."""
if text_length is None:
text_length = len(self.search_entry.get_text())
def position_cursor():
if hasattr(self.search_entry, "set_position"):
self.search_entry.set_position(-1) # Move caret to end
if hasattr(self.search_entry, "select_region"):
self.search_entry.select_region(
text_length, text_length
) # No selection
return False # Only run once
GLib.idle_add(position_cursor)
def _add_space_to_trigger(self, trigger_word: str) -> None:
"""Add space after trigger keyword and position cursor."""
trigger_text_with_space = f"{trigger_word} "
# Temporarily disable search change handling to prevent recursion
self._auto_adding_space = True
self.search_entry.set_text(trigger_text_with_space)
# Position cursor at the end
def position_cursor():
if hasattr(self.search_entry, "set_position"):
self.search_entry.set_position(-1) # Move caret to end
if hasattr(self.search_entry, "select_region"):
self.search_entry.select_region(
len(trigger_text_with_space), len(trigger_text_with_space)
) # No selection
self._auto_adding_space = False
return False # Only run once
GLib.idle_add(position_cursor)
# Update query
self.query = trigger_text_with_space
return trigger_text_with_space
def close_launcher(self):
"""Hide the launcher and clear search."""
self.hide()
self.search_entry.set_text("")
self._clear_results()
self.triggered_plugin = None
self.active_trigger = ""
self.visible = False
self.opened_with_trigger = False
def _on_search_changed(self, entry):
"""Handle search text changes."""
# Skip if still initializing
if getattr(self, "_initializing", True):
return
# Skip if we're automatically adding a space to prevent recursion
if getattr(self, "_auto_adding_space", False):
return
# Skip if we're processing a backspace to prevent interference
if getattr(self, "_processing_backspace", False):
return
# Reset focus to search when user types
if self.focus_mode != "search":
self.focus_mode = "search"
query = entry.get_text().strip()
self.query = query
# If query is exactly ':', show all triggers
if query == ":":
self._show_available_triggers()
# If query matches a trigger exactly (with or without space), handle trigger activation
elif any(
query == trig or query == f"{trig} "
for trig in [
t.strip()
for p in self.plugin_manager.get_active_plugins()
for t in p.get_triggers()
]
):
# Check if we need to add space immediately for exact trigger matches
if not query.endswith(" "):
# This is an exact trigger match without space - add space immediately
trigger_text_with_space = self._add_space_to_trigger(query)
GLib.timeout_add(
TRIGGER_SEARCH_DEBOUNCE_MS,
self._perform_search,
trigger_text_with_space,
)
else:
# Already has space, proceed with normal search
GLib.timeout_add(SEARCH_DEBOUNCE_MS, self._perform_search, query)
elif query:
# Debounce search to avoid too many queries
GLib.timeout_add(SEARCH_DEBOUNCE_MS, self._perform_search, query)
else:
# Show applications when query is empty
self._perform_search("")
def _perform_search(self, query: str) -> bool:
"""Perform search across all plugins."""
# Only search if query hasn't changed
if query != self.query:
return False
if not query:
# Empty query - show popular applications
self.triggered_plugin = None
self.active_trigger = ""
# Get applications plugin and show popular apps
applications_plugin = self._get_applications_plugin()
if applications_plugin:
try:
# Query with empty string to get popular/frequently used applications
all_results = applications_plugin.query("")
# Limit to max_results for empty query
self.results = all_results[: self.max_results]
self.selected_index = 0
self._update_results_display()
except Exception as e:
print(f"Error getting applications: {e}")
self._clear_results()
else:
self._clear_results()
return False
# Check if we're already in trigger mode
if self.triggered_plugin and self.active_trigger:
# We're in trigger mode - search within the triggered plugin
try:
# Extract the search query after the trigger
remaining_query = self._extract_query_after_trigger(
query, self.active_trigger
)
all_results = self.triggered_plugin.query(remaining_query)
except Exception as e:
print(f"Error in triggered plugin {self.triggered_plugin.name}: {e}")
all_results = []
else:
# Check for trigger activation
triggered_plugin, trigger = self._detect_trigger(query)
if triggered_plugin:
# New trigger detected - check if we need to add space automatically
trigger_word = trigger.strip()
current_text = self.search_entry.get_text()
# If the current text is exactly the trigger word (no space), add space automatically
if (
current_text.strip() == trigger_word
and not current_text.endswith(" ")
and not getattr(self, "_auto_adding_space", False)
):
query = self._add_space_to_trigger(trigger_word)
# Enter trigger mode
self.triggered_plugin = triggered_plugin
self.active_trigger = trigger
# Extract search query after trigger
remaining_query = self._extract_query_after_trigger(query, trigger)
# Always call the plugin's query method, even with empty remaining query
# This allows plugins to show default options when just the trigger is typed
try:
all_results = triggered_plugin.query(remaining_query)
except Exception as e:
print(f"Error in triggered plugin {triggered_plugin.name}: {e}")
all_results = []
else:
# No trigger detected - search applications and show trigger suggestions
self.triggered_plugin = None
self.active_trigger = ""
all_results = []
# Search applications directly without trigger
applications_plugin = self._get_applications_plugin()
if applications_plugin:
try:
app_results = applications_plugin.query(query)
all_results.extend(app_results)
except Exception as e:
print(f"Error searching applications: {e}")
# Also show trigger suggestions if query matches trigger prefixes
trigger_suggestions = self._get_trigger_suggestions(query)
all_results.extend(trigger_suggestions)
# Sort results by relevance score
all_results.sort(key=lambda r: r.relevance, reverse=True)
# Check if any results have bypass_max_results flag
has_bypass = any(
hasattr(r, "data") and r.data and r.data.get("bypass_max_results")
for r in all_results
)
# Don't limit results for triggered plugin queries, only for global searches and trigger suggestions
if self.triggered_plugin and self.active_trigger:
# In trigger mode - show all results from the triggered plugin
self.results = all_results
elif not has_bypass:
# Global search - check if it's an application search
applications_plugin = self._get_applications_plugin()
is_application_search = applications_plugin and any(
hasattr(r, "plugin_name") and r.plugin_name == "Applications"
for r in all_results
)
if is_application_search:
# Don't limit application search results
self.results = all_results
else:
# Apply max_results limit for other global searches and trigger suggestions
self.results = all_results[: self.max_results]
else:
# Has bypass flag - show all results
self.results = all_results
self.selected_index = 0
# Update UI
self._update_results_display()
return False # Don't repeat timeout
def _extract_query_after_trigger(self, query: str, trigger: str) -> str:
"""
Extract the search query after removing the trigger.
Args:
query: The full query string
trigger: The trigger keyword
Returns:
The remaining query after the trigger
"""
if not query or not trigger:
return ""
query_lower = query.lower()
trigger_lower = trigger.lower()
# Handle trigger with space (e.g., "app ")
if trigger_lower.endswith(" ") and query_lower.startswith(trigger_lower):
return query[len(trigger) :].strip()
# Handle trigger without space (e.g., "app")
trigger_word = trigger.strip().lower()
if query_lower.startswith(trigger_word):
# Check if it's followed by space or end of string
if len(query) == len(trigger_word):
return ""
elif len(query) > len(trigger_word) and query[len(trigger_word)] == " ":
return query[len(trigger_word) + 1 :].strip()
elif len(query) > len(trigger_word):
# No space after trigger word, extract rest
return query[len(trigger_word) :].strip()
return ""
def _show_available_triggers(self):
"""Show available triggers when launcher is first opened."""
trigger_suggestions = self._get_trigger_suggestions("")
self.results = trigger_suggestions # Show all available triggers without limit
self.selected_index = 0
self._update_results_display()
def _detect_trigger(self, query: str) -> Tuple[Optional[object], str]:
"""
Detect if query starts with a trigger keyword.
Args:
query: The search query
Returns:
Tuple of (plugin, trigger) if triggered, (None, "") otherwise
"""
if not query.strip():
return None, ""
# Check all plugins for triggers
for plugin in self.plugin_manager.get_active_plugins():
trigger = plugin.get_active_trigger(query)
if trigger:
return plugin, trigger
return None, ""
def _get_applications_plugin(self):
"""Get the applications plugin instance."""
for plugin in self.plugin_manager.get_active_plugins():
if (
hasattr(plugin, "display_name")
and plugin.display_name == "Applications"
):
return plugin
return None
def _get_trigger_suggestions(self, query: str) -> List[Result]:
"""
Get trigger suggestions based on the current query.
Args:
query: The search query
Returns:
List of Result objects showing available triggers
"""
suggestions = []
query_lower = query.lower().strip()
# Get all available triggers from plugins
all_triggers = {}
for plugin in self.plugin_manager.get_active_plugins():
triggers = plugin.get_triggers()
for trigger in triggers:
trigger_clean = trigger.strip()
if trigger_clean not in all_triggers:
all_triggers[trigger_clean] = {
"plugin": plugin,
"trigger": trigger,
"examples": [],
}
# Get max examples to show from configuration
max_examples = self.trigger_config.settings.get("max_examples_shown", 2)
# Show trigger suggestions based on query
if query_lower:
# Show triggers that match the query
for trigger_clean, _ in all_triggers.items():
if trigger_clean.lower().startswith(query_lower):
result = self._create_trigger_result(trigger_clean, max_examples)
suggestions.append(result)
else:
# Empty query - show all available triggers
for trigger_clean, _ in all_triggers.items():
result = self._create_trigger_result(trigger_clean, max_examples)
suggestions.append(result)
return suggestions # Return all trigger suggestions without limit
def _create_trigger_result(self, trigger_clean: str, max_examples: int) -> Result:
"""Create a Result object for a trigger suggestion."""
examples = self.trigger_config.get_trigger_examples(trigger_clean)
icon_name = self.trigger_config.get_trigger_icon(trigger_clean)
description = self.trigger_config.get_trigger_description(trigger_clean)
return Result(
title=f"{trigger_clean}",
subtitle=f"{description} - {', '.join(examples[:max_examples])}",
icon_name=icon_name,
action=lambda t=trigger_clean: self._activate_trigger(t),
# Shorter triggers get higher relevance
relevance=100 - len(trigger_clean),
data={"type": "trigger_suggestion", "trigger": trigger_clean},
)
def _activate_trigger(self, trigger: str):
"""
Activate a trigger by setting it in the search entry.
Args:
trigger: The trigger keyword to activate
"""
# Set the trigger text in the search entry
trigger_text = f"{trigger} "
self.search_entry.set_text(trigger_text)
self.search_entry.grab_focus()
def clear_selection():
if hasattr(self.search_entry, "set_position"):
self.search_entry.set_position(-1) # Move caret to end
if hasattr(self.search_entry, "select_region"):
self.search_entry.select_region(
len(trigger_text), len(trigger_text)
) # No selection
return False # Only run once
GLib.idle_add(clear_selection)
# Manually set the trigger mode to avoid search processing issues
triggered_plugin, detected_trigger = self._detect_trigger(trigger_text)
if triggered_plugin:
self.triggered_plugin = triggered_plugin
self.active_trigger = detected_trigger
# Clear results and show trigger ready state
self.results = []
self.selected_index = 0
self._update_results_display()
# Focus back to the search entry for immediate typing
self.search_entry.grab_focus()
# Don't hide the launcher - user should be able to continue typing
def _execute_external_command(self, command_string: str):
"""Execute a command externally without showing the launcher UI.
Args:
command_string: Full command string (e.g., "wall random", "calc 2+2")
Returns:
Result of the command execution or None if failed
"""
try:
# Parse the command to extract trigger and query
parts = command_string.strip().split(" ", 1)
if not parts:
return None
trigger_part = parts[0]
query_part = parts[1] if len(parts) > 1 else ""
# Find the plugin that handles this trigger
triggered_plugin = None
for plugin in self.plugin_manager.get_active_plugins():
trigger = plugin.get_active_trigger(f"{trigger_part} ")
if trigger:
triggered_plugin = plugin
break
if not triggered_plugin:
print(f"No plugin found for trigger: {trigger_part}")
return None
# Query the plugin with the remaining query
try:
results = triggered_plugin.query(query_part)
if not results:
print(f"No results found for query: {query_part}")
return None
# Find the first result that matches the query exactly or has highest relevance
best_result = None
for result in results:
# For exact matches like "random", execute immediately
if (
hasattr(result, "data")
and result.data
and result.data.get("action") == query_part.strip()
):
best_result = result
break
# For partial matches, take the first high-relevance result
elif not best_result and result.relevance >= 0.9:
best_result = result
# If no exact match, take the first result
if not best_result and results:
best_result = results[0]
if best_result:
# Execute the result action
try:
result_value = best_result.activate()
print(f"External command executed: {command_string}")
return result_value
except Exception as e:
print(f"Error executing result action: {e}")
return None
else:
print(f"No suitable result found for: {command_string}")
return None
except Exception as e:
print(f"Error querying plugin {triggered_plugin.name}: {e}")
return None
except Exception as e:
print(f"Error executing external command '{command_string}': {e}")
return None
def _update_results_display(self):
"""Update the results display."""
# Skip if still initializing or results_box not ready
if getattr(self, "_initializing", True) or not hasattr(self, "results_box"):
return
# Update input field with trigger indication (Spotlight-style)
self._update_input_action_text()
# Clear existing results
for child in self.results_box.get_children():
self.results_box.remove(child)
# Add new results
for i, result in enumerate(self.results):
# Check if this result has a custom widget
if result.custom_widget:
# Ensure the widget is not already parented
parent = result.custom_widget.get_parent()
if parent:
parent.remove(result.custom_widget)
result.custom_widget.show_all() # Ensure widget is visible
self.results_box.add(result.custom_widget)
else:
# Create normal result item
result_item = ResultItem(
result=result, selected=(i == self.selected_index), index=i
)
result_item.clicked.connect(
lambda _, idx=i: self._on_result_clicked(result_item, idx)
)
result_item.hovered.connect(
lambda _, idx=i: self._on_result_hovered(idx)
)
self.results_box.add(result_item)
self.results_box.show_all()
self.results_scroll.show()
def _update_input_action_text(self):
"""Update the input field with action text (Spotlight-style)."""
# Check if search_entry is initialized
if not hasattr(self, "search_entry") or self.search_entry is None:
return
# Get the current input text
current_text = self.search_entry.get_text()
# Spotlight-style placeholder text
if self.triggered_plugin:
if self.results:
# Show more minimal placeholder for triggered mode
self.search_entry.set_placeholder_text(
f"Search {self.active_trigger.strip()}..."
)
else:
# In trigger mode but no results yet
if current_text == self.active_trigger.strip():
# Just the trigger keyword
self.search_entry.set_placeholder_text(
f"Search {self.active_trigger.strip()}..."
)
else:
# Searching within trigger
self.search_entry.set_placeholder_text(
f"Searching {self.active_trigger.strip()}..."
)
else:
# Not in trigger mode - show Spotlight-style help
if current_text == ":":
self.search_entry.set_placeholder_text("Available search triggers")
else:
self.search_entry.set_placeholder_text("Spotlight Search")
def _clear_results(self):
"""Clear all results."""
self.results = []
self.selected_index = 0
for child in self.results_box.get_children():
self.results_box.remove(child)
# Keep the results scroll visible even when empty
def _handle_escape_key(self) -> bool:
"""Handle escape key press."""
# First check if there's a password entry widget that should handle Escape
password_entry_widget = self._find_password_entry_widget()
if password_entry_widget:
# Cancel the password entry
password_entry_widget.cancel_password_entry()
return True
elif self.opened_with_trigger:
# If launcher was opened with a trigger keyword, close directly
self.close_launcher()
return True
else:
# Hide launcher
self.close_launcher()
return True
def _handle_backspace_key(self) -> bool:
"""Handle backspace key press in trigger mode."""
if self.triggered_plugin:
trigger_text = self.active_trigger.strip()
current_text = self.search_entry.get_text()
# Set flag to prevent search change handler from interfering
self._processing_backspace = True
# Allow normal backspace behavior first, then check if we need to exit trigger mode
# Don't intercept the backspace - let GTK handle it normally
# Schedule a check after the backspace is processed
def check_trigger_after_backspace():
try:
# Get the text after backspace has been processed
new_text = self.search_entry.get_text()
# Check if we should exit trigger mode
# We need to check if the text still matches the trigger pattern
should_exit_trigger = False
# If the active trigger ends with a space (like "calc "),
# we should exit if the text doesn't contain that space anymore
if self.active_trigger.endswith(" "):
# For triggers like "calc ", exit if text is just "calc" or doesn't start with "calc "
trigger_with_space = self.active_trigger.lower()
if not new_text.lower().startswith(trigger_with_space):
should_exit_trigger = True
else:
# For triggers without space, exit if text doesn't start with trigger
if not new_text.lower().startswith(trigger_text.lower()):
should_exit_trigger = True
if should_exit_trigger:
self.triggered_plugin = None
self.active_trigger = ""
self._clear_results()
# Don't clear the text - let the user's edit stand
# If we're still in trigger mode but the text changed, update the search
elif self.triggered_plugin and new_text != current_text:
# Trigger a search with the new text
self.query = new_text
GLib.timeout_add(50, self._perform_search, new_text)
finally:
# Clear the backspace processing flag
self._processing_backspace = False
return False # Don't repeat
# Use idle_add to check after the backspace is processed
GLib.idle_add(check_trigger_after_backspace)
# Allow the normal backspace to proceed
return False
# Let normal backspace behavior continue for other cases
return False
def _on_key_press(self, _widget, event):
"""Handle key press events."""
# Track keyboard activity
import time
current_time = int(time.time() * 1000)
self._last_keyboard_activity = current_time
self._keyboard_used_recently = True
keyval = event.keyval
# Escape - handle password entry, exit trigger mode, or hide launcher
if keyval == Gdk.KEY_Escape:
return self._handle_escape_key()
# Backspace - handle trigger mode backspace behavior
if keyval == Gdk.KEY_BackSpace:
return self._handle_backspace_key()
# Up/Down - navigate results (alternative to Tab)
if keyval == Gdk.KEY_Up:
if self.focus_mode == "results" and self.results:
if self.selected_index > 0:
# Move to previous result
self.selected_index -= 1
self._update_selection()
else:
# At first result, go back to search entry
self.focus_mode = "search"
self._focus_search_entry_without_selection()
elif self.results:
# If not in results mode but have results, enter results mode at last item
self.focus_mode = "results"
self.selected_index = len(self.results) - 1
self._update_selection()
return True
if keyval == Gdk.KEY_Down:
if self.focus_mode == "results" and self.results:
if self.selected_index < len(self.results) - 1:
# Move to next result
self.selected_index += 1
self._update_selection()
else:
# At last result, wrap around to first result
self.selected_index = 0
self._update_selection()
elif self.results:
# If not in results mode but have results, enter results mode at first item
self.focus_mode = "results"
self.selected_index = 0
self._update_selection()
return True
# Enter - activate selected result
if keyval == Gdk.KEY_Return:
# Check for Shift+Enter for alternative actions
if event.state & Gdk.ModifierType.SHIFT_MASK:
if self.results and 0 <= self.selected_index < len(self.results):
result = self.results[self.selected_index]
if result.data:
# Check for generic alternative action first
if result.data.get("alt_action"):
result.data["alt_action"]()
return True
# Fallback to pin_action for backward compatibility
elif result.data.get("pin_action"):
result.data["pin_action"]()
return True
# Check if the selected result has a custom widget with Entry fields
if self.results and 0 <= self.selected_index < len(self.results):
result = self.results[self.selected_index]
if result.custom_widget:
# Check if the custom widget contains Entry widgets that should handle Enter
if self._custom_widget_has_entry(result.custom_widget):
# Let the custom widget handle the Enter key
# Find the focused Entry widget and trigger its activate signal
focused_entry = self._find_focused_entry_in_widget(
result.custom_widget
)
if focused_entry:
focused_entry.emit("activate")
return True
# Normal Enter behavior
self._activate_selected()
return True
# Tab - cycle through focus areas and results
if keyval == Gdk.KEY_Tab:
if event.state & Gdk.ModifierType.SHIFT_MASK:
# Shift+Tab - reverse direction
if self.focus_mode == "results":
# Navigate through results in reverse
self._navigate_results_backward()
else:
self._cycle_focus_backward()
else:
# Tab - forward direction
if self.focus_mode == "results":
# Navigate through results forward
self._navigate_results_forward()
else:
self._cycle_focus_forward()
return True
# Page Up/Page Down - navigate results faster
if keyval == Gdk.KEY_Page_Up:
if self.results:
self.selected_index = max(0, self.selected_index - PAGE_NAVIGATION_STEP)
self._update_selection()
return True
if keyval == Gdk.KEY_Page_Down:
if self.results:
self.selected_index = min(
len(self.results) - 1, self.selected_index + PAGE_NAVIGATION_STEP
)
self._update_selection()
return True
# Home/End - go to first/last result
if keyval == Gdk.KEY_Home:
if self.results:
self.selected_index = 0
self._update_selection()
return True
if keyval == Gdk.KEY_End:
if self.results:
self.selected_index = len(self.results) - 1
self._update_selection()
return True
# Forward other keys to custom widgets if they can handle them
if self.results and 0 <= self.selected_index < len(self.results):
result = self.results[self.selected_index]
if result.custom_widget and hasattr(result.custom_widget, "on_key_press"):
# Try to forward the key event to the custom widget
if result.custom_widget.on_key_press(result.custom_widget, event):
return True
return False
def _custom_widget_has_entry(self, widget):
"""Check if a custom widget contains Entry widgets."""
if isinstance(widget, Entry):
return True
# Check children recursively
if hasattr(widget, "get_children"):
for child in widget.get_children():
if self._custom_widget_has_entry(child):
return True
return False
def _find_focused_entry_in_widget(self, widget):
"""Find the focused Entry widget within a custom widget."""
if isinstance(widget, Entry):
# Try multiple ways to check if this Entry is focused
try:
if widget.has_focus() or widget.is_focus():
return widget
# Also check if this is the only Entry in the widget (likely to be the target)
return widget
except:
# If focus checking fails, assume this Entry should handle the event
return widget
# Check children recursively
if hasattr(widget, "get_children"):
for child in widget.get_children():
focused_entry = self._find_focused_entry_in_widget(child)
if focused_entry:
return focused_entry
return None
def _find_password_entry_widget(self):
"""Find a NetworkPasswordEntry widget in the current results."""
for result in self.results:
if result.custom_widget:
# Check if this is a NetworkPasswordEntry widget
if (
hasattr(result.custom_widget, "__class__")
and result.custom_widget.__class__.__name__
== "NetworkPasswordEntry"
):
return result.custom_widget
# Also check if it has the cancel_password_entry method (duck typing)
elif hasattr(result.custom_widget, "cancel_password_entry"):
return result.custom_widget
return None
def _on_entry_activate(self, _entry):
"""Handle entry activation (Enter key)."""
self._activate_selected()
def _on_result_clicked(self, _result_item, index):
"""Handle result item click."""
# Only allow clicks when mouse is active or keyboard hasn't been used recently
if self._should_allow_mouse_interaction():
self.selected_index = index
self._activate_selected()
def _on_result_hovered(self, index):
"""Handle result item hover."""
# Only allow hover selection when mouse is active and keyboard hasn't been used recently
if self._should_allow_mouse_interaction():
if 0 <= index < len(self.results):
self.selected_index = index
self.focus_mode = "results" # Switch to results focus mode
self._update_selection_visual_only()
def _on_mouse_activity(self, widget, event):
"""Track mouse activity to determine when mouse interactions should be enabled."""
import time
current_time = int(time.time() * 1000)
self._last_mouse_activity = current_time
self._mouse_active = True
# Schedule a check to disable mouse activity after timeout
def check_mouse_timeout():
if current_time - self._last_mouse_activity >= self._mouse_activity_timeout:
self._mouse_active = False
return False
GLib.timeout_add(self._mouse_activity_timeout, check_mouse_timeout)
return False
def _should_allow_mouse_interaction(self):
"""Determine if mouse interactions should be allowed."""
import time
current_time = int(time.time() * 1000)
# Don't allow mouse interactions immediately after launcher opens
if self._launcher_just_opened:
return False
# Allow mouse interaction if:
# 1. Mouse is currently active (recent activity)
# 2. OR keyboard hasn't been used recently (2 seconds)
mouse_recently_active = (
current_time - self._last_mouse_activity
) < self._mouse_activity_timeout
keyboard_not_recently_used = (
current_time - self._last_keyboard_activity
) > self._keyboard_activity_timeout
return mouse_recently_active or keyboard_not_recently_used
def _is_mouse_over_results(self):
"""Check if mouse is over the results area."""
# Simple check - if we have results visible, assume user might be interacting
return len(self.results) > 0 and self.results_scroll.get_visible()
def _update_selection(self):
"""Update the visual selection of results with scrolling (for keyboard navigation)."""
children = self.results_box.get_children()
selected_widget = None
for i, child in enumerate(children):
if isinstance(child, ResultItem):
is_selected = i == self.selected_index
child.set_selected(is_selected)
if is_selected:
selected_widget = child
# For custom widgets, we don't need to handle selection visually
# since they manage their own interaction
# Focus custom widgets when selected for keyboard interaction
if self.results and 0 <= self.selected_index < len(self.results):
result = self.results[self.selected_index]
if result.custom_widget and result.custom_widget.get_can_focus():
# Give focus to the custom widget for keyboard interaction
result.custom_widget.grab_focus()
# Scroll to make the selected item visible
if selected_widget and self.results_scroll.get_visible():
# Use immediate scrolling for better responsiveness
self._scroll_to_widget(selected_widget)
# Also schedule a more accurate scroll after layout is complete
GLib.idle_add(self._ensure_selected_visible)
def _update_selection_visual_only(self):
"""Update the visual selection of results without scrolling (for mouse hover)."""
children = self.results_box.get_children()
for i, child in enumerate(children):
if isinstance(child, ResultItem):
is_selected = i == self.selected_index
child.set_selected(is_selected)
# For custom widgets, we don't need to handle selection visually
# since they manage their own interaction
# Focus custom widgets when selected for keyboard interaction
if self.results and 0 <= self.selected_index < len(self.results):
result = self.results[self.selected_index]
if result.custom_widget and result.custom_widget.get_can_focus():
# Give focus to the custom widget for keyboard interaction
result.custom_widget.grab_focus()
def _scroll_to_widget(self, widget):
"""Scroll the results container to make the widget visible."""
if not widget or not self.results_scroll.get_visible():
return
# Debug output (can be removed in production)
# print(f"Scrolling to selected index: {self.selected_index}")
# Get the scrolled window's vertical adjustment
vadjustment = self.results_scroll.get_vadjustment()
if not vadjustment:
return
# Use a simpler approach: scroll to the selected item index
if self.results and 0 <= self.selected_index < len(self.results):
# Get all children to work with actual widgets
children = self.results_box.get_children()
if not children or self.selected_index >= len(children):
return
# Get the selected child widget
selected_child = children[self.selected_index]
# Try to get actual allocation, fallback to estimation
try:
allocation = selected_child.get_allocation()
item_height = allocation.height if allocation.height > 0 else 68
item_y = allocation.y
except:
# Fallback to estimation
item_height = DEFAULT_ITEM_HEIGHT
item_y = self.selected_index * item_height
# Get current scroll info
current_scroll = vadjustment.get_value()
page_size = vadjustment.get_page_size()
max_scroll = vadjustment.get_upper() - page_size
# Calculate visible area
visible_top = current_scroll
visible_bottom = current_scroll + page_size
# Check if selected item is visible
item_top = item_y
item_bottom = item_y + item_height
# Add some padding for better visibility
# Scroll if needed
if item_top < visible_top + SCROLL_PADDING:
# Item is above visible area - scroll up
new_scroll = max(0, item_top - SCROLL_PADDING)
vadjustment.set_value(new_scroll)
elif item_bottom > visible_bottom - SCROLL_PADDING:
# Item is below visible area - scroll down
new_scroll = min(max_scroll, item_bottom - page_size + SCROLL_PADDING)
vadjustment.set_value(new_scroll)
def _ensure_selected_visible(self):
"""Alternative method to ensure selected item is visible using GTK methods."""
if (
not self.results
or self.selected_index < 0
or self.selected_index >= len(self.results)
):
return False
children = self.results_box.get_children()
if self.selected_index < len(children):
selected_child = children[self.selected_index]
# Try to use widget's allocation for more accurate scrolling
try:
allocation = selected_child.get_allocation()
if allocation.height > 0:
vadjustment = self.results_scroll.get_vadjustment()
if vadjustment:
# Calculate the position to center the selected item
page_size = vadjustment.get_page_size()
target_pos = (
allocation.y - (page_size / 2) + (allocation.height / 2)
)
target_pos = max(
0, min(target_pos, vadjustment.get_upper() - page_size)
)
vadjustment.set_value(target_pos)
except Exception as e:
print(f"Error in _ensure_selected_visible: {e}")
return False # Don't repeat the idle callback
def _cycle_focus_forward(self):
"""Cycle focus forward: search -> results"""
if self.focus_mode == "search":
if self.results:
self.focus_mode = "results"
self._update_selection()
def _cycle_focus_backward(self):
"""Cycle focus backward: results -> search"""
if self.focus_mode == "results":
self.focus_mode = "search"
self._focus_search_entry_without_selection()
def _focus_search_entry_without_selection(self):
"""Focus search entry and position cursor at end without selecting text."""
# First grab focus
self.search_entry.grab_focus()
# Use multiple approaches to prevent text selection
def clear_selection():
try:
text_length = len(self.search_entry.get_text())
# Method 1: Set position and clear selection
if hasattr(self.search_entry, "set_position"):
self.search_entry.set_position(text_length)
if hasattr(self.search_entry, "select_region"):
self.search_entry.select_region(text_length, text_length)
# Method 2: Try to access underlying GTK widget
try:
# For fabric Entry widgets, try to get the actual GTK Entry
if hasattr(self.search_entry, "_entry"):
gtk_entry = self.search_entry._entry
elif (
hasattr(self.search_entry, "get_children")
and self.search_entry.get_children()
):
gtk_entry = self.search_entry.get_children()[0]
else:
gtk_entry = self.search_entry
if hasattr(gtk_entry, "set_position"):
gtk_entry.set_position(text_length)
if hasattr(gtk_entry, "select_region"):
gtk_entry.select_region(text_length, text_length)
except Exception:
pass
except Exception as e:
print(f"Could not clear selection: {e}")
return False
# Schedule clearing selection after focus is established
GLib.idle_add(clear_selection)
# Also try with a small delay as backup
GLib.timeout_add(CURSOR_POSITION_DELAY_MS, clear_selection)
def _navigate_results_forward(self):
"""Navigate to next result or wrap around to first result."""
if self.results and self.selected_index < len(self.results) - 1:
# Move to next result
self.selected_index += 1
self._update_selection()
else:
# At last result, wrap around to first result
self.selected_index = 0
self._update_selection()
def _navigate_results_backward(self):
"""Navigate to previous result or exit results mode."""
if self.results and self.selected_index > 0:
# Move to previous result
self.selected_index -= 1
self._update_selection()
else:
# Exit results mode and go to search
self.focus_mode = "search"
self._focus_search_entry_without_selection()
def _activate_selected(self):
"""Activate the currently selected result."""
if self.results and 0 <= self.selected_index < len(self.results):
result = self.results[self.selected_index]
try:
# Check if this result has a custom widget
if result.custom_widget:
# For custom widgets, we don't activate them since they're already displayed
# The widget handles its own interactions
return
# Check if this is a trigger suggestion or should keep launcher open
is_trigger_suggestion = (
result.data and result.data.get("type") == "trigger_suggestion"
)
keep_launcher_open = result.data and result.data.get(
"keep_launcher_open", False
)
# Activate the result
result.activate()
# Only hide launcher if it's not a trigger suggestion and doesn't have keep_launcher_open flag
if not is_trigger_suggestion and not keep_launcher_open:
self.close_launcher()
# For trigger suggestions and keep_launcher_open actions, the launcher stays open
except Exception as e:
print(f"Error activating result: {e}")
================================================
FILE: modules/launcher/plugin_base.py
================================================
from abc import ABC, abstractmethod
from typing import List
from modules.launcher.result import Result
class PluginBase(ABC):
"""
Abstract base class for launcher plugins.
All plugins must inherit from this class.
"""
def __init__(self):
self.name = self.__class__.__name__.lower()
self.display_name = self.__class__.__name__
self.description = "A launcher plugin"
self.version = "1.0.0"
self.enabled = True
self._triggers = [] # List of trigger keywords
@abstractmethod
def initialize(self):
"""
Initialize the plugin.
Called when the plugin is activated.
"""
pass
@abstractmethod
def cleanup(self):
"""
Cleanup the plugin.
Called when the plugin is deactivated.
"""
pass
@abstractmethod
def query(self, query_string: str) -> List[Result]:
"""
Process a search query and return results.
Args:
query_string: The search query from the user
Returns:
List of Result objects
"""
pass
def get_triggers(self) -> List[str]:
"""
Get list of trigger keywords for this plugin.
If the query starts with any of these, this plugin gets priority.
Returns:
List of trigger strings (e.g., ["calc", "=", "math"])
"""
return self._triggers
def set_triggers(self, triggers: List[str]):
"""
Set the trigger keywords for this plugin.
Args:
triggers: List of trigger keywords
"""
self._triggers = triggers
def handles_query(self, query_string: str) -> bool:
"""
Check if this plugin should handle the given query.
Args:
query_string: The search query
Returns:
True if this plugin should process the query
"""
if not self.enabled:
return False
# Check triggers
triggers = self.get_triggers()
if triggers:
query_lower = query_string.lower().strip()
return any(query_lower.startswith(trigger.lower()) for trigger in triggers)
# Default: handle all queries
return True
def get_active_trigger(self, query_string: str) -> str:
"""
Get the active trigger for the given query.
Args:
query_string: The search query
Returns:
The trigger keyword if found, empty string otherwise
"""
if not self.enabled:
return ""
triggers = self.get_triggers()
if triggers:
query_lower = query_string.lower().strip()
# Sort triggers by length (longest first) to match more specific triggers first
sorted_triggers = sorted(triggers, key=len, reverse=True)
for trigger in sorted_triggers:
trigger_lower = trigger.lower()
# Exact match with trigger (including space if present)
if query_lower.startswith(trigger_lower):
return trigger
# Match trigger word followed by space
trigger_word = trigger.strip().lower()
if (
query_lower.startswith(trigger_word + " ")
or query_lower == trigger_word
):
return trigger
return ""
def query_triggered(self, query_string: str, trigger: str) -> List[Result]:
"""
Process a triggered query (when plugin is in sticky mode).
Default implementation removes trigger and calls query().
Args:
query_string: The full query string including trigger
trigger: The trigger that activated this plugin
Returns:
List of Result objects
"""
# Remove trigger from query and process remaining text
remaining_query = query_string[len(trigger) :].strip()
return self.query(remaining_query)
def get_config(self) -> dict:
"""
Get plugin configuration.
Returns:
Dictionary of configuration options
"""
return {
"name": self.name,
"display_name": self.display_name,
"description": self.description,
"version": self.version,
"enabled": self.enabled,
}
def set_config(self, config: dict):
"""
Set plugin configuration.
Args:
config: Dictionary of configuration options
"""
self.enabled = config.get("enabled", self.enabled)
def __str__(self):
return f"Plugin({self.name})"
def __repr__(self):
return self.__str__()
================================================
FILE: modules/launcher/plugin_manager.py
================================================
import importlib
import importlib.util
import os
from typing import Dict, List, Type
from modules.launcher.plugin_base import PluginBase
class PluginManager:
"""
Manages launcher plugins.
"""
def __init__(self):
self.plugins: Dict[str, PluginBase] = {}
self.plugin_classes: Dict[str, Type[PluginBase]] = {}
self.active_plugins: List[str] = []
# Load built-in plugins
self._load_builtin_plugins()
# Load external plugins
self._load_external_plugins()
# Activate default plugins
self._activate_default_plugins()
def _load_builtin_plugins(self):
"""Load built-in plugins from the plugins directory."""
plugins_dir = os.path.join(os.path.dirname(__file__), "plugins")
if not os.path.exists(plugins_dir):
return
for filename in os.listdir(plugins_dir):
if filename.endswith(".py") and not filename.startswith("_"):
plugin_name = filename[:-3] # Remove .py extension
self._load_plugin_from_file(plugins_dir, plugin_name)
def _load_external_plugins(self):
"""Load external plugins from user directory."""
# Could be implemented to load from ~/.config/launcher/plugins/
pass
def _load_plugin_from_file(self, plugins_dir: str, plugin_name: str):
"""Load a plugin from a Python file."""
try:
plugin_path = os.path.join(plugins_dir, f"{plugin_name}.py")
spec = importlib.util.spec_from_file_location(
f"modules.launcher.plugins.{plugin_name}", plugin_path
)
if spec and spec.loader:
module = importlib.util.module_from_spec(spec)
# Add the module to sys.modules to support relative imports
import sys
sys.modules[f"modules.launcher.plugins.{plugin_name}"] = module
spec.loader.exec_module(module)
# Look for plugin class
for attr_name in dir(module):
attr = getattr(module, attr_name)
if (
isinstance(attr, type)
and issubclass(attr, PluginBase)
and attr != PluginBase
):
self.plugin_classes[plugin_name] = attr
break
except Exception as e:
print(f"Failed to load plugin {plugin_name}: {e}")
def _activate_default_plugins(self):
"""Activate default plugins."""
default_plugins = [
"applications",
"calculator",
"system",
"clipboard",
"power",
"caffeine",
"screencapture",
"emoji",
"wallpaper",
"websearch",
"reminders",
"otp",
"password",
"bookmarks",
"bash_scripts",
"tmux",
]
for plugin_name in default_plugins:
self.activate_plugin(plugin_name)
def activate_plugin(self, plugin_name: str) -> bool:
"""Activate a plugin by name."""
if plugin_name in self.plugins:
# Already activated
return True
if plugin_name not in self.plugin_classes:
return False
try:
# Instantiate plugin
plugin_class = self.plugin_classes[plugin_name]
plugin_instance = plugin_class()
# Initialize plugin
plugin_instance.initialize()
# Store plugin
self.plugins[plugin_name] = plugin_instance
self.active_plugins.append(plugin_name)
return True
except Exception as e:
print(f"Failed to activate plugin {plugin_name}: {e}")
return False
def deactivate_plugin(self, plugin_name: str) -> bool:
"""Deactivate a plugin by name."""
if plugin_name not in self.plugins:
return False
try:
# Cleanup plugin
plugin = self.plugins[plugin_name]
plugin.cleanup()
# Remove from active plugins
del self.plugins[plugin_name]
if plugin_name in self.active_plugins:
self.active_plugins.remove(plugin_name)
return True
except Exception as e:
print(f"Failed to deactivate plugin {plugin_name}: {e}")
return False
def get_active_plugins(self) -> List[PluginBase]:
"""Get list of active plugin instances."""
return [
self.plugins[name] for name in self.active_plugins if name in self.plugins
]
def get_plugin_names(self) -> List[str]:
"""Get list of available plugin names."""
return list(self.plugin_classes.keys())
def get_active_plugin_names(self) -> List[str]:
"""Get list of active plugin names."""
return self.active_plugins.copy()
def reload_plugin(self, plugin_name: str) -> bool:
"""Reload a plugin."""
was_active = plugin_name in self.active_plugins
# Deactivate if active
if was_active:
self.deactivate_plugin(plugin_name)
# Remove from classes
if plugin_name in self.plugin_classes:
del self.plugin_classes[plugin_name]
# Reload from file
plugins_dir = os.path.join(os.path.dirname(__file__), "plugins")
self._load_plugin_from_file(plugins_dir, plugin_name)
# Reactivate if it was active
if was_active and plugin_name in self.plugin_classes:
return self.activate_plugin(plugin_name)
return plugin_name in self.plugin_classes
================================================
FILE: modules/launcher/plugins/__init__.py
================================================
"""
Built-in plugins for the launcher.
"""
================================================
FILE: modules/launcher/plugins/applications.py
================================================
import json
import re
from typing import List
import subprocess
from fabric.utils import DesktopApp
from fabric.utils.helpers import get_desktop_applications, get_relative_path
from modules.launcher.plugin_base import PluginBase
from modules.launcher.result import Result
from utils.roam import modus_service
class ApplicationsPlugin(PluginBase):
def __init__(self):
super().__init__()
self.display_name = "Applications"
self.description = "Search and launch desktop applications"
def initialize(self):
pass
def cleanup(self):
pass
def _pin_application(self, app):
"""Pin an application to the dock."""
config_path = get_relative_path("../../../config/assets/dock.json")
try:
with open(config_path, "r") as file:
pinned_apps = json.load(file)
except (FileNotFoundError, json.JSONDecodeError):
pinned_apps = []
# Handle legacy format (dict with "pinned_apps" key) and convert to new format (simple list)
if isinstance(pinned_apps, dict):
pinned_apps = pinned_apps.get("pinned_apps", [])
# Check if app is already pinned (by name/app_id)
app_id = app.name # Use app.name as the identifier
if app_id not in pinned_apps:
pinned_apps.append(app_id)
# Save the updated list
with open(config_path, "w") as file:
json.dump(pinned_apps, file, indent=4)
# Notify dock about the change via modus_service
if modus_service:
try:
dock_apps_json = json.dumps(pinned_apps)
modus_service.dock_apps = dock_apps_json
except Exception as e:
print(f"Failed to notify dock about pinned app change: {e}")
def query(self, query_string: str) -> List[Result]:
"""Search applications based on query."""
if not query_string.strip():
return self._get_all_applications()
try:
applications = get_desktop_applications(include_hidden=False)
except Exception as e:
print(f"Failed to load applications: {e}")
applications = []
query = query_string.lower().strip()
results = []
for app in applications:
relevance = self._calculate_relevance(app, query)
if relevance > 0:
description = app.description or app.generic_name or ""
if len(description) > 80:
description = description[:70] + "..."
result = Result(
title=app.display_name or app.name,
subtitle=description,
icon=app.get_icon_pixbuf(size=48),
action=lambda a=app: self._launch_application(a),
relevance=relevance,
plugin_name=self.display_name,
data={
"app": app,
"pin_action": lambda a=app: self._pin_application(a),
},
)
results.append(result)
return results
def _calculate_relevance(self, app, query: str) -> float:
"""Calculate relevance score for an application."""
if not query:
return 0.0
# Get searchable text
name = (app.name or "").lower()
display_name = (app.display_name or "").lower()
description = (app.description or "").lower()
generic_name = (app.generic_name or "").lower()
executable = (app.executable or "").lower()
# Exact matches get highest score
if query == name or query == display_name:
return 1.0
# Starts with matches get high score
if (
name.startswith(query)
or display_name.startswith(query)
or generic_name.startswith(query)
):
return 0.9
# Contains matches get medium score
if (
query in name
or query in display_name
or query in description
or query in generic_name
):
return 0.7
# Fuzzy matching for partial matches
if self._fuzzy_match(query, name) or self._fuzzy_match(query, display_name):
return 0.5
# Executable name matching
if executable and query in executable:
return 0.4
return 0.0
def _fuzzy_match(self, query: str, text: str) -> bool:
"""Simple fuzzy matching algorithm."""
if not query or not text:
return False
# Create regex pattern for fuzzy matching
pattern = ".*".join(re.escape(char) for char in query)
return bool(re.search(pattern, text, re.IGNORECASE))
def _launch_application(self, app: DesktopApp):
# Remove ALL % codes (e.g., %u, %U, %f, %F, %i, %c, etc.)
cleaned_command = re.sub(r"%\w+", "", app.command_line).strip()
# Final command with hyprctl dispatch
final_command = f"hyprctl dispatch exec 'uwsm app -- {cleaned_command}'"
subprocess.Popen(final_command, shell=True)
# app.launch()
def _get_all_applications(self) -> List[Result]:
"""Get a list of all available applications."""
try:
applications = get_desktop_applications(include_hidden=False)
except Exception as e:
print(f"Failed to load applications: {e}")
return []
results = []
for app in applications:
# Truncate description
description = app.description or app.generic_name or ""
if len(description) > 80:
description = description[:70] + "..."
result = Result(
title=app.display_name or app.name,
subtitle=description,
icon=app.get_icon_pixbuf(size=48),
action=lambda a=app: self._launch_application(a),
relevance=0.5, # Default relevance for all apps
plugin_name=self.display_name,
data={
"app": app,
"pin_action": lambda a=app: self._pin_application(a),
},
)
results.append(result)
return results
================================================
FILE: modules/launcher/plugins/bash_scripts.py
================================================
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}")
================================================
FILE: modules/launcher/plugins/bookmarks.py
================================================
import json
import subprocess
import threading
import time
from pathlib import Path
from typing import Dict, List, Optional
from urllib.parse import urlparse
from thefuzz import fuzz
from fabric.utils.helpers import get_relative_path
from modules.launcher.plugin_base import PluginBase
from modules.launcher.result import Result
class BookmarkManager:
"""Manages user's custom bookmarks."""
def __init__(self, storage_file: Path):
self.storage_file = storage_file
self.bookmarks: List[Dict] = []
self.cache_lock = threading.Lock()
self.last_loaded = 0
self.cache_ttl = 30 # Cache for 30 seconds
self._load_bookmarks()
def _get_favicon_url(self, url: str) -> str:
"""Generate favicon URL for a given website URL."""
try:
parsed = urlparse(url)
return f"{parsed.scheme}://{parsed.netloc}/favicon.ico"
except:
return ""
def _extract_domain(self, url: str) -> str:
"""Extract domain from URL."""
try:
parsed = urlparse(url)
domain = parsed.netloc
# Remove www. prefix
if domain.startswith("www."):
domain = domain[4:]
return domain
except:
return url
def _normalize_url(self, url: str) -> str:
"""Normalize URL by adding protocol if missing."""
url = url.strip()
if not url.startswith(("http://", "https://")):
if url.startswith("www."):
url = "https://" + url
else:
url = "https://" + url
return url
def _load_bookmarks(self):
"""Load bookmarks from JSON file with caching."""
with self.cache_lock:
current_time = time.time()
# Check if cache is still valid
if (
current_time - self.last_loaded
) < self.cache_ttl and self.last_loaded > 0:
return
try:
if self.storage_file.exists():
with open(self.storage_file, "r", encoding="utf-8") as f:
data = json.load(f)
self.bookmarks = data.get("bookmarks", [])
else:
# File doesn't exist, start with empty list but don't save yet
self.bookmarks = []
self.last_loaded = current_time
except Exception as e:
print(f"Error loading bookmarks: {e}")
self.bookmarks = []
def get_bookmarks(self) -> List[Dict]:
"""Get bookmarks, loading from file if needed."""
self._load_bookmarks()
return self.bookmarks
def _save_bookmarks_unlocked(self):
"""Save bookmarks to JSON file without acquiring lock."""
try:
self.storage_file.parent.mkdir(parents=True, exist_ok=True)
data = {
"bookmarks": self.bookmarks,
"last_updated": time.time(),
}
with open(self.storage_file, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
# Update cache timestamp
self.last_loaded = time.time()
except Exception as e:
print(f"Error saving bookmarks: {e}")
def _save_bookmarks(self):
"""Save bookmarks to JSON file."""
with self.cache_lock:
self._save_bookmarks_unlocked()
def add_bookmark(
self, title: str, url: str, description: str = "", tags: List[str] = None
) -> bool:
"""Add a new bookmark."""
try:
url = self._normalize_url(url)
# Check if bookmark already exists
for bookmark in self.bookmarks:
if bookmark["url"] == url:
return False # Already exists
new_bookmark = {
"title": title.strip(),
"url": url,
"description": description.strip(),
"tags": tags or [],
"created": time.time(),
"accessed": 0,
}
self.bookmarks.append(new_bookmark)
self._save_bookmarks()
# Clear cache to force reload
self.last_loaded = 0
return True
except Exception as e:
print(f"Error adding bookmark: {e}")
return False
def remove_bookmark(self, identifier: str) -> bool:
"""Remove a bookmark by title or URL."""
try:
identifier = identifier.lower().strip()
for i, bookmark in enumerate(self.bookmarks):
if (
bookmark["title"].lower() == identifier
or bookmark["url"].lower() == identifier
or self._extract_domain(bookmark["url"]).lower() == identifier
):
self.bookmarks.pop(i)
self._save_bookmarks()
# Clear cache to force reload
self.last_loaded = 0
return True
return False
except Exception as e:
print(f"Error removing bookmark: {e}")
return False
def update_access_time(self, url: str):
"""Update the last accessed time for a bookmark."""
try:
for bookmark in self.bookmarks:
if bookmark["url"] == url:
bookmark["accessed"] = time.time()
self._save_bookmarks()
break
except Exception as e:
print(f"Error updating access time: {e}")
def get_bookmark_count(self) -> int:
"""Get total number of bookmarks."""
return len(self.get_bookmarks())
class BookmarksPlugin(PluginBase):
"""
User bookmarks plugin for the launcher.
Allows users to add, remove, and search their own bookmarks.
"""
def __init__(self):
super().__init__()
self.display_name = "Bookmarks"
self.description = "Manage and search your personal bookmarks"
# Initialize bookmark manager with storage file
self.bookmark_file = Path(
get_relative_path("../../../config/assets/bookmarks.json")
)
self.bookmark_manager = BookmarkManager(self.bookmark_file)
self.max_results = 15
# Cache for results
self._results_cache = {}
self._cache_timestamps = {}
self._cache_ttl = 30 # 30 seconds
# Launcher instance for refreshing
self._launcher_instance = None
self._original_close_launcher = None
def initialize(self):
"""Initialize the bookmarks plugin."""
self.set_triggers(["bm"])
self._setup_launcher_hooks()
def cleanup(self):
"""Cleanup the bookmarks plugin."""
self._results_cache.clear()
self._cache_timestamps.clear()
self._cleanup_launcher_hooks()
def query(self, query_string: str) -> List[Result]:
"""Process bookmark queries with caching."""
query_key = query_string.strip()
current_time = time.time()
# Check cache first (except for add/remove commands which should always execute)
if (
not query_key.startswith(("add ", "remove ", "delete ", "rm "))
and query_key in self._results_cache
and (current_time - self._cache_timestamps.get(query_key, 0))
< self._cache_ttl
):
return self._results_cache[query_key]
query = query_key.lower()
results = []
if not query:
# Show recent/popular bookmarks when no query
results = self._get_recent_bookmarks()
elif query.startswith("add "):
# Add new bookmark (don't cache)
results = self._handle_add_command(query[4:].strip())
elif query.startswith(("remove ", "delete ", "rm ")):
# Remove bookmark (don't cache)
command_parts = query_key.split(" ", 1)
if len(command_parts) > 1:
results = self._handle_remove_command(command_parts[1].strip())
else:
results = self._show_remove_help()
else:
# Search bookmarks
results = self._search_bookmarks(query)
# Cache results (except for add/remove commands)
if not query.startswith(("add ", "remove ", "delete ", "rm ")):
self._results_cache[query_key] = results
self._cache_timestamps[query_key] = current_time
return results
def _search_bookmarks(self, query: str) -> List[Result]:
"""Search through bookmarks."""
bookmarks = self.bookmark_manager.get_bookmarks()
if not bookmarks:
return [
Result(
title="No Bookmarks Found",
subtitle="Use 'add ' to add first bookmark",
icon_name="info",
action=lambda: None,
relevance=1.0,
plugin_name=self.display_name,
data={"type": "info", "keep_launcher_open": True},
)
]
# Search bookmarks
results = []
for bookmark in bookmarks:
relevance = self._calculate_relevance(bookmark, query)
if relevance > 0.3: # Only show relevant results
result = self._create_bookmark_result(bookmark, relevance)
if result:
results.append(result)
# Sort by relevance and limit results
results.sort(key=lambda r: r.relevance, reverse=True)
return results[: self.max_results]
def _get_recent_bookmarks(self) -> List[Result]:
"""Get recent/popular bookmarks when no query is provided."""
bookmarks = self.bookmark_manager.get_bookmarks()
if not bookmarks:
return [
Result(
title="No Bookmarks Yet",
subtitle="Use 'add ' to add first bookmark",
icon_name="info",
action=lambda: None,
relevance=1.0,
plugin_name=self.display_name,
data={"type": "help", "keep_launcher_open": True},
),
Result(
title="Example: Add Google",
subtitle="add Google https://google.com",
icon_name="info",
action=lambda: None,
relevance=0.9,
plugin_name=self.display_name,
data={"type": "example", "keep_launcher_open": True},
),
]
# Sort by access time (most recent first) and show top 10
sorted_bookmarks = sorted(
bookmarks, key=lambda b: b.get("accessed", 0), reverse=True
)
results = []
for bookmark in sorted_bookmarks[:10]:
result = self._create_bookmark_result(bookmark, 0.8)
if result:
results.append(result)
return results
def _handle_add_command(self, args: str) -> List[Result]:
"""Handle add bookmark command."""
if not args:
return [
Result(
title="Add Bookmark",
subtitle="Usage: add [description]",
icon_name="info",
action=lambda: None,
relevance=1.0,
plugin_name=self.display_name,
data={"type": "help", "keep_launcher_open": True},
),
Result(
title="Example",
subtitle="add Google https://google.com Search engine",
icon_name="info",
action=lambda: None,
relevance=0.9,
plugin_name=self.display_name,
data={"type": "example", "keep_launcher_open": True},
),
]
# Parse arguments: title url [description]
parts = args.split()
if len(parts) < 2:
return [
Result(
title="Invalid Format",
subtitle="Usage: add [description]",
icon_name="alert",
action=lambda: None,
relevance=1.0,
plugin_name=self.display_name,
data={"type": "error", "keep_launcher_open": True},
)
]
title = parts[0]
url = parts[1]
description = " ".join(parts[2:]) if len(parts) > 2 else ""
# Check if bookmark already exists
normalized_url = self.bookmark_manager._normalize_url(url)
existing_bookmarks = self.bookmark_manager.get_bookmarks()
already_exists = any(
bookmark["url"] == normalized_url for bookmark in existing_bookmarks
)
if already_exists:
# Truncate URL for display to prevent launcher resize
display_url = normalized_url
if len(display_url) > 35:
display_url = display_url[:32] + "..."
return [
Result(
title="Bookmark Already Exists",
subtitle=f"URL '{display_url}' already exists",
icon_name="alert",
action=lambda: None,
relevance=1.0,
plugin_name=self.display_name,
data={"type": "error", "keep_launcher_open": True},
)
]
# Show add action - will execute on Enter
domain = self.bookmark_manager._extract_domain(normalized_url)
# Truncate domain if too long
if len(domain) > 25:
domain = domain[:22] + "..."
subtitle = f"Click to add: {domain}"
if description:
# Truncate description to prevent launcher resize
max_desc_len = 35 - len(domain) # Account for domain + separator
if len(description) > max_desc_len:
description = description[: max_desc_len - 3] + "..."
subtitle += f" • {description}"
# Truncate title for display
display_title = title
if len(display_title) > 25:
display_title = display_title[:22] + "..."
return [
Result(
title=f"Add bookmark '{display_title}'",
subtitle=subtitle,
icon_name="plus",
action=lambda: self._add_bookmark_action(title, url, description),
relevance=1.0,
plugin_name=self.display_name,
data={"type": "add", "name": title, "keep_launcher_open": True},
)
]
def _handle_remove_command(self, identifier: str) -> List[Result]:
"""Handle remove bookmark command."""
if not identifier:
return self._show_remove_help()
# Find matching bookmarks
bookmarks = self.bookmark_manager.get_bookmarks()
identifier_lower = identifier.lower().strip()
matching_bookmarks = []
for bookmark in bookmarks:
if (
bookmark["title"].lower() == identifier_lower
or bookmark["url"].lower() == identifier_lower
or self.bookmark_manager._extract_domain(bookmark["url"]).lower()
== identifier_lower
):
matching_bookmarks.append(bookmark)
if not matching_bookmarks:
return [
Result(
title="Bookmark Not Found",
subtitle=f"No bookmark found matching '{identifier}'",
icon_name="alert",
action=lambda: None,
relevance=1.0,
plugin_name=self.display_name,
data={"type": "error", "keep_launcher_open": True},
)
]
# Show remove action - will execute on Enter
bookmark = matching_bookmarks[0] # Take first match
title = bookmark.get("title", "Untitled")
domain = self.bookmark_manager._extract_domain(bookmark.get("url", ""))
# Truncate title and domain for display
display_title = title
if len(display_title) > 25:
display_title = display_title[:22] + "..."
display_domain = domain
if len(display_domain) > 30:
display_domain = display_domain[:27] + "..."
return [
Result(
title=f"Remove '{display_title}'?",
subtitle=f"Click to confirm: {display_domain}",
icon_name="trash",
action=lambda: self._remove_bookmark_action(identifier),
relevance=1.0,
plugin_name=self.display_name,
data={"type": "remove", "name": title, "keep_launcher_open": True},
)
]
def _show_remove_help(self) -> List[Result]:
"""Show help for remove command."""
bookmarks = self.bookmark_manager.get_bookmarks()
results = [
Result(
title="Remove Bookmark",
subtitle="Usage: remove ",
icon_name="info",
action=lambda: None,
relevance=1.0,
plugin_name=self.display_name,
data={"type": "help", "keep_launcher_open": True},
)
]
# Show available bookmarks to remove
if bookmarks:
results.append(
Result(
title="Available Bookmarks:",
subtitle=f"{len(bookmarks)} bookmarks available to remove",
icon_name="bookmarks-organize",
action=lambda: None,
relevance=0.9,
plugin_name=self.display_name,
data={"type": "info", "keep_launcher_open": True},
)
)
# Show first few bookmarks as examples
for bookmark in bookmarks[:3]:
title = bookmark.get("title", "Untitled")
domain = self._extract_domain(bookmark.get("url", ""))
# Truncate title and domain for consistent display
display_title = title
if len(display_title) > 20:
display_title = display_title[:17] + "..."
display_domain = domain
if len(display_domain) > 20:
display_domain = display_domain[:17] + "..."
results.append(
Result(
title=f"remove {display_title}",
subtitle=f"Click to remove: {display_title} ({display_domain})",
icon_name="trash",
action=lambda t=title: self._remove_bookmark_action(t),
relevance=0.8,
plugin_name=self.display_name,
data={
"type": "remove_option",
"bookmark": bookmark,
"keep_launcher_open": True,
},
)
)
return results
def _add_bookmark_action(self, title: str, url: str, description: str = ""):
"""Execute the add bookmark action."""
success = self.bookmark_manager.add_bookmark(title, url, description)
if success:
print(f"✓ Added bookmark '{title}' - {url}")
# Clear cache to force refresh
self._results_cache.clear()
self._cache_timestamps.clear()
# Reset to trigger word and refresh
self._reset_to_trigger()
else:
print(f"✗ Failed to add bookmark '{title}' - already exists")
def _remove_bookmark_action(self, identifier: str):
"""Execute the remove bookmark action."""
success = self.bookmark_manager.remove_bookmark(identifier)
if success:
print(f"✓ Removed bookmark '{identifier}'")
# Clear cache to force refresh
self._results_cache.clear()
self._cache_timestamps.clear()
# Reset to trigger word and refresh
self._reset_to_trigger()
else:
print(f"✗ Failed to remove bookmark '{identifier}' - not found")
def _remove_bookmark_with_reset(self, identifier: str):
"""Execute the remove bookmark action via alt_action (Shift+Enter) and reset to trigger."""
success = self.bookmark_manager.remove_bookmark(identifier)
if success:
print(f"✓ Removed bookmark '{identifier}'")
# Clear cache to force refresh
self._results_cache.clear()
self._cache_timestamps.clear()
# Reset to trigger word and refresh
self._reset_to_trigger()
else:
print(f"✗ Failed to remove bookmark '{identifier}' - not found")
def _calculate_relevance(self, bookmark: Dict, query: str) -> float:
"""Calculate relevance score for a bookmark."""
title = bookmark.get("title", "").lower()
url = bookmark.get("url", "").lower()
description = bookmark.get("description", "").lower()
# Exact title match
if query == title:
return 1.0
# Title starts with query
if title.startswith(query):
return 0.95
# Query in title
if query in title:
position = title.index(query)
position_score = 1.0 - (position / len(title))
return 0.8 + (position_score * 0.1)
# Query in URL
if query in url:
return 0.7
# Query in description
if query in description:
return 0.6
# Fuzzy matching for title
if len(query) >= 3:
fuzzy_score = fuzz.partial_ratio(query, title) / 100.0
if fuzzy_score >= 0.7:
return fuzzy_score * 0.6
return 0.0
def _create_bookmark_result(
self, bookmark: Dict, relevance: float
) -> Optional[Result]:
"""Create a Result object for a bookmark."""
try:
title = bookmark.get("title", "Untitled")
url = bookmark.get("url", "")
description = bookmark.get("description", "")
# Truncate long titles to prevent launcher resize
if len(title) > 45:
title = title[:42] + "..."
# Create subtitle with domain and description
domain = self._extract_domain(url)
# Truncate domain if too long
if len(domain) > 30:
domain = domain[:27] + "..."
if description:
# Truncate description to prevent launcher resize
# Account for domain + separator
max_desc_len = 50 - len(domain)
if len(description) > max_desc_len:
description = description[: max_desc_len - 3] + "..."
subtitle = f"{domain} • {description}"
else:
subtitle = domain
# Final subtitle length check to ensure consistent launcher size
if len(subtitle) > 60:
subtitle = subtitle[:57] + "..."
return Result(
title=title,
subtitle=subtitle,
icon_name="bookmark-organize",
action=lambda u=url: self._open_bookmark(u),
relevance=relevance,
plugin_name=self.display_name,
data={
"type": "bookmark",
"url": url,
"domain": domain,
"description": description,
"keep_launcher_open": False,
"alt_action": lambda t=title: self._remove_bookmark_with_reset(t),
},
)
except Exception as e:
print(f"Error creating bookmark result: {e}")
return None
def _extract_domain(self, url: str) -> str:
"""Extract domain from URL."""
try:
parsed = urlparse(url)
domain = parsed.netloc
# Remove www. prefix
if domain.startswith("www."):
domain = domain[4:]
return domain
except:
return url
def _open_bookmark(self, url: str):
"""Open bookmark URL in default browser and update access time."""
try:
# Update access time
self.bookmark_manager.update_access_time(url)
# Open URL
subprocess.Popen(
["xdg-open", url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
)
except Exception as e:
print(f"Failed to open bookmark: {e}")
def _setup_launcher_hooks(self):
"""Setup hooks to monitor launcher state."""
try:
# Try to find the launcher instance
import gc
for obj in gc.get_objects():
if (
hasattr(obj, "__class__")
and obj.__class__.__name__ == "Launcher"
and hasattr(obj, "close_launcher")
):
self._launcher_instance = obj
break
except Exception as e:
print(f"Warning: Could not setup launcher hooks: {e}")
def _cleanup_launcher_hooks(self):
"""Cleanup launcher hooks."""
try:
self._launcher_instance = None
except Exception as e:
print(f"Warning: Could not cleanup launcher hooks: {e}")
def _reset_to_trigger(self):
"""Reset launcher to trigger word and refresh."""
try:
if self._launcher_instance and hasattr(
self._launcher_instance, "search_entry"
):
# Get the current trigger (bookmark or bm)
current_text = self._launcher_instance.search_entry.get_text()
trigger = "bookmark "
# Determine which trigger was used
if current_text.lower().startswith("bm "):
trigger = "bm "
# Reset to trigger word with space
try:
from gi.repository import GLib
def reset_and_refresh():
# Set text to trigger word
self._launcher_instance.search_entry.set_text(trigger)
# Position cursor at end
self._launcher_instance.search_entry.set_position(-1)
# Trigger search to show default bookmarks
self._launcher_instance._perform_search(trigger)
return False
GLib.timeout_add(50, reset_and_refresh)
except ImportError:
# Fallback: direct call if GLib not available
self._launcher_instance.search_entry.set_text(trigger)
self._launcher_instance.search_entry.set_position(-1)
self._launcher_instance._perform_search(trigger)
except Exception as e:
print(f"Could not reset to trigger: {e}")
def _force_launcher_refresh(self):
"""Force the launcher to refresh and show updated results."""
try:
if self._launcher_instance and hasattr(
self._launcher_instance, "_perform_search"
):
# Get current search text
current_text = ""
if hasattr(self._launcher_instance, "search_entry"):
current_text = self._launcher_instance.search_entry.get_text()
# Trigger a search to refresh results
try:
from gi.repository import GLib
def refresh():
self._launcher_instance._perform_search(current_text)
return False
GLib.timeout_add(50, refresh)
except ImportError:
# Fallback: direct call if GLib not available
self._launcher_instance._perform_search(current_text)
except Exception as e:
print(f"Could not force launcher refresh: {e}")
================================================
FILE: modules/launcher/plugins/caffeine.py
================================================
import subprocess
from threading import Timer
from typing import List
import gi
import config.data as data
from fabric.utils import get_relative_path
from fabric.utils.helpers import exec_shell_command_async
from modules.launcher.plugin_base import PluginBase
from modules.launcher.result import Result
gi.require_version("Gtk", "3.0")
class CaffeinePlugin(PluginBase):
"""
Plugin for preventing system idle using the inhibit script.
"""
def __init__(self):
super().__init__()
self.display_name = "Caffeine"
self.description = "Prevent system from going idle"
self.inhibit_script = get_relative_path("../../../utils/inhibit.py")
# Predefined durations
self.durations = {
"30m": "30 minutes",
"1h": "1 hour",
"2h": "2 hours",
"4h": "4 hours",
"8h": "8 hours",
"on": "On",
"off": "Off",
}
def initialize(self):
"""Initialize the caffeine plugin."""
self.set_triggers(["caffeine"])
def cleanup(self):
"""Cleanup the caffeine plugin."""
pass
def _create_inhibit_action(self, duration: str):
"""Create an inhibit action that properly captures the duration."""
def action():
try:
# Run the script in the background without waiting
subprocess.Popen(
["python3", self.inhibit_script, duration],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True, # Run in a new session to prevent hanging
)
# Send notification based on duration
if duration.lower() == "off":
# Deactivation notification
exec_shell_command_async(
f"notify-send '☕ Caffeine' 'Deactivated' -a '{
data.APP_NAME_CAP
}' -e"
)
else:
# Activation notification with duration
duration_text = self.durations.get(duration, duration)
exec_shell_command_async(
f"notify-send '☕ Caffeine' 'Activated for {
duration_text
}' -a '{data.APP_NAME_CAP}' -e"
)
# Schedule expiration notification for timed durations
if duration.lower() not in ["on", "off"]:
self._schedule_expiration_notification(duration, duration_text)
except Exception as e:
print(f"Error starting inhibit script: {e}")
return action
def _parse_duration_to_seconds(self, duration_str: str) -> int:
"""Parse duration string into seconds. Same logic as inhibit.py"""
try:
if duration_str.lower() in ["on", "off"]:
return 0
elif duration_str.endswith("h"):
return int(float(duration_str[:-1]) * 3600)
elif duration_str.endswith("m"):
return int(float(duration_str[:-1]) * 60)
elif duration_str.endswith("s"):
return int(float(duration_str[:-1]))
else:
return int(duration_str)
except ValueError:
return 0
def _schedule_expiration_notification(self, duration: str, duration_text: str):
"""Schedule a notification for when the caffeine effect expires."""
seconds = self._parse_duration_to_seconds(duration)
if seconds > 0:
def send_expiration_notification():
exec_shell_command_async(
f"notify-send '☕ Caffeine' 'Expired after {duration_text}' -a '{
data.APP_NAME_CAP
}' -e"
)
# Schedule the notification
timer = Timer(seconds, send_expiration_notification)
timer.daemon = True # Don't prevent program exit
timer.start()
def _is_valid_duration(self, query: str) -> bool:
"""Check if the query is a valid duration format."""
if query in self.durations:
return True
if query.isdigit():
return True
if query.endswith(("h", "m", "s")) and query[:-1].replace(".", "").isdigit():
return True
return False
def _get_default_action(self, query: str):
"""Get the default action for a direct duration input."""
if self._is_valid_duration(query):
return self._create_inhibit_action(query)
return None
def query(self, query_string: str) -> List[Result]:
"""Search caffeine durations based on query."""
# For empty queries, show all available durations
if not query_string.strip():
query_string = "" # Will match all durations in the loop below
query = query_string.lower().strip()
results = []
# Handle direct search entry (e.g., "caffeine 30m" or just "30m")
if query.startswith("caffeine "):
query = query[9:].strip() # Remove "caffeine " prefix
# If query becomes empty after removing prefix, show all durations
if not query:
query = "" # Will match all durations in the loop below
elif query == "caffeine":
# Handle just "caffeine" without space - show all durations
query = ""
elif not query:
# Handle empty query - show all durations
pass
elif (
query in self.durations
or query.isdigit()
or (
query.endswith(("h", "m", "s"))
and query[:-1].replace(".", "").isdigit()
)
):
# If query is a valid duration without prefix, use it directly
pass
else:
return []
# Add custom duration option
if query.isdigit() or (
query.endswith(("h", "m", "s")) and query[:-1].replace(".", "").isdigit()
):
result = Result(
title=f"Custom: {query}",
subtitle="Set custom duration",
icon_name="caffeine",
action=self._create_inhibit_action(query),
relevance=1.0,
plugin_name=self.display_name,
data={"duration": query},
)
results.append(result)
# Add predefined durations
for duration, description in self.durations.items():
# If query is empty, show all durations; otherwise filter by query
if not query or query in duration or query in description.lower():
# Special handling for "off" - it should stop inhibition
if duration.lower() == "off":
subtitle = "Stop idle inhibition"
else:
subtitle = f"Prevent idle for {description.lower()}"
result = Result(
title=description,
subtitle=subtitle,
icon_name="caffeine",
action=self._create_inhibit_action(duration),
relevance=0.9 if query == duration else 0.7,
plugin_name=self.display_name,
data={"duration": duration},
)
results.append(result)
# Set default action for direct duration input
if results:
results[0].default_action = self._get_default_action(query)
return results
================================================
FILE: modules/launcher/plugins/calculator.py
================================================
import math
import re
import subprocess
import time
from typing import List
from modules.launcher.plugin_base import PluginBase
from modules.launcher.result import Result
from utils.conversion import Conversion
class CalculatorPlugin(PluginBase):
"""
Plugin for calculating mathematical expressions and converting units.
"""
def __init__(self):
super().__init__()
self.display_name = "Calculator"
self.description = "Evaluate mathematical expressions and convert units"
# Safe functions for evaluation
self.safe_functions = {
"abs": abs,
"round": round,
"min": min,
"max": max,
"sum": sum,
"pow": pow,
"sqrt": math.sqrt,
"sin": math.sin,
"cos": math.cos,
"tan": math.tan,
"asin": math.asin,
"acos": math.acos,
"atan": math.atan,
"log": math.log,
"log10": math.log10,
"exp": math.exp,
"pi": math.pi,
"e": math.e,
}
# Initialize conversion utility
self.converter = Conversion()
# Pre-compiled regex patterns
self.expression_pattern = re.compile(r"[\d+\-*/^()=]")
self.number_pattern = re.compile(r"\d")
self.conversion_pattern = re.compile(
r"(\d+(?:\.\d+)?)\s*([a-zA-Z]+)\s*(?:to|in|=)\s*([a-zA-Z]+)"
)
# Cache for conversion results
self._conversion_cache = {}
self._last_cache_cleanup = time.time()
self._cache_cleanup_interval = 300 # 5 minutes
def initialize(self):
"""Initialize the files plugin."""
self.set_triggers(["="])
def _cleanup_cache(self):
"""Clean up old cache entries."""
current_time = time.time()
if current_time - self._last_cache_cleanup > self._cache_cleanup_interval:
self._conversion_cache.clear()
self._last_cache_cleanup = current_time
def query(self, query: str) -> List[Result]:
"""Process a query and return results."""
if not query:
return []
# Clean up cache periodically
self._cleanup_cache()
# Check if it's a conversion query
conversion_match = self.conversion_pattern.match(query)
if conversion_match:
try:
value, from_unit, to_unit = conversion_match.groups()
value = float(value)
# Check cache first
cache_key = f"{value}_{from_unit}_{to_unit}"
if cache_key in self._conversion_cache:
result = self._conversion_cache[cache_key]
subtitle = f"{value} {from_unit} = {result:.6g} {to_unit}"
else:
# Use the conversion utility
result = self.converter.convert(value, from_unit, to_unit)
# Cache the result
self._conversion_cache[cache_key] = result
subtitle = f"{value} {from_unit} = {result:.6g} {to_unit}"
return [
Result(
title=f"{result:.6g} {to_unit}",
subtitle=subtitle,
icon_name="calculator-symbolic",
action=lambda r=f"{result:.6g}": self._copy_to_clipboard(r),
relevance=1.0,
plugin_name=self.display_name,
data={"from": (value, from_unit), "to": (result, to_unit)},
)
]
except ValueError as e:
return [
Result(
title="Invalid conversion",
subtitle=str(e),
icon_name="calculator-symbolic",
relevance=0.0,
plugin_name=self.display_name,
)
]
# Check if it's a math expression
if self.expression_pattern.search(query):
try:
# Evaluate the expression
result = eval(query, {"__builtins__": {}}, self.safe_functions)
if isinstance(result, (int, float)):
return [
Result(
title=f"{result:.6g}",
subtitle=f"{query} = {result:.6g}",
icon_name="calculator-symbolic",
action=lambda r=f"{result:.6g}": self._copy_to_clipboard(r),
relevance=1.0,
plugin_name=self.display_name,
)
]
except Exception:
pass
return []
def _format_cache_age(self, age_seconds: float) -> str:
"""Format cache age for display."""
if age_seconds < 60:
return f"{int(age_seconds)}s ago"
elif age_seconds < 3600:
return f"{int(age_seconds // 60)}m ago"
else:
return f"{int(age_seconds // 3600)}h ago"
def _copy_to_clipboard(self, text: str):
"""Copy text to clipboard using cliphist."""
try:
# First copy to clipboard
subprocess.run(["wl-copy"], input=text.encode(), check=True)
# Then store in cliphist
subprocess.run(["cliphist", "store"], input=text.encode(), check=True)
except subprocess.CalledProcessError:
# If cliphist fails, at least we have the text in clipboard
pass
def cleanup(self):
"""Clean up resources."""
self._conversion_cache.clear()
self.converter.cleanup()
================================================
FILE: modules/launcher/plugins/clipboard.py
================================================
import os
import subprocess
import sys
import tempfile
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from typing import Dict, List, Optional
from gi.repository import GdkPixbuf, GLib
from modules.launcher.plugin_base import PluginBase
from modules.launcher.result import Result
class ClipboardPlugin(PluginBase):
def __init__(self):
super().__init__()
self.name = "clipboard"
self.display_name = "Clipboard History"
self.description = "Search and manage clipboard history using cliphist"
# Performance settings - show more history like example_cliphist.py
self.max_results = 50
# Cache clipboard items for 5 seconds (more responsive)
self.cache_ttl = 5
# Initialize cache and temp directory
self.tmp_dir = tempfile.mkdtemp(prefix="cliphist-")
# Cache images forever like example_cliphist.py
self.image_cache: Dict[str, GdkPixbuf.Pixbuf] = {}
self.clipboard_items_cache: List[str] = []
self.cache_timestamp = 0
# Threading
self.executor = ThreadPoolExecutor(
max_workers=2, thread_name_prefix="clipboard"
)
self.cache_lock = threading.Lock()
# State tracking
self._loading = False
self._pending_updates = False
def initialize(self):
"""Initialize the plugin."""
self.set_triggers(["clip"])
try:
subprocess.run(
["cliphist", "list"], capture_output=True, check=True, timeout=5
)
except (subprocess.SubprocessError, FileNotFoundError):
raise RuntimeError("cliphist is not installed or not working properly")
# Pre-warm cache in background
self.executor.submit(self._load_clipboard_items_cached)
def cleanup(self):
"""Cleanup the plugin."""
try:
# Shutdown executor
if hasattr(self, "executor"):
self.executor.shutdown(wait=False)
# Clean up temp files
if os.path.exists(self.tmp_dir):
import shutil
shutil.rmtree(self.tmp_dir)
# Clear caches
with self.cache_lock:
self.image_cache.clear()
self.clipboard_items_cache.clear()
except Exception as e:
print(f"Error cleaning up temporary files: {e}", file=sys.stderr)
def invalidate_cache(self):
"""Force invalidation of the clipboard cache."""
with self.cache_lock:
self.clipboard_items_cache.clear()
self.cache_timestamp = 0
# Keep image cache forever like example_cliphist.py
def _force_launcher_refresh(self):
"""Force the launcher to refresh its results."""
try:
from gi.repository import GLib
def trigger_refresh():
try:
# Try to access the launcher through the fabric Application
from fabric import Application
app = Application.get_default()
if app and hasattr(app, "launcher"):
launcher = app.launcher
if (
launcher
and hasattr(launcher, "search_entry")
and hasattr(launcher, "_perform_search")
):
# Get current search text to preserve the query
current_text = launcher.search_entry.get_text()
# Trigger the search to refresh results
launcher._perform_search(current_text)
return False
# Fallback: try to find launcher instance through other means
import gc
for obj in gc.get_objects():
if (
hasattr(obj, "__class__")
and obj.__class__.__name__ == "Launcher"
):
if hasattr(obj, "search_entry") and hasattr(
obj, "_perform_search"
):
current_text = obj.search_entry.get_text()
obj._perform_search(current_text)
return False
except Exception as e:
print(f"Error forcing launcher refresh: {e}")
return False # Don't repeat
# Use immediate refresh
GLib.timeout_add(10, trigger_refresh)
except Exception as e:
print(f"Could not trigger refresh: {e}")
def _load_clipboard_items_cached(self) -> List[str]:
"""Load clipboard items from cliphist with caching and change detection."""
current_time = time.time()
# Always load fresh data to check for changes
try:
result = subprocess.run(
["cliphist", "list"], capture_output=True, check=True, timeout=5
)
stdout_str = result.stdout.decode("utf-8", errors="replace")
if stdout_str.strip():
lines = stdout_str.strip().split("\n")
items = [
line for line in lines if line and " List[str]:
"""Load clipboard items (legacy method for compatibility)."""
return self._load_clipboard_items_cached()
def _create_pixbuf_from_bytes(
self, image_data: bytes, max_size: int = 100
) -> GdkPixbuf.Pixbuf:
"""Create a GdkPixbuf from image bytes with size limit."""
try:
loader = GdkPixbuf.PixbufLoader()
loader.write(image_data)
loader.close()
pixbuf = loader.get_pixbuf()
# Scale image if needed
width, height = pixbuf.get_width(), pixbuf.get_height()
if width > height:
new_width = max_size
new_height = int(height * (max_size / width))
else:
new_height = max_size
new_width = int(width * (max_size / height))
return pixbuf.scale_simple(
new_width, new_height, GdkPixbuf.InterpType.BILINEAR
)
except GLib.Error:
return None
def _is_image_data(self, content: str) -> bool:
"""Determine if clipboard content is likely an image (like example_cliphist.py)."""
return (
content.startswith("data:image/")
or content.startswith("\x89PNG")
or content.startswith("GIF8")
or content.startswith("\xff\xd8\xff")
or "binary" in content.lower()
and any(
ext in content.lower() for ext in ["jpg", "jpeg", "png", "bmp", "gif"]
)
)
def _get_text_preview(self, content: str) -> str:
"""Get a text preview of the content."""
if len(content) > 50:
return content[:37] + "..."
return content
def _load_image_preview_cached(self, item_id: str) -> Optional[GdkPixbuf.Pixbuf]:
"""Load image preview with forever caching (like example_cliphist.py)."""
# Check cache first - cache forever like example_cliphist.py
with self.cache_lock:
if item_id in self.image_cache:
return self.image_cache[item_id]
try:
result = subprocess.run(
["cliphist", "decode", item_id],
capture_output=True,
check=True,
timeout=3,
)
pixbuf = self._create_pixbuf_from_bytes(result.stdout)
if pixbuf:
with self.cache_lock:
self.image_cache[item_id] = pixbuf
return pixbuf
except Exception as e:
print(f"Error loading image preview: {e}", file=sys.stderr)
return None
def _load_image_preview_async(self, item_id: str) -> Optional[GdkPixbuf.Pixbuf]:
"""Load image preview (legacy method for compatibility)."""
return self._load_image_preview_cached(item_id)
def query(self, query_string: str) -> List[Result]:
"""Search clipboard history using cliphist with optimized performance."""
results = []
# Handle query string
if query_string.lower() == "clip":
query_string = "" # Show all items
try:
# Load clipboard items from cache
clipboard_items = self._load_clipboard_items_cached()
# Early exit if no items
if not clipboard_items:
return results
# Filter items based on query - search through ALL items first
filtered_items = []
query_lower = query_string.lower() if query_string else ""
for item in clipboard_items:
parts = item.split("\t", 1)
content = parts[1] if len(parts) > 1 else item
content_lower = content.lower()
# Fast filtering - search through all items, don't limit here
if not query_lower or query_lower in content_lower:
# Calculate relevance score for better sorting
relevance = 1.0
if query_lower:
if content_lower.startswith(query_lower):
relevance = 1.0 # Exact start match
elif query_lower in content_lower:
# Position-based scoring: earlier matches get higher scores
position = content_lower.find(query_lower)
relevance = max(
0.5, 1.0 - (position / len(content_lower)) * 0.4
)
filtered_items.append((item, relevance))
# Sort by relevance (highest first) and then limit results
filtered_items.sort(key=lambda x: x[1], reverse=True)
filtered_items = filtered_items[: self.max_results]
# Process items with lazy image loading
for i, (item, relevance) in enumerate(filtered_items):
parts = item.split("\t", 1)
item_id = parts[0] if len(parts) > 1 else str(i)
content = parts[1] if len(parts) > 1 else item
# Handle image content like example_cliphist.py
if self._is_image_data(content):
# Check if image is already cached (forever cache like example_cliphist.py)
cached_pixbuf = None
with self.cache_lock:
cached_pixbuf = self.image_cache.get(item_id)
if cached_pixbuf:
# Use cached image immediately
result = Result(
title="Image from clipboard",
subtitle="Click to copy image to clipboard",
description="Image content",
icon=cached_pixbuf,
relevance=relevance,
plugin_name=self.name,
action=lambda id=item_id: self._copy_to_clipboard(id),
data={"bypass_max_results": True},
)
else:
# Try to load image immediately like example_cliphist.py
try:
immediate_pixbuf = self._load_image_preview_cached(item_id)
if immediate_pixbuf:
# Successfully loaded immediately
result = Result(
title="Image from clipboard",
subtitle="Click to copy image to clipboard",
description="Image content",
icon=immediate_pixbuf,
relevance=relevance,
plugin_name=self.name,
action=lambda id=item_id: self._copy_to_clipboard(
id
),
data={"bypass_max_results": True},
)
else:
# Show placeholder if loading failed
result = Result(
title="Image from clipboard",
subtitle="Click to copy image to clipboard",
description="Image content",
icon_name="image-x-generic",
relevance=relevance,
plugin_name=self.name,
action=lambda id=item_id: self._copy_to_clipboard(
id
),
data={"bypass_max_results": True},
)
except Exception:
# Fallback to placeholder
result = Result(
title="Image from clipboard",
subtitle="Click to copy image to clipboard",
description="Image content",
icon_name="image-x-generic",
relevance=relevance,
plugin_name=self.name,
action=lambda id=item_id: self._copy_to_clipboard(id),
data={"bypass_max_results": True},
)
results.append(result)
continue
# Handle text content
display_text = self._get_text_preview(content)
result = Result(
title=display_text,
subtitle="Text from clipboard",
description=(
content if len(content) <= 100 else content[:97] + "..."
),
icon_name="edit-paste",
relevance=relevance,
plugin_name=self.name,
action=lambda id=item_id: self._copy_to_clipboard(id),
data={"bypass_max_results": True},
)
results.append(result)
except Exception as e:
# Handle errors gracefully
results.append(
Result(
title="Error accessing clipboard history",
subtitle=str(e),
icon_name="dialog-error",
relevance=0.0,
plugin_name=self.name,
data={"bypass_max_results": True},
)
)
return results
def _copy_to_clipboard(self, entry_id: str):
"""Copy entry to clipboard using cliphist with timeout."""
try:
result = subprocess.run(
["cliphist", "decode", entry_id],
capture_output=True,
check=True,
timeout=5,
)
# Use wl-copy for Wayland or xclip for X11
try:
subprocess.run(["wl-copy"], input=result.stdout, check=True, timeout=3)
except subprocess.SubprocessError:
subprocess.run(
["xclip", "-selection", "clipboard"],
input=result.stdout,
check=True,
timeout=3,
)
# Don't invalidate image cache - keep images cached forever like example_cliphist.py
# Only invalidate clipboard items cache
with self.cache_lock:
self.clipboard_items_cache.clear()
self.cache_timestamp = 0
except subprocess.SubprocessError as e:
print(f"Error copying to clipboard: {e}", file=sys.stderr)
except subprocess.TimeoutExpired as e:
print(f"Timeout copying to clipboard: {e}", file=sys.stderr)
================================================
FILE: modules/launcher/plugins/emoji.py
================================================
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},
)
================================================
FILE: modules/launcher/plugins/otp.py
================================================
import json
import subprocess
import threading
import time
from pathlib import Path
from typing import Dict, List, Optional
from fabric.utils import get_relative_path
from modules.launcher.plugin_base import PluginBase
from modules.launcher.result import Result
from services.auth import (
generate_totp,
get_time_remaining_with_blink,
parse_otpauth_uri,
scan_qr_and_add_account,
validate_base32_secret,
)
class OTPPlugin(PluginBase):
"""Plugin for managing TOTP (Time-based One-Time Password) codes."""
def __init__(self):
super().__init__()
self.display_name = "OTP Manager"
self.description = "Manage TOTP codes and 2FA authentication"
self.secrets_file = Path(
get_relative_path("../../../config/assets/accounts.json")
)
self.secrets: Dict[str, Dict] = {}
self.last_update = 0
# Threading for auto-refresh
self.refresh_thread = None
self.stop_refresh = threading.Event()
def initialize(self):
"""Initialize the OTP plugin."""
self.set_triggers(["otp"])
self._load_secrets()
self._ensure_config_file()
self._start_refresh_thread()
def cleanup(self):
"""Cleanup the OTP plugin."""
if self.refresh_thread and self.refresh_thread.is_alive():
self.stop_refresh.set()
self.refresh_thread.join(timeout=1)
def _load_secrets(self):
"""Load secrets from JSON file."""
try:
if self.secrets_file.exists():
with open(self.secrets_file, "r", encoding="utf-8") as f:
self.secrets = json.load(f)
else:
self.secrets = {}
except Exception as e:
print(f"Error loading OTP secrets: {e}")
self.secrets = {}
def _save_secrets(self):
"""Save secrets to JSON file."""
try:
self.secrets_file.parent.mkdir(parents=True, exist_ok=True)
with open(self.secrets_file, "w", encoding="utf-8") as f:
json.dump(self.secrets, f, indent=2)
except Exception as e:
print(f"Error saving OTP secrets: {e}")
def _ensure_config_file(self):
"""Ensure the config file exists."""
if not self.secrets_file.exists():
self.secrets_file.parent.mkdir(parents=True, exist_ok=True)
with open(self.secrets_file, "w", encoding="utf-8") as f:
json.dump({}, f, indent=2)
def _start_refresh_thread(self):
"""Start background thread for auto-refreshing tokens."""
def refresh_loop():
while not self.stop_refresh.wait(5):
current_time = time.time()
if current_time - self.last_update >= 5:
self.last_update = current_time
# Only refresh if we have secrets and launcher is likely active
if self.secrets:
try:
self._selective_force_refresh()
except Exception:
pass
self.refresh_thread = threading.Thread(target=refresh_loop, daemon=True)
self.refresh_thread.start()
def _selective_force_refresh(self):
"""Update time display in existing OTP result items."""
try:
import gc
from gi.repository import GLib
def do_update():
try:
for obj in gc.get_objects():
if (
hasattr(obj, "__class__")
and obj.__class__.__name__ == "Launcher"
and hasattr(obj, "results_box")
and hasattr(obj, "visible")
and obj.visible
and hasattr(obj, "results")
and obj.results
):
has_otp_results = any(
result.data and result.data.get("type") == "totp"
for result in obj.results
if hasattr(result, "data") and result.data
)
if has_otp_results:
self._update_existing_result_labels(obj.results_box)
return False
except Exception:
pass
return False
GLib.idle_add(do_update)
except Exception:
pass
def _update_existing_result_labels(self, results_box):
"""Update subtitle labels in existing ResultItem widgets."""
try:
time_display = self._get_time_remaining_with_blink()
for child in results_box.get_children():
if (
hasattr(child, "__class__")
and child.__class__.__name__ == "ResultItem"
and hasattr(child, "result")
and hasattr(child.result, "data")
and child.result.data
and child.result.data.get("type") == "totp"
):
self._update_result_item_content(child, time_display)
except Exception as e:
print(f"Error updating result labels: {e}")
def _update_result_item_content(self, result_item, time_display):
"""Update both the title (OTP code) and subtitle (time display) of a specific ResultItem."""
try:
account_name = result_item.result.data.get("account", "")
if not account_name or account_name not in self.secrets:
return
account_data = self.secrets[account_name]
secret = account_data.get("secret", "")
issuer = account_data.get("issuer", "")
display_name = f"{issuer} - {account_name}" if issuer else account_name
current_totp_code = self._generate_totp(secret)
if not current_totp_code:
return
old_code = result_item.result.data.get("code", "")
if current_totp_code != old_code:
result_item.result.data["code"] = current_totp_code
self._find_and_update_title_label(result_item, current_totp_code)
result_item.result.action = (
lambda code=current_totp_code: self._copy_to_clipboard(code)
)
new_subtitle_markup = f"{display_name} • {time_display} remaining"
self._find_and_update_subtitle_label(result_item, new_subtitle_markup)
except Exception as e:
print(f"Error updating result item: {e}")
def _find_and_update_title_label(self, result_item, new_title):
"""Find the title label widget and update its text."""
def find_title_label(widget):
if hasattr(widget, "get_name") and widget.get_name() == "result-item-title":
return widget
if hasattr(widget, "get_children"):
for child in widget.get_children():
found = find_title_label(child)
if found:
return found
return None
title_label = find_title_label(result_item)
if title_label and hasattr(title_label, "set_label"):
title_label.set_label(new_title)
def _find_and_update_subtitle_label(self, result_item, new_markup):
"""Find the subtitle label widget and update its markup."""
def find_subtitle_label(widget):
if (
hasattr(widget, "get_name")
and widget.get_name() == "result-item-subtitle"
):
return widget
if hasattr(widget, "get_children"):
for child in widget.get_children():
found = find_subtitle_label(child)
if found:
return found
return None
subtitle_label = find_subtitle_label(result_item)
if subtitle_label and hasattr(subtitle_label, "set_markup"):
subtitle_label.set_markup(new_markup)
def _copy_to_clipboard(self, text: str):
"""Copy text to clipboard."""
try:
try:
subprocess.run(["wl-copy"], input=text.encode(), check=True)
except subprocess.SubprocessError:
subprocess.run(
["xclip", "-selection", "clipboard"],
input=text.encode(),
check=True,
)
except Exception as e:
print(f"Failed to copy to clipboard: {e}")
def _trigger_refresh(self):
"""Trigger launcher refresh to return to default OTP view."""
try:
from gi.repository import GLib
# Use a small delay to ensure the action completes first
def trigger_refresh():
try:
# Try to access the launcher through the fabric Application
from fabric import Application
app = Application.get_default()
if app and hasattr(app, "launcher"):
launcher = app.launcher
if launcher and hasattr(launcher, "search_entry"):
# Clear the search entry and set it to just "otp "
launcher.search_entry.set_text("otp ")
# Position cursor at the end
launcher.search_entry.set_position(-1)
# Trigger the search to show default OTP view
if hasattr(launcher, "_perform_search"):
launcher._perform_search("otp ")
return False
# Fallback: try to find launcher instance through other means
import gc
for obj in gc.get_objects():
if (
hasattr(obj, "__class__")
and obj.__class__.__name__ == "Launcher"
):
if hasattr(obj, "search_entry") and hasattr(
obj, "_perform_search"
):
obj.search_entry.set_text("otp ")
obj.search_entry.set_position(-1)
obj._perform_search("otp ")
return False
except Exception as e:
print(f"Error forcing launcher refresh: {e}")
return False # Don't repeat
# Use a small delay to ensure the action completes first
GLib.timeout_add(50, trigger_refresh)
except Exception as e:
print(f"Could not trigger refresh: {e}")
def _remove_account_and_refresh(self, account_name: str):
"""Remove an account and trigger refresh to return to default OTP view."""
try:
if account_name in self.secrets:
# Remove the account
del self.secrets[account_name]
self._save_secrets()
# Trigger refresh to return to default OTP view
self._trigger_refresh()
except Exception as e:
print(f"Error removing account {account_name}: {e}")
def _generate_totp(self, secret: str) -> Optional[str]:
"""Generate TOTP code from secret."""
return generate_totp(secret)
def _get_time_remaining_with_blink(self) -> str:
"""Get time remaining with blinking effect."""
return get_time_remaining_with_blink()
def query(self, query_string: str) -> List[Result]:
"""Process OTP queries."""
query = query_string.strip()
if not query:
return self._list_otp_codes()
query_lower = query.lower()
if query_lower.startswith("add "):
add_content = query[4:].strip()
# Handle both formats: with ``` and without ```
if "```" in add_content:
# Old format: add account```secret```
parts = add_content.split("```", 1)
if len(parts) == 2:
account_name = parts[0].strip()
secret_or_uri = parts[1].strip()
return self._handle_direct_add(account_name, secret_or_uri)
elif " " in add_content:
# New format: add account secret
parts = add_content.split(" ", 1)
if len(parts) == 2:
account_name = parts[0].strip()
secret_or_uri = parts[1].strip()
return self._handle_direct_add(account_name, secret_or_uri)
return self._handle_add_command(add_content)
elif query_lower == "remove" or query_lower.startswith("remove "):
# Handle both "remove" and "remove accountname"
if query_lower == "remove":
remove_content = ""
else:
remove_content = query[7:].strip()
return self._handle_remove_command(remove_content)
elif query_lower == "qr" or query_lower.startswith("qr "):
# Handle QR scanning command
if query_lower == "qr":
qr_content = ""
else:
qr_content = query[3:].strip()
return self._handle_qr_command(qr_content)
else:
return self._search_accounts(query)
def _handle_direct_add(self, account_name: str, secret_or_uri: str) -> List[Result]:
"""Handle direct addition of OTP account."""
if not account_name or not secret_or_uri:
return [
Result(
title="Invalid format",
subtitle="Usage: add or add ``````",
icon_name="info",
action=lambda: None,
relevance=1.0,
plugin_name=self.display_name,
data={"type": "help", "keep_launcher_open": True},
)
]
try:
if secret_or_uri.startswith("otpauth://"):
return self._handle_otpauth_uri(account_name, secret_or_uri)
else:
return self._handle_base32_secret(account_name, secret_or_uri)
except Exception as e:
print(f"OTP Debug: Error in _handle_direct_add: {e}")
return [
Result(
title="Error adding account",
subtitle=f"Debug: {str(e)}",
icon_name="cancel",
action=lambda: None,
relevance=0.5,
plugin_name=self.display_name,
data={"type": "error", "keep_launcher_open": True},
)
]
def _list_otp_codes(self) -> List[Result]:
"""List all OTP codes with current tokens."""
results = []
if not self.secrets:
results.append(
Result(
title="No OTP accounts configured",
subtitle="Use 'add ' to add your first account",
icon_name="gtk-authentication-symbolic",
action=lambda: None,
relevance=1.0,
plugin_name=self.display_name,
data={"type": "empty", "keep_launcher_open": True},
)
)
results.append(
Result(
title="Available commands:",
subtitle="add | remove | qr ",
icon_name="info",
action=lambda: None,
relevance=0.9,
plugin_name=self.display_name,
data={"type": "help", "keep_launcher_open": True},
)
)
return results
time_display = self._get_time_remaining_with_blink()
for account_name, account_data in self.secrets.items():
secret = account_data.get("secret", "")
issuer = account_data.get("issuer", "")
totp_code = self._generate_totp(secret)
if totp_code:
display_name = f"{issuer} - {account_name}" if issuer else account_name
results.append(
Result(
title=f"{totp_code}",
subtitle_markup=f"{display_name} • {
time_display
} remaining • Shift+Enter: remove",
icon_name="gtk-authentication-symbolic",
action=lambda code=totp_code: self._copy_to_clipboard(code),
relevance=1.0,
plugin_name=self.display_name,
data={
"type": "totp",
"account": account_name,
"code": totp_code,
"alt_action": lambda acc=account_name: self._remove_account_and_refresh(
acc
),
},
)
)
else:
results.append(
Result(
title=f"Error: {account_name}",
subtitle="Invalid secret or configuration",
icon_name="dialog-cancel-symbolic",
action=lambda: None,
relevance=0.5,
plugin_name=self.display_name,
data={
"type": "error",
"account": account_name,
"keep_launcher_open": True,
},
)
)
return results
def _search_accounts(self, query: str) -> List[Result]:
"""Search accounts by name or issuer."""
results = []
query_lower = query.lower()
for account_name, account_data in self.secrets.items():
issuer = account_data.get("issuer", "").lower()
account_lower = account_name.lower()
if query_lower in account_lower or query_lower in issuer:
secret = account_data.get("secret", "")
totp_code = self._generate_totp(secret)
if totp_code:
display_name = (
f"{account_data.get('issuer', '')} - {account_name}"
if account_data.get("issuer")
else account_name
)
time_display = self._get_time_remaining_with_blink()
results.append(
Result(
title=f"{totp_code}",
subtitle_markup=f"{display_name} • {
time_display
} remaining • Shift+Enter: remove",
icon_name="gtk-authentication-symbolic",
action=lambda code=totp_code: self._copy_to_clipboard(code),
relevance=1.0,
plugin_name=self.display_name,
data={
"type": "totp",
"account": account_name,
"code": totp_code,
"alt_action": lambda acc=account_name: self._remove_account_and_refresh(
acc
),
},
)
)
if not results:
results.append(
Result(
title=f"No accounts found for '{query}'",
subtitle="Use 'add ' to create new account",
icon_name="edit-find-symbolic",
action=lambda: None,
relevance=0.5,
plugin_name=self.display_name,
data={"type": "no_results", "keep_launcher_open": True},
)
)
results.append(
Result(
title="Available commands:",
subtitle="add | remove | qr ",
icon_name="info",
action=lambda: None,
relevance=0.4,
plugin_name=self.display_name,
data={"type": "help", "keep_launcher_open": True},
)
)
return results
def _handle_add_command(self, account_name: str) -> List[Result]:
"""Handle manual addition of OTP secret."""
if not account_name:
return [
Result(
title="Enter account name",
subtitle="Usage: add ",
icon_name="dialog-question-symbolic",
action=lambda: None,
relevance=1.0,
plugin_name=self.display_name,
data={"type": "help", "keep_launcher_open": True},
)
]
return [
Result(
title=f"To add '{account_name}':",
subtitle=f"Type: add {account_name} ",
icon_name="info",
action=lambda: None,
relevance=1.0,
plugin_name=self.display_name,
data={
"type": "instruction",
"account": account_name,
"keep_launcher_open": True,
},
),
Result(
title="Base32 Secret Format:",
subtitle="Example: add gmail JBSWY3DPEHPK3PXP",
icon_name="info",
action=lambda: None,
relevance=0.9,
plugin_name=self.display_name,
data={"type": "help", "keep_launcher_open": True},
),
Result(
title="otpauth URI Format:",
subtitle="Example: add github otpauth://totp/GitHub:user?secret=JBSWY3DPEHPK3PXP",
icon_name="info",
action=lambda: None,
relevance=0.9,
plugin_name=self.display_name,
data={"type": "help", "keep_launcher_open": True},
),
Result(
title="Remove Account:",
subtitle="Example: remove gmail",
icon_name="trash",
action=lambda: None,
relevance=0.8,
plugin_name=self.display_name,
data={"type": "help", "keep_launcher_open": True},
),
]
def _handle_qr_command(self, account_name: str) -> List[Result]:
"""Handle QR scanning command."""
if not account_name:
return [
Result(
title="Scan QR Code",
subtitle="Click to scan QR code from screen",
icon_name="view-barcode-qr-symbolic",
action=lambda: self._scan_qr_and_add_account(""),
relevance=1.0,
plugin_name=self.display_name,
data={"type": "qr_scan"},
),
Result(
title="QR Scan Instructions:",
subtitle="Use 'qr ' to specify account name",
icon_name="info",
action=lambda: None,
relevance=0.9,
plugin_name=self.display_name,
data={"type": "help", "keep_launcher_open": True},
),
]
else:
return [
Result(
title=f"Scan QR Code for '{account_name}'",
subtitle="Click to scan QR code from screen",
icon_name="view-barcode-qr-symbolic",
action=lambda name=account_name: self._scan_qr_and_add_account(
name
),
relevance=1.0,
plugin_name=self.display_name,
data={"type": "qr_scan"},
),
]
def _scan_qr_and_add_account(self, account_name: str):
"""Scan QR code and add OTP account."""
print(f"QR scan action called for account: '{account_name}'")
print("Starting QR scan process asynchronously...")
# Run QR scanning asynchronously so launcher closes immediately
import threading
thread = threading.Thread(target=self._scan_qr_async, args=(account_name,))
thread.daemon = True
thread.start()
def _scan_qr_async(self, account_name: str):
"""Async QR scanning process."""
result = scan_qr_and_add_account(account_name, str(self.secrets_file))
if result["success"]:
print(result["message"])
# Reload secrets from file
self._load_secrets()
# Trigger refresh to show the new account
self._trigger_refresh()
else:
print(f"QR scan failed: {result['error']}")
def _handle_remove_command(self, account_name: str) -> List[Result]:
"""Handle removal of OTP account."""
if not account_name:
# Show all available accounts for removal
results = []
if not self.secrets:
results.append(
Result(
title="No OTP accounts to remove",
subtitle="Use 'add ' to add accounts first",
icon_name="info",
action=lambda: None,
relevance=1.0,
plugin_name=self.display_name,
data={"type": "empty", "keep_launcher_open": True},
)
)
else:
results.append(
Result(
title="Select account to remove:",
subtitle="Type: remove to remove an account",
icon_name="user-trash-symbolic",
action=lambda: None,
relevance=1.0,
plugin_name=self.display_name,
data={"type": "help", "keep_launcher_open": True},
)
)
# Get time display for consistency with main OTP view
time_display = self._get_time_remaining_with_blink()
# Show all accounts with their current OTP codes and remove actions
for acc_name, account_data in self.secrets.items():
secret = account_data.get("secret", "")
issuer = account_data.get("issuer", "")
display_name = f"{issuer} - {acc_name}" if issuer else acc_name
# Generate current TOTP code
totp_code = self._generate_totp(secret)
if totp_code:
results.append(
Result(
title=f"{totp_code}",
subtitle_markup=f"Press Enter to remove • {
time_display
} remaining",
icon_name="user-trash-symbolic",
action=lambda acc=acc_name: self._remove_account_and_refresh(
acc
),
relevance=0.9,
plugin_name=self.display_name,
data={
"type": "remove_instruction",
"account": acc_name,
"code": totp_code,
"keep_launcher_open": True,
},
)
)
else:
results.append(
Result(
title=f"Error: {acc_name}",
subtitle="Press Enter to remove (Invalid secret)",
icon_name="user-trash-symbolic",
action=lambda acc=acc_name: self._remove_account_and_refresh(
acc
),
relevance=0.8,
plugin_name=self.display_name,
data={
"type": "remove_instruction",
"account": acc_name,
"keep_launcher_open": True,
},
)
)
return results
# Check if account exists
if account_name not in self.secrets:
return [
Result(
title=f"Account '{account_name}' not found",
subtitle="Use 'remove' to see all available accounts",
icon_name="dialog-cancel-symbolic",
action=lambda: None,
relevance=0.5,
plugin_name=self.display_name,
data={"type": "error", "keep_launcher_open": True},
)
]
# Get account info for confirmation
account_data = self.secrets[account_name]
issuer = account_data.get("issuer", "")
display_name = f"{issuer} - {account_name}" if issuer else account_name
# Show confirmation for removal
return [
Result(
title=f"Remove '{display_name}'?",
subtitle="Press Enter to confirm removal",
icon_name="user-trash-symbolic",
action=lambda acc=account_name: self._remove_account_and_refresh(acc),
relevance=1.0,
plugin_name=self.display_name,
data={
"type": "remove_confirm",
"account": account_name,
"keep_launcher_open": True,
},
)
]
def _handle_base32_secret(self, account_name: str, secret: str) -> List[Result]:
"""Handle raw Base32 secret."""
result = validate_base32_secret(secret)
if not result["success"]:
return [
Result(
title="Invalid Base32 secret",
subtitle=result["error"],
icon_name="dialog-cancel-symbolic",
action=lambda: None,
relevance=0.5,
plugin_name=self.display_name,
data={"type": "error", "keep_launcher_open": True},
)
]
self.secrets[account_name] = {
"secret": result["secret"],
"issuer": "",
"algorithm": "SHA1",
"digits": 6,
"period": 30,
}
self._save_secrets()
return [
Result(
title=f"✓ Added '{account_name}'",
subtitle=f"OTP account added successfully (secret: {
result['secret'][:4]
}...)",
icon_name="emblem-ok-symbolic",
action=lambda: self._trigger_refresh(),
relevance=1.0,
plugin_name=self.display_name,
data={"type": "success", "keep_launcher_open": True},
)
]
def _handle_otpauth_uri(self, account_name: str, uri: str) -> List[Result]:
"""Handle otpauth:// URI."""
result = parse_otpauth_uri(uri, account_name)
if not result["success"]:
return [
Result(
title="Error parsing otpauth URI",
subtitle=result["error"],
icon_name="dialog-cancel-symbolic",
action=lambda: None,
relevance=0.5,
plugin_name=self.display_name,
data={"type": "error", "keep_launcher_open": True},
)
]
self.secrets[result["account_name"]] = {
"secret": result["secret"],
"issuer": result["issuer"],
"algorithm": result["algorithm"],
"digits": result["digits"],
"period": result["period"],
}
self._save_secrets()
display_name = (
f"{result['issuer']} - {result['account_name']}"
if result["issuer"]
else result["account_name"]
)
return [
Result(
title=f"✓ Added '{display_name}'",
subtitle="OTP account added from URI",
icon_name="emblem-ok-symbolic",
action=lambda: self._trigger_refresh(),
relevance=1.0,
plugin_name=self.display_name,
data={"type": "success", "keep_launcher_open": True},
)
]
================================================
FILE: modules/launcher/plugins/password.py
================================================
import base64
import json
import subprocess
import threading
import time
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional
from fabric.utils import get_relative_path
from modules.launcher.plugin_base import PluginBase
from modules.launcher.result import Result
class PasswordManager:
"""Simple password manager with basic encryption and caching."""
def __init__(self, storage_file: Path):
self.storage_file = storage_file
self.passwords: Dict[str, Dict] = {}
self._cache_lock = threading.Lock()
self._last_loaded = 0
self._cache_ttl = 30 # Cache for 30 seconds
self._load_passwords()
def _simple_encrypt(self, text: str, key: str = "modus_pass") -> str:
"""Simple encryption using XOR with base64 encoding."""
key_bytes = key.encode("utf-8")
text_bytes = text.encode("utf-8")
# XOR encryption
encrypted = bytearray()
for i, byte in enumerate(text_bytes):
encrypted.append(byte ^ key_bytes[i % len(key_bytes)])
# Base64 encode
return base64.b64encode(encrypted).decode("utf-8")
def _simple_decrypt(self, encrypted_text: str, key: str = "modus_pass") -> str:
"""Simple decryption using XOR with base64 decoding."""
try:
key_bytes = key.encode("utf-8")
# Base64 decode
encrypted_bytes = base64.b64decode(encrypted_text.encode("utf-8"))
# XOR decryption
decrypted = bytearray()
for i, byte in enumerate(encrypted_bytes):
decrypted.append(byte ^ key_bytes[i % len(key_bytes)])
return decrypted.decode("utf-8")
except Exception:
return encrypted_text # Return as-is if decryption fails
def _load_passwords(self):
"""Load passwords from JSON file with caching."""
with self._cache_lock:
current_time = time.time()
# Check if cache is still valid
if (current_time - self._last_loaded) < self._cache_ttl and self.passwords:
return
try:
if self.storage_file.exists():
with open(self.storage_file, "r", encoding="utf-8") as f:
data = json.load(f)
self.passwords = data.get("passwords", {})
else:
self.passwords = {}
self._last_loaded = current_time
except Exception as e:
print(f"Error loading passwords: {e}")
self.passwords = {}
def _save_passwords(self):
"""Save passwords to JSON file."""
with self._cache_lock:
try:
self.storage_file.parent.mkdir(parents=True, exist_ok=True)
data = {
"passwords": self.passwords,
"last_modified": datetime.now().isoformat(),
}
with open(self.storage_file, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
# Update cache timestamp
self._last_loaded = time.time()
except Exception as e:
print(f"Error saving passwords: {e}")
def add_password(self, name: str, password: str, description: str = "") -> bool:
"""Add a new password entry."""
try:
encrypted_password = self._simple_encrypt(password)
self.passwords[name] = {
"password": encrypted_password,
"description": description,
"created": datetime.now().isoformat(),
"last_accessed": None,
}
self._save_passwords()
return True
except Exception as e:
print(f"Error adding password: {e}")
return False
def get_password(self, name: str, update_access_time: bool = True) -> Optional[str]:
"""Get decrypted password by name."""
# Ensure we have fresh data
self._load_passwords()
if name in self.passwords:
try:
encrypted = self.passwords[name]["password"]
decrypted = self._simple_decrypt(encrypted)
# Update last accessed time only if requested (to avoid frequent saves)
if update_access_time:
self.passwords[name]["last_accessed"] = datetime.now().isoformat()
# Don't save immediately - batch saves for better performance
return decrypted
except Exception as e:
print(f"Error decrypting password: {e}")
return None
return None
def remove_password(self, name: str) -> bool:
"""Remove a password entry."""
if name in self.passwords:
del self.passwords[name]
self._save_passwords()
return True
return False
def list_passwords(self) -> List[str]:
"""Get list of all password names."""
# Ensure we have fresh data
self._load_passwords()
return list(self.passwords.keys())
def get_password_info(self, name: str) -> Optional[Dict]:
"""Get password metadata without decrypting."""
if name in self.passwords:
info = self.passwords[name].copy()
info.pop("password", None) # Remove encrypted password
return info
return None
class PasswordPlugin(PluginBase):
"""
Password manager plugin for the launcher.
Stores passwords securely and allows easy access.
"""
def __init__(self):
super().__init__()
self.display_name = "Password Manager"
self.description = "Secure password storage and management"
# Initialize password manager
self.password_file = Path(
get_relative_path("../../../config/assets/passwords.json")
)
self.password_manager = PasswordManager(self.password_file)
# State for password visibility
self.revealed_passwords: Dict[str, str] = {}
# Cache for results to avoid repeated queries
self._results_cache: Dict[str, List[Result]] = {}
self._cache_timestamps: Dict[str, float] = {}
self._cache_ttl = 5 # Cache results for 5 seconds
# Track launcher state for auto-hiding passwords
self._launcher_instance = None
def initialize(self):
"""Initialize the password plugin."""
self.set_triggers(["pass"])
self._setup_launcher_hooks()
def cleanup(self):
"""Cleanup the password plugin."""
self.revealed_passwords.clear()
self._results_cache.clear()
self._cache_timestamps.clear()
self._cleanup_launcher_hooks()
def query(self, query_string: str) -> List[Result]:
"""Process password manager queries with caching."""
query_key = query_string.strip()
current_time = time.time()
# Check cache first (except for add/remove commands which should always execute)
if (
not query_key.startswith(("add ", "remove ", "delete "))
and query_key in self._results_cache
and (current_time - self._cache_timestamps.get(query_key, 0))
< self._cache_ttl
):
return self._results_cache[query_key]
results = []
query = query_key.lower()
# Handle different commands
if not query:
# Show all passwords
results.extend(self._list_all_passwords())
elif query.startswith("add "):
# Add new password (don't cache)
results.extend(self._handle_add_command(query_string))
elif query.startswith("remove ") or query.startswith("delete "):
# Remove password (don't cache)
results.extend(self._handle_remove_command(query_string))
else:
# Search for specific password
results.extend(self._search_passwords(query))
# Cache results (except for add/remove commands)
if not query.startswith(("add ", "remove ", "delete ")):
self._results_cache[query_key] = results
self._cache_timestamps[query_key] = current_time
return results
def _list_all_passwords(self) -> List[Result]:
"""List all stored passwords."""
results = []
password_names = self.password_manager.list_passwords()
if not password_names:
results.append(
Result(
title="No passwords stored",
subtitle="Use 'pass add ' to add your first password",
icon_name="password-symbolic",
action=lambda: None,
relevance=1.0,
plugin_name=self.display_name,
data={"type": "empty", "keep_launcher_open": True},
)
)
results.append(
Result(
title="Available commands:",
subtitle="add | remove | (to search)",
icon_name="info",
action=lambda: None,
relevance=0.9,
plugin_name=self.display_name,
data={"type": "help", "keep_launcher_open": True},
)
)
return results
# Sort passwords alphabetically
password_names.sort()
for name in password_names:
info = self.password_manager.get_password_info(name)
description = info.get("description", "") if info else ""
# Check if password is revealed
if name in self.revealed_passwords:
title = f"{name}: {self.revealed_passwords[name]}"
subtitle = "Password revealed - Enter: copy | Shift+Enter: hide"
else:
title = f"{name}: {'*' * 8}"
subtitle = "Enter: copy | Shift+Enter: reveal password"
if description:
subtitle += f" | {description}"
results.append(
Result(
title=title,
subtitle=subtitle,
icon_name="key",
action=lambda n=name: self._copy_password_to_clipboard(n),
relevance=1.0,
plugin_name=self.display_name,
data={
"type": "password",
"name": name,
"keep_launcher_open": False,
"alt_action": lambda n=name: self._toggle_password_visibility(
n
),
},
)
)
return results
def _handle_add_command(self, query_string: str) -> List[Result]:
"""Handle add password command."""
results = []
parts = query_string.strip().split(" ", 3)
if len(parts) < 3:
results.append(
Result(
title="Add Password - Invalid format",
subtitle="Usage: add [description]",
icon_name="cancel",
action=lambda: None,
relevance=1.0,
plugin_name=self.display_name,
data={"type": "error", "keep_launcher_open": True},
)
)
return results
name = parts[1]
password = parts[2]
description = parts[3] if len(parts) > 3 else ""
# Check if password already exists
if name in self.password_manager.list_passwords():
results.append(
Result(
title=f"Update password for '{name}'?",
subtitle="Password already exists. Click to update it.",
icon_name="key",
action=lambda: self._add_password_action(
name, password, description, update=True
),
relevance=1.0,
plugin_name=self.display_name,
data={"type": "update", "name": name, "keep_launcher_open": False},
)
)
else:
results.append(
Result(
title=f"Add password for '{name}'",
subtitle="Click to save password"
+ (f" | {description}" if description else ""),
icon_name="plus",
action=lambda: self._add_password_action(
name, password, description
),
relevance=1.0,
plugin_name=self.display_name,
data={"type": "add", "name": name, "keep_launcher_open": False},
)
)
return results
def _handle_remove_command(self, query_string: str) -> List[Result]:
"""Handle remove password command."""
results = []
parts = query_string.strip().split(" ", 1)
if len(parts) < 2:
results.append(
Result(
title="Remove Password - Invalid format",
subtitle="Usage: remove ",
icon_name="cancel",
action=lambda: None,
relevance=1.0,
plugin_name=self.display_name,
data={"type": "error", "keep_launcher_open": True},
)
)
return results
name = parts[1]
if name not in self.password_manager.list_passwords():
results.append(
Result(
title=f"Password '{name}' not found",
subtitle="Check the name and try again",
icon_name="cancel",
action=lambda: None,
relevance=1.0,
plugin_name=self.display_name,
data={"type": "error", "keep_launcher_open": True},
)
)
else:
results.append(
Result(
title=f"Remove password '{name}'?",
subtitle="Click to confirm deletion (this cannot be undone)",
icon_name="trash",
action=lambda: self._remove_password_action(name),
relevance=1.0,
plugin_name=self.display_name,
data={"type": "remove", "name": name, "keep_launcher_open": False},
)
)
return results
def _search_passwords(self, query: str) -> List[Result]:
"""Search for passwords by name."""
results = []
password_names = self.password_manager.list_passwords()
# Filter passwords that match the query
matching_passwords = [
name for name in password_names if query.lower() in name.lower()
]
if not matching_passwords:
results.append(
Result(
title=f"No passwords found matching '{query}'",
subtitle="Try a different search term or use 'pass' to see all passwords",
icon_name="magnifier",
action=lambda: None,
relevance=1.0,
plugin_name=self.display_name,
data={"type": "no_results", "keep_launcher_open": True},
)
)
return results
# Sort by relevance (exact match first, then starts with, then contains)
def get_relevance(name: str) -> float:
name_lower = name.lower()
query_lower = query.lower()
if name_lower == query_lower:
return 1.0
elif name_lower.startswith(query_lower):
return 0.9
else:
return 0.7
matching_passwords.sort(key=get_relevance, reverse=True)
for name in matching_passwords:
info = self.password_manager.get_password_info(name)
description = info.get("description", "") if info else ""
# Check if password is revealed
if name in self.revealed_passwords:
title = f"{name}: {self.revealed_passwords[name]}"
subtitle = "Password revealed - Enter: copy | Shift+Enter: hide"
else:
title = f"{name}: {'*' * 8}"
subtitle = "Enter: copy | Shift+Enter: reveal password"
if description:
subtitle += f" | {description}"
results.append(
Result(
title=title,
subtitle=subtitle,
icon_name="key",
action=lambda n=name: self._copy_password_to_clipboard(n),
relevance=get_relevance(name),
plugin_name=self.display_name,
data={
"type": "password",
"name": name,
"keep_launcher_open": False,
"alt_action": lambda n=name: self._toggle_password_visibility(
n
),
},
)
)
return results
def _add_password_action(
self, name: str, password: str, description: str = "", update: bool = False
):
"""Action to add/update a password."""
try:
success = self.password_manager.add_password(name, password, description)
if success:
action_word = "updated" if update else "added"
# Clear cache to force refresh
self._results_cache.clear()
# Send notification if available (non-blocking)
try:
subprocess.Popen(
[
"notify-send",
"Password Manager",
f"Password '{name}' {action_word} successfully",
]
)
except:
pass
else:
print(f"Failed to add password '{name}'")
except Exception as e:
print(f"Error adding password: {e}")
def _remove_password_action(self, name: str):
"""Action to remove a password."""
try:
success = self.password_manager.remove_password(name)
if success:
# Remove from revealed passwords if present
self.revealed_passwords.pop(name, None)
# Clear cache to force refresh
self._results_cache.clear()
# Send notification if available (non-blocking)
try:
subprocess.Popen(
[
"notify-send",
"Password Manager",
f"Password '{name}' removed successfully",
]
)
except:
pass
else:
print(f"Failed to remove password '{name}'")
except Exception as e:
print(f"Error removing password: {e}")
def _copy_password_to_clipboard(self, name: str):
"""Copy password to clipboard and reveal it temporarily."""
try:
password = self.password_manager.get_password(
name, update_access_time=False
)
if password:
# Copy to clipboard (use timeout to avoid hanging)
try:
subprocess.run(
["wl-copy"], input=password.encode(), check=True, timeout=2
)
except subprocess.SubprocessError:
# Fall back to X11
subprocess.run(
["xclip", "-selection", "clipboard"],
input=password.encode(),
check=True,
timeout=2,
)
# Reveal password temporarily
self.revealed_passwords[name] = password
# Send notification if available (non-blocking)
try:
subprocess.Popen(
[
"notify-send",
"Password Manager",
f"Password for '{name}' copied to clipboard",
]
)
except:
pass
# Clear cache to force refresh
self._results_cache.clear()
else:
print(f"Failed to retrieve password for '{name}'")
except Exception as e:
print(f"Error copying password: {e}")
def _reveal_password(self, name: str):
"""Reveal password without copying to clipboard."""
try:
password = self.password_manager.get_password(
name, update_access_time=False
)
if password:
self.revealed_passwords[name] = password
# Clear cache to force refresh with revealed password
self._results_cache.clear()
else:
print(f"Failed to retrieve password for '{name}'")
except Exception as e:
print(f"Error revealing password: {e}")
def _hide_password(self, name: str):
"""Hide revealed password."""
self.revealed_passwords.pop(name, None)
def _hide_all_passwords(self):
"""Hide all revealed passwords."""
if self.revealed_passwords:
self.revealed_passwords.clear()
# Clear cache to force refresh with hidden passwords
self._results_cache.clear()
def _setup_launcher_hooks(self):
"""Setup hooks to monitor launcher state."""
try:
# Try to find the launcher instance
import gc
for obj in gc.get_objects():
if (
hasattr(obj, "__class__")
and obj.__class__.__name__ == "Launcher"
and hasattr(obj, "close_launcher")
):
self._launcher_instance = obj
# Store original close_launcher method
self._original_close_launcher = obj.close_launcher
# Replace with our wrapper
obj.close_launcher = self._wrapped_close_launcher
break
except Exception as e:
print(f"Warning: Could not setup launcher hooks: {e}")
def _cleanup_launcher_hooks(self):
"""Cleanup launcher hooks."""
try:
if self._launcher_instance and hasattr(self, "_original_close_launcher"):
# Restore original close_launcher method
self._launcher_instance.close_launcher = self._original_close_launcher
self._launcher_instance = None
except Exception as e:
print(f"Warning: Could not cleanup launcher hooks: {e}")
def _wrapped_close_launcher(self):
"""Wrapper for launcher close that hides passwords."""
# Hide all passwords when launcher closes
self._hide_all_passwords()
# Call original close_launcher method
if hasattr(self, "_original_close_launcher"):
self._original_close_launcher()
def _toggle_password_visibility(self, name: str):
"""Toggle password visibility when Shift+Enter is pressed."""
if name in self.revealed_passwords:
# Hide password
self.revealed_passwords.pop(name, None)
self._results_cache.clear()
else:
# Reveal password
try:
password = self.password_manager.get_password(
name, update_access_time=False
)
if password:
self.revealed_passwords[name] = password
self._results_cache.clear()
else:
print(f"Failed to retrieve password for '{name}'")
except Exception as e:
print(f"Error revealing password: {e}")
# Force refresh of the launcher to show updated state
self._force_launcher_refresh()
def _force_launcher_refresh(self):
"""Force the launcher to refresh and show updated results."""
try:
if self._launcher_instance and hasattr(
self._launcher_instance, "_perform_search"
):
# Get current search text
current_text = ""
if hasattr(self._launcher_instance, "search_entry"):
current_text = self._launcher_instance.search_entry.get_text()
# Trigger a search to refresh results
try:
from gi.repository import GLib
def refresh():
self._launcher_instance._perform_search(current_text)
return False
GLib.timeout_add(50, refresh)
except ImportError:
# Fallback: direct call if GLib not available
self._launcher_instance._perform_search(current_text)
except Exception as e:
print(f"Could not force launcher refresh: {e}")
================================================
FILE: modules/launcher/plugins/power.py
================================================
from typing import List
from fabric.utils import exec_shell_command_async
from modules.launcher.plugin_base import PluginBase
from modules.launcher.result import Result
class PowerPlugin(PluginBase):
"""
Plugin for system power management operations.
"""
def __init__(self):
super().__init__()
self.display_name = "Power"
self.description = "System power management and control"
self.commands = {
"shutdown": {
"description": "Shutdown the system",
"icon": "system-shutdown-symbolic",
"action": self.shutdown,
},
"restart": {
"description": "Restart the system",
"icon": "system-reboot-symbolic",
"action": self.restart,
},
"lock": {
"description": "Lock the screen",
"icon": "system-lock-screen-symbolic",
"action": self.lock,
},
"suspend": {
"description": "Suspend the system",
"icon": "system-suspend-symbolic",
"action": self.suspend,
},
"logout": {
"description": "Logout from current session",
"icon": "system-log-out-symbolic",
"action": self.logout,
},
}
def initialize(self):
"""Initialize the power plugin."""
self.set_triggers(["power"])
def cleanup(self):
"""Cleanup the power plugin."""
pass
def query(self, query_string: str) -> List[Result]:
"""Search power commands based on query."""
query = query_string.lower().strip()
results = []
# If no query, show all power commands
if not query:
for cmd, info in self.commands.items():
result = Result(
title=cmd.capitalize(),
subtitle=info["description"],
icon_name=info["icon"],
action=info["action"],
relevance=1.0,
plugin_name=self.display_name,
data={"command": cmd},
)
results.append(result)
else:
# Filter commands based on query
for cmd, info in self.commands.items():
if query in cmd.lower() or query in info["description"].lower():
result = Result(
title=cmd.capitalize(),
subtitle=info["description"],
icon_name=info["icon"],
action=info["action"],
relevance=1.0 if query == cmd else 0.7,
plugin_name=self.display_name,
data={"command": cmd},
)
results.append(result)
return results
def shutdown(self, *args) -> None:
exec_shell_command_async("systemctl poweroff")
def restart(self, *args) -> None:
exec_shell_command_async("systemctl reboot")
def lock(self, *args) -> None:
exec_shell_command_async("loginctl lock-session")
def suspend(self, *args) -> None:
exec_shell_command_async("systemctl suspend")
def logout(self, *args) -> None:
exec_shell_command_async("hyprctl dispatch exit")
================================================
FILE: modules/launcher/plugins/reminders.py
================================================
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
================================================
FILE: modules/launcher/plugins/screencapture.py
================================================
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
================================================
FILE: modules/launcher/plugins/system.py
================================================
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}")
================================================
FILE: modules/launcher/plugins/tmux.py
================================================
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}"
================================================
FILE: modules/launcher/plugins/wallpaper.py
================================================
import colorsys
import hashlib
import json
import os
import random
import re
import threading
import time
from typing import Dict, List, Optional
from loguru import logger
from gi.repository import GdkPixbuf
from PIL import Image
import config.data as data
from fabric.utils.helpers import exec_shell_command_async
from modules.launcher.plugin_base import PluginBase
from modules.launcher.result import Result
class WallpaperPlugin(PluginBase):
"""
Plugin for wallpaper management with search, random selection, and matugen integration.
"""
def __init__(self):
super().__init__()
self.display_name = "Wallpaper"
self.wallpapers = []
self.cache_dir = f"{data.CACHE_DIR}/thumbs"
self.thumbnail_cache: Dict[str, Optional[GdkPixbuf.Pixbuf]] = {}
self.thumbnail_loading = set() # Track which thumbnails are being loaded
self.schemes = {
"scheme-tonal-spot": "Tonal Spot",
"scheme-content": "Content",
"scheme-expressive": "Expressive",
"scheme-fidelity": "Fidelity",
"scheme-fruit-salad": "Fruit Salad",
"scheme-monochrome": "Monochrome",
"scheme-neutral": "Neutral",
"scheme-rainbow": "Rainbow",
}
def initialize(self):
"""Initialize the wallpaper plugin."""
self.set_triggers(["wall"])
self._load_wallpapers()
os.makedirs(self.cache_dir, exist_ok=True)
# Start background thumbnail creation
self._start_background_thumbnail_creation()
def cleanup(self):
"""Cleanup the wallpaper plugin."""
pass
def _load_wallpapers(self):
"""Load available wallpapers from the wallpapers directory."""
try:
if os.path.exists(data.WALLPAPERS_DIR):
self.wallpapers = sorted(
[f for f in os.listdir(data.WALLPAPERS_DIR) if self._is_image(f)]
)
else:
logger.error(f"Wallpapers directory not found: {data.WALLPAPERS_DIR}")
except Exception as e:
logger.error(f"Error loading wallpapers: {e}")
def _is_image(self, filename: str) -> bool:
"""Check if file is a supported image format."""
return filename.lower().endswith(
(".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp")
)
def _get_matugen_state(self) -> bool:
"""Get current matugen state from config.json."""
self.matugen_enabled = True # Default to True
try:
with open(data.CONFIG_FILE, "r") as f:
config = json.load(f)
self.matugen_enabled = config.get("matugen_enabled", True)
except FileNotFoundError:
# File doesn't exist, keep default True
pass
except Exception as e:
logger.error(f"Error reading config file: {e}")
# Keep default True on error
return self.matugen_enabled
def _set_matugen_state(self, enabled: bool):
"""Set matugen state and save to config.json."""
self.matugen_enabled = enabled
# Save the state to config.json
try:
# Read current config
config = {}
if os.path.exists(data.CONFIG_FILE):
with open(data.CONFIG_FILE, "r") as f:
config = json.load(f)
# Update matugen state
config["matugen_enabled"] = enabled
# Write back to config file
with open(data.CONFIG_FILE, "w") as f:
json.dump(config, f, indent=4)
except Exception as e:
logger.error(f"Error writing matugen state to config: {e}")
# Clear the search query after toggling matugen
self._clear_launcher_query()
# # Send notification
# status = "enabled" if enabled else "disabled"
# exec_shell_command_async(
# f"notify-send '🎨 Matugen' 'Dynamic colors {status}' -a '{
# data.APP_NAME_CAP
# }' -e"
# )
def _clear_launcher_query(self):
"""Clear the launcher search query and reset to trigger."""
try:
# Try to access the launcher through the fabric Application
from gi.repository import GLib
from fabric import Application
app = Application.get_default()
if app and hasattr(app, "launcher"):
launcher = app.launcher
if launcher and hasattr(launcher, "search_entry"):
def clear_and_refresh():
# Clear the search entry to just the trigger
launcher.search_entry.set_text("wall ")
# Position cursor at the end
launcher.search_entry.set_position(-1)
# Trigger the search to show default wallpaper view
if hasattr(launcher, "_perform_search"):
launcher._perform_search("wall ")
return False
# Use a small delay to ensure the action completes first
GLib.timeout_add(50, clear_and_refresh)
return
# Fallback: try to find launcher instance through other means
import gc
for obj in gc.get_objects():
if hasattr(obj, "__class__") and obj.__class__.__name__ == "Launcher":
if hasattr(obj, "search_entry") and hasattr(obj, "_perform_search"):
def clear_and_refresh():
obj.search_entry.set_text("wall ")
obj.search_entry.set_position(-1)
obj._perform_search("wall ")
return False
GLib.timeout_add(50, clear_and_refresh)
return
except Exception as e:
logger.error(f"Could not clear launcher query: {e}")
def _get_cache_path(self, filename: str) -> str:
"""Get cache path for wallpaper thumbnail."""
file_hash = hashlib.md5(filename.encode("utf-8")).hexdigest()
return os.path.join(self.cache_dir, f"{file_hash}.png")
def _create_thumbnail(self, filename: str) -> str:
"""Create thumbnail for wallpaper if it doesn't exist."""
full_path = os.path.join(data.WALLPAPERS_DIR, filename)
cache_path = self._get_cache_path(filename)
if not os.path.exists(cache_path):
try:
with Image.open(full_path) as img:
# Use faster thumbnail creation with smaller size for better performance
img.thumbnail((32, 32), Image.Resampling.LANCZOS)
img.save(cache_path, "PNG", optimize=True)
except Exception as e:
logger.error(f"Error creating thumbnail for {filename}: {e}")
return None
return cache_path
def _start_background_thumbnail_creation(self):
"""Start background thread to create thumbnails for all wallpapers."""
def create_thumbnails():
for wallpaper in self.wallpapers:
if wallpaper not in self.thumbnail_loading:
self.thumbnail_loading.add(wallpaper)
try:
cache_path = self._create_thumbnail(wallpaper)
if cache_path and os.path.exists(cache_path):
# Load thumbnail into memory cache
try:
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
cache_path, 32, 32, True
)
self.thumbnail_cache[wallpaper] = pixbuf
except Exception as e:
logger.error(
f"Error loading thumbnail for {wallpaper}: {e}"
)
self.thumbnail_cache[wallpaper] = None
else:
self.thumbnail_cache[wallpaper] = None
except Exception as e:
logger.error(f"Error processing thumbnail for {wallpaper}: {e}")
self.thumbnail_cache[wallpaper] = None
finally:
self.thumbnail_loading.discard(wallpaper)
# Small delay to prevent overwhelming the system
time.sleep(0.01)
# Start background thread
thread = threading.Thread(target=create_thumbnails, daemon=True)
thread.start()
def _get_thumbnail_fast(self, filename: str) -> Optional[GdkPixbuf.Pixbuf]:
"""Get thumbnail quickly from cache or return None if not ready."""
# Return cached thumbnail if available
if filename in self.thumbnail_cache:
return self.thumbnail_cache[filename]
# Check if thumbnail file exists and load it immediately
cache_path = self._get_cache_path(filename)
if os.path.exists(cache_path):
try:
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
cache_path, 32, 32, True
)
self.thumbnail_cache[filename] = pixbuf
return pixbuf
except Exception as e:
logger.error(f"Error loading thumbnail for {filename}: {e}")
self.thumbnail_cache[filename] = None
return None
# If not in cache and file doesn't exist, trigger background creation
if filename not in self.thumbnail_loading:
self.thumbnail_loading.add(filename)
def create_async():
try:
cache_path = self._create_thumbnail(filename)
if cache_path and os.path.exists(cache_path):
try:
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
cache_path, 32, 32, True
)
self.thumbnail_cache[filename] = pixbuf
except Exception as e:
print(f"Error loading thumbnail for {filename}: {e}")
self.thumbnail_cache[filename] = None
else:
self.thumbnail_cache[filename] = None
except Exception as e:
print(f"Error creating thumbnail for {filename}: {e}")
self.thumbnail_cache[filename] = None
finally:
self.thumbnail_loading.discard(filename)
thread = threading.Thread(target=create_async, daemon=True)
thread.start()
return None
def _set_wallpaper(self, filename: str, scheme: str = None):
"""Set wallpaper and apply matugen color scheme if enabled."""
full_path = os.path.join(data.WALLPAPERS_DIR, filename)
current_wall = os.path.expanduser("~/.current.wall")
if scheme is None:
scheme = self._get_current_scheme()
# Update current wallpaper symlink
if os.path.isfile(current_wall) or os.path.islink(current_wall):
os.remove(current_wall)
os.symlink(full_path, current_wall)
# Always set the wallpaper image
exec_shell_command_async(
f'swww img "{
full_path
}" -t outer --transition-duration 1.5 --transition-step 255 --transition-fps 60 -f Nearest'
)
# If Matugen is enabled, also apply the color scheme
matugen_enabled = self._get_matugen_state()
if matugen_enabled:
exec_shell_command_async(f'matugen image "{full_path}" -t {scheme}')
def _set_random_wallpaper(self):
"""Set a random wallpaper."""
if not self.wallpapers:
return
filename = random.choice(self.wallpapers)
self._set_wallpaper(filename)
# Show notification for immediate feedback
exec_shell_command_async(
f"notify-send '🎲 Random Wallpaper' 'Applied: {filename}' -a '{
data.APP_NAME_CAP
}' -e"
)
return filename
def _hsl_to_rgb_hex(self, h: float, s: float = 1.0, l: float = 0.5) -> str:
"""Convert HSL color value to RGB HEX string."""
# colorsys uses HLS, not HSL, and expects values between 0.0 and 1.0
hue = h / 360.0
r, g, b = colorsys.hls_to_rgb(hue, l, s) # Note the order: H, L, S
r_int, g_int, b_int = int(r * 255), int(g * 255), int(b * 255)
return f"#{r_int:02X}{g_int:02X}{b_int:02X}"
def _is_valid_hex_color(self, hex_color: str) -> bool:
"""Check if string is a valid hex color."""
if not hex_color.startswith("#"):
hex_color = "#" + hex_color
return bool(re.match(r"^#[0-9A-Fa-f]{6}$", hex_color))
def _get_current_scheme(self) -> str:
"""Get current color scheme from config (default to tonal-spot)."""
try:
with open(data.CONFIG_FILE, "r") as f:
config = json.load(f)
return config.get("current_scheme", "scheme-tonal-spot")
except FileNotFoundError:
# File doesn't exist, return default
return "scheme-tonal-spot"
except Exception as e:
print(f"Error reading current scheme from config: {e}")
return "scheme-tonal-spot"
def _set_current_scheme(self, scheme: str):
"""Set current color scheme and apply it."""
scheme_name = self.schemes.get(scheme, scheme)
matugen_enabled = self._get_matugen_state()
# Save the scheme to config
try:
# Read current config
config = {}
if os.path.exists(data.CONFIG_FILE):
with open(data.CONFIG_FILE, "r") as f:
config = json.load(f)
# Update current scheme
config["current_scheme"] = scheme
# Write back to config file
with open(data.CONFIG_FILE, "w") as f:
json.dump(config, f, indent=4)
except Exception as e:
print(f"Error saving current scheme to config: {e}")
return
# Apply the scheme to current wallpaper if matugen is enabled
if matugen_enabled:
current_wall = os.path.expanduser("~/.current.wall")
if os.path.exists(current_wall) and os.path.islink(current_wall):
# Get the current wallpaper path
wallpaper_path = os.readlink(current_wall)
if os.path.exists(wallpaper_path):
# Apply the new scheme to current wallpaper
exec_shell_command_async(
f'matugen image "{wallpaper_path}" -t {scheme}'
)
# Send notification
exec_shell_command_async(
f"notify-send '🎨 Color Scheme' 'Applied {
scheme_name
} scheme' -a '{data.APP_NAME_CAP}' -e"
)
else:
# No current wallpaper, just show scheme change notification
exec_shell_command_async(
f"notify-send '🎨 Color Scheme' 'Set to {
scheme_name
} (will apply to next wallpaper)' -a '{data.APP_NAME_CAP}' -e"
)
else:
# No current wallpaper, just show scheme change notification
exec_shell_command_async(
f"notify-send '🎨 Color Scheme' 'Set to {
scheme_name
} (will apply to next wallpaper)' -a '{data.APP_NAME_CAP}' -e"
)
else:
# Matugen is disabled, just save the setting
exec_shell_command_async(
f"notify-send '🎨 Color Scheme' 'Set to {
scheme_name
} (matugen disabled)' -a '{data.APP_NAME_CAP}' -e"
)
def _apply_hex_color(self, hex_color: str, scheme: str = None):
"""Apply hex color using matugen. Assumes matugen is enabled."""
if not hex_color.startswith("#"):
hex_color = "#" + hex_color
if scheme is None:
scheme = self._get_current_scheme()
exec_shell_command_async(f'matugen color hex "{hex_color}" -t {scheme}')
def _apply_hex_color_direct(self, hex_color: str, scheme: str = None):
"""Apply hex color using matugen (following example_wallpapers.py pattern)."""
if not hex_color.startswith("#"):
hex_color = "#" + hex_color
if scheme is None:
scheme = self._get_current_scheme()
exec_shell_command_async(f'matugen color hex "{hex_color}" -t {scheme}')
# Send notification
exec_shell_command_async(
f"notify-send '🎨 Hex Color Applied' 'Applied color: {hex_color}' -a '{
data.APP_NAME_CAP
}' -e"
)
def _generate_random_hex_color(self) -> str:
"""Generate a random hex color."""
hue = random.randint(0, 360)
return self._hsl_to_rgb_hex(hue)
def _get_status_indicators(self) -> tuple:
"""Get current status indicators for display."""
current_scheme = self._get_current_scheme()
matugen_enabled = self._get_matugen_state()
scheme_name = self.schemes.get(current_scheme, current_scheme)
indicators = []
if not matugen_enabled:
indicators.append("Matugen Off")
indicator_text = " • " + " • ".join(indicators) if indicators else ""
status_text = f"Matugen: {
'Enabled' if matugen_enabled else 'Disabled'
} • Scheme: {scheme_name}"
return indicator_text, status_text, current_scheme, matugen_enabled
def query(self, query_string: str) -> List[Result]:
"""Search wallpapers and provide management options."""
results = []
query = query_string.lower().strip()
# Get status indicators for consistent display
indicator_text, status_text, current_scheme, matugen_enabled = (
self._get_status_indicators()
)
# Special commands
if query.strip() == "random":
# Show result for random wallpaper (execute on Enter)
results.append(
Result(
title=f"Random Wallpaper{indicator_text}",
subtitle=f"Set a random wallpaper • {status_text}",
icon_name="media-playlist-shuffle-symbolic",
action=lambda: self._set_random_wallpaper(),
relevance=1.0,
plugin_name=self.display_name,
data={
"action": "random",
"bypass_max_results": True,
"keep_launcher_open": True,
},
)
)
elif query.startswith("random") and query.strip() != "random":
# Show suggestion for random wallpaper (partial match)
results.append(
Result(
title=f"Random Wallpaper{indicator_text}",
subtitle=f"Set a random wallpaper • {status_text}",
icon_name="media-playlist-shuffle-symbolic",
action=lambda: self._set_random_wallpaper(),
relevance=0.9,
plugin_name=self.display_name,
data={
"action": "random_suggestion",
"bypass_max_results": True,
"keep_launcher_open": True,
},
)
)
# Hex color commands
if "color" in query or "hex" in query or query.startswith("#"):
# Check for scheme specification in the query
scheme = self._get_current_scheme()
for scheme_id, scheme_name in self.schemes.items():
if (
scheme_name.lower() in query.lower()
or scheme_id.lower() in query.lower()
):
scheme = scheme_id
break
# Check for hex color patterns
hex_match = re.search(r"#?([0-9A-Fa-f]{6})", query)
if hex_match:
hex_color = "#" + hex_match.group(1)
scheme_name = self.schemes.get(scheme, scheme)
# Check if this is a complete hex color input (6 digits)
# Execute immediately when we have a complete 6-digit hex color
if len(hex_match.group(1)) == 6:
# Check if there's additional text after the hex color
hex_end_pos = hex_match.end()
remaining_text = query[hex_end_pos:].strip()
# Only show result if no additional text after hex color (exact match)
if not remaining_text:
# Hex colors work when matugen is OFF (following example_wallpapers.py pattern)
if not matugen_enabled:
# Show result for hex color (execute on Enter)
results.append(
Result(
title=f"Apply Hex Color: {hex_color}{
indicator_text
}",
subtitle=f"Apply with {scheme_name} scheme • {
status_text
}",
icon_name="color-picker-symbolic",
action=lambda c=hex_color, s=scheme: self._apply_hex_color_direct(
c, s
),
relevance=1.0,
plugin_name=self.display_name,
data={
"action": "hex_color",
"color": hex_color,
"scheme": scheme,
"bypass_max_results": True,
"keep_launcher_open": True,
},
)
)
else:
# Show error result when matugen is enabled
results.append(
Result(
title=f"Cannot Apply Hex Color: {hex_color}{
indicator_text
}",
subtitle="Matugen is enabled • Disable matugen to use hex colors",
icon_name="color-picker-symbolic",
action=lambda: None,
relevance=1.0,
plugin_name=self.display_name,
data={
"action": "hex_color_failed",
"color": hex_color,
"bypass_max_results": True,
},
)
)
else:
# Show suggestion for hex color with additional text (partial match)
results.append(
Result(
title=f"Apply Hex Color: {hex_color}{indicator_text}",
subtitle=f"Apply with {scheme_name} scheme • {
status_text
}",
icon_name="color-picker-symbolic",
action=lambda c=hex_color, s=scheme: (
self._apply_hex_color_direct(c, s)
if not matugen_enabled
else None
),
relevance=0.9,
plugin_name=self.display_name,
data={
"action": "hex_color_suggestion",
"color": hex_color,
"scheme": scheme,
"bypass_max_results": True,
"keep_launcher_open": True,
},
)
)
else:
# Incomplete hex color, show as suggestion
results.append(
Result(
title=f"Hex Color (incomplete): {hex_color}",
subtitle="Complete the 6-digit hex color to apply",
icon_name="color-picker-symbolic",
action=lambda: None,
relevance=0.7,
plugin_name=self.display_name,
data={
"action": "hex_color_incomplete",
"color": hex_color,
"bypass_max_results": True,
},
)
)
elif "random" in query and ("color" in query or "hex" in query):
# Check for exact matches for random color commands
if (
query.strip() == "color random"
or query.strip() == "hex random"
or query.strip() == "random color"
or query.strip() == "random hex"
):
# Random hex color - show result (execute on Enter)
scheme_name = self.schemes.get(scheme, scheme)
# Check if matugen is disabled (hex colors work when matugen is OFF)
if not matugen_enabled:
results.append(
Result(
title=f"Random Hex Color{indicator_text}",
subtitle=f"Generate and apply random color with {
scheme_name
} scheme • {status_text}",
icon_name="color-picker-symbolic",
action=lambda s=scheme: self._apply_hex_color_direct(
self._generate_random_hex_color(), s
),
relevance=1.0,
plugin_name=self.display_name,
data={
"action": "random_hex",
"scheme": scheme,
"bypass_max_results": True,
"keep_launcher_open": True,
},
)
)
else:
# Show error result when matugen is enabled
results.append(
Result(
title=f"Cannot Apply Random Color{indicator_text}",
subtitle="Matugen is enabled • Disable matugen to use hex colors",
icon_name="color-picker-symbolic",
action=lambda: None,
relevance=1.0,
plugin_name=self.display_name,
data={
"action": "random_hex_failed",
"bypass_max_results": True,
},
)
)
else:
# Show suggestion for random hex color (partial match)
results.append(
Result(
title=f"Random Hex Color{indicator_text}",
subtitle=f"Generate and apply random color • {status_text}",
icon_name="color-picker-symbolic",
action=lambda s=scheme: (
self._apply_hex_color_direct(
self._generate_random_hex_color(), s
)
if not matugen_enabled
else None
),
relevance=0.8,
plugin_name=self.display_name,
data={
"action": "random_hex_suggestion",
"scheme": scheme,
"bypass_max_results": True,
"keep_launcher_open": True,
},
)
)
else:
# Show hex color help
results.append(
Result(
title="Hex Color Commands",
subtitle="Use: color #FF5733, hex #00FF00, color random",
icon_name="color-picker-symbolic",
action=lambda: None,
relevance=0.8,
plugin_name=self.display_name,
data={"action": "hex_help", "bypass_max_results": True},
)
)
# Color scheme commands
if "scheme" in query:
current_scheme = self._get_current_scheme()
matugen_enabled = self._get_matugen_state()
# Show all available schemes
for scheme_id, scheme_name in self.schemes.items():
# Check if this scheme matches the query (for filtering)
if query.strip() == "scheme":
# Show all schemes when just "scheme" is typed
scheme_matches = True
else:
# Extract search terms from query (remove "scheme" keyword)
search_terms = query.replace("scheme", "").strip().split()
scheme_matches = False
# Check if any search term matches the scheme name or ID
for term in search_terms:
if (
term.lower() in scheme_name.lower()
or term.lower() in scheme_id.lower()
):
scheme_matches = True
break
if scheme_matches:
# Create indicators
indicators = []
if scheme_id == current_scheme:
indicators.append("Current")
if not matugen_enabled:
indicators.append("Matugen Off")
indicator_text = (
" • " + " • ".join(indicators) if indicators else ""
)
results.append(
Result(
title=f"{scheme_name}{indicator_text}",
subtitle=f"Set color scheme to {scheme_name}"
+ (
" (requires matugen enabled)"
if not matugen_enabled
else ""
),
icon_name="color-management-symbolic",
action=lambda s=scheme_id: self._set_current_scheme(s),
relevance=1.0 if scheme_id == current_scheme else 0.8,
plugin_name=self.display_name,
data={
"action": "scheme_select",
"scheme": scheme_id,
"bypass_max_results": True,
"keep_launcher_open": True,
},
)
)
# Matugen controls
if "matugen" in query:
current_state = self._get_matugen_state()
# Check for exact command matches
if query.strip() == "matugen on" or query.strip() == "matugen enable":
# Show result for enabling matugen (execute on Enter)
results.append(
Result(
title=f"Enable Matugen{indicator_text}",
subtitle=f"Turn on dynamic color generation • {status_text}",
icon_name="color-management-symbolic",
action=lambda: self._set_matugen_state(True),
relevance=0.9,
plugin_name=self.display_name,
data={
"action": "matugen_on",
"bypass_max_results": True,
"keep_launcher_open": True,
},
)
)
elif query.strip() == "matugen off" or query.strip() == "matugen disable":
# Show result for disabling matugen (execute on Enter)
results.append(
Result(
title=f"Disable Matugen{indicator_text}",
subtitle=f"Turn off dynamic color generation • {status_text}",
icon_name="color-management-symbolic",
action=lambda: self._set_matugen_state(False),
relevance=0.9,
plugin_name=self.display_name,
data={
"action": "matugen_off",
"bypass_max_results": True,
"keep_launcher_open": True,
},
)
)
elif query.strip() == "matugen toggle":
# Show result for toggling matugen (execute on Enter)
new_state = not current_state
results.append(
Result(
title=f"Toggle Matugen to {
'Enabled' if new_state else 'Disabled'
}{indicator_text}",
subtitle=f"Switch matugen to {
'enabled' if new_state else 'disabled'
} • {status_text}",
icon_name="color-management-symbolic",
action=lambda: self._set_matugen_state(new_state),
relevance=0.9,
plugin_name=self.display_name,
data={
"action": "matugen_toggle",
"bypass_max_results": True,
"keep_launcher_open": True,
},
)
)
elif ("on" in query or "enable" in query) and not query.strip().endswith(
("on", "enable")
):
# Show suggestion for enabling (partial match)
results.append(
Result(
title=f"Enable Matugen{indicator_text}",
subtitle=f"Turn on dynamic color generation • {status_text}",
icon_name="color-management-symbolic",
action=lambda: self._set_matugen_state(True),
relevance=0.8,
plugin_name=self.display_name,
data={
"action": "matugen_on_suggestion",
"bypass_max_results": True,
"keep_launcher_open": True,
},
)
)
elif ("off" in query or "disable" in query) and not query.strip().endswith(
("off", "disable")
):
# Show suggestion for disabling (partial match)
results.append(
Result(
title=f"Disable Matugen{indicator_text}",
subtitle=f"Turn off dynamic color generation • {status_text}",
icon_name="color-management-symbolic",
action=lambda: self._set_matugen_state(False),
relevance=0.8,
plugin_name=self.display_name,
data={
"action": "matugen_off_suggestion",
"bypass_max_results": True,
"keep_launcher_open": True,
},
)
)
elif "toggle" in query and not query.strip().endswith("toggle"):
# Show suggestion for toggling (partial match)
results.append(
Result(
title=f"Toggle Matugen{indicator_text}",
subtitle=f"Switch matugen state • {status_text}",
icon_name="color-management-symbolic",
action=lambda: self._set_matugen_state(not current_state),
relevance=0.8,
plugin_name=self.display_name,
data={
"action": "matugen_toggle_suggestion",
"bypass_max_results": True,
"keep_launcher_open": True,
},
)
)
else:
# Show current state with scheme info
results.append(
Result(
title=f"Matugen: {'Enabled' if current_state else 'Disabled'}{
indicator_text
}",
subtitle=f"Dynamic colors • {status_text}",
icon_name="color-management-symbolic",
action=lambda: None,
relevance=0.8,
plugin_name=self.display_name,
data={"action": "matugen_status", "bypass_max_results": True},
)
)
# Status command
if query == "status" or query == "info":
results.append(
Result(
title=f"Wallpaper System Status{indicator_text}",
subtitle=status_text,
icon_name="color-management-symbolic",
action=lambda: None,
relevance=1.0,
plugin_name=self.display_name,
data={"action": "status", "bypass_max_results": True},
)
)
# Show quick actions
results.append(
Result(
title=f"Random Wallpaper{indicator_text}",
subtitle=f"Set a random wallpaper • {status_text}",
icon_name="media-playlist-shuffle-symbolic",
action=lambda: self._set_random_wallpaper(),
relevance=0.9,
plugin_name=self.display_name,
data={
"action": "random_quick",
"bypass_max_results": True,
"keep_launcher_open": True,
},
)
)
# Search wallpapers by filename - Show ALL wallpapers like example_wallpapers.py
if not query or (
query
and "matugen" not in query
and "random" not in query
and "scheme" not in query
and "status" not in query
and "info" not in query
and "color" not in query
and "hex" not in query
):
matching_wallpapers = []
for wallpaper in self.wallpapers:
if not query or query in wallpaper.lower():
# Calculate relevance
relevance = 1.0 if query == wallpaper.lower() else 0.7
if query and query in wallpaper.lower():
relevance = 0.8
matching_wallpapers.append((wallpaper, relevance))
# Sort by relevance and show ALL wallpapers (like example_wallpapers.py)
matching_wallpapers.sort(key=lambda x: x[1], reverse=True)
# Show ALL wallpapers instead of limiting (following example_wallpapers.py pattern)
for wallpaper, relevance in matching_wallpapers:
# Use fast thumbnail loading
icon = self._get_thumbnail_fast(wallpaper)
results.append(
Result(
title=f"{wallpaper}{indicator_text if not query else ''}",
subtitle=f"Set as wallpaper{
' • ' + status_text if not query else ''
}",
icon=icon,
icon_name="image-x-generic-symbolic" if not icon else None,
action=lambda w=wallpaper: self._set_wallpaper(w),
relevance=relevance,
plugin_name=self.display_name,
data={
"wallpaper": wallpaper,
"action": "set",
"bypass_max_results": True,
"keep_launcher_open": True,
},
)
)
# Sort by relevance - no limit since we use bypass_max_results
results.sort(key=lambda x: x.relevance, reverse=True)
return results
================================================
FILE: modules/launcher/plugins/websearch.py
================================================
import subprocess
import urllib.parse
from typing import List
from modules.launcher.plugin_base import PluginBase
from modules.launcher.result import Result
class WebSearchPlugin(PluginBase):
"""
Web search plugin that supports multiple search engines.
"""
def __init__(self):
super().__init__()
self.display_name = "Web Search"
self.description = "Search the web using various search engines"
self.current_trigger = "" # Track the current active trigger
# Define search engines with their URLs and icons
self.search_engines = {
"google": {
"name": "Google",
"url": "https://www.google.com/search?q={}",
"icon": "google-symbolic",
"description": "Search with Google",
},
"duckduckgo": {
"name": "DuckDuckGo",
"url": "https://duckduckgo.com/?q={}",
"icon": "preferences-desktop-search",
"description": "Search with DuckDuckGo (privacy-focused)",
},
"youtube": {
"name": "YouTube",
"url": "https://www.youtube.com/results?search_query={}",
"icon": "youtube-symbolic",
"description": "Search videos on YouTube",
},
"github": {
"name": "GitHub",
"url": "https://github.com/search?q={}",
"icon": "github-desktop-symbolic",
"description": "Search repositories on GitHub",
},
"stackoverflow": {
"name": "Stack Overflow",
"url": "https://stackoverflow.com/search?q={}",
"icon": "stack",
"description": "Search programming questions on Stack Overflow",
},
"wikipedia": {
"name": "Wikipedia",
"url": "https://en.wikipedia.org/wiki/Special:Search?search={}",
"icon": "search-menus-symbolic",
"description": "Search articles on Wikipedia",
},
"reddit": {
"name": "Reddit",
"url": "https://www.reddit.com/search/?q={}",
"icon": "reddit-symbolic",
"description": "Search discussions on Reddit",
},
"linkedin": {
"name": "LinkedIn",
"url": "https://www.linkedin.com/search/results/all/?keywords={}",
"icon": "link",
"description": "Search professionals and jobs on LinkedIn",
},
}
# Default search engine
self.default_engine = "google"
def initialize(self):
"""Initialize the web search plugin."""
# Set up triggers for web search - only main triggers, not individual search engines
triggers = ["?"]
# Don't add individual search engine triggers to avoid cluttering trigger keywords
# Search engines can still be used within web search context (e.g., "web google cats")
self.set_triggers(triggers)
def get_active_trigger(self, query_string: str) -> str:
"""Override to track which trigger was activated."""
trigger = super().get_active_trigger(query_string)
if trigger:
self.current_trigger = trigger.strip()
return trigger
def cleanup(self):
"""Cleanup the web search plugin."""
pass
def query(self, query_string: str) -> List[Result]:
"""Process web search queries."""
results = []
if not query_string.strip():
# Show available search engines when no query
return self._get_search_engine_list()
# Check if the query is a URL
if self._is_url(query_string):
results.append(self._create_url_result(query_string))
return results
# Parse query to check if it starts with a search engine name
engine_name, search_query = self._parse_engine_query(query_string)
if engine_name and engine_name in self.search_engines:
# Specific search engine specified in query (e.g., "google cats")
if search_query:
# Search with specific engine
results.append(self._create_search_result(engine_name, search_query))
else:
# Show specific engine info
results.append(self._create_engine_info_result(engine_name))
else:
# General search - offer multiple engines
search_query = query_string.strip()
# Add default search engine first
results.append(
self._create_search_result(self.default_engine, search_query)
)
# Add other popular search engines
popular_engines = ["duckduckgo", "youtube", "github"]
for engine in popular_engines:
if engine != self.default_engine:
results.append(self._create_search_result(engine, search_query))
return results
def _parse_engine_query(self, query: str) -> tuple[str, str]:
"""Parse query to extract search engine and search terms."""
query = query.strip().lower()
for engine in self.search_engines.keys():
if query.startswith(f"{engine} "):
return engine, query[len(engine) :].strip()
elif query == engine:
return engine, ""
return "", query
def _is_url(self, text: str) -> bool:
"""Check if the text is a URL."""
text = text.strip().lower()
return text.startswith(("http://", "https://", "www.")) or (
"." in text and " " not in text and len(text) > 3
)
def _create_url_result(self, url: str) -> Result:
"""Create a result for opening a URL directly."""
# Add protocol if missing
if not url.startswith(("http://", "https://")):
if url.startswith("www."):
url = "https://" + url
else:
url = "https://" + url
return Result(
title=f"Open {url}",
subtitle="Open this URL in your default browser",
icon_name="link",
action=lambda u=url: self._open_url(u),
relevance=1.0,
plugin_name=self.display_name,
data={"type": "url", "url": url},
)
def _open_url(self, url: str):
"""Open a URL directly in the default browser."""
try:
subprocess.Popen(
["xdg-open", url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
)
except Exception as e:
print(f"Failed to open URL: {e}")
def _get_search_engine_list(self) -> List[Result]:
"""Get list of available search engines."""
results = []
for engine_id, engine_info in self.search_engines.items():
result = Result(
title=engine_info["name"],
subtitle=engine_info["description"],
icon_name=engine_info["icon"],
action=lambda e=engine_id: self._show_engine_help(e),
relevance=1.0 if engine_id == self.default_engine else 0.8,
plugin_name=self.display_name,
data={"type": "engine_info", "engine": engine_id},
)
results.append(result)
return results
def _create_search_result(self, engine_id: str, query: str) -> Result:
"""Create a search result for a specific engine and query."""
engine_info = self.search_engines[engine_id]
return Result(
title=f"Search '{query}' on {engine_info['name']}",
subtitle=f"{engine_info['description']} - {query}",
icon_name=engine_info["icon"],
action=lambda e=engine_id, q=query: self._perform_search(e, q),
relevance=1.0 if engine_id == self.default_engine else 0.9,
plugin_name=self.display_name,
data={"type": "search", "engine": engine_id, "query": query},
)
def _create_engine_info_result(self, engine_id: str) -> Result:
"""Create an info result for a specific search engine."""
engine_info = self.search_engines[engine_id]
return Result(
title=f"{engine_info['name']} Search",
subtitle=f"{engine_info['description']} - Type your search query",
icon_name=engine_info["icon"],
action=lambda: None, # No action for info result
relevance=1.0,
plugin_name=self.display_name,
data={"type": "engine_ready", "engine": engine_id},
)
def _perform_search(self, engine_id: str, query: str):
"""Perform a web search using the specified engine."""
if not query.strip():
return
engine_info = self.search_engines.get(engine_id)
if not engine_info:
return
# URL encode the search query
encoded_query = urllib.parse.quote_plus(query)
search_url = engine_info["url"].format(encoded_query)
try:
# Open the search URL in the default browser
subprocess.Popen(
["xdg-open", search_url],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except Exception as e:
print(f"Failed to open search URL: {e}")
def _show_engine_help(self, engine_id: str):
"""Show help for a specific search engine."""
engine_info = self.search_engines.get(engine_id)
if engine_info:
print(
f"Search engine: {engine_info['name']} - {engine_info['description']}"
)
================================================
FILE: modules/launcher/result.py
================================================
from dataclasses import dataclass
from typing import Any, Callable, Optional
from gi.repository import GdkPixbuf, Gtk
@dataclass
class Result:
"""
Represents a search result that can be displayed and activated.
"""
# Display information
title: str
subtitle: str = ""
subtitle_markup: Optional[str] = None # Pango markup for subtitle
description: str = ""
icon: Optional[GdkPixbuf.Pixbuf] = None
icon_name: Optional[str] = None
icon_markup: Optional[str] = None
# Behavior
action: Optional[Callable[[], Any]] = None
relevance: float = 0.0
# Custom widget support
custom_widget: Optional[Gtk.Widget] = None
# Metadata
plugin_name: str = ""
data: Optional[dict] = None
def activate(self):
"""Activate this result (execute its action)."""
if self.action:
return self.action()
else:
raise NotImplementedError("No action defined for this result")
def __post_init__(self):
"""Post-initialization processing."""
# Ensure relevance is within valid range
self.relevance = max(0.0, min(1.0, self.relevance))
# Set default data if None
if self.data is None:
self.data = {}
def __str__(self):
return f"Result(title='{self.title}', relevance={self.relevance})"
def __repr__(self):
return self.__str__()
================================================
FILE: modules/launcher/result_item.py
================================================
import gi
from fabric.core.service import Signal
from fabric.widgets.box import Box
from fabric.widgets.eventbox import EventBox
from fabric.widgets.image import Image
from fabric.widgets.label import Label
from modules.launcher.result import Result
gi.require_version("Gtk", "3.0")
class ResultItem(EventBox):
"""
Widget for displaying a single search result.
"""
# Signals
@Signal
def clicked(self, index: int) -> None:
"""Emitted when result is clicked."""
pass
@Signal
def hovered(self, index: int) -> None:
"""Emitted when result is hovered."""
pass
def __init__(
self, result: Result, selected: bool = False, index: int = 0, **kwargs
):
super().__init__(name="launcher-result-item", **kwargs)
self.result = result
self._selected = selected
self.index = index
# Setup UI
self._setup_ui()
# Connect signals
self.connect("button-press-event", self._on_button_press)
self.connect("enter-notify-event", self._on_enter)
self.connect("leave-notify-event", self._on_leave)
# Set initial selection state
self.set_selected(selected)
def _setup_ui(self):
"""Setup the result item UI."""
# Main container
main_box = Box(
name="result-item-main",
orientation="h",
spacing=12,
h_align="fill",
v_align="center",
)
self.add(main_box)
# Icon
if self.result.icon:
icon_widget = Image(pixbuf=self.result.icon, name="result-item-icon")
elif self.result.icon_name:
icon_widget = Image(
icon_name=self.result.icon_name, icon_size=48, name="result-item-icon"
)
elif self.result.icon_markup:
icon_widget = Label(
markup=self.result.icon_markup, name="launcher-icon-label"
)
else:
# Default icon
icon_widget = Image(
icon_name="application-default-icon",
icon_size=48,
name="result-item-icon",
)
main_box.add(icon_widget)
# Text container
text_box = Box(
name="result-item-text",
orientation="v",
spacing=2,
h_expand=True,
v_align="center",
)
main_box.add(text_box)
# Title
title_label = Label(
label=self.result.title,
name="result-item-title",
h_align="start",
v_align="center",
ellipsize="end",
)
text_box.add(title_label)
# Subtitle (if present)
if self.result.subtitle or self.result.subtitle_markup:
if self.result.subtitle_markup:
# Use markup for subtitle (supports Pango markup)
subtitle_label = Label(
markup=self.result.subtitle_markup,
name="result-item-subtitle",
h_align="start",
v_align="center",
ellipsize="end",
)
else:
# Use plain text for subtitle
subtitle_label = Label(
label=self.result.subtitle,
name="result-item-subtitle",
h_align="start",
v_align="center",
ellipsize="end",
)
text_box.add(subtitle_label)
# Plugin name (small text)
if self.result.plugin_name:
plugin_label = Label(
label=f"via {self.result.plugin_name}",
name="result-item-plugin",
h_align="start",
v_align="center",
ellipsize="end",
)
text_box.add(plugin_label)
def set_selected(self, selected: bool):
"""Set the selection state of this result item."""
self._selected = selected
if selected:
self.add_style_class("selected")
else:
self.remove_style_class("selected")
def get_selected(self) -> bool:
"""Get the selection state of this result item."""
return self._selected
def _on_button_press(self, widget, event):
"""Handle button press events."""
if event.button == 1: # Left click
self.clicked.emit(self.index)
return True
return False
def _on_enter(self, widget, event):
"""Handle mouse enter events."""
# Emit hover signal to update selection
self.hovered.emit(self.index)
return False
def _on_leave(self, widget, event):
"""Handle mouse leave events."""
# Could be used for hover effects cleanup
return False
================================================
FILE: modules/launcher/trigger_config.py
================================================
import json
import os
from typing import Any, Dict, List
from fabric.utils import get_relative_path
class TriggerConfig:
def __init__(self, config_path: str = None):
if config_path is None:
config_path = get_relative_path("../../config/assets/launcher.json")
self.config_path = config_path
# Load configuration from JSON file
config = {"launcher_config": {}, "settings": {}}
if os.path.exists(config_path):
try:
with open(config_path, "r", encoding="utf-8") as f:
config = json.load(f)
except Exception as e:
print(f"Error loading trigger config: {e}")
self.config = config
self.launcher_config = self.config.get("launcher_config", {})
# Initialize settings with defaults
default_settings = {
"max_examples_shown": 2,
"default_icon": "application-default-icon",
"fallback_example_template": "{trigger} ",
"config_version": "1.0",
}
self.settings = {**default_settings, **self.config.get("settings", {})}
def get_trigger_examples(self, trigger: str) -> List[str]:
examples = self.launcher_config.get(trigger, {}).get("examples", [])
if not examples:
template = self.settings.get(
"fallback_example_template", "{trigger} "
)
examples = [template.format(trigger=trigger)]
return examples
def get_trigger_icon(self, trigger: str) -> str:
icon = self.launcher_config.get(trigger, {}).get(
"icon", self.settings.get("default_icon", "application-default-icon")
)
return icon
def get_trigger_description(self, trigger: str) -> str:
return self.launcher_config.get(trigger, {}).get(
"description", f"{trigger} - No description available"
)
def get_all_triggers(self) -> Dict[str, Dict[str, Any]]:
return self.launcher_config.copy()
================================================
FILE: modules/notification/notification.py
================================================
import os
import hashlib
import time
import uuid
from fabric.utils import get_relative_path
from gi.repository import Gdk, GdkPixbuf, GLib, Gtk # type: ignore
from loguru import logger
import config.data as data
from .unified_cache import (
get_unified_cache_key,
save_to_cache,
get_from_cache,
cleanup_cache,
get_fallback_icon,
ensure_cache_dir
)
from fabric.notifications import (
Notification,
NotificationAction,
NotificationCloseReason,
)
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.centerbox import CenterBox
from fabric.widgets.eventbox import EventBox
from fabric.widgets.image import Image
from fabric.widgets.label import Label
from utils.roam import modus_service
from utils.functions import escape_markup_text
from widgets.custom_image import CustomImage
from widgets.customrevealer import SlideRevealer
from widgets.wayland import WaylandWindow as Window
from services.modus import notification_service
NOTIFICATION_WIDTH = 360
NOTIFICATION_IMAGE_SIZE = 48
NOTIFICATION_WIDTH = 360
NOTIFICATION_IMAGE_SIZE = 48
# Unified notification cache directory (for both app icons and notification images)
UNIFIED_NOTIFICATION_CACHE_DIR = os.path.join(data.CACHE_DIR, "notifications")
# Backward compatibility constants
NOTIFICATION_ICON_CACHE_DIR = UNIFIED_NOTIFICATION_CACHE_DIR
NOTIFICATION_IMAGE_CACHE_DIR = UNIFIED_NOTIFICATION_CACHE_DIR
def ensure_notification_cache_dirs():
"""Ensure unified notification cache directory exists"""
os.makedirs(UNIFIED_NOTIFICATION_CACHE_DIR, exist_ok=True)
def cleanup_old_cache_files():
"""Clean up old notification cache files (older than 7 days)"""
try:
if not os.path.exists(UNIFIED_NOTIFICATION_CACHE_DIR):
return
current_time = time.time()
week_ago = current_time - (7 * 24 * 60 * 60) # 7 days
for filename in os.listdir(UNIFIED_NOTIFICATION_CACHE_DIR):
filepath = os.path.join(UNIFIED_NOTIFICATION_CACHE_DIR, filename)
try:
if os.path.isfile(filepath):
file_mtime = os.path.getmtime(filepath)
if file_mtime < week_ago:
os.unlink(filepath)
logger.debug(f"Cleaned up old notification cache: {filename}")
except Exception as e:
logger.warning(f"Failed to cleanup cache file {filename}: {e}")
except Exception as e:
logger.warning(f"Failed to cleanup notification cache: {e}")
def get_unified_cache_key(source_data, size=None, app_name=None):
"""Generate a unified cache key that works for both app icons and notification images"""
try:
if hasattr(source_data, "get_pixels"):
# For pixbuf data - use hash of pixel data for deterministic caching
try:
pixel_data = source_data.get_pixels()
image_hash = hashlib.md5(pixel_data).hexdigest()[:8]
return image_hash
except Exception:
# Fallback to timestamp if pixel data fails
return str(int(time.time()))[:8]
elif isinstance(source_data, str):
# For file paths - create hash-based name
if source_data.startswith("file://"):
source_data = source_data[7:]
# Create hash from file path and size
hash_input = source_data
if size:
hash_input += f"_{size[0]}x{size[1]}"
return hashlib.md5(hash_input.encode()).hexdigest()[:8]
else:
# Fallback to timestamp
return str(int(time.time()))[:8]
except Exception:
# Ultimate fallback
return str(int(time.time()))[:8]
# Backward compatibility
get_cache_key = get_unified_cache_key
def save_pixbuf_to_cache(pixbuf, cache_key, cache_dir):
"""Save a pixbuf to the specified cache directory"""
try:
ensure_notification_cache_dirs()
cache_path = os.path.join(cache_dir, f"{cache_key}.png")
# Don't overwrite existing cache
if os.path.exists(cache_path):
return cache_path
pixbuf.savev(cache_path, "png", [], [])
logger.debug(f"Cached notification icon: {cache_key}")
return cache_path
except Exception as e:
logger.warning(f"Failed to cache notification icon: {e}")
return None
def get_cached_pixbuf(cache_key, fallback_size=(48, 48), cache_dir=None):
"""Get a cached pixbuf or return None if not found"""
if cache_dir is None:
cache_dir = NOTIFICATION_ICON_CACHE_DIR
try:
cache_path = os.path.join(cache_dir, f"{cache_key}.png")
if os.path.exists(cache_path):
logger.debug(f"Using cached notification icon: {cache_key}")
logger.debug(f"Using cached notification icon: {cache_key}")
return GdkPixbuf.Pixbuf.new_from_file_at_scale(
cache_path, fallback_size[0], fallback_size[1], True
)
except Exception as e:
logger.warning(f"Failed to load cached notification icon: {e}")
return None
def cache_notification_icon(source, size=(48, 48), app_name=None):
"""Optimized notification icon caching with immediate pixbuf generation and caching"""
try:
ensure_notification_cache_dirs()
# Handle different source types with optimized caching
if isinstance(source, str):
cache_key = get_unified_cache_key(source, size, app_name)
# Check cache first for immediate return
cached_pixbuf = get_cached_pixbuf(
cache_key, fallback_size=size, cache_dir=NOTIFICATION_ICON_CACHE_DIR
)
if cached_pixbuf:
return cached_pixbuf
# Load, cache, and return icon in one optimized flow
if source.startswith("file://"):
# Local file URL - process and cache immediately
file_path = source[7:]
pixbuf = load_and_cache_local_icon(file_path, cache_key, size)
elif os.path.exists(source):
# Direct file path - process and cache immediately
pixbuf = load_and_cache_local_icon(source, cache_key, size)
else:
# Icon name - resolve from theme and cache immediately
pixbuf = load_and_cache_theme_icon(source, cache_key, size)
return pixbuf
elif hasattr(source, "scale_simple"):
# Already a pixbuf - cache it directly with optimized flow
cache_key = get_unified_cache_key(source, size, app_name)
# Check cache first
cached_pixbuf = get_cached_pixbuf(
cache_key, fallback_size=size, cache_dir=NOTIFICATION_ICON_CACHE_DIR
)
if cached_pixbuf:
return cached_pixbuf
# Scale once and cache immediately
scaled_pixbuf = source.scale_simple(
size[0], size[1], GdkPixbuf.InterpType.BILINEAR
)
save_pixbuf_to_cache(scaled_pixbuf, cache_key, NOTIFICATION_ICON_CACHE_DIR)
return scaled_pixbuf
except Exception as e:
logger.warning(f"Failed to cache notification icon: {e}")
# Return fallback with caching
return get_fallback_notification_icon(size)
def load_and_cache_local_icon(file_path, cache_key, size):
"""Load a local icon file and cache it"""
try:
if os.path.exists(file_path):
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
file_path, size[0], size[1], True
)
save_pixbuf_to_cache(pixbuf, cache_key, NOTIFICATION_ICON_CACHE_DIR)
return pixbuf
except Exception as e:
logger.warning(f"Failed to load local notification icon {file_path}: {e}")
return get_fallback_notification_icon(size)
def load_and_cache_theme_icon(icon_name, cache_key, size):
"""Load an icon from the current theme and cache it"""
# For simplicity, just use fallback since theme icon loading is complex in GTK4
logger.debug(f"Using fallback for theme icon {icon_name}")
return get_fallback_notification_icon(size)
def get_fallback_notification_icon(size=(48, 48)):
"""Get the fallback notification icon"""
try:
fallback_path = get_relative_path("../../config/assets/icons/notification.png")
return GdkPixbuf.Pixbuf.new_from_file_at_scale(
fallback_path, size[0], size[1], True
)
except Exception as e:
logger.warning(f"Failed to load fallback notification icon: {e}")
# Create a simple colored rectangle as ultimate fallback
try:
return GdkPixbuf.Pixbuf.new(
GdkPixbuf.Colorspace.RGB, True, 8, size[0], size[1]
)
except:
return None
def get_notification_image_cache_key(notification_id, image_pixbuf):
"""Generate a deterministic cache key based on image content to prevent duplicate caching"""
try:
# Use image content hash for deterministic caching
if image_pixbuf and hasattr(image_pixbuf, "get_pixels"):
try:
pixel_data = image_pixbuf.get_pixels()
image_hash = hashlib.md5(pixel_data).hexdigest()[:8]
return image_hash
except Exception:
# If pixel data fails, use image dimensions + timestamp
try:
width = image_pixbuf.get_width()
height = image_pixbuf.get_height()
dimension_hash = hashlib.md5(f"{width}x{height}".encode()).hexdigest()[:8]
return dimension_hash
except Exception:
pass
# Fallback to timestamp for invalid pixbufs
return str(int(time.time()))[:8]
except Exception:
# Ultimate fallback
return str(int(time.time()))[:8]
def cache_notification_image(notification_id, image_pixbuf, size=(64, 64)):
"""Smart notification image caching that avoids duplicate caching"""
try:
ensure_notification_cache_dirs()
# Generate deterministic cache key based on image content
cache_key = get_notification_image_cache_key(notification_id, image_pixbuf)
cache_path = os.path.join(NOTIFICATION_IMAGE_CACHE_DIR, f"{cache_key}.png")
# Check if already cached to avoid redundant work
if os.path.exists(cache_path):
logger.debug(f"Image cache hit - already exists: {cache_key}")
return cache_path, cache_key
# Try to scale and save the image
try:
scaled_pixbuf = image_pixbuf.scale_simple(
size[0], size[1], GdkPixbuf.InterpType.BILINEAR
)
scaled_pixbuf.savev(cache_path, "png", [], [])
logger.debug(f"Generated and cached notification image: {cache_key}")
return cache_path, cache_key
except Exception as scale_error:
logger.debug(f"Failed to cache image (temp file likely gone): {scale_error}")
return None, None
except Exception as e:
logger.warning(f"Failed to cache notification image: {e}")
return None, None
def get_cached_notification_image(cache_key):
"""Get a cached notification image or return None if not found"""
try:
cache_path = os.path.join(NOTIFICATION_IMAGE_CACHE_DIR, f"{cache_key}.png")
if os.path.exists(cache_path):
return GdkPixbuf.Pixbuf.new_from_file(cache_path)
except Exception as e:
logger.warning(f"Failed to load cached notification image: {e}")
return None
def cleanup_notification_image_cache(cache_key=None):
"""Clean up notification image cache - specific key or all"""
try:
ensure_notification_cache_dirs()
if cache_key:
# Remove specific cached image
cache_path = os.path.join(NOTIFICATION_IMAGE_CACHE_DIR, f"{cache_key}.png")
if os.path.exists(cache_path):
os.unlink(cache_path)
logger.debug(f"Cleaned up cached notification image: {cache_key}")
else:
# Remove all cached images
for filename in os.listdir(NOTIFICATION_IMAGE_CACHE_DIR):
if filename.endswith(".png"):
filepath = os.path.join(NOTIFICATION_IMAGE_CACHE_DIR, filename)
try:
os.unlink(filepath)
logger.debug(f"Cleaned up cached notification image: {filename}")
except Exception as e:
logger.warning(f"Failed to cleanup cache file {filename}: {e}")
except Exception as e:
logger.warning(f"Failed to cleanup notification image cache: {e}")
def cleanup_notification_specific_caches(
app_icon_source=None, notification_image_cache_key=None
):
"""Clean up caches specific to a notification (both app icon and notification image) - SINGLE ICON SIZE"""
try:
# Clean up notification image cache
if notification_image_cache_key:
cleanup_notification_image_cache(notification_image_cache_key)
# Clean up app icon cache for this specific source (only 35x35 version)
if app_icon_source:
# Only clean 35x35 version since we only cache this size now
cache_key_35 = get_unified_cache_key(app_icon_source, (35, 35))
cache_path_35 = os.path.join(
NOTIFICATION_ICON_CACHE_DIR, f"{cache_key_35}.png"
)
if os.path.exists(cache_path_35):
os.unlink(cache_path_35)
logger.debug(f"Cleaned up cached app icon (35x35): {cache_key_35}")
except Exception as e:
logger.warning(f"Failed to cleanup notification specific caches: {e}")
def cleanup_all_notification_caches():
"""Clean up ALL notification caches (icons and images)"""
try:
# Clean icon cache
if os.path.exists(NOTIFICATION_ICON_CACHE_DIR):
for filename in os.listdir(NOTIFICATION_ICON_CACHE_DIR):
if filename.endswith(".png"):
filepath = os.path.join(NOTIFICATION_ICON_CACHE_DIR, filename)
try:
os.unlink(filepath)
logger.debug(f"Cleaned up cached notification icon: {filename}")
except Exception as e:
logger.warning(
f"Failed to cleanup icon cache file {filename}: {e}"
)
# Clean image cache
cleanup_notification_image_cache()
logger.info("Cleaned up all notification caches")
except Exception as e:
logger.warning(f"Failed to cleanup all notification caches: {e}")
def verify_cache_persistence():
"""Verify that cached icons persist and can be loaded after restart"""
try:
icon_cache_files = []
image_cache_files = []
if os.path.exists(NOTIFICATION_ICON_CACHE_DIR):
icon_cache_files = [
f for f in os.listdir(NOTIFICATION_ICON_CACHE_DIR) if f.endswith(".png")
]
if os.path.exists(NOTIFICATION_IMAGE_CACHE_DIR):
image_cache_files = [
f
for f in os.listdir(NOTIFICATION_IMAGE_CACHE_DIR)
if f.endswith(".png")
]
logger.info(
f"Cache persistence check: {len(icon_cache_files)} icons, {
len(image_cache_files)
} images cached"
)
# Test loading a few cached items to verify they work
for cache_file in icon_cache_files[:2]: # Test first 2 icon files
try:
cache_path = os.path.join(NOTIFICATION_ICON_CACHE_DIR, cache_file)
test_pixbuf = GdkPixbuf.Pixbuf.new_from_file(cache_path)
if test_pixbuf:
logger.debug(f"Successfully verified cached icon: {cache_file}")
except Exception as e:
logger.warning(f"Failed to load cached icon {cache_file}: {e}")
for cache_file in image_cache_files[:2]: # Test first 2 image files
try:
cache_path = os.path.join(NOTIFICATION_IMAGE_CACHE_DIR, cache_file)
test_pixbuf = GdkPixbuf.Pixbuf.new_from_file(cache_path)
if test_pixbuf:
logger.debug(f"Successfully verified cached image: {cache_file}")
except Exception as e:
logger.warning(f"Failed to load cached image {cache_file}: {e}")
return len(icon_cache_files) + len(image_cache_files) > 0
except Exception as e:
logger.error(f"Failed to verify cache persistence: {e}")
return False
def migrate_persistent_notifications():
"""Migrate persistent notifications to use cached assets when temp files are gone"""
try:
from services.modus import notification_service
migrated_count = 0
for cached_notification in notification_service.cached_notifications:
notification = cached_notification._notification
# Check if notification has image_pixbuf but temp file might be gone
if hasattr(notification, "image_pixbuf") and notification.image_pixbuf:
try:
# Try to access pixel data to test if temp file still exists
notification.image_pixbuf.get_pixels()
except Exception:
# Temp file is gone, ensure app icon is cached as fallback
try:
if hasattr(notification, "app_icon") and notification.app_icon:
cache_notification_icon(notification.app_icon, (35, 35))
migrated_count += 1
logger.debug(
f"Migrated notification for {
notification.app_name
} to use cached app icon"
)
except Exception as cache_error:
logger.debug(
f"Failed to cache app icon for {notification.app_name}: {
cache_error
}"
)
if migrated_count > 0:
logger.info(
f"Migrated {migrated_count} persistent notifications to use cached assets"
)
except Exception as e:
logger.warning(f"Failed to migrate persistent notifications: {e}")
# Initialize cache and verify persistence on module load
ensure_notification_cache_dirs()
cleanup_old_cache_files()
verify_cache_persistence()
# Run migration for persistent notifications on startup
try:
migrate_persistent_notifications()
except Exception as e:
logger.debug(f"Migration skipped (service not ready): {e}")
def preload_notification_assets(notification):
"""Preload and cache notification assets with robust error handling for persistent notifications - SINGLE ICON SIZE"""
try:
# Cache app icon only at content size (35x35) - scale down for headers at runtime
if hasattr(notification, "app_icon") and notification.app_icon:
try:
# Only cache at 35x35 to reduce disk usage - headers will scale this down
cache_notification_icon(notification.app_icon, (35, 35))
except Exception as icon_error:
logger.debug(
f"Failed to preload app icon for {notification.app_name}: {
icon_error
}"
)
# Cache notification image if available
if hasattr(notification, "image_pixbuf") and notification.image_pixbuf:
cache_notification_image(notification.id, notification.image_pixbuf, (35, 35))
except Exception as e:
logger.warning(f"Failed to preload notification assets: {e}")
def smooth_revealer_animation(revealer: SlideRevealer, duration: int = 280):
"""Configure revealer for ultra-smooth animation"""
revealer.duration = duration
class ActionButton(Button):
def __init__(
self, action: NotificationAction, index: int, total: int, notification_box
):
super().__init__(
name="action-button",
h_expand=True,
on_clicked=self.on_clicked,
child=Label(name="button-label", label=action.label),
)
self.action = action
self.notification_box = notification_box
style_class = (
"start-action"
if index == 0
else "end-action"
if index == total - 1
else "middle-action"
)
self.add_style_class(style_class)
self.connect(
"enter-notify-event", lambda *_: notification_box.hover_button(self)
)
self.connect(
"leave-notify-event", lambda *_: notification_box.unhover_button(self)
)
def on_clicked(self, *_):
# Mark for cache cleanup when action button is clicked
self.notification_box._should_cleanup_cache = True
self.action.invoke()
self.action.parent.close("dismissed-by-user")
class NotificationWidget(Box):
def __init__(
self,
notification: Notification,
timeout_ms=data.NOTIFICATION_TIMEOUT,
show_close_button=True,
name="notification",
**kwargs,
):
self.show_close_button = show_close_button
self.close_button = None
self._is_hovered = False
self.notification_image_cache_key = None # Track cached image for cleanup
self.app_icon_source = (
notification.app_icon
) # Track app icon source for cleanup
self._should_cleanup_cache = False # Only cleanup cache on manual dismissal
super().__init__(
size=(NOTIFICATION_WIDTH, -1),
name=name,
orientation="v",
h_align="fill",
h_expand=True,
children=[
self.create_content(notification),
self.create_action_buttons(notification),
],
)
self.notification = notification
self.timeout_ms = timeout_ms
self._timeout_id = None
# Add hover events to the main notification widget
self.connect("enter-notify-event", self._on_enter_notify)
self.connect("leave-notify-event", self._on_leave_notify)
self.start_timeout()
def create_header(self, notification):
"""Create notification header with optimized cached app icon - SINGLE CACHE SIZE"""
try:
# Get 35x35 cached icon and scale down to 24x24 for header
cached_app_icon_pixbuf = cache_notification_icon(
notification.app_icon, (35, 35)
)
if cached_app_icon_pixbuf:
# Scale down the 35x35 cached icon to 24x24 for header display
header_icon_pixbuf = cached_app_icon_pixbuf.scale_simple(
24, 24, GdkPixbuf.InterpType.BILINEAR
)
app_icon = CustomImage(pixbuf=header_icon_pixbuf)
app_icon.set_name("notification-icon")
else:
# Fallback to theme icon if caching fails completely
app_icon = Image(
name="notification-icon",
icon_name="notifications",
icon_size=24,
)
except Exception as e:
logger.warning(f"Failed to load cached header icon: {e}")
# Ultimate fallback
app_icon = Image(
name="notification-icon",
icon_name="notifications",
icon_size=24,
)
return CenterBox(
name="notification-title",
start_children=[
Box(
spacing=4,
children=[
app_icon,
Label(
notification.app_name,
name="notification-app-name",
h_align="start",
),
],
)
],
end_children=[
self.create_close_button() if self.show_close_button else Box()
],
)
def create_content(self, notification):
return Box(
name="notification-content",
spacing=8,
children=[
Box(
name="notification-image",
children=CustomImage(
pixbuf=self._get_notification_pixbuf(notification)
),
),
Box(
name="notification-text",
orientation="v",
v_align="center",
children=[
Box(
name="notification-summary-box",
orientation="h",
children=[
Label(
name="notification-summary",
markup=escape_markup_text(notification.summary.replace("\n", " ")),
h_align="start",
max_chars_width=40,
ellipsization="end",
),
# Label(
# name="notification-app-name",
# markup=" | " + notification.app_name,
# h_align="start",
# ellipsization="end",
# ),
],
),
(
Label(
markup=escape_markup_text(notification.body.replace("\n", " ")),
h_align="start",
max_chars_width=45,
ellipsization="end",
)
if notification.body
else Label(
markup="",
h_align="start",
ellipsization="end",
)
),
],
),
Box(h_expand=True),
Box(
orientation="v",
children=[
Button(
name="notification-close-button",
image=CustomImage(icon_name="close-symbolic", icon_size=18),
visible=True, # Initially hidden
on_clicked=lambda *_: self._manual_close(),
),
Box(v_expand=True),
],
),
],
)
def _on_enter_notify(self, widget, event):
self._is_hovered = True
if self.close_button:
self.close_button.set_visible(True)
self.pause_timeout()
return False
def _on_leave_notify(self, widget, event):
self._is_hovered = False
if self.close_button:
self.close_button.set_visible(False)
self.resume_timeout()
return False
def get_pixbuf(self, icon_path, width, height):
"""Get pixbuf with caching support"""
try:
# Use the icon caching system
cached_pixbuf = cache_notification_icon(icon_path, (width, height))
if cached_pixbuf:
return cached_pixbuf
except Exception as e:
logger.warning(f"Failed to get cached pixbuf for {icon_path}: {e}")
# Fallback to original method if caching fails
if icon_path.startswith("file://"):
icon_path = icon_path[7:]
if not os.path.exists(icon_path):
logger.warning(f"Icon path does not exist: {icon_path}")
return get_fallback_notification_icon((width, height))
try:
pixbuf = GdkPixbuf.Pixbuf.new_from_file(icon_path)
if pixbuf:
return pixbuf.scale_simple(width, height, GdkPixbuf.InterpType.BILINEAR)
else:
return get_fallback_notification_icon((width, height))
except Exception as e:
logger.error(f"Failed to load or scale icon: {e}")
return get_fallback_notification_icon((width, height))
def _get_notification_pixbuf(self, notification):
"""Simplified notification pixbuf with NO CACHING for image-pixmap to prevent disk bloat"""
notification_id = getattr(notification, "id", int(time.time()))
try:
# Try to get cached notification image first
if hasattr(notification, "image_pixbuf") and notification.image_pixbuf:
try:
cache_key = get_notification_image_cache_key(notification_id, notification.image_pixbuf)
cached_image = get_cached_notification_image(cache_key)
if cached_image:
return cached_image
except Exception as image_error:
logger.debug(f"Notification image processing failed: {image_error}")
# Continue to app icon fallback
except Exception as e:
logger.debug(f"Failed to process notification image: {e}")
# Use cached app icon as fallback
try:
app_icon_source = getattr(notification, "app_icon", None)
if app_icon_source:
cached_app_icon = cache_notification_icon(app_icon_source, (35, 35))
if cached_app_icon:
return cached_app_icon
except Exception as e:
logger.debug(f"Failed to get cached app icon: {e}")
# Ultimate fallback
return get_fallback_notification_icon((35, 35))
def create_action_buttons(self, notification):
return Box(
name="notification-action-buttons",
spacing=4,
h_expand=True,
children=[
ActionButton(action, i, len(notification.actions), self)
for i, action in enumerate(notification.actions)
],
)
def start_timeout(self):
self.stop_timeout()
self._timeout_id = GLib.timeout_add(self.timeout_ms, self.close_notification)
def stop_timeout(self):
if self._timeout_id is not None:
GLib.source_remove(self._timeout_id)
self._timeout_id = None
def close_notification(self):
self.notification.close("expired")
self.stop_timeout()
return False
def pause_timeout(self):
self.stop_timeout()
def resume_timeout(self):
if not self._is_hovered: # Only resume if not hovered
self.start_timeout()
def _manual_close(self):
"""Handle manual close button click - just close notification"""
self.notification.close("dismissed-by-user")
def destroy(self):
self.stop_timeout()
# Only clean up caches if this was a manual dismissal
if self._should_cleanup_cache:
cleanup_notification_specific_caches(
app_icon_source=getattr(self, "app_icon_source", None),
notification_image_cache_key=getattr(
self, "notification_image_cache_key", None
),
)
logger.debug(f"Cleaned up caches for manually dismissed notification")
else:
logger.debug(f"Preserved caches for timeout/auto-dismissed notification")
super().destroy()
# @staticmethod
def set_pointer_cursor(self, widget, cursor_name):
window = widget.get_window()
if window:
cursor = Gdk.Cursor.new_from_name(widget.get_display(), cursor_name)
window.set_cursor(cursor)
def hover_button(self, button):
self.pause_timeout()
self.set_pointer_cursor(button, "hand2")
def unhover_button(self, button):
# Don't resume timeout here since the notification itself might still be hovered
self.set_pointer_cursor(button, "arrow")
class NotificationRevealer(SlideRevealer):
def __init__(
self,
notification: Notification,
on_transition_end=None,
parent_window=None,
**kwargs,
):
self.notif_box = NotificationWidget(notification, show_close_button=False)
self.notification = notification
self.on_transition_end = on_transition_end
# Reference to NotificationCenter window for queue clearing
self.parent_window = parent_window
self._is_closing = False
# Enhanced swipe detection variables for Android-style animation
self._drag_start_y = 0
self._drag_start_x = 0
self._is_dragging = False
self._swipe_threshold = 80 # Distance to trigger auto-dismiss
self._swipe_velocity_threshold = (
150 # Velocity to trigger dismiss even on shorter swipes
)
self._swipe_in_progress = False
self._current_offset = 0
self._last_drag_time = 0
self._drag_velocity = 0
self._spring_back_duration = 200 # Duration for spring-back animation
self._dismiss_threshold = 0.3 # Dismiss if swiped 30% of width
# Animation state
self._animation_in_progress = False
self._spring_timer_id = None
self._css_provider = None
# Wrap notification in EventBox for swipe detection
self.event_box = EventBox(
events=[
"button-press-event",
"button-release-event",
"motion-notify-event",
],
child=self.notif_box,
)
super().__init__(
child=self.event_box,
direction="right",
duration=280, # Faster, smoother duration
)
smooth_revealer_animation(self)
# Connect our own handler that manages the slide animation
self.notification.connect("closed", self.on_resolved)
self._animation_in_progress = True
def _ease_out_cubic(self, t):
"""Smoother easing function for better animation quality"""
return 1 - pow(1 - t, 3)
def _ease_out_quart(self, t):
"""Even smoother easing for ultra-smooth animations"""
return 1 - pow(1 - t, 4)
def _apply_transform(self, offset_x, opacity, scale):
"""Apply smooth CSS transforms for animation"""
try:
# Create CSS transformation
transform_css = f"""
opacity: {opacity};
transform: translateX({offset_x}px) scale({scale});
transition: none;
"""
# Apply to the notification box
if hasattr(self.notif_box, "get_style_context"):
style_context = self.notif_box.get_style_context()
if style_context:
# Use CSS provider for smooth transforms
if not hasattr(self, "_css_provider") or not self._css_provider:
from gi.repository import Gtk
self._css_provider = Gtk.CssProvider()
style_context.add_provider(
self._css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
)
css_data = f"* {{ {transform_css} }}"
self._css_provider.load_from_data(css_data.encode())
except Exception as e:
logger.debug(f"Transform apply failed (non-critical): {e}")
def _animate_dismiss(self, start_offset):
"""Animate the notification sliding out with smooth 60fps animation"""
target_offset = NOTIFICATION_WIDTH + 50
duration = 200 # Slightly longer for smoother feel
if self._spring_timer_id:
GLib.source_remove(self._spring_timer_id)
start_time = GLib.get_monotonic_time() / 1000
offset_diff = target_offset - start_offset
def animate_step():
current_time = GLib.get_monotonic_time() / 1000
elapsed = current_time - start_time
progress = min(1.0, elapsed / duration)
# Use smoother easing for premium feel
eased_progress = self._ease_out_quart(progress)
current_offset = start_offset + (offset_diff * eased_progress)
# Smoother fade and scale transitions
opacity = max(0.0, 1.0 - (progress * 0.9)) # Gentler fade
scale = max(0.9, 1.0 - (progress * 0.1)) # Subtle scale
self._apply_transform(current_offset, opacity, scale)
if progress >= 1.0:
# Mark notification for cache cleanup on swipe dismissal
self.notif_box._should_cleanup_cache = True
try:
self.notification.close("dismissed-by-user")
except:
pass
return False
return True
# Use consistent 60fps timing
self._animation_in_progress = True
self._spring_timer_id = GLib.timeout_add(16, animate_step) # ~60 FPS
def _calculate_drag_velocity(self, current_x):
"""Calculate the velocity of the drag gesture"""
current_time = GLib.get_monotonic_time() / 1000
if self._last_drag_time > 0:
time_diff = current_time - self._last_drag_time
if time_diff > 0:
distance_diff = current_x - self._drag_start_x - self._current_offset
self._drag_velocity = abs(distance_diff / time_diff)
self._last_drag_time = current_time
def _on_animation_complete(self, is_hiding=False):
if is_hiding:
# Manually destroy the notification widget since we disconnected its handler
self.notif_box.destroy()
if self.on_transition_end:
self.on_transition_end()
self.destroy()
def on_resolved(
self,
_notification: Notification,
reason: NotificationCloseReason,
):
if self._is_closing:
return
self._is_closing = True
# Clean up any ongoing animations
if self._spring_timer_id:
GLib.source_remove(self._spring_timer_id)
# Use different slide directions based on dismiss reason
if reason == "expired":
# Gentle fade-out for auto-dismiss
self.set_slide_direction("left")
self.duration = 250 # Slightly slower for natural feel
elif self._swipe_in_progress:
# Quick slide for swipe dismissals
self.duration = 150
self.set_slide_direction("right")
else:
# Smooth slide for manual close
self.set_slide_direction("right")
self.duration = 200
self.hide()
# Consistent timing for smooth transitions
timeout_duration = self.duration + 50
GLib.timeout_add(timeout_duration, lambda: self._on_animation_complete(True))
def destroy(self):
# Clean up CSS provider and timers
if self._spring_timer_id:
GLib.source_remove(self._spring_timer_id)
super().destroy()
class NotificationState:
IDLE = 0
SHOWING = 1
HIDING = 2
TRANSITIONING = 3 # New state for smooth transitions
class ModusNoti(Window):
def __init__(self):
self._server = notification_service
self.notifications = Box(
v_expand=True,
h_expand=True,
style="margin: 1px 0px 1px 1px;",
orientation="v",
spacing=5,
)
# Enhanced queue system for ultra-smooth transitions
self.notification_queue = []
self.current_notification = None
self.notification_state = NotificationState.IDLE
self._transition_timer_id = None
self._debounce_timer_id = None
self._last_notification_time = 0
# Queue management settings for smooth behavior
self.MAX_QUEUE_SIZE = 3 # Limit queue to prevent overwhelming
self.TRANSITION_DELAY = 100 # Smoother transition timing
self.DEBOUNCE_DELAY = 50 # Prevent rapid fire notifications
self._server.connect("notification-added", self.on_new_notification)
super().__init__(
anchor="top right",
child=self.notifications,
layer="overlay",
title="modus-notifications", # More specific title for debugging
all_visible=True,
visible=False, # Start hidden, show only when we have content
exclusive=False,
)
def on_new_notification(self, fabric_notif, id):
notification: Notification = fabric_notif.get_notification_from_id(id)
# Check if notification still exists (might have been removed already)
if not notification:
return
if self._server.dont_disturb or modus_service.dont_disturb:
# Notification is already cached by the service, just don't show popup
return
# Preload assets immediately for optimal caching and display performance
preload_notification_assets(notification)
# Implement smart queue management for smooth transitions
current_time = GLib.get_monotonic_time() / 1000
# If queue is getting full, remove oldest notifications smoothly
if len(self.notification_queue) >= self.MAX_QUEUE_SIZE:
# Remove oldest notification from queue (not current showing one)
if self.notification_queue:
oldest = self.notification_queue.pop(0)
try:
oldest.close("dismissed-by-user")
except:
pass
# Add new notification to queue
self.notification_queue.append(notification)
# Debounce rapid notifications for smoother experience
if self._debounce_timer_id:
GLib.source_remove(self._debounce_timer_id)
self._debounce_timer_id = GLib.timeout_add(
self.DEBOUNCE_DELAY,
lambda: self._process_notification_queue_debounced() or False,
)
def _process_notification_queue_debounced(self):
"""Process queue after debounce delay for smooth transitions"""
self._debounce_timer_id = None
self._process_notification_queue()
return False
def _process_notification_queue(self):
# If we're currently showing a notification and there's a new one in queue
if (
self.notification_state == NotificationState.SHOWING
and self.current_notification
and self.notification_queue
):
# Smooth transition: start hiding current notification
self.notification_state = NotificationState.TRANSITIONING
self._start_smooth_transition()
elif (
self.notification_state == NotificationState.IDLE
and self.notification_queue
):
# If we're idle and have notifications in queue, show the next one
self._show_next_notification()
def _start_smooth_transition(self):
"""Start smooth transition between notifications"""
if self.current_notification and not self.current_notification._is_closing:
# Don't mark for cache cleanup during smooth transitions
# to maintain performance
# Use shorter timeout for smooth transitions
self.current_notification.notif_box.timeout_ms = 100
# Force close current notification with smooth animation
try:
self.current_notification.notification.close("expired")
except:
pass
def _show_next_notification(self):
if (
not self.notification_queue
or self.notification_state != NotificationState.IDLE
):
return
notification = self.notification_queue.pop(0)
# Check if notification is still valid (might have been removed)
if not notification or not hasattr(notification, 'app_icon'):
# Skip invalid notifications and try next one
if self.notification_queue:
self._show_next_notification()
return
self.notification_state = NotificationState.SHOWING
new_box = NotificationRevealer(
notification,
on_transition_end=lambda: self._on_notification_finished(new_box),
parent_window=self,
)
self.current_notification = new_box
# Clear any existing children
for child in list(self.notifications.children):
try:
self.notifications.remove(child)
except:
pass
self.notifications.children = [new_box]
# Show the window now that we have content to display
self.set_visible(True)
new_box.show()
self.notifications.queue_resize()
def start_animation():
if new_box.get_parent() and new_box.get_realized():
new_box.reveal()
return False
return True
GLib.idle_add(start_animation)
def _on_notification_finished(self, notification_box):
if notification_box != self.current_notification:
return
# Cancel any pending transition timer
if self._transition_timer_id:
GLib.source_remove(self._transition_timer_id)
self._transition_timer_id = None
# Safely remove notification box
try:
if notification_box in self.notifications.children:
self.notifications.remove(notification_box)
except:
pass
# Reset state
self.current_notification = None
self.notification_state = NotificationState.IDLE
# Process next notification with optimized delay for ultra-smooth transitions
if self.notification_queue:
self._transition_timer_id = GLib.timeout_add(
self.TRANSITION_DELAY, # Consistent smooth timing
lambda: self._show_next_notification() or False,
)
else:
# Hide window when no more notifications to show
self.set_visible(False)
def show_next_notification(self):
# Legacy method for compatibility - redirect to new implementation
self._show_next_notification()
def on_notification_finished(self, notification_box):
# Legacy method for compatibility - redirect to new implementation
self._on_notification_finished(notification_box)
def clear_notification_queue(self):
"""Clear queue with smooth cleanup"""
queue_length = len(self.notification_queue)
if queue_length > 0:
# Smooth dismissal of queued notifications
for notification in list(self.notification_queue):
try:
notification.close("dismissed-by-user")
except:
pass
self.notification_queue.clear()
# Also clean current notification if showing
if self.current_notification:
# Mark for cache cleanup when clearing queue
self.current_notification.notif_box._should_cleanup_cache = True
# Clear animation timers
if self._transition_timer_id:
GLib.source_remove(self._transition_timer_id)
self._transition_timer_id = None
if self._debounce_timer_id:
GLib.source_remove(self._debounce_timer_id)
self._debounce_timer_id = None
def get_queue_length(self):
return len(self.notification_queue)
================================================
FILE: modules/notification/notification_center.py
================================================
from collections import defaultdict
import time
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.centerbox import CenterBox
from fabric.widgets.eventbox import EventBox
from fabric.widgets.label import Label
from fabric.widgets.revealer import Revealer
from fabric.widgets.scrolledwindow import ScrolledWindow
from gi.repository import GLib, GdkPixbuf
from loguru import logger
from modules.notification.notification import (
NotificationWidget,
cache_notification_icon,
cache_notification_image,
get_cached_notification_image,
get_notification_image_cache_key,
preload_notification_assets,
cleanup_all_notification_caches,
cleanup_notification_specific_caches,
get_fallback_notification_icon,
)
from services.modus import notification_service
from utils.functions import escape_markup_text
from widgets.custom_image import CustomImage
from widgets.wayland import WaylandWindow as Window
from config import data
class ExpandableNotificationGroup(Box):
def __init__(self, app_name, notifications, **kwargs):
super().__init__(
name="notification-group", orientation="v", spacing=0, **kwargs
)
self.app_name = app_name
self.notifications = notifications
self.is_expanded = False # Always start collapsed
# Create collapsed state first (shows only latest notification)
self.create_collapsed_state()
# Create expanded state (hidden initially)
self.create_expanded_state()
# Ensure we start in collapsed state
self.collapsed_eventbox.set_visible(True)
self.expanded_container.set_visible(False)
def create_collapsed_state(self):
latest_notification = self.notifications[0] # Most recent notification
# Create clickable event box
self.collapsed_eventbox = EventBox(
events=["button-press-event"],
)
self.collapsed_eventbox.connect("button-press-event", self.on_clicked)
# Only create stacked effect if we have multiple notifications
num_notifications = len(self.notifications)
if num_notifications == 1:
# Single notification - no stacking needed
notification_widget = NotificationCenterWidget(
notification=latest_notification
)
self.collapsed_eventbox.add(notification_widget)
else:
# Multiple notifications - create stacked effect
# Create container for the entire stack
stack_container = Box(
name="notification-stack-container",
orientation="v",
spacing=0,
)
# Add bottom shadow layer first (deepest)
if num_notifications >= 3:
bottom_shadow = Box(
name="stack-shadow-bottom",
)
stack_container.add(bottom_shadow)
# Add middle shadow layer
if num_notifications >= 2:
middle_shadow = Box(
name="stack-shadow-middle",
)
stack_container.add(middle_shadow)
# Add the main notification content on top
main_notification = Box(
name="stack-main-notification",
spacing=8,
children=[
Box(
name="notification-image",
children=CustomImage(
pixbuf=self._get_notification_pixbuf_for_group(
latest_notification
)
),
),
Box(
name="notification-text",
orientation="v",
v_align="center",
h_expand=True,
children=[
Box(
name="notification-summary-box",
orientation="h",
children=[
Label(
name="notification-summary",
markup=f"{self.app_name} ",
h_align="start",
ellipsization="end",
),
],
),
Label(
name="notification-body",
markup=escape_markup_text(latest_notification._notification.summary.replace(
"\n", " "
)),
max_chars_width=25,
h_align="start",
ellipsization="end",
),
],
),
Box(
name="notification-count",
orientation="v",
children=[
Button(
name="notification-close",
image=CustomImage(
icon_name="close-symbolic", icon_size=18
),
visible=True,
on_clicked=lambda *_: self._close_single_notification_and_stop_propagation(
latest_notification
),
),
Label(
name="notification-count-label",
label=f"{len(self.notifications)}",
h_align="end",
),
],
),
],
)
stack_container.add(main_notification)
self.collapsed_eventbox.add(stack_container)
self.add(self.collapsed_eventbox)
# Create expanded state (hidden initially)
self.create_expanded_state()
def _get_notification_pixbuf_for_group(self, cached_notification):
"""Get notification pixbuf using cached image key - fallback to app icon"""
notification = cached_notification._notification
notification_id = getattr(notification, "id", None)
# First try to get cached notification image using stored cache key
if (
hasattr(cached_notification, "cache_metadata")
and cached_notification.cache_metadata
):
notification_image_cache_key = cached_notification.cache_metadata.get(
"notification_image_cache_key"
)
if notification_image_cache_key:
try:
cached_image = get_cached_notification_image(
notification_image_cache_key
)
if cached_image:
logger.debug(
f"Using cached notification image: {notification_image_cache_key}"
)
return cached_image
except Exception as e:
logger.debug(f"Failed to load cached notification image: {e}")
# Fallback to app icon using cached key
if (
hasattr(cached_notification, "cache_metadata")
and cached_notification.cache_metadata
):
app_icon_cache_key = cached_notification.cache_metadata.get(
"app_icon_cache_key"
)
if app_icon_cache_key:
try:
from modules.notification.unified_cache import get_from_cache
cached_app_icon = get_from_cache(app_icon_cache_key, (35, 35))
if cached_app_icon:
# logger.debug(f"Using cached app icon: {app_icon_cache_key}")
return cached_app_icon
except Exception as e:
logger.debug(f"Failed to load cached app icon: {e}")
# Final fallback - try to cache app icon directly if available
try:
app_icon_source = getattr(notification, "app_icon", None)
if app_icon_source:
cached_app_icon = cache_notification_icon(app_icon_source, (35, 35))
if cached_app_icon:
logger.debug(
f"Using directly cached app icon for: {app_icon_source}"
)
return cached_app_icon
except Exception as e:
logger.debug(f"Failed to get directly cached app icon: {e}")
# Ultimate fallback
logger.debug("Using fallback notification icon")
return get_fallback_notification_icon((35, 35))
def create_expanded_state(self):
# Create main expanded container
self.expanded_container = Box(
name="notification-group-expanded-container",
orientation="v",
spacing=0,
)
# Header with app name and controls
self.header_content = Box(
orientation="h",
h_expand=True,
children=[
Label(
name="notification-group-title",
markup=f"{self.app_name} ",
h_align="start",
h_expand=True,
),
Button(
name="notification-show-less",
label="Show less",
on_clicked=self.collapse,
h_align="end",
),
Button(
name="notification-close-summery",
h_expand=False,
v_expand=False,
on_clicked=self.close_all,
image=CustomImage(
icon_name="close-symbolic",
name="notification-close-header",
icon_size=18,
h_align="end",
),
visible=True,
),
],
)
# Wrap header in revealer for slide-up animation during collapse
self.header_revealer = Revealer(
child=self.header_content,
transition_type="slide-up",
transition_duration=300,
child_revealed=False,
)
# Box for individual notifications
self.notifications_list = Box(
name="notification-group-notifications",
orientation="v",
spacing=5,
)
# Add individual notifications to the list
for notification in self.notifications:
notification_widget = NotificationCenterWidget(notification=notification)
self.notifications_list.add(notification_widget)
# Wrap notifications list in revealer for slide-down animation
self.notifications_revealer = Revealer(
child=self.notifications_list,
transition_type="slide-down",
transition_duration=300,
child_revealed=False,
)
# Wrap notifications revealer in crossfade revealer for closing animation
self.notifications_crossfade = Revealer(
child=self.notifications_revealer,
transition_type="crossfade",
transition_duration=250,
child_revealed=True, # Start revealed so crossfade works on close
)
# Add header revealer and notifications crossfade to container
self.expanded_container.add(self.header_revealer)
self.expanded_container.add(self.notifications_crossfade)
# Add the container to the main group
self.add(self.expanded_container)
# Hide the entire expanded container initially
self.expanded_container.set_visible(False)
def on_clicked(self, widget, event):
if event.button == 1: # Left click
# Always allow expansion, even for single notifications
# This ensures single notifications can show their expanded entry view
self.expand()
return True
def expand(self, *args):
"""Expand to show all notifications in this group with slide-down animation"""
self.is_expanded = True
self.collapsed_eventbox.set_visible(False)
self.expanded_container.set_visible(True)
# Show header immediately (no animation on expand)
self.header_revealer.set_reveal_child(True)
# Ensure crossfade is revealed for expand
self.notifications_crossfade.set_reveal_child(True)
# Small delay then animate notifications sliding down
GLib.timeout_add(50, lambda: self.notifications_revealer.set_reveal_child(True))
logger.debug(f"Expanded notification group: {self.app_name}")
def collapse(self, *args):
"""Collapse with header sliding up, notifications crossfading, then sliding up"""
self.is_expanded = False
# Start header slide-up and notifications crossfade simultaneously
self.header_revealer.set_reveal_child(False)
self.notifications_crossfade.set_reveal_child(False)
# Show collapsed state and hide expanded container halfway through crossfade
GLib.timeout_add(125, self._show_collapsed_midway)
# After crossfade completes, start slide-up animation (just for cleanup)
GLib.timeout_add(
260, lambda: self.notifications_revealer.set_reveal_child(False)
)
logger.debug(f"Collapsed notification group: {self.app_name}")
def _show_collapsed_midway(self):
"""Show collapsed state and hide expanded container to prevent deformation"""
self.collapsed_eventbox.set_visible(True)
self.expanded_container.set_visible(False)
return False # Don't repeat timeout
def _complete_collapse(self):
"""Complete the collapse animation - no longer needed but kept for compatibility"""
return False # Don't repeat timeout
def close_all(self, *args):
"""Close all notifications in this group with proper cache cleanup"""
# Close all notifications in this group
for notification in self.notifications:
try:
# Get notification cache metadata for cleanup
cache_metadata = getattr(notification, "cache_metadata", {})
# Clean up caches using stored metadata
from modules.notification.notification import (
cleanup_notification_specific_caches,
)
cleanup_notification_specific_caches(
app_icon_source=notification._notification.app_icon,
notification_image_cache_key=cache_metadata.get(
"notification_image_cache_key"
),
)
logger.debug(
f"Cleaned up caches for notification ID: {notification._notification.id}"
)
notification_service.remove_cached_notification(notification.cache_id)
except Exception as e:
logger.error(
f"Error removing notification {notification.cache_id}: {e}"
)
def _close_single_notification(self, notification):
"""Close a single notification from this group with proper cache cleanup"""
try:
# Get notification cache metadata for cleanup
cache_metadata = getattr(notification, "cache_metadata", {})
# Clean up caches using stored metadata
from modules.notification.notification import (
cleanup_notification_specific_caches,
)
cleanup_notification_specific_caches(
app_icon_source=notification._notification.app_icon,
notification_image_cache_key=cache_metadata.get(
"notification_image_cache_key"
),
)
logger.debug(
f"Cleaned up caches for notification ID: {notification._notification.id}"
)
notification_service.remove_cached_notification(notification.cache_id)
logger.debug(f"Closed single notification: {notification.cache_id}")
except Exception as e:
logger.error(
f"Error removing single notification {notification.cache_id}: {e}"
)
def _close_single_notification_and_stop_propagation(self, notification):
"""Close notification and prevent click from expanding the group"""
self._close_single_notification(notification)
# If this was the last notification in the group, the group will be removed
# by the notification_removed signal handler. If there are still notifications,
# we need to check if this group should be removed from view.
remaining_notifications = [
n for n in self.notifications if n.cache_id != notification.cache_id
]
if not remaining_notifications:
# This was the last notification, the group will be destroyed by signal handler
pass
else:
# Update the notifications list and refresh the view immediately
self.notifications = remaining_notifications
# Force immediate UI update by destroying and recreating collapsed state
if hasattr(self, "collapsed_eventbox"):
self.collapsed_eventbox.destroy()
self.create_collapsed_state()
self.show_all()
return True # Stop event propagation
class NotificationCenterWidget(NotificationWidget):
def __init__(self, notification, **kwargs):
self.notification_id = notification.cache_id
self.cache_metadata = getattr(notification, "cache_metadata", {})
super().__init__(
notification._notification,
timeout_ms=0,
show_close_button=True,
name="notification-centre-notifs",
**kwargs,
)
def _get_notification_pixbuf(self, notification):
"""Get notification pixbuf using cached image key - fallback to app icon"""
notification_id = getattr(notification, "id", None)
# First try to get cached notification image using stored cache key
if self.cache_metadata:
notification_image_cache_key = self.cache_metadata.get(
"notification_image_cache_key"
)
if notification_image_cache_key:
try:
cached_image = get_cached_notification_image(
notification_image_cache_key
)
if cached_image:
logger.debug(
f"Using cached notification image: {notification_image_cache_key}"
)
return cached_image
except Exception as e:
logger.debug(f"Failed to load cached notification image: {e}")
# Fallback to app icon using cached key
if self.cache_metadata:
app_icon_cache_key = self.cache_metadata.get("app_icon_cache_key")
if app_icon_cache_key:
try:
from modules.notification.unified_cache import get_from_cache
cached_app_icon = get_from_cache(app_icon_cache_key, (35, 35))
if cached_app_icon:
# logger.debug(f"Using cached app icon: {app_icon_cache_key}")
return cached_app_icon
except Exception as e:
logger.debug(f"Failed to load cached app icon: {e}")
# Final fallback - try to cache app icon directly if available
try:
app_icon_source = getattr(notification, "app_icon", None)
if app_icon_source:
cached_app_icon = cache_notification_icon(app_icon_source, (35, 35))
if cached_app_icon:
logger.debug(
f"Using directly cached app icon for: {app_icon_source}"
)
return cached_app_icon
except Exception as e:
logger.debug(f"Failed to get directly cached app icon: {e}")
# Ultimate fallback
logger.debug("Using fallback notification icon")
return get_fallback_notification_icon((35, 35))
def create_content(self, notification):
# Create our custom close button for notification center
self.close_button = Button(
name="notif-close-button",
image=CustomImage(
icon_name="close-symbolic", name="notification-close", icon_size=18
),
visible=True, # Always visible in notification center
on_clicked=self._on_close_clicked,
)
self.close_button.connect(
"enter-notify-event", lambda *_: self.hover_button(self.close_button)
)
self.close_button.connect(
"leave-notify-event", lambda *_: self.unhover_button(self.close_button)
)
# Create the content box manually with our custom close button
return Box(
name="notification-content",
spacing=8,
children=[
Box(
name="notification-image",
children=CustomImage(
pixbuf=self._get_notification_pixbuf(notification)
),
),
Box(
name="notification-text",
orientation="v",
v_align="center",
children=[
Box(
name="notification-summary-box",
orientation="h",
children=[
Label(
name="notification-summary",
markup=escape_markup_text(notification.summary.replace("\n", " ")),
h_align="start",
max_chars_width=25,
ellipsization="end",
),
],
),
(
Label(
markup=escape_markup_text(notification.body.replace("\n", " ")),
h_align="start",
max_chars_width=35,
ellipsization="end",
)
if notification.body
else Label(
markup="",
h_align="start",
ellipsization="end",
)
),
],
),
Box(h_expand=True),
Box(
orientation="v",
children=[
self.close_button, # Use our custom close button
Box(v_expand=True),
],
),
],
)
# Override to disable the action buttons
def create_action_buttons(self, notification):
return Box(name="notification-action-buttons")
def _on_close_clicked(self, *args):
try:
# Use instance cache metadata instead of notification attribute
cache_metadata = self.cache_metadata
# Clean up caches using stored metadata
from modules.notification.notification import (
cleanup_notification_specific_caches,
)
cleanup_notification_specific_caches(
app_icon_source=self.notification.app_icon,
notification_image_cache_key=cache_metadata.get(
"notification_image_cache_key"
),
)
logger.debug(
f"Cleaned up caches for notification center ID: {self.notification.id}"
)
notification_service.remove_cached_notification(self.notification_id)
except Exception as e:
logger.error(f"Error removing notification {self.notification_id}: {e}")
# Override to disable timeout functionality
def start_timeout(self):
pass
# Override to disable timeout functionality
def stop_timeout(self):
pass
# Override to disable auto-close functionality
def close_notification(self):
return False
class NotificationCenter(Window):
def __init__(self):
super().__init__(
layer="overlay",
anchor="top right",
visible=False,
keyboard_mode="on-demand",
title="modus",
)
NOTIFICATION_CENTER_WIDTH = 410
self.set_size_request(NOTIFICATION_CENTER_WIDTH, 600)
# Group notifications by app name
self.notification_groups = defaultdict(list)
self.group_widgets = {}
notification_service.connect(
"cached-notification-added", self.on_notification_added
)
notification_service.connect(
"cached-notification-removed", self.on_notification_removed
)
notification_service.connect("clear-all", self.on_clear_all)
notification_service.connect("notify::count", self.on_count_changed)
main_box = Box(
orientation="v",
spacing=5,
name="noti-center-box",
)
self.scrolled = ScrolledWindow(h_expand=False, v_expand=False)
self.notifications_box = Box(
v_expand=False,
h_expand=False,
style="margin: 1px 0px 1px 1px;",
orientation="v",
spacing=5,
)
self.scrolled.add(self.notifications_box)
main_box.add(self.scrolled)
# No notifications label - REMOVED
self.clear_all_button = Button(
name="noti-clear-button",
label="Clear",
on_clicked=self.clear_all_notifications,
visible=(notification_service.count > 0),
)
self.button_centre_box = CenterBox(
center_children=[self.clear_all_button],
)
main_box.add(self.button_centre_box)
# Wrap main content in revealer for slide-left animation
self.main_revealer = Revealer(
child=main_box,
transition_type="slide-left",
transition_duration=400,
child_revealed=False,
)
self.children = self.main_revealer
# Load existing notifications and group them
self._rebuild_notification_groups()
self.add_keybinding("Escape", self._on_escape_pressed)
self.connect("destroy", self._on_destroy)
def _rebuild_notification_groups(self):
"""Rebuild notification groups from scratch with enhanced asset preloading and debugging"""
# Clear existing groups
self.notification_groups.clear()
self.group_widgets.clear()
# Clear notifications box
for child in self.notifications_box.get_children():
child.destroy()
# Group notifications by app name and preload assets with debugging
rebuild_count = 0
for cached_notification in notification_service.cached_notifications:
app_name = cached_notification._notification.app_name
notification_id = getattr(cached_notification._notification, "id", None)
# Skip ignored apps during rebuild
if app_name in data.NOTIFICATION_IGNORED_APPS_HISTORY:
continue
# Preload assets for each cached notification to ensure display consistency
preload_notification_assets(cached_notification._notification)
self.notification_groups[app_name].append(cached_notification)
rebuild_count += 1
logger.info(
f"Rebuilt {rebuild_count} notifications into {
len(self.notification_groups)
} groups"
)
# Create group widgets and handle limited apps
for app_name, notifications in self.notification_groups.items():
# Sort notifications by ID (highest ID first - newest notifications)
# This ensures the latest notifications appear at the top of each group
notifications.sort(
key=lambda n: getattr(n._notification, "id", 0), reverse=True
)
# Handle limited apps history - only keep 5 notifications during rebuild
if app_name in data.NOTIFICATION_LIMITED_APPS_HISTORY:
if len(notifications) > 5:
# Keep only the 5 most recent notifications
self.notification_groups[app_name] = notifications[:5]
group_widget = ExpandableNotificationGroup(
app_name, self.notification_groups[app_name]
)
self.group_widgets[app_name] = group_widget
self.notifications_box.add(group_widget)
def on_notification_added(self, service, cached_notification):
try:
app_name = cached_notification._notification.app_name
# Check if this app should be ignored for history (don't add to notification center)
if app_name in data.NOTIFICATION_IGNORED_APPS_HISTORY:
return
# Preload assets for notification center display (ensure caching consistency)
preload_notification_assets(cached_notification._notification)
# Add to groups in sorted order (highest ID first - maintains newest-first ordering)
notifications_list = self.notification_groups[app_name]
new_notification_id = getattr(cached_notification._notification, "id", 0)
# Find the correct position to insert based on ID (highest first)
insert_position = 0
for i, existing_notification in enumerate(notifications_list):
existing_id = getattr(existing_notification._notification, "id", 0)
if new_notification_id > existing_id:
insert_position = i
break
insert_position = i + 1
notifications_list.insert(insert_position, cached_notification)
# Handle limited apps history - only keep 5 notifications
if app_name in data.NOTIFICATION_LIMITED_APPS_HISTORY:
notifications_for_app = self.notification_groups[app_name]
if len(notifications_for_app) > 5:
# Remove oldest notifications beyond the limit
excess_notifications = notifications_for_app[5:]
self.notification_groups[app_name] = notifications_for_app[:5]
# Remove excess notifications from the service cache
for excess_notification in excess_notifications:
try:
notification_service.remove_cached_notification(
excess_notification.cache_id
)
except Exception as e:
logger.error(f"Error removing excess notification: {e}")
# Update or create group widget
if app_name in self.group_widgets:
# Update existing group
group_widget = self.group_widgets[app_name]
group_widget.notifications = self.notification_groups[app_name]
# Refresh the group widget
self._refresh_group_widget(group_widget)
else:
# Create new group widget
group_widget = ExpandableNotificationGroup(
app_name, self.notification_groups[app_name]
)
self.group_widgets[app_name] = group_widget
self.notifications_box.pack_start(group_widget, False, False, 0)
group_widget.show_all()
logger.debug(f"Added notification to group {app_name}")
except Exception as e:
logger.error(f"Error adding notification to group: {e}")
def _refresh_group_widget(self, group_widget):
"""Refresh a group widget's content"""
try:
# Remove existing children
for child in group_widget.get_children():
group_widget.remove(child)
# Recreate content
group_widget.create_collapsed_state()
group_widget.show_all()
except Exception as e:
logger.error(f"Error refreshing group widget: {e}")
def on_notification_removed(self, service, cached_notification):
try:
app_name = cached_notification._notification.app_name
# Remove from groups
if app_name in self.notification_groups:
self.notification_groups[app_name] = [
n
for n in self.notification_groups[app_name]
if n.cache_id != cached_notification.cache_id
]
# If no more notifications for this app, remove group widget
if not self.notification_groups[app_name]:
if app_name in self.group_widgets:
group_widget = self.group_widgets[app_name]
group_widget.destroy()
del self.group_widgets[app_name]
del self.notification_groups[app_name]
else:
# Update existing group widget
group_widget = self.group_widgets[app_name]
group_widget.notifications = self.notification_groups[app_name]
self._refresh_group_widget(group_widget)
# Clean up caches
cleanup_notification_specific_caches(
app_icon_source=getattr(cached_notification, "app_icon_source", None),
notification_image_cache_key=getattr(
cached_notification, "notification_image_cache_key", None
),
)
logger.debug(f"Removed notification from group {app_name}")
except Exception as e:
logger.error(f"Error removing notification from group: {e}")
def on_clear_all(self, service):
try:
# Clear all groups
self.notification_groups.clear()
self.group_widgets.clear()
# Clear all remaining cached notification images AND icons
cleanup_all_notification_caches()
for child in self.notifications_box.get_children():
child.destroy()
logger.debug("Cleared all notification groups and remaining cached images")
except Exception as e:
logger.error(f"Error clearing notification groups: {e}")
def on_count_changed(self, service, count=None):
current_count = notification_service.count
# No notifications label removed - only update clear button and scrolled visibility
self.clear_all_button.set_visible(current_count > 0)
self.scrolled.set_visible(current_count > 0)
# Auto-close notification center when no notifications remain
if current_count == 0 and hasattr(self, "mousecapture"):
self.mousecapture.hide_child_window()
def clear_all_notifications(self, *_):
# Clear all groups
self.notification_groups.clear()
self.group_widgets.clear()
# Clear all remaining cached notification images AND icons when clear all is clicked
cleanup_all_notification_caches() # Clear ALL caches (icons + images)
notification_service.clear_all_cached_notifications()
if hasattr(self, "mousecapture"):
self.mousecapture.hide_child_window()
def _on_escape_pressed(self, *_):
if hasattr(self, "mousecapture"):
self.mousecapture.hide_child_window()
def _init_mousecapture(self, mousecapture):
self.mousecapture = mousecapture
def _set_mousecapture(self, visible):
"""Control notification center visibility with slide-left animation"""
if visible:
self.main_revealer.set_reveal_child(True)
else:
self.main_revealer.set_reveal_child(False)
logger.debug(f"Notification center visibility set to: {visible}")
def _on_destroy(self, *_):
# Signals will be automatically disconnected when the object is destroyed
pass
================================================
FILE: modules/notification/unified_cache.py
================================================
import os
import hashlib
import time
import uuid
from fabric.utils import get_relative_path
from gi.repository import GdkPixbuf
from loguru import logger
import config.data as data
# Unified notification cache directory (for both app icons and notification images)
UNIFIED_NOTIFICATION_CACHE_DIR = os.path.join(data.CACHE_DIR, "notifications")
def ensure_cache_dir():
"""Ensure unified notification cache directory exists"""
os.makedirs(UNIFIED_NOTIFICATION_CACHE_DIR, exist_ok=True)
def get_unified_cache_key(source_data, size=None, app_name=None):
"""Generate a unified cache key that works for both app icons and notification images"""
try:
if hasattr(source_data, "get_pixels"):
# For pixbuf data - use hash of pixel data for deterministic caching
try:
pixel_data = source_data.get_pixels()
image_hash = hashlib.md5(pixel_data).hexdigest()[:8]
return image_hash
except Exception:
# Fallback to random UUID if pixel data fails
return str(uuid.uuid4())[:8]
elif isinstance(source_data, str):
# For file paths - create hash-based name
if source_data.startswith("file://"):
source_data = source_data[7:]
# Create hash from file path and size
hash_input = source_data
if size:
hash_input += f"_{size[0]}x{size[1]}"
return hashlib.md5(hash_input.encode()).hexdigest()[:8]
else:
# Fallback to random UUID
return str(uuid.uuid4())[:8]
except Exception:
# Ultimate fallback
return str(uuid.uuid4())[:8]
def save_to_cache(pixbuf, cache_key, size=None):
"""Save a pixbuf to the unified cache directory"""
try:
ensure_cache_dir()
cache_path = os.path.join(UNIFIED_NOTIFICATION_CACHE_DIR, f"{cache_key}.png")
# Don't overwrite existing cache
if os.path.exists(cache_path):
logger.debug(f"Cache hit - already exists: {cache_key}")
return cache_path, cache_key
# Scale if size is specified
if size and (pixbuf.get_width() != size[0] or pixbuf.get_height() != size[1]):
pixbuf = pixbuf.scale_simple(
size[0], size[1], GdkPixbuf.InterpType.BILINEAR
)
pixbuf.savev(cache_path, "png", [], [])
logger.debug(f"Cached notification asset: {cache_key}")
return cache_path, cache_key
except Exception as e:
logger.warning(f"Failed to cache notification asset: {e}")
return None, None
def get_from_cache(cache_key, size=None):
"""Get a cached asset or return None if not found"""
try:
cache_path = os.path.join(UNIFIED_NOTIFICATION_CACHE_DIR, f"{cache_key}.png")
if os.path.exists(cache_path):
# logger.debug(f"Using cached asset: {cache_key}")
if size:
return GdkPixbuf.Pixbuf.new_from_file_at_scale(
cache_path, size[0], size[1], True
)
else:
return GdkPixbuf.Pixbuf.new_from_file(cache_path)
except Exception as e:
logger.warning(f"Failed to load cached asset: {e}")
return None
def cleanup_cache(cache_key=None):
"""Clean up unified cache - specific key or all"""
try:
ensure_cache_dir()
if cache_key:
# Remove specific cached asset
cache_path = os.path.join(
UNIFIED_NOTIFICATION_CACHE_DIR, f"{cache_key}.png"
)
if os.path.exists(cache_path):
os.unlink(cache_path)
logger.debug(f"Cleaned up cached asset: {cache_key}")
else:
# Remove all cached assets
for filename in os.listdir(UNIFIED_NOTIFICATION_CACHE_DIR):
if filename.endswith(".png"):
filepath = os.path.join(UNIFIED_NOTIFICATION_CACHE_DIR, filename)
try:
os.unlink(filepath)
logger.debug(f"Cleaned up cached asset: {filename}")
except Exception as e:
logger.warning(f"Failed to cleanup cache file {filename}: {e}")
except Exception as e:
logger.warning(f"Failed to cleanup cache: {e}")
def cleanup_old_cache_files():
"""Clean up old cache files (older than 7 days)"""
try:
if not os.path.exists(UNIFIED_NOTIFICATION_CACHE_DIR):
return
current_time = time.time()
week_ago = current_time - (7 * 24 * 60 * 60) # 7 days
for filename in os.listdir(UNIFIED_NOTIFICATION_CACHE_DIR):
filepath = os.path.join(UNIFIED_NOTIFICATION_CACHE_DIR, filename)
try:
if os.path.isfile(filepath):
file_mtime = os.path.getmtime(filepath)
if file_mtime < week_ago:
os.unlink(filepath)
logger.debug(f"Cleaned up old cache: {filename}")
except Exception as e:
logger.warning(f"Failed to cleanup cache file {filename}: {e}")
except Exception as e:
logger.warning(f"Failed to cleanup cache: {e}")
def verify_cache_persistence():
"""Verify that cached assets persist and can be loaded after restart"""
try:
cache_files = []
if os.path.exists(UNIFIED_NOTIFICATION_CACHE_DIR):
cache_files = [
f
for f in os.listdir(UNIFIED_NOTIFICATION_CACHE_DIR)
if f.endswith(".png")
]
logger.info(f"Cache persistence check: {len(cache_files)} assets cached")
# Test loading a few cached items to verify they work
for cache_file in cache_files[:2]: # Test first 2 files
try:
cache_path = os.path.join(UNIFIED_NOTIFICATION_CACHE_DIR, cache_file)
test_pixbuf = GdkPixbuf.Pixbuf.new_from_file(cache_path)
if test_pixbuf:
logger.debug(f"Successfully verified cached asset: {cache_file}")
except Exception as e:
logger.warning(f"Failed to load cached asset {cache_file}: {e}")
return len(cache_files) > 0
except Exception as e:
logger.error(f"Failed to verify cache persistence: {e}")
return False
def get_fallback_icon(size=(48, 48)):
"""Get the fallback notification icon"""
try:
fallback_path = get_relative_path("../../config/assets/icons/notification.png")
return GdkPixbuf.Pixbuf.new_from_file_at_scale(
fallback_path, size[0], size[1], True
)
except Exception as e:
logger.warning(f"Failed to load fallback icon: {e}")
# Create a simple colored rectangle as ultimate fallback
try:
return GdkPixbuf.Pixbuf.new(
GdkPixbuf.Colorspace.RGB, True, 8, size[0], size[1]
)
except:
return None
# Initialize cache on module load
ensure_cache_dir()
cleanup_old_cache_files()
verify_cache_persistence()
================================================
FILE: modules/osd.py
================================================
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
================================================
FILE: modules/panel/components/enhanced_system_tray.py
================================================
"""
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
================================================
FILE: modules/panel/components/indicators.py
================================================
from fabric.bluetooth import BluetoothClient
from fabric.utils import get_relative_path
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.label import Label
from fabric.widgets.svg import Svg
from modules.controlcenter.battery import BatteryControl
from modules.controlcenter.bluetooth import BluetoothConnections
from modules.controlcenter.wifi import WifiConnections
from services.battery import Battery
from services.network import NetworkClient
from utils.roam import modus_service
from utils.functions import get_wifi_icon_for_strength, get_wifi_connecting_icon
from widgets.mousecapture import DropDownMouseCapture
from widgets.wayland import WaylandWindow as Window
class BluetoothIndicator(Box):
def __init__(self, show_window=True, **kwargs):
super().__init__(name="bluetooth-indicator", orientation="h", **kwargs)
self.show_window = show_window
self.bluetooth = BluetoothClient()
self.bt_icon = Svg(
name="bt-icon",
size=22,
svg_file=get_relative_path(
"../../../config/assets/icons/applets/bluetooth-clear.svg"
),
)
self.bt_button = Button(
name="bt-button", child=self.bt_icon, on_clicked=self.on_bluetooth_clicked
)
self.add(self.bt_button)
# Create Bluetooth control center widget only if show_window is True
if self.show_window:
self.bluetooth_window = Window(
layer="overlay",
title="modus",
anchor="top right",
margin="2px 10px 0px 0px",
exclusivity="auto",
keyboard_mode="on-demand",
name="bluetooth-control-window",
visible=False,
)
self.bluetooth_widget = BluetoothConnections(self, show_back_button=False)
self.bluetooth_window.children = [self.bluetooth_widget]
# Create mouse capture for Bluetooth widget
self.bluetooth_mousecapture = DropDownMouseCapture(
layer="top", child_window=self.bluetooth_window
)
else:
self.bluetooth_window = None
self.bluetooth_widget = None
self.bluetooth_mousecapture = None
modus_service.connect("bluetooth-changed", self.on_bluetooth_changed)
self.bluetooth.connect("changed", self.on_bluetooth_direct_changed)
self.bluetooth.connect("device-added", self.on_device_added)
self.bluetooth.connect("device-removed", self.on_device_removed)
self.update_modus_service_bluetooth_state()
self.update_state()
def update_state(self):
if not self.bluetooth.enabled:
self.bt_icon.set_from_file(
get_relative_path(
"../../../config/assets/icons/applets/bluetooth-off-clear.svg"
)
)
tooltip = "Bluetooth disabled"
else:
connected_devices = self.bluetooth.connected_devices
if connected_devices:
self.bt_icon.set_from_file(
get_relative_path(
"../../../config/assets/icons/applets/bluetooth-clear.svg"
)
)
if len(connected_devices) >= 1:
self.bt_icon.set_from_file(
get_relative_path(
"../../../config/assets/icons/applets/bluetooth-paired.svg"
)
)
device = connected_devices[0]
tooltip = f"Connected to {device.alias}"
if device.battery_percentage > 0:
tooltip += f" ({device.battery_percentage:.0f}%)"
else:
tooltip = f"Connected to {len(connected_devices)} devices"
else:
self.bt_icon.set_from_file(
get_relative_path(
"../../../config/assets/icons/applets/bluetooth-clear.svg"
)
)
tooltip = "No devices connected"
self.bt_button.set_tooltip_text(tooltip)
def on_bluetooth_changed(self, service, new_bluetooth_state):
self.update_state()
def on_bluetooth_direct_changed(self, *args):
self.update_modus_service_bluetooth_state()
self.update_state()
def on_device_added(self, _, address):
self.update_modus_service_bluetooth_state()
self.update_state()
def on_device_removed(self, _, address):
self.update_modus_service_bluetooth_state()
self.update_state()
def update_modus_service_bluetooth_state(self):
if not self.bluetooth.enabled:
bluetooth_state = "disabled"
else:
connected_devices = self.bluetooth.connected_devices
if connected_devices:
if len(connected_devices) == 1:
device = connected_devices[0]
bluetooth_state = f"connected:{device.alias}"
if (
hasattr(device, "battery_percentage")
and device.battery_percentage > 0
):
bluetooth_state += f":{device.battery_percentage:.0f}%"
else:
bluetooth_state = f"connected:{len(connected_devices)}_devices"
else:
bluetooth_state = "enabled"
modus_service.bluetooth = bluetooth_state
def on_bluetooth_clicked(self, *args):
"""Handle Bluetooth indicator click"""
if self.show_window and self.bluetooth_mousecapture:
self.bluetooth_mousecapture.toggle_mousecapture()
def close_bluetooth(self, *args):
"""Close Bluetooth control center"""
if self.show_window and self.bluetooth_mousecapture:
self.bluetooth_mousecapture.hide_child_window()
def hide_controlcenter(self, *args):
"""Hide Bluetooth control center"""
if self.show_window and self.bluetooth_mousecapture:
self.bluetooth_mousecapture.hide_child_window()
class NetworkIndicator(Box):
def __init__(self, show_window=True, **kwargs):
super().__init__(name="network-indicator", orientation="h", **kwargs)
self.show_window = show_window
self.network_service = NetworkClient()
self.network_icon = Svg(
name="network-icon",
size=22,
svg_file=get_relative_path(
"../../../config/assets/icons/applets/wifi-clear.svg"
),
)
self.network_button = Button(
name="network-button",
child=self.network_icon,
on_clicked=self.on_wifi_clicked,
)
self.add(self.network_button)
# Create WiFi control center widget only if show_window is True
if self.show_window:
self.wifi_window = Window(
layer="overlay",
title="modus",
anchor="top right",
margin="2px 10px 0px 0px",
exclusivity="auto",
keyboard_mode="on-demand",
name="wifi-control-window",
visible=False,
)
self.wifi_widget = WifiConnections(self, show_back_button=False)
self.wifi_window.children = [self.wifi_widget]
# Create mouse capture for WiFi widget
self.wifi_mousecapture = DropDownMouseCapture(
layer="top", child_window=self.wifi_window
)
else:
self.wifi_window = None
self.wifi_widget = None
self.wifi_mousecapture = None
modus_service.connect("wlan-changed", self.on_wlan_changed)
self.network_service.connect("wifi-device-added", self.on_wifi_device_added)
self.network_service.connect(
"ethernet-device-added", self.on_ethernet_device_added
)
self.network_service.connect("changed", self.on_network_changed)
self.update_modus_service_wlan_state()
self.update_state()
def on_wlan_changed(self, service, new_wlan_state):
self.update_state()
def on_wifi_device_added(self, *args):
"""Called when WiFi device is added"""
if self.network_service.wifi_device:
self.network_service.wifi_device.connect(
"changed", self.on_network_direct_changed
)
self.update_modus_service_wlan_state()
self.update_state()
def on_ethernet_device_added(self, *args):
"""Called when Ethernet device is added"""
if self.network_service.ethernet_device:
self.network_service.ethernet_device.connect(
"changed", self.on_network_direct_changed
)
self.update_modus_service_wlan_state()
self.update_state()
def on_network_direct_changed(self, *args):
self.update_modus_service_wlan_state()
self.update_state()
def on_network_changed(self, *args):
self.update_modus_service_wlan_state()
self.update_state()
def update_modus_service_wlan_state(self):
wlan_state = "disconnected"
# Check WiFi first (prioritize WiFi over Ethernet)
if self.network_service.wifi_device:
wifi = self.network_service.wifi_device
if not wifi.wireless_enabled:
wlan_state = "disabled"
elif wifi.active_access_point:
ap = wifi.active_access_point
wlan_state = f"connected:{ap.ssid}"
if ap.strength >= 0:
wlan_state += f":{ap.strength}%"
else:
wlan_state = "enabled"
# Check Ethernet if WiFi is not connected
elif self.network_service.ethernet_device:
ethernet = self.network_service.ethernet_device
if ethernet.internet == "activated":
wlan_state = "ethernet:connected"
if hasattr(ethernet, "speed") and ethernet.speed:
wlan_state += f":{ethernet.speed}"
elif ethernet.internet == "activating":
wlan_state = "ethernet:connecting"
else:
wlan_state = "ethernet:disconnected"
modus_service.wlan = wlan_state
def update_state(self):
tooltip = "No network connection"
icon_file = "wifi-off-clear.svg"
# Check WiFi first (prioritize WiFi over Ethernet)
if self.network_service.wifi_device:
wifi = self.network_service.wifi_device
if not wifi.wireless_enabled:
icon_file = "wifi-off-clear.svg"
tooltip = "WiFi disabled"
elif wifi.active_access_point:
ap = wifi.active_access_point
# Use dynamic WiFi icon based on signal strength
wifi_icon_path = get_wifi_icon_for_strength(ap.strength)
self.network_icon.set_from_file(wifi_icon_path)
tooltip = f"Connected to {ap.ssid}"
if ap.strength >= 0:
tooltip += f" ({ap.strength}%)"
self.network_button.set_tooltip_text(tooltip)
return # Early return to avoid setting icon again
else:
icon_file = "wifi-off-clear.svg"
tooltip = "WiFi disconnected"
# Check Ethernet if WiFi is not connected
elif self.network_service.ethernet_device:
ethernet = self.network_service.ethernet_device
if ethernet.internet == "activated":
icon_file = "network-wired.svg"
tooltip = "Ethernet connected"
if hasattr(ethernet, "speed") and ethernet.speed:
tooltip += f" ({ethernet.speed})"
elif ethernet.internet == "activating":
icon_file = "network-wired.svg"
tooltip = "Ethernet connecting..."
else:
icon_file = "network-wired-offline.svg"
tooltip = "Ethernet disconnected"
self.network_icon.set_from_file(
get_relative_path(f"../../../config/assets/icons/applets/{icon_file}")
)
self.network_button.set_tooltip_text(tooltip)
def on_wifi_clicked(self, *args):
"""Handle WiFi indicator click"""
if self.show_window and self.wifi_mousecapture:
self.wifi_mousecapture.toggle_mousecapture()
def close_wifi(self, *args):
"""Close WiFi control center"""
if self.show_window and self.wifi_mousecapture:
self.wifi_mousecapture.hide_child_window()
def hide_controlcenter(self, *args):
"""Hide WiFi control center"""
if self.show_window and self.wifi_mousecapture:
self.wifi_mousecapture.hide_child_window()
class BatteryIndicator(Box):
def __init__(self, show_window=True, **kwargs):
super().__init__(name="battery-indicator", orientation="h", **kwargs)
self.show_window = show_window
self.battery_service = Battery()
self.battery_icon = Svg(
name="battery-icon",
size=23,
svg_file=get_relative_path(
"../../../config/assets/icons/battery/battery-100.svg"
),
)
self.battery_button = Button(
name="battery-button",
child=self.battery_icon,
on_clicked=self.on_battery_clicked,
)
self.battery_label = Label(name="battery-label", label="--- %")
self.add(self.battery_label)
self.add(self.battery_button)
# Create Battery control center widget only if show_window is True
if self.show_window:
self.battery_window = Window(
layer="top",
title="modus",
anchor="top right",
margin="2px 10px 0px 0px",
exclusivity="auto",
keyboard_mode="on-demand",
name="battery-control-window",
visible=False,
)
self.battery_widget = BatteryControl(self, show_back_button=False)
self.battery_window.children = [self.battery_widget]
# Create mouse capture for Battery widget
self.battery_mousecapture = DropDownMouseCapture(
layer="top", child_window=self.battery_window
)
else:
self.battery_window = None
self.battery_widget = None
self.battery_mousecapture = None
modus_service.connect("battery-changed", self.on_battery_changed)
self.battery_service.connect("changed", self.on_battery_direct_changed)
self.update_modus_service_battery_state()
self.update_state()
def on_battery_changed(self, service, new_battery_state):
self.update_state()
def on_battery_direct_changed(self, *args):
self.update_modus_service_battery_state()
self.update_state()
def update_modus_service_battery_state(self):
if not self.battery_service.is_present:
battery_state = "not_present"
else:
percentage = self.battery_service.percentage
state = self.battery_service.state.lower()
battery_state = f"{state}:{percentage}%"
if state == "discharging":
time_to_empty = self.battery_service.time_to_empty
if time_to_empty != "N/A":
battery_state += f":{time_to_empty}"
elif state == "charging":
time_to_full = self.battery_service.time_to_full
if time_to_full != "N/A":
battery_state += f":{time_to_full}"
modus_service.battery = battery_state
def get_battery_tooltip(self, percentage, state):
tooltip = f"Battery: {percentage}%"
if state == "CHARGING":
tooltip += " (Charging)"
time_to_full = self.battery_service.time_to_full
if time_to_full != "N/A":
tooltip += f" - {time_to_full} until full"
elif state == "DISCHARGING":
time_to_empty = self.battery_service.time_to_empty
if time_to_empty != "N/A":
tooltip += f" - {time_to_empty} remaining"
elif state == "FULLY_CHARGED":
tooltip += " (Fully charged)"
return tooltip
def update_state(self):
if not self.battery_service.is_present:
# Hide the entire battery component when no battery is present
self.set_visible(False)
return
else:
# Show the battery component when battery is present
self.set_visible(True)
percentage = self.battery_service.percentage
state = self.battery_service.state
is_charging = state in ["CHARGING", "FULLY_CHARGED"]
icon_file = Battery.get_battery_icon_file(
percentage, is_charging, base_path="../../../config/assets/icons/"
)
tooltip = self.get_battery_tooltip(percentage, state)
percentage_text = f"{percentage}%"
# Update icon, tooltip, and percentage label
self.battery_icon.set_from_file(get_relative_path(icon_file))
self.battery_button.set_tooltip_text(tooltip)
self.battery_label.set_label(percentage_text)
def on_battery_clicked(self, *args):
"""Handle Battery indicator click"""
if self.show_window and self.battery_mousecapture:
self.battery_mousecapture.toggle_mousecapture()
def close_battery(self, *args):
"""Close Battery control center"""
if self.show_window and self.battery_mousecapture:
self.battery_mousecapture.hide_child_window()
def hide_controlcenter(self, *args):
"""Hide Battery control center"""
if self.show_window and self.battery_mousecapture:
self.battery_mousecapture.hide_child_window()
================================================
FILE: modules/panel/components/menubar.py
================================================
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)
================================================
FILE: modules/panel/components/recording_indicator.py
================================================
import os
import subprocess
import time
from fabric.utils import get_relative_path
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.label import Label
from fabric.widgets.svg import Svg
from gi.repository import GLib
class RecordingIndicator(Button):
def __init__(self, **kwargs):
super().__init__(name="panel-button", visible=True, **kwargs)
self.script_path = get_relative_path("../../../scripts/screen-capture.sh")
self.recording_start_time = None
self.last_process_check = 0
self.process_check_interval = 1.0
self.timer_update_interval = 1000
self.status_check_interval = 2000
self.timer_timeout_id = None
self.status_timeout_id = None
self.recording_icon = Svg(
name="indicators-icon",
size=24,
svg_file=get_relative_path(
"../../../config/assets/icons/misc/media-record.svg"
),
)
self.time_label = Label(
name="recording-time-label",
markup="00:00",
max_width_chars=5,
ellipsize="none",
)
self.recording_box = Box(
orientation="h",
spacing=2,
children=[self.recording_icon, self.time_label],
size=(80, -1),
)
self.add(self.recording_box)
self.connect("clicked", self.on_stop_recording)
self.connect("button-press-event", self.on_button_press)
self.hide()
GLib.timeout_add(100, self._delayed_init)
def on_button_press(self, *args):
GLib.timeout_add(100, lambda: self.remove_style_class("pressed") or False)
return False
def is_recorder_running(self):
# add more process names if needed
recorder_processes = ["wf-recorder", "gpu-screen-recorder"]
try:
for proc in recorder_processes:
result = subprocess.run(
["pgrep", "-x", proc],
capture_output=True,
text=True,
timeout=1,
)
if result.returncode == 0:
return True # Found a running recorder process
return False # None found running
except Exception:
return False
def check_recording_status(self):
current_time = time.time()
self.last_process_check = current_time
try:
is_recording = self.is_recorder_running()
if is_recording:
if not self.get_visible():
self.set_visible(True)
if self.timer_timeout_id is None:
self.timer_timeout_id = GLib.timeout_add(
self.timer_update_interval, self.update_timer_display
)
if self.recording_start_time is None:
self.recording_start_time = self.get_recording_start_time()
self.update_timer_display()
else:
if self.get_visible():
self.set_visible(False)
self.cleanup_recording_state()
except Exception as e:
print(f"[DEBUG] Error checking recording status: {e}")
if self.get_visible():
self.set_visible(False)
self.cleanup_recording_state()
return True
def update_timer_display(self):
if not self.get_visible() or self.recording_start_time is None:
return False
try:
elapsed_seconds = int(time.time() - self.recording_start_time)
minutes = elapsed_seconds // 60
seconds = elapsed_seconds % 60
time_text = f"{minutes:02d}:{seconds:02d}"
self.time_label.set_markup(time_text)
self.set_tooltip_text(
f"Recording in progress ({time_text}) - Click to stop"
)
return True
except Exception as e:
print(f"[DEBUG] Error updating timer display: {e}")
return False
def cleanup_recording_state(self):
self.recording_start_time = None
if self.timer_timeout_id:
GLib.source_remove(self.timer_timeout_id)
self.timer_timeout_id = None
def get_recording_start_time(self):
wf_file = "/tmp/recording_start_time.txt"
gpu_file = "/tmp/gpu_recording_start_time.txt"
def read_timestamp(path):
try:
with open(path, "r") as f:
content = f.read().strip()
if content:
t = float(content)
if abs(t - time.time()) <= 3600:
return t
return os.path.getmtime(path)
except (OSError, ValueError):
return None
if os.path.exists(wf_file):
t = read_timestamp(wf_file)
if t:
print("[DEBUG] Using wf-recorder start time")
return t
if os.path.exists(gpu_file):
t = read_timestamp(gpu_file)
if t:
print("[DEBUG] Using gpu-screen-recorder start time")
return t
print("[DEBUG] No start time file found, using current time")
return time.time()
def on_stop_recording(self, *args):
try:
self.set_visible(False)
self.cleanup_recording_state()
def send_stop_command():
try:
subprocess.Popen(
[self.script_path, "record", "stop"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except Exception as e:
print(f"[DEBUG] Error sending stop command: {e}")
GLib.idle_add(send_stop_command)
GLib.timeout_add(500, self._verify_recording_stopped)
GLib.timeout_add(1500, self._verify_recording_stopped)
GLib.timeout_add(3000, self._verify_recording_stopped)
except Exception:
self.set_visible(False)
self.cleanup_recording_state()
def _verify_recording_stopped(self):
try:
if self.is_recorder_running():
if self.recording_start_time is None:
self.recording_start_time = self.get_recording_start_time()
if self.recording_start_time:
self.set_visible(True)
self.update_timer_display()
if self.timer_timeout_id is None:
self.timer_timeout_id = GLib.timeout_add(
self.timer_update_interval, self.update_timer_display
)
else:
self.set_visible(False)
self.cleanup_recording_state()
except Exception:
self.set_visible(False)
self.cleanup_recording_state()
return False
def _delayed_init(self):
try:
self.check_recording_status()
self.status_timeout_id = GLib.timeout_add(
self.status_check_interval, self.check_recording_status
)
except Exception as e:
print(f"[DEBUG] Error in delayed recording indicator init: {e}")
return False
def destroy(self):
if self.timer_timeout_id:
GLib.source_remove(self.timer_timeout_id)
self.timer_timeout_id = None
if self.status_timeout_id:
GLib.source_remove(self.status_timeout_id)
self.status_timeout_id = None
super().destroy()
================================================
FILE: modules/panel/main.py
================================================
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)
================================================
FILE: modules/switcher.py
================================================
import json
import gi
from gi.repository import Gdk, Glace
import config.data as data
from fabric.hyprland.widgets import get_hyprland_connection
from fabric.widgets.box import Box
from fabric.widgets.eventbox import EventBox
from fabric.widgets.image import Image
from fabric.widgets.label import Label
from utils.icon_resolver import IconResolver
from utils.occlusion import get_screen_dimensions
from utils.functions import is_special_workspace
from widgets.wayland import WaylandWindow as Window
gi.require_version("Glace", "0.1")
class ApplicationSwitcher(Window):
def __init__(self, **kwargs):
super().__init__(
name="application-switcher",
title="modus-switcher",
layer="top",
anchor="center",
exclusivity="auto",
keyboard_mode="exclusive",
visible=False, # Start hidden until explicitly shown
**kwargs,
)
self.conn = get_hyprland_connection()
self.icon_resolver = IconResolver()
self.windows = []
self.current_index = 0
self.tab_pressed = False
self.items_per_row = data.WINDOW_SWITCHER_ITEMS_PER_ROW
self.icon_size = 64
# Initialize Glace manager for window previews
self._manager = Glace.Manager()
# Calculate preview size based on screen ratio
# Formula: screen_ratio = a:b, width = x, height = (x*b)/a
screen_width, screen_height = get_screen_dimensions()
preview_width = 150 # Base width
preview_height = int((preview_width * screen_height) / screen_width)
self.preview_size = [preview_width, preview_height]
self.glace_clients = {} # Map window addresses to Glace clients
self.window_previews = {} # Map window addresses to preview images
container = Box(
name="application-switcher-container",
orientation="v",
h_align="center",
v_align="center",
expand=True,
)
self.add(container)
self.view = Box(
name="application-switcher-view",
orientation="v",
spacing=12,
h_align="center",
v_align="center",
)
container.add(self.view)
self.connect("key-press-event", self.on_key_press)
self.connect("key-release-event", self.on_key_release)
# Connect to Glace manager signals to track clients
self._manager.connect("client-added", self._on_glace_client_added)
self._manager.connect("client-removed", self._on_glace_client_removed)
self.show_all()
self.hide()
def show_switcher(self) -> None:
self.update_windows()
if not self.windows:
return
self.show()
self.grab_keyboard()
self.tab_pressed = False
def hide_switcher(self) -> None:
self.hide()
self.ungrab_keyboard()
def _on_glace_client_added(self, _, client):
"""Handle when a Glace client is added"""
try:
# Map the client by its window address for later lookup
# We'll need to match this with Hyprland window data
client_id = client.get_id()
self.glace_clients[client_id] = client
except Exception as e:
print(f"Error adding Glace client: {e}")
def _on_glace_client_removed(self, _, client):
"""Handle when a Glace client is removed"""
try:
client_id = client.get_id()
if client_id in self.glace_clients:
del self.glace_clients[client_id]
except Exception as e:
print(f"Error removing Glace client: {e}")
def _find_glace_client_for_window(self, window):
"""Find the corresponding Glace client for a Hyprland window"""
try:
window_class = window.get("class", "").lower()
window_title = window.get("title", "")
# Try to match by app_id/class and title
for _, client in self.glace_clients.items():
try:
client_app_id = client.get_app_id()
client_title = client.get_title()
if (
client_app_id
and client_app_id.lower() == window_class
and client_title
and client_title == window_title
):
return client
except Exception:
continue
# Fallback: try to match by class only
for _, client in self.glace_clients.items():
try:
client_app_id = client.get_app_id()
if client_app_id and client_app_id.lower() == window_class:
return client
except Exception:
continue
except Exception as e:
print(f"Error finding Glace client: {e}")
return None
def create_preview_for_window(self, window):
"""Create a preview image for a specific window"""
glace_client = self._find_glace_client_for_window(window)
# Create a placeholder image first
preview_image = Image()
if glace_client:
def capture_callback(pbuf, _):
try:
scaled_pixbuf = pbuf.scale_simple(
self.preview_size[0],
self.preview_size[1],
2, # GdkPixbuf.InterpType.BILINEAR
)
preview_image.set_from_pixbuf(scaled_pixbuf)
except Exception as e:
print(f"Error setting preview image: {e}")
try:
self._manager.capture_client(
client=glace_client,
overlay_cursor=False,
callback=capture_callback,
user_data=None,
)
except Exception as e:
print(f"Error capturing client preview: {e}")
# Fallback to icon if preview fails
self._set_fallback_icon(preview_image, window)
else:
# Use icon as fallback if no Glace client found
self._set_fallback_icon(preview_image, window)
return preview_image
def _set_fallback_icon(self, image_widget, window):
"""Set a fallback icon when preview is not available"""
class_name = window.get("class", "").lower()
icon_img = self.icon_resolver.get_icon_pixbuf(class_name, self.icon_size)
if not icon_img:
icon_img = self.icon_resolver.get_icon_pixbuf(
"application-x-executable-symbolic", self.icon_size
)
image_widget.set_from_pixbuf(icon_img)
def _is_special_workspace(self, client):
return is_special_workspace(client)
def update_windows(self) -> None:
for child in self.view.get_children():
self.view.remove(child)
try:
clients_data = self.conn.send_command("j/clients").reply
if not clients_data:
return
clients = json.loads(clients_data.decode("utf-8"))
# Filter out hidden windows and optionally special workspace windows
filtered_windows = []
for c in clients:
if c.get("hidden", False):
continue
# Skip clients in special workspaces if the setting is enabled
if (
data.DOCK_HIDE_SPECIAL_WORKSPACE_APPS
and self._is_special_workspace(c)
):
continue
filtered_windows.append(c)
self.windows = filtered_windows
active_data = self.conn.send_command("j/activewindow").reply
active_window = (
json.loads(active_data.decode("utf-8")) if active_data else None
)
self.current_index = 0
if active_window:
for i, window in enumerate(self.windows):
if window.get("address") == active_window.get("address"):
self.current_index = i
break
current_row = Box(
name="window-row",
orientation="h",
spacing=12,
h_align="center",
v_align="center",
)
self.view.add(current_row)
for i, window in enumerate(self.windows):
title = window.get("title", "")
# Create preview image for this window
preview_image = self.create_preview_for_window(window)
button_content = Box(
name="switcher-button",
orientation="v",
spacing=4,
h_align="center",
v_align="center",
children=[
Box(
name="switcher-preview-box",
style_classes=["window-basic", "sleek-border"],
children=[preview_image],
h_align="center",
v_align="center",
),
Label(
label=title[:15] + "..." if len(title) > 15 else title,
h_align="center",
v_align="center",
max_width_chars=15,
ellipsize="end",
),
],
)
event_box = EventBox(
name="window-button",
style_classes=["active"] if i == self.current_index else None,
child=button_content,
)
current_row.add(event_box)
if (i + 1) % self.items_per_row == 0 and i + 1 < len(self.windows):
current_row = Box(
name="window-row",
orientation="h",
spacing=12,
h_align="center",
v_align="center",
)
self.view.add(current_row)
self.view.show_all()
self.update_selection()
except Exception as e:
print(f"Failed to update windows: {e}")
def on_key_press(self, _, event):
keyval = event.keyval
state = event.state
alt_pressed = bool(state & Gdk.ModifierType.MOD1_MASK)
if not self.windows:
return False
if keyval == Gdk.KEY_Escape:
self.hide_switcher()
return True
if keyval == Gdk.KEY_Tab:
if not self.tab_pressed or alt_pressed:
self.current_index = (self.current_index + 1) % len(self.windows)
self.update_selection()
self.tab_pressed = True
return True
if keyval == Gdk.KEY_ISO_Left_Tab or (
keyval == Gdk.KEY_Tab and (state & Gdk.ModifierType.SHIFT_MASK)
):
self.current_index = (self.current_index - 1) % len(self.windows)
self.update_selection()
return True
if keyval == Gdk.KEY_Return:
self.activate_selected()
self.hide_switcher()
return True
if keyval == Gdk.KEY_Right or keyval == Gdk.KEY_l:
self.current_index = (self.current_index + 1) % len(self.windows)
self.update_selection()
return True
if keyval == Gdk.KEY_Left or keyval == Gdk.KEY_h:
self.current_index = (self.current_index - 1) % len(self.windows)
self.update_selection()
return True
if keyval == Gdk.KEY_Down:
next_index = self.current_index + self.items_per_row
if next_index < len(self.windows):
self.current_index = next_index
self.update_selection()
return True
if keyval == Gdk.KEY_Up:
next_index = self.current_index - self.items_per_row
if next_index >= 0:
self.current_index = next_index
self.update_selection()
return True
return False
def on_key_release(self, _, event):
keyval = event.keyval
if keyval in (Gdk.KEY_Alt_L, Gdk.KEY_Alt_R):
self.activate_selected()
self.hide_switcher()
return True
if keyval == Gdk.KEY_Tab:
self.tab_pressed = False
return True
return False
def update_selection(self):
for row in self.view.get_children():
for i, child in enumerate(row.get_children()):
index = self.view.get_children().index(row) * self.items_per_row + i
if index == self.current_index:
child.add_style_class("active")
else:
child.remove_style_class("active")
def activate_selected(self):
if not self.windows or self.current_index >= len(self.windows):
return
window = self.windows[self.current_index]
address = window.get("address")
if address:
try:
command = f"/dispatch focuswindow address:{address}"
self.conn.send_command(command)
except Exception as e:
print(f"Failed to focus window: {e}")
def grab_keyboard(self):
try:
display = Gdk.Display.get_default()
seat = display.get_default_seat()
window = self.get_window()
seat.grab(window, Gdk.SeatCapabilities.KEYBOARD, False, None, None, None)
except Exception as e:
print(f"Failed to grab keyboard: {e}")
def ungrab_keyboard(self):
try:
display = Gdk.Display.get_default()
seat = display.get_default_seat()
seat.ungrab()
except Exception as e:
print(f"Failed to ungrab keyboard: {e}")
================================================
FILE: modules/todo/__init__.py
================================================
# Todo module
================================================
FILE: modules/todo/todo_widget.py
================================================
# Standard library imports
from datetime import datetime
# Fabric imports
from fabric.utils import get_relative_path
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.centerbox import CenterBox
from fabric.widgets.entry import Entry
from fabric.widgets.label import Label
from fabric.widgets.scrolledwindow import ScrolledWindow
from fabric.widgets.svg import Svg
from gi.repository import GLib
# Local imports
from services.todo import todo_service
from widgets.mousecapture import MouseCapture
from widgets.wayland import WaylandWindow as Window
class TodoItem(Box):
"""Individual todo item widget"""
def __init__(self, todo_data, todo_list_widget, **kwargs):
self.todo_data = todo_data
self.todo_list_widget = todo_list_widget
self.editing = False
super().__init__(
name="todo-item",
orientation="h",
spacing=8,
style_classes=["menu"],
**kwargs,
)
self._build_ui()
def _build_ui(self):
"""Build the todo item UI"""
# Checkbox for completion - using SVG icons
checkbox_icon = (
"checkbox-check.svg"
if self.todo_data["completed"]
else "checkbox-uncheck.svg"
)
self.checkbox_icon = Svg(
name="todo-checkbox-icon",
size=24,
svg_file=get_relative_path(
"../../config/assets/icons/todo/" + checkbox_icon
),
)
self.checkbox = Button(
name="todo-checkbox",
child=self.checkbox_icon,
on_clicked=self._toggle_completion,
)
# Todo text (can be converted to entry for editing)
text_content = self.todo_data["text"]
if self.todo_data["completed"]:
text_content = f"{text_content} "
self.text_label = Label(
markup=text_content,
name="todo-text",
h_align="start",
h_expand=True,
line_wrap="word-char",
style_classes=(
["title-widget"]
if not self.todo_data["completed"]
else ["status-label"]
),
)
# Date/time label
created_at = datetime.fromisoformat(self.todo_data["created_at"])
date_text = created_at.strftime("%b %d, %Y at %I:%M %p")
self.date_label = Label(
label=date_text,
name="todo-date",
h_align="start",
style_classes=["todo-date-text"],
)
self.text_entry = Entry(
name="todo-text-entry",
text=self.todo_data["text"],
h_expand=True,
visible=False,
)
self.text_entry.connect("activate", self._save_edit)
# Priority indicator
# priority_symbols = {"high": "🔴", "medium": "🟡", "low": "🟢"}
#
# self.priority_label = Label(
# label=priority_symbols.get(self.todo_data["priority"], "🟡"),
# name="todo-priority-label",
# )
# self.priority_button = Button(
# name="todo-priority",
# size=(20, 20),
# child=self.priority_label,
# on_clicked=self._cycle_priority,
# )
#
# Edit button - using SVG icon
self.edit_icon = Svg(
name="todo-edit-icon",
size=12,
svg_file=get_relative_path("../../config/assets/icons/todo/edit.svg"),
)
self.edit_button = Button(
name="todo-edit",
child=self.edit_icon,
on_clicked=self._start_edit,
)
# Delete button - using SVG icon
self.delete_icon = Svg(
name="todo-delete-icon",
size=12,
svg_file=get_relative_path(
"../../config/assets/icons/todo/delete-symbolic.svg"
),
)
self.delete_button = Button(
name="todo-delete",
child=self.delete_icon,
on_clicked=self._delete_todo,
)
# Text container that switches between label and entry
self.text_container = Box(
orientation="v",
h_expand=True,
children=[
Box(orientation="h", children=[self.text_label]),
self.date_label,
],
)
self.children = [
self.checkbox,
self.text_container,
# self.priority_button,
self.edit_button,
self.delete_button,
]
def _toggle_completion(self, *_):
"""Toggle todo completion status"""
todo_service.toggle_todo(self.todo_data["id"])
def _cycle_priority(self, *_):
"""Cycle through priority levels"""
priorities = ["low", "medium", "high"]
current_index = priorities.index(self.todo_data["priority"])
new_priority = priorities[(current_index + 1) % len(priorities)]
todo_service.set_priority(self.todo_data["id"], new_priority)
def _start_edit(self, *_):
"""Start editing the todo text"""
if self.editing:
return
self.editing = True
self.text_container.children = [
Box(orientation="h", children=[self.text_entry]),
self.date_label,
]
self.text_entry.set_visible(True)
self.text_entry.grab_focus()
self.text_entry.set_position(-1) # Move cursor to end
def _save_edit(self, *_):
"""Save the edited todo text"""
if not self.editing:
return
new_text = self.text_entry.get_text().strip()
if new_text:
todo_service.edit_todo(self.todo_data["id"], new_text)
self._cancel_edit()
def _cancel_edit(self):
"""Cancel editing and revert to label"""
self.editing = False
self.text_container.children = [
Box(orientation="h", children=[self.text_label]),
self.date_label,
]
self.text_entry.set_visible(False)
def _delete_todo(self, *_):
"""Delete this todo"""
todo_service.delete_todo(self.todo_data["id"])
def update_from_data(self, todo_data):
"""Update the widget from new todo data"""
self.todo_data = todo_data
# Update checkbox icon by recreating it
checkbox_icon = (
"checkbox-check.svg" if todo_data["completed"] else "checkbox-uncheck.svg"
)
new_checkbox_icon = Svg(
name="todo-checkbox-icon",
size=20,
svg_file=get_relative_path(
"../../config/assets/icons/todo/" + checkbox_icon
),
)
self.checkbox.set_child(new_checkbox_icon)
self.checkbox_icon = new_checkbox_icon
# Update text and styling with markup
text_content = todo_data["text"]
if todo_data["completed"]:
text_content = f"{text_content} "
self.text_label.set_markup(text_content)
self.text_label.style_classes = (
["title-widget"] if not todo_data["completed"] else ["status-label"]
)
# Update date/time
created_at = datetime.fromisoformat(todo_data["created_at"])
date_text = created_at.strftime("%b %d, %Y at %I:%M %p")
self.date_label.set_label(date_text)
class TodoListWidget(Window):
"""Main todo list widget window"""
def __init__(self, **kwargs):
super().__init__(
title="modus-todo",
anchor="top right",
margin="2px 10px 0px 0px",
exclusivity="auto",
keyboard_mode="on-demand",
name="todo-list-window",
visible=False, # Back to hidden by default
**kwargs,
)
self.todo_items = {} # Maps todo IDs to TodoItem widgets
# Register callback with todo service
todo_service.add_callback(self._on_todo_event)
self._build_ui()
self._refresh_todos()
# Add keybinding for escape
self.add_keybinding("Escape", self.hide_todo_list)
def _build_ui(self):
"""Build the main UI"""
# Header with title and stats
self.stats_label = Label(
label="",
name="todo-stats",
style_classes=["status-label"],
h_align="start",
)
self.header = Box(
name="todo-header",
orientation="v",
children=[
Label(
label="Todo List",
name="todo-title",
style_classes=["title"],
h_align="start",
),
self.stats_label,
],
)
# Add new todo section
self.new_todo_entry = Entry(
name="new-todo-entry",
placeholder_text="Add a new task...",
h_expand=True,
)
self.new_todo_entry.connect("activate", self._add_todo)
# Add button - using SVG icon
self.add_icon = Svg(
name="add-todo-icon",
size=12,
svg_file=get_relative_path(
"../../config/assets/icons/todo/plus-symbolic.svg"
),
)
self.add_button = Button(
name="add-todo-button",
child=self.add_icon,
on_clicked=self._add_todo,
)
self.add_section = Box(
name="todo-add-section",
orientation="h",
spacing=8,
style_classes=["menu"],
children=[
self.new_todo_entry,
self.add_button,
],
)
# Todo items container
self.todos_container = Box(
name="todos-container",
orientation="v",
spacing=4,
)
# Scrolled window for todos
self.scrolled = ScrolledWindow(
name="todos-scrolled",
min_content_height=300,
max_content_height=500,
min_content_width=400,
child=self.todos_container,
policy="automatic",
v_expand=True, # Allow vertical expansion
)
# Clear completed button
self.clear_button = Button(
name="clear-completed-button",
label="Clear Completed",
style_classes=["status-label"],
on_clicked=self._clear_completed,
)
# Main container
self.main_container = Box(
name="todo-main-container",
orientation="v",
spacing=8,
style_classes=["menu"],
children=[
self.header,
self.add_section,
self.scrolled,
self.clear_button,
],
)
self.children = [self.main_container]
def _add_todo(self, *_):
"""Add a new todo"""
text = self.new_todo_entry.get_text().strip()
if text:
todo_service.add_todo(text)
self.new_todo_entry.set_text("")
def _clear_completed(self, *_):
"""Clear all completed todos"""
todo_service.clear_completed()
def _on_todo_event(self, event_type, data=None):
"""Handle todo service events via callback"""
if event_type == "todos-changed":
GLib.idle_add(self._refresh_todos)
elif event_type == "todo-added":
GLib.idle_add(self._refresh_todos)
elif event_type == "todo-deleted":
GLib.idle_add(self._refresh_todos)
elif event_type in ["todo-toggled", "todo-edited", "todo-priority-changed"]:
if data and data["id"] in self.todo_items:
GLib.idle_add(
lambda: self.todo_items[data["id"]].update_from_data(data)
)
GLib.idle_add(self._update_stats)
def _refresh_todos(self, *_):
"""Refresh the entire todo list"""
# Clear existing items
self.todo_items.clear()
self.todos_container.children = []
# Get all todos
todos = todo_service.todos
# Sort todos: incomplete first, then by priority, then by creation date
def sort_key(todo):
priority_order = {"high": 0, "medium": 1, "low": 2}
return (
todo["completed"], # False (incomplete) comes before True (completed)
priority_order.get(todo["priority"], 1),
todo["created_at"],
)
sorted_todos = sorted(todos, key=sort_key)
# Create todo item widgets
for todo in sorted_todos:
todo_item = TodoItem(todo, self)
self.todo_items[todo["id"]] = todo_item
# Update container children
self.todos_container.children = list(self.todo_items.values())
# Update stats
self._update_stats()
def _update_stats(self):
"""Update the statistics display"""
stats = todo_service.get_stats()
stats_text = f"{stats['pending']} pending, {stats['completed']} completed"
self.stats_label.set_label(stats_text)
def set_visible(self, visible):
"""Override set_visible for debugging"""
super().set_visible(visible)
def hide_todo_list(self, *_):
"""Hide the todo list"""
if hasattr(self, "_mousecapture_parent"):
self._mousecapture_parent.toggle_mousecapture()
self.set_visible(False)
def _init_mousecapture(self, mousecapture):
"""Initialize mousecapture parent reference"""
self._mousecapture_parent = mousecapture
def destroy(self):
"""Clean up when destroyed"""
# Remove callback from todo service
todo_service.remove_callback(self._on_todo_event)
super().destroy()
class TodoListCapture(MouseCapture):
"""MouseCapture wrapper for the todo list"""
def __init__(self, **kwargs):
super().__init__(
layer="top",
child_window=TodoListWidget(),
**kwargs,
)
================================================
FILE: modules/widget.py
================================================
# 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)
================================================
FILE: requirements.txt
================================================
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
================================================
FILE: scripts/gamemode.sh
================================================
#!/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
================================================
FILE: scripts/hyprpicker.sh
================================================
#!/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
================================================
FILE: scripts/screen-capture.sh
================================================
#!/bin/env bash
# Script name
SCRIPT_NAME=$(basename "$0")
# Function to display usage
usage() {
cat < [options]
Commands:
screenshot Take a screenshot
Targets:
selection - Screenshot selected area
eDP-1 - Screenshot eDP-1 display
HDMI-A-1 - Screenshot HDMI-A-1 display
both - Screenshot both displays
record Start/stop recording (with audio)
Targets:
selection - Record selected area
eDP-1 - Record eDP-1 display
HDMI-A-1 - Record HDMI-A-1 display
stop - Stop current recording
record-noaudio Start/stop recording (no audio)
Targets:
selection - Record selected area without audio
eDP-1 - Record eDP-1 display without audio
HDMI-A-1 - Record HDMI-A-1 display without audio
stop - Stop current recording
record-hq Start/stop high-quality recording (for YouTube)
Targets:
selection - Record selected area in high quality
eDP-1 - Record eDP-1 display in high quality
HDMI-A-1 - Record HDMI-A-1 display in high quality
stop - Stop current recording
record-gif Start/stop GIF recording
Targets:
selection - Record selected area as GIF
eDP-1 - Record eDP-1 display as GIF
HDMI-A-1 - Record HDMI-A-1 display as GIF
stop - Stop current recording
status Check if recording is active (exit 0 if recording, 1 if not)
convert [file] Convert recordings
Formats:
webm - Convert latest MKV to WebM (or specify file)
iphone - Convert latest MKV to iPhone format (or specify file)
youtube - Convert latest video to YouTube format (or specify file)
gif - Convert latest video to GIF (or specify file)
Optional [file] parameter:
- If not provided, converts the latest recorded video
- If provided, converts the specified file (full path or filename in Recordings folder)
Examples:
$SCRIPT_NAME screenshot selection
$SCRIPT_NAME record eDP-1
$SCRIPT_NAME record-noaudio selection
$SCRIPT_NAME record-hq eDP-1
$SCRIPT_NAME record-gif selection
$SCRIPT_NAME record stop
$SCRIPT_NAME convert gif # Convert latest video to GIF
$SCRIPT_NAME convert youtube # Convert latest video for YouTube
$SCRIPT_NAME convert webm /path/to/video.mkv # Convert specific file to WebM
EOF
exit 1
}
# Check if no arguments provided
if [ $# -eq 0 ]; then
usage
fi
# Function to send screenshot notification with action buttons
send_screenshot_notification() {
local full_path="$1"
local save_dir=$(dirname "$full_path")
ACTION=$(notify-send -a "Modus" -i "$full_path" "Screenshot saved" "in $full_path" \
-A "view=View" -A "edit=Edit" -A "open=Open Folder")
case "$ACTION" in
view) xdg-open "$full_path" ;;
edit) swappy -f "$full_path" ;;
open) xdg-open "$save_dir" ;;
esac
}
# Function to send recording notification with action buttons
send_recording_notification() {
local full_path="$1"
local save_dir=$(dirname "$full_path")
ACTION=$(notify-send -a "Modus" -i "camera-video-symbolic" "Recording saved" "in $full_path" \
-A "view=View" -A "open=Open Folder")
case "$ACTION" in
view) xdg-open "$full_path" ;;
open) xdg-open "$save_dir" ;;
esac
}
wf-recorder_check() {
if pgrep -x "wf-recorder" >/dev/null; then
pkill -INT -x wf-recorder
# Get the recording file path and send notification with actions
if [ -f /tmp/recording.txt ]; then
recording_file=$(cat /tmp/recording.txt)
wl-copy <"$recording_file"
send_recording_notification "$recording_file"
else
notify-send "Recording stopped" "wf-recorder process terminated"
fi
# Clean up recording start time file
rm -f /tmp/recording_start_time.txt
exit 0
fi
}
# Function to record with standard settings
record_video() {
local output_file="$1"
shift
wf-recorder "$@" -f "$output_file" -c libvpx-vp9 --pixel-format yuv420p -F "eq=brightness=0.12:contrast=1.1"
}
# Function to record without audio
record_video_noaudio() {
local output_file="$1"
shift
wf-recorder "$@" -f "$output_file" -c libvpx-vp9 --pixel-format yuv420p -F "eq=brightness=0.12:contrast=1.1" --no-audio
}
record_high_quality() {
local output_file="$1"
shift
# High quality settings for YouTube uploads
# - h264_vaapi for hardware encoding (if available) or libx264 for software
# - yuv420p pixel format for maximum compatibility
# - High bitrate (8000k) for quality
# - GOP size of 30 for better seeking
# - Preset 'slow' for better compression efficiency
# - CRF 18 for high quality (lower = better quality, 0-51 scale)
# - Audio at 192k bitrate
# - 60 FPS for smooth motion
# - No color filters to maintain original colors
# Check if VAAPI hardware encoding is available
if vainfo &>/dev/null && wf-recorder --help | grep -q "h264_vaapi"; then
# Use hardware encoding for better performance
wf-recorder "$@" -f "$output_file" \
-c h264_vaapi \
-p "preset=slow" \
-p "crf=18" \
-r 60 \
-b 8000000 \
-B 192000 \
--pixel-format yuv420p \
-g 30
else
# Fallback to software encoding
wf-recorder "$@" -f "$output_file" \
-c libx264 \
-p "preset=slow" \
-p "crf=18" \
-r 60 \
-b 8000000 \
-B 192000 \
--pixel-format yuv420p \
-g 30
fi
}
record_gif() {
local output_file="$1"
shift
# Record temporary video first (MKV format for better quality)
local temp_video="/tmp/gif_recording_$(date +%s).mkv"
echo "$temp_video" >/tmp/gif_temp_video.txt
# GIF-optimized recording settings:
# - Lower framerate (15 fps) for smaller file size
# - No audio recording
# - Standard codec for compatibility
wf-recorder "$@" -f "$temp_video" \
-c libvpx-vp9 \
-r 15 \
--pixel-format yuv420p \
--no-audio
# After recording stops, convert to GIF
if [ -f "$temp_video" ]; then
notify-send "Converting to GIF" "Processing recording..."
# Create high-quality GIF with optimized palette
# Using ffmpeg with palette generation for better colors
ffmpeg -i "$temp_video" \
-vf "fps=15,scale=iw:-1:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=128:stats_mode=diff[p];[s1][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle" \
-loop 0 \
"$output_file" 2>/tmp/gif_conversion.log
if [ $? -eq 0 ]; then
# Clean up temp file
rm -f "$temp_video"
rm -f /tmp/gif_temp_video.txt
# Copy to clipboard and send notification with actions
wl-copy <"$output_file"
send_recording_notification "$output_file"
else
error=$(cat /tmp/gif_conversion.log | tail -n 5)
notify-send "GIF Conversion Failed" "Error: $error"
rm -f "$temp_video"
rm -f /tmp/gif_temp_video.txt
fi
fi
}
# Function to find the latest video file for conversion
find_latest_video() {
local format="$1"
local recording_dir="${HOME}/Videos/Recordings"
case "$format" in
"webm"|"iphone")
# For webm and iphone, only look for MKV files
find "$recording_dir" -name "*.mkv" -type f -printf '%T@ %p\n' 2>/dev/null | sort -n | tail -1 | cut -d' ' -f2-
;;
"youtube"|"gif")
# For youtube and gif, look for both MKV and MP4 files
find "$recording_dir" \( -name "*.mkv" -o -name "*.mp4" \) -type f -printf '%T@ %p\n' 2>/dev/null | sort -n | tail -1 | cut -d' ' -f2-
;;
*)
echo ""
;;
esac
}
# Function to resolve video file path
resolve_video_file() {
local format="$1"
local file_param="$2"
local recording_dir="${HOME}/Videos/Recordings"
if [ -n "$file_param" ]; then
# File parameter provided
if [ -f "$file_param" ]; then
# Full path provided and exists
echo "$file_param"
elif [ -f "$recording_dir/$file_param" ]; then
# Filename provided, exists in recordings folder
echo "$recording_dir/$file_param"
else
echo ""
fi
else
# No file parameter, find latest
find_latest_video "$format"
fi
}
# Parse command
COMMAND="$1"
TARGET="$2"
FILE_PARAM="$3" # Optional file parameter for convert command
# Set up file paths
IMG="${HOME}/Pictures/Screenshots/$(date +%Y-%m-%d_%H-%m-%s).png"
VID="${HOME}/Videos/Recordings/$(date +%Y-%m-%d_%H-%m-%s).mkv"
case "$COMMAND" in
"screenshot")
case "$TARGET" in
"selection")
grim -g "$(slurp)" "$IMG"
wl-copy <"$IMG"
send_screenshot_notification "$IMG"
;;
"eDP-1")
grim -c -o eDP-1 "$IMG"
wl-copy <"$IMG"
send_screenshot_notification "$IMG"
;;
"HDMI-A-1")
grim -c -o HDMI-A-1 "$IMG"
wl-copy <"$IMG"
send_screenshot_notification "$IMG"
;;
"both")
grim -c -o eDP-1 "${IMG//.png/-eDP-1.png}"
grim -c -o HDMI-A-1 "${IMG//.png/-HDMI-A-1.png}"
montage "${IMG//.png/-eDP-1.png}" "${IMG//.png/-HDMI-A-1.png}" -tile 2x1 -geometry +0+0 "$IMG"
wl-copy <"$IMG"
rm "${IMG//.png/-eDP-1.png}" "${IMG//.png/-HDMI-A-1.png}"
send_screenshot_notification "$IMG"
;;
*)
echo "Error: Invalid screenshot target '$TARGET'"
usage
;;
esac
;;
"record")
case "$TARGET" in
"stop")
wf-recorder_check
;;
"selection")
wf-recorder_check
echo "$VID" >/tmp/recording.txt
date +%s >/tmp/recording_start_time.txt
record_video "$VID" -g "$(slurp)"
;;
"eDP-1")
wf-recorder_check
echo "$VID" >/tmp/recording.txt
date +%s >/tmp/recording_start_time.txt
record_video "$VID" -a -o eDP-1
;;
"HDMI-A-1")
wf-recorder_check
echo "$VID" >/tmp/recording.txt
date +%s >/tmp/recording_start_time.txt
record_video "$VID" -a -o HDMI-A-1
;;
*)
echo "Error: Invalid record target '$TARGET'"
usage
;;
esac
;;
"record-noaudio")
case "$TARGET" in
"stop")
wf-recorder_check
;;
"selection")
wf-recorder_check
echo "$VID" >/tmp/recording.txt
date +%s >/tmp/recording_start_time.txt
record_video_noaudio "$VID" -g "$(slurp)"
;;
"eDP-1")
wf-recorder_check
echo "$VID" >/tmp/recording.txt
date +%s >/tmp/recording_start_time.txt
record_video_noaudio "$VID" -o eDP-1
;;
"HDMI-A-1")
wf-recorder_check
echo "$VID" >/tmp/recording.txt
date +%s >/tmp/recording_start_time.txt
record_video_noaudio "$VID" -o HDMI-A-1
;;
*)
echo "Error: Invalid record-noaudio target '$TARGET'"
usage
;;
esac
;;
"record-hq")
# Change file extension to mp4 for high quality recordings
VID_HQ="${HOME}/Videos/Recordings/$(date +%Y-%m-%d_%H-%m-%s)-hq.mp4"
case "$TARGET" in
"stop")
wf-recorder_check
;;
"selection")
wf-recorder_check
echo "$VID_HQ" >/tmp/recording.txt
date +%s >/tmp/recording_start_time.txt
notify-send "High Quality Recording" "Starting YouTube-quality recording..."
record_high_quality "$VID_HQ" -g "$(slurp)"
;;
"eDP-1")
wf-recorder_check
echo "$VID_HQ" >/tmp/recording.txt
date +%s >/tmp/recording_start_time.txt
notify-send "High Quality Recording" "Starting YouTube-quality recording on eDP-1..."
record_high_quality "$VID_HQ" -a -o eDP-1
;;
"HDMI-A-1")
wf-recorder_check
echo "$VID_HQ" >/tmp/recording.txt
date +%s >/tmp/recording_start_time.txt
notify-send "High Quality Recording" "Starting YouTube-quality recording on HDMI-A-1..."
record_high_quality "$VID_HQ" -a -o HDMI-A-1
;;
*)
echo "Error: Invalid record-hq target '$TARGET'"
usage
;;
esac
;;
"record-gif")
# GIF files go to a specific location
GIF="${HOME}/Videos/Recordings/$(date +%Y-%m-%d_%H-%m-%s).gif"
case "$TARGET" in
"stop")
wf-recorder_check
;;
"selection")
wf-recorder_check
echo "$GIF" >/tmp/recording.txt
date +%s >/tmp/recording_start_time.txt
notify-send "GIF Recording" "Starting GIF recording (15 FPS)..."
record_gif "$GIF" -g "$(slurp)"
;;
"eDP-1")
wf-recorder_check
echo "$GIF" >/tmp/recording.txt
date +%s >/tmp/recording_start_time.txt
notify-send "GIF Recording" "Starting GIF recording on eDP-1..."
record_gif "$GIF" -o eDP-1
;;
"HDMI-A-1")
wf-recorder_check
echo "$GIF" >/tmp/recording.txt
date +%s >/tmp/recording_start_time.txt
notify-send "GIF Recording" "Starting GIF recording on HDMI-A-1..."
record_gif "$GIF" -o HDMI-A-1
;;
*)
echo "Error: Invalid record-gif target '$TARGET'"
usage
;;
esac
;;
"status")
# Check if wf-recorder is running
if pgrep -x "wf-recorder" >/dev/null; then
echo "true"
exit 0
else
echo "false"
exit 0
fi
;;
"convert")
case "$TARGET" in
"webm")
# Check if ffmpeg is installed
if ! command -v ffmpeg >/dev/null 2>&1; then
notify-send "Error" "ffmpeg is not installed. Please install it to use this feature."
exit 1
fi
# Resolve the video file to convert
video_file=$(resolve_video_file "webm" "$FILE_PARAM")
if [ -z "$video_file" ] || [ ! -f "$video_file" ]; then
if [ -n "$FILE_PARAM" ]; then
notify-send "WebM Conversion Error" "File not found: $FILE_PARAM"
else
notify-send "WebM Conversion Error" "No MKV files found in Recordings folder"
fi
exit 1
fi
# Ensure it's an MKV file
if [[ "$video_file" != *.mkv ]]; then
notify-send "WebM Conversion Error" "Only MKV files can be converted to WebM. Found: $(basename "$video_file")"
exit 1
fi
webm_file="${video_file%.mkv}.webm"
# Check if webm version doesn't already exist
if [ -f "$webm_file" ]; then
notify-send "WebM Conversion Skipped" "WebM version already exists: $(basename "$webm_file")"
exit 0
fi
notify-send "Converting to WebM" "Processing: $(basename "$video_file")"
# Convert the file
ffmpeg -y -i "$video_file" -c:v libvpx -b:v 1M -c:a libvorbis "$webm_file" 2>/tmp/ffmpeg_error.log
if [ $? -eq 0 ]; then
file_size=$(du -h "$webm_file" | cut -f1)
notify-send "WebM Conversion Success" "$(basename "$video_file") → $(basename "$webm_file") ($file_size)"
else
error=$(cat /tmp/ffmpeg_error.log | tail -n 5)
notify-send "WebM Conversion Failed" "Error: $error"
fi
;;
"iphone")
# Check if ffmpeg is installed
if ! command -v ffmpeg >/dev/null 2>&1; then
notify-send "Error" "ffmpeg is not installed. Please install it to use this feature."
exit 1
fi
# Resolve the video file to convert
video_file=$(resolve_video_file "iphone" "$FILE_PARAM")
if [ -z "$video_file" ] || [ ! -f "$video_file" ]; then
if [ -n "$FILE_PARAM" ]; then
notify-send "iPhone Conversion Error" "File not found: $FILE_PARAM"
else
notify-send "iPhone Conversion Error" "No MKV files found in Recordings folder"
fi
exit 1
fi
# Ensure it's an MKV file
if [[ "$video_file" != *.mkv ]]; then
notify-send "iPhone Conversion Error" "Only MKV files can be converted for iPhone. Found: $(basename "$video_file")"
exit 1
fi
base_filename=$(basename "$video_file")
# Skip files with "iphone" in the filename
if [[ $base_filename == *"iphone"* ]]; then
notify-send "iPhone Conversion Skipped" "File already appears to be iPhone format: $(basename "$video_file")"
exit 0
fi
iphone_file="${video_file%.mkv}-iphone.mp4"
# Check if iPhone version doesn't already exist
if [ -f "$iphone_file" ]; then
notify-send "iPhone Conversion Skipped" "iPhone version already exists: $(basename "$iphone_file")"
exit 0
fi
notify-send "Converting for iPhone" "Processing: $(basename "$video_file")"
# Convert the file
ffmpeg -y -i "$video_file" -vcodec h264 -acodec aac "$iphone_file" 2>/tmp/ffmpeg_error.log
if [ $? -eq 0 ]; then
file_size=$(du -h "$iphone_file" | cut -f1)
notify-send "iPhone Conversion Success" "$(basename "$video_file") → $(basename "$iphone_file") ($file_size)"
else
error=$(cat /tmp/ffmpeg_error.log | tail -n 5)
notify-send "iPhone Conversion Failed" "Error: $error"
fi
;;
"youtube")
# Check if ffmpeg is installed
if ! command -v ffmpeg >/dev/null 2>&1; then
notify-send "Error" "ffmpeg is not installed. Please install it to use this feature."
exit 1
fi
# Resolve the video file to convert
video_file=$(resolve_video_file "youtube" "$FILE_PARAM")
if [ -z "$video_file" ] || [ ! -f "$video_file" ]; then
if [ -n "$FILE_PARAM" ]; then
notify-send "YouTube Conversion Error" "File not found: $FILE_PARAM"
else
notify-send "YouTube Conversion Error" "No video files found in Recordings folder"
fi
exit 1
fi
base_filename=$(basename "$video_file")
# Skip files already marked as YouTube uploads
if [[ $base_filename == *"youtube"* ]]; then
notify-send "YouTube Conversion Skipped" "File already appears to be YouTube format: $(basename "$video_file")"
exit 0
fi
# Create YouTube optimized filename
youtube_file="${video_file%.*}-youtube.mp4"
# Check if YouTube version doesn't already exist
if [ -f "$youtube_file" ]; then
notify-send "YouTube Conversion Skipped" "YouTube version already exists: $(basename "$youtube_file")"
exit 0
fi
notify-send "Converting for YouTube" "Processing: $(basename "$video_file")"
# YouTube recommended settings:
# - H.264 codec with High profile
# - 1080p or source resolution
# - 60fps or source framerate
# - High bitrate for quality (8-12 Mbps for 1080p60)
# - AAC audio at 384kbps
# - yuv420p pixel format for compatibility
# - Keyframe interval of 2 seconds (GOP)
# - No filters to preserve original colors
ffmpeg -y -i "$video_file" \
-c:v libx264 \
-profile:v high \
-preset slow \
-crf 18 \
-pix_fmt yuv420p \
-c:a aac \
-b:a 384k \
-movflags +faststart \
"$youtube_file" 2>/tmp/ffmpeg_error.log
if [ $? -eq 0 ]; then
file_size=$(du -h "$youtube_file" | cut -f1)
notify-send "YouTube Conversion Success" "$(basename "$video_file") → $(basename "$youtube_file") ($file_size)"
else
error=$(cat /tmp/ffmpeg_error.log | tail -n 5)
notify-send "YouTube Conversion Failed" "Error converting $(basename "$video_file"): $error"
fi
;;
"gif")
# Check if ffmpeg is installed
if ! command -v ffmpeg >/dev/null 2>&1; then
notify-send "Error" "ffmpeg is not installed. Please install it to use this feature."
exit 1
fi
# Resolve the video file to convert
video_file=$(resolve_video_file "gif" "$FILE_PARAM")
if [ -z "$video_file" ] || [ ! -f "$video_file" ]; then
if [ -n "$FILE_PARAM" ]; then
notify-send "GIF Conversion Error" "File not found: $FILE_PARAM"
else
notify-send "GIF Conversion Error" "No video files found in Recordings folder"
fi
exit 1
fi
base_filename=$(basename "$video_file")
# Skip files already GIFs
if [[ $base_filename == *.gif ]]; then
notify-send "GIF Conversion Skipped" "File is already a GIF: $(basename "$video_file")"
exit 0
fi
# Create GIF filename
gif_file="${video_file%.*}.gif"
# Check if GIF version doesn't already exist
if [ -f "$gif_file" ]; then
notify-send "GIF Conversion Skipped" "GIF version already exists: $(basename "$gif_file")"
exit 0
fi
notify-send "Converting to GIF" "Processing: $(basename "$video_file")"
# Get video dimensions for scaling
width=$(ffprobe -v error -select_streams v:0 -show_entries stream=width -of csv=s=x:p=0 "$video_file")
# Scale down if wider than 800px to keep file size reasonable
if [ "$width" -gt 800 ]; then
scale_filter="scale=800:-1:flags=lanczos,"
else
scale_filter=""
fi
# Create high-quality GIF with optimized palette
ffmpeg -i "$video_file" \
-vf "${scale_filter}fps=15,split[s0][s1];[s0]palettegen=max_colors=256:stats_mode=diff[p];[s1][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle" \
-loop 0 \
"$gif_file" 2>/tmp/ffmpeg_error.log
if [ $? -eq 0 ]; then
file_size=$(du -h "$gif_file" | cut -f1)
notify-send "GIF Conversion Success" "$(basename "$video_file") → $(basename "$gif_file") ($file_size)"
send_recording_notification "$gif_file"
else
error=$(cat /tmp/ffmpeg_error.log | tail -n 5)
notify-send "GIF Conversion Failed" "Error converting $(basename "$video_file"): $error"
fi
;;
*)
echo "Error: Invalid convert format '$TARGET'"
usage
;;
esac
;;
*)
echo "Error: Invalid command '$COMMAND'"
usage
;;
esac
================================================
FILE: services/__init__.py
================================================
"""
Modus services package.
Contains background services and utilities for the shell.
"""
================================================
FILE: services/auth.py
================================================
import json
import os
import subprocess
import time
from datetime import datetime
from pathlib import Path
from urllib.parse import parse_qs, urlparse
import pyotp
from PIL import Image
from pyzbar.pyzbar import decode
import config.data as data
def get_otp_file_path():
"""Returns the path to the OTP file inside the cache directory."""
cache_dir = Path(data.CACHE_DIR) / "otp"
# Create the directory if it doesn't exist
cache_dir.mkdir(parents=True, exist_ok=True)
file_path = cache_dir / "otp_codes.json"
# Create the file if it doesn't exist
if not file_path.exists():
with open(file_path, "w") as f:
json.dump([], f)
print(f"File {file_path} created successfully!")
return file_path
def capture_selected_area(filename="/tmp/screenshot.png"):
"""
Uses slurp to let the user select an area of the screen,
then captures that area using grim.
"""
try:
# slurp returns coordinates in format: x,y widthxheight (e.g. 100,200 300x400)
result = subprocess.run(["slurp"], check=True, capture_output=True, text=True)
geometry = result.stdout.strip()
if not geometry:
print("No area selected with slurp.")
return None
except subprocess.CalledProcessError as e:
print("Error selecting area with slurp:", e)
return None
try:
# grim uses -g to capture a specific region
subprocess.run(["grim", "-g", geometry, filename], check=True)
except subprocess.CalledProcessError as e:
print("Error capturing screenshot with grim:", e)
return None
return filename
def read_and_save_to_json():
# Get the full path to the JSON file
json_file = get_otp_file_path()
screenshot = capture_selected_area()
if screenshot is None:
print("Failed to capture the selected area.")
return False
# Short delay to ensure the file is written
time.sleep(1)
# Open the captured image
try:
img = Image.open(screenshot)
except Exception as e:
print("Error opening the image:", e)
return False
# Decode QR Code(s) from the image
decoded_objects = decode(img)
if not decoded_objects:
print("No QR Code detected in the selected area.")
return False
results = []
for obj in decoded_objects:
data = obj.data.decode("utf-8")
print("QR Code detected:", data)
result_entry = {"timestamp": datetime.now().isoformat(), "qr_data": data}
if data.startswith("otpauth://"):
parsed = urlparse(data)
query = parse_qs(parsed.query)
# Extract the secret properly
secret = query.get("secret", [None])[0]
issuer_from_query = query.get("issuer", [None])[0]
# Extract the label (path), which may contain issuer and account
label = parsed.path.lstrip("/") if parsed.path else ""
account_name = label
issuer_from_path = None
if ":" in label:
parts = label.split(":", 1)
issuer_from_path = parts[0]
account_name = parts[1]
# Prefer issuer from path if available, otherwise use from query
issuer = issuer_from_path or issuer_from_query
# Extract the period (default is 30 seconds)
period = int(query.get("period", ["30"])[0])
# Create TOTP object with the correct interval
totp = pyotp.TOTP(secret, interval=period)
current_otp = totp.now()
print(f"Generated OTP: {current_otp} (valid for {period} seconds)")
result_entry.update(
{
"type": "otp",
"secret": secret,
"issuer": issuer,
"account_name": account_name,
}
)
else:
result_entry["type"] = "unknown"
print("Unrecognized format. Expected a URI like otpauth://")
return False
results.append(result_entry)
# Load existing data if the file exists
existing_data = []
if os.path.exists(json_file):
try:
with open(json_file, "r") as f:
existing_data = json.load(f)
except json.JSONDecodeError:
print(f"Error reading the file {json_file}. Creating a new one.")
# Append new results
existing_data.extend(results)
# Save to JSON file
with open(json_file, "w") as f:
json.dump(existing_data, f, indent=4)
print(f"OTP data saved to {json_file}")
return True
def CodeOTP(uri):
parsed = urlparse(uri)
query = parse_qs(parsed.query)
secret = query.get("secret", [None])[0]
if secret is None:
return None
else:
totp = pyotp.TOTP(secret)
return totp.now()
# TOTP/OTP utility functions
def generate_totp(secret: str) -> str:
"""Generate TOTP code from secret."""
try:
return pyotp.TOTP(secret).now()
except Exception as e:
print(f"Error generating TOTP: {e}")
return None
def get_time_remaining() -> int:
"""Get seconds remaining until next token refresh."""
return 30 - (int(time.time()) % 30)
def get_time_remaining_with_blink() -> str:
"""Get time remaining with blinking effect."""
time_remaining = get_time_remaining()
current_second = int(time.time())
should_blink = current_second % 2 == 0
if should_blink:
return f"{time_remaining}s "
else:
return f"{time_remaining}s"
def validate_base32_secret(secret: str) -> dict:
"""Validate and clean Base32 secret."""
import base64
import re
try:
# Clean up the secret - remove spaces, dashes, and convert to uppercase
clean_secret = secret.replace(" ", "").replace("-", "").replace("_", "").upper()
# Remove any non-base32 characters
clean_secret = re.sub(r"[^A-Z2-7]", "", clean_secret)
# Add padding if needed (Base32 requires padding to multiple of 8)
while len(clean_secret) % 8 != 0:
clean_secret += "="
# Validate Base32 format
try:
base64.b32decode(clean_secret)
except Exception as e:
return {"success": False, "error": f"Invalid Base32 secret: {str(e)}"}
# Test if the secret can generate a valid TOTP
try:
test_totp = pyotp.TOTP(clean_secret)
test_code = test_totp.now()
if not test_code or len(test_code) != 6:
raise ValueError("Generated invalid TOTP code")
except Exception as e:
return {"success": False, "error": f"Cannot generate TOTP: {str(e)}"}
return {"success": True, "secret": clean_secret}
except Exception as e:
return {"success": False, "error": f"Unexpected error: {str(e)}"}
def parse_otpauth_uri(uri: str, account_name: str = "") -> dict:
"""Parse otpauth URI and extract account information."""
try:
parsed = urlparse(uri)
if parsed.scheme != "otpauth" or parsed.netloc != "totp":
return {
"success": False,
"error": "Only otpauth://totp/ URIs are supported",
}
if not account_name:
account_path = parsed.path.lstrip("/")
if ":" in account_path:
issuer, extracted_name = account_path.split(":", 1)
account_name = extracted_name
else:
account_name = account_path
params = parse_qs(parsed.query)
secret = params.get("secret", [""])[0]
issuer = params.get("issuer", [""])[0]
algorithm = params.get("algorithm", ["SHA1"])[0]
digits = int(params.get("digits", ["6"])[0])
period = int(params.get("period", ["30"])[0])
if not secret:
return {"success": False, "error": "No secret found in URI"}
return {
"success": True,
"account_name": account_name,
"secret": secret,
"issuer": issuer,
"algorithm": algorithm,
"digits": digits,
"period": period,
}
except Exception as e:
return {"success": False, "error": f"Error parsing otpauth URI: {str(e)}"}
def scan_qr_and_add_account(account_name: str, secrets_file_path: str) -> dict:
"""Scan QR code and add OTP account to secrets file."""
try:
# Capture QR code from screen
screenshot_path = capture_selected_area()
if not screenshot_path:
return {"success": False, "error": "QR scan cancelled or failed"}
# Decode QR code
try:
img = Image.open(screenshot_path)
decoded_objects = decode(img)
if not decoded_objects:
return {
"success": False,
"error": "No QR code detected in selected area",
}
# Process the first QR code found
qr_data = decoded_objects[0].data.decode("utf-8")
print(f"QR Code detected: {qr_data}")
if qr_data.startswith("otpauth://"):
# Parse otpauth URI
result = parse_otpauth_uri(qr_data, account_name)
if not result["success"]:
return result
# Load existing secrets
secrets = {}
if os.path.exists(secrets_file_path):
try:
with open(secrets_file_path, "r", encoding="utf-8") as f:
secrets = json.load(f)
except Exception as e:
print(f"Error loading secrets: {e}")
# Add new account
secrets[result["account_name"]] = {
"secret": result["secret"],
"issuer": result["issuer"],
"algorithm": result["algorithm"],
"digits": result["digits"],
"period": result["period"],
}
# Save secrets
try:
os.makedirs(os.path.dirname(secrets_file_path), exist_ok=True)
with open(secrets_file_path, "w", encoding="utf-8") as f:
json.dump(secrets, f, indent=2)
except Exception as e:
return {
"success": False,
"error": f"Error saving secrets: {str(e)}",
}
display_name = (
f"{result['issuer']} - {result['account_name']}"
if result["issuer"]
else result["account_name"]
)
return {
"success": True,
"account_name": result["account_name"],
"display_name": display_name,
"message": f"Successfully added OTP account: {display_name}",
}
else:
return {"success": False, "error": "QR code is not an otpauth URI"}
except Exception as e:
return {"success": False, "error": f"Error processing QR code: {str(e)}"}
except Exception as e:
return {"success": False, "error": f"Error during QR scan: {str(e)}"}
================================================
FILE: services/battery.py
================================================
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()
================================================
FILE: services/brightness.py
================================================
import os
from gi.repository import GLib
from loguru import logger
from fabric.core.service import Property, Service, Signal
from fabric.utils import exec_shell_command_async, monitor_file
def exec_brightnessctl_async(args: str):
exec_shell_command_async(f"brightnessctl {args}", lambda _: None)
# Discover screen backlight device
try:
screen_device = os.listdir("/sys/class/backlight")
screen_device = screen_device[0] if screen_device else ""
except FileNotFoundError:
logger.error("No backlight devices found, brightness control disabled")
screen_device = ""
class Brightness(Service):
"""Service to manage screen brightness levels."""
instance = None
@staticmethod
def get_initial():
if Brightness.instance is None:
Brightness.instance = Brightness()
return Brightness.instance
@Signal
def screen(self, value: int) -> None:
"""Signal emitted when screen brightness changes."""
# Implement as needed for your application
def __init__(self, **kwargs):
super().__init__(**kwargs)
# Path for screen backlight control
self.screen_backlight_path = f"/sys/class/backlight/{screen_device}"
# Initialize maximum brightness level
self.max_screen = self.do_read_max_brightness(self.screen_backlight_path)
if screen_device == "":
return
# Monitor screen brightness file
self.screen_monitor = monitor_file(f"{self.screen_backlight_path}/brightness")
self.screen_monitor.connect(
"changed",
lambda _, file, *args: self.emit(
"screen",
round(int(file.load_bytes()[0].get_data())),
),
)
# Log the initialization of the service
logger.info(f"Brightness service initialized for device: {screen_device}")
def do_read_max_brightness(self, path: str) -> int:
# Reads the maximum brightness value from the specified path.
max_brightness_path = os.path.join(path, "max_brightness")
if os.path.exists(max_brightness_path):
with open(max_brightness_path) as f:
return int(f.readline())
return -1 # Return -1 if file doesn't exist, indicating an error.
@Property(int, "read-write")
def screen_brightness(self) -> int:
# Property to get or set the screen brightness.
brightness_path = os.path.join(self.screen_backlight_path, "brightness")
if os.path.exists(brightness_path):
with open(brightness_path) as f:
return int(f.readline())
logger.warning(f"Brightness file does not exist: {brightness_path}")
return -1 # Return -1 if file doesn't exist, indicating error.
@screen_brightness.setter
def screen_brightness(self, value: int):
# Setter for screen brightness property.
if not (0 <= value <= self.max_screen):
value = max(0, min(value, self.max_screen))
try:
exec_brightnessctl_async(f"--device '{screen_device}' set {value}")
self.emit("screen", int((value / self.max_screen) * 100))
except GLib.Error as e:
logger.error(f"Error setting screen brightness: {e.message}")
except Exception as e:
logger.exception(f"Unexpected error setting screen brightness: {e}")
================================================
FILE: services/custom_notification.py
================================================
# Standard library imports
import json
import os
import time
from typing import List
# Fabric imports
import gi
gi.require_version("Gtk", "3.0")
gi.require_version("GdkPixbuf", "2.0")
from gi.repository import GdkPixbuf
import config.data as data
from fabric.core.service import Property, Service, Signal
from fabric.notifications import (
Notification,
NotificationAction,
NotificationImagePixmap,
Notifications,
)
gi.require_version("Gtk", "3.0")
gi.require_version("GdkPixbuf", "2.0")
NOTIFICATION_CACHE_FILE = f"{data.CACHE_DIR}/notification_history.json"
class CachedNotification(Service):
@classmethod
def create_from_dict(cls, data, **kwargs):
"""Create CachedNotification from enhanced JSON data"""
data["timeout"] = 0
self = cls.__new__(cls)
Service.__init__(self, **kwargs)
self._notification = Notification.deserialize(data)
self._cache_id = data["cached-id"] # Set directly to private var
# Store cache metadata for cleanup
self.cache_metadata = data.get("cache_metadata", {})
self.timestamp = data.get("timestamp", int(time.time()))
return self
@Signal
def removed_from_cache(self) -> None: ...
@Property(int, "readable")
def cache_id(self) -> int:
return self._cache_id
@Property(str, "readable")
def app_name(self) -> str:
return self._notification.app_name
@Property(str, "readable")
def app_icon(self) -> str:
return self._notification.app_icon
@Property(str, "readable")
def summary(self) -> str:
return self._notification.summary
@Property(str, "readable")
def body(self) -> str:
return self._notification.body
@Property(int, "readable")
def id(self) -> int:
return self._notification.id
@Property(int, "readable")
def replaces_id(self) -> int:
return self._notification.replaces_id
@Property(int, "readable")
def urgency(self) -> int:
return self._notification.urgency
@Property(list[NotificationAction], "readable")
def actions(self) -> list[NotificationAction]:
return self._notification.actions
@Property(NotificationImagePixmap, "readable")
def image_pixmap(self) -> NotificationImagePixmap:
return self._notification.image_pixmap # type: ignore
@Property(str, "readable")
def image_file(self) -> str:
return self._notification.image_file # type: ignore
@Property(object, "readable")
def image_pixbuf(self) -> GdkPixbuf.Pixbuf | None:
try:
if self.image_pixmap:
return self.image_pixmap.as_pixbuf()
if self.image_file and os.path.exists(self.image_file):
try:
return GdkPixbuf.Pixbuf.new_from_file(self.image_file)
except Exception:
# If file can't be loaded, return None
pass
except Exception:
# If any error occurs (including temp file gone), return None safely
pass
return None
@Property(dict, "readable")
def serialized(self) -> dict:
"""Enhanced serialization with cache metadata - stores only cache keys"""
from modules.notification.notification import (
get_cache_key,
get_notification_image_cache_key,
)
# Get better cache keys for icons
app_icon_cache_key = None
notification_image_cache_key = None
if self.app_icon:
app_icon_cache_key = get_cache_key(self.app_icon, (35, 35), self.app_name)
# Only try to get notification image cache key if we can safely access image_pixbuf
if self.id:
try:
# First check if we already have the cache key stored
if hasattr(self, 'cache_metadata') and self.cache_metadata:
notification_image_cache_key = self.cache_metadata.get('notification_image_cache_key')
# If not, try to generate it safely
if not notification_image_cache_key and hasattr(self._notification, 'image_pixbuf'):
try:
# Check if image_pixbuf exists and can be accessed without loading from file
image_pixbuf = getattr(self._notification, 'image_pixbuf', None)
if image_pixbuf:
notification_image_cache_key = get_notification_image_cache_key(
self.id, image_pixbuf
)
except (AttributeError, OSError, Exception):
# If temp file is gone or any other error, just mark as None
pass
except Exception:
# If any error occurs during cache key generation, skip it
pass
return {
"cached-id": self.cache_id,
"id": self.id,
"replaces-id": self.replaces_id,
"app-name": self.app_name,
"app-icon": self.app_icon,
"summary": self.summary,
"body": self.body,
"urgency": self.urgency,
"actions": [(action.identifier, action.label) for action in self.actions],
"image-file": self.image_file,
# Only store image-pixmap if no cache key is available (fallback)
"image-pixmap": None, # Don't store image data, only cache key
"timestamp": int(time.time()),
"group": self.app_name, # Group notifications by app name
# Enhanced cache metadata - store only cache keys
"cache_metadata": {
"app_icon_cache_key": app_icon_cache_key,
"notification_image_cache_key": notification_image_cache_key,
"has_cached_image": notification_image_cache_key is not None,
"cache_timestamp": int(time.time())
}
}
def __init__(self, notification: Notification, cache_id: int, **kwargs):
super().__init__()
self._notification: Notification = notification
self._cache_id = cache_id
self.cache_metadata = {}
self.timestamp = int(time.time())
def remove_from_cache(self):
self.removed_from_cache.emit()
class CachedNotifications(Notifications):
"""A service to manage the cached notifications."""
@Signal
def clear_all(self) -> None:
"""Signal emitted when notifications are emptied."""
pass
@Signal
def cached_notification_added(self, notification: CachedNotification) -> None:
"""Signal emitted when a notification is cached."""
pass
@Signal
def cached_notification_removed(self, notification: CachedNotification) -> None:
"""Signal emitted when a notification is removed from cache."""
pass
@Property(List[CachedNotification], "readable")
def cached_notifications(self) -> List[CachedNotification]:
"""Return the cached notifications."""
return list(self._cached_notifications.values())
@Property(int, "readable")
def count(self) -> int:
"""Return the count of notifications."""
return self._count
@Property(bool, "read-write", default_value=False)
def dont_disturb(self) -> bool:
"""Return the pause status."""
return self._dont_disturb
def set_dont_disturb(self, value: bool):
"""Set the pause status."""
self._dont_disturb = value
self.notify("dont-disturb")
def __init__(self, **kwargs):
super().__init__()
self._cached_notifications: dict[int, CachedNotification] = {}
self._signal_handlers = {} # Store signal handlers by notification_id
self._dont_disturb = False
self._count = 0
self._next_cache_id = 1 # Track next available cache ID
self._session_start_time = int(time.time()) # Track session start time for deduplication
self.load_cached_notifications()
# Connect to the notification_added signal to cache new notifications
# Note: self here refers to the CachedNotifications service, which inherits from Notifications
# So we connect to our own notification_added signal
super().notification_added.connect(self.on_notification_added)
def load_cached_notifications(self) -> dict[int, CachedNotification]:
"""Load cached notifications from a JSON file (deserialization)."""
try:
with open(NOTIFICATION_CACHE_FILE, "r") as file:
data = json.load(file) # Load list of serialized notifications
except (FileNotFoundError, json.JSONDecodeError):
# If file doesn't exist or is corrupted, start with empty list
data = []
max_cache_id = 0
for notification in data:
cached_notification = CachedNotification.create_from_dict(notification)
cache_id = cached_notification.cache_id
max_cache_id = max(max_cache_id, cache_id)
handler_id = cached_notification.connect(
"removed-from-cache",
lambda *args: self.remove_cached_notification(
notification_id=cache_id
),
)
self._signal_handlers[cache_id] = handler_id
self._cached_notifications[cache_id] = cached_notification
self._count += 1
# Set next cache ID to be higher than any existing ID
self._next_cache_id = max_cache_id + 1
self.notify("count")
return self._cached_notifications
def cache_notifications(self) -> None:
"""Save cached notifications to a JSON file."""
# Ensure cache directory exists
os.makedirs(os.path.dirname(NOTIFICATION_CACHE_FILE), exist_ok=True)
serialized_data = [
notif.serialized for notif in self._cached_notifications.values()
] # Convert to serializable format
with open(NOTIFICATION_CACHE_FILE, "w") as file:
json.dump(serialized_data, file, indent=4)
def clear_all_cached_notifications(self):
"""Empty the notifications with enhanced cache cleanup"""
# Clean up all cached files before clearing notifications
from modules.notification.notification import cleanup_all_notification_caches
for cached_notification in self._cached_notifications.values():
handler_id = self._signal_handlers.pop(cached_notification.cache_id, None)
if handler_id:
cached_notification.disconnect(handler_id)
# Clear all notification caches (icons and images)
cleanup_all_notification_caches()
self._cached_notifications = {}
self.cache_notifications()
self._count = 0
self._next_cache_id = 1 # Reset cache ID counter
self.notify("count")
self.clear_all.emit()
def on_notification_added(self, service, notification_id: int) -> None:
"""Handle notification added and cache it with enhanced metadata - GUARANTEED STORAGE"""
# Don't call super() - we're handling this ourselves
# Import logger at the top of the function
from loguru import logger
notification = self.get_notification_from_id(notification_id)
if not notification:
logger.error(f"CRITICAL: Failed to get notification with ID {notification_id}")
return
# Import here to avoid circular imports
from config import data
from modules.notification.notification import (
preload_notification_assets,
cache_notification_icon,
cache_notification_image,
get_cache_key,
get_notification_image_cache_key
)
# Check if this app should be ignored for history (don't cache)
if notification.app_name in data.NOTIFICATION_IGNORED_APPS_HISTORY:
# Don't cache notifications from ignored apps, but still allow popup display
logger.debug(f"Ignoring notification from {notification.app_name} (in ignore list)")
return
# Check for duplicates using both notification ID and timestamp to avoid session restart issues
existing_notification = None
current_time = int(time.time())
for cached_notif in self._cached_notifications.values():
# Only consider it a duplicate if:
# 1. Same notification ID AND
# 2. Notification was cached in the current session (after session start time) AND
# 3. Notification was cached recently (within last 5 minutes)
cached_time = getattr(cached_notif, 'timestamp', 0)
is_recent = (current_time - cached_time) < 300 # 5 minutes
is_current_session = cached_time >= self._session_start_time
if (cached_notif._notification.id == notification.id and
is_current_session and is_recent):
existing_notification = cached_notif
break
if existing_notification:
logger.debug(f"Notification ID {notification.id} already cached in current session, skipping")
return
logger.debug(f"Caching new notification: ID={notification.id}, App={notification.app_name}, Summary={notification.summary[:50]}...")
# GUARANTEED STORAGE: Always create and store notification to history first
cache_id = self._next_cache_id
self._next_cache_id += 1
self._count += 1
cached_notification = CachedNotification(
notification=notification, cache_id=cache_id
)
# Set cache_id directly since it's read-only property
cached_notification._cache_id = cache_id
# Initialize cache metadata (will be populated below)
cached_notification.cache_metadata = {
"app_icon_cache_key": None,
"notification_image_cache_key": None,
"has_cached_image": False,
"cache_timestamp": int(time.time())
}
# IMMEDIATELY store to history before attempting any caching operations
handler_id = cached_notification.connect(
"removed-from-cache",
lambda *args: self.remove_cached_notification(notification_id=cache_id),
)
self._signal_handlers[cache_id] = handler_id
self._cached_notifications[cache_id] = cached_notification
# Save to JSON file immediately - GUARANTEED STORAGE
try:
self.cache_notifications()
logger.debug(f"GUARANTEED: Notification {cache_id} stored to history")
except Exception as e:
logger.error(f"CRITICAL: Failed to save notification {cache_id} to history: {e}")
# Now attempt asset caching (failures here won't affect history storage)
try:
# Preload assets and store cache metadata
preload_notification_assets(notification)
# Store enhanced cache metadata
app_icon_cache_key = None
notification_image_cache_key = None
if notification.app_icon:
try:
# Only cache at 35x35 to reduce disk usage - headers will scale this down
app_icon_cache_key = get_cache_key(notification.app_icon, (35, 35), notification.app_name)
cache_notification_icon(notification.app_icon, (35, 35), notification.app_name)
cached_notification.cache_metadata["app_icon_cache_key"] = app_icon_cache_key
logger.debug(f"Cached app icon (35x35) for notification {cache_id}")
except Exception as e:
logger.warning(f"Failed to cache app icon for notification {cache_id}: {e}")
if hasattr(notification, 'image_pixbuf'):
try:
# Safely try to access image_pixbuf
image_pixbuf = getattr(notification, 'image_pixbuf', None)
if image_pixbuf:
notification_image_cache_key = get_notification_image_cache_key(
notification.id, image_pixbuf
)
cache_notification_image(notification.id, image_pixbuf, (35, 35))
cached_notification.cache_metadata["notification_image_cache_key"] = notification_image_cache_key
cached_notification.cache_metadata["has_cached_image"] = True
logger.debug(f"Cached notification image for notification {cache_id}")
except (AttributeError, OSError, Exception) as e:
logger.warning(f"Failed to cache notification image for notification {cache_id}: {e}")
# Update cached notification with final metadata
self._cached_notifications[cache_id] = cached_notification
# Save updated metadata to JSON
self.cache_notifications()
except Exception as e:
logger.error(f"Asset caching failed for notification {cache_id}, but notification is still stored: {e}")
# Always emit signals regardless of caching success
self.notify("count")
self.emit("cached-notification-added", cached_notification)
logger.debug(f"Successfully processed notification: Cache ID={cache_id}, Total cached={len(self._cached_notifications)}")
def remove_cached_notification(self, notification_id: int):
"""Remove the notification of given id with enhanced cache cleanup"""
if notification_id in self._cached_notifications:
cached_notification = self._cached_notifications.pop(notification_id)
# Enhanced cache cleanup using stored metadata
if hasattr(cached_notification, 'cache_metadata'):
cache_metadata = cached_notification.cache_metadata
# Clean up specific cached files using stored keys
from modules.notification.notification import cleanup_notification_specific_caches
cleanup_notification_specific_caches(
app_icon_source=cached_notification.app_icon,
notification_image_cache_key=cache_metadata.get('notification_image_cache_key')
)
self.cache_notifications() # Update JSON
self._count -= 1
self.notify("count")
# Get the stored signal handler ID and disconnect it
handler_id = self._signal_handlers.pop(notification_id, None)
if handler_id:
cached_notification.disconnect(handler_id)
# Emit signal to notify UI that notification was removed
self.emit("cached-notification-removed", cached_notification)
def toggle_dnd(self):
self.set_dont_disturb(not self.dont_disturb)
================================================
FILE: services/modus.py
================================================
import json
from fabric.core.service import Property, Service, Signal
from fabric.hyprland.service import Hyprland
from loguru import logger
from services.custom_notification import CachedNotifications
notification_service = CachedNotifications()
class ModusService(Service):
@Signal
def bluetooth_changed(self, new_bluetooth: str) -> None: ...
@Signal
def volume_changed(self, new_volume: int) -> None: ...
@Signal
def wlan_changed(self, new_wlan: str) -> None: ...
@Signal
def battery_changed(self, new_battery: str) -> None: ...
@Signal
def dock_apps_changed(self, new_dock_apps: str) -> None: ...
@Signal
def dont_disturb_changed(self, value: bool) -> None: ...
@Signal
def current_active_app_name_changed(self, value: str) -> None: ...
@Signal
def current_workspace_changed(self, value: str) -> None: ...
@Signal
def music_changed(self, value: str) -> None: ...
@Signal
def current_dropdown_changed(self, value: str) -> None: ...
@Signal
def dropdowns_hide_changed(self, value: bool) -> None: ...
@Signal
def dock_width_changed(self, value: int) -> None: ...
@Signal
def dock_height_changed(self, value: int) -> None: ...
@Signal
def dock_hidden_changed(self, value: bool) -> None: ...
@Signal
def show_notificationcenter_changed(self, value: bool) -> None: ...
@Signal
def notification_count_changed(self, value: int) -> None: ...
@Property(str, flags="read-write")
def current_active_app_name(self) -> str:
return self._current_active_app_name
@Property(str, flags="read-write")
def current_workspace(self) -> str:
return self._current_workspace
@Property(str, flags="read-write")
def bluetooth(self) -> str:
return self._bluetooth
@Property(str, flags="read-write")
def wlan(self) -> str:
return self._wlan
@Property(str, flags="read-write")
def battery(self) -> str:
return self._battery
@Property(int, flags="read-write")
def volume(self) -> int:
return self._volume
@Property(str, flags="read-write")
def dock_apps(self) -> str:
return self._dock_apps
@Property(bool, flags="read-write", default_value=False)
def dont_disturb(self) -> bool:
return self._dont_disturb
@Property(str, flags="read-write")
def music(self) -> str:
return self._music
@Property(str, flags="read-write")
def current_dropdown(self) -> str:
return self._current_dropdown
@Property(bool, flags="read-write", default_value=False)
def dropdowns_hide(self) -> bool:
return self._dropdowns_hide
@Property(int, flags="read-write")
def dock_width(self) -> int:
return self._dock_width
@Property(int, flags="read-write")
def dock_height(self) -> int:
return self._dock_height
@Property(bool, flags="read-write", default_value=False)
def dock_hidden(self) -> bool:
return self._dock_hidden
@Property(bool, flags="read-write", default_value=False)
def show_notificationcenter(self) -> bool:
return self._show_notificationcenter
@current_active_app_name.setter
def current_active_app_name(self, value: str):
if value != self._current_active_app_name:
self._current_active_app_name = value
self.current_active_app_name_changed(value)
@current_workspace.setter
def current_workspace(self, value: str):
if value != self._current_workspace:
self._current_workspace = value
self.current_workspace_changed(value)
@volume.setter
def volume(self, value: int):
if value != self._volume:
self._name = value
self.volume_changed(value)
@wlan.setter
def wlan(self, value: str):
if value != self._wlan:
self._wlan = value
self.wlan_changed(value)
@battery.setter
def battery(self, value: str):
if value != self._battery:
self._battery = value
self.battery_changed(value)
@bluetooth.setter
def bluetooth(self, value: str):
if value != self._bluetooth:
self._bluetooth = value
self.bluetooth_changed(value)
@dock_apps.setter
def dock_apps(self, value: str):
if value != self._dock_apps:
self._dock_apps = value
self.dock_apps_changed(value)
@dont_disturb.setter
def dont_disturb(self, value: bool):
if value != self._dont_disturb:
self._dont_disturb = value
self.dont_disturb_changed(value)
@music.setter
def music(self, value: str):
if value != self._music:
self._music = value
self.music_changed(value)
@current_dropdown.setter
def current_dropdown(self, value: str):
if value != self._current_dropdown:
self._current_dropdown = value
self.current_dropdown_changed(value)
@dropdowns_hide.setter
def dropdowns_hide(self, value: bool):
if value != self._dropdowns_hide:
self._dropdowns_hide = value
self.dropdowns_hide_changed(value)
@dock_width.setter
def dock_width(self, value: int):
if value != self._dock_width:
self._dock_width = value
self.dock_width_changed(value)
@dock_height.setter
def dock_height(self, value: int):
if value != self._dock_height:
self._dock_height = value
self.dock_height_changed(value)
@dock_hidden.setter
def dock_hidden(self, value: bool):
if value != self._dock_hidden:
self._dock_hidden = value
self.dock_hidden_changed(value)
@show_notificationcenter.setter
def show_notificationcenter(self, value: bool):
if value != self._show_notificationcenter:
self._show_notificationcenter = value
self.show_notificationcenter_changed(value)
def sc(self, signal_name: str, callback: callable, def_value="..."):
self.connect(signal_name, callback)
# Return current property value instead of default
if signal_name == "bluetooth-changed":
return self.bluetooth if self.bluetooth else "Off"
elif signal_name == "wlan-changed":
return self.wlan if self.wlan else "No Connection"
elif signal_name == "battery-changed":
return self.battery if self.battery else "Unknown"
elif signal_name == "music-changed":
return self.music if self.music else ""
else:
return def_value
def __init__(self):
super().__init__()
self._volume = 0
self._wlan = ""
self._battery = ""
self._bluetooth = ""
self._dock_apps = ""
self._dont_disturb = False
self._current_active_app_name = "Finder" # Changed from "Hyprland" to "Finder"
self._current_workspace = "1"
self._music = ""
self._current_dropdown = None
self._dropdowns_hide = False
self._dock_hidden = False
self._show_notificationcenter = False
self._dock_width = 0
self._dock_height = 0
# Initialize Hyprland connection for workspace and window monitoring
self._setup_workspace_monitoring()
self._setup_active_window_monitoring()
def _setup_workspace_monitoring(self):
"""Setup Hyprland connection and workspace monitoring"""
try:
self._hyprland_connection = Hyprland()
# Get initial workspace
workspace_data = self._hyprland_connection.send_command(
"j/activeworkspace"
).reply
active_workspace = json.loads(workspace_data.decode("utf-8"))["name"]
self._current_workspace = str(active_workspace)
# Connect to workspace change events
self._hyprland_connection.connect(
"event::workspace", self._on_workspace_changed
)
except Exception as e:
logger.error(f"[ModusService] Failed to setup workspace monitoring: {e}")
self._current_workspace = "1"
def _setup_active_window_monitoring(self):
"""Setup active window monitoring"""
try:
if not hasattr(self, '_hyprland_connection') or not self._hyprland_connection:
return
# Get initial active window
self._update_active_window()
# Note: The HyprlandActiveWindow widget from Fabric library
# should handle active window updates automatically.
# We just need to ensure the initial state is correct.
except Exception as e:
logger.error(f"[ModusService] Failed to setup active window monitoring: {e}")
def _update_active_window(self):
"""Update the current active app name based on active window"""
try:
if not hasattr(self, '_hyprland_connection') or not self._hyprland_connection:
return
window_data = self._hyprland_connection.send_command("j/activewindow").reply
if not window_data:
self.current_active_app_name = "Finder"
return
window_info = json.loads(window_data.decode("utf-8"))
wmclass = window_info.get("class", "")
title = window_info.get("title", "")
# Handle the case when there's no active window
if not title and not wmclass:
self.current_active_app_name = "Finder"
return
# Simple app name formatting without circular import
name = wmclass if wmclass else title
if name:
# Basic formatting: capitalize first letter and remove file extensions
name = str(name).title()
if "." in name:
name = name.split(".")[-1]
else:
name = "Finder"
self.current_active_app_name = name
except Exception as e:
logger.error(f"[ModusService] Error updating active window: {e}")
self.current_active_app_name = "Finder"
def _on_workspace_changed(self, obj, signal):
"""Handle workspace change events from Hyprland"""
try:
workspace_name = json.loads(signal.data[0])
self.current_workspace = str(workspace_name)
except Exception as e:
logger.error(f"[ModusService] Error processing workspace change: {e}")
def remove_notification(self, id: int):
notification_service.remove_cached_notification(id)
self.notification_count_changed(self.notification_count)
def clear_all_notifications(self):
notification_service.clear_all_cached_notifications()
self.notification_count_changed(self.notification_count)
def get_cached_notifications(self):
return notification_service.cached_notifications
def get_deserialized_with_ids(self):
return [
(notif._notification, notif.cache_id)
for notif in notification_service.cached_notifications
]
@property
def notification_count(self) -> int:
return notification_service.count
def toggle_dnd(self):
notification_service.toggle_dnd()
self.dont_disturb_changed(notification_service.dont_disturb)
global modus_service
try:
modus_service = ModusService()
except Exception as e:
logger.error("[Main] Failed to create ModusShellService:", e)
================================================
FILE: services/mpris.py
================================================
# 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
================================================
FILE: services/network.py
================================================
from gi.repository import NM, GLib
import gi
from typing import List, Optional
from fabric.core.service import Property, Service, Signal
from fabric.utils import bulk_connect, get_enum_member_name, snake_case_to_kebab_case
from loguru import logger
gi.require_version("NM", "1.0") # Ensure the correct version is loaded
class NetworkClient(Service):
"""A service to manage network devices"""
@Signal
def wifi_device_added(self) -> None: ...
@Signal
def ethernet_device_added(self) -> None: ...
@Signal
def wifi_device_removed(self) -> None: ...
@Signal
def ethernet_device_removed(self) -> None: ...
@Signal
def changed(self) -> None: ...
@Property(list, "readable")
def connections(self) -> Optional[list]:
"""Returns the active connections, if available."""
return self._client.get_property("connections") if self._client else None
@Property(object, "readable")
def wifi_device(self) -> Optional[object]:
"""Returns the WiFi device if available."""
return self._wifi_device
@Property(object, "readable")
def ethernet_device(self) -> Optional[object]:
"""Returns the Ethernet device if available."""
return self._ethernet_device
@Property(str, "readable")
def primary_connection(self) -> Optional[str]:
"""Returns the primary connection if available."""
return self._client.get_property("primary_connection") if self._client else None
@Property(str, "readable")
def active_connection(self) -> Optional[str]:
"""Returns the active connection if available."""
return self._client.get_property("active_connection") if self._client else None
@Property(str, "readable")
def state(self) -> str:
"""Returns the current network state."""
if not self._client:
return "disconnected"
return snake_case_to_kebab_case(
get_enum_member_name(
self._client.get_property("state"), default="disconnected"
)
)
@Property(str, "readable")
def connectivity(self) -> str:
"""Returns the connectivity state."""
if not self._client:
return "disconnected"
return snake_case_to_kebab_case(
get_enum_member_name(
self._client.get_property("connectivity"), default="disconnected"
)
)
@Property(list, "readable")
def devices(self) -> Optional[list]:
"""Returns the list of network devices if available."""
return self._client.get_property("devices") if self._client else None
@Property(str, "readable")
def hostname(self) -> Optional[str]:
"""Returns the hostname if available."""
return self._client.get_property("hostname") if self._client else None
@Property(bool, "read-write", default_value=False)
def networking_enabled(self) -> bool:
"""Checks if networking is enabled."""
return (
self._client.get_property("networking_enabled") if self._client else False
)
@networking_enabled.setter
def networking_enabled(self, value: bool):
"""Sets the networking state if the client is available."""
if self._client:
self._client.set_property("networking_enabled", value)
@Property(bool, "read-write", default_value=False)
def wireless_enabled(self) -> bool:
"""Checks if wireless networking is enabled."""
return self._client.get_property("wireless_enabled") if self._client else False
@wireless_enabled.setter
def wireless_enabled(self, value: bool):
"""Sets the wireless networking state if the client is available."""
if self._client:
self._client.set_property("wireless_enabled", value)
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._client: NM.Client = None
self._wifi_device: Wifi | None = None
self._ethernet_device: Ethernet | None = None
logger.info("[Network] Initializing client asynchronously...")
# Start async NM client initialization
NM.Client.new_async(None, self.on_client_ready)
def on_client_ready(self, source, result):
"""Callback when NM.Client is ready."""
try:
self._client = NM.Client.new_finish(result) # Retrieve client instance
logger.info("[Network] NM.Client initialized successfully!")
# Connect signals
bulk_connect(
self._client,
{
"device-added": lambda _, device: self.on_device_added(
device=device
),
"device-removed": lambda _, device: self.on_device_removed(
device=device
),
"notify::state": lambda *args: self.notifier("state"),
"notify::networking-enabled": lambda *args: self.notifier(
"networking-enabled"
),
"notify::wireless-enabled": lambda *args: self.notifier(
"wireless-enabled"
),
# "notify::primary-connection": lambda *args: self.notifier('primary-connection'),
# "notify::active-connection": lambda *args: self.notifier('active-connection'),
# "active-connection-added": lambda *args: self.emit("changed"),
# "active-connection-removed": lambda *args: self.emit("changed")
},
)
# Process devices AFTER client is ready
for device in self.do_get_raw_devices():
self.on_device_added(device=device)
self.notify("state")
self.notify("networking-enabled")
self.notify("wireless-enabled")
except Exception as e:
logger.error(f"[Network] Error initializing NM.Client: {e}")
def do_get_raw_devices(self) -> list[NM.Device]:
return [
dev
for dev in self.devices
if dev.get_device_type() in (NM.DeviceType.WIFI, NM.DeviceType.ETHERNET)
]
def on_device_added(self, device):
device_type = device.get_device_type()
if device_type == NM.DeviceType.WIFI and not self._wifi_device:
logger.info("[Network] WiFi device detected, initializing...")
self._wifi_device = Wifi(client=self, device=device)
self.wifi_device_added.emit()
elif device_type == NM.DeviceType.ETHERNET and not self._ethernet_device:
logger.info("[Network] Ethernet device detected, initializing...")
self._ethernet_device = Ethernet(client=self, device=device)
self.ethernet_device_added.emit()
def on_device_removed(self, device):
if device == self._wifi_device:
logger.info("[Network] WiFi device removed.")
self._wifi_device = None
self.wifi_device_removed.emit()
elif device == self._ethernet_device:
logger.info("[Network] Ethernet device removed.")
self._ethernet_device = None
self.ethernet_device_removed.emit()
def toggle_network(self):
"""Enable or disable Network"""
self.networking_enabled = not self.networking_enabled
def deactivate_connection(self, connection):
"""Disconnect"""
self._client.deactivate_connection_async(connection, None, None)
def notifier(self, name):
self.notify(name)
self.emit("changed")
class AccessPoint(Service):
"""A service to manage access points"""
@Signal
def changed(self) -> None: ...
@Property(object, "readable")
def device(self) -> object:
return self._device
@Property(int, "readable")
def strength(self) -> int:
return self._ap.get_property("strength")
@Property(int, "readable")
def frequency(self) -> int:
return self._ap.get_property("frequency")
@Property(str, "readable")
def bssid(self) -> str:
return self._ap.get_property("bssid") if self._ap else None
@Property(str, "readable")
def hw_address(self) -> str:
return self._ap.get_property("hw_address")
@Property(str, "readable")
def ssid(self) -> str:
ssid = self._ap.get_ssid()
return NM.utils_ssid_to_utf8(ssid.get_data()) if ssid else "Unknown"
@Property(str, "readable")
def icon(self) -> str:
return {
80: "network-wireless-signal-excellent-symbolic",
60: "network-wireless-signal-good-symbolic",
40: "network-wireless-signal-ok-symbolic",
20: "network-wireless-signal-weak-symbolic",
00: "network-wireless-signal-none-symbolic",
}.get(
min(80, 20 * round(self.strength / 20)),
"network-wireless-no-route-symbolic",
)
@Property(bool, "readable", default_value=False)
def requires_password(self) -> bool:
ssid = self.ssid
settings = self._client.connections
connection = None
for setting in settings:
wifi_setting = setting.get_setting_wireless()
if (
wifi_setting
and NM.utils_ssid_to_utf8(wifi_setting.get_ssid().get_data()) == ssid
):
connection = setting
break
if not connection:
return bool(self._ap.get_wpa_flags() or self._ap.get_rsn_flags())
return False
@Property(bool, "readable", default_value=False)
def is_active(self) -> bool:
if self._device.active_access_point:
return self.bssid == self._device.active_access_point.get_bssid()
return False
def __init__(self, device: "Wifi", ap: NM.AccessPoint, **kwargs):
super().__init__(**kwargs)
self._client: NetworkClient = device.client
self._device: Wifi = device
self._ap: NM.AccessPoint = ap
self._ap.connect("notify::strength", lambda *args: self.notifier("strength"))
self._device.connect(
"notify::active-access-point", lambda *args: self.notifier("is-active")
)
def notifier(self, name: str, *args):
self.notify(name)
self.emit("changed")
return
class Wifi(Service):
"""A service to manage wifi devices"""
@Signal
def changed(self) -> None: ...
@Signal
def ap_added(self, ap: AccessPoint) -> None: ...
@Signal
def ap_removed(self, ap: AccessPoint) -> None: ...
@Property(NetworkClient, "readable")
def client(self) -> NetworkClient:
"""Returns the client"""
return self._client
@Property(bool, "read-write", default_value=False)
def wireless_enabled(self) -> bool:
"""Returns if the wifi is enabled"""
return self._client.get_property("wireless_enabled")
@wireless_enabled.setter
def wireless_enabled(self, value: bool):
return self._client.set_property("wireless_enabled", value)
@Property(list[AccessPoint], "readable")
def access_points(self) -> list[AccessPoint]:
return sorted(
self._access_points.values(), key=lambda x: x.is_active, reverse=True
)
@Property(AccessPoint, "readable")
def active_access_point(self) -> Optional[AccessPoint]:
return self._active_access_point
def __init__(self, client: NetworkClient, device: NM.DeviceWifi, **kwargs):
super().__init__(**kwargs)
self._client: NetworkClient = client
self._device: NM.DeviceWifi = device
self._active_access_point: NM.AccessPoint | None = None
self._access_points: dict[str, AccessPoint] = {}
bulk_connect(
self._device,
{
"notify::active-access-point": lambda *args: self.on_access_point_activated(),
"access-point-added": lambda _, ap: self.on_access_point_added(ap=ap),
"access-point-removed": lambda _, ap: self.on_access_point_removed(
ap=ap
),
# "state-changed": lambda device, new, old, reason: self.on_state_changed(new),
},
)
self._client.connect(
"notify::wireless-enabled", lambda *args: self.notifier("wireless-enabled")
)
for ap in self.do_get_access_points():
self.on_access_point_added(ap=ap)
self.on_access_point_activated()
def on_state_changed(self, state):
self.emit("changed")
def do_get_access_points(self):
return self._device.get_access_points()
def on_access_point_added(self, ap):
ssid = ap.get_ssid()
ssid = NM.utils_ssid_to_utf8(ssid.get_data()) if ssid else "Unknown"
access_point: AccessPoint = AccessPoint(ap=ap, device=self)
self._access_points[ssid] = access_point
self.ap_added.emit(access_point)
# self.notifier("access-points")
logger.info(f"[Wifi] New access point added with ssid: {ssid}")
def on_access_point_removed(self, ap):
ssid = ap.get_ssid()
ssid = NM.utils_ssid_to_utf8(ssid.get_data()) if ssid else "Unknown"
if not (access_point := self._access_points.pop(ssid, None)):
return logger.warning(
f"[Network] tried to remove a unknwon access point with ssid {ssid}"
)
self.ap_removed.emit(access_point)
logger.info(f"[Wifi] Access point with ssid: {ssid} removed.")
def on_access_point_activated(self):
if self._device.get_active_access_point():
self._active_access_point: AccessPoint = AccessPoint(
ap=self._device.get_active_access_point(), device=self
)
else:
self._active_access_point = None
self.notifier("active-access-point")
logger.info("[Wifi] New active connection")
def disconnect_wifi(self):
"""Disconnect from the current WiFi network."""
active_connection = self._device.get_active_connection()
self._client.deactivate_connection(active_connection)
logger.info("[Wifi] Wifi network disconnected")
def scan(self):
self._device.request_scan_async(
None,
lambda device, result: [
device.request_scan_finish(result),
self.notifier("access-points"),
],
)
logger.info("[Wifi] Scan started")
def toggle_wifi(self):
"""Enable or disable WiFi"""
self.wireless_enabled = not self.wireless_enabled
def connect_to_wifi(self, ap: AccessPoint, password: str = None, callback=None):
"""Connect to a WiFi network."""
ssid = ap.ssid
if ssid == "Unknown":
logger.error("Invalid access point data")
if callback:
callback(False, "Invalid access point data")
return False
logger.info(f"Connecting to WiFi SSID: {ssid}")
# Check for existing connections
settings = self._client.connections
connection = None
for setting in settings:
wifi_setting = setting.get_setting_wireless()
if (
wifi_setting
and NM.utils_ssid_to_utf8(wifi_setting.get_ssid().get_data()) == ssid
):
connection = setting
break
def on_activation_result(client, result):
"""Handle the result of connection activation"""
try:
active_connection = client.activate_connection_finish(result)
if active_connection:
logger.info(f"Successfully connected to '{ssid}'")
if callback:
callback(True, "Connected successfully")
else:
logger.error(f"Failed to connect to '{ssid}'")
if callback:
callback(False, "Failed to connect. Please try again.")
except Exception as e:
error_msg = str(e).lower()
logger.error(f"Connection to '{ssid}' failed: {e}")
# Parse NetworkManager error messages and provide user-friendly responses
if "802-11-wireless-security" in error_msg:
if "property is invalid" in error_msg or "psk" in error_msg:
user_msg = "Incorrect password. Please try again."
else:
user_msg = "Security configuration error. Please try again."
elif "secrets were required" in error_msg or "no secrets" in error_msg:
user_msg = "Incorrect password. Please try again."
elif "timeout" in error_msg:
user_msg = "Connection timeout. Please try again."
elif "not found" in error_msg or "no such device" in error_msg:
user_msg = "Network not available. Please try again."
elif "already connected" in error_msg:
user_msg = "Already connected to this network."
else:
user_msg = "Failed to connect. Please try again."
if callback:
callback(False, user_msg)
if not connection:
# Create a new connection profile
logger.info(f"Creating new WiFi connection for SSID '{ssid}'")
connection = NM.SimpleConnection.new()
# Required connection settings
s_con = NM.SettingConnection.new()
s_con.set_property(NM.SETTING_CONNECTION_ID, ssid)
s_con.set_property(NM.SETTING_CONNECTION_TYPE, "802-11-wireless")
s_con.set_property(
NM.SETTING_CONNECTION_INTERFACE_NAME, self._device.get_iface()
) # Set interface name
connection.add_setting(s_con)
# Wireless settings
s_wifi = NM.SettingWireless.new()
s_wifi.set_property(NM.SETTING_WIRELESS_SSID, GLib.Bytes.new(ssid.encode()))
s_wifi.set_property(
NM.SETTING_WIRELESS_MODE, "infrastructure"
) # Ensure mode is correct
connection.add_setting(s_wifi)
# Security settings (only if password is required and provided)
if ap.requires_password:
if not password:
logger.error("Password required but not provided")
if callback:
callback(False, "Password required but not provided")
return False
s_sec = NM.SettingWirelessSecurity.new()
s_sec.set_property(NM.SETTING_WIRELESS_SECURITY_KEY_MGMT, "wpa-psk")
s_sec.set_property(NM.SETTING_WIRELESS_SECURITY_PSK, password)
connection.add_setting(s_sec)
# IPv4 settings
s_ipv4 = NM.SettingIP4Config.new()
s_ipv4.set_property("method", "auto")
connection.add_setting(s_ipv4)
# IPv6 settings
s_ipv6 = NM.SettingIP6Config.new()
s_ipv6.set_property("method", "auto")
connection.add_setting(s_ipv6)
# Callback for async connection
def on_connection_added(client, result):
try:
new_connection = client.add_connection_finish(result)
if not new_connection:
logger.error(f"Failed to create connection for '{ssid}'")
if callback:
callback(
False, "Failed to create connection. Please try again."
)
return
logger.info(f"Connection for '{ssid}' added successfully")
# Now activate the newly created connection
client.activate_connection_async(
new_connection, self._device, None, None, on_activation_result
)
except Exception as e:
error_msg = str(e).lower()
logger.error(f"Failed to add connection: {e}")
# Parse connection creation errors and provide user-friendly responses
if "802-11-wireless-security" in error_msg:
if "property is invalid" in error_msg or "psk" in error_msg:
user_msg = "Incorrect password. Please try again."
else:
user_msg = "Security configuration error. Please try again."
elif "invalid" in error_msg or "property" in error_msg:
user_msg = "Invalid network configuration. Please try again."
else:
user_msg = "Failed to connect. Please try again."
if callback:
callback(False, user_msg)
# Save the new connection
self._client._client.add_connection_async(
connection, True, None, on_connection_added
)
else:
# Activate existing connection
self._client._client.activate_connection_async(
connection, self._device, None, None, on_activation_result
)
return True
def notifier(self, name: str, *args):
self.notify(name)
self.emit("changed")
return
class Ethernet(Service):
"""A service to manage ethernet devices"""
@Signal
def changed(self) -> None: ...
@Signal
def enabled(self) -> bool: ...
@Property(int, "readable")
def speed(self) -> str:
speed_mbps = self._device.get_speed()
return f"{speed_mbps} Mb/s"
@Property(str, "readable")
def state(self) -> str:
return snake_case_to_kebab_case(
get_enum_member_name(self._device.get_state(), default="disconnected")
)
@Property(str, "readable")
def internet(self) -> str:
if self._active_connection:
return snake_case_to_kebab_case(
get_enum_member_name(
self._active_connection.get_state(), default="disconnected"
)
)
return "disconnected"
@Property(str, "readable")
def iface(self) -> str:
return self._device.get_iface() if self._device else None
@Property(str, "readable")
def icon_name(self) -> str:
network = self.internet
if network == "activated":
return "network-wired-symbolic"
elif network == "activating":
return "network-wired-acquiring-symbolic"
return "network-wired-disconnected-symbolic"
def __init__(self, client: NM.Client, device: NM.DeviceEthernet, **kwargs) -> None:
super().__init__(**kwargs)
self._client: NM.Client = client
self._device: NM.DeviceEthernet = device
self._active_connection = None
self._device.connect(
"state-changed", lambda *args: self.on_network_state_changed()
)
self.update_active_connection()
def on_network_state_changed(self):
"""Called when networking is toggled on/off."""
if self.state != "unmanaged":
# Re-initialize device when networking is re-enabled
self.update_active_connection()
else:
self._active_connection = None
def update_active_connection(self):
"""Updates the active connection and connects to its state change signal."""
active_connection = self._device.get_active_connection()
if active_connection:
self._active_connection = active_connection
self._active_connection.connect(
"state-changed", lambda *args: self.emit("changed")
)
def get_network_stats(self):
"""Fetch received and transmitted bytes from the system files"""
try:
# Read data from /sys/class/net for accurate speed
with open(
f"/sys/class/net/{self.iface}/statistics/rx_bytes", "r"
) as rx_file:
rx_bytes = int(rx_file.read().strip())
with open(
f"/sys/class/net/{self.iface}/statistics/tx_bytes", "r"
) as tx_file:
tx_bytes = int(tx_file.read().strip())
return rx_bytes, tx_bytes
except FileNotFoundError:
return None, None
================================================
FILE: services/todo.py
================================================
# Standard library imports
import json
import uuid
from datetime import datetime
from pathlib import Path
# Fabric imports
from fabric.core.service import Property, Service
# Local imports
import config.data as data
class TodoService(Service):
"""Service for managing persistent todo list with JSON storage"""
def __init__(self):
super().__init__()
self._todos = []
self._file_path = self._get_todos_file_path()
self._load_todos()
self._callbacks = []
def add_callback(self, callback):
"""Add a callback function to be notified of changes"""
self._callbacks.append(callback)
def remove_callback(self, callback):
"""Remove a callback function"""
if callback in self._callbacks:
self._callbacks.remove(callback)
def _notify_callbacks(self, event_type, data=None):
"""Notify all registered callbacks of changes"""
for callback in self._callbacks:
try:
callback(event_type, data)
except Exception as e:
print(f"Error in todo callback: {e}")
def _get_todos_file_path(self):
"""Returns the path to the todos JSON file"""
cache_dir = Path(data.CACHE_DIR) / "todos"
cache_dir.mkdir(parents=True, exist_ok=True)
return cache_dir / "todos.json"
def _load_todos(self):
"""Load todos from JSON file"""
try:
if self._file_path.exists():
with open(self._file_path, "r", encoding="utf-8") as f:
self._todos = json.load(f)
else:
self._todos = []
except Exception as e:
print(f"Error loading todos: {e}")
self._todos = []
def _save_todos(self):
"""Save todos to JSON file"""
try:
with open(self._file_path, "w", encoding="utf-8") as f:
json.dump(self._todos, f, indent=2, ensure_ascii=False)
except Exception as e:
print(f"Error saving todos: {e}")
@Property(list, "readable")
def todos(self):
"""Get all todos"""
return self._todos.copy()
def add_todo(self, text: str, priority: str = "medium") -> dict:
"""Add a new todo item"""
todo = {
"id": str(uuid.uuid4()),
"text": text,
"completed": False,
"priority": priority, # low, medium, high
"created_at": datetime.now().isoformat(),
"updated_at": datetime.now().isoformat(),
}
self._todos.append(todo)
self._save_todos()
self._notify_callbacks("todo-added", todo)
self._notify_callbacks("todos-changed")
return todo
def delete_todo(self, todo_id: str) -> bool:
"""Delete a todo item by ID"""
for i, todo in enumerate(self._todos):
if todo["id"] == todo_id:
deleted_todo = self._todos.pop(i)
self._save_todos()
self._notify_callbacks("todo-deleted", deleted_todo)
self._notify_callbacks("todos-changed")
return True
return False
def toggle_todo(self, todo_id: str) -> bool:
"""Toggle completion status of a todo item"""
for todo in self._todos:
if todo["id"] == todo_id:
todo["completed"] = not todo["completed"]
todo["updated_at"] = datetime.now().isoformat()
self._save_todos()
self._notify_callbacks("todo-toggled", todo)
self._notify_callbacks("todos-changed")
return True
return False
def edit_todo(self, todo_id: str, new_text: str) -> bool:
"""Edit the text of a todo item"""
for todo in self._todos:
if todo["id"] == todo_id:
todo["text"] = new_text
todo["updated_at"] = datetime.now().isoformat()
self._save_todos()
self._notify_callbacks("todo-edited", todo)
self._notify_callbacks("todos-changed")
return True
return False
def set_priority(self, todo_id: str, priority: str) -> bool:
"""Set the priority of a todo item"""
if priority not in ["low", "medium", "high"]:
return False
for todo in self._todos:
if todo["id"] == todo_id:
todo["priority"] = priority
todo["updated_at"] = datetime.now().isoformat()
self._save_todos()
self._notify_callbacks("todo-priority-changed", todo)
self._notify_callbacks("todos-changed")
return True
return False
def get_todo(self, todo_id: str) -> dict | None:
"""Get a specific todo by ID"""
for todo in self._todos:
if todo["id"] == todo_id:
return todo.copy()
return None
def clear_completed(self):
"""Remove all completed todos"""
initial_count = len(self._todos)
self._todos = [todo for todo in self._todos if not todo["completed"]]
if len(self._todos) < initial_count:
self._save_todos()
self._notify_callbacks("todos-changed")
def get_stats(self) -> dict:
"""Get todo statistics"""
total = len(self._todos)
completed = sum(1 for todo in self._todos if todo["completed"])
pending = total - completed
return {
"total": total,
"completed": completed,
"pending": pending,
"completion_rate": (completed / total * 100) if total > 0 else 0,
}
# Global service instance
todo_service = TodoService()
================================================
FILE: styles/about.css
================================================
#about-menu {
background-color: alpha(#000, 0.34);
box-shadow:
inset 0 -0.5px 0 0.5px alpha(#555, 0.7),
inset 0 0.5px 0 0.5px alpha(#777, 0.7);
border: 1px solid alpha(#111, 0.3);
border-top: none;
border-radius: 1.25rem;
padding: 2rem;
}
#about-options {
background-color: alpha(#333, 0.9);
border-radius: 15px;
padding: 2rem;
}
#about-logo-box {
margin-top: 2rem;
margin-bottom: 2rem;
}
#about-button-box {
padding: 1rem;
}
#vendor-label {
color: #999999;
margin-top: -1rem;
}
#info-label {
color: #888888;
}
#about-info-title-box {
padding-left: 4rem;
}
#about-name-label {
font-size: 24px;
margin: 0px;
font-weight: 600;
}
#about-date-label {
font-size: 11px;
margin: 0px;
color: #ddd;
font-weight: 400;
margin-bottom: 2rem;
}
#about-chip-title-label,
#about-so-title-label,
#about-mem-title-label {
font-size: 12px;
margin: 0px;
margin-right: 10px;
font-weight: 500;
}
#more-info-button {
background-color: #888888;
border-radius: 5px;
padding: 0px 10px;
margin: 1px 2px;
transition: all 200ms ease-in-out;
}
#more-info-button:hover {
background-color: rgb(72, 130, 255);
background-color: #2369ff;
}
#more-info-button label {
font-size: 12px;
margin: 0px;
font-weight: 400;
color: #fff;
}
================================================
FILE: styles/battery-widget.css
================================================
#battery-widget {
background: rgba(0, 0, 0, 0);
padding-left: 5px;
padding-right: 5px;
}
.battery-main-title {
font-size: 16px;
font-weight: bold;
color: rgba(255, 255, 255, 0.95);
margin: 6px 10px;
}
.battery-power-source {
font-size: 0.9em;
font-weight: 500;
color: rgba(255, 255, 255, 0.8);
margin: 0px 12px;
}
.battery-section-title {
font-size: 0.9em;
font-weight: 500;
color: rgba(255, 255, 255, 0.9);
margin: 8px 12px 4px 12px;
}
.battery-energy-item {
margin: 4px 12px;
padding: 4px 0;
}
.battery-status-section {
/* background: rgba(255, 255, 255, 0.06); */
/* border-radius: 12px; */
padding: 2px;
/* margin: 8px 0; */
/* border: 1px solid rgba(255, 255, 255, 0.08); */
}
.battery-percentage {
font-size: 16px;
margin-right: 8px;
font-weight: 500;
color: rgba(255, 255, 255, 0.95);
}
#energy-mode-button-clickable {
background: transparent;
border: none;
border-radius: 8px;
padding: 6px 12px;
margin: 1px 0;
}
#energy-mode-button-clickable:hover {
background: rgba(255, 255, 255, 0.1);
}
#energy-mode-icon {
border-radius: 50%;
padding: 4px;
margin-top: -2px;
margin-bottom: -2px;
margin-right: 6px;
margin-left: -6px;
background-color: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.7);
}
#energy-mode-icon.connected {
background-color: #007aff;
color: #ffffff;
}
.battery-power-source {
font-size: 12px;
margin-bottom: -4px;
color: alpha(#ffffff, 0.5);
}
.battery-section-title {
font-size: 14px;
color: alpha(#ffffff, 0.7);
margin: 2px 0 0 8px;
}
.battery-power-mode {
font-size: 13px;
}
.battery-settings-button:hover {
margin-top: -5px;
background: alpha(#ffffff, 0.1);
min-height: 30px;
border-radius: 8px;
}
.battery-settings-button {
margin-top: -5px;
min-height: 30px;
border-radius: 8px;
}
.battery-settings-button label {
font-weight: 500;
color: alpha(#ffffff, 1);
}
#energy-mode-button:first-child {
margin-top: 0;
}
#energy-mode-button {
margin-top: -8px;
margin-bottom: 4px;
color: rgba(255, 255, 255, 0.85);
}
.gamemode-button {
font-size: 12px;
margin-left: -6px;
color: rgba(255, 255, 255, 0.85);
}
#game-mode-button {
margin: 2px 0;
}
#game-mode-icon {
margin-left: -6px;
}
#game-mode-button-clickable {
background: transparent;
border: none;
border-radius: 8px;
padding: 6px 12px;
margin: 1px 0;
/* min-height: 36px; */
}
#game-mode-button-clickable:hover {
background: rgba(255, 255, 255, 0.1);
}
#game-mode-icon {
border-radius: 50%;
padding: 4px;
margin-right: 8px;
background-color: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.7);
}
#game-mode-icon.connected {
background-color: #007aff;
color: #ffffff;
}
================================================
FILE: styles/colors.css
================================================
: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;
}
================================================
FILE: styles/controlcenter.css
================================================
.title {
font-size: 16px;
font-family: "SF Pro Rounded";
font-weight: bold;
}
#control-center-menu {
background-color: transparent;
border-radius: 12px;
box-shadow: none;
margin: 0;
}
#separator {
min-height: 0.09rem;
margin: 3px 0;
background-color: alpha(#fff, 0.2);
border-radius: 8px;
}
/* #bluetooth-control-window { */
/* #wifi-control-window, */
#battery-control-window {
margin: 6px;
/* background-color: alpha(#fff, 0.09); */
border: 1px solid alpha(#111, 0.3);
box-shadow: inset 0 0 200px 0 alpha(#111, 0.3);
border-radius: 15px;
}
#bluetooth-title,
#wifi-title {
font-size: 16px;
font-weight: bold;
}
#control-center-widgets {
background-color: alpha(#010101, 0.01);
box-shadow: inset 0 0 0 1px alpha(#aaa, 0.4);
/* border: 1px solid alpha(#111, 0.4); */
border-radius: 12px;
padding: 0.5rem;
}
#focus-widget {
}
#wb-widget,
#brightness-menu {
background-color: alpha(#000, 0.08);
box-shadow: inset 0 0 0 0.5px alpha(#aaa, 0.4);
border: 1px solid alpha(#111, 0.4);
border-radius: 12px;
}
/* Widgets */
#wifi-widget,
#bluetooth-widget,
#nightlight-widget {
min-width: 140px;
padding: 5px 0 0px 0;
}
.icon {
background-color: #2369ff;
font-size: 15px;
padding: 20px 20px;
}
#bluetooth-widget-label,
#wifi-widget-label,
#nightlight-widget-label {
font-size: 12px;
margin-left: 5px;
font-weight: 500;
color: #999;
}
/* WiFi Password Dialog */
#wifi-password-dialog {
background-color: transparent;
}
#wifi-dialog-background {
background-color: alpha(#fff, 0.05);
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
padding: 20px;
min-width: 450px;
}
#wifi-dialog-title-container {
/* margin-bottom: 12px; */
}
#wifi-dialog-icon {
color: #007aff;
margin-right: 4px;
}
#wifi-dialog-title {
color: #ffffff;
font-size: 14px;
font-weight: 500;
}
#wifi-dialog-error {
color: #ff4444;
font-size: 12px;
font-weight: 500;
/* margin-bottom: 8px; */
}
#wifi-dialog-password-container {
margin: 5px 0;
}
#wifi-dialog-password-label {
color: #ffffff;
font-size: 13px;
font-weight: 500;
margin-bottom: 4px;
}
#wifi-dialog-password-entry {
background-color: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
padding: 8px 12px;
color: #ffffff;
font-size: 13px;
}
#wifi-dialog-password-entry:focus {
border-color: #007aff;
box-shadow: 0 0 0 2px rgba(0, 122, 255, 0.3);
background-color: rgba(255, 255, 255, 0.15);
}
#wifi-dialog-show-password-box {
margin-top: 4px;
}
#wifi-dialog-show-password-button {
background-color: transparent;
border: none;
padding: 4px;
border-radius: 4px;
min-width: 24px;
min-height: 24px;
}
#wifi-dialog-show-password-button:hover {
background-color: alpha(#fff, 0.1);
}
#wifi-dialog-show-password-button image {
color: #ffffff;
}
#wifi-dialog-show-password-label {
color: #ffffff;
font-size: 12px;
}
#wifi-dialog-button-box {
margin-top: -15px;
}
#wifi-dialog-cancel-button,
#wifi-dialog-join-button {
border-radius: 6px;
font-size: 13px;
font-weight: 500;
min-width: 80px;
min-height: 30px;
}
#wifi-dialog-cancel-button {
background-color: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
color: #ffffff;
}
#wifi-dialog-cancel-button:hover {
background-color: rgba(255, 255, 255, 0.15);
}
#wifi-dialog-join-button {
background-color: #007aff;
border: 1px solid #007aff;
color: #ffffff;
}
#wifi-dialog-join-button:hover {
background-color: #0056cc;
border-color: #0056cc;
}
#wifi-dialog-join-button.disabled {
opacity: 0.5;
background-color: #555555;
border-color: #555555;
color: #aaaaaa;
}
#wifi-dialog-join-button.disabled:hover {
background-color: #555555;
border-color: #555555;
color: #aaaaaa;
}
#bluetooth-widget-title {
font-size: 12px;
font-weight: 500;
color: #fff;
}
/* #bluetooth-widget-top { */
/* padding-bottom: 10px; */
/* border-bottom: 1px solid alpha(#aaa, 0.3); */
/* } */
#device-icon {
border-radius: 50%;
padding: 5px;
margin-bottom: 2.5px;
margin-top: 2.5px;
margin-right: 10px;
background-color: #555;
}
#devices-title {
color: alpha(#fff, 0.6);
}
#device-icon.paired {
background-color: #888;
}
#device-icon.connected {
background-color: #2369ff;
}
#toggle-button {
min-width: 40px;
min-height: 20px;
background-color: #bababa;
border-radius: 15px;
padding: 2px;
transition: background-color 0.3s ease;
}
#toggle-button slider {
background-color: #fff;
border-radius: 50%;
min-width: 16px;
min-height: 8px;
transition: background-color 0.1s cubic-bezier(0.5, 0.25, 0, 1.25);
}
#toggle-button:checked {
background-color: #4487f6;
}
/* #toggle-button:checked slider { */
/* background-color: #fff; */
/* } */
/**/
/* #toggle-button:checked image { */
/* opacity: 0; */
/* } */
.menu {
background-color: alpha(#000, 0.08);
box-shadow: inset 0 0 0 0.5px alpha(#aaa, 0.4);
border: 1px solid alpha(#111, 0.4);
border-radius: 12px;
margin: 5px;
/* background-color: alpha(#999, 0.1); */
/* border: 0.5px solid alpha(#000, 0.4); */
/* border-radius: 8px; */
padding: 1rem;
}
.title {
padding: 0;
font-weight: 500;
font-size: 12px;
}
#bluetooth-widget-name,
#wifi-widget-name,
#nightlight-widget-name {
margin-top: 6px;
font-size: 14px;
font-weight: bold;
}
.ct {
margin-left: 5px;
}
#vol-slider-box {
/* margin-bottom: 2px; */
margin-top: -10px;
/* border: 1px solid alpha(#fff, 1); */
}
#volume-widget-slider {
/* margin-bottom: 2px; */
}
#volume-widget-icon {
font-size: 22px;
margin-top: -35px;
margin-right: -36px;
color: #000;
}
#brightness-widget-icon {
font-size: 15px;
margin-top: -27px;
margin-right: -29px;
color: #000;
}
#control-center-menu slider {
background: linear-gradient(135deg, #ffffff 0%, #f0f1f2 100%);
border-color: #000;
min-width: 28px;
min-height: 28px;
margin: -2px;
border-radius: 50%;
/* background-image: none; */
/* background-color: transparent; */
/* min-width: 4px; */
/* min-height: 40px; */
/* margin: -9px; */
}
#control-center-menu slider:hover {
border-color: #999;
min-width: 30px;
min-height: 30px;
margin: -3px;
border-radius: 50%;
/* background-image: none; */
/* background-color: transparent; */
/* min-width: 4px; */
/* min-height: 40px; */
/* margin: -9px; */
}
#control-center-menu scale {
background-color: transparent;
margin-top: 10px;
border-radius: 20px;
}
#control-center-menu trough {
min-width: 25px;
border-radius: 99px;
background-color: alpha(#666, 0.5);
border: 1px solid alpha(#444, 0.3);
}
#control-center-menu highlight {
background: alpha(#fff, 0.8);
border-radius: 99px;
}
#control-center-menu mark indicator {
background: none;
background-image: none;
color: alpha(#fff, 0.2);
}
#control-center-menu mark label {
background: none;
background-image: none;
color: alpha(#fff, 0.2);
}
/* Per-app volume control styles */
#per-app-volume-control {
min-height: 140px;
min-width: 350px;
/* background-color: alpha(#000, 0.3); */
/* background-color: alpha(#999, 0.1); */
/* box-shadow: inset 0 0 200px 0 alpha(#111, 0.3); */
/* border: 0.5px solid alpha(#000, 0.4); */
border-radius: 8px;
padding: 1rem;
/* padding: 1rem; */
}
#apps-scrolled-container {
min-height: 120px;
min-width: 120px;
}
#back-button {
padding: 8px 12px;
background-color: alpha(#fff, 0.1);
border-radius: 8px;
border: none;
color: #fff;
font-size: 13px;
font-weight: 500;
transition: background-color 0.2s ease;
}
#back-button:hover {
background-color: alpha(#fff, 0.18);
}
/* Apple-style app volume items */
.apple-app-volume-item {
margin: 3px 0;
padding: 16px;
background-color: alpha(#fff, 0.08);
border-radius: 12px;
border: 1px solid alpha(#fff, 0.12);
transition: background-color 0.2s ease;
}
.apple-app-volume-item:hover {
background-color: alpha(#fff, 0.12);
}
.apple-app-name {
font-size: 14px;
font-weight: 600;
color: #fff;
margin-bottom: 4px;
letter-spacing: -0.01em;
}
/* Apple-style volume slider */
#apple-volume-slider {
background-color: transparent;
margin-top: 4px;
margin-bottom: 4px;
min-height: 32px;
}
#apple-volume-slider trough {
min-width: 28px;
min-height: 6px;
border-radius: 3px;
background-color: alpha(#fff, 0.25);
border: none;
box-shadow: inset 0 1px 2px alpha(#000, 0.2);
}
#apple-volume-slider highlight {
background: linear-gradient(90deg, #007aff 0%, #0051d0 100%);
border-radius: 3px;
border: none;
box-shadow: 0 1px 3px alpha(#007aff, 0.3);
}
#apple-volume-slider slider {
background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
/* border: 2px solid alpha(#007aff, 0.8); */
border-radius: 50%;
min-width: 20px;
min-height: 20px;
margin: -10px;
box-shadow:
0 2px 8px alpha(#000, 0.15),
0 1px 3px alpha(#000, 0.2);
transition: all 0.15s ease;
}
#apple-volume-slider slider:hover {
background: linear-gradient(135deg, #ffffff 0%, #f0f1f2 100%);
border-color: #007aff;
box-shadow:
0 4px 12px alpha(#000, 0.2),
0 2px 6px alpha(#007aff, 0.3);
min-width: 22px;
min-height: 22px;
margin: -9px;
}
#apple-volume-slider slider:active {
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
min-width: 21px;
min-height: 21px;
margin: -9px;
box-shadow:
0 2px 6px alpha(#000, 0.25),
0 1px 3px alpha(#007aff, 0.4);
}
/* Legacy app volume styles (fallback) */
.app-volume-item {
margin: 8px 0;
padding: 8px;
background-color: alpha(#fff, 0.05);
border-radius: 6px;
border: 1px solid alpha(#fff, 0.1);
}
.app-name {
font-size: 13px;
font-weight: 500;
color: #fff;
margin-bottom: 5px;
}
#app-volume-slider {
background-color: transparent;
margin-top: 5px;
}
#app-volume-slider trough {
min-width: 20px;
border-radius: 99px;
background-color: alpha(#666, 0.5);
border: 1px solid alpha(#444, 0.3);
}
#app-volume-slider highlight {
background: alpha(#0078d4, 0.8);
border-radius: 99px;
}
#app-volume-slider slider {
background-color: #fff;
padding: 2px;
min-width: 4px;
min-height: 18px;
border-radius: 20px;
}
/* Circle button for per-app volume */
#per-app-volume-button {
background-color: alpha(#fff, 0.15);
border: 1px solid alpha(#999, 0.6);
/* min-width: 33px; */
/* min-height: 30px; */
border-radius: 50%;
}
#per-app-volume-button:hover {
background-color: alpha(#fff, 0.25);
/* border-color: #007aff; */
box-shadow: 0 4px 12px alpha(#000, 0.2);
}
#per-app-volume-button:active {
background-color: alpha(#fff, 0.2);
box-shadow: 0 1px 3px alpha(#000, 0.3);
}
#per-app-volume-icon {
background-color: alpha(#fff, 0.15);
border: 2px solid alpha(#999, 0.6);
border-radius: 50%;
min-width: 30px;
}
/* Apple-style seek bar for expanded player */
#apple-seek-bar {
background-color: transparent;
margin-top: 8px;
margin-bottom: 4px;
min-height: 10px;
}
#apple-seek-bar trough {
min-width: 28px;
min-height: 3px;
border-radius: 1.5px;
background-color: alpha(#fff, 0.3);
border: none;
box-shadow: inset 0 1px 1px alpha(#000, 0.1);
}
#apple-seek-bar highlight {
background: #007aff;
border-radius: 1.5px;
border: none;
box-shadow: 0 0 2px alpha(#007aff, 0.4);
}
#apple-seek-bar slider {
background: #ffffff;
border: 1px solid alpha(#007aff, 0.8);
border-radius: 50%;
min-width: 12px;
min-height: 12px;
margin: -4.5px;
box-shadow:
0 1px 3px alpha(#000, 0.2),
0 0 0 1px alpha(#fff, 0.8);
transition: all 0.1s ease;
}
#apple-seek-bar slider:hover {
background: #ffffff;
border-color: #007aff;
min-width: 14px;
min-height: 14px;
margin: -5.5px;
box-shadow:
0 2px 6px alpha(#000, 0.25),
0 0 0 2px alpha(#007aff, 0.3);
}
#apple-seek-bar slider:active {
background: #f8f9fa;
min-width: 13px;
min-height: 13px;
margin: -5px;
box-shadow:
0 1px 3px alpha(#000, 0.3),
0 0 0 2px alpha(#007aff, 0.5);
}
/* macOS-style expanded player - more compact */
#macos-outer-player-box {
min-height: 80px; /* Reduced height */
min-width: 380px; /* Slightly wider to accommodate expanded track info */
/* margin: 10px; */
padding: 10px;
/* background-color: alpha(#000, 0.4); */
}
#macos-main-section {
margin-left: 5px;
}
#macos-album-cover-image image {
min-width: 70px;
min-height: 70px;
border-radius: 9px;
}
#macos-album-image {
min-width: 70px;
min-height: 70px;
border-radius: 9px;
background-position: center;
background-size: cover;
box-shadow: 0 2px 8px alpha(#000, 0.3);
}
#macos-album-image-no {
min-width: 90px;
min-height: 90px;
border-radius: 9px;
margin-left: 12px;
background-position: center;
background-size: cover;
box-shadow: 0 2px 8px alpha(#000, 0.3);
}
#macos-album-image image {
min-width: 70px;
min-height: 70px;
border-radius: 9px;
box-shadow: 0 2px 8px alpha(#000, 0.3);
}
/* Track info container that expands to fill available space */
#macos-track-info {
margin-top: 5px;
margin-left: 15px;
margin-right: 15px;
}
#macos-player-title {
font-weight: 600;
font-size: 16px;
color: #ffffff;
margin-bottom: 2px;
}
#macos-player-artist {
font-weight: 400;
margin-top: -2px;
font-size: 13px;
color: alpha(#999, 0.8);
}
#macos-player-album {
font-weight: 400;
margin-top: -4px;
font-size: 13px;
color: alpha(#999, 0.8);
margin-bottom: 8px;
}
/* macOS seek bar styling */
#macos-seek-bar {
background-color: transparent;
margin: 8px 0 4px 0;
min-height: 10px;
}
#macos-seek-bar trough {
min-width: 200px;
min-height: 2px;
border-radius: 2px;
background-color: alpha(#fff, 0.25);
border: none;
}
#macos-seek-bar highlight {
background: #ffffff;
border-radius: 2px;
border: none;
}
#macos-seek-bar slider {
background: #ffffff;
border: none;
border-radius: 20%;
min-width: 1px;
min-height: 8px;
margin: -3px;
box-shadow: 0 1px 3px alpha(#000, 0.3);
transition: all 0.1s ease;
}
#macos-seek-bar slider:hover {
min-width: 4px;
min-height: 14px;
margin: -5px;
box-shadow: 0 2px 6px alpha(#000, 0.4);
}
#macos-position-label,
#macos-length-label {
font-size: 11px;
color: alpha(#999, 0.9);
font-weight: 400;
}
/* macOS control buttons - more compact */
#macos-button-box {
margin-top: -1px; /* Reduced top margin */
}
#macos-control-button {
background: transparent;
border: none;
border-radius: 50%;
min-width: 28px; /* Slightly smaller */
opacity: 0.7;
min-height: 28px;
padding: 5px; /* Reduced padding */
color: #ffffff;
transition: all 0.15s ease;
}
#macos-control-button:hover {
opacity: 1;
}
#macos-play-button {
background: transparent;
border: none;
border-radius: 50%;
min-width: 36px; /* Slightly smaller */
opacity: 0.7;
min-height: 36px;
padding: 7px; /* Reduced padding */
color: #ffffff;
transition: all 0.15s ease;
}
#macos-play-button:hover {
opacity: 1;
}
/* macOS player switcher dots - compact horizontal layout */
#macos-stack-buttons-box {
margin: 2px 5px; /* Minimal margins */
}
.macos-switcher-dot {
background: alpha(#fff, 0.3);
/* border: none; */
border-radius: 50%;
min-width: 12px; /* Smaller dots */
min-height: 12px;
margin: 2px; /* Reduced margins */
/* transition: all 0.2s ease; */
}
.macos-switcher-dot:hover {
background: alpha(#fff, 0.5);
}
.macos-switcher-dot.active {
background: #ffffff;
box-shadow: 0 0 0 1px alpha(#fff, 0.3); /* Smaller glow */
}
/* Per app-volume-item */
.compact-app-volume-item {
padding-bottom: 5px;
padding-right: 14px;
border-bottom: 1px solid alpha(#fff, 0.1);
}
#app-icon {
margin-top: 8px;
}
.app-name-compact {
font-size: 14px;
font-weight: bold;
margin-top: 8px;
}
#compact-app-volume-slider scale {
margin-right: 8px;
padding-right: 8px;
}
#expanded-seek-bar {
background-color: transparent;
margin: 8px 0 4px 0;
min-height: 10px;
}
#expanded-seek-bar trough {
min-width: 200px;
min-height: 2px;
border-radius: 2px;
background-color: alpha(#fff, 0.25);
border: none;
}
#expanded-seek-bar highlight {
background: #ffffff;
border-radius: 2px;
border: none;
}
#expanded-seek-bar slider {
background: #ffffff;
border: none;
border-radius: 20%;
min-width: 1px;
min-height: 10px;
margin: -3px;
box-shadow: 0 1px 3px alpha(#000, 0.3);
transition: all 0.1s ease;
}
#expanded-seek-bar slider:hover {
min-width: 4px;
min-height: 14px;
margin: -5px;
box-shadow: 0 2px 6px alpha(#000, 0.4);
}
/* macOS-style WiFi interface */
#wifi-connections,
#bluetooth-connections {
/* background-color: rgba(255, 255, 255, 0.08); */
background-color: alpha(#fff, 0.05);
box-shadow: inset 0 0 0 1px alpha(#aaa, 0.4);
border: 1px solid alpha(#111, 0.4);
border-radius: 8px;
/* padding: 0.5rem; */
/* border-radius: 12px; */
padding: 10px;
}
/* WiFi network slots - macOS style */
#wifi-network-slot button {
border-radius: 8px;
padding: 4px 4px;
/* min-width: 280px; */
margin: 2px 0;
}
#wifi-network-slot button:hover {
background-color: rgba(255, 255, 255, 0.1);
}
/* WiFi-specific device icon styles */
#wifi-connections #device-icon {
background-color: rgba(255, 255, 255, 0.1);
}
#wifi-connections #device-icon.connected {
background-color: #007aff;
color: #ffffff;
}
#wifi-network-name {
color: #ffffff;
font-size: 14px;
font-weight: 500;
}
#wifi-lock-icon {
color: rgba(255, 255, 255, 0.6);
margin-left: 8px;
}
/* Other Networks and Settings buttons */
#wifi-other-button {
background: transparent;
border: none;
padding: 8px 6px;
margin-bottom: -12px;
margin-top: -6px;
border-radius: 8px;
/* transition: background-color 0.2s ease; */
}
#wifi-other-button:hover {
background-color: rgba(255, 255, 255, 0.1);
}
#wifi-other-button:last-child {
margin-bottom: 3px;
}
#device-button {
/* background-color: #000; */
border-radius: 8px;
}
.device-slot:hover {
background-color: rgba(255, 255, 255, 0.05);
}
.button-hovered {
background-color: rgba(255, 255, 255, 0.1);
}
#wifi-other-device label,
#wifi-other-button label {
font-size: 12px;
font-weight: bold;
}
#app-volume-header {
font-size: 16px;
padding-left: 5px;
margin-top: -2px;
font-weight: bold;
color: #ffffff;
}
.wifi-icon-box {
background-color: #aaaaaa;
border-radius: 50%;
padding: 2px;
/* margin-right: 8px; */
}
.wifi-icon-box-connected {
background-color: #007aff;
border-radius: 50%;
padding: 2px;
/* margin-right: 8px; */
}
/* #flight-icon { */
/* margin-left: -10px; */
/* } */
.title-widget {
/* margin-top: -14px; */
/* margin-left: 5px; */
font-size: 13px;
font-family: "SF Pro Rounded";
font-weight: bold;
}
.status-label {
font-size: 11px;
font-weight: 500;
color: #aaa;
/* margin-top: -28px; */
/* margin-left: 5px; */
font-family: "SF Pro Rounded";
/* font-weight: bold; */
/* margin-bottom: 2px; */
}
#app-control-box {
min-width: 100px;
}
#caffeine-widget,
#flight-widget {
padding: 0px 0 5px 0;
min-width: 50px;
}
================================================
FILE: styles/dock.css
================================================
#dock {
background-color: alpha(#fff, 0.07);
padding: 4px 4px;
margin: 4px 4px;
border: none;
border-radius: 16px;
transition: all 0.2s cubic-bezier(0.165, 0.84, 0.44, 1);
}
#dock.shown {
transition: all 0.2s cubic-bezier(0.165, 0.84, 0.44, 1);
}
#dock_item_main_container {
transition: all 0.2s cubic-bezier(0.165, 0.84, 0.44, 1);
}
#dock_item.shown:hover #dock_item_main_container {
margin-top: -12px;
}
#dock_item.shown.semi_hovered #dock_item_main_container {
margin-top: -7px;
}
/**/
/* #dock_item.shown.semi_hovered #dock_item_indicator { */
/* margin-top: 11px; */
/* } */
/* #dock_item.shown.activated:hover #dock_item_main_container { */
/* margin-top: -12px; */
/* } */
/**/
/* #dock_item.shown.activated:hover #dock_item_indicator { */
/* margin-top: 16px; */
/* } */
/**/
/* #dock_item.shown.activated #dock_item_main_container { */
/* margin-top: -4px; */
/* } */
#dock_item.shown.activated #dock_item_icon {
opacity: 1;
/* background-color: #01458e; */
}
/* #dock_item.shown.activated #dock_item_indicator { */
/* min-width: 4px; */
/* margin-top: 8px; */
/* background-color: #aac7ff; */
/* } */
#dock_item_icon {
border-radius: 13px;
opacity: 0.7;
transition: all 0.2s cubic-bezier(0.25, 0.1, 0.25, 1);
}
/* #dock_item_indicator { */
/* transition: all 0.15s ease-out; */
/* min-height: 4px; */
/* border-radius: 4px; */
/* } */
#dock_item {
transition: margin 0.25s cubic-bezier(0.25, 0.1, 0.25, 1);
}
#dock_item.shown {
padding-left: 4px;
opacity: 1;
}
#dock_item.shown:first-child {
padding-left: 0px;
}
.dock_separator {
transition: all 0.25s cubic-bezier(0.25, 0.1, 0.25, 1);
min-width: 1.5px;
min-height: 50px;
margin: 0px 4px;
border-radius: 2px;
background-color: #2b5ea7;
}
.dock_separator.hidden {
min-width: 0px;
background-color: transparent;
margin: 0px;
}
/* #dock_item.shown:not(.activated) { */
/* min-width: 0px; */
/* min-height: 0px; */
/* margin-top: 0px; */
/* background-color: transparent; */
/* } */
================================================
FILE: styles/dropdown.css
================================================
#dropdown-menu {
background-color: transparent;
border-radius: 0.75rem;
margin: 0;
}
#dropdown-options {
background-color: alpha(#000, 0.3);
box-shadow: inset 0 0 0 1px alpha(#aaa, 0.4);
border: 1px solid alpha(#111, 0.4);
border-radius: 0.75rem;
padding: 0.5rem;
}
#dropdown-option {
background-color: transparent;
border-radius: 5px;
padding: 0px 10px;
margin: 1px 2px;
transition: all 0ms ease-in-out;
}
#dropdown-option:hover {
transition: all 20ms ease-in-out;
color: #222;
background-color: alpha(#2369ff, 1);
}
#dropdown-option-label {
font-size: 13px;
margin: 0px;
color: #fff;
font-weight: 400;
}
#dropdown-option-label:first-child {
padding-right: 5rem;
}
#dropdown-option:hover #dropdown-option-keybind {
color: #fff;
}
#dropdown-option-keybind {
font-weight: 500;
color: #aaa;
}
/* Divider */
#dropdown-divider-box {
background-color: transparent;
border-radius: 5px;
padding: 0px 10px;
margin: 2px;
}
#dropdown-divider {
border-bottom: 1px solid alpha(#aaa, 0.3);
margin: 2px 0;
}
================================================
FILE: styles/launcher.css
================================================
#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);
}
================================================
FILE: styles/lock.css
================================================
#lockscreen-bg {
background-size: cover;
}
#indicator-box {
margin-right: 10px;
margin-top: 4px;
}
#container-box {
min-height: 50px;
min-width: 250px;
transition: all 0.7s ease-in-out;
}
#password-entry {
background-color: alpha(#fff, 0.4);
border-radius: 20px;
color: white;
min-width: 220px;
min-height: 40px;
padding-right: 5px;
padding-left: 5px;
font-size: 14px;
font-weight: 500;
transition: all 0.7s ease-in-out;
}
#profile-box {
margin-bottom: 10px;
}
#username {
font-size: 21px;
font-weight: bold;
margin-top: 5px;
transition: all 0.7s ease-in-out;
}
#lock-date label {
background-color: transparent;
color: alpha(#fff, 0.6);
border: none;
font-size: 30px;
margin-top: 80px;
font-weight: bold;
}
#lock-clock label {
background-color: transparent;
color: alpha(#fff, 0.6);
border: none;
font-size: 120px;
margin-top: -20px;
font-weight: bold;
}
#face-icon image {
margin-bottom: 10px;
}
#face-icon {
padding-bottom: 10px;
}
#unlock-box {
margin-bottom: 40px;
}
#unlock-text {
font-size: 15px;
color: alpha(#fff, 0.6);
font-weight: bold;
}
================================================
FILE: styles/notification-center.css
================================================
#noti-center-box {
background-color: transparent;
/* border: 1px solid alpha(#111, 0.3); */
border-radius: 20px;
margin-right: 10px;
/* padding: 20px; */
/* margin: 6px; */
}
#noti-clear-button {
/* min-width: 10px; */
background-color: alpha(#1c2328, 0.05);
min-height: 25px;
min-width: 70px;
border: 1px solid #525155;
border-radius: 15px;
margin-top: 10px;
}
#notif-clear-button label {
font-size: 15px;
}
#noti-clear-button:hover {
background-color: alpha(#fff, 0.09);
}
/* Expandable notification group styles */
#notification-group {
margin: 4px 0;
}
/* Single notification (no stacking) */
#single-notification-content {
background-color: alpha(#1c2328, 0.05);
border: 1.5px solid #525155;
border-radius: 12px;
padding: 12px;
transition: all 0.2s ease;
}
#single-notification-content:hover {
background-color: alpha(#999, 0.9);
border: 1.5px solid #525155;
}
/* Stacked notification container */
#notification-stack-container {
background-color: transparent;
margin: 8px 0;
}
/* Bottom shadow layer (deepest) - offset to show stacking from below */
#stack-shadow-bottom {
background-color: alpha(#1c2328, 0.05);
border-top: 1px solid #525155;
border-radius: 12px;
opacity: 1;
min-height: 16px;
margin-left: 20px;
margin-right: -4px;
margin-bottom: -10px;
}
/* Middle shadow layer */
#stack-shadow-middle {
background-color: alpha(#1c2328, 0.05);
border-top: 1px solid #525155;
border-radius: 12px;
opacity: 1;
min-height: 20px;
margin-left: 10px;
margin-right: -2px;
margin-bottom: -14px;
}
/* Main notification content (top layer) */
#stack-main-notification {
background-color: alpha(#1c2328, 0.05);
border: 1.2px solid #525155;
border-radius: 12px;
padding: 12px;
transition: all 0.2s ease;
}
/* Hover effects for stacked notifications */
#notification-stack-container:hover #stack-main-notification {
background-color: alpha(#525155, 0.1);
border: 1px solid #525155;
}
#notification-stack-container:hover #stack-shadow-middle {
opacity: 0.7;
}
#notification-stack-container:hover #stack-shadow-bottom {
opacity: 0.5;
}
/* Collapsed notification state */
#notification-content-collapsed {
background-color: alpha(#000, 0.05);
border: 1px solid #525155;
border-radius: 12px;
padding: 12px;
margin: 2px 0;
transition: all 0.2s ease;
}
#notification-content-collapsed:hover {
background-color: alpha(#525155, 0.1);
border: 1px solid #525155;
}
/* Notification count badge */
#notification-count-label {
background-color: alpha(#fff, 0.2);
color: rgba(255, 255, 255, 0.8);
border-radius: 10px;
padding: 2px 6px;
font-size: 10px;
font-weight: 500;
min-width: 16px;
}
/* Expanded notification group header */
#notification-group-header {
background-color: alpha(#000, 0.05);
border: 1px solid #525155;
border-radius: 12px 12px 0 0;
padding: 8px 12px;
margin: 2px 0 0 0;
}
#notification-group-title {
color: #ffffff;
font-weight: bold;
font-size: 14px;
}
#notification-show-less {
background-color: transparent;
color: rgba(255, 255, 255, 0.7);
border: 1px solid alpha(#fff, 0.2);
border-radius: 6px;
padding: 2px 8px;
font-size: 11px;
margin-right: 8px;
}
#notification-show-less:hover {
background-color: alpha(#999, 0.7);
color: rgba(255, 255, 255, 0.9);
}
#notification-close-all {
background-color: transparent;
color: rgba(255, 255, 255, 0.7);
border: 1px solid alpha(#fff, 0.2);
border-radius: 6px;
padding: 2px 8px;
font-size: 12px;
min-width: 24px;
}
#notification-close-all:hover {
background-color: alpha(#ff5555, 0.2);
color: rgba(255, 255, 255, 0.9);
border-color: alpha(#ff5555, 0.4);
}
/* Expanded notification list */
#notification-group-expanded {
border: 1px solid #525155;
border-top: none;
border-radius: 0 0 12px 12px;
/* background-color: alpha(#000, 0.02); */
}
/* Stacked notification group styles */
.stacked-notification-group {
margin: 4px 0;
}
.stacked-notification-container {
}
.stacked-notification-item {
background-color: alpha(#000, 0.05);
border: 1px solid #525155;
border-radius: 12px;
transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
#notif-clear-btn {
border-radius: 10px;
font-size: 12px;
padding: 2px;
}
#notif-clear-btn:hover {
background-color: var(--surface-bright);
}
#stacked-notification-item:hover {
background-color: alpha(#525155, 0.1);
border: 1px solid #525155;
}
/* Stack count indicator */
#stack-count-indicator {
font-size: 10px;
color: rgba(255, 255, 255, 0.7);
margin: 2px 8px;
font-weight: 500;
}
/* Expanded view styles - notifications use their default styling */
#expanded-notification-list {
margin-top: 8px;
}
/* Make expanded notifications clickable with subtle hover effect */
#expanded-notification-list:hover {
background-color: alpha(#111, 0.9);
border-radius: 8px;
transition: background-color 0.2s ease;
}
#notification-group-title {
font-size: 19px;
margin: 10px;
font-weight: bold;
}
#notification-show-less {
background-color: alpha(#1c2328, 0.05);
font-size: 12px;
margin: 10px;
border-radius: 15px;
}
#notification-close-all {
margin: 10px 10px 10px 0;
background-color: alpha(#1c2328, 0.05);
border-radius: 15px;
}
#notif-close-button {
background-color: transparent;
}
#notification-group-expanded {
background-color: transparent;
border: none;
}
#notification-content {
background-color: transparent;
}
#notification-count-label {
background-color: transparent;
color: white;
font-size: 13px;
}
#notification-centre-notifs {
background-color: alpha(#1c2328, 0.05);
min-height: 40px;
padding: 10px;
border: 1px solid #525155;
border-radius: 12px;
}
#notification-centre-notifs:last-child {
margin-bottom: 10px;
}
#notification-close-header {
margin-right: 10px;
}
#notification-close,
#notification-close-header {
min-width: 23px;
min-height: 23px;
border-radius: 50%;
}
#notification-close:hover {
background-color: alpha(#666, 0.8);
min-width: 23px;
min-height: 23px;
border-radius: 50%;
}
#notification-close-summery:hover .notification-close-header button {
background-color: transparent;
}
================================================
FILE: styles/notification.css
================================================
#notification {
padding: 10px;
background-color: alpha(#fff, 0.03);
border-radius: 8px;
margin-top: 10px;
margin-right: 10px;
/* margin: 5px; */
}
#notification-close-button {
background-color: alpha(#999, 0.3);
border-radius: 8px;
}
#notification-close-button:hover {
background-color: alpha(#2369ff, 0.9);
}
#notification-action-buttons button {
background-color: alpha(#999, 0.3);
padding: 4px;
border-radius: 7px;
transition: background-color 0.1s ease;
}
#notification-action-buttons button:hover {
background-color: alpha(#2369ff, 0.9);
}
#notification-action-buttons button:active {
background-color: var(--primary);
}
#notification-action-buttons button:active #button-label {
color: var(--shadow);
}
#notification-image image {
border-radius: 16px;
color: var(--on-surface);
}
#notification-summary,
#button-label {
}
#notification-summary {
font-weight: bold;
font-size: 16px;
/* color: var(--primary); */
}
#notification-app-name {
color: var(--outline);
font-weight: bold;
}
#action-button {
margin-top: 8px;
}
#notif-close-button {
background-color: var(--surface);
border-radius: 16px;
padding: 4px;
transition: background-color 0.1s ease;
}
#notif-close-button:hover,
#notif-close-button:focus {
background-color: var(--surface-bright);
}
#notif-close-button:active {
background-color: var(--red-dim);
}
#notif-close-label {
color: var(--red-dim);
font-size: 20px;
}
#notif-close-button:active #notif-close-label {
color: var(--shadow);
}
================================================
FILE: styles/osd.css
================================================
#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);
}
================================================
FILE: styles/panel.css
================================================
#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;
}
================================================
FILE: styles/player.css
================================================
#player-stack-button {
border-radius: 5px;
min-width: 7px;
min-height: 7px;
margin: 10px 3px;
}
#button-box-c {
/* margin-left: -10px; */
padding-right: 15px;
}
#player-stack-button:hover {
background-color: #646464;
box-shadow: inset rgba(255, 255, 255, 0.5) 0 0 10px;
}
#player-stack-button.active {
border-radius: 5px;
background-color: rgb(221.25, 221.25, 223.65);
}
#outer-player-box {
min-height: 110px;
min-width: 320px;
border-radius: 10px;
margin: 0 6px;
background-color: alpha(#000, 0.3);
box-shadow: inset 0 0 0 1px alpha(#aaa, 0.4);
}
#outer-player-box-c {
min-height: 55px;
min-width: 245px;
border-radius: 8px;
/* margin: 0 6px; */
/* background-color: alpha(#000, 0.3); */
/* box-shadow: inset 0 0 0 1px alpha(#aaa, 0.4); */
}
#outer-no-player-box-c {
min-height: 55px;
min-width: 310px;
border-radius: 8px;
/* margin: 0 6px; */
/* background-color: alpha(#000, 0.3); */
/* box-shadow: inset 0 0 0 1px alpha(#aaa, 0.4); */
}
#box-c {
background-color: alpha(#000, 0.08);
box-shadow: inset 0 0 0 0.5px alpha(#aaa, 0.4);
border: 1px solid alpha(#111, 0.4);
border-radius: 12px;
margin-top: 0.5rem;
margin-left: 4px;
margin-right: 4px;
margin-bottom: 0.5rem;
min-height: 50px;
min-width: 330px;
/* border-radius: 8px; */
/* margin: 0 6px 10px; */
/* border: 0.5px solid alpha(#000, 0.4); */
/* background-color: alpha(#999, 0.1); */
/* box-shadow: inset 0 0 0 1px alpha(#aaa, 0.4); */
}
#player-box:disabled highlight,
#player-box:disabled progress {
background-color: rgba(205, 214, 244, 0.6);
background-image: none;
}
#player-info-box-c {
margin-left: 10px;
}
#player-info-box {
margin-left: 10px;
}
#player-title-c {
/* margin-top: 18px; */
font-weight: bold;
font-size: 14px;
}
#player-title {
margin-top: 18px;
font-weight: 700;
font-size: 16px;
}
#player-title-no {
font-weight: 700;
margin-left: 120px;
font-size: 16px;
}
#player-app-icon {
margin-bottom: 10px;
}
#player-artist-c {
margin-top: -5px;
color: #999;
font-size: 12px;
}
#player-artist,
#player-album {
font-weight: 500;
font-size: 12px;
}
#player-controls {
margin-top: 5px;
margin-bottom: 15px;
}
.player-icon {
font-size: 16px;
}
.player-button {
padding: 1px;
}
#player-box #player-controls #button-box .player-button:disabled {
color: #5f5f5f;
}
#player-button {
opacity: 0.5;
}
#player-button:hover {
opacity: 0.9;
}
#btn svg {
margin: 0 2px;
}
.album-image-c {
min-width: 50px;
min-height: 50px;
background-position: center;
margin-top: 10px;
margin-bottom: 10px;
background-size: cover;
margin-left: 10px;
border-radius: 5px;
}
.album-image {
min-width: 70px;
min-height: 70px;
background-position: center;
background-size: cover;
margin-left: 12px;
border-radius: 9px;
}
#seek-bar slider {
background-image: none;
background-color: transparent;
padding: 0px;
}
#seek-bar scale {
background-color: transparent;
margin-top: 10px;
border-radius: 10px;
}
#seek-bar trough {
min-width: 10px;
border-radius: 99px;
background-color: alpha(#666, 0.5);
border: 1px solid alpha(#444, 0.3);
}
#seek-bar highlight {
background: alpha(#fff, 0.8);
border-radius: 99px;
}
================================================
FILE: styles/switcher.css
================================================
#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;
}
================================================
FILE: styles/todo.css
================================================
/* 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);
}
================================================
FILE: styles/tray.css
================================================
tooltip {
border: #474747 solid 1px;
border-radius: 0.75rem;
background-color: alpha(#fff, 0.05);
}
@keyframes tooltipShow {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
tooltip > * {
padding: 3px 10px;
border-radius: 0.75rem;
}
menu {
border: #474747 solid 1px;
border-radius: 0.75rem;
transition: all 100ms ease-in-out;
background-color: alpha(#fff, 0.05);
}
menu > menuitem {
padding: 5px 10px;
margin: 1px 2px;
font-size: 13px;
border-bottom: #474747 solid 1px;
}
menu > menuitem:last-child {
border-bottom: none;
}
menu > menuitem:hover {
transition: all 20ms ease-in-out;
font-size: 13px;
border-radius: 4px;
color: #222;
background-color: alpha(#2369ff, 1);
}
menu > menuitem:hover > label {
color: #fff;
font-weight: 400;
}
================================================
FILE: styles/widgets.css
================================================
/* Weather Widget CSS */
#weather-container {
background: linear-gradient(to bottom, #202020, #141414);
margin: 5px 5px 20px 5px;
min-width: 170px;
min-height: 170px;
border-radius: 16px;
transition: background 0.8s ease-in-out;
}
#weather-widget label {
margin-left: 12px;
}
#city {
margin-top: 10px;
color: var(--blue-dim);
font-weight: 600;
font-size: 20px;
}
#temperature {
font-size: 50px;
font-weight: 400;
}
#condition-emoji {
margin-top: 5px;
font-size: 16px;
}
#condition {
font-size: 15px;
font-weight: 600;
}
#feels-like {
font-weight: 600;
font-size: 15px;
}
/* # */
/* Calendar Widget */
#calendar-box-widget {
background: linear-gradient(to bottom, #202020, #141414);
margin: 5px 20px 20px 10px;
min-width: 170px;
min-height: 170px;
border-radius: 16px;
}
#calendar-widget {
margin: 5px 10px 0px 10px;
}
#calendar-month {
font-size: 16px;
font-weight: 600;
color: var(--blue-dim);
margin-bottom: 6px;
}
#calendar-days-header {
margin-bottom: 4px;
}
#calendar-day-header {
font-size: 12px;
font-weight: 600;
color: #ffffff;
min-width: 18px;
min-height: 10px;
}
#calendar-day-header-weekend {
font-size: 12px;
font-weight: 600;
color: #8e8d93;
min-width: 18px;
min-height: 10px;
}
#calendar-day {
font-size: 12px;
margin: 1.4px 0px 1.4px 0px;
font-weight: 500;
color: #ffffff;
min-width: 18px;
min-height: 10px;
}
#calendar-day-weekend {
font-size: 12px;
font-weight: 500;
color: #8e8d93;
min-width: 18px;
min-height: 16px;
}
#calendar-day-today {
font-size: 12px;
font-weight: 700;
color: #191919;
background-color: var(--blue-dim);
border-radius: 50%;
min-width: 18px;
min-height: 16px;
padding: 2px;
}
#calendar-day-empty {
min-width: 18px;
min-height: 16px;
}
#calendar-grid {
margin-top: 2px;
}
/* Date Widget CSS */
#date-widget label {
margin-top: 10px;
}
#day label,
#month label {
color: var(--foreground);
padding: 5px;
margin-top: 10px;
font-size: 30px;
color: alpha(#fff, 0.9);
font-weight: 600;
}
#date label {
color: alpha(#fff, 0.9);
margin-top: -10px;
font-size: 90px;
font-weight: 600;
}
#day label {
color: alpha(var(--blue-dim), 0.9);
}
#date-container {
background: linear-gradient(to bottom, #202020, #141414);
margin: 5px 10px 20px 5px;
min-width: 170px;
min-height: 170px;
border-radius: 16px;
}
/* System Info Widget CSS */
#info-box-widget {
background: linear-gradient(to bottom, #202020, #141414);
margin: 5px 5px 5px 5px;
min-width: 170px;
min-height: 170px;
border-radius: 16px;
}
#ram-progress {
margin-left: 12px;
color: var(--foreground);
}
#progress-label {
font-weight: 600;
font-size: 13px;
}
#progress {
border: solid 8px var(--blue-dim);
color: alpha(#aaaaaa, 0.7);
}
/* Color indicators for memory usage */
#used-color-indicator {
color: var(--blue-dim);
font-size: 10px;
font-weight: 900;
}
#free-color-indicator {
color: #7c7c7c;
font-size: 10px;
font-weight: 900;
}
#temp-color-indicator {
color: var(--blue-dim);
font-size: 10px;
font-weight: 900;
}
/* Info text styling */
#info-text {
font-size: 13px;
font-weight: 500;
color: #8e8e8e;
min-width: 32px;
}
#info-value {
font-size: 12px;
font-weight: 600;
color: #ffffff;
}
#info-container {
margin-left: -8px;
margin-top: 5px;
}
#info {
margin-left: -13px;
margin-top: 5px;
font-size: 14px;
font-weight: 600;
}
#component-name {
font-size: 10px;
}
================================================
FILE: utils/__init__.py
================================================
"""
Modus services package.
Contains background services and utilities for the shell.
"""
================================================
FILE: utils/animator.py
================================================
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()
================================================
FILE: utils/app_name_resolver.py
================================================
import os
from utils.roam import modus_service
class AppName:
def __init__(self, path="/usr/share/applications"):
self.files = os.listdir(path)
self.path = path
def get_app_name(self, wmclass, format_=False):
desktop_file = ""
for f in self.files:
if f.startswith(wmclass + ".desktop"):
desktop_file = f
desktop_app_name = wmclass
if desktop_file == "":
return wmclass
with open(os.path.join(self.path, desktop_file), "r") as f:
lines = f.readlines()
for line in lines:
if line.startswith("Name="):
desktop_app_name = line.split("=")[1].strip()
break
return desktop_app_name
def get_app_exec(self, wmclass, format_=False):
desktop_file = ""
for f in self.files:
if f.startswith(wmclass + ".desktop"):
desktop_file = f
desktop_app_name = wmclass
if desktop_file == "":
return wmclass
with open(os.path.join(self.path, desktop_file), "r") as f:
lines = f.readlines()
for line in lines:
if line.startswith("Exec="):
desktop_app_name = line.split("=")[1].strip()
break
return desktop_app_name
def get_desktop_file(self, wmclass):
desktop_file = ""
for f in self.files:
if f.startswith(wmclass + ".desktop"):
desktop_file = f
return desktop_file
def format_app_name(self, title, wmclass, update=False):
# Handle case when both title and wmclass are empty (no active window)
if not title and not wmclass:
name = "Finder"
else:
name = wmclass
if name == "":
name = title
# Try to get the proper app name from desktop file only if wmclass is not empty
if wmclass:
name = self.get_app_name(wmclass=wmclass)
# Smart title formatting (capitalize first letter)
name = str(name).title()
if "." in name:
name = name.split(".")[-1]
if update:
modus_service.current_active_app_name = name
return name
# Create a global instance for use across modules
app_name_resolver = AppName()
def format_window(title, wmclass):
# Handle the case when HyprlandActiveWindow passes "unknown" instead of empty strings
if (not title or title == "unknown") and (not wmclass or wmclass == "unknown"):
return "Finder"
# Clean up "unknown" values
if title == "unknown":
title = ""
if wmclass == "unknown":
wmclass = ""
name = app_name_resolver.format_app_name(title, wmclass, True)
return name
================================================
FILE: utils/conversion.py
================================================
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from typing import Dict, Optional, Tuple
import requests
class CurrencyCache:
"""Thread-safe currency exchange rate cache with background updates."""
def __init__(self):
# {base_currency: {rates_data, timestamp}}
self._cache: Dict[str, Dict] = {}
self._cache_lock = threading.Lock()
self._cache_ttl = 3600 # 1 hour in seconds
self._request_timeout = 2 # 2 seconds for faster response
self._executor = ThreadPoolExecutor(
max_workers=2, thread_name_prefix="currency"
)
self._pending_requests: Dict[str, threading.Event] = {}
def get_rate(self, from_code: str, to_code: str) -> Optional[Tuple[float, float]]:
"""
Get exchange rate from cache or fetch if needed.
Returns (rate, cache_age_seconds) or None if unavailable.
"""
from_lower = from_code.lower()
to_lower = to_code.lower()
if from_lower == to_lower:
return 1.0, 0.0
current_time = time.time()
with self._cache_lock:
# Check if we have fresh cached data
if from_lower in self._cache:
cache_entry = self._cache[from_lower]
cache_age = current_time - cache_entry["timestamp"]
if cache_age < self._cache_ttl and to_lower in cache_entry["rates"]:
rate = cache_entry["rates"][to_lower]["rate"]
return rate, cache_age
# Check if we're already fetching this currency
if from_lower in self._pending_requests:
# Don't wait, return cached data if available (even if stale)
if (
from_lower in self._cache
and to_lower in self._cache[from_lower]["rates"]
):
cache_entry = self._cache[from_lower]
cache_age = current_time - cache_entry["timestamp"]
rate = cache_entry["rates"][to_lower]["rate"]
return rate, cache_age
return None
# Start background fetch
self._pending_requests[from_lower] = threading.Event()
# Submit background task to fetch rates
self._executor.submit(self._fetch_rates_background, from_lower)
# Return stale cached data if available
with self._cache_lock:
if (
from_lower in self._cache
and to_lower in self._cache[from_lower]["rates"]
):
cache_entry = self._cache[from_lower]
cache_age = current_time - cache_entry["timestamp"]
rate = cache_entry["rates"][to_lower]["rate"]
return rate, cache_age
return None
def _fetch_rates_background(self, from_code: str):
"""Fetch exchange rates in background thread."""
try:
url = f"https://www.floatrates.com/daily/{from_code}.json"
response = requests.get(url, timeout=self._request_timeout)
if response.status_code == 200:
rates_data = response.json()
current_time = time.time()
with self._cache_lock:
self._cache[from_code] = {
"rates": rates_data,
"timestamp": current_time,
}
except Exception as e:
print(f"Background currency fetch failed for {from_code}: {e}")
finally:
# Mark request as complete
with self._cache_lock:
if from_code in self._pending_requests:
self._pending_requests[from_code].set()
del self._pending_requests[from_code]
def cleanup(self):
"""Cleanup resources."""
self._executor.shutdown(wait=False)
with self._cache_lock:
self._cache.clear()
self._pending_requests.clear()
# Global currency cache instance
_currency_cache = CurrencyCache()
class Units:
def __init__(self):
self.WEIGHT_CHART: dict[str, tuple[float, float]] = {
"kilogram": (1, 1),
"kg": (1, 1),
"tonne": (1000, 0.001),
"ton": (1000, 0.001),
"gram": (1e-3, 1e3),
"g": (1e-3, 1e3),
"milligram": (1e-6, 1e6),
"mg": (1e-6, 1e6),
"metric-ton": (1000, 0.001),
"metric-tonne": (1000, 0.001),
"long-ton": (1016.04608, 0.0009842073),
"short-ton": (907.184, 0.0011023122),
"pound": (0.453592, 2.2046244202),
"lb": (0.453592, 2.2046244202),
"stone": (6.35029, 0.1574731728),
"st": (6.35029, 0.1574731728),
"ounce": (0.0283495, 35.273990723),
"oz": (0.0283495, 35.273990723),
"carrat": (0.0002, 5000),
"ct": (0.0002, 5000),
"atomic-mass-unit": (1.660540199e-27, 6.022136652e26),
}
self.LENGTH_CHART: dict[str, float] = {
# meter
"m": 1,
"M": 1,
"meter": 1,
# kilometer
"km": 1e3,
"KM": 1e3,
"kilometer": 1e3,
# centimeter
"cm": 1e-2,
"CM": 1e-2,
"centimeter": 1e-2,
# millimeter
"mm": 1e-3,
"MM": 1e-3,
"millimeter": 1e-3,
# micrometer
"um": 1e-6,
"UM": 1e-6,
"micrometer": 1e-6,
# nanometer
"nm": 1e-9,
"NM": 1e-9,
"nanometer": 1e-9,
# mile
"mi": 1609.344,
"MI": 1609.344,
"mile": 1609.344,
# yard
"yd": 0.9144,
"YD": 0.9144,
"yard": 0.9144,
# foot
"ft": 0.3048,
"FT": 0.3048,
"foot": 0.3048,
"feet": 0.3048,
# inch
"in": 0.0254,
"IN": 0.0254,
"inch": 0.0254,
"inches": 0.0254,
# nautical mile
"nmi": 1852,
"NMI": 1852,
"nautical-mile": 1852,
}
self.STORAGE_TYPE_CHART: dict[str, float] = {
"bit": 1,
"byte": 8,
"B": 8,
"kilobyte": 8192,
"KB": 8192,
"megabyte": 8388608,
"MB": 8388608,
"gigabyte": 8589934592,
"GB": 8589934592,
"terabyte": 8796093022208,
"TB": 8796093022208,
"petabyte": 9007199254740992,
"PB": 9007199254740992,
"exabyte": 9223372036854775808,
"EB": 9223372036854775808,
}
self.TEMPERATURE_CHART = {
"celsius": (lambda v: v + 273.15, lambda v: v - 273.15),
"c": (lambda v: v + 273.15, lambda v: v - 273.15),
"fahrenheit": (
lambda v: (v - 32) * 5 / 9 + 273.15,
lambda v: (v - 273.15) * 9 / 5 + 32,
),
"f": (
lambda v: (v - 32) * 5 / 9 + 273.15,
lambda v: (v - 273.15) * 9 / 5 + 32,
),
"kelvin": (lambda v: v, lambda v: v),
"k": (lambda v: v, lambda v: v),
"rankine": (lambda v: v * 5 / 9, lambda v: v * 9 / 5),
"reaumur": (lambda v: v * 5 / 4 + 273.15, lambda v: (v - 273.15) * 4 / 5),
}
self.TIME_CHART: dict[str, float] = {
"second": 1,
"s": 1,
"minute": 60,
"min": 60,
"m": 60,
"hour": 3600,
"h": 3600,
"milisecond": 1e-3,
"ms": 1e-3,
"day": 86400,
"d": 86400,
"week": 604800,
"w": 604800,
"fortnight": 1209600,
"month": 2628000, # Approximation (30.44 days)
"mo": 2628000, # Approximation (30.44 days)
"year": 31536000, # Approximation (365 days)
"yr": 31536000, # Approximation (365 days)
"decade": 315360000, # Approximation (10 years)
"dec": 315360000, # Approximation (10 years)
"century": 3153600000, # Approximation (100 years)
"cent": 3153600000, # Approximation (100 years)
"millennium": 31536000000, # Approximation (1000 years)
"millenia": 31536000000, # Approximation (1000 years)
}
self.LIQUID_VOLUME_CHART: dict[str, float] = {
"liter": 1,
"l": 1,
"milliliter": 1e-3,
"ml": 1e-3,
"gallon": 3.78541,
"quart": 0.946353,
"pint": 0.473176,
"fluid-ounce": 0.0295735,
"fl-oz": 0.0295735,
"oz": 0.0295735,
"ounce": 0.0295735,
"cup": 0.236588,
"tablespoon": 0.0147868,
"tbsp": 0.0147868,
"teaspoon": 0.00492892,
"tsp": 0.00492892,
}
self.ANGLE_CHART: dict[str, float] = {
"degree": 1,
"deg": 1,
"radian": 57.2958,
"rad": 57.2958,
"gradian": 0.9,
"gon": 0.9,
}
self.ENERGY_CHART: dict[str, float] = {
"joule": 1,
"j": 1,
"kilojoule": 1000,
"kj": 1000,
"calorie": 4.184,
"cal": 4.184,
"kilocalorie": 4184,
"kcal": 4184,
"watt-hour": 3600,
"wh": 3600,
"kilowatt-hour": 3.6e6,
"kwh": 3.6e6,
}
self.SPEED_CHART: dict[str, float] = {
"mps": 1,
"kmph": 0.277778,
"mph": 0.44704,
"fps": 0.3048,
"knot": 0.514444,
}
self.PRESSURE_CHART: dict[str, float] = {
"pascal": 1,
"Pa": 1,
"bar": 100000,
"atm": 101325,
"torr": 133.322,
"mmHg": 133.322,
"psi": 6894.76,
}
self.FORCE_CHART: dict[str, float] = {
"newton": 1,
"N": 1,
"kilonewton": 1000,
"kN": 1000,
"pound-force": 4.44822,
"lbf": 4.44822,
"dyne": 1e-5,
}
self.POWER_CHART: dict[str, float] = {
"watt": 1,
"W": 1,
"kilowatt": 1000,
"kW": 1000,
"horsepower": 745.7,
"hp": 745.7,
"megawatt": 1e6,
"MW": 1e6,
}
self.VOLTAGE_CHART: dict[str, float] = {
"volt": 1,
"V": 1,
"millivolt": 1e-3,
"mV": 1e-3,
"kilovolt": 1000,
"kV": 1000,
"megavolt": 1e6,
"MV": 1e6,
}
self.CURRENT_CHART: dict[str, float] = {
"ampere": 1,
"A": 1,
"milliampere": 1e-3,
"mA": 1e-3,
"microampere": 1e-6,
"μA": 1e-6,
}
self.RESISTANCE_CHART: dict[str, float] = {
"ohm": 1,
"Ω": 1,
"kilohm": 1000,
"kΩ": 1000,
"megohm": 1e6,
"MΩ": 1e6,
}
self.CAPACITANCE_CHART: dict[str, float] = {
"farad": 1,
"F": 1,
"millifarad": 1e-3,
"mF": 1e-3,
"microfarad": 1e-6,
"μF": 1e-6,
"nanofarad": 1e-9,
"nF": 1e-9,
}
self.INDUCTANCE_CHART: dict[str, float] = {
"henry": 1,
"H": 1,
"millihenry": 1e-3,
"mH": 1e-3,
"microhenry": 1e-6,
"μH": 1e-6,
"nanohenry": 1e-9,
"nH": 1e-9,
}
self.FREQUENCY_CHART: dict[str, float] = {
"hertz": 1,
"Hz": 1,
"kilohertz": 1e3,
"kHz": 1e3,
"megahertz": 1e6,
"MHz": 1e6,
"gigahertz": 1e9,
"GHz": 1e9,
}
self.LUMINANCE_CHART: dict[str, float] = {
"candela": 1,
"cd": 1,
"lumen": 1,
"lm": 1,
"lux": 1,
"lx": 1,
}
self.AREA_CHART: dict[str, float] = {
"square-meter": 1,
"m2": 1,
"square-kilometer": 1e6,
"km2": 1e6,
"hectare": 1e4,
"ha": 1e4,
"are": 1e2,
"a": 1e2,
"square-centimeter": 1e-4,
"cm2": 1e-4,
"square-millimeter": 1e-6,
"mm2": 1e-6,
}
# We no longer use currency_converter here.
class Conversion:
def __init__(self):
self.units = Units()
self.currency_cache = _currency_cache
def convert(self, value: float, from_type: str, to_type: str):
"""
Generalized conversion function that works with all categories,
including currency via floatrates.com.
"""
# Collection of all non-currency charts
charts = {
"WEIGHT_CHART": self.units.WEIGHT_CHART,
"LENGTH_CHART": self.units.LENGTH_CHART,
"TEMPERATURE_CHART": self.units.TEMPERATURE_CHART,
"TIME_CHART": self.units.TIME_CHART,
"LIQUID_VOLUME_CHART": self.units.LIQUID_VOLUME_CHART,
"STORAGE_TYPE_CHART": self.units.STORAGE_TYPE_CHART,
"ANGLE_CHART": self.units.ANGLE_CHART,
"ENERGY_CHART": self.units.ENERGY_CHART,
"SPEED_CHART": self.units.SPEED_CHART,
"PRESSURE_CHART": self.units.PRESSURE_CHART,
"FORCE_CHART": self.units.FORCE_CHART,
"POWER_CHART": self.units.POWER_CHART,
"VOLTAGE_CHART": self.units.VOLTAGE_CHART,
"CURRENT_CHART": self.units.CURRENT_CHART,
"RESISTANCE_CHART": self.units.RESISTANCE_CHART,
"CAPACITANCE_CHART": self.units.CAPACITANCE_CHART,
"INDUCTANCE_CHART": self.units.INDUCTANCE_CHART,
"FREQUENCY_CHART": self.units.FREQUENCY_CHART,
"LUMINANCE_CHART": self.units.LUMINANCE_CHART,
"AREA_CHART": self.units.AREA_CHART,
}
# 1) Check if it's in any of the charts (non-currency)
for chart_name, chart in charts.items():
if from_type in chart and to_type in chart:
# Temperatures use lambdas
if chart_name == "TEMPERATURE_CHART":
if from_type == to_type:
return value
to_kelvin = chart[from_type][0]
from_kelvin = chart[to_type][1]
return from_kelvin(to_kelvin(value))
# Handle WEIGHT_CHART separately (tuple values)
if chart_name == "WEIGHT_CHART":
if from_type == to_type:
return value
to_kg = chart[from_type][0]
from_kg = chart[to_type][1]
return value * to_kg * from_kg
# Any other numeric chart
if from_type == to_type:
return value
return value * (chart[from_type] / chart[to_type])
# 2) If both are currency codes (e.g. “USD”, “ARS”)
# we assume they are uppercase and have 3 letters.
if (
len(from_type) == 3
and len(to_type) == 3
and from_type.isalpha()
and to_type.isalpha()
):
result = self._convert_currency_fast(value, from_type, to_type)
if result is not None:
return result
# Fallback to slow method if fast method fails
return self._convert_currency_via_floatrates(value, from_type, to_type)
# 3) If it doesn't fall into any case, error.
raise ValueError(f"Unsupported conversion: {from_type} to {to_type}")
def _convert_currency_fast(
self, value: float, from_code: str, to_code: str
) -> Optional[float]:
"""
Fast currency conversion using cached exchange rates.
Returns None if rate is not available in cache.
"""
rate_info = self.currency_cache.get_rate(from_code, to_code)
if rate_info is not None:
rate, _ = rate_info # cache_age not needed here
return value * rate
return None
def _convert_currency_via_floatrates(
self, value: float, from_code: str, to_code: str
) -> float:
"""
Converts using the JSON from floatrates.com:
- Makes GET to https://www.floatrates.com/daily/{from_lower}.json
- Takes the rate from the to_lower key and multiplies.
"""
from_lower = from_code.lower()
to_lower = to_code.lower()
if from_lower == to_lower:
return value
url = f"https://www.floatrates.com/daily/{from_lower}.json"
resp = requests.get(url, timeout=5)
if resp.status_code != 200:
raise ValueError(f"Error getting data from floatrates for {from_code}")
data = resp.json()
if to_lower not in data:
raise ValueError(
f"Target currency '{to_code}' not found in floatrates response for '{
from_code
}'"
)
rate = data[to_lower]["rate"]
return value * rate
def parse_input_and_convert(self, input: str):
parts = input.split()
addition = "s" if parts[-1].endswith("s") else ""
if "and" in parts: # value unit1 and value2 unit2 _ to target_unit
parts.remove("and")
if len(parts) != 6:
raise ValueError(
"Invalid format. Expected: 'value from_type and value2 from_type2 _ to_type'"
)
value1, from_type1, value2, from_type2, _, to_type = parts
value1, value2 = float(value1), float(value2)
from_type1 = self.clean_type(from_type1)
from_type2 = self.clean_type(from_type2)
to_type = self.clean_type(to_type)
if from_type1 == from_type2:
return (
self.convert(value1 + value2, from_type1, to_type),
to_type + addition,
)
else:
res = 0
res += self.convert(value1, from_type1, to_type)
res += self.convert(value2, from_type2, to_type)
return res, to_type + addition
else:
if len(parts) != 4:
raise ValueError(
"Invalid format. Expected: 'value from_type _ to_type'"
)
value, from_type, _, to_type = parts
value = float(value)
from_type = self.clean_type(from_type)
to_type = self.clean_type(to_type)
return self.convert(value, from_type, to_type), to_type + addition
def clean_type(self, type: str) -> str:
"""
If it's currency (3 letters), convert to uppercase.
If it ends in 's' (and is not 'celsius'), remove the 's' for
other units."""
if len(type) == 3 and type.isalpha():
return type.upper()
if type.endswith("s") and type.lower() != "celsius":
# For tables that have singular/plural
singular = type[:-1].lower()
# If it exists in STORAGE_TYPE_CHART, we use it;
# if not, we return singular in lowercase for other charts.
if singular in self.units.STORAGE_TYPE_CHART:
return singular
return singular.lower()
return type
def cleanup(self):
"""Cleanup resources."""
self.currency_cache.cleanup()
def get_currency_cache_info(
self, from_code: str, to_code: str
) -> Optional[Tuple[bool, float]]:
"""
Get currency cache information for UI display.
Returns (is_fresh, cache_age_seconds) or None if not cached.
"""
rate_info = self.currency_cache.get_rate(from_code, to_code)
if rate_info is not None:
_, cache_age = rate_info # rate not needed here
is_fresh = cache_age < 300 # Consider fresh if less than 5 minutes old
return is_fresh, cache_age
return None
# Quick usage example:
if __name__ == "__main__":
conv = Conversion()
# Convert 10 USD to ARS:
result, suffix = conv.parse_input_and_convert("10 USD _ ARS")
print(f"{result:.2f} {suffix}") # Ex: "10 USD _ ARS" -> "38754.23 ARS"
================================================
FILE: utils/functions.py
================================================
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)
================================================
FILE: utils/icon_resolver.py
================================================
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
)
================================================
FILE: utils/inhibit.py
================================================
# From https://github.com/stwa/wayland-idle-inhibitor
# License: WTFPL Version 2
import argparse
import os
import subprocess
import sys
from dataclasses import dataclass
from signal import SIGINT, SIGTERM, signal
from threading import Event, Timer
import setproctitle
from pywayland.client.display import Display
from pywayland.protocol.idle_inhibit_unstable_v1.zwp_idle_inhibit_manager_v1 import (
ZwpIdleInhibitManagerV1,
)
from pywayland.protocol.wayland.wl_compositor import WlCompositor
from pywayland.protocol.wayland.wl_registry import WlRegistryProxy
from pywayland.protocol.wayland.wl_surface import WlSurface
@dataclass
class GlobalRegistry:
surface: WlSurface | None = None
inhibit_manager: ZwpIdleInhibitManagerV1 | None = None
def parse_duration(duration_str: str) -> int:
"""Parse duration string into seconds.
Examples: '1h', '30m', '45s', '1.5h', '1.5m', '30.5s', 'on', 'off'
"""
try:
if duration_str.lower() in ["on", "off"]:
return 0
elif duration_str.endswith("h"):
return int(float(duration_str[:-1]) * 3600)
elif duration_str.endswith("m"):
return int(float(duration_str[:-1]) * 60)
elif duration_str.endswith("s"):
return int(float(duration_str[:-1]))
else:
return int(duration_str)
except ValueError:
raise ValueError(
"Invalid duration format. Use '1h', '30m', '45s', 'on', 'off', etc."
)
def kill_existing_inhibit_processes():
"""Kill any existing modus-inhibit processes."""
try:
# Find all modus-inhibit processes except the current one
output = subprocess.check_output(["pgrep", "-f", "modus-inhibit"], text=True)
pids = output.strip().split("\n")
current_pid = str(os.getpid())
killed_count = 0
for pid in pids:
pid = pid.strip()
if pid and pid != current_pid:
try:
subprocess.run(["kill", pid], check=True)
killed_count += 1
except subprocess.CalledProcessError:
pass # Process might have already exited
if killed_count > 0:
print(f"Stopped {killed_count} existing inhibit process(es)")
else:
print("No existing inhibit processes were running")
return killed_count
except subprocess.CalledProcessError:
# No processes found
print("No existing inhibit processes were running")
return 0
except Exception as e:
print(f"Error stopping existing processes: {e}")
return 0
def handle_registry_global(
wl_registry: WlRegistryProxy, id_num: int, iface_name: str, version: int
) -> None:
global_registry: GlobalRegistry = wl_registry.user_data or GlobalRegistry()
if iface_name == "wl_compositor":
compositor = wl_registry.bind(id_num, WlCompositor, version)
global_registry.surface = compositor.create_surface() # type: ignore
elif iface_name == "zwp_idle_inhibit_manager_v1":
global_registry.inhibit_manager = wl_registry.bind(
id_num, ZwpIdleInhibitManagerV1, version
)
def main() -> None:
parser = argparse.ArgumentParser(
description="Inhibit system idle for a specified duration"
)
parser.add_argument(
"duration",
nargs="?",
default="0",
help='Duration to inhibit (e.g., "1h", "30m", "45s", "on", "off"). Use "on" for indefinite, "off" to stop.',
)
args = parser.parse_args()
# Handle "off" argument - kill existing processes and exit
if args.duration.lower() == "off":
kill_existing_inhibit_processes()
sys.exit(0)
done = Event()
signal(SIGINT, lambda _, __: done.set())
signal(SIGTERM, lambda _, __: done.set())
global_registry = GlobalRegistry()
try:
display = Display()
display.connect()
registry = display.get_registry() # type: ignore
registry.user_data = global_registry
registry.dispatcher["global"] = handle_registry_global
def shutdown() -> None:
display.dispatch()
display.roundtrip()
display.disconnect()
display.dispatch()
display.roundtrip()
if global_registry.surface is None:
print("Error: Failed to create Wayland surface.")
shutdown()
sys.exit(1)
if global_registry.inhibit_manager is None:
print("Error: Your Wayland compositor does not support idle inhibition.")
print("This usually means either:")
print(
"1. Your compositor (like Hyprland) doesn't support the idle-inhibit protocol"
)
print("2. The protocol is not enabled in your compositor")
print("\nFor Hyprland, you can enable it by adding to your config:")
print("misc:disable_autoreload = true")
print("misc:enable_wayland_protocols = true")
shutdown()
sys.exit(1)
inhibitor = global_registry.inhibit_manager.create_inhibitor( # type: ignore
global_registry.surface
)
display.dispatch()
display.roundtrip()
duration = parse_duration(args.duration)
if duration > 0:
print(f"Inhibiting idle for {args.duration}...")
# Set up timer to release inhibition
timer = Timer(duration, lambda: done.set())
timer.start()
else:
print("Inhibiting idle indefinitely...")
done.wait()
print("Shutting down...")
inhibitor.destroy()
if duration > 0:
timer.cancel()
shutdown()
except Exception as e:
print(f"Error: {str(e)}")
print("Make sure you're running this under a Wayland session.")
sys.exit(1)
if __name__ == "__main__":
setproctitle.setproctitle("modus-inhibit")
main()
================================================
FILE: utils/monitors.py
================================================
import json
import warnings
from typing import Dict
import time
from fabric.hyprland import Hyprland
from gi.repository import Gdk
from functools import lru_cache
warnings.filterwarnings("ignore", category=DeprecationWarning)
def ttl_lru_cache(seconds_to_live: int, maxsize: int = 128):
def wrapper(func):
@lru_cache(maxsize)
def inner(__ttl, *args, **kwargs):
return func(*args, **kwargs)
return lambda *args, **kwargs: inner(
time.time() // seconds_to_live, *args, **kwargs
)
return wrapper
class HyprlandWithMonitors(Hyprland):
"""A Hyprland class with additional monitor common."""
instance = None
@staticmethod
def get_default():
if HyprlandWithMonitors.instance is None:
HyprlandWithMonitors.instance = HyprlandWithMonitors()
return HyprlandWithMonitors.instance
def __init__(self, commands_only: bool = False, **kwargs):
self.display: Gdk.Display = Gdk.Display.get_default()
super().__init__(commands_only, **kwargs)
@ttl_lru_cache(100, 5)
def get_all_monitors(self) -> Dict:
monitors = json.loads(self.send_command("j/monitors").reply)
return {monitor["id"]: monitor["name"] for monitor in monitors}
def get_gdk_monitor_id_from_name(self, plug_name: str) -> int | None:
for i in range(self.display.get_n_monitors()):
if self.display.get_default_screen().get_monitor_plug_name(i) == plug_name:
return i
return None
def get_gdk_monitor_id(self, hyprland_id: int) -> int | None:
monitors = self.get_all_monitors()
if hyprland_id in monitors:
return self.get_gdk_monitor_id_from_name(monitors[hyprland_id])
return None
def get_current_gdk_monitor_id(self) -> int | None:
active_workspace = json.loads(self.send_command("j/activeworkspace").reply)
return self.get_gdk_monitor_id_from_name(active_workspace["monitor"])
================================================
FILE: utils/occlusion.py
================================================
import json
import subprocess
import config.data as data
def get_current_workspace():
"""
Get the current workspace ID using hyprctl.
"""
try:
result = subprocess.run(
["hyprctl", "activeworkspace"], capture_output=True, text=True
)
# Assume the output similar to: "ID "
# Extracting the number from the output
parts = result.stdout.split()
for i, part in enumerate(parts):
if part == "ID" and i + 1 < len(parts):
return int(parts[i + 1])
except Exception as e:
print(f"Error getting current workspace: {e}")
return -1
def get_screen_dimensions():
"""
Get screen dimensions from hyprctl.
Returns:
tuple: (width, height) of the monitor containing the current workspace
"""
try:
# Get current workspace
workspace_id = get_current_workspace()
# Get monitor information
result = subprocess.run(
["hyprctl", "-j", "monitors"], capture_output=True, text=True
)
monitors = json.loads(result.stdout)
# Find the monitor containing our workspace
for monitor in monitors:
if monitor.get("activeWorkspace", {}).get("id") == workspace_id:
return monitor.get("width", data.CURRENT_WIDTH), monitor.get(
"height", data.CURRENT_HEIGHT
)
# Fallback to first monitor
if monitors:
return monitors[0].get("width", data.CURRENT_WIDTH), monitors[0].get(
"height", data.CURRENT_HEIGHT
)
except Exception as e:
print(f"Error getting screen dimensions: {e}")
# Default fallback values
return data.CURRENT_WIDTH, data.CURRENT_HEIGHT
def check_occlusion(occlusion_region, workspace=None):
"""
Check if a region is occupied by any window on a given workspace.
Parameters:
occlusion_region: Can be one of:
- tuple (side, size): where side is "top", "bottom", "left", or "right"
and size is the pixel width of the region
- tuple (x, y, width, height): The full region coordinates (legacy format)
workspace (int, optional): The workspace ID to check. If None, the current workspace is used.
Returns:
bool: True if any window overlaps with the occlusion region, False otherwise.
"""
if workspace is None:
workspace = get_current_workspace()
# Handle simplified side-based format
if isinstance(occlusion_region, tuple) and len(occlusion_region) == 2:
side, size = occlusion_region
if isinstance(side, str):
# Convert side-based format to coordinates
screen_width, screen_height = get_screen_dimensions()
if side.lower() == "bottom":
occlusion_region = (0, screen_height - size, screen_width, size)
elif side.lower() == "top":
occlusion_region = (0, 0, screen_width, size)
elif side.lower() == "left":
occlusion_region = (0, 0, size, screen_height)
elif side.lower() == "right":
occlusion_region = (screen_width - size, 0, size, screen_height)
# Ensure occlusion_region is in the correct format (x, y, width, height)
if not isinstance(occlusion_region, tuple) or len(occlusion_region) != 4:
print(f"Invalid occlusion region format: {occlusion_region}")
return False
try:
result = subprocess.run(
["hyprctl", "-j", "clients"], capture_output=True, text=True
)
clients = json.loads(result.stdout)
except Exception as e:
print(f"Error retrieving client windows: {e}")
return False
occ_x, occ_y, occ_width, occ_height = occlusion_region
occ_x2 = occ_x + occ_width
occ_y2 = occ_y + occ_height
for client in clients:
# Check if client is mapped
if not client.get("mapped", False):
continue
# Ensure client has proper workspace information and matches the workspace
client_workspace = client.get("workspace", {})
if client_workspace.get("id") != workspace:
continue
# Ensure client has position and size info
position = client.get("at")
size = client.get("size")
if not position or not size:
continue
x, y = position
width, height = size
win_x1, win_y1 = x, y
win_x2, win_y2 = x + width, y + height
# Check for intersection between the window and occlusion region
if not (
win_x2 <= occ_x or win_x1 >= occ_x2 or win_y2 <= occ_y or win_y1 >= occ_y2
):
return True # Occlusion region is occupied
return False # No window overlaps the occlusion region
================================================
FILE: utils/roam.py
================================================
from loguru import logger
from fabric.audio import Audio
from services.modus import ModusService, notification_service as notification_service_instance
global modus_service
try:
modus_service = ModusService()
except Exception as e:
logger.error(f"[Main] Failed to create ModusService: {e}")
modus_service = None
if modus_service is None:
logger.warning(
"[Main] ModusService was not initialized. Functionality may be limited."
)
global notification_service
try:
notification_service = notification_service_instance
except Exception as e:
logger.error(f"[Main] Failed to create NotificationService: {e}")
notification_service = None
if notification_service is None:
logger.warning(
"[Main] NotificationService was not initialized. Notifications may not work."
)
global audio_service
try:
audio_service = Audio()
except Exception as e:
logger.error(f"[Main] Failed to create AudioService: {e}")
audio_service = None
if audio_service is None:
logger.warning(
"[Main] AudioService was not initialized. Audio features may not work."
)
================================================
FILE: widgets/circle_image.py
================================================
import math
from typing import Literal
import cairo
import gi
from fabric.core.service import Property
from fabric.widgets.widget import Widget
gi.require_version("Gtk", "3.0")
from gi.repository import Gdk, GdkPixbuf, Gtk # noqa: E402
class CircleImage(Gtk.DrawingArea, Widget):
"""A widget that displays an image in a circular shape with a 1:1 aspect ratio."""
@Property(int, "read-write")
def angle(self) -> int:
return self._angle
@angle.setter
def angle(self, value: int):
self._angle = value % 360
self.queue_draw()
def __init__(
self,
image_file: str | None = None,
pixbuf: GdkPixbuf.Pixbuf | None = None,
name: str | None = None,
visible: bool = True,
all_visible: bool = False,
style: str | None = None,
tooltip_text: str | None = None,
tooltip_markup: str | None = None,
h_align: (
Literal["fill", "start", "end", "center", "baseline"] | Gtk.Align | None
) = None,
v_align: (
Literal["fill", "start", "end", "center", "baseline"] | Gtk.Align | None
) = None,
h_expand: bool = False,
v_expand: bool = False,
size: int | None = None,
**kwargs,
):
Gtk.DrawingArea.__init__(self)
Widget.__init__(
self,
name=name,
visible=visible,
all_visible=all_visible,
style=style,
tooltip_text=tooltip_text,
tooltip_markup=tooltip_markup,
h_align=h_align,
v_align=v_align,
h_expand=h_expand,
v_expand=v_expand,
size=size,
**kwargs,
)
self.size = size if size is not None else 100 # Default size if not provided
self._angle = 0
# Original image for reprocessing
self._orig_image: GdkPixbuf.Pixbuf | None = None
self._image: GdkPixbuf.Pixbuf | None = None
if image_file:
pix = GdkPixbuf.Pixbuf.new_from_file(image_file)
self._orig_image = pix
self._image = self._process_image(pix)
elif pixbuf:
self._orig_image = pixbuf
self._image = self._process_image(pixbuf)
self.connect("draw", self.on_draw)
def _process_image(self, pixbuf: GdkPixbuf.Pixbuf) -> GdkPixbuf.Pixbuf:
"""Crop the image to a centered square and scale it to the widget’s size."""
width, height = pixbuf.get_width(), pixbuf.get_height()
if width != height:
square_size = min(width, height)
x_offset = (width - square_size) // 2
y_offset = (height - square_size) // 2
pixbuf = pixbuf.new_subpixbuf(x_offset, y_offset, square_size, square_size)
else:
square_size = width
if square_size != self.size:
pixbuf = pixbuf.scale_simple(
self.size, self.size, GdkPixbuf.InterpType.BILINEAR
)
return pixbuf
def on_draw(self, widget: "CircleImage", ctx: cairo.Context):
if self._image:
ctx.save()
# Create a circular clipping path
ctx.arc(self.size / 2, self.size / 2, self.size / 2, 0, 2 * math.pi)
ctx.clip()
# Rotate around the center of the square image
ctx.translate(self.size / 2, self.size / 2)
ctx.rotate(self._angle * math.pi / 180.0)
ctx.translate(-self.size / 2, -self.size / 2)
Gdk.cairo_set_source_pixbuf(ctx, self._image, 0, 0)
ctx.paint()
ctx.restore()
def set_image_from_file(self, new_image_file: str):
if not new_image_file:
return
pixbuf = GdkPixbuf.Pixbuf.new_from_file(new_image_file)
self._orig_image = pixbuf
self._image = self._process_image(pixbuf)
self.queue_draw()
def set_image_from_pixbuf(self, pixbuf: GdkPixbuf.Pixbuf):
if not pixbuf:
return
self._orig_image = pixbuf
self._image = self._process_image(pixbuf)
self.queue_draw()
def set_image_size(self, size: int):
self.size = size
if self._orig_image:
self._image = self._process_image(self._orig_image)
self.queue_draw()
================================================
FILE: widgets/custom_image.py
================================================
import math
from typing import cast
import cairo
from gi.repository import Gtk
from fabric.widgets.image import Image
class CustomImage(Image):
def do_render_rectangle(
self, cr: cairo.Context, width: int, height: int, radius: int = 0
):
cr.move_to(radius, 0)
cr.line_to(width - radius, 0)
cr.arc(width - radius, radius, radius, -(math.pi / 2), 0)
cr.line_to(width, height - radius)
cr.arc(width - radius, height - radius, radius, 0, (math.pi / 2))
cr.line_to(radius, height)
cr.arc(radius, height - radius, radius, (math.pi / 2), math.pi)
cr.line_to(0, radius)
cr.arc(radius, radius, radius, math.pi, (3 * (math.pi / 2)))
cr.close_path()
def do_draw(self, cr: cairo.Context):
context = self.get_style_context()
width, height = self.get_allocated_width(), self.get_allocated_height()
cr.save()
self.do_render_rectangle(
cr,
width,
height,
cast(int, context.get_property("border-radius", Gtk.StateFlags.NORMAL)),
)
cr.clip()
Image.do_draw(self, cr)
cr.restore()
================================================
FILE: widgets/customrevealer.py
================================================
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()
================================================
FILE: widgets/dropdown.py
================================================
from fabric.widgets.box import Box
from fabric.widgets.centerbox import CenterBox
from fabric.widgets.eventbox import EventBox
from utils.roam import modus_service
from widgets.popup_window import PopupWindow
dropdowns = []
def dropdown_divider(comment):
return Box(
children=[Box(name="dropdown-divider", h_expand=True)],
name="dropdown-divider-box",
h_align="fill",
h_expand=True,
v_expand=True,
)
class ModusDropdown(PopupWindow):
def __init__(self, dropdown_children=None, dropdown_id=None, **kwargs):
super().__init__(
layer="top",
exclusivity="auto",
name="dropdown-menu",
title="modus",
keyboard_mode="none",
visible=False,
**kwargs,
)
self.id = dropdown_id or str(len(dropdowns))
dropdowns.append(self)
self._mousecapture_parent = None # Will be set by mousecapture
modus_service.connect("dropdowns-hide-changed", self.hide_dropdown)
self.dropdown = Box(
children=dropdown_children or [],
h_expand=True,
name="dropdown-options",
orientation="vertical",
)
self.child_box = CenterBox(start_children=[self.dropdown])
self.event_box = EventBox(
events=["enter-notify-event", "leave-notify-event"],
child=self.child_box,
all_visible=True,
)
self.children = [self.event_box]
self.connect("button-press-event", self.hide_dropdown)
self.add_keybinding("Escape", self.hide_dropdown)
def toggle_dropdown(self, button, parent=None):
self.set_visible(not self.is_visible())
modus_service.current_dropdown = self.id if self.is_visible() else None
def _init_mousecapture(self, mousecapture):
"""Store reference to mousecapture parent for hiding"""
self._mousecapture_parent = mousecapture
def hide_dropdown(self, widget, event):
if self.is_visible():
self.hide()
if str(modus_service.current_dropdown) == str(self.id):
modus_service.current_dropdown = None
def hide_via_mousecapture(self):
"""Hide dropdown via mousecapture parent"""
if self._mousecapture_parent:
self._mousecapture_parent.hide_child_window()
def _set_mousecapture(self, visible: bool) -> None:
self.set_visible(visible)
if visible:
modus_service.current_dropdown = self.id
else:
if str(modus_service.current_dropdown) == str(self.id):
modus_service.current_dropdown = None
def on_cursor_enter(self, *_):
self.set_visible(True)
def on_cursor_leave(self, *_):
if self.is_hovered():
return
self.set_visible(False)
modus_service.dropdowns_hide = not modus_service.dropdowns_hide
================================================
FILE: widgets/mousecapture.py
================================================
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)
================================================
FILE: widgets/popup_window.py
================================================
import contextlib
import gi # type: ignore
from gi.repository import Gdk, Gtk, GtkLayerShell # type: ignore
from widgets.wayland import WaylandWindow
from utils.monitors import HyprlandWithMonitors
gi.require_version("GtkLayerShell", "0.1")
class PopupWindow(WaylandWindow):
"""A simple popover window that can point to a widget."""
def __init__(
self,
parent: WaylandWindow,
pointing_to: Gtk.Widget | None = None,
margin: tuple[int, ...] | str = "0px 0px 0px 0px",
enable_boundary_checking: bool = True,
**kwargs,
):
super().__init__(**kwargs)
self.exclusivity = "none"
self._is_centered = False
self._parent = parent
self._pointing_widget = pointing_to
self._hyprland = HyprlandWithMonitors()
self._base_margin = self.extract_margin(margin)
self.margin = self._base_margin.values()
self._enable_boundary_checking = enable_boundary_checking
self.connect("notify::visible", self.do_update_handlers)
def get_coords_for_widget(self, widget: Gtk.Widget) -> tuple[int, int]:
if not ((toplevel := widget.get_toplevel()) and toplevel.is_toplevel()): # type: ignore
return 0, 0
allocation = widget.get_allocation()
x, y = widget.translate_coordinates(toplevel, allocation.x, allocation.y) or (
0,
0,
)
return round(x / 2), round(y / 2)
def set_pointing_to(self, widget: Gtk.Widget | None):
if self._pointing_widget:
with contextlib.suppress(Exception):
self._pointing_widget.disconnect_by_func(self.do_handle_size_allocate)
self._pointing_widget = widget
return self.do_update_handlers()
def do_update_handlers(self, *_):
if not self._pointing_widget:
return
if not self.get_visible():
try:
self._pointing_widget.disconnect_by_func(self.do_handle_size_allocate)
self.disconnect_by_func(self.do_handle_size_allocate)
except Exception:
pass
return
self._pointing_widget.connect("size-allocate", self.do_handle_size_allocate)
self.connect("size-allocate", self.do_handle_size_allocate)
return self.do_handle_size_allocate()
def do_handle_size_allocate(self, *_):
return self.do_reposition(self.do_calculate_edges())
def do_calculate_edges(self):
move_axe = "x"
parent_anchor = self._parent.anchor
if len(parent_anchor) != 3:
self.anchor = "left bottom"
self._is_centered = True
return move_axe
if (
GtkLayerShell.Edge.LEFT in parent_anchor
and GtkLayerShell.Edge.RIGHT in parent_anchor
):
# horizontal -> move on x-axies
move_axe = "x"
if GtkLayerShell.Edge.TOP in parent_anchor:
self.anchor = "left top"
else:
self.anchor = "left bottom"
elif (
GtkLayerShell.Edge.TOP in parent_anchor
and GtkLayerShell.Edge.BOTTOM in parent_anchor
):
# vertical -> move on y-axies
move_axe = "y"
if GtkLayerShell.Edge.RIGHT in parent_anchor:
self.anchor = "top right"
else:
self.anchor = "top left"
self._is_centered = False
return move_axe
def do_reposition(self, move_axe: str):
parent_margin = self._parent.margin
parent_x_margin, parent_y_margin = parent_margin[0], parent_margin[3]
height = self.get_allocated_height()
width = self.get_allocated_width()
# Get monitor geometry for boundary checking
current_monitor_id = self._hyprland.get_current_gdk_monitor_id()
if current_monitor_id is not None:
monitor = self._hyprland.display.get_monitor(current_monitor_id)
monitor_geometry = monitor.get_geometry()
monitor_x, monitor_y = monitor_geometry.x, monitor_geometry.y
monitor_width, monitor_height = monitor_geometry.width, monitor_geometry.height
else:
# Fallback to default screen
screen = Gdk.Screen.get_default()
monitor_x, monitor_y = 0, 0
monitor_width, monitor_height = screen.get_width(), screen.get_height()
if self._pointing_widget:
coords = self.get_coords_for_widget(self._pointing_widget)
coords_centered = (
round(coords[0] + self._pointing_widget.get_allocated_width() / 2),
round(coords[1] + self._pointing_widget.get_allocated_height() / 2),
)
else:
coords_centered = (
round(self._parent.get_allocated_width() / 2),
round(self._parent.get_allocated_height() / 2),
)
if self._is_centered:
# Calculate centered position with boundary checking
centered_x = (
(monitor_width / 2 - self._parent.get_allocated_width() / 2)
- width / 2
) + coords_centered[0]
# Apply boundary checking only if enabled
if self._enable_boundary_checking:
if centered_x < monitor_x:
centered_x = monitor_x
elif centered_x + width > monitor_x + monitor_width:
centered_x = monitor_x + monitor_width - width
self.margin = tuple(
a + b
for a, b in zip(
(0, 0, 0, centered_x),
self._base_margin.values(),
)
)
return
# Calculate position with boundary checking
if move_axe == "x":
# Horizontal positioning
calculated_x = round((parent_x_margin + coords_centered[0]) - (width / 2))
# Apply boundary checking only if enabled
if self._enable_boundary_checking:
if calculated_x < monitor_x:
calculated_x = monitor_x
elif calculated_x + width > monitor_x + monitor_width:
calculated_x = monitor_x + monitor_width - width
margin_values = (0, 0, 0, calculated_x)
else:
# Vertical positioning
calculated_y = round((parent_y_margin + coords_centered[1]) - (height / 2))
# Apply boundary checking only if enabled
if self._enable_boundary_checking:
if calculated_y < monitor_y:
calculated_y = monitor_y
elif calculated_y + height > monitor_y + monitor_height:
calculated_y = monitor_y + monitor_height - height
margin_values = (calculated_y, 0, 0, 0)
self.margin = tuple(
a + b
for a, b in zip(
margin_values,
self._base_margin.values(),
)
)
================================================
FILE: widgets/wayland.py
================================================
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],
}
================================================
FILE: widgets/wifi_password_dialog.py
================================================
import gi
from gi.repository import Gdk, GLib
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.entry import Entry
from fabric.widgets.image import Image
from fabric.widgets.label import Label
# from widgets.wayland import WaylandWindow as Window
from fabric.widgets.window import Window
gi.require_version("Gtk", "3.0")
class WiFiPasswordDialog(Window):
def __init__(
self,
ssid: str,
on_connect_callback=None,
on_cancel_callback=None,
on_dialog_closed=None,
**kwargs,
):
super().__init__(
title="modus",
layer="overlay",
anchor="center",
keyboard_mode="on-demand",
visible=False,
name="wifi-password-dialog",
**kwargs,
)
self.ssid = ssid
self.on_connect_callback = on_connect_callback
self.on_cancel_callback = on_cancel_callback
# self.set_size_request(400, 300)
self.set_resizable(False)
self.on_dialog_closed = on_dialog_closed
self.is_connecting = False
self.connection_timeout_id = None
self._create_dialog_content()
self.connect("key-press-event", self._on_key_press)
self.connect("notify::visible", self._on_visibility_changed)
def _create_dialog_content(self):
self.wifi_icon = Image(
icon_name="network-wireless-symbolic", size=20, name="wifi-dialog-icon"
)
self.title_label = Label(
label=f'The Wi-Fi network "{self.ssid}" requires a WPA2 password.',
name="wifi-dialog-title",
h_align="start",
wrap=True,
max_width_chars=40,
)
self.title_container = Box(
orientation="h",
spacing=8,
children=[self.wifi_icon, self.title_label],
name="wifi-dialog-title-container",
h_align="start",
)
self.error_label = Label(
label="Incorrect password. Please try again.",
name="wifi-dialog-error",
h_align="center",
visible=False,
)
self.password_label = Label(
label="Password:", name="wifi-dialog-password-label", h_align="start"
)
self.password_entry = Entry(
placeholder_text="Enter password",
name="wifi-dialog-password-entry",
visibility=False,
h_expand=True,
h_align="fill",
)
self.password_entry.connect("activate", lambda *_: self._on_join_clicked())
self.password_entry.connect("changed", self._on_password_changed)
self.password_visible = False
self.show_password_button = Button(
image=Image(icon_name="view-conceal-symbolic", size=16),
name="wifi-dialog-show-password-button",
on_clicked=self._on_show_password_clicked,
)
self.show_password_label = Label(
label="Show password", name="wifi-dialog-show-password-label"
)
self.show_password_box = Box(
orientation="h",
spacing=8,
children=[self.show_password_button, self.show_password_label],
name="wifi-dialog-show-password-box",
)
self.cancel_button = Button(
label="Cancel",
name="wifi-dialog-cancel-button",
on_clicked=self._on_cancel_clicked,
)
self.join_button = Button(
label="Join",
name="wifi-dialog-join-button",
on_clicked=self._on_join_clicked,
)
self.button_box = Box(
orientation="h",
spacing=12,
h_expand=True,
children=[self.cancel_button, self.join_button],
name="wifi-dialog-button-box",
h_align="end",
)
self.password_container = Box(
orientation="v",
h_expand=True,
spacing=6,
children=[self.password_label, self.password_entry, self.show_password_box],
name="wifi-dialog-password-container",
)
self.content_box = Box(
orientation="v",
h_expand=True,
spacing=12,
children=[
self.title_container,
self.error_label,
self.password_container,
self.button_box,
],
name="wifi-dialog-content",
h_align="fill",
v_align="center",
)
self.dialog_background = Box(
children=[self.content_box],
name="wifi-dialog-background",
h_align="center",
v_align="center",
)
self.children = self.dialog_background
self._update_join_button_state()
def _on_password_changed(self, entry):
self._update_join_button_state()
def _update_join_button_state(self):
password = self.password_entry.get_text().strip()
has_password = len(password) > 0
if has_password:
self.join_button.set_opacity(1.0)
self.join_button.remove_style_class("disabled")
else:
self.join_button.set_opacity(0.5)
self.join_button.add_style_class("disabled")
def _on_show_password_clicked(self, *args):
self.password_visible = not self.password_visible
self.password_entry.set_visibility(self.password_visible)
icon_name = (
"view-reveal-symbolic" if self.password_visible else "view-conceal-symbolic"
)
self.show_password_button.get_image().set_property("icon-name", icon_name)
def _on_key_press(self, widget, event):
keyval = event.keyval
if keyval == Gdk.KEY_Return or keyval == Gdk.KEY_KP_Enter:
self._on_join_clicked()
return True
elif keyval == Gdk.KEY_Escape:
self._on_cancel_clicked()
return True
return False
def _on_visibility_changed(self, widget, *args):
"""Handle visibility changes"""
if self.get_visible():
GLib.timeout_add(100, lambda: self.password_entry.grab_focus())
def _on_cancel_clicked(self, *args):
self.hide()
if self.on_cancel_callback:
self.on_cancel_callback()
if self.on_dialog_closed:
self.on_dialog_closed()
def _on_join_clicked(self, *args):
if self.is_connecting:
return
password = self.password_entry.get_text().strip()
if not password:
self.password_entry.grab_focus()
return
self.is_connecting = True
self.join_button.set_sensitive(False)
self.connection_timeout_id = GLib.timeout_add(5000, self._connection_timeout)
self.error_label.set_visible(False)
self.hide()
if self.on_connect_callback:
self.on_connect_callback(self.ssid, password)
if self.on_dialog_closed:
self.on_dialog_closed()
def _connection_timeout(self):
if self.is_connecting:
self.is_connecting = False
self.join_button.set_sensitive(True)
self.show_error("Connection timeout. Please try again.")
return False
def show_dialog(self):
if self.connection_timeout_id:
GLib.source_remove(self.connection_timeout_id)
self.connection_timeout_id = None
self.show_all()
self.password_entry.set_text("")
self.error_label.set_visible(False)
self.password_visible = False
self.password_entry.set_visibility(False)
self.show_password_button.get_image().set_property(
"icon-name", "view-conceal-symbolic"
)
self.is_connecting = False
self.join_button.set_sensitive(True)
self._update_join_button_state()
def show_error(self, message="Incorrect password. Please try again."):
if self.connection_timeout_id:
GLib.source_remove(self.connection_timeout_id)
self.connection_timeout_id = None
self.is_connecting = False
self.join_button.set_sensitive(True)
if not self.get_visible():
self.error_label.set_text(message)
self.error_label.set_visible(True)
self.show_all()
GLib.timeout_add(10, lambda: self._focus_and_select_password())
else:
self.error_label.set_text(message)
self.error_label.set_visible(True)
self._focus_and_select_password()
def _focus_and_select_password(self):
try:
self.password_entry.grab_focus()
self.password_entry.select_region(0, -1)
return False
except:
return False
def get_password(self):
return self.password_entry.get_text()
def destroy_dialog(self):
self.hide()
self.destroy()