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

- Logo -

-

Sparkles A modular and extensible shell for Hyprland, written on Fabric. Sparkles

+[mine](./mine/) — My second Fabric rice, featuring various improvements, refinements, and experiments based on what I learned from the legacy version. -

- - -People - - -Stars - - -Forks - - -Watches - - -Last Updated -

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

Package Installation

- -> [!NOTE] -> You need a functioning Hyprland installation. - -### Arch Linux - -> [!NOTE] -> You need to clone it into the Home directory. - -> [!TIP] -> This command also works for updating an existing installation! - -```bash -curl -fsSL https://raw.githubusercontent.com/S4NKALP/Modus/main/install.sh | bash -``` - -### Manual Installation - -1. Dependencies: - -```sh -python-fabric-git fabric-cli gnome-bluetooth-3.0 wf-recorder cliphist wl-clipboard grimblast power-profile-daemon adw-gtk-theme brightnessctl swww hypridle hyprlock imagemagick libnotify swappy libadwaita wlinhibit -``` - -```sh -python-materialyoucolor-git python-pillow python-toml python-ijson python-setproctitle python-requests python-numpy python-pywayland python-pyxdg -``` - -```sh -ttf-font-awesome otf-font-awesome ttf-material-symbols-variable-git ttf-google-sans ttf-opensans ttf-robot -``` - -- Icon: - -```sh -git clone https://github.com/vinceliuice/Tela-icon-theme -cd Tela-icon-theme -./install.sh nord -``` - -

Camera with Flash Screenshots

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

Rocket Todo

- -- [x] App Launcher -- [x] Power Menu -- [x] Wallpaper Picker -- [x] Todo -- [x] Bluetooth Manager -- [x] Clipboard Manager -- [x] OSD -- [x] Sh Runner -- [x] Emoji Picker -- [x] Update Screenshot -- [x] Installation script -- [x] Network Manager -- [x] Calendar -- [x] Power Manager -- [x] 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

+
+ +

+ Logo +

+

Sparkles A modular and extensible shell for Hyprland, written on Fabric. Sparkles

+ +

+ + +People + + +Stars + + +Forks + + +Watches + + +Last Updated +

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

Package Installation

+ +> [!NOTE] +> You need a functioning Hyprland installation. + +### Arch Linux + +> [!NOTE] +> You need to clone it into the Home directory. + +> [!TIP] +> This command also works for updating an existing installation! + +```bash +curl -fsSL https://raw.githubusercontent.com/S4NKALP/Modus/main/install.sh | bash +``` + +### Manual Installation + +1. Dependencies: + +```sh +python-fabric-git fabric-cli gnome-bluetooth-3.0 wf-recorder cliphist wl-clipboard grimblast power-profile-daemon adw-gtk-theme brightnessctl swww hypridle hyprlock imagemagick libnotify swappy libadwaita wlinhibit +``` + +```sh +python-materialyoucolor-git python-pillow python-toml python-ijson python-setproctitle python-requests python-numpy python-pywayland python-pyxdg +``` + +```sh +ttf-font-awesome otf-font-awesome ttf-material-symbols-variable-git ttf-google-sans ttf-opensans ttf-robot +``` + +- Icon: + +```sh +git clone https://github.com/vinceliuice/Tela-icon-theme +cd Tela-icon-theme +./install.sh nord +``` + +

Camera with Flash Screenshots

+ + + + + + + + + + + + + + + + + + + +
+ +

Rocket Todo

+ +- [x] App Launcher +- [x] Power Menu +- [x] Wallpaper Picker +- [x] Todo +- [x] Bluetooth Manager +- [x] Clipboard Manager +- [x] OSD +- [x] Sh Runner +- [x] Emoji Picker +- [x] Update Screenshot +- [x] Installation script +- [x] Network Manager +- [x] Calendar +- [x] Power Manager +- [x] 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 + +

+screenshots +

+ +> **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" }, +]