diff --git a/README.md b/README.md
index a64fcac..fec2ad7 100644
--- a/README.md
+++ b/README.md
@@ -1,133 +1,5 @@
-
-
It may not work on your machine
-
+[legacy](./legacy/) — My first desktop rice built with Fabric. It marks the beginning of my journey with the framework and serves as a snapshot of my early setup and ideas.
-
-
-
- A modular and extensible shell for Hyprland, written on Fabric .
+[mine](./mine/) — My second Fabric rice, featuring various improvements, refinements, and experiments based on what I learned from the legacy version.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-> [!WARNING]
-> This configuration is tailored to my preferences with little regard for others. It's not designed to be "good"—just comfortable for me.
-
- Installation
-
-> [!NOTE]
-> You need a functioning Hyprland installation.
-
-### Arch Linux
-
-> [!NOTE]
-> You need to clone it into the Home directory.
-
-> [!TIP]
-> This command also works for updating an existing installation!
-
-```bash
-curl -fsSL https://raw.githubusercontent.com/S4NKALP/Modus/main/install.sh | bash
-```
-
-### Manual Installation
-
-1. Dependencies:
-
-```sh
-python-fabric-git fabric-cli gnome-bluetooth-3.0 wf-recorder cliphist wl-clipboard grimblast power-profile-daemon adw-gtk-theme brightnessctl swww hypridle hyprlock imagemagick libnotify swappy libadwaita wlinhibit
-```
-
-```sh
-python-materialyoucolor-git python-pillow python-toml python-ijson python-setproctitle python-requests python-numpy python-pywayland python-pyxdg
-```
-
-```sh
-ttf-font-awesome otf-font-awesome ttf-material-symbols-variable-git ttf-google-sans ttf-opensans ttf-robot
-```
-
-- Icon:
-
-```sh
-git clone https://github.com/vinceliuice/Tela-icon-theme
-cd Tela-icon-theme
-./install.sh nord
-```
-
- Screenshots
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Todo
-
-- [x] App Launcher
-- [x] Power Menu
-- [x] Wallpaper Picker
-- [x] Todo
-- [x] Bluetooth Manager
-- [x] Clipboard Manager
-- [x] OSD
-- [x] Sh Runner
-- [x] Emoji Picker
-- [x] Update Screenshot
-- [x] Installation script
-- [x] Network Manager
-- [x] Calendar
-- [x] Power Manager
-- [x] Dock
-- [x] Web search in Launcher
-- [ ] Notification Panel
-- [ ] Improve CSS Styling
-- [ ] Update Screenshot
-
-## 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.
-- [koeqaife](https://github.com/koeqaife): thanks for material-colors scripts
-
-I truly appreciate your support
+These directories are no longer actively maintained or updated. My macOS branch rice is now my primary and most up-to-date setup. The Fabric versions are kept in this repository for archival purposes and as references if you're interested in seeing how my configuration evolved over time.
diff --git a/config/assets/config.json b/config/assets/config.json
deleted file mode 100644
index 2cdda50..0000000
--- a/config/assets/config.json
+++ /dev/null
@@ -1,58 +0,0 @@
-{
- "prefix_restart": "SHIFT ALT",
- "suffix_restart": "T",
- "prefix_bluetooth": "SHIFT ALT",
- "suffix_bluetooth": "B",
- "prefix_emoji": "SUPER",
- "suffix_emoji": "E",
- "prefix_cliphist": "SUPER",
- "suffix_cliphist": "V",
- "prefix_kanban": "SUPER",
- "suffix_kanban": "T",
- "prefix_toolbox": "SUPER",
- "suffix_toolbox": "S",
- "prefix_walls": "SUPER",
- "suffix_walls": "W",
- "prefix_launcher": "SUPER",
- "suffix_launcher": "D",
- "prefix_power": "SUPER",
- "suffix_power": "X",
- "prefix_tmux": "SUPER",
- "suffix_tmux": "Z",
- "prefix_notifications": "SUPER",
- "suffix_notifications": "N",
- "prefix_window_switcher": "ALT",
- "suffix_window_switcher": "W",
- "prefix_restart_inspector": "SUPER SHIFT",
- "suffix_restart_inspector": "T",
- "vertical": false,
- "vertical_right_align": false,
- "bottom_bar": true,
- "centered_bar": true,
- "terminal_command": "kitty -e",
- "wallpapers_dir": "/home/sankalp/Pictures/wallpapers",
- "dock_enabled": true,
- "dock_always_occluded": false,
- "dock_icon_size": 28,
- "osd_enabled": true,
- "bar_button_app_visible": true,
- "bar_tray_visible": true,
- "bar_workspaces_visible": true,
- "bar_metrics_visible": true,
- "bar_language_visible": true,
- "bar_date_time_visible": true,
- "bar_updates_visible": true,
- "bar_indicators_visible": true,
- "corners_visible": true,
- "bar_metrics_disks": [
- "/"
- ],
- "metrics_visible": {
- "cpu": true,
- "ram": true,
- "disk": true,
- "swap": true,
- "gpu": false
- },
- "enabled_osd": true
-}
\ No newline at end of file
diff --git a/config/hypr/modus.conf b/config/hypr/modus.conf
deleted file mode 100644
index edd9ec3..0000000
--- a/config/hypr/modus.conf
+++ /dev/null
@@ -1,88 +0,0 @@
-# Generated by Modus - DO NOT EDIT MANUALLY
-exec-once = python /home/sankalp/Modus/main.py
-exec = pgrep -x "hypridle" > /dev/null || hypridle
-exec = swww-daemon
-exec-once = wl-paste --type text --watch cliphist store
-exec-once = wl-paste --type image --watch cliphist store
-
-$fabricSend = fabric-cli exec modus
-$scriptsDir = /home/sankalp/Modus/config/scripts/
-
-# Key Bindings
-bind = SHIFT ALT, T, exec, killall modus; python /home/sankalp/Modus/main.py # Reload Modus
-bind = SHIFT ALT, B, exec, $fabricSend 'launcher.open("bluetooth")' # Bluetooth
-bind = SUPER, N, exec, $fabricSend 'launcher.open("notification-center")' # Notifications
-bind = SUPER, E, exec, $fabricSend 'launcher.open("emoji")' # Emoji
-bind = SUPER, V, exec, $fabricSend 'launcher.open("cliphist")' # Clipboard History
-bind = SUPER, T, exec, $fabricSend 'launcher.open("kanban")' # Kanban
-bind = SUPER, Z, exec, $fabricSend 'launcher.open("tmux")' # Tmux Manager
-bind = SUPER, S, exec, $fabricSend 'launcher.open("tools")' # Toolbox
-bind = SUPER, W, exec, $fabricSend 'launcher.open("wallpapers")' # Wallpaper Selector
-bind = SUPER, D, exec, $fabricSend 'launcher.open("launcher")' # App Launcher
-bind = ALT, W, exec, $fabricSend 'launcher.open("window-switcher")' # Window Switcher
-bind = SUPER, X, exec, $fabricSend 'launcher.open("power")' # Power Menu
-bind = SUPER SHIFT, T, exec, killall modus; GTK_DEBUG=interactive python /home/sankalp/Modus/main.py # Restart with inspector
-
-# Wallpapers directory: /home/sankalp/Pictures/wallpapers
-
-# Source color scheme
-source = /home/sankalp/Modus/config/hypr/colors.conf
-
-general {
- col.active_border = 0xff$primary
- col.inactive_border = 0xff$surface
- gaps_in = 2
- gaps_out = 4
- border_size = 2
- layout = dwindle
-}
-
-decoration {
- blur {
- enabled = yes
- size = 5
- passes = 3
- new_optimizations = yes
- contrast = 1
- brightness = 1
- }
- rounding = 14
- shadow {
- enabled = true
- range = 10
- render_power = 2
- color = rgba(0, 0, 0, 0.25)
- }
-}
-
-animations {
- enabled = true
- # Animation curves
- bezier = linear, 0, 0, 1, 1
- bezier = md3_standard, 0.2, 0, 0, 1
- bezier = md3_decel, 0.05, 0.7, 0.1, 1
- bezier = md3_accel, 0.3, 0, 0.8, 0.15
- bezier = overshot, 0.05, 0.9, 0.1, 1.1
- bezier = crazyshot, 0.1, 1.5, 0.76, 0.92
- bezier = hyprnostretch, 0.05, 0.9, 0.1, 1.0
- bezier = menu_decel, 0.1, 1, 0, 1
- bezier = menu_accel, 0.38, 0.04, 1, 0.07
- bezier = easeInOutCirc, 0.85, 0, 0.15, 1
- bezier = easeOutCirc, 0, 0.55, 0.45, 1
- bezier = easeOutExpo, 0.16, 1, 0.3, 1
- bezier = softAcDecel, 0.26, 0.26, 0.15, 1
- bezier = md2, 0.4, 0, 0.2, 1
-
- animation = windows, 1, 3, md3_decel, popin 60%
- animation = windowsIn, 1, 3, md3_decel, popin 60%
- animation = windowsOut, 1, 3, md3_accel, popin 60%
- animation = border, 1, 10, default
- animation = fade, 1, 3, md3_decel
- animation = layers, 1, 2, md3_decel, slide
- animation = layersIn, 1, 3, menu_decel, slide
- animation = layersOut, 1, 1.6, menu_accel, slide
- animation = fadeLayersIn, 1, 2, menu_decel
- animation = fadeLayersOut, 1, 4.5, menu_accel
- animation = workspaces, 1, 7, menu_decel, slide
- animation = specialWorkspace, 1, 3, md3_decel, slidevert
-}
diff --git a/.gitignore b/legacy/.gitignore
similarity index 100%
rename from .gitignore
rename to legacy/.gitignore
diff --git a/LICENSE b/legacy/LICENSE
similarity index 100%
rename from LICENSE
rename to legacy/LICENSE
diff --git a/legacy/README.md b/legacy/README.md
new file mode 100644
index 0000000..a64fcac
--- /dev/null
+++ b/legacy/README.md
@@ -0,0 +1,133 @@
+
+
It may not work on your machine
+
+
+
+
+
+ A modular and extensible shell for Hyprland, written on Fabric .
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+> [!WARNING]
+> This configuration is tailored to my preferences with little regard for others. It's not designed to be "good"—just comfortable for me.
+
+ Installation
+
+> [!NOTE]
+> You need a functioning Hyprland installation.
+
+### Arch Linux
+
+> [!NOTE]
+> You need to clone it into the Home directory.
+
+> [!TIP]
+> This command also works for updating an existing installation!
+
+```bash
+curl -fsSL https://raw.githubusercontent.com/S4NKALP/Modus/main/install.sh | bash
+```
+
+### Manual Installation
+
+1. Dependencies:
+
+```sh
+python-fabric-git fabric-cli gnome-bluetooth-3.0 wf-recorder cliphist wl-clipboard grimblast power-profile-daemon adw-gtk-theme brightnessctl swww hypridle hyprlock imagemagick libnotify swappy libadwaita wlinhibit
+```
+
+```sh
+python-materialyoucolor-git python-pillow python-toml python-ijson python-setproctitle python-requests python-numpy python-pywayland python-pyxdg
+```
+
+```sh
+ttf-font-awesome otf-font-awesome ttf-material-symbols-variable-git ttf-google-sans ttf-opensans ttf-robot
+```
+
+- Icon:
+
+```sh
+git clone https://github.com/vinceliuice/Tela-icon-theme
+cd Tela-icon-theme
+./install.sh nord
+```
+
+ Screenshots
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Todo
+
+- [x] App Launcher
+- [x] Power Menu
+- [x] Wallpaper Picker
+- [x] Todo
+- [x] Bluetooth Manager
+- [x] Clipboard Manager
+- [x] OSD
+- [x] Sh Runner
+- [x] Emoji Picker
+- [x] Update Screenshot
+- [x] Installation script
+- [x] Network Manager
+- [x] Calendar
+- [x] Power Manager
+- [x] Dock
+- [x] Web search in Launcher
+- [ ] Notification Panel
+- [ ] Improve CSS Styling
+- [ ] Update Screenshot
+
+## 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.
+- [koeqaife](https://github.com/koeqaife): thanks for material-colors scripts
+
+I truly appreciate your support
diff --git a/assets/modus.png b/legacy/assets/modus.png
similarity index 100%
rename from assets/modus.png
rename to legacy/assets/modus.png
diff --git a/assets/screenshots/bluetooth.png b/legacy/assets/screenshots/bluetooth.png
similarity index 100%
rename from assets/screenshots/bluetooth.png
rename to legacy/assets/screenshots/bluetooth.png
diff --git a/assets/screenshots/cliphist.png b/legacy/assets/screenshots/cliphist.png
similarity index 100%
rename from assets/screenshots/cliphist.png
rename to legacy/assets/screenshots/cliphist.png
diff --git a/assets/screenshots/emoji.png b/legacy/assets/screenshots/emoji.png
similarity index 100%
rename from assets/screenshots/emoji.png
rename to legacy/assets/screenshots/emoji.png
diff --git a/assets/screenshots/home.png b/legacy/assets/screenshots/home.png
similarity index 100%
rename from assets/screenshots/home.png
rename to legacy/assets/screenshots/home.png
diff --git a/assets/screenshots/notification.png b/legacy/assets/screenshots/notification.png
similarity index 100%
rename from assets/screenshots/notification.png
rename to legacy/assets/screenshots/notification.png
diff --git a/assets/screenshots/osd.png b/legacy/assets/screenshots/osd.png
similarity index 100%
rename from assets/screenshots/osd.png
rename to legacy/assets/screenshots/osd.png
diff --git a/assets/screenshots/powermenu.png b/legacy/assets/screenshots/powermenu.png
similarity index 100%
rename from assets/screenshots/powermenu.png
rename to legacy/assets/screenshots/powermenu.png
diff --git a/assets/screenshots/shrunner.png b/legacy/assets/screenshots/shrunner.png
similarity index 100%
rename from assets/screenshots/shrunner.png
rename to legacy/assets/screenshots/shrunner.png
diff --git a/assets/screenshots/todo.png b/legacy/assets/screenshots/todo.png
similarity index 100%
rename from assets/screenshots/todo.png
rename to legacy/assets/screenshots/todo.png
diff --git a/assets/screenshots/wallpaper.png b/legacy/assets/screenshots/wallpaper.png
similarity index 100%
rename from assets/screenshots/wallpaper.png
rename to legacy/assets/screenshots/wallpaper.png
diff --git a/assets/wallpaper/example-1.png b/legacy/assets/wallpaper/example-1.png
similarity index 100%
rename from assets/wallpaper/example-1.png
rename to legacy/assets/wallpaper/example-1.png
diff --git a/assets/wallpaper/example-2.png b/legacy/assets/wallpaper/example-2.png
similarity index 100%
rename from assets/wallpaper/example-2.png
rename to legacy/assets/wallpaper/example-2.png
diff --git a/assets/wallpaper/example-3.jpg b/legacy/assets/wallpaper/example-3.jpg
similarity index 100%
rename from assets/wallpaper/example-3.jpg
rename to legacy/assets/wallpaper/example-3.jpg
diff --git a/assets/wallpaper/example-4.jpeg b/legacy/assets/wallpaper/example-4.jpeg
similarity index 100%
rename from assets/wallpaper/example-4.jpeg
rename to legacy/assets/wallpaper/example-4.jpeg
diff --git a/assets/wallpaper/example-5.jpg b/legacy/assets/wallpaper/example-5.jpg
similarity index 100%
rename from assets/wallpaper/example-5.jpg
rename to legacy/assets/wallpaper/example-5.jpg
diff --git a/config/__init__.py b/legacy/config/__init__.py
similarity index 100%
rename from config/__init__.py
rename to legacy/config/__init__.py
diff --git a/config/assets/accounts.json b/legacy/config/assets/accounts.json
similarity index 100%
rename from config/assets/accounts.json
rename to legacy/config/assets/accounts.json
diff --git a/config/assets/emoji.json b/legacy/config/assets/emoji.json
similarity index 100%
rename from config/assets/emoji.json
rename to legacy/config/assets/emoji.json
diff --git a/config/assets/modus.json b/legacy/config/assets/modus.json
similarity index 100%
rename from config/assets/modus.json
rename to legacy/config/assets/modus.json
diff --git a/config/config.py b/legacy/config/config.py
similarity index 100%
rename from config/config.py
rename to legacy/config/config.py
diff --git a/config/data.py b/legacy/config/data.py
similarity index 100%
rename from config/data.py
rename to legacy/config/data.py
diff --git a/config/hypr/colors.conf b/legacy/config/hypr/colors.conf
similarity index 100%
rename from config/hypr/colors.conf
rename to legacy/config/hypr/colors.conf
diff --git a/config/hypr/hypridle.conf b/legacy/config/hypr/hypridle.conf
similarity index 100%
rename from config/hypr/hypridle.conf
rename to legacy/config/hypr/hypridle.conf
diff --git a/config/hypr/hyprlock.conf b/legacy/config/hypr/hyprlock.conf
similarity index 100%
rename from config/hypr/hyprlock.conf
rename to legacy/config/hypr/hyprlock.conf
diff --git a/config/matugen/templates/hyprland-colors.conf b/legacy/config/matugen/templates/hyprland-colors.conf
similarity index 100%
rename from config/matugen/templates/hyprland-colors.conf
rename to legacy/config/matugen/templates/hyprland-colors.conf
diff --git a/config/matugen/templates/modus.css b/legacy/config/matugen/templates/modus.css
similarity index 100%
rename from config/matugen/templates/modus.css
rename to legacy/config/matugen/templates/modus.css
diff --git a/config/matugen/templates/modus.scss b/legacy/config/matugen/templates/modus.scss
similarity index 100%
rename from config/matugen/templates/modus.scss
rename to legacy/config/matugen/templates/modus.scss
diff --git a/config/scripts/capture.sh b/legacy/config/scripts/capture.sh
similarity index 100%
rename from config/scripts/capture.sh
rename to legacy/config/scripts/capture.sh
diff --git a/config/scripts/gamemode.sh b/legacy/config/scripts/gamemode.sh
similarity index 100%
rename from config/scripts/gamemode.sh
rename to legacy/config/scripts/gamemode.sh
diff --git a/config/scripts/hyprpicker.sh b/legacy/config/scripts/hyprpicker.sh
similarity index 100%
rename from config/scripts/hyprpicker.sh
rename to legacy/config/scripts/hyprpicker.sh
diff --git a/config/scripts/systemupdates.sh b/legacy/config/scripts/systemupdates.sh
similarity index 100%
rename from config/scripts/systemupdates.sh
rename to legacy/config/scripts/systemupdates.sh
diff --git a/install.sh b/legacy/install.sh
similarity index 100%
rename from install.sh
rename to legacy/install.sh
diff --git a/main.py b/legacy/main.py
similarity index 100%
rename from main.py
rename to legacy/main.py
diff --git a/modules/bar/bar.py b/legacy/modules/bar/bar.py
similarity index 100%
rename from modules/bar/bar.py
rename to legacy/modules/bar/bar.py
diff --git a/modules/bar/components/__init__.py b/legacy/modules/bar/components/__init__.py
similarity index 100%
rename from modules/bar/components/__init__.py
rename to legacy/modules/bar/components/__init__.py
diff --git a/modules/bar/components/metrics.py b/legacy/modules/bar/components/metrics.py
similarity index 100%
rename from modules/bar/components/metrics.py
rename to legacy/modules/bar/components/metrics.py
diff --git a/modules/bar/components/system_indicators.py b/legacy/modules/bar/components/system_indicators.py
similarity index 100%
rename from modules/bar/components/system_indicators.py
rename to legacy/modules/bar/components/system_indicators.py
diff --git a/modules/bar/components/updates.py b/legacy/modules/bar/components/updates.py
similarity index 100%
rename from modules/bar/components/updates.py
rename to legacy/modules/bar/components/updates.py
diff --git a/modules/bar/components/workspace.py b/legacy/modules/bar/components/workspace.py
similarity index 100%
rename from modules/bar/components/workspace.py
rename to legacy/modules/bar/components/workspace.py
diff --git a/modules/dock.py b/legacy/modules/dock.py
similarity index 100%
rename from modules/dock.py
rename to legacy/modules/dock.py
diff --git a/modules/launcher/components/__init__.py b/legacy/modules/launcher/components/__init__.py
similarity index 100%
rename from modules/launcher/components/__init__.py
rename to legacy/modules/launcher/components/__init__.py
diff --git a/modules/launcher/components/applauncher.py b/legacy/modules/launcher/components/applauncher.py
similarity index 100%
rename from modules/launcher/components/applauncher.py
rename to legacy/modules/launcher/components/applauncher.py
diff --git a/modules/launcher/components/bluetooth.py b/legacy/modules/launcher/components/bluetooth.py
similarity index 100%
rename from modules/launcher/components/bluetooth.py
rename to legacy/modules/launcher/components/bluetooth.py
diff --git a/modules/launcher/components/calendar.py b/legacy/modules/launcher/components/calendar.py
similarity index 100%
rename from modules/launcher/components/calendar.py
rename to legacy/modules/launcher/components/calendar.py
diff --git a/modules/launcher/components/cliphist.py b/legacy/modules/launcher/components/cliphist.py
similarity index 100%
rename from modules/launcher/components/cliphist.py
rename to legacy/modules/launcher/components/cliphist.py
diff --git a/modules/launcher/components/dashboard.py b/legacy/modules/launcher/components/dashboard.py
similarity index 100%
rename from modules/launcher/components/dashboard.py
rename to legacy/modules/launcher/components/dashboard.py
diff --git a/modules/launcher/components/emoji.py b/legacy/modules/launcher/components/emoji.py
similarity index 100%
rename from modules/launcher/components/emoji.py
rename to legacy/modules/launcher/components/emoji.py
diff --git a/modules/launcher/components/kanban.py b/legacy/modules/launcher/components/kanban.py
similarity index 100%
rename from modules/launcher/components/kanban.py
rename to legacy/modules/launcher/components/kanban.py
diff --git a/modules/launcher/components/notification_center.py b/legacy/modules/launcher/components/notification_center.py
similarity index 100%
rename from modules/launcher/components/notification_center.py
rename to legacy/modules/launcher/components/notification_center.py
diff --git a/modules/launcher/components/power.py b/legacy/modules/launcher/components/power.py
similarity index 100%
rename from modules/launcher/components/power.py
rename to legacy/modules/launcher/components/power.py
diff --git a/modules/launcher/components/sh.py b/legacy/modules/launcher/components/sh.py
similarity index 100%
rename from modules/launcher/components/sh.py
rename to legacy/modules/launcher/components/sh.py
diff --git a/modules/launcher/components/tmux.py b/legacy/modules/launcher/components/tmux.py
similarity index 100%
rename from modules/launcher/components/tmux.py
rename to legacy/modules/launcher/components/tmux.py
diff --git a/modules/launcher/components/tools.py b/legacy/modules/launcher/components/tools.py
similarity index 100%
rename from modules/launcher/components/tools.py
rename to legacy/modules/launcher/components/tools.py
diff --git a/modules/launcher/components/wallpapers.py b/legacy/modules/launcher/components/wallpapers.py
similarity index 100%
rename from modules/launcher/components/wallpapers.py
rename to legacy/modules/launcher/components/wallpapers.py
diff --git a/modules/launcher/components/wifi.py b/legacy/modules/launcher/components/wifi.py
similarity index 100%
rename from modules/launcher/components/wifi.py
rename to legacy/modules/launcher/components/wifi.py
diff --git a/modules/launcher/components/window_switcher.py b/legacy/modules/launcher/components/window_switcher.py
similarity index 100%
rename from modules/launcher/components/window_switcher.py
rename to legacy/modules/launcher/components/window_switcher.py
diff --git a/modules/launcher/launcher.py b/legacy/modules/launcher/launcher.py
similarity index 100%
rename from modules/launcher/launcher.py
rename to legacy/modules/launcher/launcher.py
diff --git a/modules/notification_popup.py b/legacy/modules/notification_popup.py
similarity index 100%
rename from modules/notification_popup.py
rename to legacy/modules/notification_popup.py
diff --git a/modules/osd.py b/legacy/modules/osd.py
similarity index 100%
rename from modules/osd.py
rename to legacy/modules/osd.py
diff --git a/services/__init__.py b/legacy/services/__init__.py
similarity index 100%
rename from services/__init__.py
rename to legacy/services/__init__.py
diff --git a/services/brightness.py b/legacy/services/brightness.py
similarity index 100%
rename from services/brightness.py
rename to legacy/services/brightness.py
diff --git a/services/network.py b/legacy/services/network.py
similarity index 100%
rename from services/network.py
rename to legacy/services/network.py
diff --git a/services/notification.py b/legacy/services/notification.py
similarity index 100%
rename from services/notification.py
rename to legacy/services/notification.py
diff --git a/services/powerprofile.py b/legacy/services/powerprofile.py
similarity index 100%
rename from services/powerprofile.py
rename to legacy/services/powerprofile.py
diff --git a/services/screen_record.py b/legacy/services/screen_record.py
similarity index 100%
rename from services/screen_record.py
rename to legacy/services/screen_record.py
diff --git a/styles/bar.css b/legacy/styles/bar.css
similarity index 100%
rename from styles/bar.css
rename to legacy/styles/bar.css
diff --git a/styles/colors.css b/legacy/styles/colors.css
similarity index 100%
rename from styles/colors.css
rename to legacy/styles/colors.css
diff --git a/styles/dashboard.css b/legacy/styles/dashboard.css
similarity index 100%
rename from styles/dashboard.css
rename to legacy/styles/dashboard.css
diff --git a/styles/dock.css b/legacy/styles/dock.css
similarity index 100%
rename from styles/dock.css
rename to legacy/styles/dock.css
diff --git a/styles/launcher.css b/legacy/styles/launcher.css
similarity index 100%
rename from styles/launcher.css
rename to legacy/styles/launcher.css
diff --git a/styles/main.css b/legacy/styles/main.css
similarity index 100%
rename from styles/main.css
rename to legacy/styles/main.css
diff --git a/styles/notif_center.css b/legacy/styles/notif_center.css
similarity index 100%
rename from styles/notif_center.css
rename to legacy/styles/notif_center.css
diff --git a/styles/notification.css b/legacy/styles/notification.css
similarity index 100%
rename from styles/notification.css
rename to legacy/styles/notification.css
diff --git a/styles/osd.css b/legacy/styles/osd.css
similarity index 100%
rename from styles/osd.css
rename to legacy/styles/osd.css
diff --git a/utils/__init__.py b/legacy/utils/__init__.py
similarity index 100%
rename from utils/__init__.py
rename to legacy/utils/__init__.py
diff --git a/utils/animator.py b/legacy/utils/animator.py
similarity index 100%
rename from utils/animator.py
rename to legacy/utils/animator.py
diff --git a/utils/custom_image.py b/legacy/utils/custom_image.py
similarity index 100%
rename from utils/custom_image.py
rename to legacy/utils/custom_image.py
diff --git a/utils/icon_resolver.py b/legacy/utils/icon_resolver.py
similarity index 100%
rename from utils/icon_resolver.py
rename to legacy/utils/icon_resolver.py
diff --git a/utils/icons.py b/legacy/utils/icons.py
similarity index 100%
rename from utils/icons.py
rename to legacy/utils/icons.py
diff --git a/utils/occlusion.py b/legacy/utils/occlusion.py
similarity index 100%
rename from utils/occlusion.py
rename to legacy/utils/occlusion.py
diff --git a/utils/wayland.py b/legacy/utils/wayland.py
similarity index 100%
rename from utils/wayland.py
rename to legacy/utils/wayland.py
diff --git a/mine/.gitignore b/mine/.gitignore
new file mode 100644
index 0000000..9efe598
--- /dev/null
+++ b/mine/.gitignore
@@ -0,0 +1,12 @@
+**/__pycache__/
+*.pyc
+*.pyo
+*.pyd
+# config/hypr/modus.conf
+config.json
+styles/colors.css
+config/hypr/colors.lua
+.venv/*
+.venv/
+
+
diff --git a/mine/LICENSE b/mine/LICENSE
new file mode 100644
index 0000000..13fdf8f
--- /dev/null
+++ b/mine/LICENSE
@@ -0,0 +1,675 @@
+
+ GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users. We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors. You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights. Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received. You must make sure that they, too, receive
+or can get the source code. And you must show them these terms so they
+know their rights.
+
+ Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+ For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software. For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+ Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so. This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software. The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable. Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products. If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+ Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary. To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Use with the GNU Affero General Public License.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+ Copyright (C)
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+ .
+
+ The GNU General Public License does not permit incorporating your program
+into proprietary programs. If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License. But first, please read
+.
diff --git a/mine/README.md b/mine/README.md
new file mode 100644
index 0000000..a68e2f3
--- /dev/null
+++ b/mine/README.md
@@ -0,0 +1,113 @@
+# ⚙️ Modus
+
+
+
+
+
+> **Status**: Always a Work in Progress 🛠️
+
+> **Not optimized**, could use more memory and CPU than expected
+
+This shell setup is intentionally crafted to reflect my personal taste in both aesthetics and workflow.
+It includes core components like a panel, launcher, OSD, and hot-reloadable configurations — all optimized for speed, minimalism, and keyboard-centric interaction.
+
+---
+
+## ✅ Core Features
+
+- [x] Hot reload (no shell restart required)
+- [x] Panel
+- [x] On-Screen Display (OSD)
+- [x] Launcher
+- [x] Notification popups
+
+---
+
+## 🔎 Launcher Plugins — Quick Reference
+
+For full details, see the plugin docs in [Plugin.md](./assets/Plugin.md).
+
+| Plugin | Keyword | What it does / commands | Semicolon Required |
+| --------------- | ------- | ---------------------------------------------------------------------- | ------------------------ |
+| Application | — | Application search (default when no keyword matches) | No |
+| Bookmark | `bm` | `add`, `remove`, `update` | Yes |
+| Calculator | `calc` | Mathematical expressions | No |
+| Clipboard | `clip` | Clipboard history | No |
+| Emoji | `em` | Emoji search | No |
+| Google Lens | `lens` | Capture region and search | No |
+| Google Search | `gg` | Web search | No |
+| YouTube Search | `yt` | YouTube search | No |
+| Link Opener | `lk` | Open a URL | No |
+| Color Picker | `color` | Pick color as HEX/RGB/HSV | No |
+| OTP | `otp` | `add`, `remove` | No |
+| Bluetooth | `bt` | `power`, `scan`, `connect`, `disconnect` | Yes |
+| Password | `pass` | `add`, `remove`, `update`; auto-prompt for master password when needed | Yes (lock/help/list: No) |
+| Power Menu | `pm` | Power options | No |
+| Power Profile | `power` | Power profiles | No |
+| Caffeine | `caff` | On, off, custom durations (30m, 1h, 45s, 600) | No |
+| Reminder | `rem` | Presets, custom times (`10m`, `1h30m`, `21:30`), optional message | No |
+| Todo | `todo` | `add`, `done`, `remove`, `edit`, `clear` | Yes (except `clear`) |
+| Tmux | `tmux` | `new`, `kill`, `rename`, `terminal` | Yes (except `terminal`) |
+| Wallpaper | `wall` | Wallpaper browsing | No |
+| Window Switcher | `win` | Window switching | No |
+| SSH Manager | `ssh` | SSH hosts, `terminal`, `help` | No |
+| Network Manager | `net` | Scan, connect, disconnect, forget | No |
+
+---
+
+Note: `wr` applies a random wallpaper immediately.
+
+## 🧩 Launcher Plugins — Completed
+
+The launcher supports modular plugins to extend functionality. Current plugins include:
+
+- [x] OTP Manager
+- [x] Pass Manager
+- [x] Tmux Session Manager
+- [x] Bookmark Manager
+- [x] Power Profile Manager
+- [x] Window Switcher
+- [x] Power Menu
+- [x] Todo Manager
+- [x] Screen Capture Tool
+- [x] Clipboard History Viewer
+- [x] Emoji Picker
+- [x] Wallpaper Picker
+- [x] Calculator & Unit Converter
+- [x] Caffeine Manager
+- [x] SSH Session Manager
+- [x] Color Picker
+- [x] Reminder
+- [x] Network Manager
+- [x] Bluetooth Manager
+- [x] Google Lens
+
+---
+
+## 🧩 Launcher Plugins — Planned
+
+Planned plugins to further enhance productivity and system control:
+
+- [ ] Calendar with Event Reminders
+- [ ] Notification Manager
+
+---
+
+## 🙏 Special Thanks
+
+A heartfelt thank you to the following people for their incredible contributions, ideas, and inspiration. Your work has been a huge part of shaping this shell!
+
+- [darsh](https://github.com/its-darsh) for creating [**Fabric**](https://github.com/Fabric-Development/fabric/) and the [**Fabrika**](https://github.com/its-darsh/fabrika) config, which served as the foundation for the **Launcher** and **OSD** in this shell.
+
+- [gummy bear album](https://github.com/muhchaudhary) for sharing clever code snippets and useful services that saved countless hours of development.
+
+- [axenide](https://github.com/Axenide) for an inspiring config full of unique ideas, some of which I couldn’t resist adapting and integrating into my own setup.
+
+---
+
+## 📝 License
+
+This project is licensed under the [GNU General Public License](https://www.gnu.org/licenses/gpl-3.0.html) (GPL v3 or later).
+See the [LICENSE](./LICENSE) file for full details.
+
+---
diff --git a/mine/assets/Plugin.md b/mine/assets/Plugin.md
new file mode 100644
index 0000000..34e13de
--- /dev/null
+++ b/mine/assets/Plugin.md
@@ -0,0 +1,572 @@
+# Launcher Plugins Documentation
+
+This document provides comprehensive usage information for all available launcher plugins.
+
+## Table of Contents
+
+- [Application (default)](#application-default)
+- [Bookmark (`bm`)](#bookmark-bm)
+- [Calculator (`calc`)](#calculator-calc)
+- [Clipboard (`clip`)](#clipboard-clip)
+- [Emoji (`em`)](#emoji-em)
+- [Google Lens (`lens`)](#google-lens-lens)
+- [Color Picker (`color`)](#color-picker-color)
+- [Google Search (`gg`)](#google-search-gg)
+- [YouTube Search (`yt`)](#youtube-search-yt)
+- [Link Opener (`lk`)](#link-opener-lk)
+- [OTP (`otp`)](#otp-otp)
+- [Password (`pass`)](#password-pass)
+- [Power Menu (`pm`)](#power-menu-pm)
+- [Power Profile (`power`)](#power-profile-power)
+- [Caffeine (`caff`)](#caffeine-caff)
+- [Reminder (`rem`)](#reminder-rem)
+- [ScreenCapture (`sc`)](#screencapture-sc)
+- [Todo (`todo`)](#todo-todo)
+- [Tmux (`tmux`)](#tmux-tmux)
+- [Wallpaper (`wall`)](#wallpaper-wall)
+- [Window Switcher (`win`)](#window-switcher-win)
+- [SSH Manager (`ssh`)](#ssh-manager-ssh)
+- [Network Manager (`net`)](#network-manager-net)
+- [Bluetooth (`bt`)](#bluetooth-bt)
+
+---
+
+## Application (default)
+
+Launch desktop applications with fuzzy search.
+
+### Usage
+
+```
+application_name
+```
+
+### Examples
+
+- `firefox` — Launch Firefox
+- `code` — Launch VS Code
+- `terminal` — Launch terminal
+
+### Features
+
+- Fuzzy search across desktop files
+- Smart matching prioritizing exact/common apps
+
+### Interactive Features
+
+- Click: launch application
+- Enter: launch selected application
+
+---
+
+## Bookmark (`bm`)
+
+Store and manage bookmarks for URLs, file paths, commands, or any text.
+
+### Commands
+
+#### Add Bookmark
+```
+bm add name content -description(optional) -tags(optional);
+```
+
+#### Remove Bookmark
+```
+bm remove name;
+```
+
+#### Update Bookmark
+```
+bm update name field new_value;
+```
+Fields: `content`, `description`, `tags`
+
+#### Show Help
+```
+bm help
+```
+
+#### Show Bookmark List
+```
+bm add
+bm remove
+bm update
+```
+
+### Interactive Features
+
+- Click: copy content to clipboard
+- Shift+Enter: remove bookmark
+
+---
+
+## Calculator (`calc`)
+
+Perform mathematical calculations.
+
+### Usage
+```
+calc expression
+```
+
+### Examples
+
+- `calc 2 + 2`
+- `calc sqrt(16)`
+- `calc 100 USD to EUR`
+
+### Interactive Features
+
+- Click/Enter: execute calculation in terminal
+
+---
+
+## Clipboard (`clip`)
+
+Access and search clipboard history.
+
+### Usage
+```
+clip [search_term]
+```
+
+### Interactive Features
+
+- Click/Enter: copy to clipboard
+
+---
+
+## Emoji (`em`)
+
+Search and insert emojis by name/description.
+
+### Usage
+```
+em emoji_name
+```
+
+### Interactive Features
+
+- Click/Enter: copy selected emoji
+
+---
+
+## Google Lens (`lens`)
+
+Capture a region of the screen and search with Google Lens.
+
+### Usage
+```
+lens
+```
+or
+```
+google lens
+```
+
+### Behavior
+
+- Activates region selection (slurp)
+- Captures screenshot of selected region (grim)
+- Uploads image to temporary host
+- Opens default browser with Google Lens results
+
+### Interactive Features
+
+- Enter: start capture
+
+---
+
+## Color Picker (`color`)
+
+Pick a color and copy as HEX/RGB/HSV.
+
+### Usage
+```
+color [hex|rgb|hsv]
+```
+
+### Examples
+
+- `color` — Show options
+- `color hex` — Pick HEX
+- `color rgb` — Pick RGB
+- `color hsv` — Pick HSV
+
+### Interactive Features
+
+- Click/Enter: start color picking in chosen format
+
+---
+
+## Google Search (`gg`)
+
+Perform Google searches directly.
+
+### Usage
+```
+gg search_query
+```
+
+### Examples
+
+- `gg python tutorial`
+- `gg weather today`
+
+### Behavior
+
+- Opens browser directly (no in-launcher results)
+
+---
+
+## YouTube Search (`yt`)
+
+Perform YouTube searches directly.
+
+### Usage
+```
+yt search_query
+```
+
+### Behavior
+
+- Opens browser directly
+
+---
+
+## Link Opener (`lk`)
+
+Open URLs and links directly.
+
+### Usage
+```
+lk url_or_domain
+```
+
+### Behavior
+
+- Adds `https://` if protocol is missing
+- Opens in default browser
+
+---
+
+## OTP (`otp`)
+
+Generate and manage TOTP.
+
+### Commands
+
+#### Add OTP Account
+```
+otp add account_name qr
+otp add account_name secret_key
+```
+
+#### Remove OTP Account
+```
+otp remove account_name
+```
+
+#### Show Help/List
+```
+otp help
+otp remove
+```
+
+### Interactive Features
+
+- Click: copy OTP code
+- Shift+Enter: remove account
+
+---
+
+## Password (`pass`)
+
+Secure passwords with master password protection.
+
+### Prompt UX
+
+- If a master password exists and the vault is locked, searching with `pass ...` or using add/remove/update/list will show an inline prompt.
+- Type master password and press Enter (no semicolon). The launcher stays open and the list refreshes.
+- Press Enter on empty input to cancel.
+
+### Commands
+
+#### First-Time Setup
+
+On first use (no master password set), typing `pass` will show an inline prompt to set a master password. Type the password and press Enter. No semicolon is required.
+
+#### Lock Password Vault
+```
+pass lock
+```
+
+#### Add Password
+```
+pass add name username password -website(optional) -notes(optional);
+```
+
+#### Remove Password
+```
+pass remove name;
+```
+
+#### Update Password
+```
+pass update name field new_value;
+```
+Fields: `username`, `password`
+
+#### Show Help/List
+```
+pass help
+pass remove
+pass update
+```
+
+### Interactive Features
+
+- Click: copy password
+- Shift+Enter: remove password
+
+---
+
+## Power Menu (`pm`)
+
+System power actions (shutdown, reboot, etc.).
+
+### Usage
+```
+pm [option]
+```
+
+### External
+
+- `pm shutdown`, `pm reboot`, etc. via external toggle
+
+---
+
+## Power Profile (`power`)
+
+Switch system power profiles.
+
+### Usage
+```
+power [performance|balanced|power-saver]
+```
+
+### Behavior
+
+- Shows current profile, switch on Enter/click
+
+---
+
+## Caffeine (`caff`)
+
+Prevent idle/sleep.
+
+### Usage
+```
+caff [on|off|]
+```
+Examples: `caff on`, `caff 30m`, `caff 1h`, `caff 45s`, `caff 600`
+
+---
+
+## Reminder (`rem`)
+
+Schedule reminders with flexible time formats.
+
+### Usage
+```
+rem [time] [optional message]
+```
+Examples: `rem 10m`, `rem 1h30m`, `rem 21:30`, `rem 45`, `rem 10m Take a break`
+
+### Interactive Features
+
+- Click/Enter: schedule reminder
+
+---
+
+## ScreenCapture (`sc`)
+
+Screenshots and screen recording.
+
+### External (no UI)
+
+- `sc fs` — Fullscreen
+- `sc region` — Region
+- `sc record` — Toggle recording
+- `sc record mute` — Recording muted
+- `sc stop` — Stop recording
+
+---
+
+## Todo (`todo`)
+
+Manage todos with priorities.
+
+### Commands
+```
+todo add title [-d description] [-p low|medium|high];
+todo done title;
+todo remove title;
+todo edit title field new_value;
+```
+- Clear: `todo clear`
+- Help/List: `todo help`, and `todo add|done|remove|edit`
+
+### Interactive Features
+
+- Click: toggle completion
+- Shift+Enter: toggle completion
+- Alt+Enter: remove todo
+
+---
+
+## Tmux (`tmux`)
+
+Manage tmux sessions.
+
+### Commands
+```
+tmux new session_name [directory];
+tmux kill session_name;
+tmux rename old_name new_name;
+tmux terminal terminal_name
+```
+- Help/List: `tmux help`, `tmux new|kill|rename|terminal`
+
+### Interactive Features
+
+- Click: attach to session
+- Shift+Enter: kill session
+
+---
+
+## Wallpaper (`wall`)
+
+Browse and set wallpapers.
+
+### Usage
+```
+wall [search_term]
+```
+
+### Interactive Features
+
+- Click/Enter: set wallpaper
+
+---
+
+## Window Switcher (`win`)
+
+Switch between open windows/applications.
+
+### Usage
+```
+win [window_name]
+```
+
+### Interactive Features
+
+- Click/Enter: focus window
+
+---
+
+## SSH Manager (`ssh`)
+
+List and connect to SSH hosts from config/known_hosts.
+
+### Commands
+- Terminal preference: `ssh terminal terminal_name`
+- Help: `ssh help`
+
+### Interactive Features
+
+- Click/Enter: open terminal & connect
+- Shift+Enter: copy ssh command
+
+---
+
+## Network Manager (`net`)
+
+Browse, scan, connect to, and manage NetworkManager Wi‑Fi connections.
+
+### Usage
+```
+net [connection_prefix] [ifname]
+```
+
+### Direct
+```
+net connect [ifname]
+net disconnect [ifname]
+net scan [ifname]
+net forget
+```
+
+### Prompt UX
+
+- When a password/identity prompt is open, the launcher captures input until Enter (submit) or empty Enter (cancel).
+
+### Interactive Features
+
+- Click/Enter on saved network: connect/disconnect
+- Click/Enter on access point: connect (prompts if needed)
+- Shift+Enter on saved network: forget
+
+---
+
+## Bluetooth (`bt`)
+
+Manage Bluetooth power, scanning, and device connections.
+
+### Commands
+```
+bt power on;
+bt power off;
+bt scan start;
+bt scan stop;
+bt connect Device Name;
+bt disconnect Device Name;
+```
+- Help/List: `bt help`, `bt`
+
+### Notes
+
+- Commands with arguments require a trailing semicolon (`;`).
+
+---
+
+## Quick Reference
+
+| Plugin | Keyword | What it does / commands | Semicolon Required |
+| ----------------- | -------- | ------------------------------------------------------------------- | --------------------------------- |
+| Application | — | Application search (default) | No |
+| Bookmark | bm | add, remove, update | Yes |
+| Calculator | calc | Evaluate expressions | No |
+| Clipboard | clip | Clipboard history | No |
+| Emoji | em | Emoji search | No |
+| Google Lens | lens | Capture region and search | No |
+| Google Search | gg | Web search (opens browser) | No |
+| YouTube Search | yt | YouTube search (opens browser) | No |
+| Link Opener | lk | Open URL/domain (adds https://) | No |
+| Color Picker | color | HEX/RGB/HSV | No |
+| OTP | otp | add, remove | No |
+| Password | pass | add, remove, update; auto-prompt for master password when needed | Yes (lock/help/list: No); prompts: No |
+| Power Menu | pm | Power actions (shutdown, reboot, etc.) | No |
+| Power Profile | power | performance, balanced, power-saver | No |
+| Caffeine | caff | on, off, durations (30m, 1h, 45s, 600) | No |
+| Reminder | rem | Flexible times with optional message | No |
+| Todo | todo | add, done, remove, edit, clear | Yes (clear/help/list: No) |
+| Tmux | tmux | new, kill, rename, terminal | Yes (terminal/help/list: No) |
+| Wallpaper | wall | Browse/apply wallpapers | No |
+| Window Switcher | win | Switch between windows | No |
+| SSH Manager | ssh | Hosts, terminal, help | No |
+| Network Manager | net | scan/connect/disconnect/forget; inline password prompt when needed | No |
+| Bluetooth | bt | power on/off, scan start/stop, connect/disconnect | Yes |
+
+Notes:
+- Inline prompts (Wi‑Fi password, master password) capture input until Enter (submit) or empty Enter (cancel); no semicolon is used in prompts.
diff --git a/mine/assets/example_wallpaper.png b/mine/assets/example_wallpaper.png
new file mode 100644
index 0000000..663f18f
Binary files /dev/null and b/mine/assets/example_wallpaper.png differ
diff --git a/mine/assets/screenshots.png b/mine/assets/screenshots.png
new file mode 100644
index 0000000..d3e0d30
Binary files /dev/null and b/mine/assets/screenshots.png differ
diff --git a/mine/config/data.py b/mine/config/data.py
new file mode 100644
index 0000000..3b4f322
--- /dev/null
+++ b/mine/config/data.py
@@ -0,0 +1,151 @@
+from fabric.utils import Gdk, GLib, get_relative_path, logger, os
+
+from utils.constants import DEFAULT
+from utils.functions import read_json_file, write_json_file
+
+APP_NAME = "modus"
+APP_NAME_CAP = "Modus"
+HOME_DIR = GLib.get_home_dir()
+
+CACHE_DIR = f"{GLib.get_user_cache_dir()}/{APP_NAME}"
+os.makedirs(CACHE_DIR, exist_ok=True)
+ICON_CACHE_FILE = f"{CACHE_DIR}/icons.json"
+CONFIG_FILE = get_relative_path("../config.json")
+
+SYSTEM_CACHE_DIR = GLib.get_user_cache_dir()
+WALLPAPER_PATH = f"{HOME_DIR}/Pictures/Wallpapers"
+WALLPAPER_THUMBS_PATH = f"{WALLPAPER_PATH}/.thumbnails"
+WALLPAPERS_THUMBNAILS_SIZE = 200
+
+RECENT_EMOJIS_FILE = f"{CACHE_DIR}/recent_emojis.json"
+
+# Matugen template and output paths
+MATUGEN_TEMPLATE_PATH = get_relative_path("../config/matugen/templates/modus.css")
+MATUGEN_OUTPUT_PATH = get_relative_path("../styles/colors.css")
+
+CLIPBOARD_THUMBS_DIR = f"{GLib.get_user_cache_dir()}/{APP_NAME}/clipboard_thumbs"
+CLIPBOARD_DB_PATH = f"{GLib.get_user_cache_dir()}/cliphist/db"
+
+plugins = f"{GLib.get_user_state_dir()}/{APP_NAME}"
+
+screen = Gdk.Screen.get_default()
+CURRENT_WIDTH = screen.get_width()
+CURRENT_HEIGHT = screen.get_height()
+
+
+def generate_default_config():
+ """Generate default config.json file if it doesn't exist"""
+ if not os.path.exists(CONFIG_FILE):
+ write_json_file(DEFAULT, CONFIG_FILE)
+ logger.success("Config file generated successfully")
+
+
+def DATA():
+ config = read_json_file(CONFIG_FILE) or {}
+ debug = config.get("debug", {})
+ panel = config.get("panel", {})
+ corners = config.get("corners", {})
+ wallpaper_dir = config.get("wallpaper_dir", {})
+ osd = config.get("osd", {})
+ notifications = config.get("notifications", {})
+ keyboard_layout = config.get("keyboard_layout", {})
+
+ return {
+ "debug": debug.get("enabled"),
+ "wallpaper_dir": wallpaper_dir.get("path"),
+ "corners": corners.get("enabled"),
+ "panel_enabled": panel.get("enabled"),
+ "panel_autohide": panel.get("autohide"),
+ "panel_position": panel.get("position"),
+ "systray": panel.get("systray", {}).get("enabled"),
+ "systray_hide_icons": panel.get("systray", {}).get("hide_items", []),
+ "datetime": panel.get("datetime", {}).get("enabled"),
+ "datetime_12hrs": panel.get("datetime", {}).get("12hrs"),
+ "battery": panel.get("battery", {}).get("enabled"),
+ "battery_label": panel.get("battery", {}).get("label"),
+ "battery_hide_on_full": panel.get("battery", {}).get("hide_on_full"),
+ "battery_icon_size": panel.get("battery", {}).get("icon_size", 16),
+ "network": panel.get("network", {}).get("enabled"),
+ "bluetooth": panel.get("bluetooth", {}).get("enabled"),
+ "workspace": panel.get("workspace", {}).get("enabled"),
+ "workspace_hide_special": panel.get("workspace", {}).get("hide_special_ws"),
+ "taskbar": panel.get("taskbar", {}).get("enabled"),
+ "taskbar_icon_size": panel.get("taskbar", {}).get("icon_size"),
+ "taskbar_hide_empty": panel.get("taskbar", {}).get("hide_empty"),
+ "taskbar_hide_special": panel.get("taskbar", {}).get("hide_special"),
+ "taskbar_group_apps": panel.get("taskbar", {}).get("group_apps"),
+ "osd": osd.get("enabled"),
+ "osd_audio": osd.get("audio"),
+ "osd_brightness": osd.get("brightness"),
+ "osd_capslock": osd.get("capslock"),
+ "osd_kb_layout": osd.get("kb_layout"),
+ "osd_network": osd.get("network"),
+ "osd_microphone": osd.get("microphone"),
+ "osd_battery": osd.get("battery"),
+ "osd_cooldown_ms": osd.get("cooldown_ms"),
+ "osd_position": osd.get("position"),
+ "notifications": notifications.get("enabled"),
+ "notifications_timeout": notifications.get("timeout"),
+ "notifications_image_size": notifications.get("image_size"),
+ "notifications_width": notifications.get("width"),
+ "notifications_buttons_per_row": notifications.get("buttons_per_row"),
+ "notifications_enabled": notifications.get("enabled"),
+ "notifications_corner_size": notifications.get("corner_size"),
+ "notifications_revealer_duration": notifications.get("revealer_duration"),
+ "keyboard_layouts": keyboard_layout.get("layouts", ["us", "np"]),
+ }
+
+
+def update_setting(key: str, value) -> bool:
+ """Update a specific setting in config.json."""
+ config = read_json_file(CONFIG_FILE) or {}
+ key_map = {
+ "osd": ("osd", "enabled"),
+ "osd_position": ("osd", "position"),
+ "osd_audio": ("osd", "audio"),
+ "osd_brightness": ("osd", "brightness"),
+ "osd_capslock": ("osd", "capslock"),
+ "osd_kb_layout": ("osd", "kb_layout"),
+ "osd_network": ("osd", "network"),
+ "osd_microphone": ("osd", "microphone"),
+ "osd_battery": ("osd", "battery"),
+ "panel_enabled": ("panel", "enabled"),
+ "panel_position": ("panel", "position"),
+ "panel_autohide": ("panel", "autohide"),
+ "systray": ("panel", "systray", "enabled"),
+ "systray_hide_icons": ("panel", "systray", "hide_items"),
+ "network": ("panel", "network", "enabled"),
+ "bluetooth": ("panel", "bluetooth", "enabled"),
+ "datetime": ("panel", "datetime", "enabled"),
+ "datetime_12hrs": ("panel", "datetime", "12hrs"),
+ "battery": ("panel", "battery", "enabled"),
+ "battery_label": ("panel", "battery", "label"),
+ "battery_hide_on_full": ("panel", "battery", "hide_on_full"),
+ "workspace": ("panel", "workspace", "enabled"),
+ "workspace_hide_special": ("panel", "workspace", "hide_special_ws"),
+ "taskbar": ("panel", "taskbar", "enabled"),
+ "taskbar_hide_empty": ("panel", "taskbar", "hide_empty"),
+ "notifications": ("notifications", "enabled"),
+ "wallpaper_dir": ("wallpaper_dir", "path"),
+ "corners": ("corners", "enabled"),
+ "debug": ("debug", "enabled"),
+ }
+
+ if key not in key_map:
+ logger.warning(f"Unknown setting key: {key}")
+ return False
+
+ path = key_map[key]
+ if len(path) == 2:
+ if path[0] not in config:
+ config[path[0]] = {}
+ config[path[0]][path[1]] = value
+ elif len(path) == 3:
+ if path[0] not in config:
+ config[path[0]] = {}
+ if path[1] not in config[path[0]]:
+ config[path[0]][path[1]] = {}
+ config[path[0]][path[1]][path[2]] = value
+
+ write_json_file(config, CONFIG_FILE)
+ return True
diff --git a/mine/config/hypr/modus.lua b/mine/config/hypr/modus.lua
new file mode 100644
index 0000000..64c4d78
--- /dev/null
+++ b/mine/config/hypr/modus.lua
@@ -0,0 +1,152 @@
+local modus = "/home/sankalp/.config/Modus"
+
+hl.on("hyprland.start", function()
+ local cmds = {
+ "uwsm app -- awww-daemon",
+ "wl-paste --type text --watch cliphist store",
+ "wl-paste --type image --watch cliphist store",
+ "pgrep -x hypridle >/dev/null || uwsm app -- hypridle",
+ "cd " .. modus .. " && uwsm app -- uv run main.py",
+ }
+ for i = 1, #cmds do
+ local cmd = cmds[i]
+ hl.exec_cmd(cmd)
+ end
+end)
+
+local fabricSend = "uwsm app -- fabric-cli exec modus "
+
+-- notification testing
+for key, cmd in pairs({
+ ["ALT + F12"] = 'notify-send \'Test notification\' "Here\'s a really long message to test truncation and wrapping\\nYou can middle click or flick this notification to dismiss it!" -a \'terminal\' -A "Test1=I got it!" -A "Test2=Another action"',
+ ["ALT + Equal"] = "notify-send 'hmm' ${SLURP_ARGS}",
+ ["SHIFT + ALT + N"] = 'notify-send "Hello" "FIRE IN THE HOLE‼️🗣️🔥🕳️" -i "/home/sankalp/.face.icon" -A "🗣️" -A "🔥" -A "🕳️" -a "Source Code"',
+}) do
+ hl.bind(key, hl.dsp.exec_cmd(cmd))
+end
+
+-- Reload Modus
+hl.bind("ALT + SHIFT + R", hl.dsp.exec_cmd("killall modus; cd " .. modus .. " && uwsm app -- uv run main.py"))
+
+-- Fabric Launcher Binds
+for key, method in pairs({
+ ["SUPER + D"] = "launcher.toggle()", -- Launcher
+ ["SUPER + E"] = "launcher.toggle('em')", -- Emoji
+ ["SUPER + V"] = "launcher.toggle('clip')", -- Clipboard
+ ["SUPER + W"] = "launcher.toggle('wall')", -- Wallpaper
+ ["SUPER + X"] = "launcher.toggle('pm')", -- Powermenu
+ ["SUPER + Z"] = "launcher.toggle('sc')", -- Screencapture
+ ["SUPER + O"] = "launcher.toggle('otp')", -- OTP
+ ["SUPER + SHIFT + P"] = "launcher.toggle('pass')", -- Password Manager
+ ["SUPER + S"] = "launcher.toggle('sc region', external=True)", -- Screenshot region
+ ["ALT + SHIFT + W"] = "launcher.toggle('wr', external=True)", -- Random Wallpaper
+ ["SUPER + G"] = "launcher.toggle('gg')", -- Google Search
+ ["ALT + W"] = "launcher.toggle('win')", -- Window Switcher
+ ["SUPER + T"] = "launcher.toggle('tmux')", -- Tmux
+ ["ALT + SPACE"] = "switch_keyboard_layout()", -- KB_Layout Switcher
+}) do
+ hl.bind(key, hl.dsp.exec_cmd(fabricSend .. '"' .. method .. '"'))
+end
+
+-- Audio keys
+for key, cmd in pairs({
+ AudioRaiseVolume = "wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 5%+",
+ AudioLowerVolume = "wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-",
+ AudioMute = "wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle",
+ AudioMicMute = "wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle",
+}) do
+ hl.bind(
+ "XF86" .. key,
+ hl.dsp.exec_cmd(cmd .. " && " .. fabricSend .. '"osd_show_audio()"'),
+ { locked = true, repeating = true }
+ )
+end
+
+-- Brightness keys
+for key, cmd in pairs({
+ MonBrightnessUp = "brightnessctl -e4 -n2 set 5%+",
+ MonBrightnessDown = "brightnessctl -e4 -n2 set 5%-",
+}) do
+ hl.bind(
+ "XF86" .. key,
+ hl.dsp.exec_cmd(cmd .. " && " .. fabricSend .. '"osd_show_brightness()"'),
+ { locked = true, repeating = true }
+ )
+end
+
+hl.layer_rule({
+ match = {
+ namespace = "fabric",
+ },
+ no_anim = true,
+})
+
+local colors = dofile(modus .. "/config/hypr/colors.lua")
+
+hl.config({
+ general = {
+ col = {
+ active_border = colors.primary,
+ inactive_border = colors.surface,
+ },
+ gaps_in = 2,
+ gaps_out = 4,
+ border_size = 2,
+ layout = "scrolling",
+ },
+ decoration = {
+ blur = {
+ enabled = false,
+ size = 5,
+ passes = 3,
+ new_optimizations = true,
+ contrast = 1,
+ brightness = 1,
+ },
+ rounding = 14,
+ shadow = {
+ enabled = false,
+ range = 10,
+ render_power = 2,
+ color = "rgba(0, 0, 0, 0.25)",
+ },
+ },
+ animations = {
+ enabled = true,
+ },
+})
+
+-- Curves
+hl.curve("expressiveFastSpatial", { type = "bezier", points = { { 0.42, 1.67 }, { 0.21, 0.90 } } })
+hl.curve("expressiveSlowSpatial", { type = "bezier", points = { { 0.39, 1.29 }, { 0.35, 0.98 } } })
+hl.curve("expressiveDefaultSpatial", { type = "bezier", points = { { 0.38, 1.21 }, { 0.22, 1.00 } } })
+hl.curve("emphasizedDecel", { type = "bezier", points = { { 0.05, 0.7 }, { 0.1, 1 } } })
+hl.curve("emphasizedAccel", { type = "bezier", points = { { 0.3, 0 }, { 0.8, 0.15 } } })
+hl.curve("standardDecel", { type = "bezier", points = { { 0, 0 }, { 0, 1 } } })
+hl.curve("menu_decel", { type = "bezier", points = { { 0.1, 1 }, { 0, 1 } } })
+hl.curve("menu_accel", { type = "bezier", points = { { 0.52, 0.03 }, { 0.72, 0.08 } } })
+hl.curve("stall", { type = "bezier", points = { { 1, -0.1 }, { 0.7, 0.85 } } })
+
+-- Configs
+-- Animations
+local animations = {
+ -- leaf, speed, bezier, style
+ { "windowsIn", 3, "emphasizedDecel", "popin 80%" },
+ { "fadeIn", 3, "emphasizedDecel" },
+ { "windowsOut", 2, "emphasizedDecel", "popin 90%" },
+ { "fadeOut", 2, "emphasizedDecel" },
+ { "windowsMove", 3, "emphasizedDecel", "slide" },
+ { "border", 10, "emphasizedDecel" },
+ { "layersIn", 2.7, "emphasizedDecel", "popin 93%" },
+ { "layersOut", 2.4, "menu_accel", "popin 94%" },
+ { "fadeLayersIn", 0.5, "menu_decel" },
+ { "fadeLayersOut", 2.7, "stall" },
+ { "workspaces", 7, "menu_decel", "slide" },
+ { "specialWorkspaceIn", 2.8, "emphasizedDecel", "slidevert" },
+ { "specialWorkspaceOut", 1.2, "emphasizedAccel", "slidevert" },
+ { "zoomFactor", 3, "standardDecel" },
+}
+
+for _, anim in ipairs(animations) do
+ hl.animation({ leaf = anim[1], enabled = true, speed = anim[2], bezier = anim[3], style = anim[4] })
+end
diff --git a/mine/config/matugen/templates/hyprland-colors.lua b/mine/config/matugen/templates/hyprland-colors.lua
new file mode 100644
index 0000000..fc096da
--- /dev/null
+++ b/mine/config/matugen/templates/hyprland-colors.lua
@@ -0,0 +1,5 @@
+return {
+<* for name, value in colors *>
+ {{name}} = "rgb({{value.default.hex_stripped}})",
+<* endfor *>
+}
diff --git a/mine/config/matugen/templates/modus.css b/mine/config/matugen/templates/modus.css
new file mode 100644
index 0000000..7d8c607
--- /dev/null
+++ b/mine/config/matugen/templates/modus.css
@@ -0,0 +1,32 @@
+:vars {
+ --foreground: {{colors.on_background.default.hex}};
+ --background: {{colors.background.default.hex}};
+ --cursor: {{colors.on_background.default.hex}};
+ --primary: {{colors.primary.default.hex}};
+ --on-primary: {{colors.on_primary.default.hex}};
+ --secondary: {{colors.secondary.default.hex}};
+ --on-secondary: {{colors.on_secondary.default.hex}};
+ --tertiary: {{colors.tertiary.default.hex}};
+ --on-tertiary: {{colors.on_tertiary.default.hex}};
+ --surface: {{colors.surface.default.hex}};
+ --surface-bright: {{colors.surface_bright.default.hex}};
+ --error: {{colors.error.default.hex}};
+ --error-dim: {{colors.error.default.hex | set_lightness: -10.0}};
+ --on-error: {{colors.on_error.default.hex}};
+ --error-container: {{colors.error_container.default.hex}};
+ --outline: {{colors.outline.default.hex}};
+ --shadow: {{colors.shadow.default.hex}};
+ --red: {{colors.red.default.hex}};
+ --red-dim: {{colors.red.default.hex | set_lightness: -10.0}};
+ --green: {{colors.green.default.hex}};
+ --green-dim: {{colors.green.default.hex | set_lightness: -10.0}};
+ --yellow: {{colors.yellow.default.hex}};
+ --yellow-dim: {{colors.yellow.default.hex | set_lightness: -10.0}};
+ --blue: {{colors.blue.default.hex}};
+ --blue-dim: {{colors.blue.default.hex | set_lightness: -10.0}};
+ --magenta: {{colors.magenta.default.hex}};
+ --magenta-dim: {{colors.magenta.default.hex | set_lightness: -10.0}};
+ --cyan: {{colors.cyan.default.hex}};
+ --cyan-dim: {{colors.cyan.default.hex | set_lightness: -10.0}};
+ --white: {{colors.white.default.hex}};
+}
diff --git a/mine/main.py b/mine/main.py
new file mode 100644
index 0000000..623d651
--- /dev/null
+++ b/mine/main.py
@@ -0,0 +1,70 @@
+import faulthandler
+
+from fabric import Application
+from fabric.utils import get_relative_path, logger, monitor_file, os
+
+import config.data as data
+from modules.launcher.main import LauncherWindow
+from modules.notifications import NotificationWindow
+from modules.osd.main import OSDWindow
+from modules.panel.main import Panel
+from services import KeyboardLayout, start_config_service
+from services.wallpaper import generate_colors_from_wallpaper
+from utils.corners import corners
+from utils.functions import set_process_name
+
+if __name__ == "__main__":
+ # Enable faulthandler to capture segfault information
+ faulthandler.enable()
+
+ set_process_name(data.APP_NAME)
+
+ data.generate_default_config()
+
+ # Generate colors.css if it doesn't exist
+ colors_css_path = get_relative_path("styles/colors.css")
+ if not os.path.exists(colors_css_path):
+ default_wallpaper = get_relative_path("assets/example_wallpaper.png")
+ if os.path.exists(default_wallpaper):
+ generate_colors_from_wallpaper(default_wallpaper)
+
+ debug_enabled = data.DATA().get("debug")
+ if not debug_enabled:
+ for log in ["fabric", "services", "utils", "modules", "config"]:
+ logger.disable(log)
+
+ config_service = start_config_service()
+
+ panel = Panel()
+ launcher = LauncherWindow()
+ osd = OSDWindow()
+ notif = NotificationWindow()
+
+ css_file = monitor_file(get_relative_path("styles"))
+ _ = css_file.connect("changed", lambda *_: set_css())
+
+ app = Application(f"{data.APP_NAME}", panel, *corners, osd, launcher, notif)
+
+ @Application.action()
+ def set_css():
+ app.set_stylesheet_from_file(
+ get_relative_path("styles/main.css"),
+ )
+
+ @Application.action()
+ def switch_keyboard_layout():
+ keyboard_layout = KeyboardLayout.get_initial()
+ keyboard_layout.switch_to_next()
+
+ @Application.action()
+ def osd_show_audio():
+ osd.get_child().show_audio_osd()
+
+ @Application.action()
+ def osd_show_brightness():
+ osd.get_child().show_brightness_osd()
+
+ app.set_css = set_css
+ app.set_css()
+
+ app.run()
diff --git a/mine/modules/desktopwidget.py b/mine/modules/desktopwidget.py
new file mode 100644
index 0000000..545b756
--- /dev/null
+++ b/mine/modules/desktopwidget.py
@@ -0,0 +1,76 @@
+from fabric.widgets.box import Box
+from fabric.widgets.datetime import DateTime
+from fabric.widgets.wayland import WaylandWindow as Window
+
+import config.data as data
+from services import has_config_changed, on_config_change
+
+
+class ClockWidget(Box):
+ def __init__(self, **kwargs):
+ cfg = data.DATA()
+ use_12hr = cfg["desktop_widget_time_format"] == "12 hrs"
+ time_fmt = "%I:%M %p" if use_12hr else "%H:%M"
+ date_fmt = cfg["desktop_widget_date_format"]
+
+ self._date = DateTime(name="date", formatters=date_fmt, interval=10000)
+ self._time = DateTime(name="time", formatters=time_fmt)
+
+ children = [self._date, self._time]
+
+ super().__init__(
+ orientation="v",
+ name="clock-widget",
+ children=children,
+ **kwargs,
+ )
+
+ def apply_config(self):
+ cfg = data.DATA()
+ use_12hr = cfg["desktop_widget_time_format"] == "12 hrs"
+ time_fmt = "%I:%M %p" if use_12hr else "%H:%M"
+ date_fmt = cfg["desktop_widget_date_format"]
+
+ self._time.formatters = [time_fmt]
+ self._time.interval = self._time.interval
+
+ self._date.formatters = [date_fmt]
+ self._date.interval = self._date.interval
+
+
+class DesktopWidget(Window):
+ def __init__(self, **kwargs):
+ cfg = data.DATA()
+ self._clock = ClockWidget()
+
+ super().__init__(
+ name="clock",
+ layer="bottom",
+ anchor=cfg["desktop_widget_anchor"],
+ margin=cfg["desktop_widget_margin"],
+ exclusivity="none",
+ child=self._clock,
+ pass_through=True,
+ all_visible=True,
+ visible=cfg["desktop_widget_enabled"],
+ )
+
+ on_config_change(self._on_config_change)
+
+ def _on_config_change(self, new_config, old_config):
+ config_paths = [
+ "desktop_widget.enabled",
+ "desktop_widget.time_format",
+ "desktop_widget.date_format",
+ "desktop_widget.anchor",
+ "desktop_widget.margin",
+ ]
+
+ if not any(has_config_changed(old_config, new_config, p) for p in config_paths):
+ return
+
+ cfg = data.DATA()
+ self.set_visible(cfg["desktop_widget_enabled"])
+ self.set_property("anchor", cfg["desktop_widget_anchor"])
+ self.set_property("margin", cfg["desktop_widget_margin"])
+ self._clock.apply_config()
diff --git a/mine/modules/launcher/base.py b/mine/modules/launcher/base.py
new file mode 100644
index 0000000..46fa3ad
--- /dev/null
+++ b/mine/modules/launcher/base.py
@@ -0,0 +1,58 @@
+from abc import ABC, abstractmethod
+from typing import Any
+
+
+class LauncherPlugin(ABC):
+ """Base class for all launcher plugins."""
+
+ def __init__(self, handler: Any):
+ self.handler = handler
+
+ @property
+ @abstractmethod
+ def name(self) -> str:
+ """Unique name of the plugin."""
+ pass
+
+ @property
+ @abstractmethod
+ def icon(self) -> str:
+ """Icon name for the plugin."""
+ pass
+
+ @property
+ @abstractmethod
+ def keywords(self) -> list[str]:
+ """List of keywords that trigger this plugin."""
+ pass
+
+ @property
+ def full_viewport_clear(self) -> bool:
+ """Whether the plugin needs to clear the viewport and hide the scroll window when activated."""
+ return False
+
+ @property
+ def is_password_mode(self) -> bool:
+ """Whether the plugin is currently in password mode (input should be masked)."""
+ return False
+
+ @abstractmethod
+ def on_search(self, text: str) -> None:
+ """Called when the user types in the launcher while this plugin is active."""
+ pass
+
+ def on_activate(self) -> None:
+ """Called when the plugin is activated (e.g. by keyword)."""
+ pass
+
+ def on_deactivate(self) -> None:
+ """Called when the plugin is deactivated."""
+ pass
+
+ def handle_external(self, command: str, args: str) -> None:
+ """Handle external commands sent to the plugin."""
+ pass
+
+ def on_submit(self, text: str) -> None:
+ """Called when the user presses Enter while this plugin is active."""
+ pass
diff --git a/mine/modules/launcher/main.py b/mine/modules/launcher/main.py
new file mode 100644
index 0000000..6f7b3d2
--- /dev/null
+++ b/mine/modules/launcher/main.py
@@ -0,0 +1,483 @@
+from fabric.core import Service, Signal
+from fabric.utils import GLib, Gdk, Gtk, get_desktop_applications, remove_handler
+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.wayland import WaylandWindow as Window
+
+from utils.animatedscrollable import AnimatedScrollable
+from utils.clippingbox import ClippingBox
+from utils.functions import add_style_class_lazy, debounce, get_children_height_limit
+
+from modules.launcher.plugin_registry import build_plugins
+
+
+class Thread:
+ def __init__(self, func, *data, name: str | None = None):
+ self.func = func
+ self.data = data
+ self.stopped = False
+ self.gthread = GLib.Thread.new(name, self.wrapper, *data)
+
+ def stop(self):
+ self.stopped = True
+
+ def wrapper(self, *data):
+ while not self.stopped:
+ if not self.func(*data):
+ break
+ return self.gthread.exit()
+
+
+class LauncherListsHandler(Service):
+ @Signal
+ def launched(self) -> None: ...
+
+ @Signal
+ def slot_ready(self, button: Button, mode: str) -> None: ...
+
+ @Signal
+ def start(self, mode: str) -> None: ...
+
+ @Signal
+ def done(self) -> None: ...
+
+ def __init__(self, viewport=None, **kwargs):
+ super().__init__(**kwargs)
+ self._query_handler: int = 0
+ self._query_thread: Thread | None = None
+ self.viewport = viewport
+ self.header_entry = None
+
+ self.plugins = build_plugins(self)
+
+ def stop(self):
+ if self._query_handler:
+ remove_handler(self._query_handler)
+ self._query_handler = 0
+ if self._query_thread:
+ self._query_thread.stop()
+ self._query_thread = None
+
+ for plugin in self.plugins.values():
+ if hasattr(plugin, "stop"):
+ plugin.stop()
+ return
+
+ def __getattr__(self, name: str):
+ raise AttributeError(name)
+
+ def clear_viewport(self):
+ if self.viewport:
+ for old_slot in self.viewport.children:
+ self.viewport.remove(old_slot)
+ old_slot.destroy()
+
+ def clear_input(self):
+ if self.header_entry:
+ self.header_entry._ignore_change = True
+ self.header_entry.set_text("")
+
+
+class Launcher(Box):
+ @Signal
+ def launched(self): ...
+
+ def __init__(self, **kwargs):
+ super().__init__(name="launcher", spacing=4, orientation="v", **kwargs)
+
+ self._arranger_handler: int = 0
+ self._all_apps = get_desktop_applications()
+ self._selected_index: int = -1
+
+ self.viewport = Box(spacing=4, orientation="v")
+ self.max_children = 4
+ self.launch_handler = LauncherListsHandler(
+ on_start=lambda *_: self.header.add_style_class("shine"),
+ on_done=lambda *_: (
+ self.post_viewport_arrange(),
+ self.header.remove_style_class("shine"),
+ ),
+ on_slot_ready=self.on_slot_ready,
+ on_launched=lambda *_: self.launched(),
+ viewport=self.viewport,
+ )
+
+ self.header_icon = Image(icon_name="system-search-symbolic")
+ self.header_entry = Entry(
+ placeholder="Search...",
+ style_classes="app-search-entry",
+ h_expand=True,
+ on_activate=self.on_entry_accept,
+ on_changed=self.on_entry_changed,
+ ).build(lambda entry, _: entry.connect("key-press-event", self.on_key_press))
+
+ self.launch_handler.header_entry = self.header_entry
+
+ self.header = Box(
+ style_classes="app-search-header",
+ spacing=8,
+ orientation="h",
+ children=[
+ self.header_icon,
+ self.header_entry,
+ ],
+ )
+
+ self.scrolled_window = AnimatedScrollable(
+ max_content_size=(280, 320),
+ child=self.viewport,
+ h_expand=True,
+ v_expand=True,
+ visible=False,
+ )
+
+ self.scrolled_clip = ClippingBox(
+ style_classes="launcher-scroll-clip",
+ children=self.scrolled_window,
+ visible=False,
+ )
+ self.scrolled_window.connect("unmap", lambda: self.scrolled_clip.hide())
+
+ self.children = self.header, self.scrolled_clip
+ self.active_plugin = None
+
+ def route_by_keyword(self, text: str) -> bool:
+ stripped = text.lstrip()
+ lower = stripped.lower()
+
+ def starts(prefix: str) -> bool:
+ return lower.startswith(prefix + " ") or lower == prefix
+
+ for plugin in self.launch_handler.plugins.values():
+ # Check if plugin has new interface
+ keywords = getattr(plugin, "keywords", [])
+ for keyword in keywords:
+ if starts(keyword):
+ arg = stripped[len(keyword) :].lstrip()
+
+ # Update UI
+ if hasattr(plugin, "icon"):
+ self.header_icon.set_from_icon_name(plugin.icon)
+
+ # Handle plugins that need full viewport clear (e.g. search queries)
+ if getattr(plugin, "full_viewport_clear", False):
+ self.launch_handler.clear_viewport()
+ self.scrolled_window.animate_size(0)
+ self.scrolled_window.hide()
+ self.scrolled_clip.hide()
+
+ # Activate plugin
+ if self.active_plugin != plugin:
+ if self.active_plugin and hasattr(
+ self.active_plugin, "on_deactivate"
+ ):
+ self.active_plugin.on_deactivate()
+ self.active_plugin = plugin
+ if hasattr(plugin, "on_activate"):
+ plugin.on_activate()
+
+ # Call search
+ if hasattr(plugin, "on_search"):
+ plugin.on_search(arg)
+
+ return True
+
+ # No keyword matched
+ if self.active_plugin:
+ if hasattr(self.active_plugin, "on_deactivate"):
+ self.active_plugin.on_deactivate()
+ self.active_plugin = None
+
+ return False
+
+ def handle_external(self, command: str, text: str = "") -> bool:
+ # 1. Try direct plugin name match
+ plugin = self.launch_handler.plugins.get(command)
+ if plugin:
+ if hasattr(plugin, "handle_external"):
+ plugin.handle_external(command, text)
+ return True
+ return False
+
+ # 2. Try keyword match
+ for plugin in self.launch_handler.plugins.values():
+ keywords = getattr(plugin, "keywords", [])
+ if command in keywords and hasattr(plugin, "handle_external"):
+ plugin.handle_external(command, text)
+ return True
+
+ return False
+
+ def _set_entry_password_mode(self, enabled: bool):
+ """Set entry widget to password mode (show asterisks) or normal mode"""
+ if self.header_entry:
+ if hasattr(self.header_entry, "set_visibility") or isinstance(
+ self.header_entry, Gtk.Entry
+ ):
+ self.header_entry.set_visibility(not enabled)
+ if enabled:
+ self.header_entry.set_invisible_char("*")
+ else:
+ try:
+ gtk_widget = getattr(self.header_entry, "widget", self.header_entry)
+ if isinstance(gtk_widget, Gtk.Entry):
+ gtk_widget.set_visibility(not enabled)
+ if enabled:
+ gtk_widget.set_invisible_char("*")
+ except (AttributeError, TypeError):
+ pass
+
+ def _is_typing_password_in_add_command(self, text: str) -> bool:
+ """Check if user is typing the password field in an 'add' command"""
+ text_stripped = text.strip()
+ text_lower = text_stripped.lower()
+
+ is_pass_mode = text_lower.startswith("pass ") or text_lower == "pass"
+ has_add_command = "add " in text_lower
+
+ if not has_add_command:
+ return False
+
+ if is_pass_mode:
+ add_index = text_lower.find("add ")
+ if add_index == -1:
+ return False
+ rest = text_stripped[add_index + 4 :].strip()
+ else:
+ if not text_lower.startswith("add "):
+ return False
+ rest = text_stripped[4:].strip()
+
+ if not rest:
+ return False
+
+ parts = rest.split(None, 2)
+ return len(parts) == 3
+
+ @debounce(200)
+ def on_entry_changed(self, entry: Entry, *_):
+ if getattr(entry, "_ignore_change", False):
+ entry._ignore_change = False
+ return
+
+ text = entry.get_text()
+ self._selected_index = -1
+
+ # Check if any plugin is in password mode
+ for plugin in self.launch_handler.plugins.values():
+ if getattr(plugin, "is_password_mode", False):
+ self.launch_handler.stop()
+ self.header_icon.set_from_icon_name("password-manager")
+ self._set_entry_password_mode(True)
+ self.launch_handler.clear_viewport()
+ self.scrolled_window.hide()
+ self.scrolled_clip.hide()
+ return
+
+ # Check if user is typing password in "add" command
+ if self._is_typing_password_in_add_command(text):
+ self._set_entry_password_mode(True)
+ else:
+ self._set_entry_password_mode(False)
+
+ self.launch_handler.stop()
+ self.launch_handler.clear_viewport()
+
+ # Try to route to a plugin
+ if self.route_by_keyword(text):
+ return
+
+ # Default: Application Search
+ self.header_icon.set_from_icon_name("system-search-symbolic")
+ app_plugin = self.launch_handler.plugins.get("application")
+ if app_plugin:
+ app_plugin.query_applications(text)
+ return
+
+ def post_viewport_children(self):
+ if (
+ new_hight := get_children_height_limit(self.viewport, self.max_children)
+ ) < 1:
+ self.scrolled_window.animate_size(0)
+ return False
+
+ self.scrolled_clip.show()
+ self.scrolled_window.show()
+ self.scrolled_window.animate_size(new_hight)
+
+ for i, slot in enumerate(self.viewport.children, start=1):
+ if i > 8:
+ break
+ slot.set_style(f"animation-duration: {round(i * 300)}ms;")
+ slot.add_style_class("shine")
+
+ def post_viewport_arrange(self, *_):
+ if len(self.viewport.children) < 1:
+ self.remove_style_class("overshoot")
+ elif "overshoot" not in self.style_classes:
+ add_style_class_lazy(self, "overshoot")
+ self.post_viewport_children()
+ # Auto-select the first child if results are available
+ if self.viewport.children:
+ self.set_selected_index(0)
+ return False
+
+ def on_slot_ready(self, _, button: Button, mode: str):
+ self.viewport.add(button)
+ button.connect("enter-notify-event", self.on_slot_enter)
+
+ def on_slot_enter(self, button, event):
+ children = self.viewport.children
+ if button in children:
+ self.set_selected_index(children.index(button))
+
+ def on_entry_accept(self, entry: Entry, *_):
+ text = entry.get_text()
+ # Generic password submission handling
+ for plugin in self.launch_handler.plugins.values():
+ if getattr(plugin, "is_password_mode", False):
+ pwd = entry.get_text()
+ if hasattr(plugin, "submit_password") and pwd.strip():
+ plugin.submit_password(pwd)
+ elif hasattr(plugin, "cancel_password"):
+ plugin.cancel_password()
+
+ self.launch_handler.stop()
+ self.launch_handler.clear_viewport()
+ self.scrolled_window.animate_size(0)
+ self.scrolled_window.hide()
+ self.scrolled_clip.hide()
+ self.header_icon.set_from_icon_name("system-search-symbolic")
+ self._set_entry_password_mode(False)
+ entry._ignore_change = True
+ entry.set_text("")
+ return
+
+ if self._selected_index >= 0 and self._selected_index < len(
+ self.viewport.children
+ ):
+ selected_widget = self.viewport.children[self._selected_index]
+ if isinstance(selected_widget, Button):
+ selected_widget.clicked()
+ return
+
+ if self.active_plugin:
+ self.active_plugin.on_submit(text)
+ entry.set_text("")
+ return
+
+ self._set_entry_password_mode(False)
+ self.header_icon.set_from_icon_name("system-search-symbolic")
+ app_plugin = self.launch_handler.plugins.get("application")
+ if app_plugin:
+ app_plugin.query_applications(entry.get_text())
+ return
+
+ def set_selected_index(self, index: int):
+ children = self.viewport.children
+ if not children:
+ self._selected_index = -1
+ return
+
+ # Clean old selection
+ if self._selected_index >= 0 and self._selected_index < len(children):
+ children[self._selected_index].remove_style_class("selected")
+
+ # Set new selection
+ self._selected_index = index % len(children)
+ selected_widget = children[self._selected_index]
+ selected_widget.add_style_class("selected")
+
+ # Scroll to selected item
+ adj = self.scrolled_window.get_vadjustment()
+ alloc = selected_widget.get_allocation()
+ res = selected_widget.translate_coordinates(self.viewport, 0, 0)
+ if res:
+ y = res[1] if len(res) == 2 else res[2]
+ top = y
+ bottom = y + alloc.height
+ value = adj.get_value()
+ page_size = adj.get_page_size()
+ if top < value:
+ adj.set_value(top)
+ elif bottom > value + page_size:
+ adj.set_value(bottom - page_size)
+
+ def on_key_press(self, entry, event):
+ keyval = event.keyval
+ if keyval == Gdk.KEY_Up or keyval == Gdk.KEY_ISO_Left_Tab:
+ self.set_selected_index(self._selected_index - 1)
+ return True
+ elif keyval == Gdk.KEY_Down or keyval == Gdk.KEY_Tab:
+ self.set_selected_index(self._selected_index + 1)
+ return True
+ return False
+
+
+class LauncherWindow(Window):
+ def __init__(self, **kwargs):
+ self.launcher_box = Launcher(
+ on_launched=lambda: self.close_launcher(), size=(460, -1)
+ )
+ super().__init__(
+ name="launcher-window",
+ title="fabric-launcher",
+ anchor="top",
+ margin="80px 0px 0px 0px",
+ keyboard_mode="exclusive",
+ child=self.launcher_box,
+ visible=False,
+ all_visible=False,
+ **kwargs,
+ )
+
+ self.build()
+ self.add_keybinding("Escape", lambda *_: self.close_launcher())
+
+ def close_launcher(self):
+ self.launcher_box.header_entry.set_text("")
+ self.launcher_box.launch_handler.stop()
+ self.launcher_box.launch_handler.clear_viewport()
+ self.launcher_box.scrolled_window.animate_size(0)
+ self.launcher_box.scrolled_window.hide()
+ self.launcher_box.scrolled_clip.hide()
+ self.launcher_box.header_icon.set_from_icon_name("system-search-symbolic")
+ self.launcher_box._set_entry_password_mode(False)
+ self.hide()
+
+ def toggle(
+ self, command: str | None = None, text: str = "", external: bool = False
+ ):
+ if external:
+ full = f"{command or ''} {text or ''}".strip()
+ if full:
+ cmd, rest = (full.split(" ", 1) + [""])[:2]
+ self.launcher_box.handle_external(cmd.strip(), rest.strip())
+ return
+
+ if self.get_visible():
+ self.close_launcher()
+ return
+
+ cmd = (command or "").strip()
+ txt = (text or "").strip()
+
+ parts = [p for p in [cmd, txt] if p]
+ prefill = " ".join(parts)
+
+ if cmd and not txt:
+ prefill += " "
+
+ # Reset any pending password prompts on open
+ password_plugin = self.launcher_box.launch_handler.plugins.get("password")
+ if password_plugin and hasattr(password_plugin, "reset_prompt_state"):
+ password_plugin.reset_prompt_state()
+
+ self.launcher_box.header_entry.set_text(prefill)
+ self.launcher_box._set_entry_password_mode(False)
+
+ self.show_all()
+ self.launcher_box.header_entry.grab_focus()
+ self.launcher_box.header_entry.set_position(-1)
diff --git a/mine/modules/launcher/plugin_registry.py b/mine/modules/launcher/plugin_registry.py
new file mode 100644
index 0000000..b404a3e
--- /dev/null
+++ b/mine/modules/launcher/plugin_registry.py
@@ -0,0 +1,63 @@
+from __future__ import annotations
+
+from modules.launcher.plugins import (
+ ApplicationPlugin,
+ BluetoothPlugin,
+ BookmarkPlugin,
+ CaffeinePlugin,
+ CalculatorPlugin,
+ ClipboardPlugin,
+ ColorPickerPlugin,
+ EmojiPlugin,
+ GoogleLensPlugin,
+ NetworkManagerPlugin,
+ OTPPlugin,
+ PasswordManagerPlugin,
+ PowerMenuPlugin,
+ PowerProfilePlugin,
+ QueryPlugin,
+ ReminderPlugin,
+ ScreenCapturePlugin,
+ SettingsPlugin,
+ SSHPlugin,
+ TmuxPlugin,
+ TodoPlugin,
+ WallpaperPlugin,
+ WindowSwitcherPlugin,
+)
+
+
+def build_plugins(handler) -> dict[str, object]:
+ """Create all plugins."""
+
+ registry: list[tuple[str, object]] = [
+ ("wallpaper", WallpaperPlugin),
+ ("application", ApplicationPlugin),
+ ("bookmark", BookmarkPlugin),
+ ("calculator", CalculatorPlugin),
+ ("query", QueryPlugin),
+ ("emoji", EmojiPlugin),
+ ("clipboard", ClipboardPlugin),
+ ("otp", OTPPlugin),
+ ("password", PasswordManagerPlugin),
+ ("tmux", TmuxPlugin),
+ ("windows", WindowSwitcherPlugin),
+ ("power", PowerProfilePlugin),
+ ("powermenu", PowerMenuPlugin),
+ ("todo", TodoPlugin),
+ ("screen", ScreenCapturePlugin),
+ ("caffeine", CaffeinePlugin),
+ ("ssh", SSHPlugin),
+ ("color", ColorPickerPlugin),
+ ("reminder", ReminderPlugin),
+ ("network", NetworkManagerPlugin),
+ ("bluetooth", BluetoothPlugin),
+ ("lens", GoogleLensPlugin),
+ ("settings", SettingsPlugin),
+ ]
+
+ plugins: dict[str, object] = {}
+ for key, cls in registry:
+ plugins[key] = cls(handler)
+
+ return plugins
diff --git a/mine/modules/launcher/plugins/__init__.py b/mine/modules/launcher/plugins/__init__.py
new file mode 100644
index 0000000..7f624b1
--- /dev/null
+++ b/mine/modules/launcher/plugins/__init__.py
@@ -0,0 +1,24 @@
+"""
+Modus Launcher plugins package.
+Contains UI components and functionality modules
+"""
+
+import importlib
+import inspect
+import pkgutil
+
+__all__ = []
+
+for module_info in pkgutil.iter_modules(__path__):
+ module_name = module_info.name
+ module = importlib.import_module(f"{__name__}.{module_name}")
+
+ for attr_name in dir(module):
+ attr_value = getattr(module, attr_name)
+ if (
+ isinstance(attr_value, type)
+ and attr_name.endswith("Plugin")
+ and inspect.getmodule(attr_value) is module
+ ):
+ globals()[attr_name] = attr_value
+ __all__.append(attr_name)
diff --git a/mine/modules/launcher/plugins/application.py b/mine/modules/launcher/plugins/application.py
new file mode 100644
index 0000000..e148b95
--- /dev/null
+++ b/mine/modules/launcher/plugins/application.py
@@ -0,0 +1,106 @@
+from collections.abc import Iterator
+from fabric.utils import DesktopApp, get_desktop_applications, os
+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 thefuzz import fuzz, process
+
+from modules.launcher.base import LauncherPlugin
+
+
+class ApplicationPlugin(LauncherPlugin):
+ @property
+ def name(self) -> str:
+ return "applications"
+
+ @property
+ def icon(self) -> str:
+ return "system-search-symbolic"
+
+ @property
+ def keywords(self) -> list[str]:
+ return [] # Default handler, no specific keywords
+
+ def __init__(self, handler):
+ super().__init__(handler)
+ self._desktop_apps = []
+
+ def stop(self):
+ # No cleanup needed for application plugin
+ pass
+
+ def on_search(self, text: str) -> None:
+ self.query_applications(text)
+
+ def query_applications(self, text: str) -> None:
+ if not text:
+ return self.handler.done()
+ self.handler.start("applications")
+ self._desktop_apps = get_desktop_applications()
+ filtered_apps: Iterator[DesktopApp] = iter(
+ app
+ for app, _ in process.extract(
+ text,
+ self._desktop_apps,
+ scorer=fuzz.WRatio,
+ processor=lambda app: getattr(app, "display_name", app) or "",
+ limit=10,
+ )
+ )
+
+ for app in filtered_apps:
+ self.handler.slot_ready(
+ Button(
+ style_classes="app-slot",
+ child=Box(
+ orientation="h",
+ spacing=12,
+ children=[
+ Image(
+ pixbuf=app.get_icon_pixbuf(), h_align="start", size=32
+ ),
+ Box(
+ orientation="v",
+ children=[
+ Label(
+ label=app.display_name or "Unknown",
+ v_align="start",
+ h_align="start",
+ style_classes="app-title",
+ )
+ ],
+ ).build(
+ lambda box, _: app.description
+ and box.add(
+ Label(
+ label=app.description,
+ max_chars_width=42,
+ ellipsization="middle",
+ justification="center",
+ style_classes="app-description",
+ v_align="start",
+ h_align="start",
+ )
+ )
+ ),
+ ],
+ ),
+ tooltip_text=app.description,
+ on_clicked=lambda *_, app=app: (
+ self.launch_app(app),
+ self.handler.launched(),
+ ),
+ ),
+ "applications",
+ )
+
+ self.handler.done()
+
+ def launch_app(self, app):
+ old_cwd = os.getcwd()
+ os.chdir(os.path.expanduser("~"))
+ try:
+ app.launch()
+ finally:
+ os.chdir(old_cwd)
diff --git a/mine/modules/launcher/plugins/bluetooth.py b/mine/modules/launcher/plugins/bluetooth.py
new file mode 100644
index 0000000..c4ce5f9
--- /dev/null
+++ b/mine/modules/launcher/plugins/bluetooth.py
@@ -0,0 +1,519 @@
+from __future__ import annotations
+
+import shlex
+from collections.abc import Iterator
+from typing import Any
+
+from fabric.bluetooth import BluetoothClient
+from fabric.utils import GLib, idle_add, remove_handler
+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 modules.launcher.base import LauncherPlugin
+from utils.functions import run_command
+
+
+class BluetoothPlugin(LauncherPlugin):
+ @property
+ def name(self) -> str:
+ return "bluetooth"
+
+ @property
+ def icon(self) -> str:
+ return "bluetooth"
+
+ @property
+ def keywords(self) -> list[str]:
+ return ["bt"]
+
+ def __init__(self, handler):
+ super().__init__(handler)
+ self._query_handler: int = 0
+ self._signal_refresh_handler: int = 0
+ self._is_active: bool = False
+
+ self._client = BluetoothClient()
+
+ if self._client is not None:
+ self._client.connect("device-added", lambda *_: self._on_bt_changed())
+ self._client.connect("notify::enabled", lambda *_: self._on_bt_changed())
+ self._client.connect("notify::scanning", lambda *_: self._on_bt_changed())
+ self._signal_matches = []
+ self.current_slots: list = []
+
+ def on_search(self, text: str) -> None:
+ self.query_bluetooth(text)
+
+ def stop(self):
+ if self._query_handler:
+ remove_handler(self._query_handler)
+ self._query_handler = 0
+ if self._signal_refresh_handler:
+ GLib.source_remove(self._signal_refresh_handler)
+ self._signal_refresh_handler = 0
+ self._signal_matches.clear()
+ self.current_slots.clear()
+
+ def query_bluetooth(self, text: str) -> None:
+ self._is_active = True
+ self.handler.start("bluetooth")
+ self.current_slots.clear()
+
+ text_stripped = text.strip()
+ commands = {
+ "power": self._handle_power_command,
+ "scan": self._handle_scan_command,
+ "connect": self._handle_connect_command,
+ "disconnect": self._handle_disconnect_command,
+ "help": lambda t: self._show_help(),
+ }
+
+ for prefix, handler in commands.items():
+ if text_stripped.lower().startswith(prefix):
+ handler(text_stripped)
+ return
+
+ if (
+ self._client is not None
+ and hasattr(self._client, "scanning")
+ and self._client.scanning
+ ): # type: ignore
+ self._show_scanning_indicator()
+
+ items = self._enumerate(text)
+ if not items:
+ self._show_empty()
+ return
+ self._query_handler = idle_add(self._bake_next_slot, iter(items), pin=True)
+
+ def _on_bt_changed(self, *args, **kwargs):
+ if not self._is_active:
+ return
+ if self._signal_refresh_handler:
+ return
+ self._signal_refresh_handler = GLib.timeout_add(250, self._on_refresh_timeout)
+
+ def _on_refresh_timeout(self) -> bool:
+ self._signal_refresh_handler = 0
+ if self._is_active:
+ self.handler.start("bluetooth")
+ if hasattr(self.handler, "clear_viewport"):
+ self.handler.clear_viewport()
+ self.query_bluetooth("")
+ return False
+
+ def _adapters(self) -> list[Any]:
+ if self._client is not None and hasattr(self._client, "adapters"):
+ adapters = self._client.adapters # type: ignore
+ if adapters:
+ return list(adapters) # type: ignore
+ return []
+
+ def _devices(self) -> list[tuple[Any, Any | None]]:
+ if self._client is not None and hasattr(self._client, "devices"):
+ devices = self._client.devices # type: ignore
+ if devices is not None:
+ result: list[tuple[Any, Any | None]] = []
+ for d in devices:
+ adapter = d.adapter if hasattr(d, "adapter") else None
+ result.append((d, adapter))
+ return result
+ return []
+
+ def _enumerate(self, text: str) -> list[dict]:
+ text = (text or "").strip().lower()
+ items: list[dict] = []
+
+ # First add power/scan controls per adapter
+ for ad in self._adapters():
+ if hasattr(ad, "Alias"):
+ name = ad.Alias # type: ignore
+ elif hasattr(ad, "Name"):
+ name = ad.Name # type: ignore
+ else:
+ name = "Adapter"
+ if hasattr(ad, "Powered"):
+ powered = bool(ad.Powered) # type: ignore
+ elif hasattr(ad, "powered"):
+ powered = bool(ad.powered) # type: ignore
+ else:
+ powered = False
+ if hasattr(ad, "Discovering"):
+ discovering = bool(ad.Discovering) # type: ignore
+ elif hasattr(ad, "scanning"):
+ discovering = bool(ad.scanning) # type: ignore
+ else:
+ discovering = False
+ items.append(
+ {
+ "kind": "adapter",
+ "adapter": ad,
+ "name": f"{name}",
+ "subtitle": ("On" if powered else "Off")
+ + (" • Scanning" if discovering else ""),
+ }
+ )
+
+ # Then add devices
+ for dev, adapter in self._devices():
+ if hasattr(dev, "Alias"):
+ alias = dev.Alias # type: ignore
+ elif hasattr(dev, "Name"):
+ alias = dev.Name # type: ignore
+ elif hasattr(dev, "name"):
+ alias = dev.name # type: ignore
+ else:
+ alias = "Unknown"
+ if text and (alias.lower().find(text) < 0):
+ continue
+ if hasattr(dev, "Connected"):
+ connected = bool(dev.Connected) # type: ignore
+ elif hasattr(dev, "connected"):
+ connected = bool(dev.connected) # type: ignore
+ else:
+ connected = False
+ if hasattr(dev, "Paired"):
+ paired = bool(dev.Paired) # type: ignore
+ elif hasattr(dev, "paired"):
+ paired = bool(dev.paired) # type: ignore
+ else:
+ paired = False
+ if hasattr(dev, "Trusted"):
+ trusted = bool(dev.Trusted) # type: ignore
+ elif hasattr(dev, "trusted"):
+ trusted = bool(dev.trusted) # type: ignore
+ else:
+ trusted = False
+ icon = "bluetooth"
+ subtitle_bits = []
+ if adapter:
+ if hasattr(adapter, "Alias") and adapter.Alias: # type: ignore
+ subtitle_bits.append(adapter.Alias) # type: ignore
+ elif hasattr(adapter, "Name") and adapter.Name: # type: ignore
+ subtitle_bits.append(adapter.Name) # type: ignore
+ if paired:
+ subtitle_bits.append("Paired")
+ if trusted:
+ subtitle_bits.append("Trusted")
+ if connected:
+ subtitle_bits.append("Connected")
+ items.append(
+ {
+ "kind": "device",
+ "device": dev,
+ "name": alias,
+ "subtitle": " • ".join(subtitle_bits) or "",
+ "icon": icon,
+ "connected": connected,
+ }
+ )
+ return items
+
+ def _show_empty(self) -> None:
+ self.handler.slot_ready(
+ Button(
+ style_classes="app-slot bluetooth-empty",
+ child=Box(
+ orientation="h",
+ spacing=12,
+ children=[
+ Image(icon_name="bluetooth-disabled-symbolic", size=32),
+ Label(
+ label="No Bluetooth items",
+ style_classes="bluetooth-empty-text",
+ h_align="center",
+ ),
+ ],
+ ),
+ tooltip_text="No Bluetooth adapters/devices",
+ ),
+ "bluetooth",
+ )
+ self.handler.done()
+
+ def _show_no_results(self) -> None:
+ self.handler.slot_ready(
+ Button(
+ style_classes="app-slot bluetooth-no-results",
+ child=Box(
+ orientation="h",
+ spacing=12,
+ children=[
+ Label(
+ label="No devices match your search",
+ style_classes="bluetooth-no-results-text",
+ h_align="center",
+ ),
+ ],
+ ),
+ ),
+ "bluetooth",
+ )
+ self.handler.done()
+
+ def _show_scanning_indicator(self) -> None:
+ self.handler.slot_ready(
+ Button(
+ style_classes="app-slot bluetooth-scanning",
+ child=Box(
+ orientation="h",
+ spacing=12,
+ children=[
+ Image(icon_name="content-loading-symbolic", size=24),
+ Label(
+ label="Scanning for Bluetooth devices...",
+ style_classes="bluetooth-scanning-text",
+ h_align="center",
+ ),
+ ],
+ ),
+ tooltip_text="Scanning is in progress",
+ ),
+ "bluetooth",
+ )
+
+ def _show_error_message(self, message: str) -> None:
+ self.handler.slot_ready(
+ Button(
+ style_classes="app-slot bluetooth-error",
+ child=Box(
+ orientation="h",
+ spacing=12,
+ children=[
+ Label(
+ label=message,
+ style_classes="bluetooth-error-text",
+ h_align="center",
+ ),
+ ],
+ ),
+ on_clicked=lambda *_: self.handler.launched(),
+ ),
+ "bluetooth",
+ )
+ self.handler.done()
+
+ def _show_help(self) -> None:
+ help_text = (
+ "Available commands:\n"
+ "• power on|off (Toggle if no arg)\n"
+ "• scan start|stop (Toggle if no arg)\n"
+ "• connect \n"
+ "• disconnect \n"
+ )
+ self.handler.slot_ready(
+ Button(
+ style_classes="app-slot bluetooth-help",
+ child=Box(
+ orientation="h",
+ spacing=12,
+ children=[
+ Label(
+ label=help_text,
+ style_classes="bluetooth-help-text",
+ h_align="center",
+ ),
+ ],
+ ),
+ on_clicked=lambda *_: self.handler.launched(),
+ ),
+ "bluetooth",
+ )
+ self.handler.done()
+
+ def _bake_next_slot(self, iterator: Iterator[dict]) -> bool:
+ try:
+ item = next(iterator)
+ except StopIteration:
+ idle_add(self.handler.done)
+ return False
+ self._create_slot(item)
+ return True
+
+ def _create_slot(self, item: dict) -> None:
+ icon_name = item.get("icon") or (
+ "bluetooth-active" if item.get("kind") == "device" else "bluetooth"
+ )
+ slot_content = Box(
+ orientation="h",
+ spacing=12,
+ children=[
+ Image(icon_name=icon_name, h_align="start", size=32),
+ Box(
+ orientation="v",
+ children=[
+ Label(
+ label=item.get("name", "Unknown"),
+ style_classes="bluetooth-name",
+ h_align="start",
+ v_align="start",
+ ),
+ Label(
+ label=item.get("subtitle", ""),
+ style_classes="bluetooth-subtitle",
+ h_align="start",
+ v_align="start",
+ ),
+ ],
+ ),
+ ],
+ )
+
+ tooltip = (
+ "Enter: Toggle Connect/Disconnect\nShift+Enter: Pair/Remove"
+ if item.get("kind") == "device"
+ else "Enter: Toggle Power\nShift+Enter: Scan"
+ )
+
+ self.handler.slot_ready(
+ Button(
+ style_classes="app-slot bluetooth-slot",
+ child=slot_content,
+ tooltip_text=tooltip,
+ on_clicked=lambda *_: self._on_slot_click(item),
+ ),
+ "bluetooth",
+ )
+ self.current_slots.append({"widget": None, "item": item})
+
+ def _on_slot_click(self, item: dict) -> None:
+ kind = item.get("kind")
+ if kind == "device":
+ dev = item.get("device")
+ if self._client is not None:
+ if not item.get("connected") and hasattr(dev, "set_connecting"):
+ dev.set_connecting(True) # type: ignore
+ if hasattr(dev, "connected"):
+ dev.connected = not bool(item.get("connected")) # type: ignore
+ self.handler.launched()
+ return
+ if kind == "adapter":
+ ad = item.get("adapter")
+ if self._client is not None and hasattr(ad, "toggle_power"):
+ ad.toggle_power() # type: ignore
+ else:
+ current = bool(ad.powered) if hasattr(ad, "powered") else False
+ if hasattr(ad, "powered"):
+ ad.powered = not current # type: ignore
+ self.handler.launched()
+
+ def handle_external(self, command: str, args: str) -> None:
+ # Map external command to internal logic
+ # This is a simplified version of handle_direct
+ # In a real scenario, we might want to reuse handle_direct logic
+ pass
+
+ def _btctl(self, subcommand: str) -> bool:
+ try:
+ cmd = shlex.split(f"bluetoothctl {subcommand}")
+ result = run_command(cmd, timeout=5)
+ return result.returncode == 0
+ except Exception:
+ return False
+
+ def _handle_power_command(self, text: str) -> None:
+ parts = text.strip()
+ if not parts.endswith(";"):
+ self._show_error_message(
+ "Bluetooth: command must end with ';'\nUsage: power on; | power off;"
+ )
+ return
+ parts = parts[:-1].strip()
+ tokens = parts.split()
+ state = tokens[1].lower() if len(tokens) > 1 else ""
+ if self._client is not None and hasattr(self._client, "toggle_power"):
+ if state:
+ desired = state in ("on", "1", "true", "enable")
+ self._client.enabled = desired # type: ignore
+ else:
+ self._client.toggle_power() # type: ignore
+ else:
+ if state in ("on", "1", "true", "enable"):
+ self._btctl("power on")
+ elif state in ("off", "0", "false", "disable"):
+ self._btctl("power off")
+ else:
+ self._btctl("power off")
+ self._btctl("power on")
+
+ def _handle_scan_command(self, text: str) -> None:
+ parts = text.strip()
+ if not parts.endswith(";"):
+ self._show_error_message(
+ "Bluetooth: command must end with ';'\nUsage: scan start; | scan stop;"
+ )
+ return
+ parts = parts[:-1].strip()
+ tokens = parts.split()
+ action = tokens[1].lower() if len(tokens) > 1 else ""
+ if self._client is not None:
+ if action in ("start", "on", "1") and hasattr(self._client, "scan"):
+ self._client.scan() # type: ignore
+ elif action in ("stop", "off", "0"):
+ self._client.scanning = False # type: ignore
+ elif hasattr(self._client, "toggle_scan"):
+ self._client.toggle_scan() # type: ignore
+
+ def _handle_connect_command(self, text: str) -> None:
+ parts = text.strip()
+ if not parts.endswith(";"):
+ self._show_error_message(
+ "Bluetooth: command must end with ';'\nUsage: connect AA:BB:CC:DD:EE:FF;"
+ )
+ return
+ parts = parts[:-1].strip()
+ tokens = parts.split()
+ if len(tokens) < 2:
+ self._show_help()
+ return
+ name = " ".join(tokens[1:])
+ if self._client is not None:
+ self._with_device_by_name(
+ name,
+ lambda d: setattr(d, "connected", True)
+ if hasattr(d, "connected")
+ else None,
+ )
+
+ def _handle_disconnect_command(self, text: str) -> None:
+ parts = text.strip()
+ if not parts.endswith(";"):
+ self._show_error_message(
+ "Bluetooth: command must end with ';'\nUsage: disconnect AA:BB:CC:DD:EE:FF;"
+ )
+ return
+ parts = parts[:-1].strip()
+ tokens = parts.split()
+ if len(tokens) < 2:
+ self._show_help()
+ return
+ name = " ".join(tokens[1:])
+ if self._client is not None:
+ self._with_device_by_name(
+ name,
+ lambda d: setattr(d, "connected", False)
+ if hasattr(d, "connected")
+ else None,
+ )
+
+ def _with_device_by_name(self, name: str, op) -> bool:
+ name_l = name.strip().lower()
+ if self._client is None:
+ return False
+ devices = self._client.devices if hasattr(self._client, "devices") else [] # type: ignore
+ for d in devices:
+ alias = (
+ d.Alias
+ if hasattr(d, "Alias")
+ else (
+ d.Name
+ if hasattr(d, "Name")
+ else (d.name if hasattr(d, "name") else "")
+ )
+ ) # type: ignore
+ if str(alias).strip().lower() == name_l:
+ op(d)
+ return True
+ return False
diff --git a/mine/modules/launcher/plugins/bookmark.py b/mine/modules/launcher/plugins/bookmark.py
new file mode 100644
index 0000000..9c4dd9a
--- /dev/null
+++ b/mine/modules/launcher/plugins/bookmark.py
@@ -0,0 +1,377 @@
+from collections.abc import Iterator
+
+from fabric.utils import Gdk, idle_add, remove_handler
+from fabric.widgets.box import Box
+from fabric.widgets.button import Button
+from fabric.widgets.label import Label
+
+from modules.launcher.base import LauncherPlugin
+from services import BookmarkManager
+from utils.functions import copy_text
+
+
+class BookmarkPlugin(LauncherPlugin):
+ @property
+ def name(self) -> str:
+ return "bookmarks"
+
+ @property
+ def icon(self) -> str:
+ return "user-bookmarks-symbolic"
+
+ @property
+ def keywords(self) -> list[str]:
+ return ["bm"]
+
+ def __init__(self, handler):
+ super().__init__(handler)
+ self._query_handler: int = 0
+ self.current_slots: list = []
+ self.bookmarks: dict = {}
+ self.bookmark_manager = BookmarkManager()
+
+ def on_search(self, text: str) -> None:
+ self.query_bookmarks(text)
+
+ def stop(self):
+ if self._query_handler:
+ remove_handler(self._query_handler)
+ self._query_handler = 0
+ self.current_slots.clear()
+
+ def query_bookmarks(self, text: str) -> None:
+ self.handler.start("bookmarks")
+ self.current_slots.clear()
+ text_stripped = text.strip()
+
+ commands = {
+ "add": self._handle_add_command,
+ "remove": self._handle_remove_command,
+ "update": self._handle_update_command,
+ "help": lambda t: self._show_command_help(),
+ }
+
+ for prefix, handler in commands.items():
+ if text_stripped.startswith(prefix):
+ handler(text)
+ return
+
+ self._load_and_display_bookmarks(text)
+
+ def _load_and_display_bookmarks(self, text: str) -> None:
+ result = self.bookmark_manager.list_bookmarks()
+ if not result["success"]:
+ self._show_error_message(f"Failed to load bookmarks: {result['error']}")
+ return
+
+ self.bookmarks = result["data"]
+
+ if not self.bookmarks:
+ self._show_no_bookmarks()
+ return
+
+ filtered_bookmarks = self._filter_bookmarks(text)
+ if not filtered_bookmarks:
+ self._show_no_results()
+ return
+
+ self._query_handler = idle_add(
+ self._bake_next_bookmark_slot, iter(filtered_bookmarks), pin=True
+ )
+
+ def _filter_bookmarks(self, text: str) -> Iterator[tuple[str, dict]]:
+ if not text.strip():
+ return iter(self.bookmarks.items())
+
+ text_lower = text.lower()
+ return iter(
+ (name, data)
+ for name, data in self.bookmarks.items()
+ if (
+ text_lower in name.lower()
+ or text_lower in data.get("content", "").lower()
+ )
+ )
+
+ def _show_no_bookmarks(self) -> None:
+ self.handler.slot_ready(
+ Button(
+ style_classes="app-slot bookmark-no-bookmarks",
+ child=Box(
+ orientation="h",
+ spacing=12,
+ children=[
+ Label(
+ label="No bookmarks found",
+ style_classes="bookmark-no-bookmarks-text",
+ h_align="center",
+ ),
+ ],
+ ),
+ tooltip_text="Use 'add name content' to add bookmarks",
+ ),
+ "bookmarks",
+ )
+ self.handler.done()
+
+ def _show_no_results(self) -> None:
+ self.handler.slot_ready(
+ Button(
+ style_classes="app-slot bookmark-no-results",
+ child=Box(
+ orientation="h",
+ spacing=12,
+ children=[
+ Label(
+ label="No bookmarks match your search",
+ style_classes="bookmark-no-results-text",
+ h_align="center",
+ ),
+ ],
+ ),
+ ),
+ "bookmarks",
+ )
+ self.handler.done()
+
+ def _bake_next_bookmark_slot(self, iterator: Iterator[tuple[str, dict]]) -> bool:
+ try:
+ bookmark_name, bookmark_data = next(iterator)
+ except StopIteration:
+ idle_add(self.handler.done)
+ return False
+
+ self._create_bookmark_slot(bookmark_name, bookmark_data)
+ return True
+
+ def _create_bookmark_slot(self, bookmark_name: str, bookmark_data: dict) -> None:
+ content = bookmark_data.get("content", "")
+
+ name_label = Label(
+ label=bookmark_name,
+ style_classes="bookmark-name",
+ h_align="start",
+ max_chars_width=25,
+ ellipsization="end",
+ )
+
+ content_label = Label(
+ label=content if content else "No content",
+ style_classes="bookmark-content",
+ h_align="start",
+ max_chars_width=40,
+ ellipsization="end",
+ )
+
+ slot_widget = Button(
+ style_classes="app-slot bookmark-slot",
+ child=Box(
+ orientation="v",
+ spacing=4,
+ children=[
+ name_label,
+ content_label,
+ ],
+ ),
+ tooltip_text=f"Click to copy content\nContent: {content}",
+ on_clicked=lambda *_: self._copy_bookmark(bookmark_name),
+ )
+
+ slot_widget.connect(
+ "key-press-event",
+ lambda widget, event, bm_name=bookmark_name: self._on_slot_key_press(
+ widget, event, bm_name
+ ),
+ )
+
+ slot_info = {
+ "widget": slot_widget,
+ "name_label": name_label,
+ "content_label": content_label,
+ "bookmark_name": bookmark_name,
+ "bookmark_data": bookmark_data,
+ }
+ self.current_slots.append(slot_info)
+ self.handler.slot_ready(slot_widget, "bookmarks")
+
+ def _copy_bookmark(self, bookmark_name: str) -> None:
+ """Copy bookmark content to clipboard"""
+ result = self.bookmark_manager.get_bookmark(bookmark_name)
+ if not result["success"]:
+ self._show_error_message(f"Failed to get bookmark: {result['error']}")
+ return
+
+ content = result["data"]["content"]
+ self.handler.launched()
+
+ if not copy_text(content):
+ self._show_error_message("Failed to copy content to clipboard")
+
+ def _on_slot_key_press(self, widget, event, bookmark_name: str) -> bool:
+ if not event:
+ return False
+ return (
+ (event.keyval == Gdk.KEY_Return or event.keyval == Gdk.KEY_KP_Enter)
+ and event.state & Gdk.ModifierType.SHIFT_MASK
+ and (self._handle_remove_command(f"remove {bookmark_name}") or True)
+ )
+
+ def _handle_add_command(self, text: str) -> None:
+ if text.strip() == "add":
+ self._load_and_display_bookmarks("")
+ return
+
+ if not text.strip().endswith(";"):
+ self._show_error_message(
+ "Bookmark command must end with ';'\n\nUsage: add name content;"
+ )
+ return
+
+ command_text = text.strip()[:-1]
+ parts = command_text.split()
+ if len(parts) < 3:
+ self._show_command_help("add")
+ return
+
+ name = parts[1]
+ content = parts[2]
+ result = self.bookmark_manager.add_bookmark(name, content)
+ if result["success"]:
+ if hasattr(self.handler, "clear_input"):
+ self.handler.clear_input()
+ self._refresh_bookmark_list()
+ else:
+ self._show_error_message(f"Error adding bookmark: {result['error']}")
+
+ def _handle_remove_command(self, text: str) -> None:
+ if text.strip() == "remove":
+ self._load_and_display_bookmarks("")
+ return
+
+ if not text.strip().endswith(";"):
+ self._show_error_message(
+ "Bookmark command must end with ';'\n\nUsage: remove name;"
+ )
+ return
+
+ command_text = text.strip()[:-1]
+ parts = command_text.split()
+ if len(parts) < 2:
+ self._show_command_help("remove")
+ return
+
+ bookmark_name = parts[1]
+ result = self.bookmark_manager.delete_bookmark(bookmark_name)
+ if result["success"]:
+ if hasattr(self.handler, "clear_input"):
+ self.handler.clear_input()
+ self._refresh_bookmark_list()
+ else:
+ self._show_error_message(f"Error removing bookmark: {result['error']}")
+
+ def _handle_update_command(self, text: str) -> None:
+ if text.strip() == "update":
+ self._load_and_display_bookmarks("")
+ return
+
+ if not text.strip().endswith(";"):
+ self._show_error_message(
+ "Bookmark command must end with ';'\n\nUsage: update name content new_value;"
+ )
+ return
+
+ command_text = text.strip()[:-1]
+ parts = command_text.split()
+ if len(parts) < 4:
+ self._show_command_help("update")
+ return
+
+ bookmark_name = parts[1]
+ field = parts[2]
+ if field != "content":
+ self._show_error_message("Invalid field. Only 'content' is supported")
+ return
+
+ new_value = " ".join(parts[3:])
+
+ result = self.bookmark_manager.update_bookmark(bookmark_name, content=new_value)
+ if result["success"]:
+ if hasattr(self.handler, "clear_input"):
+ self.handler.clear_input()
+ self._refresh_bookmark_list()
+ else:
+ self._show_error_message(f"Error updating bookmark: {result['error']}")
+
+ def _show_command_help(self, command: str = None) -> None:
+ help_texts = {
+ "add": "Usage: add name content;\nExample: add github https://github.com;\nContent can be URLs, file paths, commands, or any text!",
+ "remove": "Usage: remove name;\nExample: remove github;",
+ "update": "Usage: update name content new_value;\nExample: update github content https://github.com/new;",
+ }
+
+ help_text = help_texts.get(
+ command,
+ """Available commands:
+• add name content; (Add new bookmark)
+• remove name; (Remove bookmark)
+• update name content new_value; (Update bookmark)
+• help (Show this help)
+
+Commands with arguments must end with semicolon (;)
+Content can be URLs, file paths, commands, or any text!
+Click on any bookmark to copy it to clipboard!""",
+ )
+
+ self.handler.slot_ready(
+ Button(
+ style_classes="app-slot bookmark-help",
+ child=Box(
+ orientation="h",
+ spacing=12,
+ children=[
+ Label(
+ label=help_text,
+ style_classes="bookmark-help-text",
+ h_align="center",
+ ),
+ ],
+ ),
+ ),
+ "bookmarks",
+ )
+ self.handler.done()
+
+ def _show_error_message(self, message: str) -> None:
+ self.handler.slot_ready(
+ Button(
+ style_classes="app-slot bookmark-error",
+ child=Box(
+ orientation="h",
+ spacing=12,
+ children=[
+ Label(
+ label=message,
+ style_classes="bookmark-error-text",
+ h_align="center",
+ ),
+ ],
+ ),
+ on_clicked=lambda *_: self.handler.launched(),
+ ),
+ "bookmarks",
+ )
+ self.handler.done()
+
+ def _refresh_bookmark_list(self) -> None:
+ self.current_slots.clear()
+ if hasattr(self.handler, "clear_viewport"):
+ self.handler.clear_viewport()
+ idle_add(self._show_bookmark_list)
+
+ def _show_bookmark_list(self) -> None:
+ self.handler.start("bookmarks")
+ self._load_and_display_bookmarks("")
+
+ def handle_external(self, command: str, args: str) -> None:
+ # Map external commands if needed
+ pass
diff --git a/mine/modules/launcher/plugins/caffeine.py b/mine/modules/launcher/plugins/caffeine.py
new file mode 100644
index 0000000..fbe376f
--- /dev/null
+++ b/mine/modules/launcher/plugins/caffeine.py
@@ -0,0 +1,187 @@
+from fabric.utils import (
+ Gdk,
+ exec_shell_command_async,
+ idle_add,
+ remove_handler,
+)
+from fabric.widgets.box import Box
+from fabric.widgets.button import Button
+from fabric.widgets.image import Image
+from fabric.widgets.label import Label
+
+import config.data as data
+from modules.launcher.base import LauncherPlugin
+from services import InhibitService
+
+
+class CaffeinePlugin(LauncherPlugin):
+ @property
+ def name(self) -> str:
+ return "caffeine"
+
+ @property
+ def icon(self) -> str:
+ return "caffeine"
+
+ @property
+ def keywords(self) -> list[str]:
+ return ["caffeine", "caff"]
+
+ def __init__(self, handler):
+ super().__init__(handler)
+ self._query_handler: int = 0
+ self.inhibit_service = InhibitService.get_initial()
+
+ self.presets = [
+ {"name": "On (indefinite)", "arg": "on", "icon": "caffeine"},
+ {"name": "30 minutes", "arg": "30m", "icon": "caffeine"},
+ {"name": "1 hour", "arg": "1h", "icon": "caffeine"},
+ {"name": "2 hours", "arg": "2h", "icon": "caffeine"},
+ ]
+
+ def on_search(self, text: str) -> None:
+ self.query_caffeine(text)
+
+ def stop(self):
+ if self._query_handler:
+ remove_handler(self._query_handler)
+ self._query_handler = 0
+
+ def _is_inhibiting(self) -> bool:
+ return self.inhibit_service.is_running
+
+ def query_caffeine(self, text: str) -> None:
+ self.handler.start("caffeine")
+
+ text_stripped = text.strip()
+ inhibiting = self._is_inhibiting()
+ options = []
+
+ if inhibiting:
+ options.append({"name": "Off", "arg": "off", "icon": "caffeine-plus-off"})
+ options.extend([preset for preset in self.presets if preset["arg"] != "on"])
+ else:
+ options.extend(self.presets)
+
+ if (
+ text_stripped
+ and text_stripped not in {o["arg"] for o in options}
+ and not (text_stripped.lower() == "off" and not inhibiting)
+ ):
+ options.insert(
+ 0,
+ {
+ "name": f"Custom: {text_stripped}",
+ "arg": text_stripped,
+ "icon": "document-send-symbolic",
+ },
+ )
+
+ filtered_options = self._filter_options(text, options)
+ if not filtered_options:
+ return
+
+ self._query_handler = idle_add(
+ self._bake_next_option_slot, iter(filtered_options), pin=True
+ )
+
+ def _filter_options(self, text: str, options) -> list:
+ return (
+ options
+ if not text.strip()
+ else [
+ option
+ for option in options
+ if text.lower() in option["name"].lower()
+ or text.lower() in option["arg"].lower()
+ ]
+ )
+
+ def _bake_next_option_slot(self, iterator) -> bool:
+ option = next(iterator, None)
+ if option is None:
+ idle_add(self.handler.done)
+ return False
+ self._create_option_slot(option)
+ return True
+
+ def _create_option_slot(self, option) -> None:
+ slot_content = Box(
+ orientation="h",
+ spacing=12,
+ children=[
+ Image(icon_name=option["icon"], h_align="start", size=32),
+ Box(
+ orientation="v",
+ children=[
+ Label(
+ label=f"Caffeine: {option['name']}",
+ style_classes="caffeine-name",
+ h_align="start",
+ v_align="start",
+ ),
+ Label(
+ label=f"Set idle inhibitor to '{option['arg']}'",
+ style_classes="caffeine-description",
+ h_align="start",
+ v_align="start",
+ ),
+ ],
+ ),
+ ],
+ )
+
+ slot_widget = Button(
+ style_classes="app-slot caffeine-slot",
+ child=slot_content,
+ tooltip_text=f"Click to set caffeine: {option['arg']}",
+ on_clicked=lambda *_: (
+ self.handler.launched(),
+ self._execute_caffeine(option["arg"]),
+ ),
+ )
+
+ slot_widget.connect(
+ "key-press-event",
+ lambda widget, event, opt=option: self._on_slot_key_press(
+ widget, event, opt
+ ),
+ )
+
+ self.handler.slot_ready(slot_widget, "caffeine")
+
+ def _execute_caffeine(self, arg: str) -> None:
+ arg_lower = arg.lower()
+ if arg_lower == "off":
+ self.inhibit_service.disable()
+ self._notify("☕ Caffeine", "Deactivated")
+ return
+ self.inhibit_service.enable(arg_lower)
+ duration_text = self._duration_to_text(arg_lower)
+ self._notify("☕ Caffeine", f"Activated for {duration_text}")
+
+ def _on_slot_key_press(self, widget, event, option) -> bool:
+ if not event or event.keyval not in (Gdk.KEY_Return, Gdk.KEY_KP_Enter):
+ return False
+ self.handler.launched()
+ self._execute_caffeine(option["arg"])
+ return True
+
+ def _notify(self, title: str, body: str) -> None:
+ exec_shell_command_async(
+ f"notify-send '{title}' '{body}' -a '{data.APP_NAME}' -e"
+ )
+
+ def _duration_to_text(self, duration: str) -> str:
+ mapping = {
+ "30m": "30 minutes",
+ "1h": "1 hour",
+ "2h": "2 hours",
+ "on": "On",
+ "off": "Off",
+ }
+ return mapping.get(duration, duration)
+
+ def handle_external(self, command: str, args: str) -> None:
+ if args:
+ self._execute_caffeine(args)
diff --git a/mine/modules/launcher/plugins/calculator.py b/mine/modules/launcher/plugins/calculator.py
new file mode 100644
index 0000000..e99a168
--- /dev/null
+++ b/mine/modules/launcher/plugins/calculator.py
@@ -0,0 +1,64 @@
+from fabric.utils import exec_shell_command, exec_shell_command_async
+from fabric.widgets.box import Box
+from fabric.widgets.button import Button
+from fabric.widgets.label import Label
+
+from modules.launcher.base import LauncherPlugin
+
+
+class CalculatorPlugin(LauncherPlugin):
+ @property
+ def name(self) -> str:
+ return "calculator"
+
+ @property
+ def icon(self) -> str:
+ return "calculator"
+
+ @property
+ def keywords(self) -> list[str]:
+ return ["calc"]
+
+ def on_search(self, text: str) -> None:
+ self.query_calc(text)
+
+ def stop(self):
+ pass
+
+ def query_calc(self, text: str) -> None:
+ if not text.strip():
+ return self.handler.done()
+
+ self.handler.start("calc")
+
+ result = exec_shell_command(f"qalc -t '{text}'")
+ if result and result.strip():
+ if " = " in result:
+ answer = result.split(" = ")[-1].strip()
+ else:
+ answer = result.strip()
+ self.handler.slot_ready(
+ Button(
+ style_classes="app-slot calc-result",
+ child=Box(
+ orientation="v",
+ spacing=8,
+ children=[
+ Label(
+ label=f"{text} =",
+ style_classes="calc-expression",
+ h_align="start",
+ ),
+ Label(
+ label=answer,
+ style_classes="calc-result-text",
+ h_align="start",
+ ),
+ ],
+ ),
+ on_clicked=lambda *_: exec_shell_command_async(f"qalc '{text}'"),
+ ),
+ "calc",
+ )
+
+ self.handler.done()
diff --git a/mine/modules/launcher/plugins/clipboard.py b/mine/modules/launcher/plugins/clipboard.py
new file mode 100644
index 0000000..778fb6b
--- /dev/null
+++ b/mine/modules/launcher/plugins/clipboard.py
@@ -0,0 +1,262 @@
+from collections.abc import Iterator
+
+from fabric.utils import Gio, GLib, idle_add, os, remove_handler
+from fabric.widgets.box import Box
+from fabric.widgets.button import Button
+from fabric.widgets.label import Label
+
+from config.data import CLIPBOARD_DB_PATH, CLIPBOARD_THUMBS_DIR
+from modules.launcher.base import LauncherPlugin
+from utils.functions import copy_image, copy_text, run_command, trigger_paste_shortcut
+
+
+class ClipboardPlugin(LauncherPlugin):
+ @property
+ def name(self) -> str:
+ return "clipboard"
+
+ @property
+ def icon(self) -> str:
+ return "edit-paste-symbolic"
+
+ @property
+ def keywords(self) -> list[str]:
+ return ["clip"]
+
+ def __init__(self, handler):
+ super().__init__(handler)
+ self._query_handler: int = 0
+ self.history: list[dict] = []
+ self.cache_dir = CLIPBOARD_THUMBS_DIR
+ self.monitor: Gio.FileMonitor | None = None
+ self.max_slots_to_render: int = 30
+ self._rendered_slots_count: int = 0
+
+ os.makedirs(self.cache_dir, exist_ok=True)
+ self._setup_file_monitor()
+
+ def on_search(self, text: str) -> None:
+ self.query_clipboard(text)
+
+ def stop(self):
+ if self._query_handler:
+ remove_handler(self._query_handler)
+ self._query_handler = 0
+
+ if self.monitor:
+ self.monitor.cancel()
+ self.monitor = None
+
+ def _setup_file_monitor(self) -> None:
+ if not os.path.exists(CLIPBOARD_DB_PATH):
+ return
+
+ file = Gio.File.new_for_path(CLIPBOARD_DB_PATH)
+ self.monitor = file.monitor_file(Gio.FileMonitorFlags.NONE, None)
+ self.monitor.connect("changed", self._on_db_changed)
+
+ def _on_db_changed(self, _monitor, _file, _other_file, event_type):
+ if event_type in (
+ Gio.FileMonitorEvent.CHANGES_DONE_HINT,
+ Gio.FileMonitorEvent.CREATED,
+ ):
+ idle_add(self.refresh_history)
+
+ def refresh_history(self) -> None:
+ self._load_history()
+
+ def _load_history(self) -> None:
+ self.history.clear()
+
+ result = run_command(["cliphist", "list"], timeout=5)
+ output = result.stdout if isinstance(result.stdout, str) else ""
+
+ lines = output.splitlines()[:100]
+
+ for line in lines:
+ if "\t" not in line:
+ continue
+
+ identifier, content = line.split("\t", 1)
+ content = content.strip()
+
+ entry = {
+ "type": "text",
+ "identifier": identifier,
+ "raw": line,
+ "content": content,
+ }
+
+ if "binary data" in content:
+ entry["type"] = "image"
+
+ self.history.append(entry)
+
+ def _cliphist_decode(self, raw: str) -> bytes | None:
+ result = run_command(["cliphist", "decode"], input=raw.encode(), text=False)
+ return result.stdout if isinstance(result.stdout, (bytes, bytearray)) else None
+
+ def _cache_image(self, raw_data: str, identifier: str) -> str | None:
+ img_path = os.path.join(self.cache_dir, f"{identifier}.png")
+ if os.path.exists(img_path):
+ return str(img_path)
+
+ decoded = self._cliphist_decode(raw_data)
+ if not decoded:
+ return None
+ with open(img_path, "wb") as f:
+ f.write(decoded)
+ return str(img_path)
+
+ def query_clipboard(self, text: str) -> None:
+ self.handler.start("clipboard")
+ self.refresh_history()
+ self._rendered_slots_count = 0
+
+ if not self.history:
+ self._show_no_results()
+ return
+
+ filtered_entries = self._filter_entries(text)
+ self._query_handler = idle_add(
+ self._bake_next_clipboard_slot, filtered_entries, pin=True
+ )
+
+ def _show_no_results(self):
+ self.handler.slot_ready(
+ Button(
+ style_classes="app-slot clipboard-no-results",
+ child=Box(
+ orientation="h",
+ spacing=12,
+ children=[
+ Label(
+ label="No clipboard history found",
+ style_classes="clipboard-no-results-text",
+ h_align="center",
+ ),
+ ],
+ ),
+ ),
+ "clipboard",
+ )
+ self.handler.done()
+
+ def _filter_entries(self, text: str) -> Iterator[dict]:
+ if not text.strip():
+ return iter(self.history)
+
+ return iter(
+ entry
+ for entry in self.history
+ if text.lower() in entry.get("content", "").lower()
+ )
+
+ def _bake_next_clipboard_slot(self, iterator: Iterator[dict]) -> bool:
+ if self._rendered_slots_count >= self.max_slots_to_render:
+ idle_add(self.handler.done)
+ return False
+
+ entry = next(iterator, None)
+ if entry is None:
+ idle_add(self.handler.done)
+ return False
+
+ self._create_clipboard_slot(entry)
+ self._rendered_slots_count += 1
+ return True
+
+ def _create_clipboard_slot(self, entry: dict) -> None:
+ if not self._is_image_entry(entry):
+ self._create_text_slot(entry)
+ return
+ self._create_image_slot(entry)
+
+ def _is_image_entry(self, entry: dict) -> bool:
+ return entry["type"] == "image"
+
+ def _create_image_slot(self, entry: dict) -> None:
+ image_path = entry.get("path")
+ if not image_path or not os.path.exists(image_path):
+ image_path = self._cache_image(entry["raw"], entry["identifier"]) or ""
+ if image_path:
+ entry["path"] = image_path
+
+ if not image_path:
+ self.handler.slot_ready(
+ Button(
+ style_classes="app-slot clipboard-text",
+ child=Box(
+ orientation="h",
+ children=[
+ Label(
+ label="Image",
+ style_classes="clipboard-content",
+ h_align="center",
+ v_align="center",
+ max_chars_width=40,
+ ellipsization="end",
+ )
+ ],
+ ),
+ on_clicked=lambda *_: self._copy_clipboard_entry(entry),
+ ),
+ "clipboard",
+ )
+ return
+
+ self.handler.slot_ready(
+ Button(
+ style_classes="app-slot wallpaper clipboard-image",
+ child=Box(h_expand=True, v_expand=True)
+ .build()
+ .set_style(
+ f"background-image: url('file://{image_path}');",
+ compile=False,
+ )
+ .unwrap(),
+ on_clicked=lambda *_: self._copy_clipboard_entry(entry),
+ ),
+ "clipboard",
+ )
+
+ def _create_text_slot(self, entry: dict) -> None:
+ content = entry["content"]
+ display_content = content[:50] + "..." if len(content) > 50 else content
+
+ self.handler.slot_ready(
+ Button(
+ style_classes="app-slot clipboard-text",
+ child=Box(
+ orientation="h",
+ children=[
+ Label(
+ label=display_content,
+ style_classes="clipboard-content",
+ h_align="center",
+ v_align="center",
+ max_chars_width=40,
+ ellipsization="end",
+ )
+ ],
+ ),
+ tooltip_text=entry["content"],
+ on_clicked=lambda *_: self._copy_clipboard_entry(entry),
+ ),
+ "clipboard",
+ )
+
+ def _copy_clipboard_entry(self, entry: dict) -> None:
+ if entry["type"] == "image" and entry.get("path"):
+ copy_image(entry["path"])
+ else:
+ decoded = self._cliphist_decode(entry["raw"])
+ if not decoded:
+ return
+ copy_text(decoded.decode())
+ self.handler.launched()
+ GLib.timeout_add(5, lambda: (trigger_paste_shortcut(0), False)[1])
+
+ def handle_external(self, command: str, args: str) -> None:
+ # Map external commands if needed
+ pass
diff --git a/mine/modules/launcher/plugins/color_picker.py b/mine/modules/launcher/plugins/color_picker.py
new file mode 100644
index 0000000..5883203
--- /dev/null
+++ b/mine/modules/launcher/plugins/color_picker.py
@@ -0,0 +1,161 @@
+from pathlib import Path
+
+from fabric.utils import (
+ GLib,
+ exec_shell_command,
+ exec_shell_command_async,
+ idle_add,
+ re,
+ remove_handler,
+)
+from fabric.widgets.box import Box
+from fabric.widgets.button import Button
+from fabric.widgets.image import Image
+from fabric.widgets.label import Label
+
+import config.data as data
+from modules.launcher.base import LauncherPlugin
+from utils.functions import copy_text
+
+
+class ColorPickerPlugin(LauncherPlugin):
+ @property
+ def name(self) -> str:
+ return "color_picker"
+
+ @property
+ def icon(self) -> str:
+ return "color-select-symbolic"
+
+ @property
+ def keywords(self) -> list[str]:
+ return ["color"]
+
+ def __init__(self, handler):
+ super().__init__(handler)
+ self._query_handler: int = 0
+ self.tmp_icon_path = Path("/tmp/color.png")
+
+ self.options = [
+ {"name": "Pick HEX", "arg": "hex", "icon": "color-select"},
+ {"name": "Pick RGB", "arg": "rgb", "icon": "color-select"},
+ {"name": "Pick HSV", "arg": "hsv", "icon": "color-select"},
+ ]
+
+ def on_search(self, text: str) -> None:
+ self.query_color(text)
+
+ def stop(self):
+ if self._query_handler:
+ remove_handler(self._query_handler)
+ self._query_handler = 0
+
+ def query_color(self, text: str) -> None:
+ self.handler.start("color")
+
+ q = text.strip().lower()
+ if q in ("hex", "rgb", "hsv"):
+ self._defer_execute(q)
+ return
+
+ filtered = (
+ self.options
+ if not q
+ else [o for o in self.options if q in o["name"].lower() or q == o["arg"]]
+ )
+ if not filtered:
+ return self.handler.done()
+ self._query_handler = idle_add(self._bake_next_slot, iter(filtered), pin=True)
+
+ def _bake_next_slot(self, iterator) -> bool:
+ option = next(iterator, None)
+ if option is None:
+ idle_add(self.handler.done)
+ return False
+ self._create_option_slot(option)
+ return True
+
+ def _create_option_slot(self, option) -> None:
+ slot_content = Box(
+ orientation="h",
+ spacing=12,
+ children=[
+ Image(icon_name=option["icon"], h_align="start", size=32),
+ Box(
+ orientation="v",
+ children=[
+ Label(
+ label=f"Color Picker: {option['name']}",
+ style_classes="colorpicker-name",
+ h_align="start",
+ v_align="start",
+ ),
+ Label(
+ label=f"Copy {option['arg'].upper()} to clipboard and show preview",
+ style_classes="colorpicker-description",
+ h_align="start",
+ v_align="start",
+ ),
+ ],
+ ),
+ ],
+ )
+
+ slot_widget = Button(
+ style_classes="app-slot colorpicker-slot",
+ child=slot_content,
+ tooltip_text=f"Click to pick {option['arg'].upper()} color",
+ on_clicked=lambda *_: self._defer_execute(option["arg"]),
+ )
+
+ self.handler.slot_ready(slot_widget, "color")
+
+ def _defer_execute(self, mode: str) -> None:
+ self.handler.launched()
+ GLib.timeout_add(200, lambda: (self._execute_pick(mode), False)[1])
+
+ def _execute_pick(self, mode: str) -> None:
+ fmt = mode.lower()
+ if fmt not in ("hex", "rgb", "hsv"):
+ fmt = "hex"
+
+ raw = exec_shell_command(f"hyprpicker -n -f {fmt}")
+ text = raw if isinstance(raw, str) else ""
+ color = self._sanitize_color_output(fmt, text)
+ if not color:
+ return
+
+ copy_text(color)
+
+ color_spec = (
+ color
+ if fmt == "hex"
+ else (f"rgb({color})" if fmt == "rgb" else f"hsv({color})")
+ )
+ self.tmp_icon_path.unlink(missing_ok=True) # type: ignore[attr-defined]
+
+ cmd = (
+ f"magick -size 64x64 xc:'{color_spec}' {self.tmp_icon_path} >/dev/null 2>&1; "
+ f"notify-send 'Color picked' '{fmt.upper()}: {color}' -i {self.tmp_icon_path} -a '{data.APP_NAME}' -e; "
+ f"rm -f {self.tmp_icon_path}"
+ )
+ exec_shell_command_async(["bash", "-lc", cmd])
+
+ def _sanitize_color_output(self, fmt: str, text: str) -> str:
+ joined = " ".join(ln.strip() for ln in text.splitlines() if ln.strip())
+ if fmt == "hex":
+ matches = re.findall(r"#?[0-9A-Fa-f]{6,8}", joined)
+ if not matches:
+ return ""
+ val = matches[-1]
+ return val if val.startswith("#") else f"#{val}"
+ if fmt in ("rgb", "hsv"):
+ matches = re.findall(
+ r"\d+(?:\.\d+)?\s*,\s*\d+(?:\.\d+)?\s*,\s*\d+(?:\.\d+)?", joined
+ )
+ return matches[-1] if matches else ""
+ return ""
+
+ def handle_external(self, command: str, args: str) -> None:
+ # Map external commands if needed
+ pass
diff --git a/mine/modules/launcher/plugins/emoji.py b/mine/modules/launcher/plugins/emoji.py
new file mode 100644
index 0000000..86b61ae
--- /dev/null
+++ b/mine/modules/launcher/plugins/emoji.py
@@ -0,0 +1,289 @@
+import contextlib
+from collections.abc import Iterator
+from pathlib import Path
+from typing import Any
+from urllib.request import Request, urlopen
+
+from fabric.utils import GLib, idle_add, os, remove_handler
+from fabric.widgets.box import Box
+from fabric.widgets.button import Button
+from fabric.widgets.label import Label
+from thefuzz import fuzz, process
+
+import config.data as data
+from modules.launcher.base import LauncherPlugin
+from utils.functions import (
+ copy_text,
+ read_json_file,
+ trigger_paste_shortcut,
+ write_json_file,
+)
+
+
+class EmojiData:
+ def __init__(self, emoji_char: str, emoji_info: dict[str, Any]):
+ self.emoji_char = emoji_char
+ self.emoji_info = emoji_info
+ self.search_text = self._build_search_text()
+
+ def _build_search_text(self) -> str:
+ return (
+ self.emoji_info.get("en", "")
+ + " "
+ + " ".join(self.emoji_info.get("alias", []))
+ + " "
+ + " ".join(self.emoji_info.get("tags", []))
+ + " "
+ + self.emoji_info.get("category", "")
+ ).casefold()
+
+ def __str__(self):
+ return self.search_text
+
+
+class EmojiPlugin(LauncherPlugin):
+ @property
+ def name(self) -> str:
+ return "emojis"
+
+ @property
+ def icon(self) -> str:
+ return "face-smile-symbolic"
+
+ @property
+ def keywords(self) -> list[str]:
+ return ["emoji", "em"]
+
+ def __init__(self, handler):
+ super().__init__(handler)
+ self._query_handler: int = 0
+ self._max_recent = 30
+
+ cldr = self._load_cldr_annotations()
+ self._all_emojis = cldr or {}
+ self._emoji_data = [EmojiData(ec, ei) for ec, ei in self._all_emojis.items()]
+
+ def on_search(self, text: str) -> None:
+ self.query_emojis(text)
+
+ def stop(self):
+ if self._query_handler:
+ remove_handler(self._query_handler)
+ self._query_handler = 0
+
+ def _load_cldr_annotations(self) -> dict[str, dict[str, Any]] | None:
+ try:
+ lang = "en"
+ annotations_path = self._get_annotations_path(lang)
+
+ if not annotations_path.exists():
+ url = (
+ "https://raw.githubusercontent.com/unicode-org/cldr-json/main/"
+ f"cldr-json/cldr-annotations-full/annotations/{lang}/annotations.json"
+ )
+ if not self._download_annotations(url, annotations_path):
+ return None
+
+ raw = self._read_annotations_file(annotations_path)
+ if raw is None:
+ return None
+
+ return self._normalize_annotations(raw) or None
+ except Exception:
+ return None
+
+ def _get_annotations_path(self, lang: str) -> Path:
+ cache_dir = Path(data.plugins) / "emoji"
+ cache_dir.mkdir(parents=True, exist_ok=True)
+ return cache_dir / f"annotations_full_{lang}.json"
+
+ def _download_annotations(self, url: str, dest: Path) -> bool:
+ headers = {"User-Agent": "Mozilla/5.0"}
+ try:
+ req = Request(url, headers=headers)
+ with urlopen(req, timeout=5) as response:
+ if response.status == 200:
+ dest.write_bytes(response.read())
+ return True
+ return False
+ except Exception:
+ return False
+
+ def _read_annotations_file(self, path: Path) -> dict[str, Any] | None:
+ raw = read_json_file(str(path))
+ if raw:
+ return raw
+ with contextlib.suppress(OSError):
+ os.remove(path)
+ return None
+
+ def _normalize_annotations(self, raw: dict[str, Any]) -> dict[str, dict[str, Any]]:
+ annotations = (
+ raw.get("annotations", {}).get("annotations", {})
+ if isinstance(raw, dict)
+ else {}
+ )
+
+ if not annotations:
+ return {}
+
+ normalized: dict[str, dict[str, Any]] = {}
+ for emoji_char, info in annotations.items():
+ if not isinstance(info, dict):
+ continue
+ tts_list = info.get("tts", []) or []
+ default_list = info.get("default", []) or []
+
+ title = tts_list[0] if tts_list else ""
+ normalized[emoji_char] = {
+ "en": title,
+ "alias": [],
+ "tags": default_list,
+ "category": "",
+ }
+
+ return {k: v for k, v in normalized.items() if isinstance(v.get("en"), str)}
+
+ def query_emojis(self, text: str) -> None:
+ if not text.strip():
+ return self._show_recent_emojis()
+
+ self.handler.start("emojis")
+ query = text.casefold()
+
+ filtered_list = [
+ emoji_data
+ for emoji_data, _ in process.extract(
+ query,
+ self._emoji_data,
+ scorer=fuzz.WRatio,
+ processor=lambda e: e.search_text if hasattr(e, "search_text") else e,
+ limit=50,
+ )
+ ]
+
+ if not filtered_list:
+ return self._show_no_results()
+ filtered_emojis: Iterator[EmojiData] = iter(filtered_list)
+
+ self._query_handler = idle_add(
+ self._bake_next_emoji_slot, filtered_emojis, pin=True
+ )
+
+ def _show_recent_emojis(self) -> None:
+ self.handler.start("emojis")
+ recent_list = self._get_recent_emojis(limit=self._max_recent)
+ if not recent_list:
+ return self._show_no_results("No recent emoji found")
+
+ recent_data: list[EmojiData] = []
+ for emoji_char in recent_list:
+ info = self._all_emojis.get(
+ emoji_char, {"en": "", "alias": [], "tags": [], "category": ""}
+ )
+ recent_data.append(EmojiData(emoji_char, info))
+
+ iterator: Iterator[EmojiData] = iter(recent_data)
+ self._query_handler = idle_add(self._bake_next_emoji_slot, iterator, pin=True)
+
+ def _show_no_results(self, message: str = "No emojis found"):
+ self.handler.slot_ready(
+ Button(
+ style_classes="app-slot emoji-no-results",
+ child=Box(
+ orientation="h",
+ spacing=12,
+ children=[
+ Label(
+ label=message,
+ style_classes="emoji-no-results-text",
+ h_align="center",
+ ),
+ ],
+ ),
+ ),
+ "emojis",
+ )
+ self.handler.done()
+
+ def _bake_next_emoji_slot(self, iterator: Iterator[EmojiData]) -> bool:
+ emoji_data = next(iterator, None)
+ if emoji_data is None:
+ idle_add(self.handler.done)
+ return False
+
+ emoji_button = self._create_emoji_button(
+ emoji_data.emoji_char, emoji_data.emoji_info
+ )
+ self.handler.slot_ready(emoji_button, "emojis")
+ return True
+
+ def _create_emoji_button(
+ self, emoji_char: str, emoji_info: dict[str, Any]
+ ) -> Button:
+ emoji_name = self._format_emoji_name(emoji_info.get("en", "Unknown"))
+
+ return Button(
+ style_classes="app-slot emoji-slot",
+ child=Box(
+ orientation="h",
+ spacing=12,
+ children=[
+ Label(
+ label=emoji_char,
+ style_classes="emoji-char",
+ h_align="start",
+ v_align="center",
+ ),
+ Box(
+ orientation="v",
+ children=[
+ Label(
+ label=emoji_name,
+ style_classes="emoji-name",
+ h_align="start",
+ v_align="start",
+ )
+ ],
+ ),
+ ],
+ ),
+ tooltip_text=f"Click to copy {emoji_char}",
+ on_clicked=lambda *_: self._copy_to_clipboard(emoji_char),
+ )
+
+ def _format_emoji_name(self, name: str) -> str:
+ return name.replace(":", "").replace("_", " ").title()
+
+ def _copy_to_clipboard(self, emoji_char: str) -> None:
+ copy_text(emoji_char)
+ self._add_recent_emoji(emoji_char)
+ self.handler.launched()
+ GLib.timeout_add(5, lambda: (trigger_paste_shortcut(0), False)[1])
+
+ def _get_recent_emojis(self, limit: int = 30) -> list[str]:
+ try:
+ store = read_json_file(data.RECENT_EMOJIS_FILE) or {}
+ recent = store.get("recent", []) if isinstance(store, dict) else []
+ filtered = [e for e in recent if isinstance(e, str)]
+ return filtered[: max(limit, 0)]
+ except Exception:
+ return []
+
+ def _add_recent_emoji(self, emoji_char: str) -> None:
+ if not isinstance(emoji_char, str) or not emoji_char:
+ return
+ try:
+ store = read_json_file(data.RECENT_EMOJIS_FILE) or {}
+ recent = store.get("recent", []) if isinstance(store, dict) else []
+ recent = [e for e in recent if isinstance(e, str) and e != emoji_char]
+ recent.insert(0, emoji_char)
+ if self._max_recent > 0:
+ recent = recent[: self._max_recent]
+ write_json_file({"recent": recent}, data.RECENT_EMOJIS_FILE)
+ except Exception:
+ pass
+
+ def handle_external(self, command: str, args: str) -> None:
+ # Map external commands if needed
+ pass
diff --git a/mine/modules/launcher/plugins/google_lens.py b/mine/modules/launcher/plugins/google_lens.py
new file mode 100644
index 0000000..39c7b84
--- /dev/null
+++ b/mine/modules/launcher/plugins/google_lens.py
@@ -0,0 +1,35 @@
+from fabric.utils import GLib
+
+from modules.launcher.base import LauncherPlugin
+from services.google_lens import GoogleLens
+
+
+class GoogleLensPlugin(LauncherPlugin):
+ def __init__(self, handler):
+ super().__init__(handler)
+ self.lens_service = GoogleLens.get_initial()
+
+ @property
+ def name(self) -> str:
+ return "Google Lens"
+
+ @property
+ def icon(self) -> str:
+ return "camera-photo-symbolic"
+
+ @property
+ def keywords(self) -> list[str]:
+ return ["lens", "google lens"]
+
+ def on_search(self, text: str) -> None:
+ pass
+
+ def on_activate(self) -> None:
+ self.handler.launched()
+ GLib.timeout_add(200, self.lens_service.search)
+
+ def on_deactivate(self) -> None:
+ pass
+
+ def on_submit(self, text: str) -> None:
+ pass
diff --git a/mine/modules/launcher/plugins/network_manager.py b/mine/modules/launcher/plugins/network_manager.py
new file mode 100644
index 0000000..583682d
--- /dev/null
+++ b/mine/modules/launcher/plugins/network_manager.py
@@ -0,0 +1,465 @@
+from collections.abc import Iterator
+
+from fabric.utils import Gdk, GLib, idle_add, logger, remove_handler
+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 modules.launcher.base import LauncherPlugin
+from services import NetworkClient
+
+
+class NetworkManagerPlugin(LauncherPlugin):
+ @property
+ def name(self) -> str:
+ return "network"
+
+ @property
+ def icon(self) -> str:
+ return "network-workgroup-symbolic"
+
+ @property
+ def keywords(self) -> list[str]:
+ return ["net"]
+
+ def __init__(self, handler):
+ super().__init__(handler)
+ self._query_handler: int = 0
+ self.network_client = NetworkClient()
+
+ self._items: list[dict] = []
+ self._awaiting_password: bool = False
+ self._pending_ssid: str | None = None
+ self._current_search_text: str = ""
+ self._signal_refresh_handler: int = 0
+ self._is_active: bool = False
+ self._signal_connections: list[int] = []
+
+ self._wire_signals()
+
+ def on_search(self, text: str) -> None:
+ self.query_networks(text)
+
+ def stop(self):
+ if self._query_handler:
+ remove_handler(self._query_handler)
+ self._query_handler = 0
+ if self._signal_refresh_handler:
+ GLib.source_remove(self._signal_refresh_handler)
+ self._signal_refresh_handler = 0
+
+ if self.network_client and self._signal_connections:
+ for connection_id in self._signal_connections:
+ self.network_client.disconnect(connection_id)
+ self._signal_connections.clear()
+
+ def _wire_signals(self) -> None:
+ self._signal_connections.append(
+ self.network_client.connect("changed", self._on_network_changed)
+ )
+ wifi_device = self.network_client.wifi_device
+ if wifi_device:
+ self._signal_connections.append(
+ wifi_device.connect("changed", self._on_network_changed)
+ )
+ self._signal_connections.append(
+ wifi_device.connect("ap-added", self._on_network_changed)
+ )
+ self._signal_connections.append(
+ wifi_device.connect("ap-removed", self._on_network_changed)
+ )
+
+ def _on_network_changed(self, *args, **kwargs):
+ if self._awaiting_password or not self._is_active:
+ return
+ if self._signal_refresh_handler:
+ return
+ self._signal_refresh_handler = GLib.timeout_add(250, self._refresh_timeout)
+
+ def _refresh_timeout(self) -> bool:
+ self._signal_refresh_handler = 0
+ if self._is_active or self._awaiting_password:
+ self._refresh_query()
+ return False
+
+ def query_networks(self, text: str) -> None:
+ self._is_active = True
+ self.handler.start("networks")
+ self._current_search_text = text
+
+ if text.lower() == "scan":
+ self._perform_scan()
+ return
+
+ self._items = self._get_network_items(text)
+ if not self._items:
+ self._show_no_connections()
+ return
+
+ self._query_handler = idle_add(
+ self._bake_next_slot, iter(self._items), pin=True
+ )
+
+ def _get_network_items(self, search_text: str) -> list[dict]:
+ items = []
+ wifi_device = self.network_client.wifi_device
+
+ if not wifi_device:
+ return items
+
+ for ap in wifi_device.access_points:
+ if search_text and not ap.ssid.lower().startswith(search_text.lower()):
+ continue
+ if not ap.ssid or ap.ssid == "Unknown":
+ continue
+ items.append(
+ {
+ "type": "ap",
+ "name": ap.ssid,
+ "ap": ap,
+ "device": wifi_device._device,
+ "active": ap.is_active,
+ }
+ )
+
+ return items
+
+ def _perform_scan(self) -> None:
+ wifi_device = self.network_client.wifi_device
+ if not wifi_device:
+ self._show_no_wifi_device()
+ return
+
+ self._show_scanning_feedback()
+ wifi_device.scan()
+ GLib.timeout_add(2000, self._scan_complete_callback)
+
+ def _show_scanning_feedback(self) -> None:
+ """Show feedback that scanning is in progress."""
+ self.handler.slot_ready(
+ Button(
+ style_classes="app-slot network-scanning",
+ child=Box(
+ orientation="h",
+ spacing=12,
+ children=[
+ Image(icon_name="network-wireless-acquiring-symbolic", size=32),
+ Label(
+ label="Scanning for networks...",
+ style_classes="network-scanning-text",
+ h_align="center",
+ ),
+ ],
+ ),
+ tooltip_text="Scanning for available WiFi networks",
+ ),
+ "networks",
+ )
+ self.handler.done()
+
+ def _show_no_wifi_device(self) -> None:
+ """Show message when no WiFi device is available."""
+ self.handler.slot_ready(
+ Button(
+ style_classes="app-slot network-no-wifi",
+ child=Box(
+ orientation="h",
+ spacing=12,
+ children=[
+ Image(icon_name="network-wireless-offline-symbolic", size=32),
+ Label(
+ label="No WiFi device available",
+ style_classes="network-no-wifi-text",
+ h_align="center",
+ ),
+ ],
+ ),
+ tooltip_text="WiFi device not found or disabled",
+ ),
+ "networks",
+ )
+ self.handler.done()
+
+ def _scan_complete_callback(self) -> bool:
+ """Callback after scan completion to refresh the network list."""
+ if self._is_active and self._current_search_text.lower() == "scan":
+ # Clear the scanning feedback and show updated results
+ if hasattr(self.handler, "clear_viewport"):
+ self.handler.clear_viewport()
+ # Show all available networks after scan (don't clear input to preserve /n prefix)
+ self._current_search_text = ""
+ self._items = self._get_network_items("")
+ if not self._items:
+ self._show_no_connections()
+ else:
+ self._query_handler = idle_add(
+ self._bake_next_slot, iter(self._items), pin=True
+ )
+ return False
+
+ def _refresh_query(self) -> None:
+ if self._awaiting_password or not self._is_active:
+ return
+ if self._query_handler:
+ remove_handler(self._query_handler)
+ self._query_handler = 0
+ if hasattr(self.handler, "clear_viewport"):
+ self.handler.clear_viewport()
+ self.query_networks(self._current_search_text)
+
+ def _show_no_connections(self) -> None:
+ self.handler.slot_ready(
+ Button(
+ style_classes="app-slot network-no-connections",
+ child=Box(
+ orientation="h",
+ spacing=12,
+ children=[
+ Image(icon_name="network-offline-symbolic", size=32),
+ Label(
+ label="No connections found",
+ style_classes="network-no-connections-text",
+ h_align="center",
+ ),
+ ],
+ ),
+ tooltip_text="No NetworkManager connections available",
+ ),
+ "networks",
+ )
+ self.handler.done()
+
+ def _bake_next_slot(self, iterator: Iterator[dict]) -> bool:
+ item = next(iterator, None)
+ if item is None:
+ idle_add(self.handler.done)
+ return False
+ self._create_slot(item)
+ return True
+
+ def _create_slot(self, item: dict) -> None:
+ ap = item["ap"]
+ name = ap.ssid
+ is_active = ap.is_active
+ strength = ap.strength
+ requires_password = ap.requires_password
+
+ icon_name = self._get_wifi_icon(strength)
+ subtitle = f"Signal: {strength}%"
+ if requires_password:
+ subtitle = f"Secured • {subtitle}"
+ if is_active:
+ subtitle = f"Connected • {subtitle}"
+
+ slot_content = Box(
+ orientation="h",
+ spacing=12,
+ children=[
+ Image(icon_name=icon_name, h_align="start", size=32),
+ Box(
+ orientation="v",
+ children=[
+ Label(
+ label=("* " + name) if is_active else name,
+ style_classes="network-name",
+ h_align="start",
+ ),
+ Label(
+ label=subtitle,
+ style_classes="network-subtitle",
+ h_align="start",
+ ),
+ ],
+ ),
+ ],
+ )
+
+ slot_widget = Button(
+ style_classes="app-slot network-slot",
+ child=slot_content,
+ tooltip_text=self._get_tooltip_text(is_active, name),
+ on_clicked=lambda *_: self._handle_slot_click(item),
+ )
+
+ slot_widget.connect(
+ "key-press-event",
+ lambda widget, event, item=item: self._on_slot_key_press(
+ widget, event, item
+ ),
+ )
+
+ self.handler.slot_ready(slot_widget, "networks")
+
+ def _get_tooltip_text(self, is_active: bool, ssid: str) -> str:
+ if is_active:
+ return (
+ f"Enter: Disconnect\nShift+Enter: Remove saved connection\nSSID: {ssid}"
+ )
+ else:
+ return f"Enter: Connect\nShift+Enter: Remove saved connection\nSSID: {ssid}"
+
+ def _on_slot_key_press(self, widget, event, item: dict) -> bool:
+ if not event:
+ return False
+
+ if (
+ event.keyval == Gdk.KEY_Return or event.keyval == Gdk.KEY_KP_Enter
+ ) and event.state & Gdk.ModifierType.SHIFT_MASK:
+ self._remove_saved_connection(item)
+ return True
+
+ return False
+
+ def _remove_saved_connection(self, item: dict) -> None:
+ ap = item["ap"]
+ ssid = ap.ssid
+
+ wifi_device = self.network_client.wifi_device
+ if not wifi_device:
+ logger.error("No WiFi device available")
+ return
+
+ client = self.network_client._client
+ connections = client.get_connections()
+
+ for connection in connections:
+ wifi_setting = connection.get_setting_wireless()
+ if wifi_setting:
+ connection_ssid = wifi_setting.get_ssid()
+ if connection_ssid:
+ connection_ssid_str = connection_ssid.get_data().decode("utf-8")
+ if connection_ssid_str == ssid:
+ try:
+ connection.delete_async(
+ None, self._on_connection_deleted, ssid
+ )
+ logger.info(f"Removing saved connection for '{ssid}'")
+ return
+ except Exception as e:
+ logger.error(
+ f"Failed to remove connection for '{ssid}': {e}"
+ )
+ return
+
+ logger.warning(f"No saved connection found for '{ssid}'")
+
+ def _on_connection_deleted(self, connection, result, ssid: str) -> None:
+ try:
+ connection.delete_finish(result)
+ logger.info(f"Successfully removed saved connection for '{ssid}'")
+ if self._is_active:
+ self._refresh_query()
+ except Exception as e:
+ logger.error(f"Failed to delete connection for '{ssid}': {e}")
+
+ def _get_wifi_icon(self, strength: int) -> str:
+ if strength >= 80:
+ return "network-wireless-signal-excellent"
+ elif strength >= 60:
+ return "network-wireless-signal-good"
+ elif strength >= 40:
+ return "network-wireless-signal-ok"
+ elif strength >= 20:
+ return "network-wireless-signal-weak"
+ else:
+ return "network-wireless-signal-none"
+
+ def _handle_slot_click(self, item: dict) -> None:
+ if item.get("active"):
+ wifi_device = self.network_client.wifi_device
+ if wifi_device:
+ wifi_device.disconnect_wifi()
+ self.handler.launched()
+ else:
+ ap = item["ap"]
+ if ap.requires_password:
+ self._prompt_password(ap.ssid)
+ else:
+ self._connect_to_wifi(ap.ssid, "")
+ self.handler.launched()
+
+ def _prompt_password(self, ssid: str) -> None:
+ self._awaiting_password = True
+ self._pending_ssid = ssid
+ self.handler.start("networks")
+ if hasattr(self.handler, "clear_viewport"):
+ self.handler.clear_viewport()
+ if hasattr(self.handler, "clear_input"):
+ self.handler.clear_input()
+
+ self.handler.slot_ready(
+ Button(
+ style_classes="app-slot network-help",
+ child=Box(
+ orientation="v",
+ spacing=8,
+ children=[
+ Label(
+ label=f"Password for {ssid}",
+ style_classes="network-help-text",
+ h_align="center",
+ ),
+ Label(
+ label="Enter=Submit • Empty=Cancel",
+ style_classes="network-subtext",
+ h_align="center",
+ ),
+ ],
+ ),
+ ),
+ "networks",
+ )
+ self.handler.done()
+
+ @property
+ def is_password_mode(self) -> bool:
+ return self._awaiting_password and self._pending_ssid is not None
+
+ def is_waiting_for_password(self) -> bool:
+ return self._awaiting_password and self._pending_ssid
+
+ def submit_password(self, password: str) -> None:
+ if not self._pending_ssid:
+ return
+
+ ssid = self._pending_ssid
+ self._awaiting_password = False
+ self._pending_ssid = None
+
+ if hasattr(self.handler, "clear_viewport"):
+ self.handler.clear_viewport()
+ self._connect_to_wifi(ssid, password)
+ self._is_active = False
+ self.handler.launched()
+
+ def cancel_password(self) -> None:
+ self._awaiting_password = False
+ self._pending_ssid = None
+ self._is_active = False
+
+ if hasattr(self.handler, "clear_viewport"):
+ self.handler.clear_viewport()
+ self.query_networks(self._current_search_text)
+
+ def _connect_to_wifi(self, ssid: str, password: str) -> None:
+ wifi_device = self.network_client.wifi_device
+ if not wifi_device:
+ return
+
+ target_ap = None
+ for ap in wifi_device.access_points:
+ if ap.ssid == ssid:
+ target_ap = ap
+ break
+
+ if not target_ap:
+ return
+
+ def callback(success: bool, message: str):
+ if success:
+ logger.info(f"Connected to '{ssid}'")
+ else:
+ logger.error(f"Failed to connect to '{ssid}': {message}")
+
+ wifi_device.connect_to_wifi(target_ap, password, callback)
diff --git a/mine/modules/launcher/plugins/otp.py b/mine/modules/launcher/plugins/otp.py
new file mode 100644
index 0000000..f42bbaa
--- /dev/null
+++ b/mine/modules/launcher/plugins/otp.py
@@ -0,0 +1,446 @@
+import json
+from collections.abc import Iterator
+from pathlib import Path
+
+from fabric.utils import Gdk, GLib, idle_add, remove_handler
+from fabric.widgets.box import Box
+from fabric.widgets.button import Button
+from fabric.widgets.label import Label
+
+from config.data import plugins
+from modules.launcher.base import LauncherPlugin
+from services import (
+ generate_totp,
+ get_time_remaining,
+ scan_qr_and_add_account,
+ validate_base32_secret,
+)
+from utils.functions import copy_text, trigger_paste_shortcut
+
+
+class OTPPlugin(LauncherPlugin):
+ @property
+ def name(self) -> str:
+ return "otp"
+
+ @property
+ def icon(self) -> str:
+ return "security-high-symbolic"
+
+ @property
+ def keywords(self) -> list[str]:
+ return ["otp"]
+
+ def __init__(self, handler):
+ super().__init__(handler)
+ self.secrets_file_path = Path(plugins) / "accounts.json"
+ self.secrets_file_path.parent.mkdir(parents=True, exist_ok=True)
+
+ self._query_handler: int = 0
+ self._timer_handler: int = 0
+ self.accounts: dict = {}
+ self.current_slots: list = []
+ self._load_accounts()
+
+ def on_search(self, text: str) -> None:
+ self.query_otp(text)
+
+ def stop(self):
+ if self._query_handler:
+ remove_handler(self._query_handler)
+ self._query_handler = 0
+ if self._timer_handler:
+ GLib.source_remove(self._timer_handler)
+ self._timer_handler = 0
+ self.current_slots.clear()
+
+ def _load_accounts(self) -> None:
+ self.accounts.clear()
+ if not self.secrets_file_path.exists():
+ return
+
+ try:
+ with open(self.secrets_file_path, encoding="utf-8") as f:
+ self.accounts = json.load(f)
+ except (OSError, json.JSONDecodeError):
+ self.accounts = {}
+
+ def _save_accounts(self) -> None:
+ try:
+ with open(self.secrets_file_path, "w", encoding="utf-8") as f:
+ json.dump(self.accounts, f, indent=2)
+ except OSError:
+ pass
+
+ def query_otp(self, text: str) -> None:
+ self.handler.start("otp")
+ self._load_accounts()
+
+ self.current_slots.clear()
+ if self._timer_handler:
+ GLib.source_remove(self._timer_handler)
+ self._timer_handler = 0
+
+ immediate_commands = {
+ "add": self._handle_add_command,
+ "remove": self._handle_remove_command,
+ "help": lambda t: self._show_command_help(),
+ }
+
+ for prefix, handler in immediate_commands.items():
+ if text.strip().startswith(prefix):
+ handler(text)
+ return
+
+ if not self.accounts:
+ self._show_no_accounts()
+ return
+
+ filtered_accounts = self._filter_accounts(text)
+ if not filtered_accounts:
+ self._show_no_results()
+ return
+
+ self._query_handler = idle_add(
+ self._bake_next_otp_slot, iter(filtered_accounts), pin=True
+ )
+
+ def _filter_accounts(self, text: str) -> Iterator[tuple[str, dict]]:
+ if not text.strip():
+ return iter(self.accounts.items())
+
+ text_lower = text.lower()
+ return iter(
+ (name, data)
+ for name, data in self.accounts.items()
+ if text_lower in name.lower()
+ )
+
+ def _show_no_accounts(self) -> None:
+ self.handler.slot_ready(
+ Button(
+ style_classes="app-slot otp-no-accounts",
+ child=Box(
+ orientation="h",
+ spacing=12,
+ children=[
+ Label(
+ label="No OTP accounts found",
+ style_classes="otp-no-accounts-text",
+ h_align="center",
+ ),
+ ],
+ ),
+ tooltip_text="Use 'add account_name qr' or 'add account_name secret_key' to add accounts",
+ ),
+ "otp",
+ )
+ self.handler.done()
+
+ def _show_no_results(self) -> None:
+ self.handler.slot_ready(
+ Button(
+ style_classes="app-slot otp-no-results",
+ child=Box(
+ orientation="h",
+ spacing=12,
+ children=[
+ Label(
+ label="No accounts match your search",
+ style_classes="otp-no-results-text",
+ h_align="center",
+ ),
+ ],
+ ),
+ ),
+ "otp",
+ )
+ self.handler.done()
+
+ def _bake_next_otp_slot(self, iterator: Iterator[tuple[str, dict]]) -> bool:
+ try:
+ account_name, account_data = next(iterator)
+ except StopIteration:
+ if self.current_slots:
+ self._start_timer()
+ idle_add(self.handler.done)
+ return False
+
+ self._create_otp_slot(account_name, account_data)
+ return True
+
+ def _create_otp_slot(self, account_name: str, account_data: dict) -> None:
+ secret = account_data.get("secret", "")
+ otp_code = generate_totp(secret)
+
+ if not otp_code:
+ return
+
+ display_name = account_name
+ time_remaining = get_time_remaining()
+
+ timer_label = Label(
+ label=f"{time_remaining}s",
+ style_classes="otp-timer",
+ h_align="end",
+ )
+
+ code_label = Label(
+ label=otp_code,
+ style_classes="otp-code",
+ h_align="start",
+ )
+
+ name_label = Label(
+ label=display_name,
+ style_classes="otp-account-name",
+ h_align="start",
+ max_chars_width=35,
+ ellipsization="end",
+ )
+
+ tooltip_text = "Click to copy OTP code"
+
+ slot_widget = Button(
+ style_classes="app-slot otp-slot",
+ child=Box(
+ orientation="v",
+ spacing=4,
+ children=[
+ name_label,
+ Box(
+ orientation="h",
+ spacing=8,
+ children=[
+ code_label,
+ timer_label,
+ ],
+ ),
+ ],
+ ),
+ tooltip_text=tooltip_text,
+ on_clicked=lambda *_: self._copy_otp_code(otp_code),
+ )
+
+ slot_widget.connect(
+ "key-press-event",
+ lambda widget, event, acc_name=account_name: self._on_slot_key_press(
+ widget, event, acc_name
+ ),
+ )
+
+ slot_info = {
+ "widget": slot_widget,
+ "timer_label": timer_label,
+ "code_label": code_label,
+ "account_name": account_name,
+ "account_data": account_data,
+ }
+ self.current_slots.append(slot_info)
+ self.handler.slot_ready(slot_widget, "otp")
+
+ def _copy_otp_code(self, code: str) -> None:
+ copy_text(code)
+ self.handler.launched()
+ GLib.timeout_add(5, lambda: (trigger_paste_shortcut(0), False)[1])
+
+ def _on_slot_key_press(self, widget, event, account_name: str) -> bool:
+ if not event:
+ return False
+ return (
+ (event.keyval == Gdk.KEY_Return or event.keyval == Gdk.KEY_KP_Enter)
+ and event.state & Gdk.ModifierType.SHIFT_MASK
+ and (
+ self.remove_account(account_name)
+ and self._refresh_account_list()
+ or True
+ )
+ )
+
+ def add_account_manually(self, account_name: str, secret: str) -> bool:
+ validation = validate_base32_secret(secret)
+ if not validation["success"]:
+ return False
+
+ self.accounts[account_name] = {
+ "secret": validation["secret"],
+ "algorithm": "SHA1",
+ "digits": 6,
+ "period": 30,
+ }
+ self._save_accounts()
+ return True
+
+ def remove_account(self, account_name: str) -> bool:
+ if account_name not in self.accounts:
+ return False
+ del self.accounts[account_name]
+ self._save_accounts()
+ return True
+
+ def _start_timer(self) -> None:
+ if self._timer_handler:
+ GLib.source_remove(self._timer_handler)
+ self._timer_handler = GLib.timeout_add_seconds(1, self._update_timers)
+
+ def _update_timers(self) -> bool:
+ if not self.current_slots:
+ return False
+
+ for slot_info in self.current_slots:
+ secret = slot_info["account_data"].get("secret", "")
+ otp_code = generate_totp(secret)
+ if otp_code:
+ slot_info["code_label"].set_text(otp_code)
+ slot_info["timer_label"].set_text(f"{get_time_remaining()}s")
+ return True
+
+ def _refresh_account_list(self) -> None:
+ self.current_slots.clear()
+ if self._timer_handler:
+ GLib.source_remove(self._timer_handler)
+ self._timer_handler = 0
+
+ if hasattr(self.handler, "clear_viewport"):
+ self.handler.clear_viewport()
+
+ self._load_accounts()
+ idle_add(self._show_account_list)
+
+ def _show_account_list(self) -> None:
+ self.handler.start("otp")
+ if not self.accounts:
+ self._show_no_accounts()
+ return
+
+ filtered_accounts = self._filter_accounts("")
+ if not filtered_accounts:
+ self._show_no_results()
+ return
+
+ self._query_handler = idle_add(
+ self._bake_next_otp_slot, iter(filtered_accounts), pin=True
+ )
+
+ def _handle_add_command(self, text: str) -> None:
+ text_stripped = text.strip()
+ parts = text_stripped.split()
+
+ if len(parts) < 3:
+ self._show_command_help("add")
+ return
+
+ account_name = parts[1]
+ secret_or_qr = parts[2].rstrip(";") # Remove semicolon if present
+
+ if secret_or_qr.lower() == "qr":
+ # QR code scanning doesn't require semicolon
+ result = scan_qr_and_add_account(account_name, str(self.secrets_file_path))
+ if not result["success"]:
+ self._show_error_message(f"Error adding account: {result['error']}")
+ return
+ else:
+ # Secret key must end with semicolon
+ if not text_stripped.endswith(";"):
+ self._show_error_message(
+ "Add command with secret key must end with ';'\n\nUsage: add account_name secret_key;"
+ )
+ return
+
+ # Remove semicolon and extract secret key (handles spaces in secret)
+ command_text = text_stripped[:-1] # Remove semicolon
+ # Split into max 3 parts: ["add", "account_name", "secret_key with spaces"]
+ command_parts = command_text.split(None, 2)
+ if len(command_parts) < 3:
+ self._show_command_help("add")
+ return
+
+ account_name = command_parts[1]
+ secret_key = command_parts[2] # This will contain the entire secret key
+
+ if not self.add_account_manually(account_name, secret_key):
+ self._show_error_message(f"Failed to add account: {account_name}")
+ return
+
+ if hasattr(self.handler, "clear_input"):
+ self.handler.clear_input()
+ self._refresh_account_list()
+
+ def _handle_remove_command(self, text: str) -> None:
+ if text.strip() == "remove":
+ self._show_account_list()
+ return
+
+ parts = text.strip().split()
+ if len(parts) < 2:
+ self._show_command_help("remove")
+ return
+
+ account_name = parts[1]
+ if not self.remove_account(account_name):
+ self._show_error_message(f"Account not found: {account_name}")
+ return
+
+ if hasattr(self.handler, "clear_input"):
+ self.handler.clear_input()
+ self._refresh_account_list()
+
+ def _show_command_help(self, command: str = None) -> None:
+ help_texts = {
+ "add": "Usage: add account_name qr\nadd account_name secret_key;",
+ "remove": "Usage: remove account_name",
+ }
+
+ help_text = help_texts.get(
+ command,
+ """Available commands:
+• add account_name qr
+• add account_name secret_key;
+• remove account_name
+• help
+Click on any OTP code to copy it!""",
+ )
+
+ self.handler.slot_ready(
+ Button(
+ style_classes="app-slot otp-help",
+ child=Box(
+ orientation="h",
+ spacing=12,
+ children=[
+ Label(
+ label=help_text,
+ style_classes="otp-help-text",
+ h_align="center",
+ ),
+ ],
+ ),
+ ),
+ "otp",
+ )
+ self.handler.done()
+
+ def _show_error_message(self, message: str) -> None:
+ self.handler.slot_ready(
+ Button(
+ style_classes="app-slot otp-error",
+ child=Box(
+ orientation="h",
+ spacing=12,
+ children=[
+ Label(
+ label=message,
+ style_classes="otp-error-text",
+ h_align="center",
+ ),
+ ],
+ ),
+ on_clicked=lambda *_: self.handler.launched(),
+ ),
+ "otp",
+ )
+ self.handler.done()
+
+ def handle_external(self, command: str, args: str) -> None:
+ # Map external commands if needed
+ pass
diff --git a/mine/modules/launcher/plugins/password.py b/mine/modules/launcher/plugins/password.py
new file mode 100644
index 0000000..dfa8764
--- /dev/null
+++ b/mine/modules/launcher/plugins/password.py
@@ -0,0 +1,614 @@
+from collections.abc import Iterator
+from pathlib import Path
+
+from fabric.utils import Gdk, GLib, idle_add, remove_handler
+from fabric.widgets.box import Box
+from fabric.widgets.button import Button
+from fabric.widgets.label import Label
+
+from config.data import plugins
+from modules.launcher.base import LauncherPlugin
+from services import PasswordManager
+from utils.functions import copy_text, trigger_paste_shortcut
+
+
+class PasswordManagerPlugin(LauncherPlugin):
+ @property
+ def name(self) -> str:
+ return "passwords"
+
+ @property
+ def icon(self) -> str:
+ return "dialog-password-symbolic"
+
+ @property
+ def keywords(self) -> list[str]:
+ return ["pass"]
+
+ def __init__(self, handler):
+ super().__init__(handler)
+ self.passwords_dir = Path(plugins) / "passwords"
+ self.passwords_dir.mkdir(parents=True, exist_ok=True)
+ self.passwords_file_path = self.passwords_dir / "passwords.json"
+ self.key_file_path = self.passwords_dir / "key.key"
+ self.salt_file_path = self.passwords_dir / "salt.key"
+
+ self._query_handler: int = 0
+ self.current_slots: list = []
+ self.passwords: dict = {}
+ self.password_manager = None
+ self.master_password_set = False
+ self._awaiting_master_password: bool = False
+
+ def on_search(self, text: str) -> None:
+ self.query_passwords(text)
+
+ def stop(self):
+ if self._query_handler:
+ remove_handler(self._query_handler)
+ self._query_handler = 0
+ self.current_slots.clear()
+
+ @property
+ def is_password_mode(self) -> bool:
+ return self._awaiting_master_password
+
+ def _is_unlocked(self) -> bool:
+ return self.password_manager is not None and self.master_password_set
+
+ def is_waiting_for_password(self) -> bool:
+ return self._awaiting_master_password
+
+ def _get_password_manager(self, master_password: str = None):
+ if self.password_manager is None and master_password:
+ self.password_manager = PasswordManager(master_password)
+ self.password_manager.passwords_file = self.passwords_file_path
+ self.password_manager.key_file = self.key_file_path
+ self.password_manager.salt_file = self.salt_file_path
+ self.password_manager._initialize_password_file()
+ self.password_manager._setup_encryption()
+ self.master_password_set = True
+ return self.password_manager
+
+ def _check_master_password_exists(self) -> bool:
+ return self.key_file_path.exists()
+
+ def _initialize_password_manager(self, password: str) -> bool:
+ try:
+ self.password_manager = PasswordManager(password)
+ self.password_manager.passwords_file = self.passwords_file_path
+ self.password_manager.key_file = self.key_file_path
+ self.password_manager.salt_file = self.salt_file_path
+ self.password_manager._initialize_password_file()
+ self.password_manager._setup_encryption()
+ self.master_password_set = True
+ return True
+ except Exception:
+ return False
+
+ def _verify_master_password(self, password: str) -> bool:
+ try:
+ temp_pm = PasswordManager(password)
+ temp_pm.passwords_file = self.passwords_file_path
+ temp_pm.key_file = self.key_file_path
+ temp_pm.salt_file = self.salt_file_path
+ temp_pm._initialize_password_file()
+ temp_pm._setup_encryption()
+
+ return temp_pm.verify_master_password(password)
+ except Exception:
+ return False
+
+ def query_passwords(self, text: str) -> None:
+ self.handler.start("passwords")
+ self.current_slots.clear()
+ text_stripped = text.strip()
+
+ commands = {
+ "lock": self._handle_lock_command,
+ "help": lambda t: self._show_command_help(),
+ "add": self._handle_add_command,
+ "remove": self._handle_remove_command,
+ "update": self._handle_update_command,
+ }
+
+ for prefix, handler in commands.items():
+ if text_stripped.startswith(prefix):
+ if (
+ prefix in ("add", "remove", "update")
+ and not self._is_unlocked()
+ and self._check_master_password_exists()
+ ):
+ self._prompt_master_password()
+ return
+ if prefix in ("add", "remove", "update") and not self._is_unlocked():
+ self._show_master_password_required()
+ return
+ handler(text)
+ return
+
+ if not self._is_unlocked():
+ if self._check_master_password_exists():
+ self._prompt_master_password()
+ else:
+ self._prompt_master_password_for_setup()
+ return
+ self._load_and_display_passwords(text)
+
+ def _load_and_display_passwords(self, text: str) -> None:
+ pm = self._get_password_manager()
+ if not pm:
+ self._show_master_password_required()
+ return
+
+ result = pm.list_passwords(decrypt_passwords=False)
+ if not result["success"]:
+ self._show_error_message(f"Failed to load passwords: {result['error']}")
+ return
+
+ self.passwords = result["data"]
+ # Remove the master password hash and salt from display
+ self.passwords.pop("_master_password_hash", None)
+ self.passwords.pop("_master_password_salt", None)
+
+ if not self.passwords:
+ self._show_no_passwords()
+ return
+
+ filtered_passwords = self._filter_passwords(text)
+ if not filtered_passwords:
+ self._show_no_results()
+ return
+
+ self._query_handler = idle_add(
+ self._bake_next_password_slot, iter(filtered_passwords), pin=True
+ )
+
+ def _filter_passwords(self, text: str) -> Iterator[tuple[str, dict]]:
+ if not text.strip():
+ return iter(self.passwords.items())
+
+ text_lower = text.lower()
+ return iter(
+ (name, data)
+ for name, data in self.passwords.items()
+ if (
+ text_lower in name.lower()
+ or text_lower in data.get("username", "").lower()
+ )
+ )
+
+ def _show_no_passwords(self) -> None:
+ self.handler.slot_ready(
+ Button(
+ style_classes="app-slot password-no-passwords",
+ child=Box(
+ orientation="h",
+ spacing=12,
+ children=[
+ Label(
+ label="No passwords found",
+ style_classes="password-no-passwords-text",
+ h_align="center",
+ ),
+ ],
+ ),
+ tooltip_text="Use 'add name username password' to add passwords",
+ ),
+ "passwords",
+ )
+ self.handler.done()
+
+ def _show_no_results(self) -> None:
+ self.handler.slot_ready(
+ Button(
+ style_classes="app-slot password-no-results",
+ child=Box(
+ orientation="h",
+ spacing=12,
+ children=[
+ Label(
+ label="No passwords match your search",
+ style_classes="password-no-results-text",
+ h_align="center",
+ ),
+ ],
+ ),
+ ),
+ "passwords",
+ )
+ self.handler.done()
+
+ def _bake_next_password_slot(self, iterator: Iterator[tuple[str, dict]]) -> bool:
+ try:
+ password_name, password_data = next(iterator)
+ except StopIteration:
+ idle_add(self.handler.done)
+ return False
+
+ self._create_password_slot(password_name, password_data)
+ return True
+
+ def _create_password_slot(self, password_name: str, password_data: dict) -> None:
+ username = password_data.get("username", "")
+ name_label = Label(
+ label=password_name,
+ style_classes="password-name",
+ h_align="start",
+ max_chars_width=25,
+ ellipsization="end",
+ )
+
+ username_label = Label(
+ label=username if username else "No username",
+ style_classes="password-username",
+ h_align="start",
+ max_chars_width=30,
+ ellipsization="end",
+ )
+
+ children = [name_label, username_label]
+
+ slot_widget = Button(
+ style_classes="app-slot password-slot",
+ child=Box(
+ orientation="v",
+ spacing=4,
+ children=children,
+ ),
+ tooltip_text="Click to copy password",
+ on_clicked=lambda *_: self._copy_password(password_name),
+ )
+
+ slot_widget.connect(
+ "key-press-event",
+ lambda widget, event, pwd_name=password_name: self._on_slot_key_press(
+ widget, event, pwd_name
+ ),
+ )
+
+ slot_info = {
+ "widget": slot_widget,
+ "name_label": name_label,
+ "username_label": username_label,
+ "password_name": password_name,
+ "password_data": password_data,
+ }
+ self.current_slots.append(slot_info)
+ self.handler.slot_ready(slot_widget, "passwords")
+
+ def _copy_password(self, password_name: str) -> None:
+ pm = self._get_password_manager()
+ if not pm:
+ self._show_master_password_required()
+ return
+
+ result = pm.get_password(password_name, decrypt=True)
+ if not result["success"]:
+ self._show_error_message(f"Failed to get password: {result['error']}")
+ return
+
+ password = result["data"]["password"]
+ copy_text(password)
+ self.handler.launched()
+ GLib.timeout_add(5, lambda: (trigger_paste_shortcut(0), False)[1])
+
+ def _on_slot_key_press(self, widget, event, password_name: str) -> bool:
+ if not event:
+ return False
+ return (
+ (event.keyval == Gdk.KEY_Return or event.keyval == Gdk.KEY_KP_Enter)
+ and event.state & Gdk.ModifierType.SHIFT_MASK
+ and (self._handle_remove_command(f"remove {password_name};") or True)
+ )
+
+ def _prompt_master_password(self) -> None:
+ self._awaiting_master_password = True
+ self.handler.start("passwords")
+ if hasattr(self.handler, "clear_viewport"):
+ self.handler.clear_viewport()
+ if hasattr(self.handler, "clear_input"):
+ self.handler.clear_input()
+
+ self.handler.slot_ready(
+ Button(
+ style_classes="app-slot password-help",
+ child=Box(
+ orientation="v",
+ spacing=8,
+ children=[
+ Label(
+ label="Enter master password",
+ style_classes="password-help-text",
+ h_align="center",
+ ),
+ Label(
+ label="Enter=Submit • Empty=Cancel",
+ style_classes="password-help-subtext",
+ h_align="center",
+ ),
+ ],
+ ),
+ ),
+ "passwords",
+ )
+ self.handler.done()
+
+ def _prompt_master_password_for_setup(self) -> None:
+ self._awaiting_master_password = True
+ self.handler.start("passwords")
+ if hasattr(self.handler, "clear_viewport"):
+ self.handler.clear_viewport()
+ if hasattr(self.handler, "clear_input"):
+ self.handler.clear_input()
+
+ self.handler.slot_ready(
+ Button(
+ style_classes="app-slot password-help",
+ child=Box(
+ orientation="v",
+ spacing=8,
+ children=[
+ Label(
+ label="Set master password",
+ style_classes="password-help-text",
+ h_align="center",
+ ),
+ Label(
+ label="Enter=Submit • Empty=Cancel",
+ style_classes="password-help-subtext",
+ h_align="center",
+ ),
+ ],
+ ),
+ ),
+ "passwords",
+ )
+ self.handler.done()
+
+ def submit_password(self, password: str) -> None:
+ if not self._awaiting_master_password:
+ return
+ if not password:
+ self.cancel_password()
+ return
+
+ # Check if this is setup (no existing master password) or unlock (existing master password)
+ if not self._check_master_password_exists():
+ # Setup mode: initialize new master password
+ if not self._initialize_password_manager(password):
+ self._show_error_message("Failed to initialize password manager!")
+ return
+ else:
+ # Unlock mode: verify existing master password
+ if not self._verify_master_password(password):
+ self._show_error_message("Invalid master password!")
+ return
+ if not self._initialize_password_manager(password):
+ self._show_error_message("Failed to initialize password manager!")
+ return
+
+ self._awaiting_master_password = False
+ if hasattr(self.handler, "clear_viewport"):
+ self.handler.clear_viewport()
+ self._refresh_password_list()
+
+ def cancel_password(self) -> None:
+ if not self._awaiting_master_password:
+ return
+ self._awaiting_master_password = False
+ if hasattr(self.handler, "clear_viewport"):
+ self.handler.clear_viewport()
+ self._show_master_password_required()
+
+ def reset_prompt_state(self) -> None:
+ self._awaiting_master_password = False
+
+ def _handle_lock_command(self, text: str) -> None:
+ self.password_manager = None
+ self.master_password_set = False
+ self.current_slots.clear()
+
+ if hasattr(self.handler, "clear_viewport"):
+ self.handler.clear_viewport()
+ if hasattr(self.handler, "clear_input"):
+ self.handler.clear_input()
+ self.handler.launched()
+
+ def _handle_add_command(self, text: str) -> None:
+ if not text.strip().endswith(";"):
+ self._show_error_message(
+ "Add command must end with ';'\n\nUsage: add name username password;"
+ )
+ return
+
+ password_text = text.strip()[:-1] # Remove semicolon
+ parts = password_text.split(
+ None, 3
+ ) # Split into max 4 parts: ["add", "name", "username", "password with spaces"]
+ if len(parts) < 4:
+ self._show_command_help("add")
+ return
+
+ name = parts[1]
+ username = parts[2]
+ password = parts[
+ 3
+ ] # This will contain the entire password, even if it has spaces
+
+ pm = self._get_password_manager()
+ if not pm:
+ self._show_master_password_required()
+ return
+
+ result = pm.add_password(name, username, password)
+ if result["success"]:
+ if hasattr(self.handler, "clear_input"):
+ self.handler.clear_input()
+ self._refresh_password_list()
+ else:
+ self._show_error_message(f"Error adding password: {result['error']}")
+
+ def _handle_remove_command(self, text: str) -> None:
+ if text.strip() == "remove":
+ self._load_and_display_passwords("")
+ return
+
+ if not text.strip().endswith(";"):
+ self._show_error_message(
+ "Remove command must end with ';'\n\nUsage: remove name;"
+ )
+ return
+
+ password_text = text.strip()[:-1]
+ parts = password_text.split()
+ if len(parts) < 2:
+ self._show_command_help("remove")
+ return
+
+ password_name = parts[1]
+ pm = self._get_password_manager()
+ if not pm:
+ self._show_master_password_required()
+ return
+
+ result = pm.delete_password(password_name)
+ if not result["success"]:
+ self._show_error_message(f"Error removing password: {result['error']}")
+ return
+
+ if hasattr(self.handler, "clear_input"):
+ self.handler.clear_input()
+ self._refresh_password_list()
+
+ def _handle_update_command(self, text: str) -> None:
+ if text.strip() == "update":
+ self._load_and_display_passwords("")
+ return
+
+ if not text.strip().endswith(";"):
+ self._show_error_message(
+ "Update command must end with ';'\n\nUsage: update name field new_value;"
+ )
+ return
+
+ password_text = text.strip()[:-1]
+ parts = password_text.split()
+ if len(parts) < 4:
+ self._show_command_help("update")
+ return
+
+ password_name = parts[1]
+ field = parts[2]
+
+ if field not in ["username", "password"]:
+ self._show_error_message("Invalid field. Use: username or password")
+ return
+
+ new_value = " ".join(parts[3:])
+ pm = self._get_password_manager()
+ if not pm:
+ self._show_master_password_required()
+ return
+
+ result = pm.update_password(password_name, **{field: new_value})
+ if not result["success"]:
+ self._show_error_message(f"Error updating password: {result['error']}")
+ return
+
+ if hasattr(self.handler, "clear_input"):
+ self.handler.clear_input()
+ self._refresh_password_list()
+
+ def _show_master_password_required(self) -> None:
+ self.handler.slot_ready(
+ Button(
+ style_classes="app-slot password-master-required",
+ child=Box(
+ orientation="h",
+ spacing=12,
+ children=[
+ Label(
+ label="Master password required!\n\nYou'll be prompted to set a master password on first use, or to unlock if already set.",
+ style_classes="password-master-required-text",
+ h_align="center",
+ ),
+ ],
+ ),
+ tooltip_text="Set a master password to encrypt your passwords",
+ ),
+ "passwords",
+ )
+ self.handler.done()
+
+ def _show_command_help(self, command: str = None) -> None:
+ help_texts = {
+ "add": "Usage: add name username password;\nExample: add gmail user@gmail.com mypass;\nNote: Must end with semicolon (;)",
+ "remove": "Usage: remove name;\nExample: remove gmail;\nNote: Must end with semicolon (;)",
+ "update": "Usage: update name field new_value;\nFields: username, password\nExample: update gmail password newpass123;\nNote: Must end with semicolon (;)",
+ "lock": "Usage: lock\nExample: lock\n\nThis locks your password vault for security.\nYou'll need to enter the master password in the prompt when requested.",
+ }
+
+ help_text = help_texts.get(
+ command,
+ """Available commands:
+• lock (LOCK VAULT FOR SECURITY!)
+• add name username password;
+• remove name;
+• update name field new_value;
+• help""",
+ )
+
+ self.handler.slot_ready(
+ Button(
+ style_classes="app-slot password-help",
+ child=Box(
+ orientation="h",
+ spacing=12,
+ children=[
+ Label(
+ label=help_text,
+ style_classes="password-help-text",
+ h_align="center",
+ ),
+ ],
+ ),
+ ),
+ "passwords",
+ )
+ self.handler.done()
+
+ def _show_error_message(self, message: str) -> None:
+ self.handler.slot_ready(
+ Button(
+ style_classes="app-slot password-error",
+ child=Box(
+ orientation="h",
+ spacing=12,
+ children=[
+ Label(
+ label=message,
+ style_classes="password-error-text",
+ h_align="center",
+ ),
+ ],
+ ),
+ on_clicked=lambda *_: self.handler.launched(),
+ ),
+ "passwords",
+ )
+ self.handler.done()
+
+ def _refresh_password_list(self) -> None:
+ self.current_slots.clear()
+ if hasattr(self.handler, "clear_viewport"):
+ self.handler.clear_viewport()
+ idle_add(self._show_password_list)
+
+ def _show_password_list(self) -> None:
+ self.handler.start("passwords")
+ self._load_and_display_passwords("")
+
+ def handle_external(self, command: str, args: str) -> None:
+ # Map external commands if needed
+ pass
diff --git a/mine/modules/launcher/plugins/power_menu.py b/mine/modules/launcher/plugins/power_menu.py
new file mode 100644
index 0000000..44dd62c
--- /dev/null
+++ b/mine/modules/launcher/plugins/power_menu.py
@@ -0,0 +1,179 @@
+import threading
+
+from fabric.utils import (
+ Gdk,
+ exec_shell_command_async,
+ idle_add,
+ remove_handler,
+ time,
+)
+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 modules.launcher.base import LauncherPlugin
+
+
+class PowerMenuPlugin(LauncherPlugin):
+ @property
+ def name(self) -> str:
+ return "power_menu"
+
+ @property
+ def icon(self) -> str:
+ return "system-shutdown-symbolic"
+
+ @property
+ def keywords(self) -> list[str]:
+ return ["powermenu", "pm"]
+
+ def __init__(self, handler):
+ super().__init__(handler)
+ self._query_handler: int = 0
+
+ self.power_options = [
+ {
+ "name": "Shutdown",
+ "icon": "system-shutdown",
+ "command": ["shutdown", "now"],
+ "description": "Shutdown the system immediately",
+ },
+ {
+ "name": "Restart",
+ "icon": "system-reboot",
+ "command": ["shutdown", "-r", "now"],
+ "description": "Restart the system immediately",
+ },
+ {
+ "name": "Suspend",
+ "icon": "system-suspend",
+ "command": ["systemctl", "suspend"],
+ "description": "Suspend the system to RAM",
+ },
+ {
+ "name": "Hibernate",
+ "icon": "system-hibernate",
+ "command": ["systemctl", "hibernate"],
+ "description": "Hibernate the system to disk",
+ },
+ {
+ "name": "Logout",
+ "icon": "system-log-out",
+ "command": ["pkill", "-SIGTERM", "Hyprland"],
+ "description": "Logout from the current session",
+ },
+ {
+ "name": "Lock Screen",
+ "icon": "system-lock-screen",
+ "command": ["hyprlock"],
+ "description": "Lock the screen",
+ },
+ ]
+
+ def on_search(self, text: str) -> None:
+ self.query_power_menu(text)
+
+ def stop(self):
+ if self._query_handler:
+ remove_handler(self._query_handler)
+ self._query_handler = 0
+
+ def query_power_menu(self, text: str) -> None:
+ self.handler.start("power-menu")
+
+ filtered_options = self._filter_options(text, self.power_options)
+ if not filtered_options:
+ return
+
+ self._query_handler = idle_add(
+ self._bake_next_option_slot, iter(filtered_options), pin=True
+ )
+
+ def _filter_options(self, text: str, options) -> list:
+ return (
+ options
+ if not text.strip()
+ else [
+ option
+ for option in options
+ if text.lower() in option["name"].lower()
+ or text.lower() in option["description"].lower()
+ ]
+ )
+
+ def _bake_next_option_slot(self, iterator) -> bool:
+ option = next(iterator, None)
+ if option is None:
+ idle_add(self.handler.done)
+ return False
+ self._create_option_slot(option)
+ return True
+
+ def _create_option_slot(self, option) -> None:
+ slot_content = Box(
+ orientation="h",
+ spacing=12,
+ children=[
+ Image(icon_name=option["icon"], h_align="start", size=32),
+ Box(
+ orientation="v",
+ children=[
+ Label(
+ label=option["name"],
+ style_classes="power-menu-name",
+ h_align="start",
+ v_align="start",
+ ),
+ Label(
+ label=option["description"],
+ style_classes="power-menu-description",
+ h_align="start",
+ v_align="start",
+ ),
+ ],
+ ),
+ ],
+ )
+
+ slot_widget = Button(
+ style_classes="app-slot power-menu-slot",
+ child=slot_content,
+ tooltip_text=f"Click to {option['name'].lower()} the system",
+ on_clicked=lambda *_: (
+ self.handler.launched(),
+ self._execute_power_command(option),
+ ),
+ )
+
+ slot_widget.connect(
+ "key-press-event",
+ lambda widget, event, opt=option: self._on_slot_key_press(
+ widget, event, opt
+ ),
+ )
+
+ self.handler.slot_ready(slot_widget, "power-menu")
+
+ def _execute_power_command(self, option) -> None:
+ def delayed_execute():
+ time.sleep(0.2)
+ exec_shell_command_async(" ".join(option["command"]))
+
+ threading.Thread(target=delayed_execute, daemon=True).start()
+
+ def _on_slot_key_press(self, widget, event, option) -> bool:
+ if not event or event.keyval not in (Gdk.KEY_Return, Gdk.KEY_KP_Enter):
+ return False
+ self.handler.launched()
+ self._execute_power_command(option)
+ return True
+
+ def handle_external(self, command: str, args: str) -> None:
+ if not args:
+ return
+
+ for option in self.power_options:
+ if args.lower() == option["name"].lower():
+ self._execute_power_command(option)
+ return
diff --git a/mine/modules/launcher/plugins/power_profile.py b/mine/modules/launcher/plugins/power_profile.py
new file mode 100644
index 0000000..46acb0b
--- /dev/null
+++ b/mine/modules/launcher/plugins/power_profile.py
@@ -0,0 +1,176 @@
+from fabric.power_profiles import PowerProfiles
+from fabric.utils import Gdk, idle_add, remove_handler
+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 modules.launcher.base import LauncherPlugin
+
+
+class PowerProfilePlugin(LauncherPlugin):
+ @property
+ def name(self) -> str:
+ return "power_profile"
+
+ @property
+ def icon(self) -> str:
+ return "power-profile-balanced" # Default icon
+
+ @property
+ def keywords(self) -> list[str]:
+ return ["power", "pp"]
+
+ def __init__(self, handler):
+ super().__init__(handler)
+ self._query_handler: int = 0
+ self.power_profiles = PowerProfiles()
+ self.power_profiles.connect("changed", self._on_power_profile_changed)
+
+ self.current_slots = []
+
+ def on_search(self, text: str) -> None:
+ self.query_power_profiles(text)
+
+ def stop(self):
+ if self._query_handler:
+ remove_handler(self._query_handler)
+ self._query_handler = 0
+
+ def _on_power_profile_changed(self, *args):
+ self._update_slot_statuses()
+
+ def _update_slot_statuses(self) -> None:
+ """Update the status indicators of all current slots"""
+ for slot_info in self.current_slots:
+ profile_name = slot_info["profile_name"]
+ is_active = profile_name == self.power_profiles.active_profile
+ display_name = profile_name.replace("-", " ").title()
+ profile_text = f"{display_name} • Active" if is_active else display_name
+
+ slot_info["label"].set_text(profile_text)
+ if is_active:
+ slot_info["label"].add_style_class("power-profile-active")
+ else:
+ slot_info["label"].remove_style_class("power-profile-active")
+
+ def query_power_profiles(self, text: str) -> None:
+ self.handler.start("power-profiles")
+ self.current_slots = []
+ self._current_search_text = text
+
+ profiles = self.power_profiles.profiles
+ if not profiles:
+ self._show_no_profiles()
+ return
+
+ filtered_profiles = self._filter_profiles(text, profiles)
+ if not filtered_profiles:
+ return
+
+ self._query_handler = idle_add(
+ self._bake_next_profile_slot, iter(filtered_profiles), pin=True
+ )
+
+ def _filter_profiles(self, text: str, profiles) -> list:
+ return (
+ profiles
+ if not text.strip()
+ else [
+ profile for profile in profiles if text.lower() in str(profile).lower()
+ ]
+ )
+
+ def _show_no_profiles(self) -> None:
+ self.handler.slot_ready(
+ Button(
+ style_classes="app-slot power-profile-no-profiles",
+ child=Box(
+ orientation="h",
+ spacing=12,
+ children=[
+ Image(icon_name="power-profile-balanced", size=32),
+ Label(
+ label="No power profiles available",
+ style_classes="power-profile-no-profiles-text",
+ h_align="center",
+ ),
+ ],
+ ),
+ tooltip_text="No power profiles are available on this system",
+ ),
+ "power-profiles",
+ )
+ self.handler.done()
+
+ def _bake_next_profile_slot(self, iterator) -> bool:
+ profile = next(iterator, None)
+ if profile is None:
+ idle_add(self.handler.done)
+ return False
+ self._create_profile_slot(profile)
+ return True
+
+ def _create_profile_slot(self, profile) -> None:
+ profile_name = (
+ profile.get("Profile", str(profile))
+ if isinstance(profile, dict)
+ else str(profile)
+ )
+
+ is_active = profile_name == self.power_profiles.active_profile
+ icon_name = f"power-profile-{profile_name}"
+ display_name = profile_name.replace("-", " ").title()
+ profile_text = f"{display_name} • Active" if is_active else display_name
+
+ label_widget = Label(
+ label=profile_text,
+ style_classes=f"power-profile-name {'power-profile-active' if is_active else ''}",
+ h_align="start",
+ v_align="center",
+ )
+
+ slot_content = Box(
+ orientation="h",
+ spacing=12,
+ children=[
+ Image(icon_name=icon_name, h_align="start", size=32),
+ label_widget,
+ ],
+ )
+
+ slot_widget = Button(
+ style_classes="app-slot power-profile-slot",
+ child=slot_content,
+ tooltip_text=f"Click to set power profile to {display_name}\nCurrent: {self.power_profiles.active_profile}",
+ on_clicked=lambda *_: self._set_power_profile(profile_name),
+ )
+
+ slot_widget.connect(
+ "key-press-event",
+ lambda widget, event, p=profile_name: self._on_slot_key_press(
+ widget, event, p
+ ),
+ )
+
+ self.current_slots.append(
+ {
+ "profile_name": profile_name,
+ "label": label_widget,
+ }
+ )
+ self.handler.slot_ready(slot_widget, "power-profiles")
+
+ def _set_power_profile(self, profile: str) -> None:
+ if profile != self.power_profiles.active_profile:
+ self.power_profiles.active_profile = profile
+
+ def _on_slot_key_press(self, widget, event, profile: str) -> bool:
+ if not event or event.keyval not in (Gdk.KEY_Return, Gdk.KEY_KP_Enter):
+ return False
+ self._set_power_profile(profile)
+ return True
+
+ def handle_external(self, command: str, args: str) -> None:
+ if args:
+ self._set_power_profile(args)
diff --git a/mine/modules/launcher/plugins/query.py b/mine/modules/launcher/plugins/query.py
new file mode 100644
index 0000000..79c18dd
--- /dev/null
+++ b/mine/modules/launcher/plugins/query.py
@@ -0,0 +1,73 @@
+import urllib.parse
+
+from fabric.utils import exec_shell_command_async
+
+from modules.launcher.base import LauncherPlugin
+
+
+class QueryPlugin(LauncherPlugin):
+ @property
+ def name(self) -> str:
+ return "query"
+
+ @property
+ def icon(self) -> str:
+ # Dynamic icon based on active keyword would be better, but for now return a default
+ # The main launcher handles icon updates for now based on keyword match
+ return "system-search-symbolic"
+
+ @property
+ def keywords(self) -> list[str]:
+ return ["gg", "yt", "lk"]
+
+ @property
+ def full_viewport_clear(self) -> bool:
+ return True
+
+ def stop(self):
+ # No cleanup needed for query plugin
+ pass
+
+ def on_search(self, text: str) -> None:
+ # The actual execution happens on Enter (on_entry_accept in main.py)
+ # But we can also implement live search results here if we wanted to.
+ # For now, we just acknowledge the search to keep the UI responsive
+ pass
+
+ def query_google(self, text: str) -> None:
+ pass
+
+ def query_link(self, link: str) -> None:
+ pass
+
+ def query_youtube(self, text: str) -> None:
+ pass
+
+ def on_submit(self, text: str) -> None:
+ if text.startswith("gg "):
+ self.handle_google_search(text[2:].strip())
+ elif text.startswith("yt "):
+ self.handle_youtube_search(text[3:].strip())
+ elif text.startswith("lk "):
+ self.handle_link_open(text.split(" ", 1)[1].strip())
+
+ def handle_google_search(self, query: str) -> None:
+ """Handle Google search - opens browser immediately"""
+ self.handler.launched()
+ return exec_shell_command_async( # pyright: ignore[reportReturnType]
+ f"xdg-open https://www.google.com/search?q={urllib.parse.quote(query)}"
+ )
+
+ def handle_link_open(self, link: str) -> None:
+ """Handle link opening - opens browser immediately"""
+ self.handler.launched()
+ return exec_shell_command_async( # pyright: ignore[reportReturnType]
+ f"xdg-open {('https://' + link) if not link.startswith('http') else link}"
+ )
+
+ def handle_youtube_search(self, query: str) -> None:
+ """Handle YouTube search - opens browser immediately"""
+ self.handler.launched()
+ return exec_shell_command_async( # pyright: ignore[reportReturnType]
+ f"xdg-open https://www.youtube.com/results?search_query={urllib.parse.quote(query)}"
+ )
diff --git a/mine/modules/launcher/plugins/reminder.py b/mine/modules/launcher/plugins/reminder.py
new file mode 100644
index 0000000..fe43244
--- /dev/null
+++ b/mine/modules/launcher/plugins/reminder.py
@@ -0,0 +1,303 @@
+from __future__ import annotations
+
+from collections.abc import Iterable
+from dataclasses import dataclass
+from datetime import datetime, timedelta
+
+from fabric.utils import (
+ Gdk,
+ GLib,
+ exec_shell_command_async,
+ idle_add,
+ re,
+ remove_handler,
+)
+from fabric.widgets.box import Box
+from fabric.widgets.button import Button
+from fabric.widgets.image import Image
+from fabric.widgets.label import Label
+
+import config.data as data
+from modules.launcher.base import LauncherPlugin
+
+
+@dataclass
+class ReminderOption:
+ name: str
+ arg: str
+ icon: str = "alarm-symbolic"
+
+
+class ReminderPlugin(LauncherPlugin):
+ @property
+ def name(self) -> str:
+ return "reminder"
+
+ @property
+ def icon(self) -> str:
+ return "alarm-symbolic"
+
+ @property
+ def keywords(self) -> list[str]:
+ return ["remind", "alarm"]
+
+ def __init__(self, handler):
+ super().__init__(handler)
+ self._query_handler: int = 0
+ self._active_timeouts: list[int] = []
+
+ self.presets: list[ReminderOption] = [
+ ReminderOption(name="In 5 minutes", arg="5m"),
+ ReminderOption(name="In 10 minutes", arg="10m"),
+ ReminderOption(name="In 30 minutes", arg="30m"),
+ ReminderOption(name="In 1 hour", arg="1h"),
+ ]
+
+ def on_search(self, text: str) -> None:
+ self.query_reminder(text)
+
+ def stop(self):
+ self._query_handler and remove_handler(self._query_handler)
+ self._query_handler = 0
+
+ for source_id in self._active_timeouts:
+ GLib.source_remove(source_id)
+ self._active_timeouts.clear()
+
+ def handle_external(self, command: str, args: str) -> None:
+ # This plugin can handle direct commands if needed, but for now we rely on query
+ pass
+
+ def handle_direct(self, text: str) -> bool:
+ # This was in the original code, possibly used by main.py directly?
+ # In the new architecture, we might want to expose this differently or just use on_search.
+ # But if main.py calls this directly for "remind 5m", we can keep it or adapt.
+ # For now, let's keep the logic available.
+ q = text.strip()
+ if not q:
+ return False
+ ms, body = self._parse_input_to_ms_and_body(q)
+ if ms is None:
+ self._notify("⏰ Reminder", f"Invalid time: {q}")
+ return True
+ self._schedule_ms(ms, body)
+ return True
+
+ def query_reminder(self, text: str) -> None:
+ self.handler.start("reminder")
+
+ text_stripped = text.strip()
+ options = [o.__dict__ for o in self.presets]
+
+ if text_stripped and text_stripped not in {o["arg"] for o in options}:
+ options.insert(
+ 0,
+ {
+ "name": f"Custom: {text_stripped}",
+ "arg": text_stripped,
+ "icon": "document-send-symbolic",
+ },
+ )
+
+ filtered_options = self._filter_options(text, options)
+ if not filtered_options:
+ return
+
+ self._query_handler = idle_add(
+ self._bake_next_option_slot, iter(filtered_options), pin=True
+ )
+
+ def _filter_options(self, text: str, options: list[dict]) -> list[dict]:
+ return (
+ options
+ if not text.strip()
+ else [
+ option
+ for option in options
+ if text.lower() in option["name"].lower()
+ or text.lower() in option["arg"].lower()
+ ]
+ )
+
+ def _bake_next_option_slot(self, iterator: Iterable[dict]) -> bool:
+ option = next(iterator, None)
+ if option is None:
+ idle_add(self.handler.done)
+ return False
+ self._create_option_slot(option)
+ return True
+
+ def _create_option_slot(self, option: dict) -> None:
+ slot_content = Box(
+ orientation="h",
+ spacing=12,
+ children=[
+ Image(icon_name=option["icon"], h_align="start", size=32),
+ Box(
+ orientation="v",
+ children=[
+ Label(
+ label=f"Reminder: {option['name']}",
+ style_classes="reminder-name",
+ h_align="start",
+ v_align="start",
+ ),
+ Label(
+ label=f"Schedule reminder '{option['arg']}'",
+ style_classes="reminder-description",
+ h_align="start",
+ v_align="start",
+ ),
+ ],
+ ),
+ ],
+ )
+
+ slot_widget = Button(
+ style_classes="app-slot reminder-slot",
+ child=slot_content,
+ tooltip_text=f"Click to schedule reminder: {option['arg']}",
+ on_clicked=lambda *_: (
+ self.handler.launched(),
+ self._schedule_reminder(option["arg"], body=None),
+ ),
+ )
+
+ slot_widget.connect(
+ "key-press-event",
+ lambda widget, event, opt=option: self._on_slot_key_press(
+ widget, event, opt
+ ),
+ )
+
+ self.handler.slot_ready(slot_widget, "reminder")
+
+ def _on_slot_key_press(self, widget, event, option) -> bool:
+ if not event or event.keyval not in (Gdk.KEY_Return, Gdk.KEY_KP_Enter):
+ return False
+ self.handler.launched()
+ self._schedule_reminder(option["arg"], body=None)
+ return True
+
+ def _schedule_reminder(self, arg: str, body: str | None) -> None:
+ ms = self._parse_delay_to_ms(arg.lower().strip())
+ if ms is None:
+ self._notify("⏰ Reminder", f"Invalid time: {arg}")
+ return
+ self._schedule_ms(ms, body)
+
+ def _schedule_ms(self, milliseconds: int, body: str | None) -> None:
+ when = datetime.now() + timedelta(milliseconds=milliseconds)
+ display_when = when.replace(second=0, microsecond=0)
+ if when.second != 0 or when.microsecond != 0:
+ display_when += timedelta(minutes=1)
+ self._notify("⏰ Reminder", f"Scheduled for {display_when.strftime('%H:%M')}")
+
+ def fire() -> bool:
+ self._notify("⏰ Reminder", body or "Time's up!")
+ return False
+
+ source_id = GLib.timeout_add(milliseconds, fire)
+ self._active_timeouts.append(source_id)
+
+ def _parse_delay_to_ms(self, duration: str) -> int | None:
+ if (
+ len(duration) == 5
+ and duration[2] == ":"
+ and duration[:2].isdigit()
+ and duration[3:].isdigit()
+ ):
+ hh = int(duration[:2])
+ mm = int(duration[3:])
+ now = datetime.now()
+ fire_at = now.replace(hour=hh, minute=mm, second=0, microsecond=0)
+ if fire_at <= now:
+ fire_at += timedelta(days=1)
+ return int((fire_at - now).total_seconds() * 1000)
+
+ norm = (
+ duration.replace("minutes", "m")
+ .replace("minute", "m")
+ .replace("mins", "m")
+ .replace("min", "m")
+ .replace("hours", "h")
+ .replace("hour", "h")
+ .replace("hrs", "h")
+ .replace("hr", "h")
+ .replace("seconds", "s")
+ .replace("second", "s")
+ .replace("secs", "s")
+ .replace("sec", "s")
+ .replace("days", "d")
+ .replace("day", "d")
+ )
+ norm = "".join(norm.split()) # remove spaces
+
+ if norm.isdigit():
+ return int(norm) * 60_000
+
+ matches = list(re.finditer(r"(\d+)([smhd])", norm))
+ if matches and matches[0].start() == 0:
+ total_ms = 0
+ for m in matches:
+ val = int(m.group(1))
+ unit = m.group(2)
+ if unit == "s":
+ total_ms += val * 1000
+ elif unit == "m":
+ total_ms += val * 60_000
+ elif unit == "h":
+ total_ms += val * 60 * 60_000
+ elif unit == "d":
+ total_ms += val * 24 * 60 * 60_000
+ return total_ms if total_ms > 0 else None
+
+ return None
+
+ def _parse_input_to_ms_and_body(self, text: str) -> tuple[int | None, str | None]:
+ lower = text.strip().lower()
+ if lower.startswith("in "):
+ lower = lower[3:].strip()
+
+ m_abs = re.match(r"^(\d{1,2}:\d{2})(?:\s+(.+))?$", lower)
+ if m_abs:
+ ms = self._parse_delay_to_ms(m_abs.group(1))
+ body = (m_abs.group(2) or "").strip() or None
+ return ms, body
+
+ norm = (
+ lower.replace("minutes", "m")
+ .replace("minute", "m")
+ .replace("mins", "m")
+ .replace("min", "m")
+ .replace("hours", "h")
+ .replace("hour", "h")
+ .replace("hrs", "h")
+ .replace("hr", "h")
+ .replace("seconds", "s")
+ .replace("second", "s")
+ .replace("secs", "s")
+ .replace("sec", "s")
+ .replace("days", "d")
+ .replace("day", "d")
+ )
+
+ m_dur = re.match(r"^((?:\d+\s*[smhd]\s*)+|\d+)\s*(.*)$", norm)
+ if not m_dur:
+ return None, None
+ dur_part = "".join(m_dur.group(1).split())
+ body = (m_dur.group(2) or "").strip() or None
+ ms = self._parse_delay_to_ms(dur_part)
+ return ms, body
+
+ def _notify(self, title: str, body: str) -> None:
+ exec_shell_command_async(
+ [
+ "notify-send",
+ title,
+ body,
+ "-a",
+ data.APP_NAME,
+ "-e",
+ ]
+ )
diff --git a/mine/modules/launcher/plugins/screencapture.py b/mine/modules/launcher/plugins/screencapture.py
new file mode 100644
index 0000000..d45d006
--- /dev/null
+++ b/mine/modules/launcher/plugins/screencapture.py
@@ -0,0 +1,120 @@
+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 modules.launcher.base import LauncherPlugin
+from services import screen_capture_service
+
+
+class ScreenCapturePlugin(LauncherPlugin):
+ @property
+ def name(self) -> str:
+ return "screen_capture"
+
+ @property
+ def icon(self) -> str:
+ return "camera-photo-symbolic"
+
+ @property
+ def keywords(self) -> list[str]:
+ return ["screen", "sc"]
+
+ def __init__(self, handler):
+ super().__init__(handler)
+ self.service = screen_capture_service
+
+ def on_search(self, text: str) -> None:
+ self.query_screen_capture(text)
+
+ def stop(self):
+ pass
+
+ def is_recording(self):
+ return self.service.is_recording
+
+ def query_screen_capture(self, text: str) -> None:
+ self._show_main_options()
+
+ def _show_main_options(self):
+ self.handler.start("screen_capture")
+
+ options = [
+ ("Screenshot Selection", "camera-photo-symbolic", "screenshot selection"),
+ ("Screenshot Fullscreen", "camera-photo-symbolic", "screenshot fullscreen"),
+ ("Record Selection", "camera-video-symbolic", "record selection"),
+ ("Record Fullscreen", "camera-video-symbolic", "record fullscreen"),
+ ("Record Selection No Audio", "camera-video-symbolic", "record no audio"),
+ (
+ "Record Fullscreen No Audio",
+ "camera-video-symbolic",
+ "record fullscreen no audio",
+ ),
+ ]
+
+ if self.is_recording():
+ options.insert(
+ 0, ("Stop Recording", "media-playback-stop-symbolic", "stop")
+ )
+
+ for title, icon, command in options:
+ self.handler.slot_ready(
+ Button(
+ style_classes="app-slot",
+ child=Box(
+ orientation="h",
+ spacing=12,
+ children=[
+ Image(icon_name=icon, style_classes="app-icon"),
+ Label(
+ label=title,
+ style_classes="app-name",
+ h_align="start",
+ ),
+ ],
+ ),
+ on_clicked=lambda _, cmd=command: self._execute_command(cmd),
+ ),
+ "screen_capture",
+ )
+
+ self.handler.done()
+
+ def _execute_command(self, command):
+ self.handler.launched()
+
+ actions = {
+ "stop": lambda: self.service.stop_recording(),
+ "screenshot selection": lambda: self.service.screenshot(fullscreen=False),
+ "screenshot fullscreen": lambda: self.service.screenshot(fullscreen=True),
+ "record selection": lambda: self.service.record(
+ fullscreen=False, no_audio=False
+ ),
+ "record fullscreen": lambda: self.service.record(
+ fullscreen=True, no_audio=False
+ ),
+ "record no audio": lambda: self.service.record(
+ fullscreen=False, no_audio=True
+ ),
+ "record fullscreen no audio": lambda: self.service.record(
+ fullscreen=True, no_audio=True
+ ),
+ }
+
+ # Add aliases
+ aliases = {
+ "region": "screenshot selection",
+ "full": "screenshot fullscreen",
+ "select": "screenshot selection",
+ }
+
+ cmd = aliases.get(command, command)
+
+ if cmd in actions:
+ actions[cmd]()
+
+ def handle_external(self, command: str, args: str) -> None:
+ if args:
+ self._execute_command(args)
+ else:
+ self.handler.launched()
diff --git a/mine/modules/launcher/plugins/settings.py b/mine/modules/launcher/plugins/settings.py
new file mode 100644
index 0000000..11363ed
--- /dev/null
+++ b/mine/modules/launcher/plugins/settings.py
@@ -0,0 +1,444 @@
+from fabric.utils import Gtk, idle_add, remove_handler
+from fabric.widgets.box import Box
+from fabric.widgets.button import Button
+from fabric.widgets.entry import Entry
+from fabric.widgets.label import Label
+
+from config import data as config_data
+from modules.launcher.base import LauncherPlugin
+
+
+class SettingsPlugin(LauncherPlugin):
+ @property
+ def name(self) -> str:
+ return "settings"
+
+ @property
+ def icon(self) -> str:
+ return "emblem-system-symbolic"
+
+ @property
+ def keywords(self) -> list[str]:
+ return ["settings", "set"]
+
+ def __init__(self, handler):
+ super().__init__(handler)
+ self._query_handler: int = 0
+ self._settings_items: list[dict] = []
+
+ def on_search(self, text: str) -> None:
+ self.query_settings(text)
+
+ def stop(self):
+ if self._query_handler:
+ remove_handler(self._query_handler)
+ self._query_handler = 0
+
+ def query_settings(self, text: str) -> None:
+ self.handler.start("settings")
+ self._settings_items = self._get_all_settings()
+
+ filtered = (
+ self._settings_items
+ if not text.strip()
+ else [i for i in self._settings_items if text.lower() in i["name"].lower()]
+ )
+
+ if not filtered:
+ return
+
+ self._query_handler = idle_add(self._bake_settings, iter(filtered), pin=True)
+
+ def _get_all_settings(self) -> list[dict]:
+ config = config_data.DATA()
+ systray_hide_icons = config.get("systray_hide_icons", [])
+ # Convert list to comma-separated string for display
+ systray_hide_icons_str = (
+ ", ".join(systray_hide_icons)
+ if isinstance(systray_hide_icons, list)
+ else str(systray_hide_icons)
+ )
+ return [
+ {
+ "name": "OSD",
+ "key": "osd",
+ "type": "switch",
+ "value": config.get("osd", True),
+ },
+ {
+ "name": "OSD Position",
+ "key": "osd_position",
+ "type": "select",
+ "options": ["top", "bottom"],
+ "value": config.get("osd_position", "bottom"),
+ },
+ {
+ "name": "OSD Audio",
+ "key": "osd_audio",
+ "type": "switch",
+ "value": config.get("osd_audio", True),
+ },
+ {
+ "name": "OSD Brightness",
+ "key": "osd_brightness",
+ "type": "switch",
+ "value": config.get("osd_brightness", True),
+ },
+ {
+ "name": "OSD Capslock",
+ "key": "osd_capslock",
+ "type": "switch",
+ "value": config.get("osd_capslock", True),
+ },
+ {
+ "name": "OSD Keyboard Layout",
+ "key": "osd_kb_layout",
+ "type": "switch",
+ "value": config.get("osd_kb_layout", True),
+ },
+ {
+ "name": "OSD Network",
+ "key": "osd_network",
+ "type": "switch",
+ "value": config.get("osd_network", True),
+ },
+ {
+ "name": "OSD Microphone",
+ "key": "osd_microphone",
+ "type": "switch",
+ "value": config.get("osd_microphone", True),
+ },
+ {
+ "name": "OSD Battery",
+ "key": "osd_battery",
+ "type": "switch",
+ "value": config.get("osd_battery", True),
+ },
+ {
+ "name": "Panel",
+ "key": "panel_enabled",
+ "type": "switch",
+ "value": config.get("panel_enabled", True),
+ },
+ {
+ "name": "Panel Position",
+ "key": "panel_position",
+ "type": "select",
+ "options": ["top", "bottom"],
+ "value": config.get("panel_position", "top"),
+ },
+ {
+ "name": "Panel Autohide",
+ "key": "panel_autohide",
+ "type": "switch",
+ "value": config.get("panel_autohide", True),
+ },
+ {
+ "name": "Systray",
+ "key": "systray",
+ "type": "switch",
+ "value": config.get("systray", True),
+ },
+ {
+ "name": "Network",
+ "key": "network",
+ "type": "switch",
+ "value": config.get("network", True),
+ },
+ {
+ "name": "Bluetooth",
+ "key": "bluetooth",
+ "type": "switch",
+ "value": config.get("bluetooth", True),
+ },
+ {
+ "name": "DateTime",
+ "key": "datetime",
+ "type": "switch",
+ "value": config.get("datetime", True),
+ },
+ {
+ "name": "DateTime 12hrs",
+ "key": "datetime_12hrs",
+ "type": "switch",
+ "value": config.get("datetime_12hrs", True),
+ },
+ {
+ "name": "Battery",
+ "key": "battery",
+ "type": "switch",
+ "value": config.get("battery", True),
+ },
+ {
+ "name": "Battery Label",
+ "key": "battery_label",
+ "type": "switch",
+ "value": config.get("battery_label", True),
+ },
+ {
+ "name": "Battery Hide Full",
+ "key": "battery_hide_on_full",
+ "type": "switch",
+ "value": config.get("battery_hide_on_full", True),
+ },
+ {
+ "name": "Workspace",
+ "key": "workspace",
+ "type": "switch",
+ "value": config.get("workspace", True),
+ },
+ {
+ "name": "Workspace Hide Special",
+ "key": "workspace_hide_special",
+ "type": "switch",
+ "value": config.get("workspace_hide_special", True),
+ },
+ {
+ "name": "Taskbar",
+ "key": "taskbar",
+ "type": "switch",
+ "value": config.get("taskbar", True),
+ },
+ {
+ "name": "Taskbar Hide Empty",
+ "key": "taskbar_hide_empty",
+ "type": "switch",
+ "value": config.get("taskbar_hide_empty", True),
+ },
+ {
+ "name": "Notifications",
+ "key": "notifications",
+ "type": "switch",
+ "value": config.get("notifications", True),
+ },
+ {
+ "name": "Desktop Widget",
+ "key": "desktop_widget_enabled",
+ "type": "switch",
+ "value": config.get("desktop_widget_enabled", True),
+ },
+ {
+ "name": "Desktop Widget Time",
+ "key": "desktop_widget_time_format",
+ "type": "select",
+ "options": ["12 hrs", "24 hrs"],
+ "value": config.get("desktop_widget_time_format", "12 hrs"),
+ },
+ {
+ "name": "Corners",
+ "key": "corners",
+ "type": "switch",
+ "value": config.get("corners", True),
+ },
+ {
+ "name": "Debug",
+ "key": "debug",
+ "type": "switch",
+ "value": config.get("debug", True),
+ },
+ {
+ "name": "Wallpaper Path",
+ "key": "wallpaper_dir",
+ "type": "entry",
+ "value": config.get("wallpaper_dir", ""),
+ },
+ {
+ "name": "Systray Hide Items",
+ "key": "systray_hide_icons",
+ "type": "entry",
+ "value": systray_hide_icons_str,
+ },
+ ]
+
+ def _bake_settings(self, iterator) -> bool:
+ item = next(iterator, None)
+ if item is None:
+ self.handler.done()
+ return False
+ self._create_setting_slot(item)
+ return True
+
+ def _create_setting_slot(self, item: dict) -> None:
+ # For all types, put label on left and control on right
+ if item["type"] == "switch":
+ # Left side: label
+ name_label = Label(label=item["name"])
+
+ # Right side: switch
+ switch = Gtk.Switch()
+ switch.set_active(item["value"])
+ switch.connect("state-set", self._on_switch_toggled, item)
+ switch.show()
+
+ content = Box(
+ orientation="h",
+ spacing=12,
+ children=[
+ name_label,
+ switch,
+ ],
+ )
+
+ slot = Button(
+ style_classes="app-slot settings-slot",
+ child=content,
+ on_clicked=lambda *_: switch.set_active(not switch.get_active()),
+ )
+ elif item["type"] == "select":
+ # Left side: label with colon
+ name_label = Label(label=f"{item['name']}: ")
+
+ # Right side: entry for editing (always visible)
+ value_text = item["options"][item["options"].index(item["value"])]
+ entry = Entry(
+ h_expand=True,
+ stop_propagation=False,
+ )
+ entry.set_text(value_text)
+ entry.connect("focus-out-event", self._on_entry_focus_out, item)
+ entry.connect("activate", self._on_entry_activate, item)
+ entry.connect("key-press-event", self._on_entry_key_press, item)
+ # Store reference for updating
+ item["_entry_ref"] = entry
+
+ content = Box(
+ orientation="h",
+ spacing=12,
+ children=[
+ name_label,
+ entry,
+ ],
+ )
+
+ # Create button that focuses the entry when clicked
+ def _focus_entry(button):
+ entry.grab_focus()
+ entry.select_region(0, -1) # Select all text
+
+ slot = Button(
+ style_classes="app-slot settings-slot",
+ child=content,
+ on_clicked=_focus_entry,
+ )
+ else: # entry type (text input)
+ # Left side: label with colon
+ name_label = Label(label=f"{item['name']}: ")
+
+ # Right side: entry for editing (always visible)
+ entry = Entry(
+ h_expand=True,
+ stop_propagation=False,
+ )
+ entry.set_text(item["value"])
+ entry.connect("focus-out-event", self._on_entry_focus_out, item)
+ entry.connect("activate", self._on_entry_activate, item)
+ entry.connect("key-press-event", self._on_entry_key_press, item)
+ # Store reference for updating
+ item["_entry_ref"] = entry
+
+ content = Box(
+ orientation="h",
+ spacing=12,
+ children=[
+ name_label,
+ entry,
+ ],
+ )
+
+ # Create button that focuses the entry when clicked
+ def _focus_entry(button):
+ entry.grab_focus()
+ entry.select_region(0, -1) # Select all text
+
+ slot = Button(
+ style_classes="app-slot settings-slot",
+ child=content,
+ on_clicked=_focus_entry,
+ )
+
+ self.handler.slot_ready(slot, "settings")
+
+ def _on_switch_toggled(self, switch: Gtk.Switch, state, item: dict) -> None:
+ new_value = switch.get_active()
+ item["value"] = new_value
+
+ def do_update():
+ config_data.update_setting(item["key"], new_value)
+ return False
+
+ idle_add(do_update)
+
+ def _on_entry_activate(self, entry, item: dict) -> None:
+ """Handle Enter key press in entry"""
+ new_value = entry.get_text().strip()
+
+ # For select type, validate against options
+ if item["type"] == "select" and new_value not in item["options"]:
+ # Reset to current value if invalid
+ entry.set_text(item["value"])
+ return
+
+ # For systray_hide_icons, convert comma-separated string to list
+ if item["key"] == "systray_hide_icons":
+ # Split by comma and strip whitespace from each item
+ new_value = [item.strip() for item in new_value.split(",") if item.strip()]
+ else:
+ item["value"] = new_value
+
+ item["value"] = new_value
+
+ def do_update():
+ config_data.update_setting(item["key"], new_value)
+ return False
+
+ idle_add(do_update)
+
+ # Remove focus from entry after saving by focusing parent button
+ def unfocus_entry():
+ try:
+ parent_box = entry.get_parent()
+ if parent_box:
+ parent_button = parent_box.get_parent()
+ if parent_button and hasattr(parent_button, "grab_focus"):
+ parent_button.grab_focus()
+ except Exception:
+ pass # Ignore errors in unfocusing
+ return False
+
+ idle_add(unfocus_entry)
+
+ def _on_entry_focus_out(self, entry, event, item: dict) -> None:
+ """Handle focus out event (when user clicks away)"""
+ new_value = entry.get_text().strip()
+
+ # For select type, validate against options
+ if item["type"] == "select" and new_value not in item["options"]:
+ # Reset to current value if invalid
+ entry.set_text(item["value"])
+ return
+
+ # For systray_hide_icons, convert comma-separated string to list
+ if item["key"] == "systray_hide_icons":
+ # Split by comma and strip whitespace from each item
+ new_value = [item.strip() for item in new_value.split(",") if item.strip()]
+ else:
+ item["value"] = new_value
+
+ item["value"] = new_value
+
+ def do_update():
+ config_data.update_setting(item["key"], new_value)
+ return False
+
+ idle_add(do_update)
+
+ def _on_entry_key_press(self, entry, event, item: dict) -> None:
+ """Handle key press events - Escape to cancel"""
+ # Escape key resets to current value
+ if event.keyval == 65307: # GDK_KEY_Escape
+ entry.set_text(item["value"])
+ return True # Prevent further processing
+ return False # Allow other keys to be processed
+
+ def handle_external(self, command: str, args: str) -> None:
+ pass
diff --git a/mine/modules/launcher/plugins/ssh.py b/mine/modules/launcher/plugins/ssh.py
new file mode 100644
index 0000000..f4681df
--- /dev/null
+++ b/mine/modules/launcher/plugins/ssh.py
@@ -0,0 +1,419 @@
+from __future__ import annotations
+
+from collections.abc import Iterator
+from pathlib import Path
+
+from fabric.utils import (
+ Gdk,
+ exec_shell_command_async,
+ idle_add,
+ os,
+ re,
+ remove_handler,
+)
+from fabric.widgets.box import Box
+from fabric.widgets.button import Button
+from fabric.widgets.label import Label
+
+from modules.launcher.base import LauncherPlugin
+from utils.functions import copy_text, terminal_exec_command
+
+
+class SSHPlugin(LauncherPlugin):
+ @property
+ def name(self) -> str:
+ return "ssh"
+
+ @property
+ def icon(self) -> str:
+ return "network-server-symbolic"
+
+ @property
+ def keywords(self) -> list[str]:
+ return ["ssh"]
+
+ def __init__(self, handler):
+ super().__init__(handler)
+ self._query_handler: int = 0
+ self.current_slots: list = []
+ self.terminal = "kitty" # default terminal
+
+ self._hosts_cache: list[dict] = []
+ self._hosts_cache_hash: int | None = None
+
+ def on_search(self, text: str) -> None:
+ self.query_ssh(text)
+
+ def stop(self):
+ if self._query_handler:
+ remove_handler(self._query_handler)
+ self._query_handler = 0
+ self.current_slots.clear()
+
+ def query_ssh(self, text: str) -> None:
+ self.handler.start("ssh")
+ self.current_slots.clear()
+ s = text.strip()
+
+ commands = {
+ "terminal": self._handle_terminal_command,
+ "help": lambda t: self._show_command_help(),
+ }
+
+ for prefix, handler in commands.items():
+ if s.startswith(prefix):
+ handler(text)
+ return
+
+ self._load_and_display_hosts(text)
+
+ def _load_and_display_hosts(self, text: str) -> None:
+ hosts = self._load_hosts()
+ if not hosts:
+ self._show_no_hosts()
+ return
+
+ filtered = self._filter_hosts(text, hosts)
+ # Convert to list to check if empty
+ filtered_list = list(filtered)
+ if not filtered_list:
+ self._show_no_results()
+ return
+
+ self._query_handler = idle_add(
+ self._bake_next_slot, iter(filtered_list), pin=True
+ )
+
+ def _filter_hosts(self, text: str, hosts: list[dict]) -> Iterator[dict]:
+ if not text.strip():
+ return iter(hosts)
+ q = text.lower()
+ return iter(
+ h
+ for h in hosts
+ if q in h["name"].lower()
+ or (h.get("user") and q in h["user"].lower())
+ or (h.get("hostname") and q in h["hostname"].lower())
+ )
+
+ def _bake_next_slot(self, iterator: Iterator[dict]) -> bool:
+ try:
+ host = next(iterator)
+ except StopIteration:
+ idle_add(self.handler.done)
+ return False
+ self._create_slot(host)
+ return True
+
+ def _create_slot(self, host: dict) -> None:
+ display = host["name"]
+ subtitle = " · ".join(
+ list(
+ filter(
+ None,
+ [
+ host.get("user"),
+ host.get("hostname")
+ if host.get("hostname") != display
+ else None,
+ ],
+ )
+ )
+ )
+
+ name_label = Label(
+ label=display,
+ style_classes="ssh-host-name",
+ h_align="start",
+ max_chars_width=35,
+ ellipsization="end",
+ )
+
+ info_label = Label(
+ label=subtitle or "",
+ style_classes="ssh-host-info",
+ h_align="start",
+ max_chars_width=40,
+ ellipsization="end",
+ )
+
+ slot_widget = Button(
+ style_classes="app-slot ssh-slot",
+ child=Box(
+ orientation="v",
+ spacing=4,
+ children=[name_label, info_label],
+ ),
+ on_clicked=lambda *_: self._connect(host),
+ tooltip_text=(
+ f"Enter: connect via {self.terminal}\nShift+Enter: copy ssh command"
+ ),
+ )
+
+ slot_widget.connect(
+ "key-press-event",
+ lambda widget, event, h=host: self._on_slot_key_press(widget, event, h),
+ )
+
+ self.current_slots.append({"widget": slot_widget, "host": host})
+ self.handler.slot_ready(slot_widget, "ssh")
+
+ def _on_slot_key_press(self, widget, event, host: dict) -> bool:
+ if not event:
+ return False
+ return bool(
+ (event.keyval == Gdk.KEY_Return or event.keyval == Gdk.KEY_KP_Enter)
+ and event.state & Gdk.ModifierType.SHIFT_MASK
+ and not self._copy_command(host)
+ )
+
+ def _copy_command(self, host: dict) -> None:
+ cmd = self._build_ssh_command(host)
+ copy_text(cmd)
+ self.handler.launched()
+
+ def _connect(self, host: dict) -> None:
+ cmd = self._build_ssh_command(host)
+ term_cmd = self._terminal_command(cmd)
+ exec_shell_command_async(term_cmd)
+ self.handler.launched()
+
+ def _terminal_command(self, inner_cmd: str) -> str:
+ return terminal_exec_command(self.terminal, inner_cmd)
+
+ def _build_ssh_command(self, host: dict) -> str:
+ user = host.get("user")
+ name = host["name"]
+ return f"ssh {user}@{name}" if user else f"ssh {name}"
+
+ def _show_no_hosts(self) -> None:
+ self.handler.slot_ready(
+ Button(
+ style_classes="app-slot ssh-no-hosts",
+ child=Box(
+ orientation="h",
+ spacing=12,
+ children=[
+ Label(
+ label=(
+ "No SSH hosts found. Add entries to ~/.ssh/config or connect once to populate known_hosts."
+ ),
+ style_classes="ssh-no-hosts-text",
+ h_align="center",
+ ),
+ ],
+ ),
+ ),
+ "ssh",
+ )
+ self.handler.done()
+
+ def _show_error_message(self, message: str) -> None:
+ self.handler.slot_ready(
+ Button(
+ style_classes="app-slot ssh-error",
+ child=Box(
+ orientation="h",
+ spacing=12,
+ children=[
+ Label(
+ label=message,
+ style_classes="ssh-error-text",
+ h_align="center",
+ ),
+ ],
+ ),
+ on_clicked=lambda *_: self.handler.launched(),
+ ),
+ "ssh",
+ )
+ self.handler.done()
+
+ def _show_no_results(self) -> None:
+ self.handler.slot_ready(
+ Button(
+ style_classes="app-slot ssh-no-results",
+ child=Box(
+ orientation="h",
+ spacing=12,
+ children=[
+ Label(
+ label="No SSH hosts match your search",
+ style_classes="ssh-no-results-text",
+ h_align="center",
+ ),
+ ],
+ ),
+ ),
+ "ssh",
+ )
+ self.handler.done()
+
+ def _handle_terminal_command(self, text: str) -> None:
+ parts = text.strip().split()
+ if len(parts) < 2:
+ self._show_command_help("terminal")
+ return
+ self.terminal = parts[1]
+ # show refreshed list after changing terminal
+ self._refresh_host_list()
+
+ def _show_command_help(self, command: str | None = None) -> None:
+ help_texts = {
+ "terminal": "Usage: terminal terminal_name\nExample: terminal kitty",
+ }
+ help_text = help_texts.get(
+ command,
+ """Available commands:
+• terminal terminal_name (Set terminal emulator)
+• help
+
+Enter: connect via terminal
+Shift+Enter: copy ssh command
+""",
+ )
+
+ self.handler.slot_ready(
+ Button(
+ style_classes="app-slot ssh-help",
+ child=Box(
+ orientation="h",
+ spacing=12,
+ children=[
+ Label(
+ label=help_text,
+ style_classes="ssh-help-text",
+ h_align="center",
+ ),
+ ],
+ ),
+ ),
+ "ssh",
+ )
+ self.handler.done()
+
+ def _refresh_host_list(self) -> None:
+ self.current_slots.clear()
+ if hasattr(self.handler, "clear_viewport"):
+ self.handler.clear_viewport()
+ idle_add(self._show_host_list)
+
+ def _show_host_list(self) -> None:
+ self.handler.start("ssh")
+ self._load_and_display_hosts("")
+
+ def _load_hosts(self) -> list[dict]:
+ user_config_path = Path(os.path.expanduser("~/.ssh/config"))
+ system_config_paths = [
+ Path("/etc/ssh/config"),
+ Path("/etc/ssh/ssh_config"),
+ ]
+ known_hosts_path = Path(os.path.expanduser("~/.ssh/known_hosts"))
+
+ cfg_mtime = self._stat_ns(user_config_path)
+ sys_mtime_total = 0
+ for p in system_config_paths:
+ sys_mtime_total ^= self._stat_ns(p)
+ kh_mtime = self._stat_ns(known_hosts_path)
+ fingerprint = cfg_mtime ^ kh_mtime ^ sys_mtime_total
+ if self._hosts_cache_hash == fingerprint and self._hosts_cache:
+ return self._hosts_cache
+
+ hosts_by_name: dict[str, dict] = {}
+
+ for sys_cfg in system_config_paths:
+ self._parse_ssh_config_content(
+ self._read_text(sys_cfg),
+ "system_config",
+ hosts_by_name,
+ overwrite=False,
+ )
+
+ self._parse_ssh_config_content(
+ self._read_text(user_config_path),
+ "user_config",
+ hosts_by_name,
+ overwrite=True,
+ )
+
+ self._parse_known_hosts(self._read_text(known_hosts_path), hosts_by_name)
+ hosts = list(hosts_by_name.values())
+ rank = {"user_config": 0, "system_config": 1}
+ hosts.sort(key=lambda h: (rank.get(h.get("source"), 2), h["name"].lower()))
+
+ self._hosts_cache = hosts
+ self._hosts_cache_hash = fingerprint
+ return hosts
+
+ def _stat_ns(self, path: Path) -> int:
+ return path.stat().st_mtime_ns if path.exists() else 0
+
+ def _read_text(self, path: Path) -> str:
+ return path.read_text() if path.exists() else ""
+
+ def _parse_ssh_config_content(
+ self,
+ content: str,
+ source: str,
+ hosts_by_name: dict[str, dict],
+ overwrite: bool,
+ ) -> None:
+ if not content:
+ return
+ blocks = re.split(r"\n(?=Host\s)", content, flags=re.IGNORECASE)
+ for block in blocks:
+ lines = [
+ ln
+ for ln in block.strip().splitlines()
+ if ln.strip() and not ln.strip().startswith("#")
+ ]
+ if not lines:
+ continue
+ header = lines[0]
+ m = re.match(r"Host\s+(.+)$", header, re.I)
+ if not m:
+ continue
+ names = [n for n in m.group(1).split() if n != "*"]
+ if not names:
+ continue
+ attrs = {}
+ for ln in lines[1:]:
+ parts = ln.split(None, 1)
+ if len(parts) != 2:
+ continue
+ key, val = parts[0].lower(), parts[1].strip()
+ if key in ("user", "hostname"):
+ attrs[key] = val
+ for name in names:
+ if not overwrite and name in hosts_by_name:
+ continue
+ hosts_by_name[name] = {
+ "name": name,
+ "user": attrs.get("user"),
+ "hostname": attrs.get("hostname", name),
+ "source": source,
+ }
+
+ def _parse_known_hosts(self, content: str, hosts_by_name: dict[str, dict]) -> None:
+ if not content:
+ return
+ for line in content.splitlines():
+ line_s = line.strip()
+ if not line_s or line_s.startswith("#"):
+ continue
+ first = line_s.split()[0]
+ if first.startswith("|"):
+ continue # hashed name
+ for hostpart in first.split(","):
+ host_clean = hostpart.strip()
+ if host_clean and host_clean not in hosts_by_name:
+ hosts_by_name[host_clean] = {
+ "name": host_clean,
+ "user": None,
+ "hostname": host_clean,
+ "source": "known_hosts",
+ }
+
+ def handle_external(self, command: str, args: str) -> None:
+ # Map external commands if needed
+ pass
diff --git a/mine/modules/launcher/plugins/tmux.py b/mine/modules/launcher/plugins/tmux.py
new file mode 100644
index 0000000..64d05ca
--- /dev/null
+++ b/mine/modules/launcher/plugins/tmux.py
@@ -0,0 +1,395 @@
+from collections.abc import Iterator
+
+from fabric.utils import Gdk, exec_shell_command_async, idle_add, remove_handler
+from fabric.widgets.box import Box
+from fabric.widgets.button import Button
+from fabric.widgets.label import Label
+
+from modules.launcher.base import LauncherPlugin
+from services import TmuxService
+from utils.functions import terminal_exec_command
+
+
+class TmuxPlugin(LauncherPlugin):
+ @property
+ def name(self) -> str:
+ return "tmux"
+
+ @property
+ def icon(self) -> str:
+ return "terminal-symbolic"
+
+ @property
+ def keywords(self) -> list[str]:
+ return ["tmux"]
+
+ def __init__(self, handler):
+ super().__init__(handler)
+ self._query_handler: int = 0
+ self.current_slots: list = []
+ self.tmux_service = TmuxService()
+ self.sessions: list = []
+ self.terminal = "kitty"
+
+ def on_search(self, text: str) -> None:
+ self.query_tmux(text)
+
+ def stop(self):
+ if self._query_handler:
+ remove_handler(self._query_handler)
+ self._query_handler = 0
+ self.current_slots.clear()
+
+ def query_tmux(self, text: str) -> None:
+ self.handler.start("tmux")
+ self.current_slots.clear()
+ text_stripped = text.strip()
+
+ commands = {
+ "new": self._handle_new_command,
+ "kill": self._handle_kill_command,
+ "rename": self._handle_rename_command,
+ "terminal": self._handle_terminal_command,
+ "help": lambda t: self._show_command_help(),
+ }
+
+ for prefix, handler in commands.items():
+ if text_stripped.startswith(prefix):
+ handler(text)
+ return
+
+ self._load_and_display_sessions(text)
+
+ def _load_and_display_sessions(self, text: str) -> None:
+ result = self.tmux_service.list_sessions()
+ if not result["success"]:
+ self._show_error_message(f"Failed to load sessions: {result['error']}")
+ return
+
+ self.sessions = result["data"]
+
+ if not self.sessions:
+ self._show_no_sessions()
+ return
+
+ filtered_sessions = self._filter_sessions(text)
+ if not filtered_sessions:
+ self._show_no_results()
+ return
+
+ self._query_handler = idle_add(
+ self._bake_next_session_slot, iter(filtered_sessions), pin=True
+ )
+
+ def _filter_sessions(self, text: str) -> Iterator[dict]:
+ if not text.strip():
+ return iter(self.sessions)
+
+ text_lower = text.lower()
+ return iter(
+ session
+ for session in self.sessions
+ if text_lower in session.get("name", "").lower()
+ )
+
+ def _show_no_sessions(self) -> None:
+ self.handler.slot_ready(
+ Button(
+ style_classes="app-slot tmux-no-sessions",
+ child=Box(
+ orientation="h",
+ spacing=12,
+ children=[
+ Label(
+ label="No active tmux sessions",
+ style_classes="tmux-no-sessions-text",
+ h_align="center",
+ ),
+ ],
+ ),
+ tooltip_text="Use 'new session_name' to create a session",
+ ),
+ "tmux",
+ )
+ self.handler.done()
+
+ def _show_no_results(self) -> None:
+ self.handler.slot_ready(
+ Button(
+ style_classes="app-slot tmux-no-results",
+ child=Box(
+ orientation="h",
+ spacing=12,
+ children=[
+ Label(
+ label="No sessions match your search",
+ style_classes="tmux-no-results-text",
+ h_align="center",
+ ),
+ ],
+ ),
+ ),
+ "tmux",
+ )
+ self.handler.done()
+
+ def _bake_next_session_slot(self, iterator: Iterator[dict]) -> bool:
+ try:
+ session = next(iterator)
+ except StopIteration:
+ idle_add(self.handler.done)
+ return False
+
+ self._create_session_slot(session)
+ return True
+
+ def _create_session_slot(self, session: dict) -> None:
+ session_name = session.get("name", "")
+ windows = session.get("windows", 0)
+ created = session.get("created", "")
+ attached = session.get("attached", False)
+
+ status_icon = "●" if attached else "○"
+ status_color = "attached" if attached else "detached"
+
+ name_label = Label(
+ label=f"{status_icon} {session_name}",
+ style_classes=f"tmux-session-name tmux-{status_color}",
+ h_align="start",
+ max_chars_width=25,
+ ellipsization="end",
+ )
+
+ info_label = Label(
+ label=f"{windows} windows • Created: {created}",
+ style_classes="tmux-session-info",
+ h_align="start",
+ max_chars_width=40,
+ ellipsization="end",
+ )
+
+ slot_widget = Button(
+ style_classes=f"app-slot tmux-slot tmux-{status_color}",
+ child=Box(
+ orientation="v",
+ spacing=4,
+ children=[
+ name_label,
+ info_label,
+ ],
+ ),
+ tooltip_text=f"Click to attach\nShift+Enter to kill session\nAlt+Enter to rename session\nAttached: {'Yes' if attached else 'No'}",
+ on_clicked=lambda *_: self._attach_session(session_name),
+ )
+
+ slot_widget.connect(
+ "key-press-event",
+ lambda widget, event, s_name=session_name: self._on_slot_key_press(
+ widget, event, s_name
+ ),
+ )
+
+ slot_info = {
+ "widget": slot_widget,
+ "name_label": name_label,
+ "info_label": info_label,
+ "session_name": session_name,
+ "session": session,
+ }
+ self.current_slots.append(slot_info)
+ self.handler.slot_ready(slot_widget, "tmux")
+
+ def _attach_session(self, session_name: str) -> None:
+ cmd = f"tmux attach -t {session_name}"
+ term_cmd = terminal_exec_command(self.terminal, cmd)
+ exec_shell_command_async(term_cmd)
+ self.handler.launched()
+
+ def _kill_session(self, session_name: str) -> None:
+ result = self.tmux_service.kill_session(session_name)
+ if not result["success"]:
+ self._show_error_message(f"Failed to kill session: {result['error']}")
+ return
+
+ self._refresh_session_list()
+
+ def _on_slot_key_press(self, widget, event, session_name: str) -> bool:
+ if not event:
+ return False
+
+ # Shift+Enter: Kill session
+ if (
+ event.keyval == Gdk.KEY_Return or event.keyval == Gdk.KEY_KP_Enter
+ ) and event.state & Gdk.ModifierType.SHIFT_MASK:
+ return self._kill_session(session_name) or True
+
+ # Alt+Enter: Rename session (pre-fill input)
+ if (
+ event.keyval == Gdk.KEY_Return or event.keyval == Gdk.KEY_KP_Enter
+ ) and event.state & Gdk.ModifierType.MOD1_MASK:
+ # This would require updating the input field, which we can't easily do directly from here
+ # without a callback or reference to the entry.
+ # For now, we can just show a message or handle it if we add that capability.
+ pass
+
+ return False
+
+ def _handle_new_command(self, text: str) -> None:
+ if not text.strip().endswith(";"):
+ self._show_error_message(
+ "New session command must end with ';'\n\nUsage: new session_name;"
+ )
+ return
+
+ command_text = text.strip()[:-1]
+ parts = command_text.split()
+
+ if len(parts) < 2:
+ self._show_command_help("new")
+ return
+
+ session_name = parts[1]
+ result = self.tmux_service.new_session(session_name)
+ if not result["success"]:
+ self._show_error_message(f"Error creating session: {result['error']}")
+ return
+
+ if hasattr(self.handler, "clear_input"):
+ self.handler.clear_input()
+ self._refresh_session_list()
+ # Optionally attach immediately
+ self._attach_session(session_name)
+
+ def _handle_kill_command(self, text: str) -> None:
+ if text.strip() == "kill":
+ self._load_and_display_sessions("")
+ return
+
+ if not text.strip().endswith(";"):
+ self._show_error_message(
+ "Kill command must end with ';'\n\nUsage: kill session_name;"
+ )
+ return
+
+ command_text = text.strip()[:-1]
+ parts = command_text.split()
+
+ if len(parts) < 2:
+ self._show_command_help("kill")
+ return
+
+ session_name = parts[1]
+ self._kill_session(session_name)
+
+ def _handle_rename_command(self, text: str) -> None:
+ if text.strip() == "rename":
+ self._load_and_display_sessions("")
+ return
+
+ if not text.strip().endswith(";"):
+ self._show_error_message(
+ "Rename command must end with ';'\n\nUsage: rename old_name new_name;"
+ )
+ return
+
+ command_text = text.strip()[:-1]
+ parts = command_text.split()
+
+ if len(parts) < 3:
+ self._show_command_help("rename")
+ return
+
+ old_name = parts[1]
+ new_name = parts[2]
+
+ result = self.tmux_service.rename_session(old_name, new_name)
+ if not result["success"]:
+ self._show_error_message(f"Error renaming session: {result['error']}")
+ return
+
+ if hasattr(self.handler, "clear_input"):
+ self.handler.clear_input()
+ self._refresh_session_list()
+
+ def _handle_terminal_command(self, text: str) -> None:
+ parts = text.strip().split()
+ if len(parts) < 2:
+ self._show_command_help("terminal")
+ return
+ self.terminal = parts[1]
+ self._refresh_session_list()
+
+ def _show_command_help(self, command: str = None) -> None:
+ help_texts = {
+ "new": "Usage: new session_name;\nExample: new dev;",
+ "kill": "Usage: kill session_name;\nExample: kill dev;",
+ "rename": "Usage: rename old_name new_name;\nExample: rename dev work;",
+ "terminal": "Usage: terminal terminal_name\nExample: terminal kitty",
+ }
+
+ help_text = help_texts.get(
+ command,
+ """Available commands:
+• new session_name; - Create new session
+• kill session_name; - Kill session
+• rename old_name new_name; - Rename session
+• terminal terminal_name - Set terminal emulator
+• help - Show this help
+
+Note: Commands with arguments must end with semicolon (;)
+Click to attach, Shift+Enter to kill""",
+ )
+
+ self.handler.slot_ready(
+ Button(
+ style_classes="app-slot tmux-help",
+ child=Box(
+ orientation="h",
+ spacing=12,
+ children=[
+ Label(
+ label=help_text,
+ style_classes="tmux-help-text",
+ h_align="center",
+ ),
+ ],
+ ),
+ ),
+ "tmux",
+ )
+ self.handler.done()
+
+ def _show_error_message(self, message: str) -> None:
+ self.handler.slot_ready(
+ Button(
+ style_classes="app-slot tmux-error",
+ child=Box(
+ orientation="h",
+ spacing=12,
+ children=[
+ Label(
+ label=message,
+ style_classes="tmux-error-text",
+ h_align="center",
+ ),
+ ],
+ ),
+ on_clicked=lambda *_: self.handler.launched(),
+ ),
+ "tmux",
+ )
+ self.handler.done()
+
+ def _refresh_session_list(self) -> None:
+ self.current_slots.clear()
+ if hasattr(self.handler, "clear_viewport"):
+ self.handler.clear_viewport()
+ idle_add(self._show_session_list)
+
+ def _show_session_list(self) -> None:
+ self.handler.start("tmux")
+ self._load_and_display_sessions("")
+
+ def handle_external(self, command: str, args: str) -> None:
+ # Map external commands if needed
+ pass
diff --git a/mine/modules/launcher/plugins/todo.py b/mine/modules/launcher/plugins/todo.py
new file mode 100644
index 0000000..f097b77
--- /dev/null
+++ b/mine/modules/launcher/plugins/todo.py
@@ -0,0 +1,533 @@
+from collections.abc import Iterator
+
+from fabric.utils import Gdk, idle_add, remove_handler
+from fabric.widgets.box import Box
+from fabric.widgets.button import Button
+from fabric.widgets.label import Label
+
+from modules.launcher.base import LauncherPlugin
+from services import TodoService
+
+
+class TodoPlugin(LauncherPlugin):
+ @property
+ def name(self) -> str:
+ return "todo"
+
+ @property
+ def icon(self) -> str:
+ return "view-list-symbolic"
+
+ @property
+ def keywords(self) -> list[str]:
+ return ["todo"]
+
+ def __init__(self, handler):
+ super().__init__(handler)
+ self.todo_service = TodoService()
+ self._query_handler: int = 0
+ self.current_slots: list = []
+ self.todos: list = []
+
+ def on_search(self, text: str) -> None:
+ self.query_todos(text)
+
+ def stop(self):
+ if self._query_handler:
+ remove_handler(self._query_handler)
+ self._query_handler = 0
+ self.current_slots.clear()
+
+ def query_todos(self, text: str) -> None:
+ self.handler.start("todos")
+ self.current_slots.clear()
+ text_stripped = text.strip()
+
+ commands = {
+ "add": self._handle_add_command,
+ "done": self._handle_done_command,
+ "remove": self._handle_remove_command,
+ "edit": self._handle_edit_command,
+ "clear": self._handle_clear_command,
+ "help": lambda t: self._show_command_help(),
+ }
+
+ for prefix, handler in commands.items():
+ if text_stripped.startswith(prefix):
+ handler(text)
+ return
+
+ self._load_and_display_todos(text)
+
+ def _load_and_display_todos(self, text: str) -> None:
+ result = self.todo_service.list_todos()
+ if not result["success"]:
+ self._show_error_message(f"Failed to load todos: {result['error']}")
+ return
+
+ self.todos = result["data"]
+
+ if not self.todos:
+ self._show_no_todos()
+ return
+
+ filtered_todos = self._filter_todos(text)
+ # Convert to list to check if empty, as we need to know if we should show "no results"
+ filtered_list = list(filtered_todos)
+ if not filtered_list:
+ self._show_no_results()
+ return
+
+ self._query_handler = idle_add(
+ self._bake_next_todo_slot, iter(filtered_list), pin=True
+ )
+
+ def _filter_todos(self, text: str) -> Iterator[dict]:
+ if not text.strip():
+ return iter(self.todos)
+
+ text_lower = text.lower()
+ return iter(
+ todo
+ for todo in self.todos
+ if (
+ text_lower in todo.get("title", "").lower()
+ or text_lower in todo.get("description", "").lower()
+ or any(text_lower in tag.lower() for tag in todo.get("tags", []))
+ )
+ )
+
+ def _show_no_todos(self) -> None:
+ self.handler.slot_ready(
+ Button(
+ style_classes="app-slot todo-no-todos",
+ child=Box(
+ orientation="h",
+ spacing=12,
+ children=[
+ Label(
+ label="No todos found",
+ style_classes="todo-no-todos-text",
+ h_align="center",
+ ),
+ ],
+ ),
+ tooltip_text="Use 'add title description' to add todos",
+ ),
+ "todos",
+ )
+ self.handler.done()
+
+ def _show_no_results(self) -> None:
+ self.handler.slot_ready(
+ Button(
+ style_classes="app-slot todo-no-results",
+ child=Box(
+ orientation="h",
+ spacing=12,
+ children=[
+ Label(
+ label="No todos match your search",
+ style_classes="todo-no-results-text",
+ h_align="center",
+ ),
+ ],
+ ),
+ ),
+ "todos",
+ )
+ self.handler.done()
+
+ def _bake_next_todo_slot(self, iterator: Iterator[dict]) -> bool:
+ try:
+ todo = next(iterator)
+ except StopIteration:
+ idle_add(self.handler.done)
+ return False
+
+ self._create_todo_slot(todo)
+ return True
+
+ def _create_todo_slot(self, todo: dict) -> None:
+ todo_id = todo.get("id")
+ title = todo.get("title", "")
+ description = todo.get("description", "")
+ status = todo.get("status", "pending")
+ priority = todo.get("priority", "medium")
+ tags = todo.get("tags", [])
+
+ status_icon = "✓" if status == "completed" else "○"
+ status_color = "completed" if status == "completed" else "pending"
+
+ priority_icons = {"low": "↓", "medium": "→", "high": "↑"}
+ priority_icon = priority_icons.get(priority, "→")
+
+ title_label = Label(
+ label=f"{status_icon} {title}",
+ style_classes=f"todo-title todo-{status_color}",
+ h_align="start",
+ max_chars_width=30,
+ ellipsization="end",
+ )
+
+ description_label = Label(
+ label=description if description else "No description",
+ style_classes="todo-description",
+ h_align="start",
+ max_chars_width=40,
+ ellipsization="end",
+ )
+
+ tags_label = Label(
+ label=f"{priority_icon} {' '.join(f'#{tag}' for tag in tags[:3])}"
+ if tags
+ else f"{priority_icon} {priority}",
+ style_classes="todo-tags",
+ h_align="start",
+ max_chars_width=35,
+ ellipsization="end",
+ )
+
+ slot_widget = Button(
+ style_classes=f"app-slot todo-slot todo-{status_color}",
+ child=Box(
+ orientation="v",
+ spacing=4,
+ children=[
+ title_label,
+ description_label,
+ tags_label,
+ ],
+ ),
+ tooltip_text=f"Click to toggle completion\nShift+Enter to toggle completion\nAlt+Enter to remove todo\nPriority: {priority}\nTags: {', '.join(tags) if tags else 'None'}",
+ on_clicked=lambda *_: self._toggle_todo_completion(todo_id),
+ )
+
+ slot_widget.connect(
+ "key-press-event",
+ lambda widget, event, tid=todo_id: self._on_slot_key_press(
+ widget, event, tid
+ ),
+ )
+
+ slot_info = {
+ "widget": slot_widget,
+ "title_label": title_label,
+ "description_label": description_label,
+ "tags_label": tags_label,
+ "todo_id": todo_id,
+ "todo": todo,
+ }
+ self.current_slots.append(slot_info)
+ self.handler.slot_ready(slot_widget, "todos")
+
+ def _toggle_todo_completion(self, todo_id: int) -> None:
+ result = self.todo_service.get_todo(todo_id)
+ if not result["success"]:
+ self._show_error_message(f"Failed to get todo: {result['error']}")
+ return
+
+ todo = result["data"]
+ current_status = todo.get("status", "pending")
+
+ result = (
+ self.todo_service.mark_pending(todo_id)
+ if current_status == "completed"
+ else self.todo_service.mark_done(todo_id)
+ )
+ if not result["success"]:
+ self._show_error_message(f"Failed to update todo: {result['error']}")
+ return
+
+ self._refresh_todo_list()
+
+ def _remove_todo(self, todo_id: int) -> None:
+ result = self.todo_service.remove_todo(todo_id)
+ if not result["success"]:
+ self._show_error_message(f"Failed to remove todo: {result['error']}")
+ return
+
+ self._refresh_todo_list()
+
+ def _on_slot_key_press(self, widget, event, todo_id: int) -> bool:
+ if not event:
+ return False
+
+ # Shift+Enter: Toggle completion
+ if (
+ event.keyval == Gdk.KEY_Return or event.keyval == Gdk.KEY_KP_Enter
+ ) and event.state & Gdk.ModifierType.SHIFT_MASK:
+ return self._toggle_todo_completion(todo_id) or True
+
+ # Alt+Enter: Remove todo
+ if (
+ event.keyval == Gdk.KEY_Return or event.keyval == Gdk.KEY_KP_Enter
+ ) and event.state & Gdk.ModifierType.MOD1_MASK:
+ return self._remove_todo(todo_id) or True
+
+ return False
+
+ def _handle_add_command(self, text: str) -> None:
+ if not text.strip().endswith(";"):
+ self._show_error_message(
+ "Todo command must end with ';'\n\nUsage: add title [-d description] [-p low|medium|high];"
+ )
+ return
+
+ command_text = text.strip()[:-1]
+ parts = command_text.split()
+
+ if len(parts) < 2:
+ self._show_command_help("add")
+ return
+
+ title = parts[1]
+ description = ""
+ priority = "medium" # Default priority
+
+ i = 2
+ while i < len(parts):
+ if parts[i] == "-d":
+ desc_parts = []
+ i += 1
+ while i < len(parts) and not parts[i].startswith("-"):
+ desc_parts.append(parts[i])
+ i += 1
+ description = " ".join(desc_parts)
+ elif parts[i] == "-p":
+ if i + 1 < len(parts) and parts[i + 1] in ["low", "medium", "high"]:
+ priority = parts[i + 1]
+ i += 2
+ else:
+ self._show_error_message(
+ "Invalid priority. Use: low, medium, or high"
+ )
+ return
+ else:
+ if not description:
+ desc_parts = []
+ while i < len(parts) and not parts[i].startswith("-"):
+ desc_parts.append(parts[i])
+ i += 1
+ description = " ".join(desc_parts)
+ else:
+ i += 1
+
+ result = self.todo_service.add_todo(title, description, priority=priority)
+ if not result["success"]:
+ self._show_error_message(f"Error adding todo: {result['error']}")
+ return
+
+ if hasattr(self.handler, "clear_input"):
+ self.handler.clear_input()
+ self._refresh_todo_list()
+
+ def _handle_done_command(self, text: str) -> None:
+ if text.strip() == "done":
+ self._load_and_display_todos("")
+ return
+
+ if not text.strip().endswith(";"):
+ self._show_error_message(
+ "Todo command must end with ';'\n\nUsage: done todo_title;"
+ )
+ return
+
+ command_text = text.strip()[:-1]
+ parts = command_text.split()
+
+ if len(parts) < 2:
+ self._load_and_display_todos("")
+ return
+
+ todo_title = " ".join(parts[1:])
+ self._execute_todo_command("mark_done", todo_title)
+
+ def _handle_remove_command(self, text: str) -> None:
+ if text.strip() == "remove":
+ self._load_and_display_todos("")
+ return
+
+ if not text.strip().endswith(";"):
+ self._show_error_message(
+ "Todo command must end with ';'\n\nUsage: remove todo_title;"
+ )
+ return
+
+ command_text = text.strip()[:-1]
+ parts = command_text.split()
+
+ if len(parts) < 2:
+ self._load_and_display_todos("")
+ return
+
+ todo_title = " ".join(parts[1:])
+ self._execute_todo_command("remove_todo", todo_title)
+
+ def _handle_edit_command(self, text: str) -> None:
+ if text.strip() == "edit":
+ self._load_and_display_todos("")
+ return
+
+ if not text.strip().endswith(";"):
+ self._show_error_message(
+ "Todo command must end with ';'\n\nUsage: edit todo_title field new_value;"
+ )
+ return
+
+ command_text = text.strip()[:-1]
+ parts = command_text.split()
+
+ if len(parts) < 4:
+ self._load_and_display_todos("")
+ return
+
+ field = parts[-2]
+ new_value = parts[-1]
+ todo_title = " ".join(parts[1:-2])
+
+ if not todo_title:
+ self._show_error_message("Todo title cannot be empty")
+ return
+
+ if field not in ["title", "description", "priority", "status"]:
+ self._show_error_message(
+ "Invalid field. Use: title, description, priority, or status"
+ )
+ return
+
+ result = self.todo_service.edit_todo(
+ self._find_todo_by_title(todo_title), **{field: new_value}
+ )
+ if not result["success"]:
+ self._show_error_message(f"Error editing todo: {result['error']}")
+ return
+
+ if hasattr(self.handler, "clear_input"):
+ self.handler.clear_input()
+ self._refresh_todo_list()
+
+ def _handle_clear_command(self, text: str) -> None:
+ result = self.todo_service.clear_completed()
+ if not result["success"]:
+ self._show_error_message(
+ f"Error clearing completed todos: {result['error']}"
+ )
+ return
+
+ if hasattr(self.handler, "clear_input"):
+ self.handler.clear_input()
+ self._refresh_todo_list()
+
+ def _execute_todo_command(self, command: str, todo_title: str) -> None:
+ todo_id = self._find_todo_by_title(todo_title)
+ if not todo_id:
+ return
+
+ result = getattr(self.todo_service, command)(todo_id)
+ if not result["success"]:
+ self._show_error_message(f"Error executing command: {result['error']}")
+ return
+
+ if hasattr(self.handler, "clear_input"):
+ self.handler.clear_input()
+ self._refresh_todo_list()
+
+ def _find_todo_by_title(self, todo_title: str) -> int:
+ todos = self.todo_service.list_todos()["data"]
+ matching_todos = [
+ todo
+ for todo in todos
+ if todo.get("title", "").lower() == todo_title.lower()
+ ]
+
+ if not matching_todos:
+ self._show_error_message(f"No todo found with title: '{todo_title}'")
+ return None
+
+ if len(matching_todos) > 1:
+ self._show_error_message(
+ f"Multiple todos found with title: '{todo_title}'\nPlease be more specific"
+ )
+ return None
+
+ return matching_todos[0]["id"]
+
+ def _show_command_help(self, command: str = None) -> None:
+ help_texts = {
+ "add": "Usage: add title [-d description] [-p low|medium|high];\nExample: add Buy groceries -d Get milk and bread -p high;\nExample: add Call mom -p low;\nExample: add Fix bug -d Critical issue;",
+ "done": "Usage: done todo_title;\nExample: done Buy groceries;",
+ "remove": "Usage: remove todo_title;\nExample: remove Buy groceries;",
+ "edit": "Usage: edit todo_title field new_value;\nFields: title, description, priority, status\nExample: edit Buy groceries title Buy organic groceries;",
+ "clear": "Usage: clear\nRemoves all completed todos",
+ }
+
+ help_text = help_texts.get(
+ command,
+ """Available commands:
+• add title [-d description] [-p low|medium|high]; - Add new todo
+• done todo_title; - Mark todo as completed
+• remove todo_title; - Remove todo
+• edit todo_title field new_value; - Edit todo
+• clear - Remove all completed todos
+• help - Show this help
+
+Note: Commands with arguments must end with semicolon (;)
+Flags: -d for description, -p for priority (low|medium|high)
+Click on todo or Shift+Enter to toggle completion
+Alt+Enter to remove todo""",
+ )
+
+ self.handler.slot_ready(
+ Button(
+ style_classes="app-slot todo-help",
+ child=Box(
+ orientation="h",
+ spacing=12,
+ children=[
+ Label(
+ label=help_text,
+ style_classes="todo-help-text",
+ h_align="center",
+ ),
+ ],
+ ),
+ ),
+ "todos",
+ )
+ self.handler.done()
+
+ def _show_error_message(self, message: str) -> None:
+ self.handler.slot_ready(
+ Button(
+ style_classes="app-slot todo-error",
+ child=Box(
+ orientation="h",
+ spacing=12,
+ children=[
+ Label(
+ label=message,
+ style_classes="todo-error-text",
+ h_align="center",
+ ),
+ ],
+ ),
+ on_clicked=lambda *_: self.handler.launched(),
+ ),
+ "todos",
+ )
+ self.handler.done()
+
+ def _refresh_todo_list(self) -> None:
+ self.current_slots.clear()
+ if hasattr(self.handler, "clear_viewport"):
+ self.handler.clear_viewport()
+ idle_add(self._show_todo_list)
+
+ def _show_todo_list(self) -> None:
+ self.handler.start("todos")
+ self._load_and_display_todos("")
+
+ def handle_external(self, command: str, args: str) -> None:
+ # Map external commands if needed
+ pass
diff --git a/mine/modules/launcher/plugins/wallpaper.py b/mine/modules/launcher/plugins/wallpaper.py
new file mode 100644
index 0000000..4856be9
--- /dev/null
+++ b/mine/modules/launcher/plugins/wallpaper.py
@@ -0,0 +1,167 @@
+import contextlib
+import gc
+import random
+
+from fabric.utils import idle_add, os, remove_handler
+from fabric.widgets.box import Box
+from fabric.widgets.button import Button
+from thefuzz import fuzz, process
+
+from config.data import WALLPAPERS_THUMBNAILS_SIZE
+from modules.launcher.base import LauncherPlugin
+from services import WallpaperService, create_thumbnail
+
+
+class WallpaperPlugin(LauncherPlugin):
+ # Memory optimization: large sliding window for virtual scrolling
+ BATCH_SIZE = 10 # Load 10 at a time for smooth UI
+ MAX_VISIBLE_WIDGETS = (
+ 100 # Keep 100 widgets in memory (allows scrolling back/forth)
+ )
+
+ @property
+ def name(self) -> str:
+ return "wallpaper"
+
+ @property
+ def icon(self) -> str:
+ return "wallpaper"
+
+ @property
+ def keywords(self) -> list[str]:
+ return ["wall", "wr"]
+
+ def __init__(self, handler):
+ super().__init__(handler)
+ self._query_handler: int = 0
+ self._current_widgets = [] # Track created widgets for cleanup
+ self._batch_count = 0 # Track how many items loaded
+ self._all_wallpapers = [] # Store paths instead of widgets
+
+ self.wallpaper_service = WallpaperService()
+
+ def on_search(self, text: str) -> None:
+ self.query_wallpapers(text)
+
+ def handle_external(self, command: str, args: str) -> None:
+ if command == "wr":
+ self.apply_random_wallpaper()
+
+ def stop(self):
+ """Stop loading and cleanup resources."""
+ if self._query_handler:
+ remove_handler(self._query_handler)
+ self._query_handler = 0
+ self._cleanup_widgets()
+
+ def _cleanup_widgets(self):
+ """Destroy old widgets to free memory."""
+ for widget in self._current_widgets:
+ with contextlib.suppress(Exception):
+ widget.destroy()
+ self._current_widgets.clear()
+ self._batch_count = 0
+
+ gc.collect()
+
+ def query_wallpapers(self, text: str) -> None:
+ # Clean up previous query results
+ self._cleanup_widgets()
+ self._all_wallpapers.clear()
+
+ self.handler.start("wallpapers") # pyright: ignore[reportOptionalCall]
+
+ all_files = self.wallpaper_service.get_all_wallpapers()
+
+ if not all_files:
+ self.handler.done()
+ return
+
+ if text.strip():
+ filtered_files = [
+ file_full
+ for file_full, _ in process.extract(
+ text,
+ all_files,
+ scorer=fuzz.WRatio,
+ processor=os.path.basename,
+ limit=10,
+ )
+ ]
+ else:
+ filtered_files = all_files
+
+ # Store all paths but only create widgets for visible ones
+ self._all_wallpapers = filtered_files
+
+ self._query_handler = idle_add(
+ self.bake_next_wallpaper_slot, iter(filtered_files), pin=True
+ )
+
+ def bake_next_wallpaper_slot(self, iterator):
+ """Load wallpapers in batches to prevent UI freezing."""
+ for _ in range(self.BATCH_SIZE):
+ if (image_path := next(iterator, None)) is None:
+ idle_add(self.handler.done) # pyright: ignore[reportArgumentType]
+ return False
+
+ self._batch_count += 1
+
+ thumbnail_path = self.wallpaper_service.get_thumbnail_path(image_path)
+
+ # Only create thumbnail if it doesn't exist
+ if not self.wallpaper_service.has_thumbnail(image_path):
+ try:
+ create_thumbnail(
+ image_path, thumbnail_path, WALLPAPERS_THUMBNAILS_SIZE
+ )
+ except Exception:
+ # Skip if thumbnail creation fails
+ continue
+
+ self.post_wallpaper_cache_check(image_path, thumbnail_path)
+
+ # Continue loading next batch
+ return True
+
+ def post_wallpaper_cache_check(self, image_path: str, thumbnail_path: str):
+ widget = Button(
+ style_classes="app-slot wallpaper",
+ child=Box(h_expand=True, v_expand=True)
+ .build()
+ .set_style(
+ f"background-image: url('file://{thumbnail_path}');", compile=False
+ )
+ .unwrap(),
+ on_clicked=lambda *_: (
+ self.handler.launched and self.handler.launched(),
+ self.wallpaper_service.set_wallpaper(image_path),
+ ),
+ )
+
+ self._current_widgets.append(widget)
+
+ # Once we exceed the window, remove oldest widgets
+ if len(self._current_widgets) > self.MAX_VISIBLE_WIDGETS:
+ widgets_to_remove = self._current_widgets[:20]
+ self._current_widgets = self._current_widgets[20:]
+
+ for old_widget in widgets_to_remove:
+ with contextlib.suppress(Exception):
+ old_widget.destroy()
+
+ gc.collect()
+
+ return self.handler.slot_ready( # pyright: ignore[reportOptionalCall]
+ widget,
+ "wallpapers",
+ )
+
+ def apply_random_wallpaper(self) -> None:
+ files = self.wallpaper_service.get_all_wallpapers()
+ if not files:
+ self.handler.done()
+ return
+ chosen = random.choice(files)
+ self.wallpaper_service.set_wallpaper(chosen)
+ self.handler.done()
diff --git a/mine/modules/launcher/plugins/window_switcher.py b/mine/modules/launcher/plugins/window_switcher.py
new file mode 100644
index 0000000..fed5e83
--- /dev/null
+++ b/mine/modules/launcher/plugins/window_switcher.py
@@ -0,0 +1,280 @@
+import json
+from collections.abc import Iterator
+
+from fabric.utils import Gdk, get_desktop_applications, idle_add, remove_handler
+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 modules.launcher.base import LauncherPlugin
+from utils.functions import is_special_workspace, run_command
+
+
+class WindowSwitcherPlugin(LauncherPlugin):
+ @property
+ def name(self) -> str:
+ return "windows"
+
+ @property
+ def icon(self) -> str:
+ return "preferences-system-windows-symbolic"
+
+ @property
+ def keywords(self) -> list[str]:
+ return ["win", "windows"]
+
+ def __init__(self, handler):
+ super().__init__(handler)
+ self._query_handler: int = 0
+ self.windows: list[dict] = []
+ self._desktop_apps = get_desktop_applications()
+
+ def on_search(self, text: str) -> None:
+ self.query_windows(text)
+
+ def stop(self):
+ if self._query_handler:
+ remove_handler(self._query_handler)
+ self._query_handler = 0
+
+ def _get_windows(self) -> list[dict]:
+ result = run_command(["hyprctl", "-j", "clients"], timeout=5)
+ if result.returncode != 0 or not isinstance(result.stdout, str):
+ return []
+
+ clients = json.loads(result.stdout)
+ filtered_clients = []
+
+ for client in clients:
+ if (
+ not client.get("mapped", False)
+ or not client.get("visible", True)
+ or is_special_workspace(client)
+ ):
+ continue
+ filtered_clients.append(client)
+
+ return filtered_clients
+
+ def _get_window_icon_pixbuf(self, window: dict):
+ class_name = window.get("class", "").lower()
+ title = window.get("title", "").lower()
+
+ for app in self._desktop_apps:
+ app_name = (app.name or "").lower()
+ if (
+ class_name in app_name
+ or app_name in class_name
+ or any(word in app_name for word in title.split() if len(word) > 2)
+ ):
+ return app.get_icon_pixbuf()
+ return None
+
+ def _get_workspace_name(self, workspace_id: int) -> str:
+ result = run_command(["hyprctl", "-j", "workspaces"], timeout=3)
+ if result.returncode != 0 or not isinstance(result.stdout, str):
+ return f"Workspace {workspace_id}"
+
+ workspaces = json.loads(result.stdout)
+ for workspace in workspaces:
+ if workspace.get("id") == workspace_id:
+ return workspace.get("name", f"Workspace {workspace_id}")
+ return f"Workspace {workspace_id}"
+
+ def query_windows(self, text: str) -> None:
+ self.handler.start("windows")
+ self.windows = self._get_windows()
+
+ if not self.windows:
+ self._show_no_windows()
+ return
+
+ filtered_windows = self._filter_windows(text)
+ # We need to check if the iterator is empty, but we can't easily do that without consuming it.
+ # So we'll convert to list first if needed, or just handle the empty case in bake.
+ # However, _filter_windows returns an iterator. Let's modify it to return a list or check emptiness.
+ # Actually, let's just try to bake. If it returns False immediately, we can show no results?
+ # But _bake_next_window_slot calls done() if empty.
+ # Let's peek? No.
+ # Let's just pass it. If it's empty, _bake_next_window_slot will just call done.
+ # But we might want to show "No matching windows" if the filter returns nothing but windows exist.
+ # So let's consume the iterator into a list for checking.
+ filtered_list = list(filtered_windows)
+ if not filtered_list:
+ # If we had windows but none matched, we could show "No results".
+ # But the original code just returned. Let's stick to that or show no results.
+ # Showing no results is better UX.
+ self._show_no_results()
+ return
+
+ self._query_handler = idle_add(
+ self._bake_next_window_slot, iter(filtered_list), pin=True
+ )
+
+ def _filter_windows(self, text: str) -> Iterator[dict]:
+ if not text.strip():
+ return iter(self.windows)
+
+ text_lower = text.lower()
+ filtered_windows = []
+
+ for window in self.windows:
+ title = window.get("title", "").lower()
+ class_name = window.get("class", "").lower()
+ initial_class = window.get("initialClass", "").lower()
+
+ if (
+ text_lower in title
+ or text_lower in class_name
+ or text_lower in initial_class
+ ):
+ filtered_windows.append(window)
+
+ def sort_key(window):
+ title = window.get("title", "").lower()
+ class_name = window.get("class", "").lower()
+
+ if text_lower in (title, class_name):
+ return 0
+ if title.startswith(text_lower) or class_name.startswith(text_lower):
+ return 1
+ return 2
+
+ filtered_windows.sort(key=sort_key)
+ return iter(filtered_windows)
+
+ def _show_no_windows(self) -> None:
+ self.handler.slot_ready(
+ Button(
+ style_classes="app-slot window-no-windows",
+ child=Box(
+ orientation="h",
+ spacing=12,
+ children=[
+ Image(icon_name="window-close", size=32),
+ Label(
+ label="No open windows found",
+ style_classes="window-no-windows-text",
+ h_align="center",
+ ),
+ ],
+ ),
+ tooltip_text="No windows are currently open",
+ ),
+ "windows",
+ )
+ self.handler.done()
+
+ def _show_no_results(self) -> None:
+ self.handler.slot_ready(
+ Button(
+ style_classes="app-slot window-no-results",
+ child=Box(
+ orientation="h",
+ spacing=12,
+ children=[
+ Label(
+ label="No windows match your search",
+ style_classes="window-no-results-text",
+ h_align="center",
+ ),
+ ],
+ ),
+ ),
+ "windows",
+ )
+ self.handler.done()
+
+ def _bake_next_window_slot(self, iterator: Iterator[dict]) -> bool:
+ window = next(iterator, None)
+ if window is None:
+ idle_add(self.handler.done)
+ return False
+
+ self._create_window_slot(window)
+ return True
+
+ def _create_window_slot(self, window: dict) -> None:
+ title = window.get("title", "Unknown Window")
+ class_name = window.get("class", "Unknown")
+ workspace_id = window.get("workspace", {}).get("id", 0)
+ workspace_name = self._get_workspace_name(workspace_id)
+ icon_pixbuf = self._get_window_icon_pixbuf(window)
+
+ display_title = title[:40] + "..." if len(title) > 40 else title
+ display_class = class_name[:30] + "..." if len(class_name) > 30 else class_name
+
+ slot_content = Box(
+ orientation="h",
+ spacing=12,
+ children=[
+ Image(pixbuf=icon_pixbuf, h_align="start", size=32)
+ if icon_pixbuf
+ else Image(
+ icon_name="application-x-executable", h_align="start", size=32
+ ),
+ Box(
+ orientation="v",
+ children=[
+ Label(
+ label=f"{display_title} • {workspace_name}",
+ style_classes="window-title",
+ h_align="start",
+ v_align="start",
+ ),
+ Label(
+ label=display_class,
+ style_classes="window-subtitle",
+ h_align="start",
+ v_align="start",
+ ),
+ ],
+ ),
+ ],
+ )
+
+ main_content = slot_content
+
+ slot_widget = Button(
+ style_classes="app-slot window-slot",
+ child=main_content,
+ tooltip_text=f"Enter: Focus window\nShift+Enter: Close window\nTitle: {title}\nClass: {class_name}\nWorkspace: {workspace_name}",
+ on_clicked=lambda *_: self._focus_window(window),
+ )
+
+ slot_widget.connect(
+ "key-press-event",
+ lambda widget, event, w=window: self._on_slot_key_press(widget, event, w),
+ )
+
+ self.handler.slot_ready(slot_widget, "windows")
+
+ def _focus_window(self, window: dict) -> None:
+ address = window.get("address")
+ if address:
+ run_command(
+ ["hyprctl", "dispatch", "focuswindow", f"address:{address}"], timeout=3
+ )
+ self.handler.launched()
+
+ def _close_window(self, window: dict) -> None:
+ address = window.get("address")
+ if address:
+ run_command(
+ ["hyprctl", "dispatch", "closewindow", f"address:{address}"], timeout=3
+ )
+
+ def _on_slot_key_press(self, widget, event, window: dict) -> bool:
+ if not event or event.keyval not in (Gdk.KEY_Return, Gdk.KEY_KP_Enter):
+ return False
+
+ if event.state & Gdk.ModifierType.SHIFT_MASK:
+ self._close_window(window)
+ else:
+ self._focus_window(window)
+ return True
+
+ def handle_external(self, command: str, args: str) -> None:
+ # Map external commands if needed
+ pass
diff --git a/mine/modules/notifications.py b/mine/modules/notifications.py
new file mode 100644
index 0000000..6dc553a
--- /dev/null
+++ b/mine/modules/notifications.py
@@ -0,0 +1,564 @@
+from typing import cast
+
+from fabric.notifications import Notification, Notifications
+from fabric.utils import (
+ GdkPixbuf,
+ GLib,
+ Gtk,
+ idle_add,
+ invoke_repeater,
+ os,
+ re,
+ remove_handler,
+)
+from fabric.widgets.box import Box
+from fabric.widgets.button import Button
+from fabric.widgets.flowbox import FlowBox
+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 fabric.widgets.wayland import WaylandWindow as Window
+
+from services.config_watcher import (
+ get_all_config,
+ get_config_value,
+ has_config_changed,
+ on_config_change,
+)
+from utils.animatedscrollable import AnimatedScrollable
+from utils.clippingbox import ClippingBox
+from utils.corners import expanded_corner
+from utils.functions import add_style_class_lazy, get_children_height_limit
+
+DEFAULT_ICONS_THEME = Gtk.IconTheme.get_default()
+
+
+def parse_timeout_to_ms(timeout: str | int) -> int:
+ if isinstance(timeout, int):
+ return timeout
+
+ if isinstance(timeout, str):
+ timeout_str = timeout.strip().lower()
+ # Match patterns like "5s", "1m", "1h", "30s", etc.
+ match = re.match(r"^(\d+(?:\.\d+)?)([hms]?)$", timeout_str)
+ if match:
+ value = float(match.group(1))
+ unit = match.group(2) or "s"
+ # Convert to milliseconds
+ multipliers = {"h": 3600 * 1000, "m": 60 * 1000, "s": 1000, "": 1000}
+ return int(value * multipliers.get(unit, 1000))
+
+ raise ValueError(
+ f"Invalid timeout format: {timeout}. Expected integer (ms) or string like '5s', '1m', '1h'"
+ )
+
+
+class LimitBox(Box):
+ """A hack for replicating CSS's `max-*` properties"""
+
+ def __init__(self, max_width: int, max_height: int, **kwargs):
+ super().__init__(**kwargs)
+ self.max_width: int = max_width
+ self.max_height: int = max_height
+
+ def do_size_allocate(self, allocation):
+ if self.max_width >= 0:
+ allocation.width = min(self.max_width, allocation.width)
+ if self.max_height >= 0:
+ allocation.height = min(self.max_height, allocation.height)
+ return Box.do_size_allocate(self, allocation)
+
+
+class NotificationItem(Box):
+ def __init__(
+ self,
+ notification: Notification,
+ image_size: int,
+ timeout: int,
+ width: int,
+ buttons_per_row: int,
+ **kwargs,
+ ):
+ super().__init__(
+ name="notification",
+ spacing=8,
+ orientation="v",
+ size=(width, -1),
+ **kwargs,
+ )
+
+ self.notification = notification
+ self._image_size = image_size
+ self._timeout = timeout
+ self._width = width
+ self._buttons_per_row = buttons_per_row
+ self._timeout_source_id: int | None = None
+
+ body_container = Box(spacing=4, orientation="h")
+
+ if image_pixbuf := self._load_notification_image():
+ body_container.add(
+ ClippingBox(
+ children=(
+ Image(
+ pixbuf=image_pixbuf,
+ v_align="fill",
+ h_align="fill",
+ v_expand=True,
+ h_expand=True,
+ )
+ ),
+ v_align="start",
+ h_align="start",
+ v_expand=False,
+ h_expand=False,
+ size=(self._image_size, self._image_size),
+ style=f"border-radius: {self._image_size // 2}px;",
+ )
+ )
+
+ body_container.add(
+ Box(
+ spacing=4,
+ orientation="v",
+ children=[
+ # a box for holding both the "summary" label and the "close" button
+ Box(
+ orientation="h",
+ children=[
+ Overlay(
+ child=Label(
+ label=self.notification.summary,
+ style_classes="summary",
+ ellipsization="middle",
+ )
+ .build()
+ .set_xalign(0.0)
+ .unwrap(),
+ # notification's source icon
+ overlays=[
+ Image(
+ # render app's icon if found
+ pixbuf=icn.load_icon()
+ if (
+ icn := DEFAULT_ICONS_THEME.lookup_icon(
+ self.notification.app_icon
+ or self.notification.app_name,
+ 12,
+ Gtk.IconLookupFlags.FORCE_SIZE,
+ )
+ )
+ is not None
+ else None,
+ v_align="start",
+ h_align="start",
+ )
+ ],
+ ),
+ ],
+ h_expand=True,
+ v_expand=True,
+ v_align="start",
+ )
+ # add the "close" button
+ .build(
+ lambda box, _: box.pack_end(
+ Button(
+ image=Image(
+ icon_name="close-symbolic",
+ icon_size=18,
+ ),
+ v_align="center",
+ h_align="end",
+ on_clicked=lambda *_: self.notification.close(),
+ on_state_flags_changed=lambda btn, *_: (
+ btn.set_cursor("pointer")
+ if btn.get_state_flags() & 2 and btn.get_realized()
+ else btn.set_cursor("default")
+ if btn.get_realized()
+ else None,
+ ),
+ ),
+ False,
+ False,
+ 0,
+ )
+ ),
+ Label(
+ label=self.notification.body,
+ style_classes="body",
+ line_wrap="word-char",
+ v_align="start",
+ h_align="start",
+ )
+ .build()
+ .set_xalign(0.0)
+ .unwrap(),
+ ],
+ h_expand=True,
+ v_expand=True,
+ )
+ )
+
+ self.add(body_container)
+
+ if actions := self.notification.actions:
+ self.add(
+ FlowBox(
+ spacing=4,
+ row_spacing=4,
+ column_spacing=4,
+ orientation="h",
+ v_expand=True,
+ h_expand=True,
+ children=[
+ Button(
+ h_expand=True,
+ v_expand=True,
+ label=action.label,
+ on_clicked=lambda *_, action=action: action.invoke(),
+ on_state_flags_changed=lambda btn, *_: (
+ btn.set_cursor("pointer")
+ if btn.get_state_flags() & 2 and btn.get_realized()
+ else btn.set_cursor("default")
+ if btn.get_realized()
+ else None,
+ ),
+ )
+ for action in actions
+ ],
+ )
+ .build()
+ .set_max_children_per_line(min(len(actions), self._buttons_per_row))
+ .unwrap()
+ )
+
+ # automatically close the notification after the timeout period
+ self._start_timeout()
+
+ add_style_class_lazy(self, "shine")
+
+ def _start_timeout(self):
+ if self._timeout_source_id is not None:
+ remove_handler(self._timeout_source_id)
+ self._timeout_source_id = None
+
+ self._timeout_source_id = invoke_repeater(
+ self._timeout,
+ self.notification.close,
+ "expired",
+ initial_call=False,
+ )
+
+ def update_timeout(self, new_timeout: int):
+ if self._timeout != new_timeout:
+ self._timeout = new_timeout
+ self._start_timeout()
+
+ def _load_notification_image(self) -> GdkPixbuf.Pixbuf | None:
+ if image_pixbuf := self.notification.image_pixbuf:
+ return self._scale_pixbuf(image_pixbuf)
+
+ if image_file := self._get_image_file_path():
+ return self._load_image_from_file(image_file)
+
+ return None
+
+ def _get_image_file_path(self) -> str | None:
+ try:
+ if image_file := self.notification.get_image_file():
+ return image_file
+ except (AttributeError, TypeError):
+ pass
+
+ try:
+ if image_file := getattr(self.notification, "image_file", None):
+ return image_file
+ except (AttributeError, TypeError):
+ pass
+
+ if image_file := self._get_hint_string("image-path"):
+ return image_file
+
+ try:
+ app_icon = getattr(self.notification, "app_icon", None)
+ if app_icon and os.path.exists(app_icon):
+ return app_icon
+ except (AttributeError, TypeError):
+ pass
+
+ return None
+
+ def _get_hint_string(self, hint_name: str) -> str | None:
+ try:
+ hint_entry = self.notification.do_get_hint_entry(hint_name)
+ if not hint_entry:
+ return None
+
+ if hasattr(hint_entry, "get_variant"):
+ variant = hint_entry.get_variant()
+ if variant and variant.is_of_type(GLib.VariantType.new("s")):
+ return variant.get_string()[0]
+
+ if hasattr(hint_entry, "get_string"):
+ return hint_entry.get_string()
+ except (AttributeError, TypeError, Exception):
+ pass
+ return None
+
+ def _load_image_from_file(self, image_file: str) -> GdkPixbuf.Pixbuf | None:
+ if not (image_path := self._normalize_image_path(image_file)):
+ return None
+
+ paths_to_try = [image_path] + (
+ [os.path.abspath(image_path)] if not os.path.isabs(image_path) else []
+ )
+
+ for path in paths_to_try:
+ if not os.path.exists(path):
+ continue
+ try:
+ return GdkPixbuf.Pixbuf.new_from_file_at_size(
+ path, self._image_size, self._image_size
+ )
+ except (GLib.GError, Exception):
+ continue
+ return None
+
+ def _normalize_image_path(self, image_file: str) -> str | None:
+ if not image_file:
+ return None
+
+ image_file = image_file[7:] if image_file.startswith("file://") else image_file
+ return os.path.expanduser(image_file)
+
+ def _scale_pixbuf(self, pixbuf: GdkPixbuf.Pixbuf) -> GdkPixbuf.Pixbuf:
+ """Scale pixbuf to notification image size if needed"""
+ if (
+ pixbuf.get_width() == self._image_size
+ and pixbuf.get_height() == self._image_size
+ ):
+ return pixbuf
+
+ return pixbuf.scale_simple(
+ self._image_size, self._image_size, GdkPixbuf.InterpType.BILINEAR
+ )
+
+
+# TODO: add the whole thing to a revealer that reveals to the left
+class NotificationsView(Box):
+ def __init__(self, **kwargs):
+ super().__init__(orientation="v", visible=False, **kwargs)
+
+ self._notification_items: list[NotificationItem] = []
+
+ self._load_config()
+ on_config_change(self._on_config_change)
+
+ self.viewport = Box(spacing=4, orientation="v")
+
+ self.scrolled_window = AnimatedScrollable(
+ min_content_size=(420, 1),
+ max_content_size=(420, 480),
+ h_scrollbar_policy="never",
+ v_scrollbar_policy="never",
+ child=self.viewport,
+ h_expand=True,
+ v_expand=True,
+ )
+
+ self.overall_container = Box(
+ name="notifications",
+ orientation="v",
+ children=ClippingBox(
+ style_classes="notifications-clip", children=self.scrolled_window
+ ),
+ h_expand=True,
+ v_expand=True,
+ )
+
+ self.notifications = Notifications(
+ on_notification_added=lambda _, notification_id: (
+ notification := cast(
+ Notification, self.notifications.notifications.get(notification_id)
+ ),
+ self._enabled
+ and (
+ notification_item := NotificationItem(
+ notification,
+ self._image_size,
+ self._timeout,
+ self._width,
+ self._buttons_per_row,
+ ),
+ self._notification_items.append(notification_item),
+ self.viewport.add(
+ item_rev := Revealer(
+ child=notification_item,
+ reveal_child=False,
+ transition_type="slide-down",
+ transition_duration=self._revealer_duration,
+ )
+ ),
+ self.viewport.reorder_child(item_rev, 0),
+ # ready to show
+ item_rev.reveal(),
+ notification.closed.connect(
+ lambda: (
+ self._notification_items.remove(notification_item)
+ if notification_item in self._notification_items
+ else None,
+ item_rev.connect(
+ "notify::child-revealed",
+ lambda: item_rev.destroy()
+ if not item_rev.fully_revealed
+ else None,
+ ),
+ idle_add(item_rev.unreveal),
+ )
+ ),
+ )
+ or None,
+ ),
+ )
+
+ self.children = (
+ Box(
+ orientation="h",
+ children=[
+ Box(
+ orientation="v",
+ spacing=self._corner_size,
+ children=[
+ LimitBox(
+ max_width=self._corner_size,
+ max_height=self._corner_size,
+ children=expanded_corner(orientation="top-right"),
+ v_expand=True,
+ size=(self._corner_size, -1),
+ ),
+ Box(
+ h_expand=True,
+ v_expand=True,
+ ),
+ ],
+ h_expand=True,
+ v_expand=True,
+ ),
+ self.overall_container,
+ ],
+ ),
+ Box(
+ children=expanded_corner(orientation="top-right"),
+ size=self._corner_size,
+ h_align="end",
+ v_align="end",
+ ),
+ )
+
+ self.viewport.connect("add", self.on_children_change)
+ self.viewport.connect("remove", self.on_children_change)
+ self.connect("notify::visible", self.on_visiblity_change)
+
+ def on_children_change(self, *_):
+ self.scrolled_window.animate_size(
+ get_children_height_limit(
+ self.viewport,
+ 4,
+ lambda rev: (
+ cast(Revealer, rev).get_child().get_preferred_size().minimum_size # type: ignore
+ ),
+ )
+ )
+
+ if not self._enabled:
+ return self.hide()
+
+ return self.hide() if not self.viewport.children else self.show()
+
+ def on_visiblity_change(self, *_):
+ self.viewport.remove_style_class("popped")
+
+ if not self.get_visible():
+ return
+
+ return add_style_class_lazy(self.viewport, "popped")
+
+ def _load_config(self):
+ config = get_all_config()
+ self._enabled = get_config_value(config, "notifications.enabled", True)
+ timeout_value = get_config_value(config, "notifications.timeout", "10s")
+ self._timeout = parse_timeout_to_ms(timeout_value)
+ self._image_size = get_config_value(config, "notifications.image_size", 64)
+ self._width = get_config_value(config, "notifications.width", 360)
+ self._buttons_per_row = get_config_value(
+ config, "notifications.buttons_per_row", 2
+ )
+ self._corner_size = get_config_value(config, "notifications.corner_size", 16)
+ self._revealer_duration = get_config_value(
+ config, "notifications.revealer_duration", 400
+ )
+
+ def _on_config_change(self, new_config, old_config):
+ enabled_changed = has_config_changed(
+ old_config, new_config, "notifications.enabled"
+ )
+ timeout_changed = has_config_changed(
+ old_config, new_config, "notifications.timeout"
+ )
+ image_size_changed = has_config_changed(
+ old_config, new_config, "notifications.image_size"
+ )
+ width_changed = has_config_changed(
+ old_config, new_config, "notifications.width"
+ )
+ buttons_per_row_changed = has_config_changed(
+ old_config, new_config, "notifications.buttons_per_row"
+ )
+ corner_size_changed = has_config_changed(
+ old_config, new_config, "notifications.corner_size"
+ )
+ revealer_duration_changed = has_config_changed(
+ old_config, new_config, "notifications.revealer_duration"
+ )
+
+ if (
+ enabled_changed
+ or timeout_changed
+ or image_size_changed
+ or width_changed
+ or buttons_per_row_changed
+ or corner_size_changed
+ or revealer_duration_changed
+ ):
+ old_timeout = self._timeout
+ old_enabled = self._enabled
+ self._load_config()
+
+ if enabled_changed and self._enabled != old_enabled:
+ if not self._enabled:
+ for item in list(self._notification_items):
+ item.notification.close()
+
+ if timeout_changed and self._timeout != old_timeout:
+ for item in self._notification_items:
+ item.update_timeout(self._timeout)
+
+
+class NotificationWindow(Window):
+ def __init__(self, **kwargs):
+ super().__init__(
+ layer="top",
+ anchor="top right",
+ visible=False,
+ all_visible=False,
+ )
+
+ self.build(
+ lambda win, _: win.add(
+ NotificationsView().build(
+ lambda notifs, _: notifs.bind("visible", "visible", win)
+ )
+ )
+ )
diff --git a/mine/modules/osd/components/__init__.py b/mine/modules/osd/components/__init__.py
new file mode 100644
index 0000000..92aaa6d
--- /dev/null
+++ b/mine/modules/osd/components/__init__.py
@@ -0,0 +1,24 @@
+"""
+Modus OSD modules package.
+Contains UI components and functionality modules
+"""
+
+import importlib
+import inspect
+import pkgutil
+
+__all__ = []
+
+for module_info in pkgutil.iter_modules(__path__):
+ module_name = module_info.name
+ module = importlib.import_module(f"{__name__}.{module_name}")
+
+ for attr_name in dir(module):
+ attr_value = getattr(module, attr_name)
+ if (
+ isinstance(attr_value, type)
+ and (attr_name.endswith("OSDContainer") or attr_name == "AnimatedScale")
+ and inspect.getmodule(attr_value) is module
+ ):
+ globals()[attr_name] = attr_value
+ __all__.append(attr_name)
diff --git a/mine/modules/osd/components/animated_scale.py b/mine/modules/osd/components/animated_scale.py
new file mode 100644
index 0000000..bb7c01d
--- /dev/null
+++ b/mine/modules/osd/components/animated_scale.py
@@ -0,0 +1,29 @@
+from functools import partial
+
+from fabric.widgets.scale import Scale
+
+from utils.animator import Animator, cubic_bezier
+
+
+class AnimatedScale(Scale):
+ def __init__(self, **kwargs):
+ super().__init__(**kwargs)
+ self.animator = (
+ Animator(
+ duration=0.8,
+ timing_function=partial(cubic_bezier, 0.34, 1.56, 0.64, 1.0),
+ min_value=self.min_value,
+ max_value=self.value,
+ tick_widget=self,
+ notify_value=lambda anim: self.set_value(anim.value),
+ )
+ .build()
+ .play()
+ .unwrap()
+ )
+
+ def animate_value(self, value: float):
+ self.animator.pause()
+ self.animator.min_value = self.value
+ self.animator.max_value = value
+ self.animator.play()
diff --git a/mine/modules/osd/components/audio.py b/mine/modules/osd/components/audio.py
new file mode 100644
index 0000000..9912908
--- /dev/null
+++ b/mine/modules/osd/components/audio.py
@@ -0,0 +1,161 @@
+from fabric.widgets.image import Image
+from fabric.widgets.scale import ScaleMark
+from fabric.widgets.wayland import WaylandWindow as Window
+
+from modules.osd.components.animated_scale import AnimatedScale
+from modules.osd.components.base import BaseOSDContainer
+from services import Audio
+
+
+class AudioOSDContainer(BaseOSDContainer):
+ def __init__(self, window: Window, **kwargs):
+ super().__init__(window, **kwargs)
+ self.audio = Audio(controller_name="fabric-osd")
+ self._last_volume = None
+ self._last_muted = None
+ self._setup_specific_components()
+ self._connect_specific_signals()
+
+ def _setup_specific_components(self):
+ self.icon = Image(icon_name="audio-volume-medium-symbolic", icon_size=26)
+ self.scale = AnimatedScale(
+ marks=(ScaleMark(value=i) for i in range(1, 100, 10)),
+ value=70,
+ min_value=0,
+ max_value=100,
+ inverted=False,
+ increments=(1, 1),
+ orientation=self.get_orientation(),
+ on_state_flags_changed=lambda sc, *_: (
+ self.unfocus() if not (sc.get_state_flags() & 2) else None,
+ ),
+ on_value_changed=lambda *_: self.is_hovered()
+ and self._on_user_volume_change(),
+ )
+ self.children = self.icon, self.scale
+
+ def _connect_specific_signals(self):
+ # Connect to audio service changed signal (catches all audio changes including mute)
+ self.audio.connect("changed", self._on_audio_changed)
+
+ self.audio.connect(
+ "notify::speaker",
+ self._on_speaker_ready,
+ )
+
+ # If speaker already exists, connect now
+ if self.audio.speaker:
+ self._on_speaker_ready()
+
+ def _on_speaker_ready(self, *_):
+ """Called when audio speaker is ready."""
+ speaker = self.audio.speaker
+ if not speaker:
+ return
+
+ # Initialize state
+ self._last_volume = round(speaker.volume)
+ self._last_muted = speaker.muted
+
+ # Connect to speaker signals
+ speaker.connect("notify::volume", self._on_volume_changed)
+ speaker.connect("notify::is-muted", self._on_mute_changed)
+
+ # Initial update
+ self.scale.animate_value(speaker.volume)
+ self._update_volume_display()
+
+ def _on_audio_changed(self, *_):
+ """Handle general audio service changes (includes mute changes)."""
+ if not self.audio.speaker:
+ return
+
+ # Check if mute state changed
+ current_muted = self.audio.speaker.muted
+ if self._last_muted is None or current_muted != self._last_muted:
+ self._last_muted = current_muted
+ self._update_volume_display()
+ # Trigger parent OSD to show
+ if self.osd:
+ self.osd.show_audio_osd()
+
+ def _on_volume_changed(self, speaker, *_):
+ """Handle volume changes from the system."""
+ if not speaker or self.is_hovered():
+ # User is dragging slider, don't update
+ return
+
+ new_volume = round(speaker.volume)
+
+ # Only animate if volume actually changed
+ if self._last_volume is None or abs(new_volume - self._last_volume) > 0:
+ self._last_volume = new_volume
+ self.scale.animate_value(new_volume)
+ self._update_volume_display(new_volume)
+ # Trigger parent OSD to show
+ if self.osd:
+ self.osd.show_audio_osd()
+
+ def _on_mute_changed(self, speaker, *_):
+ """Handle mute state changes."""
+ if not speaker:
+ return
+
+ new_muted = speaker.muted
+
+ # Only update if mute state changed
+ if self._last_muted is None or self._last_muted != new_muted:
+ self._last_muted = new_muted
+ self._update_volume_display()
+ # Trigger parent OSD to show
+ if self.osd:
+ self.osd.show_audio_osd()
+
+ def _on_user_volume_change(self):
+ """Called when user drags the slider."""
+ if not self.audio.speaker or not self.is_hovered():
+ return
+
+ new_volume = self.scale.value
+ self.audio.speaker.set_volume(new_volume)
+ self._last_volume = round(new_volume)
+ self._update_volume_display(new_volume)
+
+ def _update_volume_display(self, volume=None):
+ """Update icon and scale styling based on volume and mute state."""
+ if not self.audio.speaker:
+ return
+
+ current_volume = (
+ round(self.audio.speaker.volume) if volume is None else round(volume)
+ )
+ is_muted = self.audio.speaker.muted
+
+ # Update muted styling
+ if is_muted or current_volume == 0:
+ self.scale.add_style_class("muted")
+ self.icon.set_from_icon_name("audio-volume-muted-symbolic", 26)
+ else:
+ self.scale.remove_style_class("muted")
+ # Update icon based on volume level
+ if current_volume >= 80:
+ icon_name = "audio-volume-high-symbolic"
+ elif current_volume < 50:
+ icon_name = "audio-volume-low-symbolic"
+ else:
+ icon_name = "audio-volume-medium-symbolic"
+
+ self.icon.set_from_icon_name(icon_name, 26)
+
+ def update(self, *_):
+ """Override to update display before showing OSD."""
+ # Update the display with current speaker state
+ if self.audio.speaker:
+ speaker = self.audio.speaker
+ self._last_volume = round(speaker.volume)
+ self._last_muted = speaker.muted
+ self.scale.animate_value(speaker.volume)
+ self._update_volume_display()
+
+ # Call parent's update to show the OSD
+ super().update()
diff --git a/mine/modules/osd/components/base.py b/mine/modules/osd/components/base.py
new file mode 100644
index 0000000..6bb1c16
--- /dev/null
+++ b/mine/modules/osd/components/base.py
@@ -0,0 +1,155 @@
+import contextlib
+from abc import abstractmethod
+
+from fabric.utils import Gdk, GLib, idle_add, invoke_repeater, remove_handler, time
+from fabric.widgets.box import Box
+from fabric.widgets.wayland import WaylandWindow as Window
+
+
+class BaseOSDContainer(Box):
+ MAX_VISIBLE_TIME = 6.0 # Increased for safety: 1.7s focus + 1.7s unfocus + margin
+ COOLDOWN_MS = 200 # Minimum time between OSD triggers (milliseconds)
+
+ def __init__(self, window: Window, **kwargs):
+ super().__init__(**kwargs, spacing=12, name="osd-container")
+ self.last_handler: int = 0
+ self.window = window
+ self.osd = None
+ self.show_timestamp: float = 0.0
+ self.watchdog_handler: int = 0
+ self._update_in_progress = False # Prevent concurrent updates
+ self._is_hovered = False
+ self._last_trigger_time: float = 0.0 # Track last trigger time for cooldown
+
+ # Defer signal connection until window is initialized
+ idle_add(self._setup_window_signals)
+
+ def _setup_window_signals(self):
+ self.window.add_events(
+ Gdk.EventMask.ENTER_NOTIFY_MASK | Gdk.EventMask.LEAVE_NOTIFY_MASK
+ )
+ self.window.connect("enter-notify-event", self._on_enter_notify)
+ self.window.connect("leave-notify-event", self._on_leave_notify)
+ return False
+
+ def _on_enter_notify(self, widget, event):
+ if event.detail != Gdk.NotifyType.INFERIOR:
+ self._is_hovered = True
+
+ def _on_leave_notify(self, widget, event):
+ if event.detail != Gdk.NotifyType.INFERIOR:
+ self._is_hovered = False
+
+ def is_hovered(self):
+ return self._is_hovered
+
+ def remove_last_handler(self):
+ if self.last_handler:
+ with contextlib.suppress(Exception):
+ remove_handler(self.last_handler)
+ self.last_handler = 0
+
+ def remove_watchdog_handler(self):
+ if self.watchdog_handler:
+ with contextlib.suppress(Exception):
+ GLib.source_remove(self.watchdog_handler)
+ self.watchdog_handler = 0
+
+ def cleanup_all_handlers(self):
+ """Clean up all handlers and reset state."""
+ self.remove_last_handler()
+ self.remove_watchdog_handler()
+ self.show_timestamp = 0.0
+ self._update_in_progress = False
+
+ def watchdog_force_hide(self, *_):
+ """Failsafe timer to force-hide OSD if normal sequence fails."""
+ if not self.window.get_visible():
+ self.cleanup_all_handlers()
+ return False
+
+ # Check if we've exceeded maximum visible time
+ elapsed = time.time() - self.show_timestamp
+ if elapsed >= self.MAX_VISIBLE_TIME:
+ if not self.is_hovered():
+ self.window.hide()
+ self.cleanup_all_handlers()
+ return False
+ else:
+ # Still hovered, check again in 1 second
+ return True
+
+ return True # Keep checking
+
+ def update(self, *_):
+ # Prevent concurrent updates
+ if self._update_in_progress:
+ return
+
+ # Check cooldown to prevent spam
+ current_time = time.time()
+ time_since_last_trigger = (
+ current_time - self._last_trigger_time
+ ) * 1000 # Convert to ms
+
+ if time_since_last_trigger < self.COOLDOWN_MS:
+ return # Still in cooldown period, ignore this trigger
+
+ self._last_trigger_time = current_time
+ self._update_in_progress = True
+
+ # Clean up any existing timers
+ self.cleanup_all_handlers()
+
+ # Record show time and display window
+ self.show_timestamp = time.time()
+ self.window.show()
+
+ # Start animation sequence
+ self.focus()
+
+ # Schedule unfocus after focus animation completes
+ self.last_handler = invoke_repeater(1700, self.unfocus, initial_call=False)
+
+ # Start watchdog timer (checks every second)
+ self.watchdog_handler = GLib.timeout_add(1000, self.watchdog_force_hide)
+
+ self._update_in_progress = False
+
+ def focus(self, *_):
+ self.style_classes = "focused"
+ return False
+
+ def unfocus(self, *_):
+ if not self.window.get_visible():
+ self.cleanup_all_handlers()
+ return False
+
+ self.style_classes = ()
+ self.remove_last_handler()
+
+ # Schedule hide after unfocus animation
+ self.last_handler = invoke_repeater(1700, self.unpop, initial_call=False)
+ return False
+
+ def unpop(self, *_):
+ if not self.window.get_visible():
+ self.cleanup_all_handlers()
+ return False
+
+ if not self.is_hovered():
+ self.window.hide()
+ self.cleanup_all_handlers()
+ else:
+ # User is hovering, check again in 500ms
+ self.last_handler = invoke_repeater(500, self.unpop, initial_call=False)
+
+ return False
+
+ @abstractmethod
+ def _setup_specific_components(self):
+ pass
+
+ @abstractmethod
+ def _connect_specific_signals(self):
+ pass
diff --git a/mine/modules/osd/components/battery.py b/mine/modules/osd/components/battery.py
new file mode 100644
index 0000000..f1d99e4
--- /dev/null
+++ b/mine/modules/osd/components/battery.py
@@ -0,0 +1,127 @@
+from fabric.utils import GLib
+from fabric.widgets.image import Image
+from fabric.widgets.label import Label
+from fabric.widgets.wayland import WaylandWindow as Window
+
+from modules.osd.components.base import BaseOSDContainer
+from services.battery import BatteryService
+
+
+class BatteryOSDContainer(BaseOSDContainer):
+ def __init__(self, window: Window, **kwargs):
+ super().__init__(window, **kwargs)
+ self.battery = BatteryService()
+ self._initialized = False # Track if initial setup is complete
+ self._last_state = None
+ self._last_percentage = None
+ self._setup_specific_components()
+ self._connect_specific_signals()
+
+ def _setup_specific_components(self):
+ self.icon = Image(icon_name="battery-symbolic", icon_size=26)
+ self.label = Label()
+
+ # Defer initialization to allow services to initialize
+ GLib.timeout_add(100, self._initialize_state)
+
+ self.children = self.icon, self.label
+
+ def _connect_specific_signals(self):
+ self.battery.connect("changed", self._on_battery_changed)
+
+ def _initialize_state(self):
+ """Initialize the battery state after component setup."""
+ self._last_state = self.battery.get_property("State")
+ self._last_percentage = self.battery.get_property("Percentage")
+ self._update_battery_display()
+ # Mark as initialized after initial state is set
+ GLib.timeout_add(500, self._mark_initialized)
+ return False
+
+ def _mark_initialized(self):
+ """Mark the component as fully initialized."""
+ self._initialized = True
+ return False
+
+ def _on_battery_changed(self, *args):
+ """Handle battery state changes."""
+ if not self._initialized:
+ return
+
+ if not self.is_hovered():
+ current_state = self.battery.get_property("State")
+ current_percentage = self.battery.get_property("Percentage")
+
+ # Only show OSD for meaningful changes
+ should_show = False
+
+ # State changes (charging/discharging/full)
+ if current_state != self._last_state:
+ should_show = True
+
+ # Battery level warnings (crossing thresholds)
+ elif current_percentage is not None and self._last_percentage is not None:
+ # Show on low battery (crossing 20% threshold going down)
+ if (
+ self._last_percentage > 20
+ and current_percentage <= 20
+ or self._last_percentage > 98
+ and current_percentage <= 10
+ or self._last_percentage < 100
+ and current_percentage >= 100
+ ):
+ should_show = True
+
+ self._last_state = current_state
+ self._last_percentage = current_percentage
+
+ if should_show:
+ self._update_battery_display()
+ self.show_battery_osd()
+
+ def _update_battery_display(self):
+ """Update display based on current battery state."""
+ state = self.battery.get_property("State")
+ percentage = self.battery.get_property("Percentage")
+ icon_name = self.battery.get_property("IconName")
+
+ if percentage is None:
+ percentage = 0
+
+ # Determine icon
+ if icon_name:
+ self.update_icon(icon_name)
+ else:
+ self.update_icon("battery-symbolic")
+
+ # Format the label
+ if state == 4: # Fully charged
+ self.update_label(f"Battery Full ({percentage:.0f}%)")
+ elif state == 1: # Charging
+ self.update_label(f"Charging ({percentage:.0f}%)")
+ elif percentage <= 10:
+ self.update_label(f"Battery Critical ({percentage:.0f}%)")
+ elif percentage <= 20:
+ self.update_label(f"Battery Low ({percentage:.0f}%)")
+ else:
+ self.update_label(f"Battery: {percentage:.0f}%")
+
+ def update_icon(self, icon_name: str):
+ """Update the battery icon."""
+ self.icon.set_from_icon_name(icon_name, 26)
+
+ def update_label(self, text: str):
+ """Update the battery label."""
+ self.label.set_text(text)
+
+ def show_battery_osd(self):
+ """Trigger the OSD to show."""
+ if self.osd:
+ self.osd.show_battery_osd()
+ else:
+ self.update()
+
+ def update(self, *_):
+ """Override to update display before showing OSD."""
+ self._update_battery_display()
+ super().update()
diff --git a/mine/modules/osd/components/brightness.py b/mine/modules/osd/components/brightness.py
new file mode 100644
index 0000000..c382dae
--- /dev/null
+++ b/mine/modules/osd/components/brightness.py
@@ -0,0 +1,107 @@
+from fabric.widgets.image import Image
+from fabric.widgets.scale import ScaleMark
+from fabric.widgets.wayland import WaylandWindow as Window
+
+from modules.osd.components.animated_scale import AnimatedScale
+from modules.osd.components.base import BaseOSDContainer
+from services import Brightness
+
+
+class BrightnessOSDContainer(BaseOSDContainer):
+ def __init__(self, window: Window, **kwargs):
+ super().__init__(window, **kwargs)
+ self.brightness = Brightness.get_initial()
+ self._last_brightness = None # Track to avoid duplicate updates
+ self._setup_specific_components()
+ self._connect_specific_signals()
+
+ def _setup_specific_components(self):
+ self.icon = Image(icon_name="display-brightness-symbolic", icon_size=26)
+
+ current_brightness = self.brightness.screen_brightness
+ brightness_percentage = (
+ int((current_brightness / self.brightness.max_screen) * 100)
+ if self.brightness.max_screen > 0
+ else 50
+ )
+
+ self._last_brightness = brightness_percentage
+
+ self.scale = AnimatedScale(
+ marks=(ScaleMark(value=i) for i in range(1, 100, 10)),
+ value=brightness_percentage,
+ min_value=0,
+ max_value=100,
+ inverted=False,
+ increments=(1, 1),
+ orientation=self.get_orientation(),
+ on_state_flags_changed=lambda sc, *_: (
+ self.unfocus() if not (sc.get_state_flags() & 2) else None
+ ),
+ on_value_changed=lambda *_: self.is_hovered() and self._update_brightness(),
+ )
+ self.children = self.icon, self.scale
+
+ def _connect_specific_signals(self):
+ self.brightness.connect(
+ "brightness_changed",
+ self._on_brightness_changed,
+ )
+
+ def _on_brightness_changed(self, _, value):
+ """Handle brightness changes from the service."""
+ if self.is_hovered():
+ # User is interacting with the slider, don't update
+ return
+
+ brightness_percentage = int((value / self.brightness.max_screen) * 100)
+
+ # Only update if brightness actually changed
+ if (
+ self._last_brightness is None
+ or abs(brightness_percentage - self._last_brightness) > 0
+ ):
+ self._last_brightness = brightness_percentage
+ self._update_icon(brightness_percentage)
+ self.scale.animate_value(brightness_percentage)
+ self.show_brightness_osd()
+
+ def show_brightness_osd(self):
+ """Trigger the OSD to show the brightness container."""
+ if self.osd:
+ self.osd.show_brightness_osd()
+ else:
+ self.update()
+
+ def _update_icon(self, brightness_percentage: int):
+ """Update icon based on brightness level."""
+ self.icon.set_from_icon_name(
+ "display-brightness-high-symbolic"
+ if brightness_percentage >= 80
+ else "display-brightness-low-symbolic"
+ if brightness_percentage < 50
+ else "display-brightness-medium-symbolic",
+ 26,
+ )
+
+ def _update_brightness(self):
+ """Update brightness when user drags the slider."""
+ if self.is_hovered():
+ new_brightness = int((self.scale.value / 100) * self.brightness.max_screen)
+ self.brightness.screen_brightness = new_brightness
+ self._last_brightness = int(self.scale.value)
+ self._update_icon(int(self.scale.value))
+
+ def update(self, *_):
+ """Override to update display before showing OSD."""
+ # Update the display before showing
+ if self.brightness.screen_brightness is not None:
+ brightness_percentage = int(
+ (self.brightness.screen_brightness / self.brightness.max_screen) * 100
+ )
+ self._last_brightness = brightness_percentage
+ self.scale.animate_value(brightness_percentage)
+ self._update_icon(brightness_percentage)
+
+ # Call parent's update to show the OSD
+ super().update()
diff --git a/mine/modules/osd/components/capslock.py b/mine/modules/osd/components/capslock.py
new file mode 100644
index 0000000..c0ec76e
--- /dev/null
+++ b/mine/modules/osd/components/capslock.py
@@ -0,0 +1,65 @@
+from fabric.utils import GLib
+from fabric.widgets.image import Image
+from fabric.widgets.label import Label
+from fabric.widgets.wayland import WaylandWindow as Window
+
+from modules.osd.components.base import BaseOSDContainer
+from services import CapsLock
+
+
+class CapsLockOSDContainer(BaseOSDContainer):
+ def __init__(self, window: Window, **kwargs):
+ super().__init__(window, **kwargs)
+ self.capslock = CapsLock.get_initial()
+ self._setup_specific_components()
+ self._connect_specific_signals()
+
+ def _setup_specific_components(self):
+ self.icon = Image(icon_name="capslock-disabled-symbolic", icon_size=26)
+ self.label = Label()
+
+ self.update_icon(False)
+ self.update_label(False)
+ self.children = self.icon, self.label
+
+ def _connect_specific_signals(self):
+ self.capslock.connect("state_changed", self._on_caps_lock_state_changed)
+ GLib.timeout_add(100, self._initialize_state)
+
+ def _initialize_state(self):
+ """Initialize the caps lock state after component setup."""
+ is_caps_on = self.capslock.is_on
+ self.update_icon(is_caps_on)
+ self.update_label(is_caps_on)
+ return False
+
+ def _on_caps_lock_state_changed(self, _, is_on: bool):
+ """Handle CapsLock state changes."""
+ if not self.is_hovered():
+ self.update_icon(is_on)
+ self.update_label(is_on)
+ self.show_capslock_osd()
+
+ def show_capslock_osd(self):
+ """Trigger the OSD to show the CapsLock container."""
+ if self.osd:
+ self.osd.show_capslock_osd()
+ else:
+ self.update()
+
+ def update_icon(self, is_on: bool):
+ self.icon.set_from_icon_name(
+ "capslock-enabled-symbolic" if is_on else "capslock-disabled-symbolic",
+ 26,
+ )
+ if is_on:
+ self.icon.style_classes = "capslock-on"
+ else:
+ self.icon.style_classes = "capslock-off"
+
+ def update_label(self, is_on: bool):
+ self.label.set_text("CapsLock ON" if is_on else "CapsLock OFF")
+ if is_on:
+ self.label.style_classes = "capslock-on"
+ else:
+ self.label.style_classes = "capslock-off"
diff --git a/mine/modules/osd/components/keyboard_layout.py b/mine/modules/osd/components/keyboard_layout.py
new file mode 100644
index 0000000..37ef3c0
--- /dev/null
+++ b/mine/modules/osd/components/keyboard_layout.py
@@ -0,0 +1,52 @@
+from fabric.widgets.image import Image
+from fabric.widgets.label import Label
+from fabric.widgets.wayland import WaylandWindow as Window
+
+from modules.osd.components.base import BaseOSDContainer
+from services import KeyboardLayout
+
+
+class KeyboardLayoutOSDContainer(BaseOSDContainer):
+ def __init__(self, window: Window, **kwargs):
+ super().__init__(window, **kwargs)
+ self.icon = Image(icon_name="input-keyboard-symbolic", icon_size=26)
+ self.label = Label()
+
+ self.keyboard_layout = KeyboardLayout.get_initial()
+
+ self._setup_specific_components()
+ self._connect_specific_signals()
+
+ def _setup_specific_components(self):
+ # Get initial layout from service
+ layout = self.keyboard_layout.current_layout
+ self.label.set_text(layout)
+ self.children = self.icon, self.label
+
+ def _connect_specific_signals(self):
+ # Connect to layout change signal from service
+ self.keyboard_layout.connect("layout_changed", self._on_layout_changed)
+
+ def _on_layout_changed(self, service, layout: str):
+ """Handle layout changes from the service."""
+ if not self.is_hovered():
+ current_text = self.label.get_text()
+ if current_text != layout:
+ self.label.set_text(layout)
+ self.show_kb_layout_osd()
+
+ def show_kb_layout_osd(self):
+ """Trigger the OSD to show the keyboard layout container."""
+ if self.osd:
+ self.osd.show_kb_layout_osd()
+ else:
+ self.update()
+
+ def update(self, *_):
+ """Override to update display before showing OSD."""
+ # Update the display with current layout
+ layout = self.keyboard_layout.current_layout
+ self.label.set_text(layout)
+
+ # Call parent's update to show the OSD
+ super().update()
diff --git a/mine/modules/osd/components/microphone.py b/mine/modules/osd/components/microphone.py
new file mode 100644
index 0000000..870eaba
--- /dev/null
+++ b/mine/modules/osd/components/microphone.py
@@ -0,0 +1,116 @@
+from fabric.utils import GLib
+from fabric.widgets.image import Image
+from fabric.widgets.label import Label
+from fabric.widgets.wayland import WaylandWindow as Window
+
+from modules.osd.components.base import BaseOSDContainer
+from services import Audio
+
+
+class MicrophoneOSDContainer(BaseOSDContainer):
+ def __init__(self, window: Window, **kwargs):
+ super().__init__(window, **kwargs)
+ self.audio = Audio(controller_name="fabric-osd-microphone")
+ self._last_muted = None
+ self._connected_mics = set()
+ self._setup_specific_components()
+ self._connect_specific_signals()
+
+ def _setup_specific_components(self):
+ self.icon = Image(
+ icon_name="microphone-sensitivity-muted-symbolic", icon_size=26
+ )
+ self.label = Label()
+
+ self.update_icon(True) # Start with muted state
+ self.update_label(True)
+ self.children = self.icon, self.label
+
+ def _connect_specific_signals(self):
+ # Connect to audio service changed signal
+ self.audio.connect("changed", self._on_audio_changed)
+
+ # Connect to microphones list changes
+ self.audio.connect("notify::microphones", self._on_microphones_ready)
+
+ # Connect to default microphone changes
+ self.audio.connect("notify::microphone", self._on_default_microphone_changed)
+
+ # Initial connection to all existing microphones
+ self._on_microphones_ready()
+
+ # Defer initialization to allow services to initialize
+ GLib.timeout_add(100, self._initialize_state)
+
+ def _initialize_state(self):
+ """Initialize the microphone mute state after component setup."""
+ mic = self.audio.microphone
+ if mic:
+ is_muted = mic.muted
+ self._last_muted = is_muted
+ self.update_icon(is_muted)
+ self.update_label(is_muted)
+ return False
+
+ def _on_microphones_ready(self, *_):
+ """Called when audio microphones list changes."""
+ for mic in self.audio.microphones:
+ if mic not in self._connected_mics:
+ mic.connect("notify::is-muted", self._on_mute_changed)
+ self._connected_mics.add(mic)
+
+ def _on_default_microphone_changed(self, *_):
+ """Handle default microphone changes."""
+ mic = self.audio.microphone
+ if mic:
+ self._on_mute_changed(mic)
+
+ def _on_audio_changed(self, *_):
+ """Handle general audio service changes."""
+ mic = self.audio.microphone
+ if mic:
+ self._on_mute_changed(mic)
+
+ def _on_mute_changed(self, microphone, *_):
+ """Handle microphone mute changes for any microphone."""
+ is_muted = microphone.muted
+
+ # Only update and show if mute state actually changed
+ if self._last_muted is None or is_muted != self._last_muted:
+ self._last_muted = is_muted
+ self.update_icon(is_muted)
+ self.update_label(is_muted)
+ self.show_microphone_osd()
+
+ def update_icon(self, is_muted: bool):
+ """Update icon based on mute state."""
+ icon_name = (
+ "microphone-sensitivity-muted-symbolic"
+ if is_muted
+ else "microphone-sensitivity-high-symbolic"
+ )
+ self.icon.set_from_icon_name(icon_name, 26)
+
+ # Add style classes for styling
+ self.icon.style_classes = "mic-muted" if is_muted else "mic-unmuted"
+
+ def update_label(self, is_muted: bool):
+ """Update label based on mute state."""
+ self.label.set_text("Microphone MUTED" if is_muted else "Microphone ON")
+ self.label.style_classes = "mic-muted" if is_muted else "mic-unmuted"
+
+ def show_microphone_osd(self):
+ """Trigger the OSD to show."""
+ if self.osd:
+ self.osd.show_microphone_osd()
+ else:
+ self.update()
+
+ def update(self, *_):
+ """Override to update display before showing OSD."""
+ mic = self.audio.microphone
+ if mic:
+ self._last_muted = mic.muted
+ self.update_icon(mic.muted)
+ self.update_label(mic.muted)
+ super().update()
diff --git a/mine/modules/osd/components/network.py b/mine/modules/osd/components/network.py
new file mode 100644
index 0000000..1a91eb9
--- /dev/null
+++ b/mine/modules/osd/components/network.py
@@ -0,0 +1,159 @@
+from fabric.utils import GLib
+from fabric.widgets.image import Image
+from fabric.widgets.label import Label
+from fabric.widgets.wayland import WaylandWindow as Window
+
+from modules.osd.components.base import BaseOSDContainer
+from services import NetworkClient
+
+
+class NetworkOSDContainer(BaseOSDContainer):
+ def __init__(self, window: Window, **kwargs):
+ super().__init__(window, **kwargs)
+ self.network = NetworkClient()
+ self._initialized = False # Track if initial setup is complete
+ self._setup_specific_components()
+ self._connect_specific_signals()
+
+ def _setup_specific_components(self):
+ self.icon = Image(icon_name="network-wired-symbolic", icon_size=26)
+ self.label = Label()
+
+ # Defer initialization to allow services to initialize
+ GLib.timeout_add(100, self._initialize_state)
+
+ self.children = self.icon, self.label
+
+ def _connect_specific_signals(self):
+ # Connect to WiFi device signals
+ self.network.connect("wifi_device_added", self._on_wifi_device_added)
+ self.network.connect("wifi_device_removed", self._on_wifi_device_removed)
+
+ # Connect to Ethernet device signals
+ self.network.connect("ethernet_device_added", self._on_ethernet_device_added)
+ self.network.connect(
+ "ethernet_device_removed", self._on_ethernet_device_removed
+ )
+
+ # Connect to network state changes
+ self.network.connect("notify::state", self._on_network_state_changed)
+
+ def _initialize_state(self):
+ """Initialize the network state after component setup."""
+ self._update_network_display()
+ # Mark as initialized after initial state is set
+ GLib.timeout_add(500, self._mark_initialized)
+ return False
+
+ def _mark_initialized(self):
+ """Mark the component as fully initialized."""
+ self._initialized = True
+ return False
+
+ def _on_wifi_device_added(self, *args):
+ """Handle WiFi device added."""
+ if self.network.wifi_device:
+ # Connect to WiFi-specific signals
+ self.network.wifi_device.connect(
+ "notify::active-access-point", self._on_wifi_connection_changed
+ )
+ # Only show OSD if we're already initialized (not first setup)
+ if self._initialized:
+ self._on_wifi_connection_changed()
+
+ def _on_wifi_device_removed(self, *args):
+ """Handle WiFi device removed."""
+ if not self._initialized:
+ return
+ self.update_icon("network-wired-disconnected-symbolic")
+ self.update_label("WiFi Disconnected")
+ self.show_network_osd()
+
+ def _on_ethernet_device_added(self, *args):
+ """Handle Ethernet device added."""
+ if self.network.ethernet_device:
+ # Connect to Ethernet-specific signals
+ self.network.ethernet_device.connect("changed", self._on_ethernet_changed)
+ # Only show OSD if we're already initialized (not first setup)
+ if self._initialized:
+ self._on_ethernet_changed()
+
+ def _on_ethernet_device_removed(self, *args):
+ """Handle Ethernet device removed."""
+ if not self._initialized:
+ return
+ self.update_icon("network-wired-disconnected-symbolic")
+ self.update_label("Ethernet Disconnected")
+ self.show_network_osd()
+
+ def _on_wifi_connection_changed(self, *args):
+ """Handle WiFi connection changes."""
+ if not self._initialized:
+ return
+ if not self.is_hovered():
+ self._update_network_display()
+ self.show_network_osd()
+
+ def _on_ethernet_changed(self, *args):
+ """Handle Ethernet state changes."""
+ if not self._initialized:
+ return
+ if not self.is_hovered():
+ self._update_network_display()
+ self.show_network_osd()
+
+ def _on_network_state_changed(self, *args):
+ """Handle network state changes."""
+ if not self._initialized:
+ return
+ if not self.is_hovered():
+ self._update_network_display()
+ self.show_network_osd()
+
+ def _update_network_display(self):
+ """Update display based on current network state."""
+ wifi = self.network.wifi_device
+ ethernet = self.network.ethernet_device
+
+ # Priority: WiFi active > Ethernet active > Disconnected
+ if wifi and wifi.active_access_point:
+ # WiFi is connected
+ ssid = wifi.active_access_point.ssid
+ icon = wifi.active_access_point.icon
+ self.update_icon(icon)
+ self.update_label(f"WiFi: {ssid}")
+
+ elif ethernet and ethernet.internet == "activated":
+ # Ethernet is connected
+ self.update_icon("network-wired-symbolic")
+ self.update_label("Wired Connection")
+
+ elif ethernet and ethernet.internet == "activating":
+ # Ethernet is connecting
+ self.update_icon("network-wired-acquiring-symbolic")
+ self.update_label("Connecting...")
+
+ else:
+ # No active connection
+ self.update_icon("network-wired-disconnected-symbolic")
+ self.update_label("Disconnected")
+
+ def update_icon(self, icon_name: str):
+ """Update the network icon."""
+ self.icon.set_from_icon_name(icon_name, 26)
+
+ def update_label(self, text: str):
+ """Update the network label."""
+ self.label.set_text(text)
+
+ def show_network_osd(self):
+ """Trigger the OSD to show."""
+ if self.osd:
+ self.osd.show_network_osd()
+ else:
+ self.update()
+
+ def update(self, *_):
+ """Override to update display before showing OSD."""
+ self._update_network_display()
+ super().update()
diff --git a/mine/modules/osd/main.py b/mine/modules/osd/main.py
new file mode 100644
index 0000000..38dcef9
--- /dev/null
+++ b/mine/modules/osd/main.py
@@ -0,0 +1,255 @@
+from fabric.widgets.box import Box
+from fabric.widgets.wayland import WaylandWindow as Window
+
+import config.data as data
+from modules.osd.components import (
+ AudioOSDContainer,
+ BatteryOSDContainer,
+ BrightnessOSDContainer,
+ CapsLockOSDContainer,
+ KeyboardLayoutOSDContainer,
+ MicrophoneOSDContainer,
+ NetworkOSDContainer,
+)
+from services import has_config_changed, on_config_change
+
+
+class OSD(Box):
+ def __init__(self, window: Window, **kwargs):
+ super().__init__(orientation="h", name="osd", **kwargs)
+
+ self.window = window
+ self._load_config()
+
+ # Container dictionary for easy access
+ self.containers = {}
+ self.current_container = None
+
+ self._create_containers()
+ self._setup_initial_container()
+ self._connect_signals()
+
+ on_config_change(self._on_config_change)
+
+ def _create_containers(self):
+ if self._audio_enabled:
+ container = AudioOSDContainer(orientation="h", window=self.window)
+ container.osd = self
+ container.COOLDOWN_MS = self._cooldown_ms
+ self.containers["audio"] = container
+
+ if self._brightness_enabled:
+ container = BrightnessOSDContainer(orientation="h", window=self.window)
+ container.osd = self
+ container.COOLDOWN_MS = self._cooldown_ms
+ self.containers["brightness"] = container
+
+ if self._capslock_enabled:
+ container = CapsLockOSDContainer(orientation="h", window=self.window)
+ container.osd = self
+ container.COOLDOWN_MS = self._cooldown_ms
+ self.containers["capslock"] = container
+
+ if getattr(self, "_kb_layout_enabled", False):
+ container = KeyboardLayoutOSDContainer(orientation="h", window=self.window)
+ container.osd = self
+ container.COOLDOWN_MS = self._cooldown_ms
+ self.containers["kb_layout"] = container
+
+ if getattr(self, "_network_enabled", False):
+ container = NetworkOSDContainer(orientation="h", window=self.window)
+ container.osd = self
+ container.COOLDOWN_MS = self._cooldown_ms
+ self.containers["network"] = container
+
+ if getattr(self, "_microphone_enabled", False):
+ container = MicrophoneOSDContainer(orientation="h", window=self.window)
+ container.osd = self
+ container.COOLDOWN_MS = self._cooldown_ms
+ self.containers["microphone"] = container
+
+ if getattr(self, "_battery_enabled", False):
+ container = BatteryOSDContainer(orientation="h", window=self.window)
+ container.osd = self
+ container.COOLDOWN_MS = self._cooldown_ms
+ self.containers["battery"] = container
+
+ def _setup_initial_container(self):
+ self.current_container = None
+ self.children = []
+
+ def _connect_signals(self):
+ # All OSD containers (audio, brightness, capslock, keyboard_layout,
+ # network, microphone) handle their signal connections internally
+ # and call their respective show_*_osd() methods when needed.
+ # No external signal connections are needed here.
+ pass
+
+ def _load_config(self):
+ config = data.DATA()
+ self._osd_enabled = config.get("osd")
+ self._audio_enabled = config.get("osd_audio")
+ self._brightness_enabled = config.get("osd_brightness")
+ self._capslock_enabled = config.get("osd_capslock")
+ self._kb_layout_enabled = config.get("osd_kb_layout")
+ self._network_enabled = config.get("osd_network")
+ self._microphone_enabled = config.get("osd_microphone")
+ self._battery_enabled = config.get("osd_battery")
+ self._cooldown_ms = config.get("osd_cooldown_ms")
+
+ def _on_config_change(self, new_config, old_config):
+ paths = [
+ "osd",
+ "osd_audio",
+ "osd_brightness",
+ "osd_capslock",
+ "osd_kb_layout",
+ "osd_network",
+ "osd_microphone",
+ "osd_battery",
+ "osd_cooldown_ms",
+ "osd_position",
+ ]
+
+ if any(has_config_changed(old_config, new_config, p) for p in paths):
+ old_osd_enabled = self._osd_enabled
+ old_audio_enabled = self._audio_enabled
+ old_brightness_enabled = self._brightness_enabled
+ old_capslock_enabled = self._capslock_enabled
+ old_kb_layout_enabled = getattr(self, "_kb_layout_enabled", False)
+ old_network_enabled = getattr(self, "_network_enabled", False)
+ old_microphone_enabled = getattr(self, "_microphone_enabled", False)
+ old_battery_enabled = getattr(self, "_battery_enabled", False)
+ old_osd_position = getattr(self.window, "_current_osd_position", None)
+
+ self._load_config()
+
+ # Get current OSD position from config
+ config = data.DATA()
+ current_osd_position = config.get("osd_position") or "bottom"
+
+ # Handle OSD position change by updating window properties
+ if (
+ old_osd_position is not None
+ and old_osd_position != current_osd_position
+ ):
+ self.window._update_osd_position(current_osd_position)
+
+ if old_osd_enabled != self._osd_enabled:
+ self.window.set_visible(self._osd_enabled)
+
+ if not self._osd_enabled:
+ self.window.hide()
+ return
+
+ if (
+ old_audio_enabled != self._audio_enabled
+ or old_brightness_enabled != self._brightness_enabled
+ or old_capslock_enabled != self._capslock_enabled
+ or old_kb_layout_enabled != self._kb_layout_enabled
+ or old_network_enabled != self._network_enabled
+ or old_microphone_enabled != self._microphone_enabled
+ or old_battery_enabled != self._battery_enabled
+ ):
+ # Clean up old containers
+ for container in self.containers.values():
+ container.cleanup_all_handlers()
+
+ self.containers.clear()
+
+ self._create_containers()
+ self._setup_initial_container()
+ self._connect_signals()
+
+ def _show_osd(self, container_type: str, enabled_flag: bool):
+ container = self.containers.get(container_type)
+ if not self._osd_enabled or not enabled_flag or not container:
+ return
+
+ if self.current_container != container_type:
+ # Clean up previous container if it exists
+ if self.current_container:
+ prev_container = self.containers.get(self.current_container)
+ if prev_container:
+ prev_container.cleanup_all_handlers()
+ prev_container.window.hide()
+
+ self.current_container = container_type
+ self.children = container
+ container.update()
+
+ def show_audio_osd(self):
+ self._show_osd("audio", self._audio_enabled)
+
+ def show_brightness_osd(self):
+ self._show_osd("brightness", self._brightness_enabled)
+
+ def show_capslock_osd(self):
+ self._show_osd("capslock", self._capslock_enabled)
+
+ def show_kb_layout_osd(self):
+ self._show_osd("kb_layout", getattr(self, "_kb_layout_enabled", False))
+
+ def show_network_osd(self):
+ self._show_osd("network", getattr(self, "_network_enabled", False))
+
+ def show_microphone_osd(self):
+ self._show_osd("microphone", self._microphone_enabled)
+
+ def show_battery_osd(self):
+ self._show_osd("battery", getattr(self, "_battery_enabled", False))
+
+ def toggle(self):
+ if not self._osd_enabled:
+ return
+
+ if self.current_container:
+ current_container = self.containers.get(self.current_container)
+ if current_container:
+ current_container.update()
+
+
+class OSDWindow(Window):
+ def __init__(self, **kwargs):
+ # Load position config
+ config = data.DATA()
+ position = config.get("osd_position") or "bottom"
+
+ # Set margin based on position
+ if position == "top":
+ margin = "40px 0px 0px 0px"
+ anchor = "top"
+ else: # bottom (default)
+ margin = "0px 0px 40px 0px"
+ anchor = "bottom"
+
+ super().__init__(
+ name="osd-window",
+ title="fabric-osd",
+ anchor=anchor,
+ margin=margin,
+ child=OSD(window=self),
+ visible=False,
+ all_visible=False,
+ **kwargs,
+ )
+
+ # Store current position for change detection
+ self._current_osd_position = position
+
+ self.build()
+
+ def _update_osd_position(self, position):
+ """Update OSD window position based on config change"""
+ # Set margin based on position
+ if position == "top":
+ margin = "40px 0px 0px 0px"
+ anchor = "top"
+ else: # bottom (default)
+ margin = "0px 0px 40px 0px"
+ anchor = "bottom"
+
+ # Update window properties
+ self.set_anchor(anchor)
+ self.set_margin(margin)
+ self._current_osd_position = position
diff --git a/mine/modules/panel/components/__init__.py b/mine/modules/panel/components/__init__.py
new file mode 100644
index 0000000..ee779f3
--- /dev/null
+++ b/mine/modules/panel/components/__init__.py
@@ -0,0 +1,23 @@
+"""
+Modus Panel modules package.
+Contains UI components and functionality modules
+"""
+
+import importlib
+import inspect
+import pkgutil
+
+__all__ = []
+
+for module_info in pkgutil.iter_modules(__path__):
+ module_name = module_info.name
+ module = importlib.import_module(f"{__name__}.{module_name}")
+
+ for attr_name in dir(module):
+ if not attr_name.startswith("_"):
+ attr_value = getattr(module, attr_name)
+ if (
+ inspect.isclass(attr_value) or inspect.isfunction(attr_value)
+ ) and inspect.getmodule(attr_value) is module:
+ globals()[attr_name] = attr_value
+ __all__.append(attr_name)
diff --git a/mine/modules/panel/components/battery.py b/mine/modules/panel/components/battery.py
new file mode 100644
index 0000000..e553a13
--- /dev/null
+++ b/mine/modules/panel/components/battery.py
@@ -0,0 +1,82 @@
+from fabric.widgets.box import Box
+from fabric.widgets.image import Image
+from fabric.widgets.label import Label
+
+from config.data import DATA
+from services import BatteryService, DeviceState, has_config_changed, on_config_change
+
+
+class Battery(Box):
+ def __init__(self, **kwargs):
+ self._load_config()
+ self._icon = Image(icon_name="battery-100", icon_size=self._icon_size)
+ self._label_widget = Label(label="")
+ self._label_widget.set_no_show_all(True)
+
+ super().__init__(
+ name="battery",
+ orientation="h",
+ spacing=4,
+ children=[self._icon, self._label_widget],
+ **kwargs,
+ )
+
+ if not self._show_label:
+ self._label_widget.hide()
+
+ self._service = BatteryService()
+ self._service.connect("changed", lambda *_: self._update())
+ on_config_change(self._on_config_change)
+
+ self._update()
+
+ def _load_config(self):
+ components = DATA()
+ self._show_label = components["battery_label"]
+ self._hide_on_full = components["battery_hide_on_full"]
+ self._icon_size = components["battery_icon_size"]
+
+ def _update(self):
+ percentage = self._service.get_property("Percentage") or 0
+ state = self._service.get_property("State") or 0
+
+ is_full = int(percentage) >= 100 or DeviceState.get(state) in ("FULLY_CHARGED",)
+ self.set_visible(not (self._hide_on_full and is_full))
+
+ icon_name = self._resolve_icon_name(percentage, state)
+ self._icon.set_from_icon_name(icon_name, self._icon_size)
+
+ self._label_widget.set_visible(self._show_label)
+ if self._show_label:
+ self._label_widget.set_label(f"{int(percentage)}%")
+
+ def _on_config_change(self, new_config, old_config):
+ paths = [
+ "panel.battery.enabled",
+ "panel.battery.label",
+ "panel.battery.hide_on_full",
+ "panel.battery.icon_size",
+ ]
+ if any(has_config_changed(old_config, new_config, p) for p in paths):
+ old_show_label = self._show_label
+ old_hide_on_full = self._hide_on_full
+ old_icon_size = self._icon_size
+ self._load_config()
+ if (
+ old_show_label != self._show_label
+ or old_hide_on_full != self._hide_on_full
+ or old_icon_size != self._icon_size
+ ):
+ self._update()
+
+ def _resolve_icon_name(self, percentage, state) -> str:
+ pct = int(percentage)
+ pct = max(0, min(100, pct))
+
+ bucket = (pct // 10) * 10
+ base = f"battery-{bucket:03d}"
+
+ if state in {1, 4, 5}: # CHARGING, FULLY_CHARGED, PENDING_CHARGE
+ return f"{base}-charging"
+
+ return f"{base}"
diff --git a/mine/modules/panel/components/enhanced_system_tray.py b/mine/modules/panel/components/enhanced_system_tray.py
new file mode 100644
index 0000000..b6651c9
--- /dev/null
+++ b/mine/modules/panel/components/enhanced_system_tray.py
@@ -0,0 +1,251 @@
+"""
+Enhanced System Tray
+
+Patches icon loading (absolute path support), item filtering, and
+hot-reloading of filter config based on `config.json`.
+"""
+
+from pathlib import Path
+
+from fabric.system_tray.service import SystemTrayItem as SystemTrayItemService
+from fabric.utils import GdkPixbuf, GLib, Gtk, idle_add
+
+import config.data as data
+from services.config_watcher import get_all_config, has_config_changed, on_config_change
+
+
+def _get_filter_from_config(config):
+ systray_config = config.get("panel", {}).get("systray", {})
+ hide_list = systray_config.get("hide_items", [])
+ return set(x.lower() for x in hide_list if isinstance(x, str))
+
+
+def _should_hide_item(identifier, filter_set, item=None):
+ if not filter_set:
+ return False
+
+ title = ""
+ if item and hasattr(item, "title") and item.title:
+ title = item.title
+
+ identifier_str = identifier if identifier else ""
+
+ # Check both title and identifier (case-insensitive for convenience)
+ for pattern in filter_set:
+ p = pattern.lower()
+ if p in title.lower() or p in identifier_str.lower():
+ return True
+ return False
+
+
+def apply_enhanced_system_tray(tray_widget):
+ """
+ Patch the system tray widget for advanced icon fallback, config hot-reloading, and item filtering.
+ This handles the case where the tray might not initially own the DBus name.
+ """
+
+ # Mark that we've already patched this widget to avoid double-patching
+ if hasattr(tray_widget, "_enhanced_applied"):
+ return
+ tray_widget._enhanced_applied = True
+
+ # Set initial filter FIRST
+ initial_config = get_all_config()
+ tray_widget._current_filter = _get_filter_from_config(initial_config)
+ tray_widget._bypass_filter = False
+
+ if is_debug():
+ print(
+ f"[Systray] Applying enhanced tray with filter: {tray_widget._current_filter}"
+ )
+
+ original_get_preferred_icon_pixbuf = SystemTrayItemService.get_preferred_icon_pixbuf
+ original_on_item_added = tray_widget.on_item_added
+
+ def patched_get_preferred_icon_pixbuf(self, size=None, resize_method="bilinear"):
+ # enhanced icon selection with theme/path fallback.
+ try:
+ size = size or 24
+ icon_name = self.icon_name
+ attention_icon_name = self.attention_icon_name
+ preferred_icon_name = (
+ attention_icon_name
+ if (self.status == "NeedsAttention" and attention_icon_name)
+ else icon_name
+ )
+ preferred_icon_pixmap = self.icon_pixmap
+
+ if preferred_icon_pixmap is None and preferred_icon_name:
+ path = Path(preferred_icon_name)
+ if path.is_absolute() and path.exists():
+ try:
+ target = size
+ return GdkPixbuf.Pixbuf.new_from_file_at_scale(
+ str(path),
+ target,
+ target,
+ True,
+ )
+ except GLib.GError:
+ return None
+
+ if preferred_icon_name:
+ icon_theme = self.icon_theme
+ if icon_theme.has_icon(preferred_icon_name):
+ try:
+ pixbuf = icon_theme.load_icon(
+ preferred_icon_name, size, Gtk.IconLookupFlags.FORCE_SIZE
+ )
+ if pixbuf:
+ return pixbuf
+ except Exception as e:
+ print(f"[Systray] Theme icon error: {e}")
+ # Fallback to original/default logic
+ return original_get_preferred_icon_pixbuf(self, size, resize_method)
+ except Exception as e:
+ print(f"[Systray] Error in patched_get_preferred_icon_pixbuf: {e}")
+ try:
+ return original_get_preferred_icon_pixbuf(self, size, resize_method)
+ except Exception as fallback_e:
+ print(f"[Systray] Fallback also failed: {fallback_e}")
+ return None
+
+ SystemTrayItemService.get_preferred_icon_pixbuf = patched_get_preferred_icon_pixbuf
+
+ def patched_on_item_added(watcher_self, item_identifier: str):
+ """Intercept item addition to check filter BEFORE widget creation"""
+ item = tray_widget._watcher.items.get(item_identifier)
+ title = item.title if item and hasattr(item, "title") and item.title else ""
+ if is_debug():
+ print(
+ f"[Systray] on_item_added called for: {item_identifier} (title: '{title}')"
+ )
+ print(f"[Systray] Current filter: {tray_widget._current_filter}")
+ print(
+ f"[Systray] Should hide: {_should_hide_item(item_identifier, tray_widget._current_filter, item)}"
+ )
+
+ # Check if should be hidden
+ if not tray_widget._bypass_filter and _should_hide_item(
+ item_identifier, tray_widget._current_filter, item
+ ):
+ if is_debug():
+ print(
+ f"[Systray] ✗ Blocking hidden item at registration: {item_identifier}"
+ )
+
+ # Get the item from watcher but don't create widget
+ item = tray_widget._watcher.items.get(item_identifier)
+ if item:
+ # Store None as placeholder to track this hidden item
+ tray_widget._items[item_identifier] = None
+ return
+
+ if is_debug():
+ print(f"[Systray] ✓ Allowing item: {item_identifier}")
+
+ # Item is not hidden, proceed with normal creation
+ original_on_item_added(watcher_self, item_identifier)
+
+ tray_widget.on_item_added = patched_on_item_added
+
+ def sync_visibility():
+ current_filter = tray_widget._current_filter
+ app_names = set()
+
+ if is_debug():
+ print("[Systray] === sync_visibility called ===")
+ print(f"[Systray] Current filter: {current_filter}")
+ print(f"[Systray] Items in _items: {list(tray_widget._items.keys())}")
+
+ for identifier, item_button in list(tray_widget._items.items()):
+ item = tray_widget._watcher.items.get(identifier)
+
+ title_name = (
+ item.title
+ if item and hasattr(item, "title") and item.title
+ else identifier.split("/")[-1]
+ )
+ app_names.add(title_name)
+
+ # Handle None entries (items that were blocked at registration)
+ if item_button is None:
+ should_hide = _should_hide_item(identifier, current_filter, item)
+ if is_debug():
+ print(
+ f"[Systray] Found None entry for {identifier}, should_hide={should_hide}"
+ )
+
+ if not should_hide:
+ # Item should now be visible, need to create it
+ if is_debug():
+ print(
+ f"[Systray] Creating previously hidden item: {identifier}"
+ )
+ # Remove the None placeholder
+ tray_widget._items.pop(identifier)
+ # Trigger normal creation by calling original handler
+ tray_widget._bypass_filter = True
+ original_on_item_added(None, identifier)
+ tray_widget._bypass_filter = False
+ continue
+
+ should_hide = _should_hide_item(identifier, current_filter, item)
+ is_in_parent = item_button.get_parent() is not None
+ should_be_in_parent = not should_hide
+
+ title = item.title if item and hasattr(item, "title") and item.title else ""
+ if is_debug():
+ print(
+ f"[Systray] {identifier} (title: '{title}'): should_hide={should_hide}, is_in_parent={is_in_parent}"
+ )
+
+ if is_in_parent and not should_be_in_parent:
+ tray_widget.remove(item_button)
+ if is_debug():
+ print(f"[Systray] ✗ Hiding: {identifier}")
+ elif not is_in_parent and should_be_in_parent:
+ # Re-add to container
+ container = tray_widget.get_children()
+ if item_button not in container:
+ tray_widget.pack_start(item_button, False, False, 0)
+ item_button.show()
+ if is_debug():
+ print(f"[Systray] ✓ Showing: {identifier}")
+
+ if app_names and is_debug():
+ debug_str = ",".join(sorted(app_names))
+ print(f"[Systray] Tray app identifiers: {debug_str}")
+
+ tray_widget.queue_resize()
+ tray_widget.show_all()
+ return False
+
+ def on_config_changed(new_config, old_config):
+ if has_config_changed(old_config, new_config, "panel.systray.hide_items"):
+ tray_widget._current_filter = _get_filter_from_config(new_config)
+ if is_debug():
+ print(
+ f"[Systray] Config changed, new filter: {tray_widget._current_filter}"
+ )
+ idle_add(sync_visibility)
+
+ # Monitor the watcher's 'changed' signal to handle re-registration after acquiring DBus name
+ def on_tray_changed(*args):
+ """Called when tray items change, including when we take over from another bar"""
+ if is_debug():
+ print("[Systray] Tray changed event, syncing visibility")
+ idle_add(sync_visibility)
+
+ tray_widget._watcher.connect("changed", on_tray_changed)
+
+ # Initial sync and config watching
+ idle_add(sync_visibility)
+ on_config_change(on_config_changed)
+
+ if is_debug():
+ print("[Systray] Enhanced system tray applied successfully")
+
+
+def is_debug():
+ return bool(data.DATA()["debug"])
diff --git a/mine/modules/panel/components/taskbar.py b/mine/modules/panel/components/taskbar.py
new file mode 100644
index 0000000..9d73fc5
--- /dev/null
+++ b/mine/modules/panel/components/taskbar.py
@@ -0,0 +1,334 @@
+import json
+
+from fabric.hyprland.service import Hyprland, HyprlandEvent
+from fabric.widgets.box import Box
+from fabric.widgets.button import Button
+from fabric.widgets.image import Image
+
+import config.data as data
+from services import has_config_changed, on_config_change
+from utils.functions import is_special_workspace_id
+from utils.icon_resolver import IconResolver
+
+
+class TaskBar(Box):
+ def __init__(self, **kwargs):
+ super().__init__(
+ orientation="h",
+ spacing=6,
+ name="taskbar",
+ **kwargs,
+ )
+
+ self.icon_resolver = IconResolver()
+ self._clients = {}
+ self._buttons = {}
+ self._workspace_monitor_source_id = None
+ self._visibility_callback = None
+ self._hyprland_connection = Hyprland()
+
+ # Connect to Hyprland events
+ self._hyprland_connection.connect("event::openwindow", self._on_open_window)
+ self._hyprland_connection.connect("event::closewindow", self._on_close_window)
+ self._hyprland_connection.connect(
+ "event::activewindowv2", self._on_active_window
+ )
+ self._hyprland_connection.connect("event::movewindow", self._on_move_window)
+ self._hyprland_connection.connect(
+ "event::workspacev2", self._on_workspace_changed
+ )
+
+ on_config_change(self._on_config_change)
+ self._load_config()
+ self._init_clients()
+ self._update_visibility()
+
+ def _init_clients(self):
+ """Initialize clients from current Hyprland state"""
+ res = self._hyprland_connection.send_command("j/clients")
+ if not res or not res.reply:
+ return
+
+ try:
+ clients = json.loads(res.reply.decode("utf-8"))
+ active_res = self._hyprland_connection.send_command("j/activewindow")
+ active_address = None
+ if active_res and active_res.reply:
+ active_window = json.loads(active_res.reply.decode("utf-8"))
+ active_address = active_window.get("address")
+
+ for client_data in clients:
+ address = client_data.get("address")
+ if address:
+ is_active = address == active_address
+ self._add_client(address, client_data, is_active)
+ except Exception as e:
+ print(f"Error initializing taskbar clients: {e}")
+
+ def _on_open_window(self, _, event):
+ if len(event.data) >= 1:
+ address = "0x" + event.data[0]
+ self._update_client_data(address)
+
+ def _on_close_window(self, _, event):
+ if len(event.data) >= 1:
+ address = "0x" + event.data[0]
+ self._remove_client(address)
+
+ def _on_active_window(self, _, event):
+ active_address = (
+ ("0x" + event.data[0]) if event.data and event.data[0] else None
+ )
+
+ for key, btn_data in self._buttons.items():
+ button = btn_data["button"]
+ if self._group_apps:
+ # Grouped mode: active if any address in group is active
+ is_active = active_address in btn_data["addresses"]
+ else:
+ # Normal mode: active if address matches key
+ is_active = key == active_address
+
+ if is_active:
+ button.add_style_class("active")
+ else:
+ button.remove_style_class("active")
+
+ def _on_move_window(self, _, event):
+ if len(event.data) >= 1:
+ address = "0x" + event.data[0]
+ self._update_client_data(address)
+
+ def _update_client_data(self, address):
+ res = self._hyprland_connection.send_command("j/clients")
+ if not res or not res.reply:
+ return
+
+ try:
+ clients = json.loads(res.reply.decode("utf-8"))
+ for client_data in clients:
+ if client_data.get("address") == address:
+ if address in self._clients:
+ self._clients[address] = client_data
+ self._update_client_button_properties(address)
+ else:
+ self._add_client(address, client_data)
+ break
+ except Exception as e:
+ print(f"Error updating client data: {e}")
+
+ def _get_button_key(self, client_data):
+ if self._group_apps:
+ return client_data.get("class", "unknown")
+ return client_data.get("address")
+
+ def _add_client(self, address, client_data, is_active=False):
+ self._clients[address] = client_data
+ key = self._get_button_key(client_data)
+
+ if key in self._buttons:
+ # Button already exists (only in grouping mode)
+ if address not in self._buttons[key]["addresses"]:
+ self._buttons[key]["addresses"].append(address)
+ if is_active:
+ self._buttons[key]["button"].add_style_class("active")
+ else:
+ # Create new button
+ client_image = Image()
+
+ def on_click(k=key):
+ addresses = self._buttons[k]["addresses"]
+ if not addresses:
+ return
+
+ # Cycle behavior: focus the next one if the current active is in this group
+ active_res = self._hyprland_connection.send_command("j/activewindow")
+ current_active = None
+ if active_res and active_res.reply:
+ active_window = json.loads(active_res.reply.decode("utf-8"))
+ current_active = active_window.get("address")
+
+ target_address = addresses[0]
+ if current_active in addresses:
+ idx = (addresses.index(current_active) + 1) % len(addresses)
+ target_address = addresses[idx]
+
+ self._hyprland_connection.send_command(
+ f"/dispatch focuswindow address:{target_address}"
+ )
+
+ client_button = Button(
+ image=client_image, on_button_press_event=lambda *_: on_click()
+ )
+ if is_active:
+ client_button.add_style_class("active")
+
+ self._buttons[key] = {
+ "button": client_button,
+ "image": client_image,
+ "addresses": [address],
+ }
+
+ self._update_button_visuals(key)
+
+ self._update_visibility()
+
+ def _remove_client(self, address):
+ if address not in self._clients:
+ return
+
+ client_data = self._clients[address]
+ key = self._get_button_key(client_data)
+
+ if key in self._buttons:
+ if address in self._buttons[key]["addresses"]:
+ self._buttons[key]["addresses"].remove(address)
+
+ if not self._buttons[key]["addresses"]:
+ # Last window of the group/address closed
+ button = self._buttons[key]["button"]
+ if button.get_parent() == self:
+ self.remove(button)
+ button.destroy()
+ del self._buttons[key]
+ else:
+ # Update visuals (e.g. tooltip)
+ self._update_button_visuals(key)
+
+ del self._clients[address]
+ self._update_visibility()
+
+ def _update_client_button_properties(self, address):
+ """Update properties of the button associated with this address"""
+ if address not in self._clients:
+ return
+ key = self._get_button_key(self._clients[address])
+ self._update_button_visuals(key)
+
+ def _update_button_visuals(self, key):
+ if key not in self._buttons:
+ return
+
+ btn_data = self._buttons[key]
+ button = btn_data["button"]
+ image = btn_data["image"]
+ addresses = btn_data["addresses"]
+
+ if not addresses:
+ return
+
+ # Use data from the first address for the icon/title
+ first_address = addresses[0]
+ client_data = self._clients.get(first_address, {})
+
+ app_id = client_data.get("class", "")
+ title = client_data.get("title", "")
+
+ if self._group_apps and len(addresses) > 1:
+ title = f"{title} ({len(addresses)})"
+
+ image.set_from_pixbuf(
+ self.icon_resolver.get_icon_pixbuf(app_id, self._icon_size)
+ )
+ button.set_tooltip_text(title)
+
+ # Also re-check visibility
+ self._update_button_visibility(key)
+
+ def _on_workspace_changed(self, _, event: HyprlandEvent):
+ for key in list(self._buttons.keys()):
+ self._update_button_visibility(key)
+ self._update_visibility()
+
+ def _update_button_visibility(self, key):
+ if key not in self._buttons:
+ return
+
+ btn_data = self._buttons[key]
+ button = btn_data["button"]
+ addresses = btn_data["addresses"]
+
+ # A grouped button should be visible if ANY of its windows should be visible
+ should_be_visible = False
+ for address in addresses:
+ client_data = self._clients.get(address)
+ if not client_data:
+ continue
+
+ workspace_id = self._get_workspace_id(client_data)
+ if workspace_id is not None:
+ is_special = is_special_workspace_id(workspace_id)
+ if not (self._hide_special and is_special):
+ should_be_visible = True
+ break
+
+ button_present = button.get_parent() is not None
+ if should_be_visible and not button_present:
+ self.add(button)
+ elif not should_be_visible and button_present:
+ self.remove(button)
+
+ def _get_workspace_id(self, client_data):
+ workspace_data = client_data.get("workspace", {})
+ if isinstance(workspace_data, dict):
+ return workspace_data.get("id")
+ elif isinstance(workspace_data, (int, str)):
+ return workspace_data
+ return None
+
+ def _load_config(self):
+ self._icon_size = data.DATA()["taskbar_icon_size"]
+ self._hide_special = data.DATA()["taskbar_hide_special"]
+ self._group_apps = data.DATA()["taskbar_group_apps"]
+
+ def _on_config_change(self, new_config, old_config):
+ config_paths = [
+ "panel.taskbar.icon_size",
+ "panel.taskbar.hide_empty",
+ "panel.taskbar.hide_special",
+ "panel.taskbar.group_apps",
+ ]
+
+ config_changed = any(
+ has_config_changed(old_config, new_config, path) for path in config_paths
+ )
+
+ if config_changed:
+ self._load_config()
+ # Force full rebuild of buttons on mode change
+ self._rebuild_all()
+
+ def _rebuild_all(self):
+ """Clear all and re-initialize based on current config"""
+ for child in self.get_children():
+ self.remove(child)
+ for btn_data in self._buttons.values():
+ btn_data["button"].destroy()
+
+ self._buttons = {}
+ current_clients = self._clients.copy()
+ self._clients = {}
+
+ for address, client_data in current_clients.items():
+ active_res = self._hyprland_connection.send_command("j/activewindow")
+ is_active = False
+ if active_res and active_res.reply:
+ active_window = json.loads(active_res.reply.decode("utf-8"))
+ is_active = address == active_window.get("address")
+
+ self._add_client(address, client_data, is_active)
+
+ self._update_visibility()
+
+ def force_update_for_workspace_change(self):
+ self._on_workspace_changed(None, None)
+
+ def set_visibility_callback(self, callback):
+ self._visibility_callback = callback
+
+ def _update_visibility(self):
+ has_visible_children = any(child.get_visible() for child in self.get_children())
+ self.set_visible(has_visible_children)
+
+ if self._visibility_callback:
+ self._visibility_callback()
diff --git a/mine/modules/panel/main.py b/mine/modules/panel/main.py
new file mode 100644
index 0000000..c883694
--- /dev/null
+++ b/mine/modules/panel/main.py
@@ -0,0 +1,393 @@
+from fabric.hyprland.service import Hyprland, HyprlandEvent
+from fabric.hyprland.widgets import HyprlandWorkspaces, WorkspaceButton
+from fabric.system_tray.widgets import SystemTray
+from fabric.utils import Gdk, GLib
+from fabric.widgets.box import Box
+from fabric.widgets.centerbox import CenterBox
+from fabric.widgets.datetime import DateTime
+from fabric.widgets.eventbox import EventBox
+from fabric.widgets.revealer import Revealer
+from fabric.widgets.wayland import WaylandWindow as Window
+
+import config.data as data
+from modules.panel.components import (
+ Battery,
+ TaskBar,
+ apply_enhanced_system_tray,
+)
+from services import has_config_changed, on_config_change
+from utils.corners import MyCorner
+from utils.functions import is_special_workspace_id
+from utils.occlusion import check_occlusion, is_mouse_in_region, get_screen_dimensions
+
+
+def config_dependent(func):
+ def wrapper(self, *args, **kwargs):
+ return func(self, *args, **kwargs)
+
+ wrapper._config_dependent = True
+ return wrapper
+
+
+class Panel(Window):
+ def __init__(self, **kwargs):
+ panel_autohide = data.DATA()["panel_autohide"]
+ panel_position = data.DATA().get("panel_position") or "bottom"
+
+ super().__init__(
+ name="panel-window",
+ layer="top",
+ anchor=panel_position,
+ exclusivity="auto" if not panel_autohide else "none",
+ pass_through=False,
+ visible=False,
+ all_visible=False,
+ )
+
+ self.hide_id = None
+ self.is_mouse_over_panel_area = False
+ self.effective_occlusion_size = 40
+ self.panel_position = panel_position
+
+ # Set corner types based on position
+ if panel_position == "top":
+ corner_left_type = "top-right"
+ corner_right_type = "top-left"
+ # For top position, corner goes first (at top)
+ corner_left_children = [
+ MyCorner(corner_left_type),
+ Box(v_expand=True, v_align="fill"),
+ ]
+ corner_right_children = [
+ MyCorner(corner_right_type),
+ Box(v_expand=True, v_align="fill"),
+ ]
+ else: # bottom
+ corner_left_type = "bottom-right"
+ corner_right_type = "bottom-left"
+ # For bottom position, corner goes last (at bottom)
+ corner_left_children = [
+ Box(v_expand=True, v_align="fill"),
+ MyCorner(corner_left_type),
+ ]
+ corner_right_children = [
+ Box(v_expand=True, v_align="fill"),
+ MyCorner(corner_right_type),
+ ]
+
+ self.corner_left = Box(
+ name="corner-left",
+ orientation="v",
+ h_align="start",
+ children=corner_left_children,
+ )
+ self.corner_right = Box(
+ name="corner-right",
+ orientation="v",
+ h_align="end",
+ children=corner_right_children,
+ )
+
+ use_12hr = data.DATA()["datetime_12hrs"]
+ formatter = "%a %-d %b %I:%M %P" if use_12hr else "%a %-d %b %H:%M"
+
+ self.datetime = DateTime(
+ name="date-time", formatters=[formatter], interval=30000
+ )
+ self.tray = SystemTray(name="panel-button", spacing=4, icon_size=20)
+ apply_enhanced_system_tray(self.tray)
+ self.battery = Battery()
+ self.taskbar = TaskBar()
+ self.taskbar.set_visibility_callback(self._rebuild_layout_from_config)
+ self.workspaces = HyprlandWorkspaces(
+ name="workspaces",
+ spacing=4,
+ buttons_factory=lambda ws_id: (
+ None
+ if (
+ data.DATA()["workspace_hide_special"]
+ and is_special_workspace_id(ws_id)
+ )
+ else WorkspaceButton(id=ws_id, label=str(ws_id))
+ ),
+ )
+
+ self.items = Box()
+
+ # Create panel CenterBox with position-based CSS class
+ self.panel_centerbox = CenterBox(
+ name="panel",
+ center_children=self.items,
+ )
+
+ # Add CSS class for top position using GTK style context
+ if panel_position == "top":
+ self.panel_centerbox.get_style_context().add_class("panel-top")
+
+ self.panel_content = Box(
+ name="panel-container",
+ orientation="h",
+ h_expand=True,
+ children=[
+ self.corner_left,
+ self.panel_centerbox,
+ self.corner_right,
+ ],
+ )
+
+ # Set revealer transition based on position
+ transition = "slide-down" if panel_position == "top" else "slide-up"
+
+ self.panel_revealer = Revealer(
+ name="panel-revealer",
+ transition_type=transition,
+ transition_duration=350,
+ child_revealed=False,
+ child=self.panel_content,
+ )
+
+ self.hover_activator = EventBox(name="hover-activator")
+ self.hover_activator.set_size_request(-1, 8)
+
+ # Arrange children based on position
+ if panel_position == "top":
+ panel_children = [self.panel_revealer, self.hover_activator]
+ else: # bottom
+ panel_children = [self.hover_activator, self.panel_revealer]
+
+ self.main_container = Box(
+ name="panel-main",
+ orientation="v",
+ children=panel_children,
+ )
+
+ self.event_box = EventBox(
+ name="panel-event-box",
+ child=self.main_container,
+ )
+ self.event_box.connect("enter-notify-event", self._on_mouse_enter)
+ self.event_box.connect("leave-notify-event", self._on_mouse_leave)
+
+ self.children = self.event_box
+
+ self.battery.connect(
+ "notify::visible", lambda *_: self._rebuild_layout_from_config()
+ )
+
+ self._rebuild_layout_from_config()
+ on_config_change(self._on_config_change)
+ self._update_panel_visibility()
+
+ self.hyprland = Hyprland()
+ self.hyprland.connect("event::openwindow", self._on_window_event)
+ self.hyprland.connect("event::closewindow", self._on_window_event)
+ self.hyprland.connect("event::movewindow", self._on_window_event)
+ self.hyprland.connect("event::changefloatingmode", self._on_window_event)
+ self.hyprland.connect("event::fullscreen", self._on_window_event)
+ self.hyprland.connect("event::workspacev2", self._on_window_event)
+
+ if panel_autohide:
+ self.panel_revealer.set_reveal_child(False)
+ self.check_occlusion_state()
+
+ def _get_enabled_widgets(self):
+ components = data.DATA()
+ enabled_widgets = {
+ "workspace": components["workspace"],
+ "taskbar": components["taskbar"],
+ "systray": components["systray"],
+ "battery": components["battery"],
+ "datetime": components["datetime"],
+ }
+ return enabled_widgets
+
+ @config_dependent
+ def _rebuild_layout_from_config(self):
+ enabled_widgets = self._get_enabled_widgets()
+
+ items = []
+ if enabled_widgets["workspace"]:
+ items.append(self.workspaces)
+ if enabled_widgets["taskbar"]:
+ items.append(self.taskbar)
+ if enabled_widgets["systray"]:
+ items.append(self.tray)
+ if enabled_widgets["battery"]:
+ items.append(self.battery)
+ if enabled_widgets["datetime"]:
+ items.append(self.datetime)
+
+ filtered_items = [w for w in items if w.get_visible()]
+
+ self.items.children = filtered_items
+
+ if data.DATA()["panel_enabled"]:
+ self.show_all()
+
+ def _update_panel_visibility(self):
+ panel_enabled = data.DATA()["panel_enabled"]
+ self.set_visible(panel_enabled)
+
+ def _on_mouse_enter(self, widget, event):
+ self.is_mouse_over_panel_area = True
+ if self.hide_id:
+ GLib.source_remove(self.hide_id)
+ self.hide_id = None
+ if data.DATA()["panel_autohide"]:
+ self.panel_revealer.set_reveal_child(True)
+ return True
+
+ def _on_mouse_leave(self, widget, event):
+ if event.detail == Gdk.NotifyType.INFERIOR:
+ return False
+ self.is_mouse_over_panel_area = False
+ if data.DATA()["panel_autohide"]:
+ self.delay_hide()
+ return True
+
+ def delay_hide(self):
+ if self.hide_id:
+ GLib.source_remove(self.hide_id)
+ self.hide_id = GLib.timeout_add(250, self.hide_panel_if_not_hovered)
+
+ def hide_panel_if_not_hovered(self):
+ self.hide_id = None
+ if not self.is_mouse_over_panel_area:
+ occlusion_region = (self.panel_position, self.effective_occlusion_size)
+ if check_occlusion(occlusion_region):
+ self.panel_revealer.set_reveal_child(False)
+ return False
+
+ def _on_window_event(self, _, event: HyprlandEvent):
+ GLib.idle_add(self.check_occlusion_state)
+
+ def check_occlusion_state(self):
+ try:
+ # Robust hover check as fallback for high refresh rate monitors
+ # If GTK events were missed, we verify the mouse position manually
+ if not self.is_mouse_over_panel_area:
+ # Determine the region we care about for hover
+ # If the panel is hidden, it's just the activator area
+ # If shown, it's the whole panel area
+ is_revealed = self.panel_revealer.get_reveal_child()
+ size = (
+ 48 if is_revealed else 8
+ ) # Approximate panel height when revealed
+
+ # Get current width of the panel to avoid full-width hover detection
+ width = self.main_container.get_allocated_width()
+ if width > 1:
+ screen_width, screen_height = get_screen_dimensions()
+ x = (screen_width - width) // 2
+ y = 0 if self.panel_position == "top" else screen_height - size
+ occlusion_region = (x, y, width, size)
+ else:
+ occlusion_region = (self.panel_position, size)
+
+ if is_mouse_in_region(occlusion_region):
+ self.is_mouse_over_panel_area = True
+ if self.hide_id:
+ GLib.source_remove(self.hide_id)
+ self.hide_id = None
+
+ if self.is_mouse_over_panel_area:
+ if not self.panel_revealer.get_reveal_child():
+ self.panel_revealer.set_reveal_child(True)
+ return True
+
+ if not data.DATA()["panel_autohide"]:
+ if not self.panel_revealer.get_reveal_child():
+ self.panel_revealer.set_reveal_child(True)
+ return True
+
+ # Use actual width for occlusion check too
+ width = self.main_container.get_allocated_width()
+ if width > 1:
+ screen_width, screen_height = get_screen_dimensions()
+ x = (screen_width - width) // 2
+ y = (
+ 0
+ if self.panel_position == "top"
+ else screen_height - self.effective_occlusion_size
+ )
+ occlusion_region = (
+ x,
+ y,
+ width,
+ self.effective_occlusion_size,
+ )
+ else:
+ occlusion_region = (self.panel_position, self.effective_occlusion_size)
+
+ is_occluded_by_window = check_occlusion(occlusion_region)
+
+ self.panel_revealer.set_reveal_child(not is_occluded_by_window)
+ except Exception as e:
+ from fabric.utils import logger
+
+ logger.warning(f"Error in check_occlusion_state: {e}")
+
+ return False
+
+ @config_dependent
+ def _on_config_change(self, new_config, old_config):
+ config_paths = [
+ "panel.enabled",
+ "panel.autohide",
+ "panel.position",
+ "panel.systray.enabled",
+ "panel.datetime.enabled",
+ "panel.datetime.12hrs",
+ "panel.workspace.enabled",
+ "panel.workspace.hide_special_ws",
+ "panel.taskbar.enabled",
+ "panel.taskbar.icon_size",
+ "panel.taskbar.hide_empty",
+ "panel.taskbar.hide_special",
+ "panel.battery.enabled",
+ "panel.battery.label",
+ "panel.battery.hide_on_full",
+ "panel.battery.icon_size",
+ ]
+
+ config_changed = any(
+ has_config_changed(old_config, new_config, path) for path in config_paths
+ )
+
+ if config_changed:
+ # Check if position changed (requires application restart)
+ new_position = data.DATA().get("panel_position") or "bottom"
+ if new_position != self.panel_position:
+ # Position change requires full restart due to Wayland limitations
+ print("Panel position changed - restarting application...")
+ import os
+ import sys
+
+ # Restart the Python script
+ os.execv(sys.executable, ["python"] + sys.argv)
+ return
+
+ # Position didn't change, handle other config changes
+ panel_autohide = data.DATA()["panel_autohide"]
+
+ # Set exclusivity based on autohide setting
+ exclusivity = "none" if panel_autohide else "auto"
+ self.set_property("exclusivity", exclusivity)
+
+ self._update_panel_visibility()
+
+ use_12hr = data.DATA()["datetime_12hrs"]
+ formatter = "%a %-d %b %I:%M %P" if use_12hr else "%a %-d %b %H:%M"
+ self.datetime.formatters = [formatter]
+
+ hide_special = data.DATA()["workspace_hide_special"]
+ self.workspaces.buttons_factory = lambda ws_id: (
+ None
+ if (hide_special and is_special_workspace_id(ws_id))
+ else WorkspaceButton(id=ws_id, label=str(ws_id))
+ )
+
+ self._rebuild_layout_from_config()
+
+ if panel_autohide:
+ self.check_occlusion_state()
diff --git a/mine/package.txt b/mine/package.txt
new file mode 100644
index 0000000..21b3679
--- /dev/null
+++ b/mine/package.txt
@@ -0,0 +1,13 @@
+libqalculate
+tmux
+awww
+matugen-bin
+cliphist
+wl-clip-persist
+wf-recorder
+hypridle
+hyprlock
+hyprshot
+power-profile-daemon
+uv
+wtype
\ No newline at end of file
diff --git a/mine/pyproject.toml b/mine/pyproject.toml
new file mode 100644
index 0000000..050ab58
--- /dev/null
+++ b/mine/pyproject.toml
@@ -0,0 +1,29 @@
+[project]
+name = "Modus"
+version = "0.1.0"
+description = "A modular and extensible shell, built with Fabric, designed for seamless integration with Hyprland"
+readme = "README.md"
+requires-python = ">=3.12"
+authors = [
+ { name = "Sankalp Tharu", email = 'sankalptharu50028@gmail.com' },
+]
+
+dependencies = [
+ "cryptography>=46.0.3",
+ "fabric",
+ "psutil>=7.1.2",
+ "pygobject>=3.50.0",
+ "pyotp>=2.9.0",
+ "pywayland>=0.4.18",
+ "pyzbar>=0.1.9",
+ "qrcode>=8.2",
+ "thefuzz>=0.22.1",
+]
+
+[tool.uv.sources]
+fabric = { git = "https://github.com/Fabric-Development/fabric.git" }
+
+[dependency-groups]
+dev = [
+ "ruff>=0.14.6",
+]
diff --git a/mine/requirements.txt b/mine/requirements.txt
new file mode 100644
index 0000000..da547a5
--- /dev/null
+++ b/mine/requirements.txt
@@ -0,0 +1,8 @@
+cryptography==46.0.3
+fabric @ git+https://github.com/Fabric-Development/fabric.git@0f3d317b047799191ff04a32352a63084eac11d9
+psutil==7.1.2
+pygobject==3.50.0
+pyotp==2.9.0
+pywayland==0.4.18
+pyzbar==0.1.9
+thefuzz==0.22.1
\ No newline at end of file
diff --git a/mine/ruff.toml b/mine/ruff.toml
new file mode 100644
index 0000000..168f983
--- /dev/null
+++ b/mine/ruff.toml
@@ -0,0 +1,10 @@
+[format]
+quote-style = "double"
+indent-style = "space"
+line-ending = "auto"
+
+[lint]
+ignore = ["E501", "C901", "PLR0915", "PLR0912", "PLR0913", "PLR2004", "N802", "N803", "C901"]
+
+[lint.per-file-ignores]
+"__init__.py" = ["F401"]
\ No newline at end of file
diff --git a/mine/services/__init__.py b/mine/services/__init__.py
new file mode 100644
index 0000000..2e1fe3d
--- /dev/null
+++ b/mine/services/__init__.py
@@ -0,0 +1,24 @@
+# ruff: noqa: F401, F403
+
+"""
+Modus services package.
+Contains background services and utilities for the shell.
+"""
+
+from fabric.audio import Audio
+
+from .auth import *
+from .battery import *
+from .bookmark_manager import BookmarkManager
+from .brightness import Brightness
+from .capslock import CapsLock
+from .config_watcher import *
+from .google_lens import GoogleLens
+from .inhibit import InhibitService
+from .keyboard_layout import KeyboardLayout
+from .network import *
+from .password_manager import PasswordManager
+from .screencapture import screen_capture_service
+from .tmux import TmuxService
+from .todo import TodoService
+from .wallpaper import *
diff --git a/mine/services/auth.py b/mine/services/auth.py
new file mode 100644
index 0000000..7f0b7f8
--- /dev/null
+++ b/mine/services/auth.py
@@ -0,0 +1,257 @@
+import base64
+import json
+from datetime import datetime
+from pathlib import Path
+from urllib.parse import parse_qs, urlparse
+
+import pyotp
+from fabric.utils import GdkPixbuf, logger, os, re, time
+from pyzbar.pyzbar import decode
+
+import config.data as data
+from utils.functions import run_command
+
+
+def get_otp_file_path():
+ cache_dir = Path(data.CACHE_DIR) / "otp"
+ cache_dir.mkdir(parents=True, exist_ok=True)
+ file_path = cache_dir / "otp_codes.json"
+
+ if not file_path.exists():
+ with open(file_path, "w") as f:
+ json.dump([], f)
+
+ return file_path
+
+
+def capture_selected_area(filename="/tmp/screenshot.png"):
+ result = run_command(["slurp"], timeout=10)
+ geometry = result.stdout.strip() if isinstance(result.stdout, str) else ""
+ if not geometry or result.returncode != 0:
+ return None
+
+ if run_command(["grim", "-g", geometry, filename], timeout=10).returncode != 0:
+ return None
+ return filename
+
+
+def read_and_save_to_json():
+ json_file = get_otp_file_path()
+ screenshot = capture_selected_area()
+ if screenshot is None:
+ logger.error("Failed to capture the selected area.")
+ return False
+
+ time.sleep(1)
+ try:
+ pixbuf = GdkPixbuf.Pixbuf.new_from_file(screenshot)
+ pixels = pixbuf.get_pixels()
+ channels = pixbuf.get_n_channels()
+ gray_pixels = pixels[::channels]
+ decoded_objects = decode((gray_pixels, pixbuf.get_width(), pixbuf.get_height()))
+ finally:
+ del pixbuf
+ if not decoded_objects:
+ return False
+
+ results = []
+
+ for obj in decoded_objects:
+ data = obj.data.decode("utf-8")
+ result_entry = {"timestamp": datetime.now().isoformat(), "qr_data": data}
+
+ if not data.startswith("otpauth://"):
+ result_entry["type"] = "unknown"
+ return False
+
+ parsed = urlparse(data)
+ query = parse_qs(parsed.query)
+ secret = query.get("secret", [None])[0]
+ issuer_from_query = query.get("issuer", [None])[0]
+
+ 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]
+
+ issuer = issuer_from_path or issuer_from_query
+ period = int(query.get("period", ["30"])[0])
+
+ totp = pyotp.TOTP(secret, interval=period)
+ current_otp = totp.now()
+ logger.success(f"Generated OTP: {current_otp} (valid for {period} seconds)")
+
+ result_entry.update(
+ {
+ "type": "otp",
+ "secret": secret,
+ "issuer": issuer,
+ "account_name": account_name,
+ }
+ )
+ results.append(result_entry)
+
+ existing_data = []
+ if os.path.exists(json_file):
+ with open(json_file) as f:
+ existing_data = json.load(f)
+
+ existing_data.extend(results)
+
+ with open(json_file, "w") as f:
+ json.dump(existing_data, f, indent=4)
+ 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
+ totp = pyotp.TOTP(secret)
+ return totp.now()
+
+
+def generate_totp(secret: str) -> str:
+ return pyotp.TOTP(secret).now()
+
+
+def get_time_remaining() -> int:
+ return 30 - (int(time.time()) % 30)
+
+
+def get_time_remaining_with_blink() -> str:
+ time_remaining = get_time_remaining()
+ current_second = int(time.time())
+ should_blink = current_second % 2 == 0
+
+ return (
+ f"{time_remaining}s "
+ if should_blink
+ else f"{time_remaining}s"
+ )
+
+
+def validate_base32_secret(secret: str) -> dict:
+ clean_secret = secret.replace(" ", "").replace("-", "").replace("_", "").upper()
+ clean_secret = re.sub(r"[^A-Z2-7]", "", clean_secret)
+
+ while len(clean_secret) % 8 != 0:
+ clean_secret += "="
+
+ try:
+ base64.b32decode(clean_secret)
+ except Exception as e:
+ return {"success": False, "error": f"Invalid Base32 secret: {str(e)}"}
+
+ 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}
+
+
+def parse_otpauth_uri(uri: str, account_name: str = "") -> dict:
+ 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,
+ }
+
+
+def scan_qr_and_add_account(account_name: str, secrets_file_path: str) -> dict:
+ screenshot_path = capture_selected_area()
+ if not screenshot_path:
+ return {"success": False, "error": "QR scan cancelled or failed"}
+
+ try:
+ pixbuf = GdkPixbuf.Pixbuf.new_from_file(screenshot_path)
+ pixels = pixbuf.get_pixels()
+ channels = pixbuf.get_n_channels()
+ gray_pixels = pixels[::channels]
+ decoded_objects = decode((gray_pixels, pixbuf.get_width(), pixbuf.get_height()))
+ finally:
+ del pixbuf
+
+ if not decoded_objects:
+ return {
+ "success": False,
+ "error": "No QR code detected in selected area",
+ }
+
+ qr_data = decoded_objects[0].data.decode("utf-8")
+
+ if not qr_data.startswith("otpauth://"):
+ return {"success": False, "error": "QR code is not an otpauth URI"}
+
+ result = parse_otpauth_uri(qr_data, account_name)
+ if not result["success"]:
+ return result
+
+ secrets = {}
+ if os.path.exists(secrets_file_path):
+ with open(secrets_file_path, encoding="utf-8") as f:
+ secrets = json.load(f)
+
+ secrets[result["account_name"]] = {
+ "secret": result["secret"],
+ "issuer": result["issuer"],
+ "algorithm": result["algorithm"],
+ "digits": result["digits"],
+ "period": result["period"],
+ }
+
+ 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)
+
+ 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}",
+ }
diff --git a/mine/services/battery.py b/mine/services/battery.py
new file mode 100644
index 0000000..7ec488a
--- /dev/null
+++ b/mine/services/battery.py
@@ -0,0 +1,79 @@
+from typing import Literal
+
+from fabric import Service, Signal
+from fabric.utils import Gio, logger
+
+from utils.dbus_helper import GioDBusHelper
+
+DeviceState = {
+ 0: "UNKNOWN",
+ 1: "CHARGING",
+ 2: "DISCHARGING",
+ 3: "EMPTY",
+ 4: "FULLY_CHARGED",
+ 5: "PENDING_CHARGE",
+ 6: "PENDING_DISCHARGE",
+}
+
+
+class BatteryService(Service):
+ """Service to interact with UPower via GIO D-Bus"""
+
+ @Signal
+ def changed(self) -> None:
+ """Signal emitted when battery changes."""
+
+ _instance = None
+
+ def __new__(cls):
+ if cls._instance is None:
+ cls._instance = super().__new__(cls)
+ return cls._instance
+
+ def __init__(self, **kwargs):
+ super().__init__(**kwargs)
+
+ self.bus_name = "org.freedesktop.UPower"
+ self.object_path = "/org/freedesktop/UPower/devices/DisplayDevice"
+ self.interface_name = "org.freedesktop.UPower.Device"
+
+ self.dbus_helper = GioDBusHelper(
+ bus_type=Gio.BusType.SYSTEM,
+ bus_name=self.bus_name,
+ object_path=self.object_path,
+ interface_name=self.interface_name,
+ )
+
+ self.proxy = self.dbus_helper.proxy
+
+ # Listen for PropertiesChanged signals
+ self.dbus_helper.listen_signal(
+ member="PropertiesChanged",
+ callback=self.handle_property_change,
+ )
+
+ def get_property(
+ self,
+ property: Literal[
+ "Percentage",
+ "Temperature",
+ "TimeToEmpty",
+ "TimeToFull",
+ "IconName",
+ "State",
+ "Capacity",
+ "IsPresent",
+ "Vendor",
+ ],
+ ):
+ try:
+ result = self.proxy.get_cached_property(property)
+ return result.unpack() if result is not None else None
+ except Exception as e:
+ logger.exception(f"[Battery] Error retrieving '{property}': {e}")
+ return None
+
+ def handle_property_change(self, *_):
+ # You may filter which property changed by checking parameters[1]
+
+ self.emit("changed")
diff --git a/mine/services/bookmark_manager.py b/mine/services/bookmark_manager.py
new file mode 100644
index 0000000..b6f07ca
--- /dev/null
+++ b/mine/services/bookmark_manager.py
@@ -0,0 +1,117 @@
+import json
+from datetime import datetime
+from pathlib import Path
+from typing import Any
+
+from fabric.utils import logger
+
+import config.data as data
+
+
+class BookmarkManager:
+ """Bookmark manager service for storing and managing bookmarks"""
+
+ def __init__(self, bookmarks_file: str | None = None):
+ if bookmarks_file:
+ self.bookmarks_file = Path(bookmarks_file)
+ else:
+ self.bookmarks_file = Path(data.plugins) / "bookmarks.json"
+ self._initialize_bookmarks_file()
+
+ def _initialize_bookmarks_file(self):
+ if not self.bookmarks_file.exists():
+ with open(self.bookmarks_file, "w") as f:
+ json.dump({}, f)
+
+ def add_bookmark(
+ self,
+ name: str,
+ content: str,
+ ) -> dict[str, Any]:
+ bookmarks = self._load_bookmarks()
+
+ if name in bookmarks:
+ return {
+ "success": False,
+ "error": f"Bookmark '{name}' already exists",
+ }
+
+ bookmarks[name] = {
+ "content": content.strip(),
+ "created_at": self._get_timestamp(),
+ "updated_at": self._get_timestamp(),
+ }
+
+ self._save_bookmarks(bookmarks)
+ return {
+ "success": True,
+ "message": f"Bookmark '{name}' added successfully",
+ }
+
+ def get_bookmark(self, name: str) -> dict[str, Any]:
+ bookmarks = self._load_bookmarks()
+ if name not in bookmarks:
+ return {"success": False, "error": f"Bookmark '{name}' not found"}
+ return {"success": True, "data": bookmarks[name]}
+
+ def update_bookmark(self, name: str, **kwargs) -> dict[str, Any]:
+ bookmarks = self._load_bookmarks()
+ if name not in bookmarks:
+ return {"success": False, "error": f"Bookmark '{name}' not found"}
+
+ for field, value in kwargs.items():
+ if field == "content":
+ bookmarks[name][field] = value.strip()
+ else:
+ logger.warning(f"Unknown field '{field}' ignored")
+
+ bookmarks[name]["updated_at"] = self._get_timestamp()
+ self._save_bookmarks(bookmarks)
+ return {
+ "success": True,
+ "message": f"Bookmark '{name}' updated successfully",
+ }
+
+ def delete_bookmark(self, name: str) -> dict[str, Any]:
+ bookmarks = self._load_bookmarks()
+ if name not in bookmarks:
+ return {"success": False, "error": f"Bookmark '{name}' not found"}
+
+ del bookmarks[name]
+ self._save_bookmarks(bookmarks)
+ return {
+ "success": True,
+ "message": f"Bookmark '{name}' deleted successfully",
+ }
+
+ def list_bookmarks(self) -> dict[str, Any]:
+ bookmarks = self._load_bookmarks()
+ return {"success": True, "data": bookmarks, "count": len(bookmarks)}
+
+ def search_bookmarks(self, query: str) -> dict[str, Any]:
+ bookmarks = self._load_bookmarks()
+ query_lower = query.lower()
+
+ matches = {}
+ for name, bookmark in bookmarks.items():
+ searchable_text = f"{name} {bookmark.get('content', '')}".lower()
+
+ if query_lower in searchable_text:
+ matches[name] = bookmark
+
+ return {"success": True, "data": matches, "count": len(matches), "query": query}
+
+ def _load_bookmarks(self) -> dict[str, Any]:
+ try:
+ with open(self.bookmarks_file) as f:
+ return json.load(f)
+ except (FileNotFoundError, json.JSONDecodeError) as e:
+ logger.warning(f"Error loading bookmarks file: {e}")
+ return {}
+
+ def _save_bookmarks(self, bookmarks: dict[str, Any]):
+ with open(self.bookmarks_file, "w") as f:
+ json.dump(bookmarks, f, indent=2)
+
+ def _get_timestamp(self) -> str:
+ return datetime.now().isoformat()
diff --git a/mine/services/brightness.py b/mine/services/brightness.py
new file mode 100644
index 0000000..89014d0
--- /dev/null
+++ b/mine/services/brightness.py
@@ -0,0 +1,122 @@
+from fabric.core.service import Property, Service, Signal
+from fabric.utils import exec_shell_command_async, logger, monitor_file, os
+
+
+def exec_brightnessctl_async(args: str):
+ exec_shell_command_async(f"brightnessctl {args}", lambda _: None)
+
+
+class Brightness(Service):
+ """Service to manage screen brightness levels."""
+
+ _instance = None
+
+ def __new__(cls):
+ if cls._instance is None:
+ cls._instance = super().__new__(cls)
+ return cls._instance
+
+ @staticmethod
+ def get_initial():
+ if Brightness._instance is None:
+ Brightness._instance = Brightness()
+ return Brightness._instance
+
+ @Signal
+ def brightness_changed(self, value: int) -> None:
+ """Signal emitted when screen brightness changes."""
+
+ def __init__(self, **kwargs):
+ super().__init__(**kwargs)
+
+ # Discover screen backlight device
+ screen_device_list = os.listdir("/sys/class/backlight")
+ self.screen_device = screen_device_list[0] if screen_device_list else ""
+
+ if self.screen_device == "":
+ logger.warning("No screen backlight device detected.")
+ self.screen_backlight_path = ""
+ self.max_screen = 0
+ self.screen_monitor = None
+ else:
+ self.screen_backlight_path = f"/sys/class/backlight/{self.screen_device}"
+ self.max_screen = self._read_max_brightness(self.screen_backlight_path)
+
+ self.screen_monitor = monitor_file(
+ f"{self.screen_backlight_path}/brightness", initial_call=True
+ )
+
+ self.screen_monitor.connect(
+ "changed",
+ lambda _, file, *args: self.emit(
+ "brightness_changed",
+ round(int(file.load_bytes()[0].get_data())),
+ ),
+ )
+
+ logger.info(
+ f"Brightness service initialized for device: {self.screen_device}"
+ )
+
+ # Discover keyboard backlight device
+ kbd_list = os.listdir("/sys/class/leds")
+ kbd_filtered = [x for x in kbd_list if "kbd_backlight" in x]
+ self.kbd = kbd_filtered[0] if kbd_filtered else ""
+
+ self.kbd_backlight_path = f"/sys/class/leds/{self.kbd}" if self.kbd else ""
+ self.max_kbd = self._read_max_brightness(self.kbd_backlight_path)
+
+ def _read_max_brightness(self, path: str) -> int:
+ if not path:
+ return -1
+ max_brightness_path = os.path.join(path, "max_brightness")
+ if not os.path.exists(max_brightness_path):
+ return -1
+ with open(max_brightness_path) as f:
+ return int(f.readline())
+
+ @Property(int, "read-write")
+ def screen_brightness(self) -> int:
+ if not self.screen_backlight_path:
+ logger.warning("Cannot get brightness: no screen device.")
+ return -1
+ brightness_path = os.path.join(self.screen_backlight_path, "brightness")
+ if not os.path.exists(brightness_path):
+ logger.warning(f"Brightness file does not exist: {brightness_path}")
+ return -1
+ with open(brightness_path) as f:
+ return int(f.readline())
+
+ @screen_brightness.setter
+ def screen_brightness(self, value: int):
+ if not self.screen_backlight_path:
+ logger.warning("Cannot set brightness: no screen device.")
+ return
+ if not (0 <= value <= self.max_screen):
+ value = max(0, min(value, self.max_screen))
+ exec_brightnessctl_async(f"--device '{self.screen_device}' set {value}")
+ self.emit("brightness_changed", value)
+
+ @Property(int, "read-write")
+ def keyboard_brightness(self) -> int: # type: ignore
+ if not self.kbd_backlight_path:
+ logger.warning("No keyboard backlight device detected.")
+ return -1
+ with open(self.kbd_backlight_path + "/brightness") as f:
+ return int(f.readline())
+
+ @keyboard_brightness.setter
+ def keyboard_brightness(self, value: int):
+ if not self.kbd_backlight_path:
+ logger.warning("No keyboard backlight device detected.")
+ return
+ if value < 0 or value > self.max_kbd:
+ return
+ exec_brightnessctl_async(f"--device '{self.kbd}' set {value}")
+
+ @Property(int, "readable")
+ def screen_brightness_percentage(self):
+ if not self.screen_backlight_path or self.max_screen <= 0:
+ return 0
+ current_brightness = self.screen_brightness
+ return int((current_brightness / self.max_screen) * 100)
diff --git a/mine/services/capslock.py b/mine/services/capslock.py
new file mode 100644
index 0000000..b2a7708
--- /dev/null
+++ b/mine/services/capslock.py
@@ -0,0 +1,81 @@
+from pathlib import Path
+
+from fabric.core.service import Property, Service, Signal
+from fabric.utils import GLib, logger
+
+# Discover CapsLock LED device
+capslock_leds = list(Path("/sys/class/leds").glob("input*::capslock"))
+capslock_device = capslock_leds[0] if capslock_leds else None
+
+
+class CapsLock(Service):
+ instance = None
+
+ @staticmethod
+ def get_initial():
+ if CapsLock.instance is None:
+ CapsLock.instance = CapsLock()
+
+ return CapsLock.instance
+
+ @Signal
+ def state_changed(self, is_on: bool) -> None:
+ """Signal emitted when CapsLock state changes."""
+
+ def __init__(self, **kwargs):
+ super().__init__(**kwargs)
+
+ self.capslock_led_path = capslock_device
+ self._timeout_id = None
+ self._last_state = None
+
+ if capslock_device is None:
+ logger.warning("CapsLock device not found, CapsLock service disabled")
+
+ def _start_polling(self):
+ """Poll CapsLock LED at 500ms interval (lightweight file read)."""
+ if self._timeout_id is None:
+ self._timeout_id = GLib.timeout_add(500, self._poll)
+
+ def _poll(self) -> bool:
+ try:
+ current_state = self._get_current_state()
+ if current_state != self._last_state:
+ self._last_state = current_state
+ self.emit("state_changed", current_state)
+ except Exception as e:
+ logger.error(f"CapsLock polling error: {e}")
+ self._timeout_id = None
+ return False
+
+ return True
+
+ def stop(self):
+ if self._timeout_id is not None:
+ GLib.source_remove(self._timeout_id)
+ self._timeout_id = None
+
+ def _ensure_monitoring_started(self):
+ if self._last_state is not None:
+ return
+ if not self.capslock_led_path:
+ return
+
+ self._last_state = self._get_current_state()
+ self._start_polling()
+
+ def _get_current_state(self) -> bool:
+ if not self.capslock_led_path:
+ return False
+ brightness_path = self.capslock_led_path / "brightness"
+ if brightness_path.exists():
+ try:
+ return bool(int(brightness_path.read_text().strip()))
+ except (ValueError, OSError):
+ return False
+ return False
+
+ @Property(bool, "read-write", default_value=False)
+ def is_on(self) -> bool:
+ self._ensure_monitoring_started()
+ return self._get_current_state()
diff --git a/mine/services/config_watcher.py b/mine/services/config_watcher.py
new file mode 100644
index 0000000..1a93f53
--- /dev/null
+++ b/mine/services/config_watcher.py
@@ -0,0 +1,227 @@
+"""
+Centralized configuration service with file watching and dynamic reloads.
+
+This service encapsulates the logic for loading the application's JSON config,
+watching for file changes, and notifying registered listeners when the config
+changes. It is implemented as a singleton and intended to be reused by any
+module that needs dynamic configuration.
+"""
+
+from __future__ import annotations
+
+import json
+from collections.abc import Callable
+from typing import Any
+
+from fabric.utils import Gio, GLib, get_relative_path, os
+
+
+def log_errors(func):
+ """Decorator to log errors in config operations"""
+
+ def wrapper(*args, **kwargs):
+ return func(*args, **kwargs)
+
+ return wrapper
+
+
+def safe_operation(func):
+ """Decorator for safe operations that shouldn't raise exceptions"""
+
+ def wrapper(*args, **kwargs):
+ return func(*args, **kwargs)
+
+ return wrapper
+
+
+class ConfigService:
+ """Singleton service handling config state and reload notifications."""
+
+ _instance: ConfigService | None = None
+
+ def __new__(cls):
+ if cls._instance is None:
+ cls._instance = super().__new__(cls)
+ return cls._instance
+
+ def __init__(self):
+ if getattr(self, "_initialized", False):
+ return
+
+ self._initialized = True
+ self._config: dict[str, Any] = {}
+ self._reload_callbacks: list[
+ Callable[[dict[str, Any], dict[str, Any]], None]
+ ] = []
+ self._config_file: str = self._get_config_file_path()
+ self._monitors: list[Gio.FileMonitor] = []
+ self._reload_source_id: int | None = None
+
+ self._load_config()
+ self._setup_monitors()
+
+ @staticmethod
+ def _get_config_file_path() -> str:
+ """Get the configuration file path"""
+ return get_relative_path("../config.json")
+
+ @staticmethod
+ def _is_valid_config_file(file_path: str) -> bool:
+ """Check if the config file exists and is readable"""
+ return os.path.exists(file_path) and os.access(file_path, os.R_OK)
+
+ def get(self, key: str, default: Any = None) -> Any:
+ return self._config.get(key, default)
+
+ def get_all(self) -> dict[str, Any]:
+ return self._config.copy()
+
+ def has_changed(self, key: str, old_config: dict[str, Any]) -> bool:
+ return self._config.get(key) != old_config.get(key)
+
+ def register_reload_callback(
+ self, callback: Callable[[dict[str, Any], dict[str, Any]], None]
+ ) -> None:
+ if callback in self._reload_callbacks:
+ return
+ self._reload_callbacks.append(callback)
+
+ def unregister_reload_callback(
+ self, callback: Callable[[dict[str, Any], dict[str, Any]], None]
+ ) -> None:
+ if callback not in self._reload_callbacks:
+ return
+ self._reload_callbacks.remove(callback)
+
+ def stop(self) -> None:
+ # Cancel any pending reload
+ if self._reload_source_id is not None:
+ GLib.source_remove(self._reload_source_id)
+ self._reload_source_id = None
+
+ for monitor in self._monitors:
+ monitor.cancel()
+ self._monitors.clear()
+
+ @log_errors
+ def _load_config(self) -> None:
+ if not self._is_valid_config_file(self._config_file):
+ self._config = {}
+ return
+ with open(self._config_file) as f:
+ self._config = json.load(f)
+
+ @safe_operation
+ def _setup_monitors(self) -> None:
+ files_to_watch = [self._config_file]
+ for file_path in files_to_watch:
+ if not self._is_valid_config_file(file_path):
+ continue
+ gio_file = Gio.File.new_for_path(file_path)
+ monitor = gio_file.monitor_file(Gio.FileMonitorFlags.NONE, None)
+ monitor.connect("changed", self._on_file_changed, file_path)
+ self._monitors.append(monitor)
+
+ def _on_file_changed(self, monitor, file, other_file, event_type, file_path: str):
+ # Option 1: Immediate reload (completely eliminates polling)
+ if event_type == Gio.FileMonitorEvent.CHANGES_DONE_HINT:
+ self._reload_config_immediate()
+
+ # Option 2: If you still want debouncing but without polling, use idle_add
+ # This processes the reload on the next main loop iteration
+ # if event_type == Gio.FileMonitorEvent.CHANGES_DONE_HINT:
+ # if self._reload_source_id is None:
+ # self._reload_source_id = GLib.idle_add(self._reload_config_idle)
+
+ @log_errors
+ def _reload_config_immediate(self) -> None:
+ """Immediately reload config without any delays."""
+ old_config = self._config.copy()
+ self._load_config()
+ self._notify_callbacks(self._config, old_config)
+
+ @safe_operation
+ def _notify_callbacks(
+ self, new_config: dict[str, Any], old_config: dict[str, Any]
+ ) -> None:
+ """Notify all registered callbacks of config changes"""
+ for callback in list(self._reload_callbacks):
+ callback(new_config, old_config)
+
+ def _reload_config_idle(self) -> bool:
+ """Alternative: Reload on next idle cycle (still non-polling)."""
+ old_config = self._config.copy()
+ self._load_config()
+ self._notify_callbacks(self._config, old_config)
+ self._reload_source_id = None
+ return False # Don't repeat
+
+
+_service: ConfigService | None = None
+
+
+def start_config_service() -> ConfigService:
+ global _service
+ if _service is not None:
+ return _service
+ _service = ConfigService()
+ return _service
+
+
+def stop_config_service() -> None:
+ global _service
+ if _service is None:
+ return
+ _service.stop()
+ _service = None
+
+
+# Convenience helpers for concise usage
+def config() -> ConfigService:
+ """Get the singleton service (concise alias)."""
+ return start_config_service()
+
+
+def get_config(key: str, default: Any = None) -> Any:
+ """Get a single config value from the singleton service."""
+ return start_config_service().get(key, default)
+
+
+def get_all_config() -> dict[str, Any]:
+ """Get all config values from the singleton service."""
+ return start_config_service().get_all()
+
+
+def on_config_change(
+ callback: Callable[[dict[str, Any], dict[str, Any]], None],
+) -> None:
+ """Register a reload callback on the singleton service."""
+ start_config_service().register_reload_callback(callback)
+
+
+# Static utility functions
+def validate_config_structure(config: dict[str, Any]) -> bool:
+ """Validate that the config has the expected structure"""
+ return isinstance(config, dict)
+
+
+def get_config_value(config: dict[str, Any], key_path: str, default: Any = None) -> Any:
+ """Get a config value using dot notation (e.g., 'panel.datetime.enabled')"""
+ keys = key_path.split(".")
+ current = config
+
+ for key in keys:
+ if not isinstance(current, dict) or key not in current:
+ return default
+ current = current[key]
+
+ return current
+
+
+def has_config_changed(
+ old_config: dict[str, Any], new_config: dict[str, Any], key_path: str
+) -> bool:
+ """Check if a specific config value has changed"""
+ old_value = get_config_value(old_config, key_path)
+ new_value = get_config_value(new_config, key_path)
+ return old_value != new_value
diff --git a/mine/services/google_lens.py b/mine/services/google_lens.py
new file mode 100644
index 0000000..2a70dd9
--- /dev/null
+++ b/mine/services/google_lens.py
@@ -0,0 +1,176 @@
+import contextlib
+import tempfile
+import webbrowser
+from urllib.parse import quote
+
+from fabric.core.service import Service
+from fabric.utils import logger, os
+
+from utils.functions import run_command
+
+DEFAULT_UPLOADER = "https://litterbox.catbox.moe/resources/internals/api.php"
+
+
+class GoogleLens(Service):
+ """Service to capture region and search with Google Lens."""
+
+ _instance = None
+
+ def __new__(cls):
+ if cls._instance is None:
+ cls._instance = super().__new__(cls)
+ return cls._instance
+
+ @staticmethod
+ def get_initial():
+ if GoogleLens._instance is None:
+ GoogleLens._instance = GoogleLens()
+ return GoogleLens._instance
+
+ def __init__(self, **kwargs):
+ super().__init__(**kwargs)
+
+ def capture_region(self, dest_path):
+ # Check for dependencies
+ if run_command(["which", "grim"]).returncode != 0:
+ raise RuntimeError("grim not found - please install it")
+
+ if run_command(["which", "slurp"]).returncode != 0:
+ raise RuntimeError("slurp not found - please install it")
+
+ try:
+ # Select region
+ result = run_command(["slurp"], timeout=60)
+ if result.returncode != 0:
+ if result.returncode == 1:
+ raise RuntimeError("Selection cancelled")
+ raise RuntimeError(f"Failed to select region: {result.stderr}")
+
+ geom = (
+ result.stdout.strip()
+ if isinstance(result.stdout, str)
+ else result.stdout.decode("utf-8").strip()
+ )
+
+ if not geom:
+ raise RuntimeError("No region selected")
+
+ # Capture selected region
+ capture_result = run_command(["grim", "-g", geom, dest_path], timeout=60)
+ if capture_result.returncode != 0:
+ raise RuntimeError(f"Failed to capture region: {capture_result.stderr}")
+
+ except Exception as e:
+ raise RuntimeError(f"Failed to capture region: {e}")
+
+ def upload_file(self, uploader_url, file_path):
+ url_resp = None
+
+ try:
+ import mimetypes
+ import uuid
+ from urllib.request import Request, urlopen
+
+ boundary = f"----WebKitFormBoundary{uuid.uuid4().hex}"
+ headers = {"Content-Type": f"multipart/form-data; boundary={boundary}"}
+
+ data = []
+ fields = {
+ "reqtype": "fileupload",
+ "time": "1h",
+ }
+
+ for name, value in fields.items():
+ data.append(f"--{boundary}".encode())
+ data.append(f'Content-Disposition: form-data; name="{name}"'.encode())
+ data.append(b"")
+ data.append(value.encode())
+
+ filename = os.path.basename(file_path)
+ mime_type = mimetypes.guess_type(file_path)[0] or "application/octet-stream"
+
+ data.append(f"--{boundary}".encode())
+ data.append(
+ f'Content-Disposition: form-data; name="fileToUpload"; filename="{filename}"'.encode()
+ )
+ data.append(f"Content-Type: {mime_type}".encode())
+ data.append(b"")
+ with open(file_path, "rb") as f:
+ data.append(f.read())
+
+ data.append(f"--{boundary}--".encode())
+ data.append(b"")
+
+ body = b"\r\n".join(data)
+ req = Request(uploader_url, data=body, headers=headers)
+
+ with urlopen(req, timeout=60) as response:
+ url_resp = response.read().decode("utf-8").strip()
+ except Exception as e:
+ logger.error(f"Upload with urllib failed: {e}")
+
+ # Fallback to curl
+ if url_resp is None:
+ if run_command(["which", "curl"]).returncode != 0:
+ raise RuntimeError("Neither requests module nor curl available")
+
+ cmd = [
+ "curl",
+ "-sS",
+ "-F",
+ "reqtype=fileupload",
+ "-F",
+ "time=1h",
+ "-F",
+ f"fileToUpload=@{file_path}",
+ uploader_url,
+ ]
+ result = run_command(cmd)
+ if result.returncode != 0:
+ raise RuntimeError(f"Upload failed: {result.stderr}")
+
+ url_resp = (
+ result.stdout.strip()
+ if isinstance(result.stdout, str)
+ else result.stdout.decode("utf-8").strip()
+ )
+
+ # catbox.moe returns the URL directly
+ if (
+ not url_resp
+ or not isinstance(url_resp, str)
+ or not url_resp.startswith("http")
+ ):
+ raise RuntimeError(f"Invalid response from uploader: {url_resp}")
+
+ return url_resp
+
+ def search(self):
+ tmp = tempfile.NamedTemporaryFile(
+ prefix="google_lens_", suffix=".png", delete=False
+ )
+ tmp_path = tmp.name
+ tmp.close()
+
+ try:
+ # Capture region
+ logger.info("Select a region...")
+ self.capture_region(tmp_path)
+
+ # Upload
+ logger.info("Uploading...")
+ uploaded_url = self.upload_file(DEFAULT_UPLOADER, tmp_path)
+ logger.info(f"Uploaded: {uploaded_url}")
+
+ # Open in Lens
+ lens_url = f"https://lens.google.com/uploadbyurl?url={quote(uploaded_url, safe='')}"
+ logger.info("Opening Google Lens...")
+ webbrowser.open(lens_url)
+
+ except Exception as e:
+ logger.error(f"Error: {e}")
+ finally:
+ # Cleanup
+ if os.path.exists(tmp_path):
+ with contextlib.suppress(OSError):
+ os.unlink(tmp_path)
diff --git a/mine/services/inhibit.py b/mine/services/inhibit.py
new file mode 100644
index 0000000..a422961
--- /dev/null
+++ b/mine/services/inhibit.py
@@ -0,0 +1,129 @@
+from dataclasses import dataclass
+from threading import Event, Thread, Timer
+
+from fabric.core.service import Property, Service, Signal
+from fabric.utils import re
+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:
+ ds = duration_str.strip().lower()
+ m = re.fullmatch(r"(?:on|off)|(\d+(?:\.\d+)?)([hms]?)", ds) or (
+ (_ for _ in ()).throw(
+ ValueError(
+ "Invalid duration format. Use '1h', '30m', '45s', 'on', 'off', etc."
+ )
+ )
+ )
+ value = float(m.group(1) or 0.0)
+ unit = m.group(2) or "s"
+ multipliers = {"h": 3600, "m": 60, "s": 1, "": 1}
+ return int(value * multipliers[unit])
+
+
+class InhibitService(Service):
+ _instance = None
+
+ @staticmethod
+ def get_initial():
+ if InhibitService._instance is None:
+ InhibitService._instance = InhibitService()
+ return InhibitService._instance
+
+ @Signal
+ def state_changed(self) -> None: ...
+
+ def __init__(self, **kwargs):
+ super().__init__(**kwargs)
+ self._thread: Thread | None = None
+ self._stop_event = Event()
+ self._running = False
+ self._duration_s = 0
+
+ @Property(bool, "readable", default_value=False)
+ def is_running(self) -> bool:
+ return self._running
+
+ def enable(self, duration: str) -> None:
+ self.disable()
+ self._duration_s = (
+ 0 if duration.strip().lower() == "on" else parse_duration(duration)
+ )
+ self._stop_event.clear()
+ self._thread = Thread(target=self._run, daemon=True)
+ self._thread.start()
+
+ def disable(self) -> None:
+ if self._thread and self._thread.is_alive():
+ self._stop_event.set()
+ self._thread.join(timeout=1.0)
+ self._thread = None
+
+ def _run(self) -> None:
+ display = Display()
+ display.connect()
+
+ registry = display.get_registry() # type: ignore
+ global_registry = GlobalRegistry()
+ registry.user_data = global_registry
+ registry.dispatcher["global"] = handle_registry_global
+
+ display.dispatch()
+ display.roundtrip()
+
+ if global_registry.surface is None or global_registry.inhibit_manager is None:
+ display.disconnect()
+ return
+
+ inhibitor = global_registry.inhibit_manager.create_inhibitor( # type: ignore
+ global_registry.surface
+ )
+
+ self._running = True
+ self.emit("state_changed")
+
+ timer = (
+ Timer(self._duration_s, lambda: self._stop_event.set())
+ if self._duration_s > 0
+ else None
+ )
+ if timer:
+ timer.start()
+
+ try:
+ while not self._stop_event.is_set():
+ display.dispatch()
+ display.roundtrip()
+ finally:
+ inhibitor.destroy()
+ if timer:
+ timer.cancel()
+ display.disconnect()
+ self._running = False
+ self.emit("state_changed")
+
+
+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
+ )
diff --git a/mine/services/keyboard_layout.py b/mine/services/keyboard_layout.py
new file mode 100644
index 0000000..32f5e75
--- /dev/null
+++ b/mine/services/keyboard_layout.py
@@ -0,0 +1,194 @@
+from pathlib import Path
+from typing import ClassVar, List, Optional
+
+from fabric.core.service import Property, Service, Signal
+from fabric.utils import GLib, logger, monitor_file
+
+import config.data as data
+from services import on_config_change
+from utils.functions import read_json_file, run_command, write_json_file
+
+HYPRCTL_BIN: str = "hyprctl"
+
+
+class KeyboardLayout(Service):
+ """
+ Features full compatibility with Hyprland v0.55+ Lua config models:
+ - Reads active layout definitions strictly from Modus's config.json.
+ - Synchronizes configuration changes seamlessly on runtime events.
+ - Leverages `hyprctl eval` for dynamic runtime layout registration.
+ - Switches keymaps globally using reliable device indexing.
+ """
+
+ _instance: ClassVar[Optional["KeyboardLayout"]] = None
+
+ @Signal
+ def layout_changed(self, layout: str) -> None:
+ """Signal emitted when keyboard layout changes."""
+
+ def __new__(cls, *args, **kwargs) -> "KeyboardLayout":
+ if cls._instance is None:
+ cls._instance = super().__new__(cls)
+ return cls._instance
+
+ @classmethod
+ def get_initial(cls) -> "KeyboardLayout":
+ """Retrieve the singleton instance of the KeyboardLayout service."""
+ if cls._instance is None:
+ cls._instance = cls()
+ return cls._instance
+
+ def __init__(self, **kwargs) -> None:
+ # Prevent re-initialization if singleton instance is already setup
+ if hasattr(self, "_initialized") and self._initialized:
+ return
+ super().__init__(**kwargs)
+
+ self.layout_file: Path = Path(data.CACHE_DIR) / "kb_layout"
+ self.layout_json_file: Path = Path(data.CACHE_DIR) / "kb_layout.json"
+ self._last_layout: Optional[str] = None
+ self.layouts: List[str] = ["us"]
+ self.current_index: int = 0
+
+ self.layout_file.parent.mkdir(parents=True, exist_ok=True)
+
+ self._init_layout_json()
+ self._apply_layouts_to_hyprland()
+
+ # Set up state files & monitor
+ current = self._read_layout()
+ self._write_layout(current)
+ self.file_monitor = monitor_file(str(self.layout_file), initial_call=True)
+ self.file_monitor.connect("changed", self._on_file_changed)
+ self._last_layout = current
+
+ self._apply_current_layout_index()
+
+ on_config_change(self._on_config_change)
+ self._initialized: bool = True
+
+ def _on_config_change(self, new_config: dict, old_config: dict) -> None:
+ """Handle real-time updates to the shell configuration."""
+ old_kb_layout = old_config.get("keyboard_layout", {})
+ new_kb_layout = new_config.get("keyboard_layout", {})
+
+ if old_kb_layout.get("layouts") != new_kb_layout.get("layouts"):
+ new_layouts = new_kb_layout.get("layouts")
+ if new_layouts and new_layouts != self.layouts:
+ self.layouts = new_layouts
+ self._apply_layouts_to_hyprland()
+
+ current_layout = self._read_layout()
+ self.current_index = (
+ self.layouts.index(current_layout)
+ if current_layout in self.layouts
+ else 0
+ )
+ if self.current_index >= len(self.layouts):
+ self.current_index = 0
+
+ self._save_layout_json()
+ self._apply_current_layout_index()
+ logger.info(
+ f"[KeyboardLayout] Layouts synced from config: {self.layouts}"
+ )
+
+ def _init_layout_json(self) -> None:
+ """Initialize layout registry state from cached data or application configuration."""
+ config_layouts = data.DATA().get("keyboard_layouts") or ["us", "np"]
+
+ json_data = read_json_file(self.layout_json_file)
+ if json_data and isinstance(json_data, dict):
+ saved_layouts = json_data.get("layouts", config_layouts)
+ self.layouts = (
+ config_layouts if saved_layouts != config_layouts else saved_layouts
+ )
+ self.current_index = json_data.get("current_index", 0)
+ if self.current_index >= len(self.layouts):
+ self.current_index = 0
+ else:
+ self.layouts = config_layouts
+ self.current_index = 0
+ self._save_layout_json()
+
+ def _save_layout_json(self) -> None:
+ """Persist current registry layout details to state storage."""
+ write_json_file(
+ {
+ "layouts": self.layouts,
+ "current_index": self.current_index,
+ },
+ self.layout_json_file,
+ )
+
+ def _on_file_changed(self, monitor, file, *args) -> bool:
+ GLib.timeout_add(50, self._reload_layout)
+ return False
+
+ def _reload_layout(self) -> bool:
+ """Reload the layout state file and emit event on deviation."""
+ new_layout = self._read_layout()
+ if new_layout != self._last_layout:
+ self._last_layout = new_layout
+ self.emit("layout_changed", new_layout)
+ return False
+
+ def _read_layout(self) -> str:
+ """Safely fetch the active layout from cached JSON state."""
+ json_data = read_json_file(self.layout_json_file)
+ if json_data and isinstance(json_data, dict):
+ layouts = json_data.get("layouts", [])
+ current_index = json_data.get("current_index", 0)
+ if layouts and 0 <= current_index < len(layouts):
+ return layouts[current_index]
+ return "us"
+
+ def _write_layout(self, layout: str) -> None:
+ """Write current layout selection to raw state files."""
+ try:
+ self.layout_file.parent.mkdir(parents=True, exist_ok=True)
+ self.layout_file.write_text(layout, encoding="utf-8")
+ except Exception as e:
+ logger.error(f"[KeyboardLayout] Failed writing layout state file: {e}")
+
+ def _apply_layouts_to_hyprland(self) -> None:
+ """Configure keyboard layout lists inside the Hyprland state dynamically."""
+ if not self.layouts:
+ return
+ layouts_str = ",".join(self.layouts)
+ lua_eval_cmd = f"hl.config({{ input = {{ kb_layout = '{layouts_str}' }} }})"
+ run_command(
+ [HYPRCTL_BIN, "eval", lua_eval_cmd],
+ timeout=2,
+ )
+
+ def _apply_current_layout_index(self) -> None:
+ """Update active layout index across all connected input devices."""
+ run_command(
+ [HYPRCTL_BIN, "switchxkblayout", "all", str(self.current_index)],
+ timeout=2,
+ )
+
+ def switch_to_next(self) -> bool:
+ """Cycle forward to the next registered keyboard layout."""
+ if not self.layouts:
+ logger.warning("[KeyboardLayout] No layouts configured, cannot cycle.")
+ return False
+
+ self.current_index = (self.current_index + 1) % len(self.layouts)
+ new_layout = self.layouts[self.current_index]
+
+ self._apply_current_layout_index()
+ self._save_layout_json()
+ self._write_layout(new_layout)
+
+ logger.info(
+ f"[KeyboardLayout] Switched layout successfully to '{new_layout}' "
+ f"(index {self.current_index})"
+ )
+ return True
+
+ @Property(str, "readable")
+ def current_layout(self) -> str:
+ """The currently active keyboard layout code (e.g. 'us', 'np')."""
+ return self._read_layout()
diff --git a/mine/services/network.py b/mine/services/network.py
new file mode 100644
index 0000000..217a146
--- /dev/null
+++ b/mine/services/network.py
@@ -0,0 +1,667 @@
+import gi
+from fabric.core.service import Property, Service, Signal
+from fabric.utils import (
+ GLib,
+ bulk_connect,
+ get_enum_member_name,
+ logger,
+ snake_case_to_kebab_case,
+)
+from gi.repository import NM
+
+gi.require_version("NM", "1.0")
+
+
+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) -> list | None:
+ """Returns the active connections, if available."""
+ return self._client.get_property("connections") if self._client else None
+
+ @Property(object, "readable")
+ def wifi_device(self) -> object | None:
+ """Returns the WiFi device if available."""
+ return self._wifi_device
+
+ @Property(object, "readable")
+ def ethernet_device(self) -> object | None:
+ """Returns the Ethernet device if available."""
+ return self._ethernet_device
+
+ @Property(str, "readable")
+ def primary_connection(self) -> str | None:
+ """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) -> str | None:
+ """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) -> list | None:
+ """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) -> str | None:
+ """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) -> AccessPoint | None:
+ 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") as rx_file:
+ rx_bytes = int(rx_file.read().strip())
+ with open(f"/sys/class/net/{self.iface}/statistics/tx_bytes") as tx_file:
+ tx_bytes = int(tx_file.read().strip())
+ return rx_bytes, tx_bytes
+ except FileNotFoundError:
+ return None, None
diff --git a/mine/services/password_manager.py b/mine/services/password_manager.py
new file mode 100644
index 0000000..be68af4
--- /dev/null
+++ b/mine/services/password_manager.py
@@ -0,0 +1,353 @@
+import base64
+import getpass
+import json
+import secrets
+from pathlib import Path
+from typing import Any
+
+from cryptography.fernet import Fernet
+from cryptography.hazmat.primitives import hashes
+from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
+from fabric.utils import logger
+
+import config.data as data
+
+
+class PasswordManager:
+ """Password manager service with encryption capabilities"""
+
+ def __init__(self, master_password: str = None):
+ self.data_dir = Path(data.plugins) / "passwords"
+ self.data_dir.mkdir(parents=True, exist_ok=True)
+ self.passwords_file = self.data_dir / "passwords.json"
+ self.key_file = self.data_dir / "key.key"
+ self.salt_file = self.data_dir / "salt.key"
+
+ self.master_password = master_password
+ self.fernet = None
+
+ self._initialize_password_file()
+ self._setup_encryption()
+
+ def _initialize_password_file(self):
+ if not self.passwords_file.exists():
+ with open(self.passwords_file, "w") as f:
+ json.dump({}, f)
+
+ def _setup_encryption(self):
+ if self.master_password is None:
+ # Prompt for master password securely
+ self.master_password = getpass.getpass("Enter master password: ")
+ if not self.master_password:
+ raise ValueError("Master password cannot be empty")
+
+ # Generate or load the encryption key
+ if self.key_file.exists():
+ with open(self.key_file, "rb") as f:
+ key = f.read()
+ else:
+ # Generate new key from master password
+ key = self._derive_key_from_password(self.master_password)
+ with open(self.key_file, "wb") as f:
+ f.write(key)
+
+ self.fernet = Fernet(key)
+
+ def _derive_key_from_password(self, password: str) -> bytes:
+ # Generate or load cryptographically secure salt
+ if self.salt_file.exists():
+ with open(self.salt_file, "rb") as f:
+ salt = f.read()
+ else:
+ # Generate cryptographically secure random salt
+ salt = secrets.token_bytes(32)
+ with open(self.salt_file, "wb") as f:
+ f.write(salt)
+
+ kdf = PBKDF2HMAC(
+ algorithm=hashes.SHA256(),
+ length=32,
+ salt=salt,
+ iterations=100000, # High iteration count for security
+ )
+ key = base64.urlsafe_b64encode(kdf.derive(password.encode()))
+ return key
+
+ def _encrypt_password(self, password: str) -> str:
+ if self.fernet is None:
+ raise ValueError("Encryption not initialized")
+ encrypted = self.fernet.encrypt(password.encode())
+ return base64.urlsafe_b64encode(encrypted).decode()
+
+ def _decrypt_password(self, encrypted_password: str) -> str:
+ if self.fernet is None:
+ raise ValueError("Encryption not initialized")
+ encrypted_bytes = base64.urlsafe_b64decode(encrypted_password.encode())
+ decrypted = self.fernet.decrypt(encrypted_bytes)
+ return decrypted.decode()
+
+ def add_password(
+ self,
+ name: str,
+ username: str,
+ password: str,
+ website: str = "",
+ notes: str = "",
+ ) -> dict[str, Any]:
+ passwords = self._load_passwords()
+
+ if name in passwords:
+ return {
+ "success": False,
+ "error": f"Password entry '{name}' already exists",
+ }
+
+ encrypted_password = self._encrypt_password(password)
+ passwords[name] = {
+ "username": username,
+ "password": encrypted_password,
+ "website": website,
+ "notes": notes,
+ "created_at": self._get_timestamp(),
+ "updated_at": self._get_timestamp(),
+ }
+
+ self._save_passwords(passwords)
+ return {
+ "success": True,
+ "message": f"Password entry '{name}' added successfully",
+ }
+
+ def get_password(self, name: str, decrypt: bool = True) -> dict[str, any]:
+ passwords = self._load_passwords()
+
+ if name not in passwords:
+ return {"success": False, "error": f"Password entry '{name}' not found"}
+
+ entry = passwords[name].copy()
+
+ if not decrypt:
+ return {"success": True, "data": entry}
+
+ try:
+ entry["password"] = self._decrypt_password(entry["password"])
+ except Exception as e:
+ logger.error(f"Error decrypting password: {e}")
+ return {
+ "success": False,
+ "error": f"Failed to decrypt password: {str(e)}",
+ }
+
+ return {"success": True, "data": entry}
+
+ def update_password(self, name: str, **kwargs) -> dict[str, any]:
+ passwords = self._load_passwords()
+
+ if name not in passwords:
+ return {"success": False, "error": f"Password entry '{name}' not found"}
+
+ for field, value in kwargs.items():
+ if field == "password":
+ # Encrypt the password
+ passwords[name][field] = self._encrypt_password(value)
+ elif field in ["username", "website", "notes"]:
+ passwords[name][field] = value
+ else:
+ logger.warning(f"Unknown field '{field}' ignored")
+
+ passwords[name]["updated_at"] = self._get_timestamp()
+ self._save_passwords(passwords)
+
+ return {
+ "success": True,
+ "message": f"Password entry '{name}' updated successfully",
+ }
+
+ def delete_password(self, name: str) -> dict[str, any]:
+ passwords = self._load_passwords()
+
+ if name not in passwords:
+ return {"success": False, "error": f"Password entry '{name}' not found"}
+
+ del passwords[name]
+ self._save_passwords(passwords)
+
+ return {
+ "success": True,
+ "message": f"Password entry '{name}' deleted successfully",
+ }
+
+ def list_passwords(self, decrypt_passwords: bool = False) -> dict[str, any]:
+ passwords = self._load_passwords()
+
+ if not decrypt_passwords:
+ return {"success": True, "data": passwords, "count": len(passwords)}
+
+ for name, entry in passwords.items():
+ if name == "_master_password":
+ continue # Skip master password as it's stored as plain text
+ try:
+ entry["password"] = self._decrypt_password(entry["password"])
+ except Exception as e:
+ logger.error(f"Error decrypting password for {name}: {e}")
+ entry["password"] = "[DECRYPTION_ERROR]"
+
+ return {"success": True, "data": passwords, "count": len(passwords)}
+
+ def search_passwords(self, query: str) -> dict[str, any]:
+ passwords = self._load_passwords()
+ query_lower = query.lower()
+
+ matches = {}
+ for name, entry in passwords.items():
+ # Search in name, username, website, and notes
+ searchable_text = f"{name} {entry.get('username', '')} {entry.get('website', '')} {entry.get('notes', '')}".lower()
+
+ if query_lower in searchable_text:
+ matches[name] = entry
+
+ return {"success": True, "data": matches, "count": len(matches), "query": query}
+
+ def _load_passwords(self) -> dict[str, Any]:
+ with open(self.passwords_file) as f:
+ return json.load(f)
+
+ def _save_passwords(self, passwords: dict[str, Any]):
+ with open(self.passwords_file, "w") as f:
+ json.dump(passwords, f, indent=2)
+
+ def _get_timestamp(self) -> str:
+ from datetime import datetime
+
+ return datetime.now().isoformat()
+
+ def verify_master_password(self, password: str) -> bool:
+ try:
+ if not self.key_file.exists() or not self.salt_file.exists():
+ return False
+
+ # Stored key is the urlsafe base64 encoded key bytes
+ with open(self.key_file, "rb") as f:
+ stored_key = f.read()
+
+ # Derive key from provided password using existing salt
+ derived_key = self._derive_key_from_password(password)
+
+ # Constant-time compare
+ if secrets.compare_digest(stored_key, derived_key):
+ return True
+
+ # Fallback: try decrypting any stored entry with the derived key
+ passwords = self._load_passwords()
+ if not passwords:
+ return False
+
+ test_fernet = Fernet(derived_key)
+ for name, entry in passwords.items():
+ if name.startswith("_master_"):
+ continue
+ try:
+ test_fernet.decrypt(
+ base64.urlsafe_b64decode(entry.get("password", "").encode())
+ )
+ return True
+ except Exception:
+ continue
+ return False
+ except Exception:
+ return False
+
+ def change_master_password(
+ self, old_password: str, new_password: str
+ ) -> dict[str, Any]:
+ # Verify old password by trying to decrypt with it
+ old_key = self._derive_key_from_password(old_password)
+ old_fernet = Fernet(old_key)
+
+ # Test decryption with old password
+ passwords = self._load_passwords()
+ if passwords:
+ # Try to decrypt the first password to verify old password
+ first_entry = next(iter(passwords.values()))
+ try:
+ old_fernet.decrypt(
+ base64.urlsafe_b64decode(first_entry["password"].encode())
+ )
+ except Exception:
+ return {"success": False, "error": "Invalid old master password"}
+
+ # Generate new key
+ new_key = self._derive_key_from_password(new_password)
+ new_fernet = Fernet(new_key)
+
+ # Re-encrypt all passwords with new key (skip _master_password as it's not encrypted)
+ for name, entry in passwords.items():
+ if name == "_master_password":
+ continue # Skip master password as it's stored as plain text
+ try:
+ # Decrypt with old key
+ decrypted_password = old_fernet.decrypt(
+ base64.urlsafe_b64decode(entry["password"].encode())
+ )
+ # Encrypt with new key
+ encrypted_password = new_fernet.encrypt(decrypted_password)
+ entry["password"] = base64.urlsafe_b64encode(
+ encrypted_password
+ ).decode()
+ except Exception as e:
+ return {
+ "success": False,
+ "error": f"Failed to re-encrypt password for {name}: {str(e)}",
+ }
+
+ self._save_passwords(passwords)
+ with open(self.key_file, "wb") as f:
+ f.write(new_key)
+
+ self.master_password = new_password
+ self.fernet = new_fernet
+ return {"success": True, "message": "Master password changed successfully"}
+
+ def export_passwords(
+ self, file_path: str, include_passwords: bool = True
+ ) -> dict[str, Any]:
+ passwords = self._load_passwords()
+
+ if not include_passwords:
+ export_data = {
+ "exported_at": self._get_timestamp(),
+ "include_passwords": include_passwords,
+ "passwords": passwords,
+ }
+ with open(file_path, "w") as f:
+ json.dump(export_data, f, indent=2)
+
+ return {
+ "success": True,
+ "message": f"Passwords exported to {file_path}",
+ "count": len(passwords),
+ }
+
+ # Decrypt passwords for export (skip _master_password as it's not encrypted)
+ for name, entry in passwords.items():
+ if name == "_master_password":
+ continue # Skip master password as it's stored as plain text
+ try:
+ entry["password"] = self._decrypt_password(entry["password"])
+ except Exception as e:
+ logger.error(f"Error decrypting password for {name}: {e}")
+ entry["password"] = "[DECRYPTION_ERROR]"
+
+ export_data = {
+ "exported_at": self._get_timestamp(),
+ "include_passwords": include_passwords,
+ "passwords": passwords,
+ }
+ with open(file_path, "w") as f:
+ json.dump(export_data, f, indent=2)
+
+ return {
+ "success": True,
+ "message": f"Passwords exported to {file_path}",
+ "count": len(passwords),
+ }
diff --git a/mine/services/screencapture.py b/mine/services/screencapture.py
new file mode 100644
index 0000000..566ac8e
--- /dev/null
+++ b/mine/services/screencapture.py
@@ -0,0 +1,343 @@
+from datetime import datetime
+from pathlib import Path
+
+from fabric import Service, Signal
+from fabric.core.service import Property
+from fabric.utils import (
+ Gio,
+ exec_shell_command,
+ exec_shell_command_async,
+ logger,
+ time,
+)
+
+from utils.functions import is_app_running, run_command
+
+
+class ScreenCapture(Service):
+ """Service for screen capture and recording functionality"""
+
+ @Signal
+ def screenshot_taken(self, path: str) -> None:
+ """Signal emitted when screenshot is taken."""
+
+ @Signal
+ def recording_started(self, path: str) -> None:
+ """Signal emitted when recording starts."""
+
+ @Signal
+ def recording_stopped(self, path: str) -> None:
+ """Signal emitted when recording stops."""
+
+ _instance = None
+
+ def __new__(cls):
+ if cls._instance is None:
+ cls._instance = super().__new__(cls)
+ return cls._instance
+
+ def __init__(self, **kwargs):
+ super().__init__(**kwargs)
+ self.home = Path.home()
+ self.screenshots_dir = self.home / "Pictures" / "Screenshots"
+ self.recordings_dir = self.home / "Videos" / "Recordings"
+ self.recording_file = Path("/tmp/recording.txt")
+ self.recording_start_time_file = Path("/tmp/recording_start_time.txt")
+
+ self.screenshots_dir.mkdir(parents=True, exist_ok=True)
+ self.recordings_dir.mkdir(parents=True, exist_ok=True)
+
+ def notify_send(self, title, message, icon=None, actions=None):
+ cmd = ["notify-send", "-a", "Modus"]
+
+ if icon:
+ cmd.extend(["-i", str(icon)])
+
+ if actions:
+ for action in actions:
+ cmd.extend(["-A", f"{action}={action}"])
+
+ cmd.extend([title, message])
+ return run_command(cmd)
+
+ def send_screenshot_notification(self, file_path=None):
+ cmd = ["notify-send"]
+ cmd.extend(
+ [
+ "-A",
+ "files=Show in Files",
+ "-A",
+ "view=View",
+ "-A",
+ "edit=Edit",
+ "-i",
+ "camera-photo-symbolic",
+ "-a",
+ "Fabric Screenshot Utility",
+ "-h",
+ f"STRING:image-path:{file_path}",
+ "Screenshot Saved",
+ f"Saved Screenshot at {file_path}",
+ ]
+ if file_path
+ else ["Screenshot Sent to Clipboard"]
+ )
+
+ proc: Gio.Subprocess = Gio.Subprocess.new(cmd, Gio.SubprocessFlags.STDOUT_PIPE)
+
+ def do_callback(process: Gio.Subprocess, task: Gio.Task):
+ try:
+ _, stdout, stderr = process.communicate_utf8_finish(task)
+ except Exception:
+ logger.error(
+ f"[SCREENSHOT] Failed read notification action with error {stderr}"
+ )
+ return
+
+ match stdout.strip("\n"):
+ case "files":
+ exec_shell_command_async(f"xdg-open {self.screenshots_dir}")
+ case "view":
+ exec_shell_command_async(f"xdg-open {file_path}")
+ case "edit":
+ exec_shell_command_async(f"satty -f {file_path}")
+
+ proc.communicate_utf8_async(None, None, do_callback)
+ self.screenshot_taken(file_path)
+
+ def send_recording_notification(self, file_path):
+ cmd = ["notify-send"]
+ cmd.extend(
+ [
+ "-A",
+ "files=Show in Files",
+ "-A",
+ "view=View",
+ "-i",
+ "camera-video-symbolic",
+ "-a",
+ "Fabric Screenshot Utility",
+ "Screencast Saved",
+ f"Saved Screencast at {file_path}",
+ ]
+ )
+
+ proc: Gio.Subprocess = Gio.Subprocess.new(cmd, Gio.SubprocessFlags.STDOUT_PIPE)
+
+ def do_callback(process: Gio.Subprocess, task: Gio.Task):
+ try:
+ _, stdout, stderr = process.communicate_utf8_finish(task)
+ except Exception:
+ logger.error(
+ f"[SCREENCAST] Failed read notification action with error {stderr}"
+ )
+ return
+
+ match stdout.strip("\n"):
+ case "files":
+ exec_shell_command_async(f"xdg-open {self.recordings_dir}")
+ case "view":
+ exec_shell_command_async(f"xdg-open {file_path}")
+
+ proc.communicate_utf8_async(None, None, do_callback)
+ self.recording_stopped(file_path)
+
+ def check_wf_recorder(self):
+ if not is_app_running("wf-recorder"):
+ return False
+
+ run_command(["pkill", "-INT", "-x", "wf-recorder"])
+
+ if self.recording_file.exists():
+ recording_file = self.recording_file.read_text().strip()
+ self.send_recording_notification(recording_file)
+
+ if self.recording_start_time_file.exists():
+ self.recording_start_time_file.unlink()
+
+ return True
+
+ def get_default_monitor_source(self) -> str | None:
+ """Return the monitor source of the default sink (system audio).
+
+ wf-recorder defaults to the mic input. To capture the audio coming
+ out of the speakers (e.g. a Zoom meeting), we need the monitor of
+ the default sink (e.g. ``alsa_output.pci-...monitor``).
+ """
+ try:
+ result = run_command(["pactl", "get-default-sink"])
+ if result.returncode == 0:
+ sink = (result.stdout or "").strip()
+ if sink:
+ return f"{sink}.monitor"
+ except FileNotFoundError:
+ pass
+
+ # PipeWire fallback: parse `wpctl status` for the default sink
+ try:
+ result = run_command(["wpctl", "status"])
+ if result.returncode == 0:
+ lines = (result.stdout or "").splitlines()
+ in_sinks = False
+ default_id = None
+ sinks: dict[str, str] = {}
+ for line in lines:
+ stripped = line.strip()
+ if stripped.startswith("Sinks:"):
+ in_sinks = True
+ continue
+ if in_sinks and stripped and not stripped.startswith("─"):
+ if "Sources:" in stripped or "Filters:" in stripped:
+ in_sinks = False
+ continue
+ if stripped.startswith("*"):
+ default_id = stripped.lstrip("*").strip().split(".")[0]
+ parts = stripped.lstrip("* ").split(".", 1)
+ if len(parts) == 2:
+ sinks[parts[0]] = parts[1].split(" [")[0].strip()
+ if default_id and default_id in sinks:
+ return f"{sinks[default_id]}.monitor"
+ except FileNotFoundError:
+ pass
+
+ return None
+
+ def record_video(self, output_file, *args):
+ cmd = [
+ "wf-recorder",
+ *args,
+ "-f",
+ str(output_file),
+ "-c",
+ "libvpx-vp9",
+ "--pixel-format",
+ "yuv420p",
+ "-F",
+ "eq=brightness=0.12:contrast=1.1",
+ ]
+ return run_command(cmd)
+
+ def record_video_noaudio(self, output_file, *args):
+ cmd = [
+ "wf-recorder",
+ *args,
+ "-f",
+ str(output_file),
+ "-c",
+ "libvpx-vp9",
+ "--pixel-format",
+ "yuv420p",
+ "-F",
+ "eq=brightness=0.12:contrast=1.1",
+ "--no-audio",
+ ]
+ return run_command(cmd)
+
+ def screenshot(self, fullscreen=False, save_copy=True):
+ timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
+
+ command = [
+ "hyprshot",
+ "-s",
+ "-o",
+ str(self.screenshots_dir),
+ "-f",
+ f"{timestamp}_fabric_screenshot.png",
+ ]
+
+ command.extend(
+ ["-m", "active", "-m", "output"] if fullscreen else ["-m", "region"]
+ )
+ command.append("-- ls") # This is so there is something in stdout
+
+ try:
+ exec_shell_command_async(
+ " ".join(command),
+ self._after_screenshot,
+ )
+ except Exception as e:
+ logger.error(f"Screenshot failed: {e}")
+ return False
+
+ return True
+
+ def _after_screenshot(self, *_):
+ try:
+ screenshot_files = list(self.screenshots_dir.glob("*.png"))
+ if screenshot_files:
+ latest_file = max(screenshot_files, key=lambda f: f.stat().st_mtime)
+ self.send_screenshot_notification(file_path=str(latest_file))
+ time.sleep(0.2)
+ else:
+ # No file found after command: likely user cancelled the selection
+ self.notify_send(
+ "Screenshot cancelled",
+ "Selection was cancelled",
+ icon="camera-photo-symbolic",
+ )
+ except Exception as e:
+ logger.error(f"Screenshot notification failed: {e}")
+
+ def record(self, fullscreen=False, no_audio=False):
+ if self.is_recording:
+ logger.error(
+ "[SCREENRECORD] Another instance of wf-recorder is already running"
+ )
+ return
+
+ timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
+ output_file = self.recordings_dir / f"{timestamp}.mp4"
+
+ self._current_recording_path = str(output_file)
+ self.recording_file.write_text(str(output_file))
+ self.recording_start_time_file.write_text(str(int(time.time())))
+
+ if fullscreen:
+ area = ""
+ else:
+ geometry = exec_shell_command("slurp")
+ if not geometry or not str(geometry).strip():
+ self.notify_send(
+ "Recording cancelled",
+ "Selection was cancelled",
+ icon="camera-video-symbolic",
+ )
+ return False
+ area = f"-g '{geometry}'"
+
+ if no_audio:
+ command = f"wf-recorder --file={output_file} --pixel-format yuv420p --no-audio {area}"
+ else:
+ monitor = self.get_default_monitor_source()
+ if monitor:
+ logger.info(f"[SCREENRECORD] Recording system audio from: {monitor}")
+ command = (
+ f"wf-recorder --file={output_file} --pixel-format yuv420p "
+ f"--audio={monitor} {area}"
+ )
+ else:
+ logger.warning(
+ "[SCREENRECORD] No system monitor source found, "
+ "falling back to default audio source (mic)"
+ )
+ command = (
+ f"wf-recorder --file={output_file} --pixel-format yuv420p "
+ f"--audio {area}"
+ )
+
+ exec_shell_command_async(command)
+ self.recording_started(str(output_file))
+
+ return True
+
+ def stop_recording(self):
+ exec_shell_command_async("killall -INT wf-recorder")
+ self.recording_stopped(self._current_recording_path)
+ return True
+
+ @Property(bool, "readable", default_value=False)
+ def is_recording(self):
+ return is_app_running("wf-recorder")
+
+
+screen_capture_service = ScreenCapture()
diff --git a/mine/services/tmux.py b/mine/services/tmux.py
new file mode 100644
index 0000000..75f551d
--- /dev/null
+++ b/mine/services/tmux.py
@@ -0,0 +1,283 @@
+import threading
+from pathlib import Path
+
+from fabric.core.service import Property, Service, Signal
+from fabric.utils import exec_shell_command_async, idle_add, logger, os
+
+from utils.functions import (
+ run_command,
+ terminal_open_in_directory_command,
+)
+
+
+class TmuxService(Service):
+ _instance = None
+
+ def __new__(cls):
+ if cls._instance is None:
+ cls._instance = super().__new__(cls)
+ return cls._instance
+
+ @staticmethod
+ def get_initial():
+ if TmuxService._instance is None:
+ TmuxService._instance = TmuxService()
+ return TmuxService._instance
+
+ @Signal
+ def sessions_changed(self) -> None:
+ pass
+
+ def __init__(self, **kwargs):
+ if hasattr(self, "_initialized") and self._initialized:
+ return
+ super().__init__(**kwargs)
+ self._initialized = True
+ self._sessions: dict[str, str] = {}
+ self._monitor_thread = None
+ self._stop_monitoring = threading.Event()
+ self._last_sessions_hash = None
+ self._monitoring_started = False
+ self._tmux_available = None # Cache tmux availability check
+
+ # Don't scan sessions during init to avoid blocking
+ # This will be done when monitoring starts
+
+ def _is_tmux_available(self) -> bool:
+ if self._tmux_available is None:
+ self._tmux_available = (
+ run_command(["tmux", "-V"], timeout=5).returncode == 0
+ )
+ return self._tmux_available
+
+ def _start_session_monitoring(self):
+ self._monitor_thread = threading.Thread(
+ target=self._monitor_sessions, daemon=True
+ )
+ self._monitor_thread.start()
+
+ def _monitor_sessions(self):
+ while not self._stop_monitoring.is_set():
+ try:
+ self._scan_sessions()
+ # Use a shorter timeout to make the loop more responsive
+ if self._stop_monitoring.wait(1.0):
+ break
+ except Exception as e:
+ logger.error(f"Tmux monitoring error: {e}")
+ break
+
+ def _scan_sessions(self):
+ result = run_command(
+ ["tmux", "list-sessions", "-F", "#{session_name}"],
+ timeout=10,
+ )
+ new_sessions = {}
+ if result.returncode == 0 and isinstance(result.stdout, str):
+ for line in result.stdout.strip().split("\n"):
+ if line.strip():
+ new_sessions[line.strip()] = line.strip()
+
+ new_hash = hash(frozenset(new_sessions.keys()))
+ if new_hash == self._last_sessions_hash:
+ return
+ self._last_sessions_hash = new_hash
+ self._sessions = new_sessions
+ idle_add(self.emit, "sessions_changed")
+
+ def _ensure_monitoring_started(self):
+ """Start monitoring if not already started."""
+ if not self._monitoring_started and self._is_tmux_available():
+ self._monitoring_started = True
+ # Scan sessions once before starting monitoring
+ self._scan_sessions()
+ self._start_session_monitoring()
+
+ def refresh_sessions(self):
+ self._ensure_monitoring_started()
+ self._scan_sessions()
+
+ def stop(self):
+ if self._monitor_thread and self._monitor_thread.is_alive():
+ self._stop_monitoring.set()
+ self._monitor_thread.join(timeout=2.0)
+ if self._monitor_thread.is_alive():
+ logger.warning("Tmux monitor thread did not stop gracefully")
+
+ @Property(list, "readable")
+ def session_names(self) -> list[str]:
+ self._ensure_monitoring_started()
+ return list(self._sessions.keys())
+
+ @Property(int, "readable")
+ def session_count(self) -> int:
+ self._ensure_monitoring_started()
+ return len(self._sessions)
+
+ def list_sessions(self) -> dict:
+ """List all tmux sessions with detailed info."""
+ self._ensure_monitoring_started()
+ try:
+ result = run_command(
+ [
+ "tmux",
+ "list-sessions",
+ "-F",
+ "#{session_name}:#{session_windows}:#{session_attached}:#{session_created_string}",
+ ],
+ timeout=5,
+ )
+ if result.returncode != 0:
+ if "no server running" in (result.stderr or "").lower():
+ return {"success": True, "error": None, "data": []}
+ return {
+ "success": False,
+ "error": result.stderr or "Unknown error",
+ "data": [],
+ }
+
+ sessions = []
+ if isinstance(result.stdout, str):
+ for line in result.stdout.strip().split("\n"):
+ if not line.strip():
+ continue
+ parts = line.split(":")
+ if len(parts) >= 4:
+ sessions.append(
+ {
+ "name": parts[0],
+ "windows": int(parts[1]) if parts[1].isdigit() else 0,
+ "attached": parts[2] == "1",
+ "created": parts[3],
+ }
+ )
+ return {"success": True, "error": None, "data": sessions}
+ except Exception as e:
+ return {"success": False, "error": str(e), "data": []}
+
+ def _expand_path(self, path: str) -> str:
+ if not path:
+ return str(Path.home())
+
+ if not (path.startswith("/") or path.startswith("~")):
+ path = f"~/{path}"
+
+ expanded = os.path.expanduser(path)
+ absolute = os.path.abspath(expanded)
+
+ return absolute
+
+ def new_session(
+ self, session_name: str = None, terminal: str = "kitty", directory: str = None
+ ) -> dict:
+ self._ensure_monitoring_started()
+ if not session_name:
+ counter = 0
+ while str(counter) in self._sessions:
+ counter += 1
+ session_name = str(counter)
+
+ clean_name = session_name.strip().replace(" ", "_")
+ if clean_name in self._sessions:
+ return {
+ "success": False,
+ "error": f"Session '{clean_name}' already exists",
+ "data": None,
+ }
+
+ working_dir = self._expand_path(directory)
+
+ result = run_command(
+ [
+ "tmux",
+ "new-session",
+ "-d",
+ "-s",
+ clean_name,
+ "-c",
+ working_dir,
+ ],
+ timeout=10,
+ )
+
+ if result.returncode != 0:
+ return {
+ "success": False,
+ "error": result.stderr or "Failed to create session",
+ "data": None,
+ }
+
+ self._scan_sessions()
+ self._open_in_terminal(clean_name, terminal, working_dir)
+ return {"success": True, "error": None, "data": clean_name}
+
+ def attach_session(
+ self, session_name: str, terminal: str = "kitty", directory: str = None
+ ) -> bool:
+ self._ensure_monitoring_started()
+ if session_name not in self._sessions:
+ return False
+ working_dir = self._expand_path(directory)
+ return self._open_in_terminal(session_name, terminal, working_dir)
+
+ def kill_session(self, session_name: str) -> dict:
+ self._ensure_monitoring_started()
+ if session_name not in self._sessions:
+ return {
+ "success": False,
+ "error": f"Session '{session_name}' not found",
+ "data": None,
+ }
+
+ result = run_command(["tmux", "kill-session", "-t", session_name], timeout=10)
+
+ if result.returncode != 0:
+ return {
+ "success": False,
+ "error": result.stderr or "Failed to kill session",
+ "data": None,
+ }
+
+ self._scan_sessions()
+ return {"success": True, "error": None, "data": None}
+
+ def rename_session(self, old_name: str, new_name: str) -> dict:
+ self._ensure_monitoring_started()
+ if old_name not in self._sessions:
+ return {
+ "success": False,
+ "error": f"Session '{old_name}' not found",
+ "data": None,
+ }
+ if new_name in self._sessions:
+ return {
+ "success": False,
+ "error": f"Session '{new_name}' already exists",
+ "data": None,
+ }
+
+ result = run_command(
+ ["tmux", "rename-session", "-t", old_name, new_name], timeout=10
+ )
+
+ if result.returncode != 0:
+ return {
+ "success": False,
+ "error": result.stderr or "Failed to rename session",
+ "data": None,
+ }
+
+ self._scan_sessions()
+ return {"success": True, "error": None, "data": new_name}
+
+ def _open_in_terminal(
+ self, session_name: str, terminal: str, directory: str
+ ) -> bool:
+ cmd = terminal_open_in_directory_command(
+ terminal,
+ directory,
+ title=f"tmux: {session_name}",
+ inner_command=f"tmux attach-session -t {session_name}",
+ )
+ exec_shell_command_async(cmd)
+ return True
diff --git a/mine/services/todo.py b/mine/services/todo.py
new file mode 100644
index 0000000..675c7fa
--- /dev/null
+++ b/mine/services/todo.py
@@ -0,0 +1,277 @@
+import json
+from datetime import datetime
+from pathlib import Path
+from typing import Any
+
+from fabric import Service, Signal
+
+import config.data as data
+
+
+class TodoService(Service):
+ """Service to manage todo items with CRUD operations"""
+
+ @Signal
+ def todo_changed(self) -> None:
+ """Signal emitted when todo list changes."""
+
+ _instance = None
+
+ def __new__(cls):
+ if cls._instance is None:
+ cls._instance = super().__new__(cls)
+ return cls._instance
+
+ def __init__(self, todos_file: str | None = None, **kwargs):
+ super().__init__(**kwargs)
+ if todos_file:
+ self.todos_file = Path(todos_file)
+ else:
+ self.todos_file = Path(data.plugins) / "todos.json"
+ self._initialize_todos_file()
+
+ def _initialize_todos_file(self):
+ self.todos_file.touch()
+ if self.todos_file.stat().st_size == 0:
+ with open(self.todos_file, "w") as f:
+ json.dump([], f)
+
+ def _load_todos(self) -> list[dict[str, Any]]:
+ with open(self.todos_file) as f:
+ return json.load(f)
+
+ def _save_todos(self, todos: list[dict[str, Any]]) -> None:
+ with open(self.todos_file, "w") as f:
+ json.dump(todos, f, indent=2)
+
+ def _get_timestamp(self) -> str:
+ return datetime.now().isoformat()
+
+ def _get_next_id(self, todos: list[dict[str, Any]]) -> int:
+ return max((todo.get("id", 0) for todo in todos), default=0) + 1
+
+ def add_todo(
+ self,
+ title: str,
+ description: str = "",
+ priority: str = "medium",
+ due_date: str | None = None,
+ tags: list[str] = None,
+ ) -> dict[str, Any]:
+ if not title or not title.strip():
+ return {"success": False, "error": "Todo title cannot be empty"}
+
+ todos = self._load_todos()
+
+ # Validate priority
+ if priority not in ["low", "medium", "high"]:
+ priority = "medium"
+
+ new_todo = {
+ "id": self._get_next_id(todos),
+ "title": title.strip(),
+ "description": description.strip() if description else "",
+ "priority": priority,
+ "status": "pending",
+ "due_date": due_date,
+ "tags": tags or [],
+ "created_at": self._get_timestamp(),
+ "updated_at": self._get_timestamp(),
+ }
+
+ todos.append(new_todo)
+ self._save_todos(todos)
+ self.emit("todo_changed")
+ return {
+ "success": True,
+ "message": f"Todo '{title}' added successfully",
+ "data": new_todo,
+ }
+
+ def mark_done(self, todo_id: int) -> dict[str, Any]:
+ todos = self._load_todos()
+
+ for todo in todos:
+ if todo.get("id") == todo_id:
+ if todo.get("status") == "completed":
+ return {
+ "success": False,
+ "error": f"Todo '{todo.get('title')}' is already completed",
+ }
+
+ todo["status"] = "completed"
+ todo["updated_at"] = self._get_timestamp()
+ self._save_todos(todos)
+ self.emit("todo_changed")
+ return {
+ "success": True,
+ "message": f"Todo '{todo.get('title')}' marked as completed",
+ "data": todo,
+ }
+
+ return {"success": False, "error": f"Todo with ID {todo_id} not found"}
+
+ def mark_pending(self, todo_id: int) -> dict[str, Any]:
+ todos = self._load_todos()
+
+ for todo in todos:
+ if todo.get("id") == todo_id:
+ if todo.get("status") == "pending":
+ return {
+ "success": False,
+ "error": f"Todo '{todo.get('title')}' is already pending",
+ }
+
+ todo["status"] = "pending"
+ todo["updated_at"] = self._get_timestamp()
+ self._save_todos(todos)
+ self.emit("todo_changed")
+ return {
+ "success": True,
+ "message": f"Todo '{todo.get('title')}' marked as pending",
+ "data": todo,
+ }
+
+ return {"success": False, "error": f"Todo with ID {todo_id} not found"}
+
+ def remove_todo(self, todo_id: int) -> dict[str, Any]:
+ todos = self._load_todos()
+
+ for i, todo in enumerate(todos):
+ if todo.get("id") == todo_id:
+ removed_todo = todos.pop(i)
+ self._save_todos(todos)
+ self.emit("todo_changed")
+ return {
+ "success": True,
+ "message": f"Todo '{removed_todo.get('title')}' removed successfully",
+ "data": removed_todo,
+ }
+
+ return {"success": False, "error": f"Todo with ID {todo_id} not found"}
+
+ def edit_todo(self, todo_id: int, **kwargs) -> dict[str, Any]:
+ todos = self._load_todos()
+
+ for todo in todos:
+ if todo.get("id") == todo_id:
+ if "title" in kwargs:
+ new_title = kwargs["title"]
+ if not new_title or not new_title.strip():
+ return {"success": False, "error": "Todo title cannot be empty"}
+ todo["title"] = new_title.strip()
+
+ if "description" in kwargs:
+ todo["description"] = (
+ kwargs["description"].strip() if kwargs["description"] else ""
+ )
+
+ if "priority" in kwargs and kwargs["priority"] in [
+ "low",
+ "medium",
+ "high",
+ ]:
+ todo["priority"] = kwargs["priority"]
+
+ if "due_date" in kwargs:
+ todo["due_date"] = kwargs["due_date"]
+
+ if "tags" in kwargs:
+ todo["tags"] = kwargs["tags"] or []
+
+ if "status" in kwargs and kwargs["status"] in ["pending", "completed"]:
+ todo["status"] = kwargs["status"]
+
+ todo["updated_at"] = self._get_timestamp()
+ self._save_todos(todos)
+ self.emit("todo_changed")
+ return {
+ "success": True,
+ "message": f"Todo '{todo.get('title')}' updated successfully",
+ "data": todo,
+ }
+
+ return {"success": False, "error": f"Todo with ID {todo_id} not found"}
+
+ def get_todo(self, todo_id: int) -> dict[str, Any]:
+ todos = self._load_todos()
+
+ for todo in todos:
+ if todo.get("id") == todo_id:
+ return {"success": True, "data": todo}
+
+ return {"success": False, "error": f"Todo with ID {todo_id} not found"}
+
+ def list_todos(
+ self,
+ status: str | None = None,
+ priority: str | None = None,
+ tag: str | None = None,
+ ) -> dict[str, Any]:
+ todos = self._load_todos()
+
+ filtered_todos = [
+ todo
+ for todo in todos
+ if (not status or todo.get("status") == status)
+ and (not priority or todo.get("priority") == priority)
+ and (not tag or tag in todo.get("tags", []))
+ ]
+
+ return {
+ "success": True,
+ "data": filtered_todos,
+ "count": len(filtered_todos),
+ "total": len(todos),
+ }
+
+ def search_todos(self, query: str) -> dict[str, Any]:
+ todos = self._load_todos()
+ query_lower = query.lower()
+
+ matches = [
+ todo
+ for todo in todos
+ if query_lower in todo.get("title", "").lower()
+ or query_lower in todo.get("description", "").lower()
+ ]
+
+ return {"success": True, "data": matches, "count": len(matches), "query": query}
+
+ def clear_completed(self) -> dict[str, Any]:
+ todos = self._load_todos()
+ original_count = len(todos)
+
+ pending_todos = [todo for todo in todos if todo.get("status") != "completed"]
+ removed_count = original_count - len(pending_todos)
+
+ self._save_todos(pending_todos)
+ self.emit("todo_changed")
+ return {
+ "success": True,
+ "message": f"Removed {removed_count} completed todos",
+ "removed_count": removed_count,
+ }
+
+ def get_stats(self) -> dict[str, Any]:
+ todos = self._load_todos()
+ total = len(todos)
+ pending = len([todo for todo in todos if todo.get("status") == "pending"])
+ completed = len([todo for todo in todos if todo.get("status") == "completed"])
+
+ priority_stats = {
+ "low": len([todo for todo in todos if todo.get("priority") == "low"]),
+ "medium": len([todo for todo in todos if todo.get("priority") == "medium"]),
+ "high": len([todo for todo in todos if todo.get("priority") == "high"]),
+ }
+
+ return {
+ "success": True,
+ "data": {
+ "total": total,
+ "pending": pending,
+ "completed": completed,
+ "completion_rate": (completed / total * 100) if total > 0 else 0,
+ "priority_stats": priority_stats,
+ },
+ }
diff --git a/mine/services/wallpaper.py b/mine/services/wallpaper.py
new file mode 100644
index 0000000..5472659
--- /dev/null
+++ b/mine/services/wallpaper.py
@@ -0,0 +1,107 @@
+import hashlib
+import mimetypes
+
+from fabric.core import Service, Signal
+from fabric.utils import GdkPixbuf as gdk_pixbuf
+from fabric.utils import exec_shell_command_async, os
+
+from config.data import (
+ WALLPAPER_PATH,
+ WALLPAPER_THUMBS_PATH,
+ WALLPAPERS_THUMBNAILS_SIZE,
+)
+
+for path in (WALLPAPER_PATH, WALLPAPER_THUMBS_PATH):
+ os.makedirs(path, exist_ok=True)
+
+
+def get_thumbnail_filename(image_path: str) -> str:
+ key = hashlib.sha256(image_path.encode()).hexdigest()
+ size = WALLPAPERS_THUMBNAILS_SIZE
+ return f"{key}_{size}x{size}.webp"
+
+
+def create_thumbnail(
+ image_path: str, thumbnail_path: str, size: int = WALLPAPERS_THUMBNAILS_SIZE
+) -> bool:
+ """Create WebP thumbnail with proper memory cleanup to prevent leaks."""
+ pixbuf = None
+ try:
+ # Load image at scaled size to minimize memory usage
+ pixbuf = gdk_pixbuf.Pixbuf.new_from_file_at_scale(image_path, size, size, True)
+ # Save as WebP with quality 85 for better compression (60-80% smaller than PNG)
+ result = pixbuf.savev(thumbnail_path, "webp", ["quality"], ["85"])
+ return bool(result)
+ except Exception:
+ return False
+ finally:
+ # Explicitly clear pixbuf reference to free memory
+ if pixbuf is not None:
+ del pixbuf
+
+
+def generate_colors_from_wallpaper(image_path: str) -> bool:
+ cmd = f'matugen image "{image_path}"'
+ return bool(exec_shell_command_async(cmd))
+
+
+class WallpaperService(Service):
+ @Signal
+ def wallpaper_ready(self, image_path: str, thumbnail_path: str) -> None: ...
+
+ @Signal
+ def wallpaper_set(self, image_path: str) -> None: ...
+
+ @Signal
+ def colors_generated(self, image_path: str) -> None: ...
+
+ def __init__(self, **kwargs):
+ super().__init__(**kwargs)
+ self._processed_count = 0
+
+ def get_all_wallpapers(self) -> list[str]:
+ all_files = []
+ for file in os.listdir(WALLPAPER_PATH):
+ file_full = WALLPAPER_PATH + "/" + file
+ mt = mimetypes.guess_type(file_full)[0]
+ if mt and mt.startswith("image/"):
+ all_files.append(file_full)
+ return all_files
+
+ def get_thumbnail_path(self, image_path: str) -> str:
+ thumbnail_filename = get_thumbnail_filename(image_path)
+ return WALLPAPER_THUMBS_PATH + "/" + thumbnail_filename
+
+ def has_thumbnail(self, image_path: str) -> bool:
+ thumbnail_path = self.get_thumbnail_path(image_path)
+ return os.path.isfile(thumbnail_path)
+
+ def set_wallpaper(self, image_path: str):
+ exec_shell_command_async(
+ f"awww img {image_path} --transition-type none --transition-duration 0 --transition-fps 60"
+ )
+ self.wallpaper_set(image_path)
+
+ target_path = os.path.abspath(image_path)
+ home_dir = os.path.expanduser("~")
+ link_path = os.path.join(home_dir, ".current.wall")
+
+ if os.path.islink(link_path) or os.path.exists(link_path):
+ os.remove(link_path)
+
+ os.symlink(target_path, link_path)
+
+ exec_shell_command_async(f'matugen image "{image_path}" --source-color-index 0')
+ self.colors_generated(image_path)
+
+ def get_wallpaper_info(self, image_path: str) -> dict:
+ """Get information about a wallpaper"""
+ stat = os.stat(image_path)
+ return {
+ "path": image_path,
+ "filename": os.path.basename(image_path),
+ "size": stat.st_size,
+ "modified": stat.st_mtime,
+ "has_thumbnail": self.has_thumbnail(image_path),
+ "thumbnail_path": self.get_thumbnail_path(image_path),
+ }
diff --git a/mine/styles/calendar.css b/mine/styles/calendar.css
new file mode 100644
index 0000000..7f8c034
--- /dev/null
+++ b/mine/styles/calendar.css
@@ -0,0 +1,53 @@
+#calendar-window {
+ background-color: var(--background);
+ border-radius: 8px;
+ padding: 12px;
+}
+
+#calendar-widget {
+ background-color: transparent;
+ min-width: 280px;
+}
+
+#cal-header {
+ margin-bottom: 8px;
+}
+
+#cal-title {
+ margin-left: 10px;
+ margin-top: 10px;
+}
+
+#cal-nav-btn {
+ padding: 2px 6px;
+ margin-top: 10px;
+}
+
+#cal-nav-btn:hover {
+ background-color: var(--surface);
+}
+
+#cal-weekdays {
+ margin-bottom: 4px;
+}
+
+#cal-weekday-label {
+ font-weight: 500;
+ min-width: 40px;
+ padding: 2px 0;
+}
+
+#cal-grid {
+ min-height: 180px;
+}
+
+#cal-day-today label {
+ background: transparent;
+ border: none;
+ box-shadow: none;
+ color: var(--primary);
+ font-weight: bold;
+ border-radius: 6px;
+ min-width: 40px;
+ min-height: 36px;
+}
diff --git a/mine/styles/desktop.css b/mine/styles/desktop.css
new file mode 100644
index 0000000..01beff2
--- /dev/null
+++ b/mine/styles/desktop.css
@@ -0,0 +1,12 @@
+#clock-widget>#date>label {
+ font-size: 1.5rem;
+ margin-bottom: 5px;
+ color: var(--primary);
+}
+
+#clock-widget>#time>label {
+ margin: 0rem;
+ font-weight: 900;
+ font-size: 5.5rem;
+ color: var(--primary);
+}
diff --git a/mine/styles/launcher.css b/mine/styles/launcher.css
new file mode 100644
index 0000000..290b97f
--- /dev/null
+++ b/mine/styles/launcher.css
@@ -0,0 +1,448 @@
+@keyframes app-slot-shine {
+ 50% {
+ background-color: alpha(var(--surface-bright), 0.4);
+ margin: 1rem;
+ }
+
+ 100% {
+ background-color: alpha(var(--surface-bright), 0.2);
+ margin: inherit;
+ }
+}
+
+@keyframes overshooting {
+ 80% {
+ margin: calc(0.2rem + 0.3rem);
+ padding: calc(0.3rem + 0.2rem);
+ }
+
+ 100% {
+ margin: 0.2rem;
+ padding: 0.3rem;
+ }
+}
+
+#launcher.overshoot {
+ animation: 350ms cubic-bezier(0.2, 1, 0.8, 1) overshooting;
+}
+
+#launcher {
+ margin: 0.2rem;
+ padding: 0.3rem;
+ border-radius: 1rem;
+ background-color: var(--shadow);
+}
+
+.launcher-scroll-clip {
+ border-radius: calc(1rem - 0.3rem);
+}
+
+.app-search-entry {
+ color: var(--foreground);
+}
+
+.app-search-header.shine {
+ @apply background-shine(var(--surface-bright));
+ animation: 1s ease-in-out shine infinite;
+}
+
+.app-search-header,
+.app-slot {
+ padding: 0.5rem;
+ border: solid 1px transparent;
+
+ border-radius: calc(1rem - 0.3rem);
+ background-color: alpha(var(--surface-bright), 0.2);
+
+ transition: border 0.1s ease-in-out;
+ transition: background-color 0.2s ease-in-out;
+}
+
+.app-slot.wallpaper>box {
+ min-height: 4rem;
+ transition: all 0.2s cubic-bezier(0.2, 1, 0.8, 1);
+ background-size: cover;
+}
+
+.app-slot.wallpaper:focus > box,
+.app-slot.wallpaper.selected > box {
+ min-height: 12rem;
+}
+
+.app-slot:hover {
+ border-color: var(--outline);
+}
+
+.app-slot:focus {
+ border-color: var(--outline);
+ background-color: var(--surface);
+}
+
+.app-slot.selected {
+ border-color: var(--outline);
+ background-color: var(--surface);
+}
+
+.app-slot.shine {
+ animation: app-slot-shine;
+}
+
+.app-description {
+ color: darker(var(--foreground));
+ font-size: 12px;
+ font-weight: normal;
+}
+
+.calc-expression {
+ color: var(--foreground);
+ font-size: 14px;
+ font-weight: normal;
+}
+
+.calc-result-text {
+ color: var(--foreground);
+ font-size: 16px;
+ font-weight: bold;
+}
+
+.emoji-slot {
+ min-height: 3rem;
+}
+
+.emoji-char {
+ font-size: 24px;
+ min-width: 2rem;
+}
+
+.emoji-name {
+ color: var(--foreground);
+ font-size: 14px;
+ font-weight: normal;
+}
+
+.emoji-no-results {
+ min-height: 3rem;
+}
+
+.emoji-no-results-text {
+ color: darker(var(--foreground));
+ font-size: 14px;
+ font-style: italic;
+}
+
+.clipboard-no-results {
+ min-height: 3rem;
+}
+
+.clipboard-no-results-text {
+ color: darker(var(--foreground));
+ font-size: 14px;
+ font-style: italic;
+}
+
+.clipboard-image {
+ min-height: 4rem;
+ transition: all 0.2s cubic-bezier(0.2, 1, 0.8, 1);
+ background-size: cover;
+}
+
+.clipboard-image:focus,
+.clipboard-image.selected {
+ min-height: 12rem;
+}
+
+.clipboard-text {
+ min-height: 3rem;
+}
+
+.clipboard-icon {
+ font-size: 20px;
+ min-width: 2rem;
+}
+
+.clipboard-content {
+ color: var(--foreground);
+ font-size: 14px;
+ font-weight: normal;
+ margin-left: 1rem;
+}
+
+/* OTP Plugin Styles */
+.otp-slot {
+ min-height: 3rem;
+}
+
+.otp-account-name {
+ color: var(--foreground);
+ font-size: 14px;
+ font-weight: normal;
+}
+
+.otp-code {
+ color: var(--foreground);
+ font-size: 18px;
+ font-weight: bold;
+ font-family: monospace;
+ letter-spacing: 2px;
+}
+
+.otp-timer {
+ color: darker(var(--foreground));
+ font-size: 12px;
+ font-weight: normal;
+}
+
+.otp-no-accounts,
+.otp-no-results {
+ min-height: 3rem;
+}
+
+.otp-no-accounts-text,
+.otp-no-results-text {
+ color: darker(var(--foreground));
+ font-size: 14px;
+ font-style: italic;
+}
+
+.otp-help,
+.otp-success,
+.otp-error {
+ min-height: 3rem;
+}
+
+.otp-help-text,
+.otp-success-text,
+.otp-error-text {
+ font-size: 14px;
+ font-weight: normal;
+}
+
+.otp-success-text {
+ color: var(--success);
+}
+
+.otp-error-text {
+ color: var(--error);
+}
+
+.otp-help-text {
+ color: darker(var(--foreground));
+ font-style: italic;
+}
+
+/* Password Manager Plugin Styles */
+.password-slot {
+ min-height: 3rem;
+}
+
+.password-name {
+ color: var(--foreground);
+ font-size: 16px;
+ font-weight: bold;
+}
+
+.password-username {
+ color: darker(var(--foreground));
+ font-size: 14px;
+ font-weight: normal;
+}
+
+.password-website {
+ color: darker(var(--foreground));
+ font-size: 12px;
+ font-weight: normal;
+ font-style: italic;
+}
+
+.password-no-passwords,
+.password-no-results {
+ min-height: 3rem;
+}
+
+.password-no-passwords-text,
+.password-no-results-text {
+ color: darker(var(--foreground));
+ font-size: 14px;
+ font-style: italic;
+}
+
+.password-help,
+.password-success,
+.password-error {
+ min-height: 3rem;
+}
+
+.password-help-text,
+.password-success-text,
+.password-error-text {
+ font-size: 14px;
+ font-weight: normal;
+}
+
+.password-success-text {
+ color: var(--success);
+}
+
+.password-error-text {
+ color: var(--error);
+}
+
+.password-help-text {
+ color: darker(var(--foreground));
+ font-style: italic;
+}
+
+.password-master-required {
+ min-height: 4rem;
+}
+
+.password-master-required-text {
+ color: var(--error);
+ font-size: 14px;
+ font-weight: bold;
+}
+
+/* Window Switcher Plugin Styles */
+.window-slot {
+ min-height: 3rem;
+}
+
+.window-title {
+ color: var(--foreground);
+ font-size: 14px;
+ font-weight: bold;
+}
+
+.window-subtitle {
+ color: darker(var(--foreground));
+ font-size: 12px;
+ font-weight: normal;
+}
+
+.window-state {
+ color: var(--accent);
+ font-size: 11px;
+ font-weight: normal;
+ font-style: italic;
+}
+
+.window-action-button {
+ min-width: 2rem;
+ min-height: 2rem;
+ padding: 0.25rem;
+ border-radius: 0.25rem;
+ background-color: alpha(var(--surface-bright), 0.3);
+ border: 1px solid transparent;
+ transition: all 0.2s ease-in-out;
+}
+
+.window-action-button:hover {
+ background-color: alpha(var(--surface-bright), 0.5);
+ border-color: var(--outline);
+}
+
+.window-close-button:hover {
+ background-color: alpha(var(--error), 0.3);
+ border-color: var(--error);
+}
+
+.window-no-windows,
+.window-no-results {
+ min-height: 3rem;
+}
+
+.window-no-windows-text,
+.window-no-results-text {
+ color: darker(var(--foreground));
+ font-size: 14px;
+ font-style: italic;
+}
+
+/* Power Profile Plugin Styles */
+.power-profile-slot {
+ min-height: 3rem;
+}
+
+.power-profile-name {
+ color: var(--foreground);
+ font-size: 14px;
+ font-weight: bold;
+}
+
+.power-profile-status {
+ font-size: 12px;
+ font-weight: normal;
+}
+
+.power-profile-active {
+ color: var(--success);
+}
+
+.power-profile-inactive {
+ color: darker(var(--foreground));
+}
+
+.power-profile-no-profiles {
+ min-height: 3rem;
+}
+
+.power-profile-no-profiles-text {
+ color: darker(var(--foreground));
+ font-size: 14px;
+ font-style: italic;
+}
+
+/* Power Menu Styles */
+.power-menu-slot {
+ padding: 12px;
+ border-radius: 8px;
+ transition: all 0.2s ease;
+}
+
+.power-menu-slot:hover {
+ background-color: var(--background);
+}
+
+.power-menu-name {
+ font-weight: 600;
+ font-size: 16px;
+ color: var(--foreground);
+}
+
+.power-menu-description {
+ font-size: 12px;
+ color: var(--unfocused-foreground);
+ margin-top: 2px;
+}
+
+.settings-slot {
+ padding: 6px 12px;
+}
+
+.settings-slot > box {
+ min-height: 28px;
+}
+
+.settings-slot box > box:first-child {
+ padding-right: 12px;
+}
+
+switch {
+ background-color: var(--surface-dim);
+ border: none;
+ border-radius: 15px;
+}
+
+switch:checked {
+ background-color: var(--primary);
+}
+
+switch slider {
+ background-color: white;
+ border: none;
+ border-radius: 50%;
+}
+
+switch:checked slider {
+ background-color: white;
+}
\ No newline at end of file
diff --git a/mine/styles/main.css b/mine/styles/main.css
new file mode 100644
index 0000000..f54c56c
--- /dev/null
+++ b/mine/styles/main.css
@@ -0,0 +1,46 @@
+@import url("./colors.css");
+@import url("./panel.css");
+@import url("./osd.css");
+@import url("./launcher.css");
+@import url("./notificatios.css");
+@import url("./desktop.css");
+@import url("./calendar.css");
+
+* {
+ all: unset;
+ color: var(--foreground);
+ font-size: unset;
+ font-family: unset;
+}
+
+#corner-container {
+ min-width: 1.25rem;
+ min-height: 1.25rem;
+}
+
+#corner {
+ background-color: var(--shadow);
+ border-radius: 0;
+}
+
+tooltip {
+ border-radius: 1rem;
+ border-color: var(--surface);
+ background-color: var(--shadow);
+ animation: tooltipShow 0.25s cubic-bezier(0.5, 0.25, 0, 1);
+}
+
+@keyframes tooltipShow {
+ from {
+ opacity: 0;
+ }
+
+ to {
+ opacity: 1;
+ }
+}
+
+tooltip>* {
+ padding: 0.12rem 0.5rem;
+ border-radius: 0.5rem;
+}
diff --git a/mine/styles/notificatios.css b/mine/styles/notificatios.css
new file mode 100644
index 0000000..f8b4f4a
--- /dev/null
+++ b/mine/styles/notificatios.css
@@ -0,0 +1,90 @@
+@keyframes notifications-pop {
+ 50% {
+ padding-left: 0.8rem;
+ }
+
+ 100% {
+ padding: 0.3rem;
+ }
+}
+
+@define background-shine(--color) {
+ background-image: linear-gradient(-75deg,
+ transparent 0,
+ transparent 0,
+ --color 5%,
+ --color 20%,
+ transparent 10%,
+ transparent 100%);
+ background-size: 200% 100%;
+}
+
+@keyframes shine {
+ 0% {
+ background-position: 0% 0%;
+ }
+
+ 100% {
+ background-position: -150% 0%;
+ }
+}
+
+#notifications {
+ padding: 0.3rem;
+ background-color: var(--shadow);
+
+ /* get rid of default border radius set globally */
+ border-radius: 0rem;
+ border-bottom-left-radius: 16px;
+}
+
+#notifications.popped {
+ animation: 1s cubic-bezier(0.41, 0.42, 0.03, 0.9) notifications-pop;
+ padding: 0.3rem;
+}
+
+.notifications-clip {
+ border-radius: calc(16px - 0.3rem);
+}
+
+#notification {
+ background-color: var(--surface);
+ padding: 0.3rem;
+ border-radius: calc(16px - 0.3rem);
+ min-width: 28rem;
+}
+
+#notification.shine {
+ animation: 1s ease-in-out shine;
+ @apply background-shine(var(--surface-bright));
+}
+
+#notification image,
+#notification button {
+ /* can't use nested "clac" statements. sadage */
+ border-radius: 0.6rem;
+}
+
+#notification button {
+ background-color: var(--surface);
+ padding: 0.3rem;
+}
+
+#notification button:hover {
+ background-color: alpha(var(--surface-bright), 0.3);
+}
+
+#notification .body,
+#notification .summary {
+ margin: 0rem 0.3rem;
+}
+
+#notification .summary {
+ font-size: 20px;
+ font-weight: bold;
+ margin-bottom: 0.3rem;
+}
+
+#notification .body {
+ color: darker(var(--foreground));
+}
diff --git a/mine/styles/osd.css b/mine/styles/osd.css
new file mode 100644
index 0000000..c9a450d
--- /dev/null
+++ b/mine/styles/osd.css
@@ -0,0 +1,81 @@
+#osd {
+ padding: 0.4rem;
+ border-radius: 1.8rem;
+ background-color: var(--shadow);
+}
+
+#osd-container {
+ border-radius: 1.5rem;
+ background-color: var(--surface);
+ padding-left: 1rem;
+ padding: 0.4rem;
+}
+
+#osd-container * {
+ transition: all 0.4s cubic-bezier(0.15, 0.70, 0, 1);
+}
+
+scale {
+ min-height: 2rem;
+ min-width: 18rem;
+}
+
+trough {
+ min-height: 2rem;
+ min-width: inherit;
+ border-radius: 1rem;
+ background-color: var(--shadow);
+}
+
+highlight {
+ margin: 0.4rem;
+ background-color: var(--primary);
+ border-radius: 0.8rem;
+}
+
+/* Muted state: soften the scale visuals */
+scale.muted trough {
+ background-color: var(--surface-bright);
+}
+
+scale.muted highlight {
+ background-color: var(--surface);
+}
+
+scale:hover,
+#osd-container.focused scale {
+ min-width: 21rem;
+ min-height: inherit;
+}
+
+scale:hover trough,
+#osd-container.focused scale trough {
+ border-radius: 1.2rem;
+ min-width: 21rem;
+ min-height: 3rem;
+}
+
+.capslock-on {
+ color: var(--foreground);
+ font-weight: bold;
+}
+
+.capslock-off {
+ opacity: 0.2;
+}
+
+#osd-container label {
+ font-size: 1.2rem;
+ font-weight: 600;
+ margin-left: 0.1rem;
+}
+
+.mic-muted {
+ opacity: 0.5;
+ color: var(--foreground);
+}
+
+.mic-unmuted {
+ color: var(--primary);
+ font-weight: bold;
+}
diff --git a/mine/styles/panel.css b/mine/styles/panel.css
new file mode 100644
index 0000000..2b9e1b5
--- /dev/null
+++ b/mine/styles/panel.css
@@ -0,0 +1,123 @@
+/* Panel styles - default (bottom position) */
+#panel {
+ background-color: var(--shadow);
+ border-top-left-radius: 0.5rem;
+ border-top-right-radius: 0.5rem;
+ border-bottom-left-radius: 0;
+ border-bottom-right-radius: 0;
+ padding: 0.3rem 0;
+}
+
+/* Panel styles when at top position */
+#panel.panel-top {
+ border-top-left-radius: 0;
+ border-top-right-radius: 0;
+ border-bottom-left-radius: 0.5rem;
+ border-bottom-right-radius: 0.5rem;
+}
+
+#panel-revealer {
+ transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+#battery {
+ margin: 0 0.3rem;
+}
+
+#date-time {
+ margin: 0 0.25rem;
+ font-weight: 500;
+ background-color: var(--surface);
+ border-radius: 0.5rem;
+ padding: 0 0.5rem;
+}
+
+
+#battery {
+ margin: 0 0.25rem;
+ padding: 0 0.5rem;
+ background-color: var(--surface);
+ border-radius: 0.5rem;
+}
+
+#corner-left,
+#corner-right {
+ margin: 0;
+}
+
+
+#taskbar {
+ margin: 0 0.25rem;
+ background-color: var(--surface);
+ border-radius: 0.5rem;
+ padding: 0 0.25rem;
+ transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+#taskbar>button {
+ margin: 0.25rem 0;
+ border-radius: 0.5rem;
+ padding: 0 0.05rem;
+}
+
+#taskbar .active {
+ background-color: var(--primary);
+}
+
+#taskbar>button:hover {
+ background-color: var(--surface-bright);
+}
+
+#workspaces {
+ margin: 0 0.25rem;
+ background-color: var(--surface);
+ border-radius: 0.5rem;
+ padding: 0 0.5rem;
+ transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+#workspaces>button {
+ margin: 0.3rem 0;
+ min-width: 0.7rem;
+ min-height: 0.5rem;
+ border-radius: 1rem;
+ padding: 0 0.38rem;
+}
+
+#workspaces>button:hover {
+ background-color: var(--surface-bright);
+}
+
+#workspaces>button.active {
+ background-color: var(--primary);
+ border-radius: 1rem;
+}
+
+#workspaces>button.active label {
+ color: var(--shadow);
+}
+
+#workspaces>button.urgent label {
+ color: var(--error-dim);
+}
+
+menu {
+ border: solid 0.06rem;
+ border-radius: 1rem;
+ border-color: var(--surface);
+ background-color: var(--shadow);
+ padding: 0.4rem;
+}
+
+menu>menuitem {
+ border-radius: 0.6rem;
+ padding: 0.4rem 0.6rem;
+}
+
+menu>menuitem:hover {
+ background-color: var(--primary);
+}
+
+menu>menuitem:hover>label {
+ color: var(--shadow);
+}
diff --git a/mine/utils/__init__.py b/mine/utils/__init__.py
new file mode 100644
index 0000000..b893e90
--- /dev/null
+++ b/mine/utils/__init__.py
@@ -0,0 +1,12 @@
+# ruff: noqa: F401, F403
+
+
+from .animatedscrollable import AnimatedScrollable
+from .animator import *
+from .clippingbox import ClippingBox
+
+# from .corners import MyCorner, corners
+from .functions import *
+
+# from .icon_resolver import IconResolver
+from .occlusion import *
diff --git a/mine/utils/animatedscrollable.py b/mine/utils/animatedscrollable.py
new file mode 100644
index 0000000..72a9992
--- /dev/null
+++ b/mine/utils/animatedscrollable.py
@@ -0,0 +1,76 @@
+# Author: Yousef EL-Darsh
+# License (SPDX): CC-BY-NC-ND-4.0
+
+from functools import partial
+
+from fabric.widgets.scrolledwindow import ScrolledWindow
+
+from utils.animator import Animator, cubic_bezier
+
+
+class AnimatedScrollable(ScrolledWindow):
+ def __init__(
+ self,
+ bezier_curve: tuple[float, float, float, float] = (0.2, 1, 0.8, 1.0),
+ duration: float = 0.3,
+ **kwargs,
+ ):
+ super().__init__(**kwargs)
+ self._last_req = -1
+ self._last_height = -1
+ self._is_visible_state = False
+ _, min_height = self.min_content_size
+ _, max_height = self.max_content_size
+
+ self.height_animator = Animator(
+ duration=duration,
+ timing_function=partial(cubic_bezier, *bezier_curve),
+ min_value=min_height,
+ max_value=max_height,
+ notify_value=self.on_animator_change,
+ tick_widget=self,
+ )
+
+ def on_animator_change(self, animator: Animator, *_):
+ value = round(animator.value)
+
+ if value == self._last_height:
+ return
+
+ self._last_height = value
+
+ if value < 1:
+ if self._is_visible_state:
+ self.hide()
+ self._is_visible_state = False
+ else:
+ if not self._is_visible_state:
+ self.show()
+ self._is_visible_state = True
+
+ self.set_min_content_height(value)
+
+ def do_animate(
+ self,
+ from_height: int = 0,
+ to_height: int = -1,
+ ):
+ if to_height == -1:
+ return
+
+ self._last_height = from_height
+
+ self.height_animator.pause()
+ self.height_animator.min_value = from_height
+ self.height_animator.max_value = to_height
+ self.height_animator.play()
+ return
+
+ def animate_size(self, height: int = -1):
+ self._last_req = height
+ return self.do_animate(self.min_content_size[1], height)
+
+ def do_get_preferred_height(self):
+ value = self.height_animator.value
+ value = 0 if value < 0 else value
+ return value, value
diff --git a/mine/utils/animator.py b/mine/utils/animator.py
new file mode 100644
index 0000000..96b8a94
--- /dev/null
+++ b/mine/utils/animator.py
@@ -0,0 +1,263 @@
+# Author: Yousef EL-Darsh
+# License (SPDX): AGPL-3.0-or-later
+
+from functools import cache
+from typing import Protocol, cast
+
+from fabric.core.service import Property, Service, Signal
+from fabric.utils import GLib, Gtk, clamp
+
+
+@cache
+def lerp(start: float, end: float, progress: float) -> float:
+ return start + (end - start) * progress
+
+
+@cache
+def steps(n: int, progress: float, start_jump: bool = False) -> float:
+ if start_jump:
+ return min(int(progress * n), n - 1) / (n - 1) if n > 1 else 0.0
+ return min(int(progress * n + 1e-10), n) / n
+
+
+@cache
+def cubic_bezier(
+ x1: float, y1: float, x2: float, y2: float, progress: float, epsilon=1e-6
+) -> float:
+ # implementation yanked off of the internet, don't blame me about anything.
+ # Fast-path boundaries to avoid overshoot and unnecessary work
+ if progress <= 0.0 or progress >= 1.0:
+ return clamp(progress, 0.0, 1.0)
+
+ t_guess = progress
+ for _ in range(8):
+ t = t_guess
+ t_sq = t * t
+ omt = 1.0 - t
+ omt_sq = omt * omt
+
+ x = 3 * x1 * omt_sq * t + 3 * x2 * omt * t_sq + t * t_sq
+ dx = 3 * x1 * omt_sq + 6 * (x2 - x1) * omt * t + 3 * (1 - x2) * t_sq
+
+ if abs(dx) < epsilon:
+ break
+
+ delta = (x - progress) / dx
+ t_guess -= delta
+ t_guess = clamp(t_guess, 0.0, 1.0)
+
+ if abs(delta) < epsilon:
+ break
+
+ t = clamp(t_guess, 0.0, 1.0)
+ t_sq = t * t
+ omt = 1.0 - t
+ return 3 * y1 * omt * omt * t + 3 * y2 * omt * t_sq + t * t_sq
+
+
+def ease_linear(progress: float) -> float:
+ return cubic_bezier(1, 1, 0, 0, progress)
+
+
+def ease_in(progress: float) -> float:
+ return cubic_bezier(0.4, 0, 1, 1, progress)
+
+
+def ease_out(progress: float) -> float:
+ return cubic_bezier(0, 0, 0.2, 1, progress)
+
+
+def ease_in_out(progress: float) -> float:
+ return cubic_bezier(0.4, 0, 0.2, 1, progress)
+
+
+class TimingFunctionCallback(Protocol):
+ def __call__(self, progress: float, *args, **kwargs) -> float: ...
+
+
+class Animator(Service):
+ """
+ An animator is a simple way for animating a value on
+ a set timeline based on a given timing function
+ """
+
+ @Signal
+ def finished(self) -> None: ...
+
+ @Property(TimingFunctionCallback, "read-write")
+ def timing_function(self) -> TimingFunctionCallback:
+ return self._timing_function
+
+ @timing_function.setter
+ def timing_function(self, value: TimingFunctionCallback):
+ self._timing_function = value
+ return
+
+ @Property(float, "read-write")
+ def duration(self):
+ return self._duration
+
+ @duration.setter
+ def duration(self, value: float):
+ if value <= 0.0:
+ raise ValueError("duration can't be smaller than or equal to 0.0")
+
+ self._duration = value
+ return
+
+ @Property(float, "read-write")
+ def value(self):
+ return self._value
+
+ @value.setter
+ def value(self, value: float):
+ self._value = value
+ return
+
+ @Property(float, "read-write")
+ def max_value(self):
+ return self._max_value
+
+ @max_value.setter
+ def max_value(self, value: float):
+ self._max_value = value
+ return
+
+ @Property(float, "read-write")
+ def min_value(self):
+ return self._min_value
+
+ @min_value.setter
+ def min_value(self, value: float):
+ self._min_value = value
+ return
+
+ @Property(bool, "read-write", default_value=False)
+ def playing(self):
+ return self._playing
+
+ @playing.setter
+ def playing(self, value: bool): # this setter is intended for internal usage only
+ self._playing = value
+ return
+
+ @Property(bool, "read-write", default_value=False)
+ def repeat(self):
+ return self._repeat
+
+ @repeat.setter
+ def repeat(self, value: bool):
+ self._repeat = value
+ return
+
+ def __init__(
+ self,
+ duration: float = 0.8,
+ timing_function: TimingFunctionCallback = ease_linear,
+ value: float = 0.0,
+ min_value: float = 0.0,
+ max_value: float = 1.0,
+ repeat: bool = False,
+ tick_widget: Gtk.Widget | None = None,
+ tick_interval: int = 16,
+ **kwargs,
+ ):
+ super().__init__(**kwargs)
+ self._playing = False
+ self._value = value
+ self._min_value = 0.0
+ self._max_value = 1.0
+ self._repeat = False
+ self._duration = 0.8
+ self._timing_function = timing_function
+ self._tick_widget = tick_widget
+ self._tick_interval = tick_interval
+
+ self.timing_function = timing_function
+ self.repeat = repeat
+ self.duration = duration
+ self.min_value = min_value
+ self.max_value = max_value
+ self.value = value
+ self.playing = False
+
+ self._start_time = None
+ self._tick_handler = None
+ self._timeline_pos = 0.0
+
+ def do_get_time_now(self):
+ return GLib.get_monotonic_time() / 1_000_000
+
+ def do_update_value(self, delta_time: float):
+ if not self._playing:
+ return
+
+ elapsed_time = delta_time - cast(float, self._start_time)
+
+ self._timeline_pos = min(1.0, elapsed_time / self._duration)
+
+ self.value = lerp(
+ self._min_value,
+ self._max_value,
+ self._timing_function(progress=self._timeline_pos),
+ )
+
+ if not self._timeline_pos >= 1.0:
+ return
+
+ if not self._repeat:
+ # all done..
+ self.value = self._max_value
+ self.finished()
+ self.pause()
+ return
+
+ self._start_time = delta_time
+ self._timeline_pos = 0.0
+ return
+
+ def do_handle_tick(self, *_):
+ current_time = self.do_get_time_now()
+ self.do_update_value(current_time)
+ return True
+
+ def do_remove_tick_handlers(self):
+ if not self._tick_handler:
+ return
+
+ if self._tick_widget:
+ self._tick_widget.remove_tick_callback(self._tick_handler)
+ else:
+ GLib.source_remove(self._tick_handler)
+ self._tick_handler = None
+ return
+
+ def play(self):
+ if self._playing:
+ return
+
+ self.playing = True
+ self._start_time = self.do_get_time_now()
+
+ if self._tick_handler:
+ return
+
+ if self._tick_widget:
+ self._tick_handler = self._tick_widget.add_tick_callback(
+ self.do_handle_tick
+ )
+ return
+
+ self._tick_handler = GLib.timeout_add(self._tick_interval, self.do_handle_tick)
+ return
+
+ def pause(self):
+ self.playing = False
+ return self.do_remove_tick_handlers()
+
+ def stop(self):
+ if not self._tick_handler:
+ self._timeline_pos = 0
+ self.playing = False
+ return
+ return self.do_remove_tick_handlers()
diff --git a/mine/utils/clippingbox.py b/mine/utils/clippingbox.py
new file mode 100644
index 0000000..d9b5add
--- /dev/null
+++ b/mine/utils/clippingbox.py
@@ -0,0 +1,40 @@
+from typing import cast
+
+from fabric.utils import cairo, math
+from fabric.widgets.box import Box
+
+
+class ClippingBox(Box):
+ @staticmethod
+ def render_shape(cr: cairo.Context, width: int, height: int, radius: int = 0):
+ cr.move_to(radius, 0)
+ cr.line_to(width - radius, 0)
+ cr.arc(width - radius, radius, radius, -(math.pi / 2), 0)
+ cr.line_to(width, height - radius)
+ cr.arc(width - radius, height - radius, radius, 0, (math.pi / 2))
+ cr.line_to(radius, height)
+ cr.arc(radius, height - radius, radius, (math.pi / 2), math.pi)
+ cr.line_to(0, radius)
+ cr.arc(radius, radius, radius, math.pi, (3 * (math.pi / 2)))
+
+ return cr.close_path()
+
+ def do_draw(self, cr: cairo.Context):
+ cr.save()
+ ClippingBox.render_shape(
+ cr,
+ self.get_allocated_width(),
+ self.get_allocated_height(),
+ cast(
+ int,
+ self.get_style_context().get_property(
+ "border-radius", self.get_state_flags()
+ ),
+ ),
+ )
+ cr.clip()
+
+ Box.do_draw(self, cr)
+
+ cr.restore()
+ return True
diff --git a/mine/utils/constants.py b/mine/utils/constants.py
new file mode 100644
index 0000000..56435bd
--- /dev/null
+++ b/mine/utils/constants.py
@@ -0,0 +1,50 @@
+DEFAULT = {
+ "debug": {"enabled": False},
+ "wallpaper_dir": {"path": "~/Pictures/Wallpapers/"},
+ "corners": {"enabled": True},
+ "keyboard_layout": {"layouts": ["us"]},
+ "panel": {
+ "enabled": True,
+ "position": "top",
+ "autohide": True,
+ "systray": {"enabled": True, "hide_items": []},
+ "network": {"enabled": True},
+ "bluetooth": {"enabled": True},
+ "workspace": {"enabled": True, "hide_special_ws": True},
+ "datetime": {"enabled": True, "12hrs": True},
+ "taskbar": {
+ "enabled": True,
+ "hide_empty": True,
+ "hide_special": True,
+ "icon_size": 20,
+ "group_apps": False,
+ },
+ "battery": {
+ "enabled": True,
+ "label": True,
+ "hide_on_full": True,
+ "icon_size": 28,
+ },
+ },
+ "osd": {
+ "enabled": True,
+ "position": "bottom",
+ "audio": True,
+ "brightness": True,
+ "capslock": True,
+ "kb_layout": True,
+ "network": True,
+ "microphone": True,
+ "battery": True,
+ "cooldown_ms": 200,
+ },
+ "notifications": {
+ "enabled": True,
+ "timeout": "10s",
+ "image_size": 64,
+ "width": 360,
+ "buttons_per_row": 3,
+ "corner_size": 16,
+ "revealer_duration": 400,
+ },
+}
diff --git a/mine/utils/corners.py b/mine/utils/corners.py
new file mode 100644
index 0000000..ad88047
--- /dev/null
+++ b/mine/utils/corners.py
@@ -0,0 +1,86 @@
+from functools import partial
+
+from fabric.widgets.box import Box
+from fabric.widgets.shapes import Corner
+from fabric.widgets.wayland import WaylandWindow as Window
+
+from config.data import DATA
+from services.config_watcher import has_config_changed, on_config_change
+
+bake_corner = partial(Corner, name="corner")
+
+# Corner with defaults for standalone corner windows (fixed size, centered, non-expanding)
+CornerBase = partial(
+ bake_corner,
+ h_expand=False,
+ v_expand=False,
+ h_align="center",
+ v_align="center",
+ size=20,
+)
+
+
+def expanded_corner(orientation: str, **kwargs):
+ return bake_corner(orientation=orientation, v_expand=True, h_expand=True, **kwargs)
+
+
+class MyCorner(Box):
+ def __init__(self, corner):
+ super().__init__(
+ name="corner-container",
+ children=CornerBase(orientation=corner),
+ )
+
+
+class CornerWindow(Window):
+ def __init__(self, corner: str):
+ anchor = {
+ "top-left": "top left",
+ "top-right": "top right",
+ "bottom-left": "bottom left",
+ "bottom-right": "bottom right",
+ }[corner]
+
+ super().__init__(
+ layer="bottom",
+ anchor=anchor,
+ exclusivity="normal",
+ pass_through=True,
+ visible=False,
+ all_visible=False,
+ )
+
+ # Size to content by not expanding and only containing the single corner widget
+ content = Box(
+ children=MyCorner(corner),
+ h_expand=False,
+ v_expand=False,
+ )
+
+ self.add(content)
+ self.show_all()
+
+
+# Create all corner windows
+corners = [
+ CornerWindow("top-left"),
+ CornerWindow("top-right"),
+ CornerWindow("bottom-left"),
+ CornerWindow("bottom-right"),
+]
+
+
+def update_corners_visibility():
+ data = DATA()
+ corners_visible = data["corners"]
+ for corner in corners:
+ corner.set_visible(corners_visible)
+
+
+def _on_config_change(new_config, old_config):
+ if has_config_changed(old_config, new_config, "corners.enabled"):
+ update_corners_visibility()
+
+
+update_corners_visibility()
+on_config_change(_on_config_change)
diff --git a/mine/utils/dbus_helper.py b/mine/utils/dbus_helper.py
new file mode 100644
index 0000000..d46f7aa
--- /dev/null
+++ b/mine/utils/dbus_helper.py
@@ -0,0 +1,76 @@
+from fabric.utils import Gio, GLib
+
+
+class GioDBusHelper:
+ """A helper class for interacting with D-Bus using the Gio library."""
+
+ def __init__(
+ self,
+ bus_name,
+ object_path,
+ interface_name,
+ bus_type=Gio.BusType.SYSTEM,
+ ):
+ self.bus = Gio.bus_get_sync(bus_type, None)
+
+ self.bus_name = bus_name
+ self.object_path = object_path
+ self.proxy = Gio.DBusProxy.new_sync(
+ self.bus,
+ Gio.DBusProxyFlags.NONE,
+ None,
+ bus_name,
+ object_path,
+ interface_name,
+ None,
+ )
+
+ def call_method(
+ self,
+ bus_name,
+ object_path,
+ interface_name,
+ method_name,
+ parameters=None,
+ timeout=-1,
+ ):
+ if parameters is None:
+ parameters = GLib.Variant("()", ())
+ result = self.bus.call_sync(
+ bus_name,
+ object_path,
+ interface_name,
+ method_name,
+ parameters,
+ None,
+ Gio.DBusCallFlags.NONE,
+ timeout,
+ None,
+ )
+ return result.unpack()
+
+ def listen_signal(
+ self, member, callback, interface_name="org.freedesktop.DBus.Properties"
+ ):
+ """Register a signal listener (conn, sender, path, iface, signal, parameters)"""
+ self.bus.signal_subscribe(
+ self.bus_name,
+ interface_name,
+ member,
+ self.object_path,
+ arg0=None,
+ flags=Gio.DBusSignalFlags.NONE,
+ callback=callback,
+ )
+
+ def set_property(self, interface_name, property_name, value_variant):
+ """Sets a D-Bus property using the standard D-Bus Properties interface."""
+ return self.call_method(
+ bus_name=self.bus_name,
+ object_path=self.object_path,
+ interface_name="org.freedesktop.DBus.Properties",
+ method_name="Set",
+ parameters=GLib.Variant(
+ "(ssv)", (interface_name, property_name, value_variant)
+ ),
+ )
diff --git a/mine/utils/functions.py b/mine/utils/functions.py
new file mode 100644
index 0000000..2be5ea2
--- /dev/null
+++ b/mine/utils/functions.py
@@ -0,0 +1,264 @@
+import ctypes
+import json
+import subprocess
+from collections.abc import Callable, Iterable
+from functools import reduce
+from typing import NamedTuple, TypeVar, cast
+
+from fabric.utils import (
+ Gdk,
+ GdkPixbuf,
+ GLib,
+ Gtk,
+ cairo,
+ exec_shell_command,
+ invoke_repeater,
+ logger,
+ os,
+ time,
+)
+from fabric.widgets.box import Box
+
+T = TypeVar("T")
+
+
+class Rectangle(NamedTuple):
+ x: float
+ y: float
+ width: float
+ height: float
+
+
+def get_children_height_limit(
+ viewport: Box,
+ max_n_children: int,
+ transform_func: Callable[[Gtk.Widget], cairo.RectangleInt] | None = None,
+) -> int:
+ spacing: int = viewport.get_spacing()
+
+ children = viewport.children
+ children_len = len(viewport.children)
+
+ if children_len < 1:
+ return 0
+
+ if children_len > max_n_children:
+ children_len = max_n_children
+
+ # calculate the new height
+ # ( ) + ( )
+ return (spacing * (children_len - 1)) + reduce(
+ lambda x, y: x + y,
+ (
+ (
+ transform_func(children[i])
+ if transform_func
+ else cast(
+ cairo.RectangleInt,
+ children[i].get_preferred_size().minimum_size, # type: ignore
+ )
+ ).height # type: ignore
+ for i in range(children_len)
+ ),
+ )
+
+
+# Function to set the process name
+def set_process_name(name: str):
+ libc = ctypes.CDLL("libc.so.6")
+ libc.prctl(15, name.encode("utf-8"), 0, 0, 0)
+
+
+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) -> dict | None:
+ if not os.path.exists(file_path):
+ logger.error(f"JSON file {file_path} does not exist.")
+ return None
+
+ with open(file_path) 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
+
+
+# Function to check if a workspace ID is special
+def is_special_workspace_id(ws_id) -> bool:
+ try:
+ workspace_id = int(ws_id)
+ return workspace_id < 0
+ except (ValueError, TypeError):
+ return bool(isinstance(ws_id, str) and ws_id.startswith("special:"))
+
+
+# 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"]
+ if "name" in workspace:
+ workspace_name = workspace["name"]
+ if is_special_workspace_id(workspace_name):
+ return True
+ if "id" in workspace:
+ workspace_id = workspace["id"]
+ if is_special_workspace_id(workspace_id):
+ return True
+
+ return False
+
+
+def add_style_class_lazy(widget: Gtk.Widget, class_name: str | Iterable[str]) -> int:
+ return invoke_repeater(
+ 50, lambda: widget.add_style_class(class_name), initial_call=False
+ )
+
+
+def is_app_running(app_name: str) -> bool:
+ return len(exec_shell_command(f"pidof {app_name}")) != 0
+
+
+def copy_text(text: str) -> bool:
+ try:
+ clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
+ clipboard.set_text(text, -1)
+ clipboard.store()
+ return True
+ except Exception:
+ return False
+
+
+def copy_image(image_path: str) -> bool:
+ try:
+ clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
+ image = GdkPixbuf.Pixbuf.new_from_file(image_path)
+ clipboard.set_image(image)
+ clipboard.store()
+ return True
+ except Exception:
+ return False
+
+
+# Terminal helpers
+def terminal_exec_command(terminal: str, command: str, title: str | None = None) -> str:
+ title_flag = title or ""
+ mapping = {
+ "kitty": f"kitty{f" --title='{title_flag}'" if title_flag else ''} -e {command}",
+ "alacritty": f"alacritty{f" --title '{title_flag}'" if title_flag else ''} -e {command}",
+ "gnome-terminal": f"gnome-terminal{f" --title='{title_flag}'" if title_flag else ''} -- {command}",
+ "xterm": f"xterm{f" -title '{title_flag}'" if title_flag else ''} -e {command}",
+ "foot": f"foot {command}",
+ "konsole": f"konsole -e {command}",
+ "xfce4-terminal": f"xfce4-terminal -e '{command}'",
+ }
+ return mapping.get(terminal, f"{terminal} -e {command}")
+
+
+def terminal_open_in_directory_command(
+ terminal: str,
+ directory: str,
+ title: str | None = None,
+ inner_command: str | None = None,
+) -> str:
+ title_segment = title or ""
+ directory = directory
+ if inner_command:
+ mapping = {
+ "kitty": f"kitty{f" --title='{title_segment}'" if title_segment else ''} --directory {directory} {inner_command}",
+ "alacritty": f"alacritty{f" --title '{title_segment}'" if title_segment else ''} --working-directory {directory} -e {inner_command}",
+ "gnome-terminal": f"gnome-terminal{f" --title='{title_segment}'" if title_segment else ''} --working-directory {directory} -- {inner_command}",
+ "foot": f"foot --working-directory {directory} {inner_command}",
+ "xfce4-terminal": f"xfce4-terminal --default-working-directory {directory} -e '{inner_command}'",
+ }
+ return mapping.get(
+ terminal,
+ f"{terminal} --working-directory {directory} -e {inner_command}",
+ )
+
+ mapping = {
+ "kitty": f"kitty{f" --title='{title_segment}'" if title_segment else ''} --directory {directory}",
+ "alacritty": f"alacritty{f" --title '{title_segment}'" if title_segment else ''} --working-directory {directory}",
+ "gnome-terminal": f"gnome-terminal{f" --title='{title_segment}'" if title_segment else ''} --working-directory {directory}",
+ "foot": f"foot --working-directory {directory}",
+ "xfce4-terminal": f"xfce4-terminal --default-working-directory {directory}",
+ }
+ return mapping.get(terminal, f"{terminal} --working-directory {directory}")
+
+
+# Subprocess helper with structured result and timeouts
+class CommandResult(NamedTuple):
+ returncode: int
+ stdout: bytes | str
+ stderr: bytes | str
+
+
+def run_command(
+ args: list[str],
+ timeout: float | None = None,
+ cwd: str | None = None,
+ env: dict[str, str] | None = None,
+ *,
+ input: bytes | str | None = None,
+ text: bool = True,
+) -> CommandResult:
+ try:
+ result = subprocess.run(
+ args,
+ capture_output=True,
+ text=text,
+ timeout=timeout,
+ cwd=cwd,
+ env=env,
+ input=input,
+ )
+ return CommandResult(result.returncode, result.stdout, result.stderr)
+ except subprocess.TimeoutExpired as e:
+ return CommandResult(
+ -1,
+ (e.stdout or b"" if not text else e.stdout or ""),
+ (e.stderr or (b"timeout" if not text else "timeout")),
+ )
+ except FileNotFoundError as e:
+ return CommandResult(127, "", str(e))
+ except Exception as e:
+ return CommandResult(1, "", str(e))
+
+
+def trigger_paste_shortcut(delay_ms: int = 50) -> bool:
+ time.sleep(delay_ms / 1000)
+ result = run_command(["wtype", "-M", "ctrl", "v", "-m", "ctrl"], timeout=1)
+ return result.returncode == 0
+
+
+def debounce(ms: int):
+ """
+ Debounce a function.
+ Useful for preventing UI flickering during fast typing.
+ """
+
+ def decorator(func: Callable):
+ timer_id_attr = f"_debounce_timer_{func.__name__}"
+
+ def wrapper(self, *args, **kwargs):
+ if existing_timer := getattr(self, timer_id_attr, None):
+ GLib.source_remove(existing_timer)
+
+ def timeout_cb():
+ setattr(self, timer_id_attr, 0)
+ func(self, *args, **kwargs)
+ return False
+
+ setattr(self, timer_id_attr, GLib.timeout_add(ms, timeout_cb))
+
+ return wrapper
+
+ return decorator
diff --git a/mine/utils/icon_resolver.py b/mine/utils/icon_resolver.py
new file mode 100644
index 0000000..e308226
--- /dev/null
+++ b/mine/utils/icon_resolver.py
@@ -0,0 +1,107 @@
+from fabric.utils import GdkPixbuf, GLib, Gtk, os, re
+
+from config.data import ICON_CACHE_FILE
+from utils.functions import read_json_file, write_json_file
+
+
+class IconResolver:
+ _instance = None
+
+ def __new__(cls):
+ if cls._instance is None:
+ cls._instance = super().__new__(cls)
+ return cls._instance
+
+ def __init__(self):
+ if os.path.exists(ICON_CACHE_FILE):
+ self._icon_dict = read_json_file(ICON_CACHE_FILE) or {}
+ else:
+ self._icon_dict = {}
+
+ self.default_application_icon = "application-x-executable"
+
+ 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)
+ self._store_new_icon(app_id, new_icon)
+ return new_icon
+
+ def resolve_icon(self, pixmap, icon_name: str, app_id: str, icon_size: int = 16):
+ try:
+ return (
+ pixmap.as_pixbuf(icon_size, GdkPixbuf.InterpType.HYPER)
+ if pixmap is not None
+ else Gtk.IconTheme()
+ .get_default()
+ .load_icon(
+ icon_name,
+ icon_size,
+ Gtk.IconLookupFlags.FORCE_SIZE,
+ )
+ )
+ except GLib.GError:
+ return self.get_icon_pixbuf(app_id, icon_size)
+
+ def get_icon_pixbuf(self, app_id: str, size: int = 16):
+ icon_name = self.get_icon_name(app_id)
+ try:
+ return Gtk.IconTheme.get_default().load_icon(
+ icon_name,
+ size,
+ Gtk.IconLookupFlags.FORCE_SIZE,
+ )
+ except GLib.GError:
+ return (
+ Gtk.IconTheme()
+ .get_default()
+ .load_icon(
+ "image-missing",
+ size,
+ Gtk.IconLookupFlags.FORCE_SIZE,
+ )
+ )
+
+ def _store_new_icon(self, app_id: str, icon: str):
+ self._icon_dict[app_id] = icon
+ write_json_file(self._icon_dict, ICON_CACHE_FILE)
+
+ def _get_icon_from_desktop_file(self, desktop_file_path: str):
+ with open(desktop_file_path) as f:
+ for line in f.readlines():
+ if "Icon=" in line:
+ return "".join(line[5:].split())
+
+ return self.default_application_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 = data_dir + "/applications/"
+ if os.path.exists(data_dir):
+ # Do name resolving here
+ files = os.listdir(data_dir)
+ matching = [
+ s for s in files if "".join(app_id.lower().split()) in s.lower()
+ ]
+ if matching:
+ return 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 data_dir + matching[0]
+
+ return None
+
+ def _compositor_find_icon(self, app_id: str):
+ if Gtk.IconTheme.get_default().has_icon(app_id):
+ return app_id
+ if Gtk.IconTheme.get_default().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_application_icon
+ )
diff --git a/mine/utils/occlusion.py b/mine/utils/occlusion.py
new file mode 100644
index 0000000..6d881aa
--- /dev/null
+++ b/mine/utils/occlusion.py
@@ -0,0 +1,187 @@
+import json
+
+from fabric.utils import logger
+
+import config.data as data
+from utils.functions import run_command
+
+
+def get_current_workspace():
+ """
+ Get the current workspace ID using hyprctl.
+ """
+ try:
+ result = run_command(["hyprctl", "activeworkspace"], timeout=5)
+ # Assume the output similar to: "ID "
+ # Extracting the number from the output
+ parts = (result.stdout or "").split() if isinstance(result.stdout, str) else []
+ for i, part in enumerate(parts):
+ if part == "ID" and i + 1 < len(parts):
+ return int(parts[i + 1])
+ except Exception as e:
+ logger.error(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 = run_command(["hyprctl", "-j", "monitors"], timeout=5)
+ monitors = json.loads(result.stdout) if isinstance(result.stdout, str) else []
+
+ # 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:
+ logger.error(f"Error getting screen dimensions: {e}")
+
+ # Default fallback values
+ return data.CURRENT_WIDTH, data.CURRENT_HEIGHT
+
+
+def resolve_region(region):
+ """
+ Convert a side-based region (side, size) to coordinates (x, y, w, h).
+ """
+ if isinstance(region, tuple) and len(region) == 2:
+ side, size = region
+ if isinstance(side, str):
+ screen_width, screen_height = get_screen_dimensions()
+ if side.lower() == "bottom":
+ return (0, screen_height - size, screen_width, size)
+ elif side.lower() == "top":
+ return (0, 0, screen_width, size)
+ elif side.lower() == "left":
+ return (0, 0, size, screen_height)
+ elif side.lower() == "right":
+ return (screen_width - size, 0, size, screen_height)
+ return region
+
+
+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
+ occlusion_region = resolve_region(occlusion_region)
+
+ # Ensure occlusion_region is in the correct format (x, y, width, height)
+ if not isinstance(occlusion_region, tuple) or len(occlusion_region) != 4:
+ logger.warning(f"Invalid occlusion region format: {occlusion_region}")
+ return False
+
+ try:
+ result = run_command(["hyprctl", "-j", "clients"], timeout=2)
+ if result.returncode != 0:
+ logger.warning("hyprctl command failed, assuming no occlusion")
+ return False
+ clients = json.loads(result.stdout) if isinstance(result.stdout, str) else []
+ except Exception as e:
+ logger.warning(f"Error retrieving client windows: {e}, assuming no occlusion")
+ 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
+
+
+def get_cursor_pos():
+ """
+ Get the current cursor position from hyprctl.
+ Returns:
+ tuple: (x, y) coordinates
+ """
+ try:
+ result = run_command(["hyprctl", "cursorpos"], timeout=2)
+ if result.stdout:
+ # Output format is "x, y"
+ coords = (
+ result.stdout.strip().split(",")
+ if isinstance(result.stdout, str)
+ else []
+ )
+ if len(coords) == 2:
+ return int(coords[0].strip()), int(coords[1].strip())
+ except Exception as e:
+ logger.warning(f"Error getting cursor position: {e}")
+ return None
+
+
+def is_mouse_in_region(region):
+ """
+ Check if the cursor is within a given region (x, y, width, height).
+ """
+ cursor = get_cursor_pos()
+ if not cursor:
+ return False
+
+ # Handle side-based format
+ region = resolve_region(region)
+
+ if not isinstance(region, tuple) or len(region) != 4:
+ return False
+
+ cx, cy = cursor
+ rx, ry, rw, rh = region
+
+ return rx <= cx <= rx + rw and ry <= cy <= ry + rh
diff --git a/mine/uv.lock b/mine/uv.lock
new file mode 100644
index 0000000..5f5f5e1
--- /dev/null
+++ b/mine/uv.lock
@@ -0,0 +1,419 @@
+version = 1
+revision = 3
+requires-python = ">=3.12"
+resolution-markers = [
+ "python_full_version >= '3.14'",
+ "python_full_version == '3.13.*'",
+ "python_full_version < '3.13'",
+]
+
+[[package]]
+name = "cffi"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pycparser", marker = "implementation_name != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" },
+ { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" },
+ { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" },
+ { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
+ { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
+ { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
+ { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
+ { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
+ { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
+ { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
+ { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
+ { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
+ { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
+ { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
+]
+
+[[package]]
+name = "click"
+version = "8.3.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
+]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
+]
+
+[[package]]
+name = "cryptography"
+version = "46.0.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" },
+ { url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" },
+ { url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" },
+ { url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" },
+ { url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" },
+ { url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" },
+ { url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" },
+ { url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" },
+ { url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" },
+ { url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" },
+ { url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" },
+ { url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" },
+ { url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" },
+ { url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" },
+ { url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" },
+ { url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" },
+ { url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" },
+ { url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" },
+]
+
+[[package]]
+name = "fabric"
+version = "0.0.2"
+source = { git = "https://github.com/Fabric-Development/fabric.git#0f3d317b047799191ff04a32352a63084eac11d9" }
+dependencies = [
+ { name = "click" },
+ { name = "loguru" },
+ { name = "pycairo" },
+ { name = "pygobject" },
+]
+
+[[package]]
+name = "loguru"
+version = "0.7.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "win32-setctime", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" },
+]
+
+[[package]]
+name = "modus"
+version = "0.1.0"
+source = { virtual = "." }
+dependencies = [
+ { name = "cryptography" },
+ { name = "fabric" },
+ { name = "psutil" },
+ { name = "pygobject" },
+ { name = "pyotp" },
+ { name = "pywayland" },
+ { name = "pyzbar" },
+ { name = "qrcode" },
+ { name = "thefuzz" },
+]
+
+[package.dev-dependencies]
+dev = [
+ { name = "ruff" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "cryptography", specifier = ">=46.0.3" },
+ { name = "fabric", git = "https://github.com/Fabric-Development/fabric.git" },
+ { name = "psutil", specifier = ">=7.1.2" },
+ { name = "pygobject", specifier = ">=3.50.0" },
+ { name = "pyotp", specifier = ">=2.9.0" },
+ { name = "pywayland", specifier = ">=0.4.18" },
+ { name = "pyzbar", specifier = ">=0.1.9" },
+ { name = "qrcode", specifier = ">=8.2" },
+ { name = "thefuzz", specifier = ">=0.22.1" },
+]
+
+[package.metadata.requires-dev]
+dev = [{ name = "ruff", specifier = ">=0.14.6" }]
+
+[[package]]
+name = "psutil"
+version = "7.1.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e1/88/bdd0a41e5857d5d703287598cbf08dad90aed56774ea52ae071bae9071b6/psutil-7.1.3.tar.gz", hash = "sha256:6c86281738d77335af7aec228328e944b30930899ea760ecf33a4dba66be5e74", size = 489059, upload-time = "2025-11-02T12:25:54.619Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/bd/93/0c49e776b8734fef56ec9c5c57f923922f2cf0497d62e0f419465f28f3d0/psutil-7.1.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0005da714eee687b4b8decd3d6cc7c6db36215c9e74e5ad2264b90c3df7d92dc", size = 239751, upload-time = "2025-11-02T12:25:58.161Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/8d/b31e39c769e70780f007969815195a55c81a63efebdd4dbe9e7a113adb2f/psutil-7.1.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:19644c85dcb987e35eeeaefdc3915d059dac7bd1167cdcdbf27e0ce2df0c08c0", size = 240368, upload-time = "2025-11-02T12:26:00.491Z" },
+ { url = "https://files.pythonhosted.org/packages/62/61/23fd4acc3c9eebbf6b6c78bcd89e5d020cfde4acf0a9233e9d4e3fa698b4/psutil-7.1.3-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95ef04cf2e5ba0ab9eaafc4a11eaae91b44f4ef5541acd2ee91d9108d00d59a7", size = 287134, upload-time = "2025-11-02T12:26:02.613Z" },
+ { url = "https://files.pythonhosted.org/packages/30/1c/f921a009ea9ceb51aa355cb0cc118f68d354db36eae18174bab63affb3e6/psutil-7.1.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1068c303be3a72f8e18e412c5b2a8f6d31750fb152f9cb106b54090296c9d251", size = 289904, upload-time = "2025-11-02T12:26:05.207Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/82/62d68066e13e46a5116df187d319d1724b3f437ddd0f958756fc052677f4/psutil-7.1.3-cp313-cp313t-win_amd64.whl", hash = "sha256:18349c5c24b06ac5612c0428ec2a0331c26443d259e2a0144a9b24b4395b58fa", size = 249642, upload-time = "2025-11-02T12:26:07.447Z" },
+ { url = "https://files.pythonhosted.org/packages/df/ad/c1cd5fe965c14a0392112f68362cfceb5230819dbb5b1888950d18a11d9f/psutil-7.1.3-cp313-cp313t-win_arm64.whl", hash = "sha256:c525ffa774fe4496282fb0b1187725793de3e7c6b29e41562733cae9ada151ee", size = 245518, upload-time = "2025-11-02T12:26:09.719Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/bb/6670bded3e3236eb4287c7bcdc167e9fae6e1e9286e437f7111caed2f909/psutil-7.1.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b403da1df4d6d43973dc004d19cee3b848e998ae3154cc8097d139b77156c353", size = 239843, upload-time = "2025-11-02T12:26:11.968Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/66/853d50e75a38c9a7370ddbeefabdd3d3116b9c31ef94dc92c6729bc36bec/psutil-7.1.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad81425efc5e75da3f39b3e636293360ad8d0b49bed7df824c79764fb4ba9b8b", size = 240369, upload-time = "2025-11-02T12:26:14.358Z" },
+ { url = "https://files.pythonhosted.org/packages/41/bd/313aba97cb5bfb26916dc29cf0646cbe4dd6a89ca69e8c6edce654876d39/psutil-7.1.3-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f33a3702e167783a9213db10ad29650ebf383946e91bc77f28a5eb083496bc9", size = 288210, upload-time = "2025-11-02T12:26:16.699Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/fa/76e3c06e760927a0cfb5705eb38164254de34e9bd86db656d4dbaa228b04/psutil-7.1.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fac9cd332c67f4422504297889da5ab7e05fd11e3c4392140f7370f4208ded1f", size = 291182, upload-time = "2025-11-02T12:26:18.848Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/1d/5774a91607035ee5078b8fd747686ebec28a962f178712de100d00b78a32/psutil-7.1.3-cp314-cp314t-win_amd64.whl", hash = "sha256:3792983e23b69843aea49c8f5b8f115572c5ab64c153bada5270086a2123c7e7", size = 250466, upload-time = "2025-11-02T12:26:21.183Z" },
+ { url = "https://files.pythonhosted.org/packages/00/ca/e426584bacb43a5cb1ac91fae1937f478cd8fbe5e4ff96574e698a2c77cd/psutil-7.1.3-cp314-cp314t-win_arm64.whl", hash = "sha256:31d77fcedb7529f27bb3a0472bea9334349f9a04160e8e6e5020f22c59893264", size = 245756, upload-time = "2025-11-02T12:26:23.148Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/94/46b9154a800253e7ecff5aaacdf8ebf43db99de4a2dfa18575b02548654e/psutil-7.1.3-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2bdbcd0e58ca14996a42adf3621a6244f1bb2e2e528886959c72cf1e326677ab", size = 238359, upload-time = "2025-11-02T12:26:25.284Z" },
+ { url = "https://files.pythonhosted.org/packages/68/3a/9f93cff5c025029a36d9a92fef47220ab4692ee7f2be0fba9f92813d0cb8/psutil-7.1.3-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:bc31fa00f1fbc3c3802141eede66f3a2d51d89716a194bf2cd6fc68310a19880", size = 239171, upload-time = "2025-11-02T12:26:27.23Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/b1/5f49af514f76431ba4eea935b8ad3725cdeb397e9245ab919dbc1d1dc20f/psutil-7.1.3-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bb428f9f05c1225a558f53e30ccbad9930b11c3fc206836242de1091d3e7dd3", size = 263261, upload-time = "2025-11-02T12:26:29.48Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/95/992c8816a74016eb095e73585d747e0a8ea21a061ed3689474fabb29a395/psutil-7.1.3-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56d974e02ca2c8eb4812c3f76c30e28836fffc311d55d979f1465c1feeb2b68b", size = 264635, upload-time = "2025-11-02T12:26:31.74Z" },
+ { url = "https://files.pythonhosted.org/packages/55/4c/c3ed1a622b6ae2fd3c945a366e64eb35247a31e4db16cf5095e269e8eb3c/psutil-7.1.3-cp37-abi3-win_amd64.whl", hash = "sha256:f39c2c19fe824b47484b96f9692932248a54c43799a84282cfe58d05a6449efd", size = 247633, upload-time = "2025-11-02T12:26:33.887Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/ad/33b2ccec09bf96c2b2ef3f9a6f66baac8253d7565d8839e024a6b905d45d/psutil-7.1.3-cp37-abi3-win_arm64.whl", hash = "sha256:bd0d69cee829226a761e92f28140bec9a5ee9d5b4fb4b0cc589068dbfff559b1", size = 244608, upload-time = "2025-11-02T12:26:36.136Z" },
+]
+
+[[package]]
+name = "pycairo"
+version = "1.29.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/22/d9/1728840a22a4ef8a8f479b9156aa2943cd98c3907accd3849fb0d5f82bfd/pycairo-1.29.0.tar.gz", hash = "sha256:f3f7fde97325cae80224c09f12564ef58d0d0f655da0e3b040f5807bd5bd3142", size = 665871, upload-time = "2025-11-11T19:13:01.584Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f6/28/6363087b9e60af031398a6ee5c248639eefc6cc742884fa2789411b1f73b/pycairo-1.29.0-cp312-cp312-win32.whl", hash = "sha256:91bcd7b5835764c616a615d9948a9afea29237b34d2ed013526807c3d79bb1d0", size = 751486, upload-time = "2025-11-11T19:11:54.451Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/d2/d146f1dd4ef81007686ac52231dd8f15ad54cf0aa432adaefc825475f286/pycairo-1.29.0-cp312-cp312-win_amd64.whl", hash = "sha256:3f01c3b5e49ef9411fff6bc7db1e765f542dc1c9cfed4542958a5afa3a8b8e76", size = 845383, upload-time = "2025-11-11T19:12:01.551Z" },
+ { url = "https://files.pythonhosted.org/packages/01/16/6e6f33bb79ec4a527c9e633915c16dc55a60be26b31118dbd0d5859e8c51/pycairo-1.29.0-cp312-cp312-win_arm64.whl", hash = "sha256:eafe3d2076f3533535ad4a361fa0754e0ee66b90e548a3a0f558fed00b1248f2", size = 694518, upload-time = "2025-11-11T19:12:06.561Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/21/3f477dc318dd4e84a5ae6301e67284199d7e5a2384f3063714041086b65d/pycairo-1.29.0-cp313-cp313-win32.whl", hash = "sha256:3eb382a4141591807073274522f7aecab9e8fa2f14feafd11ac03a13a58141d7", size = 750949, upload-time = "2025-11-11T19:12:12.198Z" },
+ { url = "https://files.pythonhosted.org/packages/43/34/7d27a333c558d6ac16dbc12a35061d389735e99e494ee4effa4ec6d99bed/pycairo-1.29.0-cp313-cp313-win_amd64.whl", hash = "sha256:91114e4b3fbf4287c2b0788f83e1f566ce031bda49cf1c3c3c19c3e986e95c38", size = 844149, upload-time = "2025-11-11T19:12:19.171Z" },
+ { url = "https://files.pythonhosted.org/packages/15/43/e782131e23df69e5c8e631a016ed84f94bbc4981bf6411079f57af730a23/pycairo-1.29.0-cp313-cp313-win_arm64.whl", hash = "sha256:09b7f69a5ff6881e151354ea092137b97b0b1f0b2ab4eb81c92a02cc4a08e335", size = 693595, upload-time = "2025-11-11T19:12:23.445Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/fa/87eaeeb9d53344c769839d7b2854db7ff2cd596211e00dd1b702eeb1838f/pycairo-1.29.0-cp314-cp314-win32.whl", hash = "sha256:69e2a7968a3fbb839736257bae153f547bca787113cc8d21e9e08ca4526e0b6b", size = 767198, upload-time = "2025-11-11T19:12:42.336Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/90/3564d0f64d0a00926ab863dc3c4a129b1065133128e96900772e1c4421f8/pycairo-1.29.0-cp314-cp314-win_amd64.whl", hash = "sha256:e91243437a21cc4c67c401eff4433eadc45745275fa3ade1a0d877e50ffb90da", size = 871579, upload-time = "2025-11-11T19:12:48.982Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/91/93632b6ba12ad69c61991e3208bde88486fdfc152be8cfdd13444e9bc650/pycairo-1.29.0-cp314-cp314-win_arm64.whl", hash = "sha256:b72200ea0e5f73ae4c788cd2028a750062221385eb0e6d8f1ecc714d0b4fdf82", size = 719537, upload-time = "2025-11-11T19:12:55.016Z" },
+ { url = "https://files.pythonhosted.org/packages/93/23/37053c039f8d3b9b5017af9bc64d27b680c48a898d48b72e6d6583cf0155/pycairo-1.29.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5e45fce6185f553e79e4ef1722b8e98e6cde9900dbc48cb2637a9ccba86f627a", size = 874015, upload-time = "2025-11-11T19:12:28.47Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/54/123f6239685f5f3f2edc123f1e38d2eefacebee18cf3c532d2f4bd51d0ef/pycairo-1.29.0-cp314-cp314t-win_arm64.whl", hash = "sha256:caba0837a4b40d47c8dfb0f24cccc12c7831e3dd450837f2a356c75f21ce5a15", size = 721404, upload-time = "2025-11-11T19:12:36.919Z" },
+]
+
+[[package]]
+name = "pycparser"
+version = "2.23"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" },
+]
+
+[[package]]
+name = "pygobject"
+version = "3.50.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pycairo" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/2b/58/d34e67a79631177e3c08e7d02b5165147f590171f2cae6769502af5f7f7e/pygobject-3.50.0.tar.gz", hash = "sha256:4500ad3dbf331773d8dedf7212544c999a76fc96b63a91b3dcac1e5925a1d103", size = 1080367, upload-time = "2024-09-12T12:05:22.579Z" }
+
+[[package]]
+name = "pyotp"
+version = "2.9.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f3/b2/1d5994ba2acde054a443bd5e2d384175449c7d2b6d1a0614dbca3a63abfc/pyotp-2.9.0.tar.gz", hash = "sha256:346b6642e0dbdde3b4ff5a930b664ca82abfa116356ed48cc42c7d6590d36f63", size = 17763, upload-time = "2023-07-27T23:41:03.295Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c3/c0/c33c8792c3e50193ef55adb95c1c3c2786fe281123291c2dbf0eaab95a6f/pyotp-2.9.0-py3-none-any.whl", hash = "sha256:81c2e5865b8ac55e825b0358e496e1d9387c811e85bb40e71a3b29b288963612", size = 13376, upload-time = "2023-07-27T23:41:01.685Z" },
+]
+
+[[package]]
+name = "pywayland"
+version = "0.4.18"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/20/93/bc9b258dcb46aafc587438355a9170c3be05a6abb5cd55bf1d600d848fa0/pywayland-0.4.18.tar.gz", hash = "sha256:598ade02783aad05a328f663b51b694c5ab68bd5d1e0926c0da3c5212c566533", size = 247348, upload-time = "2024-07-27T19:24:38.008Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3f/3a/1134f6e0badc62ae4db44486ba19d30f6af8c8ef4045cc8d099839c275fe/pywayland-0.4.18-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:9bfa354df5bda91b94fbb6520543b8229d26cde1625f58c8741e647ef3ef5b4f", size = 837339, upload-time = "2024-07-27T19:24:29.72Z" },
+]
+
+[[package]]
+name = "pyzbar"
+version = "0.1.9"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6d/24/81ebe6a1c00760471a3028a23cbe0b94e5fa2926e5ba47adc895920887bc/pyzbar-0.1.9-py2.py3-none-any.whl", hash = "sha256:4559628b8192feb25766d954b36a3753baaf5c97c03135aec7e4a026036b475d", size = 32560, upload-time = "2022-03-15T14:53:40.637Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/87/7b596730179ddf17857eea33ba820354dd4e1cf941e57f51ffccce26c409/pyzbar-0.1.9-py2.py3-none-win32.whl", hash = "sha256:8f4c5264c9c7c6b9f20d01efc52a4eba1ded47d9ba857a94130afe33703eb518", size = 810633, upload-time = "2022-03-15T14:53:43.446Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/e2/1c6a8e94197612dbdfc51eab8dfb674168829885fac2c4f50ac8366c25ca/pyzbar-0.1.9-py2.py3-none-win_amd64.whl", hash = "sha256:13e3ee5a2f3a545204a285f41814d5c0db571967e8d4af8699a03afc55182a9c", size = 817363, upload-time = "2022-03-15T14:53:46.691Z" },
+]
+
+[[package]]
+name = "qrcode"
+version = "8.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/8f/b2/7fc2931bfae0af02d5f53b174e9cf701adbb35f39d69c2af63d4a39f81a9/qrcode-8.2.tar.gz", hash = "sha256:35c3f2a4172b33136ab9f6b3ef1c00260dd2f66f858f24d88418a015f446506c", size = 43317, upload-time = "2025-05-01T15:44:24.726Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/dd/b8/d2d6d731733f51684bbf76bf34dab3b70a9148e8f2cef2bb544fccec681a/qrcode-8.2-py3-none-any.whl", hash = "sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f", size = 45986, upload-time = "2025-05-01T15:44:22.781Z" },
+]
+
+[[package]]
+name = "rapidfuzz"
+version = "3.14.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d3/28/9d808fe62375b9aab5ba92fa9b29371297b067c2790b2d7cda648b1e2f8d/rapidfuzz-3.14.3.tar.gz", hash = "sha256:2491937177868bc4b1e469087601d53f925e8d270ccc21e07404b4b5814b7b5f", size = 57863900, upload-time = "2025-11-01T11:54:52.321Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fa/8e/3c215e860b458cfbedb3ed73bc72e98eb7e0ed72f6b48099604a7a3260c2/rapidfuzz-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:685c93ea961d135893b5984a5a9851637d23767feabe414ec974f43babbd8226", size = 1945306, upload-time = "2025-11-01T11:53:06.452Z" },
+ { url = "https://files.pythonhosted.org/packages/36/d9/31b33512015c899f4a6e6af64df8dfe8acddf4c8b40a4b3e0e6e1bcd00e5/rapidfuzz-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fa7c8f26f009f8c673fbfb443792f0cf8cf50c4e18121ff1e285b5e08a94fbdb", size = 1390788, upload-time = "2025-11-01T11:53:08.721Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/67/2ee6f8de6e2081ccd560a571d9c9063184fe467f484a17fa90311a7f4a2e/rapidfuzz-3.14.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57f878330c8d361b2ce76cebb8e3e1dc827293b6abf404e67d53260d27b5d941", size = 1374580, upload-time = "2025-11-01T11:53:10.164Z" },
+ { url = "https://files.pythonhosted.org/packages/30/83/80d22997acd928eda7deadc19ccd15883904622396d6571e935993e0453a/rapidfuzz-3.14.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c5f545f454871e6af05753a0172849c82feaf0f521c5ca62ba09e1b382d6382", size = 3154947, upload-time = "2025-11-01T11:53:12.093Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/cf/9f49831085a16384695f9fb096b99662f589e30b89b4a589a1ebc1a19d34/rapidfuzz-3.14.3-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:07aa0b5d8863e3151e05026a28e0d924accf0a7a3b605da978f0359bb804df43", size = 1223872, upload-time = "2025-11-01T11:53:13.664Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/0f/41ee8034e744b871c2e071ef0d360686f5ccfe5659f4fd96c3ec406b3c8b/rapidfuzz-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73b07566bc7e010e7b5bd490fb04bb312e820970180df6b5655e9e6224c137db", size = 2392512, upload-time = "2025-11-01T11:53:15.109Z" },
+ { url = "https://files.pythonhosted.org/packages/da/86/280038b6b0c2ccec54fb957c732ad6b41cc1fd03b288d76545b9cf98343f/rapidfuzz-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6de00eb84c71476af7d3110cf25d8fe7c792d7f5fa86764ef0b4ca97e78ca3ed", size = 2521398, upload-time = "2025-11-01T11:53:17.146Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/7b/05c26f939607dca0006505e3216248ae2de631e39ef94dd63dbbf0860021/rapidfuzz-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d7843a1abf0091773a530636fdd2a49a41bcae22f9910b86b4f903e76ddc82dc", size = 4259416, upload-time = "2025-11-01T11:53:19.34Z" },
+ { url = "https://files.pythonhosted.org/packages/40/eb/9e3af4103d91788f81111af1b54a28de347cdbed8eaa6c91d5e98a889aab/rapidfuzz-3.14.3-cp312-cp312-win32.whl", hash = "sha256:dea97ac3ca18cd3ba8f3d04b5c1fe4aa60e58e8d9b7793d3bd595fdb04128d7a", size = 1709527, upload-time = "2025-11-01T11:53:20.949Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/63/d06ecce90e2cf1747e29aeab9f823d21e5877a4c51b79720b2d3be7848f8/rapidfuzz-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:b5100fd6bcee4d27f28f4e0a1c6b5127bc8ba7c2a9959cad9eab0bf4a7ab3329", size = 1538989, upload-time = "2025-11-01T11:53:22.428Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/6d/beee32dcda64af8128aab3ace2ccb33d797ed58c434c6419eea015fec779/rapidfuzz-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:4e49c9e992bc5fc873bd0fff7ef16a4405130ec42f2ce3d2b735ba5d3d4eb70f", size = 811161, upload-time = "2025-11-01T11:53:23.811Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/4f/0d94d09646853bd26978cb3a7541b6233c5760687777fa97da8de0d9a6ac/rapidfuzz-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dbcb726064b12f356bf10fffdb6db4b6dce5390b23627c08652b3f6e49aa56ae", size = 1939646, upload-time = "2025-11-01T11:53:25.292Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/eb/f96aefc00f3bbdbab9c0657363ea8437a207d7545ac1c3789673e05d80bd/rapidfuzz-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1704fc70d214294e554a2421b473779bcdeef715881c5e927dc0f11e1692a0ff", size = 1385512, upload-time = "2025-11-01T11:53:27.594Z" },
+ { url = "https://files.pythonhosted.org/packages/26/34/71c4f7749c12ee223dba90017a5947e8f03731a7cc9f489b662a8e9e643d/rapidfuzz-3.14.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc65e72790ddfd310c2c8912b45106e3800fefe160b0c2ef4d6b6fec4e826457", size = 1373571, upload-time = "2025-11-01T11:53:29.096Z" },
+ { url = "https://files.pythonhosted.org/packages/32/00/ec8597a64f2be301ce1ee3290d067f49f6a7afb226b67d5f15b56d772ba5/rapidfuzz-3.14.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e38c1305cffae8472572a0584d4ffc2f130865586a81038ca3965301f7c97c", size = 3156759, upload-time = "2025-11-01T11:53:30.777Z" },
+ { url = "https://files.pythonhosted.org/packages/61/d5/b41eeb4930501cc899d5a9a7b5c9a33d85a670200d7e81658626dcc0ecc0/rapidfuzz-3.14.3-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:e195a77d06c03c98b3fc06b8a28576ba824392ce40de8c708f96ce04849a052e", size = 1222067, upload-time = "2025-11-01T11:53:32.334Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/7d/6d9abb4ffd1027c6ed837b425834f3bed8344472eb3a503ab55b3407c721/rapidfuzz-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1b7ef2f4b8583a744338a18f12c69693c194fb6777c0e9ada98cd4d9e8f09d10", size = 2394775, upload-time = "2025-11-01T11:53:34.24Z" },
+ { url = "https://files.pythonhosted.org/packages/15/ce/4f3ab4c401c5a55364da1ffff8cc879fc97b4e5f4fa96033827da491a973/rapidfuzz-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a2135b138bcdcb4c3742d417f215ac2d8c2b87bde15b0feede231ae95f09ec41", size = 2526123, upload-time = "2025-11-01T11:53:35.779Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/4b/54f804975376a328f57293bd817c12c9036171d15cf7292032e3f5820b2d/rapidfuzz-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:33a325ed0e8e1aa20c3e75f8ab057a7b248fdea7843c2a19ade0008906c14af0", size = 4262874, upload-time = "2025-11-01T11:53:37.866Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/b6/958db27d8a29a50ee6edd45d33debd3ce732e7209183a72f57544cd5fe22/rapidfuzz-3.14.3-cp313-cp313-win32.whl", hash = "sha256:8383b6d0d92f6cd008f3c9216535be215a064b2cc890398a678b56e6d280cb63", size = 1707972, upload-time = "2025-11-01T11:53:39.442Z" },
+ { url = "https://files.pythonhosted.org/packages/07/75/fde1f334b0cec15b5946d9f84d73250fbfcc73c236b4bc1b25129d90876b/rapidfuzz-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:e6b5e3036976f0fde888687d91be86d81f9ac5f7b02e218913c38285b756be6c", size = 1537011, upload-time = "2025-11-01T11:53:40.92Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/d7/d83fe001ce599dc7ead57ba1debf923dc961b6bdce522b741e6b8c82f55c/rapidfuzz-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:7ba009977601d8b0828bfac9a110b195b3e4e79b350dcfa48c11269a9f1918a0", size = 810744, upload-time = "2025-11-01T11:53:42.723Z" },
+ { url = "https://files.pythonhosted.org/packages/92/13/a486369e63ff3c1a58444d16b15c5feb943edd0e6c28a1d7d67cb8946b8f/rapidfuzz-3.14.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0a28add871425c2fe94358c6300bbeb0bc2ed828ca003420ac6825408f5a424", size = 1967702, upload-time = "2025-11-01T11:53:44.554Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/82/efad25e260b7810f01d6b69122685e355bed78c94a12784bac4e0beb2afb/rapidfuzz-3.14.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:010e12e2411a4854b0434f920e72b717c43f8ec48d57e7affe5c42ecfa05dd0e", size = 1410702, upload-time = "2025-11-01T11:53:46.066Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/1a/34c977b860cde91082eae4a97ae503f43e0d84d4af301d857679b66f9869/rapidfuzz-3.14.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cfc3d57abd83c734d1714ec39c88a34dd69c85474918ebc21296f1e61eb5ca8", size = 1382337, upload-time = "2025-11-01T11:53:47.62Z" },
+ { url = "https://files.pythonhosted.org/packages/88/74/f50ea0e24a5880a9159e8fd256b84d8f4634c2f6b4f98028bdd31891d907/rapidfuzz-3.14.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89acb8cbb52904f763e5ac238083b9fc193bed8d1f03c80568b20e4cef43a519", size = 3165563, upload-time = "2025-11-01T11:53:49.216Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/7a/e744359404d7737049c26099423fc54bcbf303de5d870d07d2fb1410f567/rapidfuzz-3.14.3-cp313-cp313t-manylinux_2_31_armv7l.whl", hash = "sha256:7d9af908c2f371bfb9c985bd134e295038e3031e666e4b2ade1e7cb7f5af2f1a", size = 1214727, upload-time = "2025-11-01T11:53:50.883Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/2e/87adfe14ce75768ec6c2b8acd0e05e85e84be4be5e3d283cdae360afc4fe/rapidfuzz-3.14.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:1f1925619627f8798f8c3a391d81071336942e5fe8467bc3c567f982e7ce2897", size = 2403349, upload-time = "2025-11-01T11:53:52.322Z" },
+ { url = "https://files.pythonhosted.org/packages/70/17/6c0b2b2bff9c8b12e12624c07aa22e922b0c72a490f180fa9183d1ef2c75/rapidfuzz-3.14.3-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:152555187360978119e98ce3e8263d70dd0c40c7541193fc302e9b7125cf8f58", size = 2507596, upload-time = "2025-11-01T11:53:53.835Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/d1/87852a7cbe4da7b962174c749a47433881a63a817d04f3e385ea9babcd9e/rapidfuzz-3.14.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:52619d25a09546b8db078981ca88939d72caa6b8701edd8b22e16482a38e799f", size = 4273595, upload-time = "2025-11-01T11:53:55.961Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/ab/1d0354b7d1771a28fa7fe089bc23acec2bdd3756efa2419f463e3ed80e16/rapidfuzz-3.14.3-cp313-cp313t-win32.whl", hash = "sha256:489ce98a895c98cad284f0a47960c3e264c724cb4cfd47a1430fa091c0c25204", size = 1757773, upload-time = "2025-11-01T11:53:57.628Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/0c/71ef356adc29e2bdf74cd284317b34a16b80258fa0e7e242dd92cc1e6d10/rapidfuzz-3.14.3-cp313-cp313t-win_amd64.whl", hash = "sha256:656e52b054d5b5c2524169240e50cfa080b04b1c613c5f90a2465e84888d6f15", size = 1576797, upload-time = "2025-11-01T11:53:59.455Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/d2/0e64fc27bb08d4304aa3d11154eb5480bcf5d62d60140a7ee984dc07468a/rapidfuzz-3.14.3-cp313-cp313t-win_arm64.whl", hash = "sha256:c7e40c0a0af02ad6e57e89f62bef8604f55a04ecae90b0ceeda591bbf5923317", size = 829940, upload-time = "2025-11-01T11:54:01.1Z" },
+ { url = "https://files.pythonhosted.org/packages/32/6f/1b88aaeade83abc5418788f9e6b01efefcd1a69d65ded37d89cd1662be41/rapidfuzz-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:442125473b247227d3f2de807a11da6c08ccf536572d1be943f8e262bae7e4ea", size = 1942086, upload-time = "2025-11-01T11:54:02.592Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/2c/b23861347436cb10f46c2bd425489ec462790faaa360a54a7ede5f78de88/rapidfuzz-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ec0c8c0c3d4f97ced46b2e191e883f8c82dbbf6d5ebc1842366d7eff13cd5a6", size = 1386993, upload-time = "2025-11-01T11:54:04.12Z" },
+ { url = "https://files.pythonhosted.org/packages/83/86/5d72e2c060aa1fbdc1f7362d938f6b237dff91f5b9fc5dd7cc297e112250/rapidfuzz-3.14.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2dc37bc20272f388b8c3a4eba4febc6e77e50a8f450c472def4751e7678f55e4", size = 1379126, upload-time = "2025-11-01T11:54:05.777Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/bc/ef2cee3e4d8b3fc22705ff519f0d487eecc756abdc7c25d53686689d6cf2/rapidfuzz-3.14.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dee362e7e79bae940a5e2b3f6d09c6554db6a4e301cc68343886c08be99844f1", size = 3159304, upload-time = "2025-11-01T11:54:07.351Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/36/dc5f2f62bbc7bc90be1f75eeaf49ed9502094bb19290dfb4747317b17f12/rapidfuzz-3.14.3-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:4b39921df948388a863f0e267edf2c36302983459b021ab928d4b801cbe6a421", size = 1218207, upload-time = "2025-11-01T11:54:09.641Z" },
+ { url = "https://files.pythonhosted.org/packages/df/7e/8f4be75c1bc62f47edf2bbbe2370ee482fae655ebcc4718ac3827ead3904/rapidfuzz-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:beda6aa9bc44d1d81242e7b291b446be352d3451f8217fcb068fc2933927d53b", size = 2401245, upload-time = "2025-11-01T11:54:11.543Z" },
+ { url = "https://files.pythonhosted.org/packages/05/38/f7c92759e1bb188dd05b80d11c630ba59b8d7856657baf454ff56059c2ab/rapidfuzz-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:6a014ba09657abfcfeed64b7d09407acb29af436d7fc075b23a298a7e4a6b41c", size = 2518308, upload-time = "2025-11-01T11:54:13.134Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/ac/85820f70fed5ecb5f1d9a55f1e1e2090ef62985ef41db289b5ac5ec56e28/rapidfuzz-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:32eeafa3abce138bb725550c0e228fc7eaeec7059aa8093d9cbbec2b58c2371a", size = 4265011, upload-time = "2025-11-01T11:54:15.087Z" },
+ { url = "https://files.pythonhosted.org/packages/46/a9/616930721ea9835c918af7cde22bff17f9db3639b0c1a7f96684be7f5630/rapidfuzz-3.14.3-cp314-cp314-win32.whl", hash = "sha256:adb44d996fc610c7da8c5048775b21db60dd63b1548f078e95858c05c86876a3", size = 1742245, upload-time = "2025-11-01T11:54:17.19Z" },
+ { url = "https://files.pythonhosted.org/packages/06/8a/f2fa5e9635b1ccafda4accf0e38246003f69982d7c81f2faa150014525a4/rapidfuzz-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:f3d15d8527e2b293e38ce6e437631af0708df29eafd7c9fc48210854c94472f9", size = 1584856, upload-time = "2025-11-01T11:54:18.764Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/97/09e20663917678a6d60d8e0e29796db175b1165e2079830430342d5298be/rapidfuzz-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:576e4b9012a67e0bf54fccb69a7b6c94d4e86a9540a62f1a5144977359133583", size = 833490, upload-time = "2025-11-01T11:54:20.753Z" },
+ { url = "https://files.pythonhosted.org/packages/03/1b/6b6084576ba87bf21877c77218a0c97ba98cb285b0c02eaaee3acd7c4513/rapidfuzz-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:cec3c0da88562727dd5a5a364bd9efeb535400ff0bfb1443156dd139a1dd7b50", size = 1968658, upload-time = "2025-11-01T11:54:22.25Z" },
+ { url = "https://files.pythonhosted.org/packages/38/c0/fb02a0db80d95704b0a6469cc394e8c38501abf7e1c0b2afe3261d1510c2/rapidfuzz-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d1fa009f8b1100e4880868137e7bf0501422898f7674f2adcd85d5a67f041296", size = 1410742, upload-time = "2025-11-01T11:54:23.863Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/72/3fbf12819fc6afc8ec75a45204013b40979d068971e535a7f3512b05e765/rapidfuzz-3.14.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b86daa7419b5e8b180690efd1fdbac43ff19230803282521c5b5a9c83977655", size = 1382810, upload-time = "2025-11-01T11:54:25.571Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/18/0f1991d59bb7eee28922a00f79d83eafa8c7bfb4e8edebf4af2a160e7196/rapidfuzz-3.14.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7bd1816db05d6c5ffb3a4df0a2b7b56fb8c81ef584d08e37058afa217da91b1", size = 3166349, upload-time = "2025-11-01T11:54:27.195Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/f0/baa958b1989c8f88c78bbb329e969440cf330b5a01a982669986495bb980/rapidfuzz-3.14.3-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:33da4bbaf44e9755b0ce192597f3bde7372fe2e381ab305f41b707a95ac57aa7", size = 1214994, upload-time = "2025-11-01T11:54:28.821Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/a0/cd12ec71f9b2519a3954febc5740291cceabc64c87bc6433afcb36259f3b/rapidfuzz-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3fecce764cf5a991ee2195a844196da840aba72029b2612f95ac68a8b74946bf", size = 2403919, upload-time = "2025-11-01T11:54:30.393Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/ce/019bd2176c1644098eced4f0595cb4b3ef52e4941ac9a5854f209d0a6e16/rapidfuzz-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ecd7453e02cf072258c3a6b8e930230d789d5d46cc849503729f9ce475d0e785", size = 2508346, upload-time = "2025-11-01T11:54:32.048Z" },
+ { url = "https://files.pythonhosted.org/packages/23/f8/be16c68e2c9e6c4f23e8f4adbb7bccc9483200087ed28ff76c5312da9b14/rapidfuzz-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea188aa00e9bcae8c8411f006a5f2f06c4607a02f24eab0d8dc58566aa911f35", size = 4274105, upload-time = "2025-11-01T11:54:33.701Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/d1/5ab148e03f7e6ec8cd220ccf7af74d3aaa4de26dd96df58936beb7cba820/rapidfuzz-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:7ccbf68100c170e9a0581accbe9291850936711548c6688ce3bfb897b8c589ad", size = 1793465, upload-time = "2025-11-01T11:54:35.331Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/97/433b2d98e97abd9fff1c470a109b311669f44cdec8d0d5aa250aceaed1fb/rapidfuzz-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9ec02e62ae765a318d6de38df609c57fc6dacc65c0ed1fd489036834fd8a620c", size = 1623491, upload-time = "2025-11-01T11:54:38.085Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/f6/e2176eb94f94892441bce3ddc514c179facb65db245e7ce3356965595b19/rapidfuzz-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:e805e52322ae29aa945baf7168b6c898120fbc16d2b8f940b658a5e9e3999253", size = 851487, upload-time = "2025-11-01T11:54:40.176Z" },
+]
+
+[[package]]
+name = "ruff"
+version = "0.14.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/52/f0/62b5a1a723fe183650109407fa56abb433b00aa1c0b9ba555f9c4efec2c6/ruff-0.14.6.tar.gz", hash = "sha256:6f0c742ca6a7783a736b867a263b9a7a80a45ce9bee391eeda296895f1b4e1cc", size = 5669501, upload-time = "2025-11-21T14:26:17.903Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/67/d2/7dd544116d107fffb24a0064d41a5d2ed1c9d6372d142f9ba108c8e39207/ruff-0.14.6-py3-none-linux_armv6l.whl", hash = "sha256:d724ac2f1c240dbd01a2ae98db5d1d9a5e1d9e96eba999d1c48e30062df578a3", size = 13326119, upload-time = "2025-11-21T14:25:24.2Z" },
+ { url = "https://files.pythonhosted.org/packages/36/6a/ad66d0a3315d6327ed6b01f759d83df3c4d5f86c30462121024361137b6a/ruff-0.14.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9f7539ea257aa4d07b7ce87aed580e485c40143f2473ff2f2b75aee003186004", size = 13526007, upload-time = "2025-11-21T14:25:26.906Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/9d/dae6db96df28e0a15dea8e986ee393af70fc97fd57669808728080529c37/ruff-0.14.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7f6007e55b90a2a7e93083ba48a9f23c3158c433591c33ee2e99a49b889c6332", size = 12676572, upload-time = "2025-11-21T14:25:29.826Z" },
+ { url = "https://files.pythonhosted.org/packages/76/a4/f319e87759949062cfee1b26245048e92e2acce900ad3a909285f9db1859/ruff-0.14.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a8e7b9d73d8728b68f632aa8e824ef041d068d231d8dbc7808532d3629a6bef", size = 13140745, upload-time = "2025-11-21T14:25:32.788Z" },
+ { url = "https://files.pythonhosted.org/packages/95/d3/248c1efc71a0a8ed4e8e10b4b2266845d7dfc7a0ab64354afe049eaa1310/ruff-0.14.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d50d45d4553a3ebcbd33e7c5e0fe6ca4aafd9a9122492de357205c2c48f00775", size = 13076486, upload-time = "2025-11-21T14:25:35.601Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/19/b68d4563fe50eba4b8c92aa842149bb56dd24d198389c0ed12e7faff4f7d/ruff-0.14.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:118548dd121f8a21bfa8ab2c5b80e5b4aed67ead4b7567790962554f38e598ce", size = 13727563, upload-time = "2025-11-21T14:25:38.514Z" },
+ { url = "https://files.pythonhosted.org/packages/47/ac/943169436832d4b0e867235abbdb57ce3a82367b47e0280fa7b4eabb7593/ruff-0.14.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:57256efafbfefcb8748df9d1d766062f62b20150691021f8ab79e2d919f7c11f", size = 15199755, upload-time = "2025-11-21T14:25:41.516Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/b9/288bb2399860a36d4bb0541cb66cce3c0f4156aaff009dc8499be0c24bf2/ruff-0.14.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ff18134841e5c68f8e5df1999a64429a02d5549036b394fafbe410f886e1989d", size = 14850608, upload-time = "2025-11-21T14:25:44.428Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/b1/a0d549dd4364e240f37e7d2907e97ee80587480d98c7799d2d8dc7a2f605/ruff-0.14.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29c4b7ec1e66a105d5c27bd57fa93203637d66a26d10ca9809dc7fc18ec58440", size = 14118754, upload-time = "2025-11-21T14:25:47.214Z" },
+ { url = "https://files.pythonhosted.org/packages/13/ac/9b9fe63716af8bdfddfacd0882bc1586f29985d3b988b3c62ddce2e202c3/ruff-0.14.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:167843a6f78680746d7e226f255d920aeed5e4ad9c03258094a2d49d3028b105", size = 13949214, upload-time = "2025-11-21T14:25:50.002Z" },
+ { url = "https://files.pythonhosted.org/packages/12/27/4dad6c6a77fede9560b7df6802b1b697e97e49ceabe1f12baf3ea20862e9/ruff-0.14.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:16a33af621c9c523b1ae006b1b99b159bf5ac7e4b1f20b85b2572455018e0821", size = 14106112, upload-time = "2025-11-21T14:25:52.841Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/db/23e322d7177873eaedea59a7932ca5084ec5b7e20cb30f341ab594130a71/ruff-0.14.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1432ab6e1ae2dc565a7eea707d3b03a0c234ef401482a6f1621bc1f427c2ff55", size = 13035010, upload-time = "2025-11-21T14:25:55.536Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/9c/20e21d4d69dbb35e6a1df7691e02f363423658a20a2afacf2a2c011800dc/ruff-0.14.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:4c55cfbbe7abb61eb914bfd20683d14cdfb38a6d56c6c66efa55ec6570ee4e71", size = 13054082, upload-time = "2025-11-21T14:25:58.625Z" },
+ { url = "https://files.pythonhosted.org/packages/66/25/906ee6a0464c3125c8d673c589771a974965c2be1a1e28b5c3b96cb6ef88/ruff-0.14.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:efea3c0f21901a685fff4befda6d61a1bf4cb43de16da87e8226a281d614350b", size = 13303354, upload-time = "2025-11-21T14:26:01.816Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/58/60577569e198d56922b7ead07b465f559002b7b11d53f40937e95067ca1c/ruff-0.14.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:344d97172576d75dc6afc0e9243376dbe1668559c72de1864439c4fc95f78185", size = 14054487, upload-time = "2025-11-21T14:26:05.058Z" },
+ { url = "https://files.pythonhosted.org/packages/67/0b/8e4e0639e4cc12547f41cb771b0b44ec8225b6b6a93393176d75fe6f7d40/ruff-0.14.6-py3-none-win32.whl", hash = "sha256:00169c0c8b85396516fdd9ce3446c7ca20c2a8f90a77aa945ba6b8f2bfe99e85", size = 13013361, upload-time = "2025-11-21T14:26:08.152Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/02/82240553b77fd1341f80ebb3eaae43ba011c7a91b4224a9f317d8e6591af/ruff-0.14.6-py3-none-win_amd64.whl", hash = "sha256:390e6480c5e3659f8a4c8d6a0373027820419ac14fa0d2713bd8e6c3e125b8b9", size = 14432087, upload-time = "2025-11-21T14:26:10.891Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/1f/93f9b0fad9470e4c829a5bb678da4012f0c710d09331b860ee555216f4ea/ruff-0.14.6-py3-none-win_arm64.whl", hash = "sha256:d43c81fbeae52cfa8728d8766bbf46ee4298c888072105815b392da70ca836b2", size = 13520930, upload-time = "2025-11-21T14:26:13.951Z" },
+]
+
+[[package]]
+name = "thefuzz"
+version = "0.22.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "rapidfuzz" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/81/4b/d3eb25831590d6d7d38c2f2e3561d3ba41d490dc89cd91d9e65e7c812508/thefuzz-0.22.1.tar.gz", hash = "sha256:7138039a7ecf540da323792d8592ef9902b1d79eb78c147d4f20664de79f3680", size = 19993, upload-time = "2024-01-19T19:18:23.135Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/82/4f/1695e70ceb3604f19eda9908e289c687ea81c4fecef4d90a9d1d0f2f7ae9/thefuzz-0.22.1-py3-none-any.whl", hash = "sha256:59729b33556850b90e1093c4cf9e618af6f2e4c985df193fdf3c5b5cf02ca481", size = 8245, upload-time = "2024-01-19T19:18:20.362Z" },
+]
+
+[[package]]
+name = "win32-setctime"
+version = "1.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" },
+]