diff --git a/.gitignore b/.gitignore index db0e77f..e68a251 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,13 @@ -__pycache__ +**/__pycache__/ +*.pyc +*.pyo +*.pyd config/assets/config.json +config/assets/dock.json +config/assets/accounts.json +config/assets/passwords.json +config/assets/bookmarks.json +config/assets/screentime.json config/hypr/modus.conf +config/hypr/colors.conf +styles/colors.css diff --git a/README.md b/README.md index 9ac9fae..54f5f10 100644 --- a/README.md +++ b/README.md @@ -1,133 +1,6 @@
-

It may not work on your machine

+ Modus Logo + +**A modular and extensible shell for Hyprland, powered by Fabric** +
- -

- 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 -fabric 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/default.png b/assets/default.png new file mode 100644 index 0000000..774a4e0 Binary files /dev/null and b/assets/default.png differ diff --git a/assets/logo.svg b/assets/logo.svg deleted file mode 100644 index 5d19dc1..0000000 --- a/assets/logo.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/modus.png b/assets/modus.png new file mode 100644 index 0000000..eb680cc Binary files /dev/null and b/assets/modus.png differ diff --git a/assets/screenshots/bluetooth.png b/assets/screenshots/bluetooth.png deleted file mode 100644 index 7033d48..0000000 Binary files a/assets/screenshots/bluetooth.png and /dev/null differ diff --git a/assets/screenshots/cliphist.png b/assets/screenshots/cliphist.png deleted file mode 100644 index 6e21db0..0000000 Binary files a/assets/screenshots/cliphist.png and /dev/null differ diff --git a/assets/screenshots/emoji.png b/assets/screenshots/emoji.png deleted file mode 100644 index 3202bf9..0000000 Binary files a/assets/screenshots/emoji.png and /dev/null differ diff --git a/assets/screenshots/home.png b/assets/screenshots/home.png deleted file mode 100644 index c7ba7c3..0000000 Binary files a/assets/screenshots/home.png and /dev/null differ diff --git a/assets/screenshots/notification.png b/assets/screenshots/notification.png deleted file mode 100644 index c8c9f30..0000000 Binary files a/assets/screenshots/notification.png and /dev/null differ diff --git a/assets/screenshots/osd.png b/assets/screenshots/osd.png deleted file mode 100644 index a02dec6..0000000 Binary files a/assets/screenshots/osd.png and /dev/null differ diff --git a/assets/screenshots/powermenu.png b/assets/screenshots/powermenu.png deleted file mode 100644 index 165fc0e..0000000 Binary files a/assets/screenshots/powermenu.png and /dev/null differ diff --git a/assets/screenshots/shrunner.png b/assets/screenshots/shrunner.png deleted file mode 100644 index 40f184b..0000000 Binary files a/assets/screenshots/shrunner.png and /dev/null differ diff --git a/assets/screenshots/todo.png b/assets/screenshots/todo.png deleted file mode 100644 index 0718afb..0000000 Binary files a/assets/screenshots/todo.png and /dev/null differ diff --git a/assets/screenshots/wallpaper.png b/assets/screenshots/wallpaper.png deleted file mode 100644 index 4e6789b..0000000 Binary files a/assets/screenshots/wallpaper.png and /dev/null differ diff --git a/assets/wallpaper/example-1.png b/assets/wallpapers_example/example-1.png similarity index 100% rename from assets/wallpaper/example-1.png rename to assets/wallpapers_example/example-1.png diff --git a/assets/wallpaper/example-2.png b/assets/wallpapers_example/example-2.png similarity index 100% rename from assets/wallpaper/example-2.png rename to assets/wallpapers_example/example-2.png diff --git a/assets/wallpaper/example-3.jpg b/assets/wallpapers_example/example-3.jpg similarity index 100% rename from assets/wallpaper/example-3.jpg rename to assets/wallpapers_example/example-3.jpg diff --git a/assets/wallpaper/example-4.jpeg b/assets/wallpapers_example/example-4.jpeg similarity index 100% rename from assets/wallpaper/example-4.jpeg rename to assets/wallpapers_example/example-4.jpeg diff --git a/assets/wallpaper/example-5.jpg b/assets/wallpapers_example/example-5.jpg similarity index 100% rename from assets/wallpaper/example-5.jpg rename to assets/wallpapers_example/example-5.jpg diff --git a/config/__init__.py b/config/__init__.py index f94e240..9a75817 100644 --- a/config/__init__.py +++ b/config/__init__.py @@ -1,6 +1,7 @@ -from .data import * +""" +Ax-Shell configuration package. +""" # Import only specific names actually defined in data.py # This prevents circular imports by not importing everything - -from .data import APP_NAME, APP_NAME_CAP, CACHE_DIR, CONFIG_FILE, STATE_DIR +from .data import APP_NAME, APP_NAME_CAP, CACHE_DIR, CONFIG_FILE 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/assets/launcher.json b/config/assets/launcher.json new file mode 100644 index 0000000..2890dc5 --- /dev/null +++ b/config/assets/launcher.json @@ -0,0 +1,97 @@ +{ + "launcher_config": { + "em": { + "icon": "emoji", + "description": "Emoji - Search and copy emojis" + }, + "clip": { + "icon": "clipboard", + "description": "Clipboard - Search and manage clipboard history" + }, + "=": { + "examples": ["= 10*5", "= 2^8", "= sin(30)"], + "icon": "calculator", + "description": "Quick Math - Fast mathematical expressions" + }, + "app": { + "icon": "apps", + "description": "Applications - Launch installed applications" + }, + "bin": { + "icon": "terminal", + "description": "Bins - Search and run executable binaries" + }, + "power": { + "icon": "shutdown", + "description": "Power - System power management and session control" + }, + "caffeine": { + "examples": ["caffeine 30m", "caffeine 1h", "caffeine 2h", "caffeine on"], + "icon": "coffee", + "description": "Caffeine - Prevent system from going idle" + }, + "sc": { + "icon": "toolbox", + "description": "Screencapture - Record screen and audio" + }, + "wall": { + "icon": "wallpapers", + "description": "Wallpapers - Set wallpapers, and tons of features" + }, + "?": { + "icon": "world", + "description": "Quick Web Search - Fast web search with question mark", + "examples": ["? cats", "? google cats", "? youtube music"] + }, + "kanban": { + "icon": "kanban", + "description": "Kanban - Task management with kanban-style columns" + }, + "cal": { + "icon": "calendar", + "description": "Calendar - View calendar, navigate dates, and get date/time info" + }, + "remind": { + "icon": "timer_on", + "description": "Reminders - Set time-based reminders with notifications" + }, + "otp": { + "icon": "auth", + "description": "Manage TOTP codes and 2FA authentication" + }, + "pass": { + "icon": "key", + "description": "Password Manager - Search and manage passwords" + }, + "bm": { + "icon": "bookmark", + "description": "Bookmarks - Search and manage bookmarks" + }, + "ps": { + "icon": "process", + "description": "Process Manager - View and manage running processes" + }, + "script": { + "icon": "terminal", + "description": "Bash Scripts - Manage and execute bash scripts" + }, + "bash": { + "icon": "terminal", + "description": "Bash Scripts - Manage and execute bash scripts" + }, + "sh": { + "icon": "terminal", + "description": "Bash Scripts - Manage and execute bash scripts" + }, + "tmux": { + "icon": "terminal", + "description": "Tmux Manager - Create, attach, rename, and kill tmux sessions" + } + }, + "settings": { + "max_examples_shown": 2, + "default_icon": "apps", + "fallback_example_template": "{trigger} ", + "config_version": "1.0" + } +} diff --git a/config/assets/modus.json b/config/assets/modus.json deleted file mode 100644 index 530c99d..0000000 --- a/config/assets/modus.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "commands": { - "/wp": "launcher.open(\"wallpapers\")", - "/em": "launcher.open(\"emoji\")", - "/ch": "launcher.open(\"cliphist\")", - "/kb": "launcher.open(\"kanban\")", - "/bt": "launcher.open(\"bluetooth\")", - "/sh": "launcher.open(\"sh\")", - "/pw": "launcher.open(\"power\")", - "/nw": "launcher.open(\"wifi\")", - "/cl": "launcher.open(\"calendar\")", - "/tl": "launcher.open(\"tools\")", - "/tx": "launcher.open(\"tmux\")", - "/ws": "launcher.open(\"window-switcher\")" - }, - "pinned_apps": [] -} \ No newline at end of file diff --git a/config/assets/todos.json b/config/assets/todos.json new file mode 100644 index 0000000..dd6113e --- /dev/null +++ b/config/assets/todos.json @@ -0,0 +1,171 @@ +{ + "todos": [ + { + "id": 14, + "title": "yo", + "description": "", + "priority": "low", + "category": "", + "due_date": null, + "completed": false, + "created": "2025-06-26T16:51:06.643095", + "completed_at": null + }, + { + "id": 16, + "title": "Medium priority task", + "description": "", + "priority": "medium", + "category": "normal", + "due_date": null, + "completed": false, + "created": "2025-06-26T16:52:59.579365", + "completed_at": null + }, + { + "id": 17, + "title": "Low priority task", + "description": "", + "priority": "low", + "category": "later", + "due_date": null, + "completed": false, + "created": "2025-06-26T16:52:59.583278", + "completed_at": null + }, + { + "id": 18, + "title": "🔥 URGENT: Fix critical bug", + "description": "", + "priority": "high", + "category": "work", + "due_date": null, + "completed": true, + "created": "2025-06-26T16:53:51.412081", + "completed_at": "2025-06-26T16:53:51.418575" + }, + { + "id": 19, + "title": "📝 Review documentation", + "description": "", + "priority": "medium", + "category": "work", + "due_date": null, + "completed": false, + "created": "2025-06-26T16:53:51.413594", + "completed_at": null + }, + { + "id": 20, + "title": "🧹 Clean desk", + "description": "", + "priority": "low", + "category": "personal", + "due_date": null, + "completed": false, + "created": "2025-06-26T16:53:51.415181", + "completed_at": null + }, + { + "id": 21, + "title": "Test high priority", + "description": "", + "priority": "high", + "category": "test", + "due_date": null, + "completed": true, + "created": "2025-06-26T16:55:12.629834", + "completed_at": "2025-06-26T17:00:48.989919" + }, + { + "id": 22, + "title": "Test medium priority", + "description": "", + "priority": "medium", + "category": "test", + "due_date": null, + "completed": false, + "created": "2025-06-26T16:55:12.631496", + "completed_at": null + }, + { + "id": 23, + "title": "Test low priority", + "description": "", + "priority": "low", + "category": "test", + "due_date": null, + "completed": false, + "created": "2025-06-26T16:55:12.632521", + "completed_at": null + }, + { + "id": 24, + "title": "High priority task", + "description": "", + "priority": "high", + "category": "test", + "due_date": null, + "completed": true, + "created": "2025-06-26T16:57:15.780304", + "completed_at": "2025-06-26T16:57:15.800393" + }, + { + "id": 25, + "title": "Medium priority task", + "description": "", + "priority": "medium", + "category": "test", + "due_date": null, + "completed": false, + "created": "2025-06-26T16:57:15.783358", + "completed_at": null + }, + { + "id": 26, + "title": "Low priority task", + "description": "", + "priority": "low", + "category": "test", + "due_date": null, + "completed": false, + "created": "2025-06-26T16:57:15.792488", + "completed_at": null + }, + { + "id": 27, + "title": "🔥 High priority", + "description": "", + "priority": "high", + "category": "test", + "due_date": null, + "completed": true, + "created": "2025-06-26T16:57:50.943800", + "completed_at": "2025-06-26T17:00:50.482300" + }, + { + "id": 28, + "title": "📝 Medium priority", + "description": "", + "priority": "medium", + "category": "test", + "due_date": null, + "completed": false, + "created": "2025-06-26T16:57:50.952343", + "completed_at": null + }, + { + "id": 29, + "title": "🧹 Low priority", + "description": "", + "priority": "low", + "category": "test", + "due_date": null, + "completed": false, + "created": "2025-06-26T16:57:50.968381", + "completed_at": null + } + ], + "next_id": 30, + "last_modified": "2025-06-26T17:00:50.482384" +} \ No newline at end of file diff --git a/config/config.py b/config/config.py index 44b7424..6aac044 100644 --- a/config/config.py +++ b/config/config.py @@ -1,1246 +1,36 @@ import os import shutil -import json import sys from pathlib import Path -import random -import subprocess -import toml -import gi -gi.require_version("Gtk", "3.0") -from gi.repository import Gtk - -# Fabric imports from fabric import Application -from fabric.widgets.window import Window -from fabric.widgets.box import Box -from fabric.widgets.label import Label -from fabric.widgets.button import Button -from fabric.widgets.entry import Entry -from fabric.widgets.scrolledwindow import ScrolledWindow -from fabric.widgets.image import Image -from fabric.widgets.stack import Stack -from fabric.widgets.scale import Scale -from fabric.utils import exec_shell_command, exec_shell_command_async - -# Assuming data.py exists in the same directory or is accessible via sys.path -# If data.py is in ./config/data.py relative to this script's original location: -try: - # Adjust path relative to the *original* location if needed - sys.path.insert(0, str(Path(__file__).resolve().parent / "../config")) - from data import ( - APP_NAME, - APP_NAME_CAP, - WALLPAPERS_DIR_DEFAULT, - ) -except ImportError as e: - print(f"Error importing data constants: {e}") - # Provide fallback defaults if import fails - APP_NAME = "modus" - APP_NAME_CAP = "Modus" - CONFIG_DIR = "~/Modus" - HOME_DIR = "~" - WALLPAPERS_DIR_DEFAULT = "~/Pictures/wallpapers" - -SOURCE_STRING = f""" -# {APP_NAME_CAP} -source = ~/{APP_NAME_CAP}/config/hypr/{APP_NAME}.conf -""" - -DEFAULTS = { - "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": True, - "bottom_bar": False, - "centered_bar": False, - "terminal_command": "kitty -e", - "wallpapers_dir": WALLPAPERS_DIR_DEFAULT, - "dock_enabled": True, - "dock_always_occluded": False, # Added default - "dock_icon_size": 28, - "osd_enabled": True, # Added default - # Defaults for bar components (assuming True initially) - "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, # Added default for corners visibility - "bar_metrics_disks": ["/"], - # Add metric visibility defaults - "metrics_visible": { - "cpu": True, - "ram": True, - "disk": True, - "swap": True, - }, -} - -bind_vars = DEFAULTS.copy() -def deep_update(target: dict, update: dict) -> dict: +def _configure_sys_path_for_direct_execution(): """ - Recursively update a nested dictionary with values from another dictionary. + Ajusta sys.path si este script se ejecuta directamente, + para asegurar que las importaciones relativas dentro del paquete 'config' funcionen. + Esto permite ejecutar `python config/config.py` desde cualquier directorio. """ - for key, value in update.items(): - if isinstance(value, dict): - target[key] = deep_update(target.get(key, {}), value) - else: - target[key] = value - return target + if __name__ == "__main__": + current_file_dir = Path(__file__).resolve().parent + project_root = current_file_dir.parent + if str(project_root) not in sys.path: + sys.path.insert(0, str(project_root)) -def ensure_matugen_config(): - """ - Ensure that the matugen configuration file exists and is updated - with the expected settings. - """ - expected_config = { - "config": { - "reload_apps": True, - "wallpaper": { - "command": "swww", - "arguments": [ - "img", - "-t", - "outer", - "--transition-duration", - "1.5", - "--transition-step", - "255", - "--transition-fps", - "60", - "-f", - "Nearest", - ], - "set": True, - }, - "custom_colors": { - "red": {"color": "#FF0000", "blend": True}, - "green": {"color": "#00FF00", "blend": True}, - "yellow": {"color": "#FFFF00", "blend": True}, - "blue": {"color": "#0000FF", "blend": True}, - "magenta": {"color": "#FF00FF", "blend": True}, - "cyan": {"color": "#00FFFF", "blend": True}, - "white": {"color": "#FFFFFF", "blend": True}, - }, - }, - "templates": { - "hyprland": { - "input_path": f"~/{APP_NAME_CAP}/config/matugen/templates/hyprland-colors.conf", - "output_path": f"~/{APP_NAME_CAP}/config/hypr/colors.conf", - }, - f"{APP_NAME}": { - "input_path": f"~/{APP_NAME_CAP}/config/matugen/templates/{APP_NAME}.css", - "output_path": f"~/{APP_NAME_CAP}/styles/colors.css", - "post_hook": f"fabric-cli exec {APP_NAME} 'app.set_css()' &", - }, - }, - } - config_path = os.path.expanduser("~/.config/matugen/config.toml") - os.makedirs(os.path.dirname(config_path), exist_ok=True) +_configure_sys_path_for_direct_execution() - # Load any existing configuration - existing_config = {} - if os.path.exists(config_path): - with open(config_path, "r") as f: - existing_config = toml.load(f) - # Backup existing configuration - shutil.copyfile(config_path, config_path + ".bak") - # Merge configurations - merged_config = deep_update(existing_config, expected_config) - with open(config_path, "w") as f: - toml.dump(merged_config, f) - - # Check if colors.css exists in styles directory - colors_css_path = os.path.expanduser(f"~/{APP_NAME_CAP}/styles/colors.css") - if not os.path.exists(colors_css_path): - # Get the wallpapers directory from bind_vars or use default - wallpapers_dir = bind_vars.get("wallpapers_dir", WALLPAPERS_DIR_DEFAULT) - wallpapers_dir = os.path.expanduser(wallpapers_dir) - - # Get list of image files in the directory - image_extensions = (".png", ".jpg", ".jpeg", ".gif", ".bmp") - image_files = [ - f - for f in os.listdir(wallpapers_dir) - if f.lower().endswith(image_extensions) - ] - - if image_files: - # Select a random image - random_image = random.choice(image_files) - image_path = os.path.join(wallpapers_dir, random_image) - # Apply the random wallpaper using matugen - os.system(f"matugen image {image_path}") - else: - # If no images found in user's directory, try the default directory - default_dir = os.path.expanduser(f"~/{APP_NAME_CAP}/assets/wallpaper/") - default_images = [ - f - for f in os.listdir(default_dir) - if f.lower().endswith(image_extensions) - ] - - random_image = random.choice(default_images) - image_path = os.path.join(default_dir, random_image) - os.system(f"matugen image {image_path}") - - -def load_bind_vars(): - """ - Load saved key binding variables from JSON, if available. - """ - config_json = os.path.expanduser(f"~/{APP_NAME_CAP}/config/assets/config.json") - if os.path.exists(config_json): - try: - with open(config_json, "r") as f: - saved_vars = json.load(f) - # Update defaults with saved values, ensuring all keys exist - for key in DEFAULTS: - if key in saved_vars: - bind_vars[key] = saved_vars[key] - else: - bind_vars[key] = DEFAULTS[ - key - ] # Use default if missing in saved - # Add any new keys from DEFAULTS not present in saved_vars - for key in saved_vars: - if key not in bind_vars: - bind_vars[key] = saved_vars[ - key - ] # Keep saved if it's not in new defaults (less likely) - for vis_key in ["metrics_visible"]: - if vis_key in DEFAULTS: - if vis_key not in bind_vars or not isinstance( - bind_vars[vis_key], dict - ): - bind_vars[vis_key] = DEFAULTS[vis_key].copy() - else: - for m in DEFAULTS[vis_key]: - if m not in bind_vars[vis_key]: - bind_vars[vis_key][m] = DEFAULTS[vis_key][m] - except json.JSONDecodeError: - print(f"Warning: Could not decode JSON from {config_json}. Using defaults.") - bind_vars.update(DEFAULTS) # Ensure defaults on error - except Exception as e: - print(f"Error loading config from {config_json}: {e}. Using defaults.") - bind_vars.update(DEFAULTS) # Ensure defaults on error - else: - # Ensure defaults are set if file doesn't exist - bind_vars.update(DEFAULTS) - - -def generate_hyprconf() -> str: - """ - Generate the Hypr configuration string using the current bind_vars. - """ - home = os.path.expanduser("~") - return f"""# Generated by {APP_NAME_CAP} - DO NOT EDIT MANUALLY -exec-once = python {home}/{APP_NAME_CAP}/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 {APP_NAME} -$scriptsDir = {home}/{APP_NAME_CAP}/config/scripts/ - -# Key Bindings -bind = {bind_vars["prefix_restart"]}, {bind_vars["suffix_restart"]}, exec, killall {APP_NAME}; python {home}/{APP_NAME_CAP}/main.py # Reload {APP_NAME_CAP} -bind = {bind_vars["prefix_bluetooth"]}, {bind_vars["suffix_bluetooth"]}, exec, $fabricSend 'launcher.open("bluetooth")' # Bluetooth -bind = {bind_vars["prefix_notifications"]}, {bind_vars["suffix_notifications"]}, exec, $fabricSend 'launcher.open("notification-center")' # Notifications -bind = {bind_vars["prefix_emoji"]}, {bind_vars["suffix_emoji"]}, exec, $fabricSend 'launcher.open("emoji")' # Emoji -bind = {bind_vars["prefix_cliphist"]}, {bind_vars["suffix_cliphist"]}, exec, $fabricSend 'launcher.open("cliphist")' # Clipboard History -bind = {bind_vars["prefix_kanban"]}, {bind_vars["suffix_kanban"]}, exec, $fabricSend 'launcher.open("kanban")' # Kanban -bind = {bind_vars["prefix_tmux"]}, {bind_vars["suffix_tmux"]}, exec, $fabricSend 'launcher.open("tmux")' # Tmux Manager -bind = {bind_vars["prefix_toolbox"]}, {bind_vars["suffix_toolbox"]}, exec, $fabricSend 'launcher.open("tools")' # Toolbox -bind = {bind_vars["prefix_walls"]}, {bind_vars["suffix_walls"]}, exec, $fabricSend 'launcher.open("wallpapers")' # Wallpaper Selector -bind = {bind_vars["prefix_launcher"]}, {bind_vars["suffix_launcher"]}, exec, $fabricSend 'launcher.open("launcher")' # App Launcher -bind = {bind_vars["prefix_window_switcher"]}, {bind_vars["suffix_window_switcher"]}, exec, $fabricSend 'launcher.open("window-switcher")' # Window Switcher -bind = {bind_vars["prefix_power"]}, {bind_vars["suffix_power"]}, exec, $fabricSend 'launcher.open("power")' # Power Menu -bind = {bind_vars["prefix_restart_inspector"]}, {bind_vars["suffix_restart_inspector"]}, exec, killall {APP_NAME}; GTK_DEBUG=interactive python {home}/{APP_NAME_CAP}/main.py # Restart with inspector - -# Wallpapers directory: {bind_vars["wallpapers_dir"]} - -# Source color scheme -source = {home}/{APP_NAME_CAP}/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 -}} -""" - - -def backup_and_replace(src: str, dest: str, config_name: str): - """ - Backup the existing configuration file and replace it with a new one. - """ - if os.path.exists(dest): - backup_path = dest + ".bak" - shutil.copy(dest, backup_path) - print(f"{config_name} config backed up to {backup_path}") - shutil.copy(src, dest) - print(f"{config_name} config replaced from {src}") - - -class HyprConfGUI(Window): - def __init__(self, show_lock_checkbox: bool, show_idle_checkbox: bool, **kwargs): - super().__init__( - title="Modus Settings", - name="modus-settings-widnow", - size=(650, 550), - **kwargs, - ) - self.set_resizable(False) - - self.show_lock_checkbox = show_lock_checkbox - self.show_idle_checkbox = show_idle_checkbox - - # Overall vertical box to hold the main content and bottom buttons - root_box = Box(orientation="v", spacing=10, style="margin: 10px;") - self.add(root_box) - - # Main horizontal box for switcher and stack - main_content_box = Box(orientation="h", spacing=6, v_expand=True, h_expand=True) - root_box.add(main_content_box) - - # --- Tab Control --- - self.tab_stack = Stack( - transition_type="slide-up-down", # Change transition for vertical feel - transition_duration=250, - v_expand=True, - h_expand=True, - ) - - # Create tabs and add to stack - self.key_bindings_tab_content = self.create_key_bindings_tab() - self.appearance_tab_content = self.create_appearance_tab() - self.system_tab_content = self.create_system_tab() - self.about_tab_content = self.create_about_tab() - - self.tab_stack.add_titled( - self.key_bindings_tab_content, "key_bindings", "Key Bindings" - ) - self.tab_stack.add_titled( - self.appearance_tab_content, "appearance", "Appearance" - ) - self.tab_stack.add_titled(self.system_tab_content, "system", "System") - self.tab_stack.add_titled( - self.about_tab_content, "about", "About" - ) # Add About tab to stack - - # Use Gtk.StackSwitcher vertically on the left - tab_switcher = Gtk.StackSwitcher() - tab_switcher.set_stack(self.tab_stack) - tab_switcher.set_orientation( - Gtk.Orientation.VERTICAL - ) # Set vertical orientation - # Optional: Adjust alignment if needed - # tab_switcher.set_valign(Gtk.Align.START) - - # Add switcher to the left of the main content box - main_content_box.add(tab_switcher) - - # Add stack to the right of the main content box - main_content_box.add(self.tab_stack) - - # --- Bottom Buttons --- - button_box = Box(orientation="h", spacing=10, h_align="end") - - reset_btn = Button(label="Reset to Defaults", on_clicked=self.on_reset) - button_box.add(reset_btn) - - # Add Close button back - close_btn = Button(label="Close", on_clicked=self.on_close) - button_box.add(close_btn) - - accept_btn = Button(label="Apply & Reload", on_clicked=self.on_accept) - button_box.add(accept_btn) - - # Add button box to the bottom of the root box - root_box.add(button_box) - - def create_key_bindings_tab(self): - """Create tab for key bindings configuration using Fabric widgets and Gtk.Grid.""" - scrolled_window = ScrolledWindow( - h_scrollbar_policy="never", - v_scrollbar_policy="automatic", - h_expand=True, - v_expand=True, - ) - # Remove fixed height constraints to allow stack to fill space - scrolled_window.set_min_content_height(300) - scrolled_window.set_max_content_height(300) - - # Main container with padding - main_vbox = Box(orientation="v", spacing=10, style="margin: 15px;") - scrolled_window.add(main_vbox) - - # Create a grid for key bindings - keybind_grid = Gtk.Grid() - keybind_grid.set_column_spacing(10) - keybind_grid.set_row_spacing(8) - keybind_grid.set_margin_start(5) - keybind_grid.set_margin_end(5) - keybind_grid.set_margin_top(5) - keybind_grid.set_margin_bottom(5) - - # Header Row - action_label = Label( - markup="Action", h_align="start", style="margin-bottom: 5px;" - ) - modifier_label = Label( - markup="Modifier", h_align="start", style="margin-bottom: 5px;" - ) - separator_label = Label( - label="+", h_align="center", style="margin-bottom: 5px;" - ) - key_label = Label( - markup="Key", h_align="start", style="margin-bottom: 5px;" - ) - - keybind_grid.attach(action_label, 0, 0, 1, 1) - keybind_grid.attach(modifier_label, 1, 0, 1, 1) - keybind_grid.attach(separator_label, 2, 0, 1, 1) - keybind_grid.attach(key_label, 3, 0, 1, 1) - - self.entries = [] - bindings = [ - (f"Reload {APP_NAME_CAP}", "prefix_restart", "suffix_restart"), - ("Bluetooth", "prefix_bluetooth", "suffix_bluetooth"), - ("Notifications", "prefix_notifications", "suffix_notifications"), - ("Kanban", "prefix_kanban", "suffix_kanban"), - ("App Launcher", "prefix_launcher", "suffix_launcher"), - ("Tmux", "prefix_tmux", "suffix_tmux"), - ("Toolbox", "prefix_toolbox", "suffix_toolbox"), - ("Wallpapers", "prefix_walls", "suffix_walls"), - ("Emoji Picker", "prefix_emoji", "suffix_emoji"), - ("Clipboard History", "prefix_cliphist", "suffix_cliphist"), - ("Window Switcher", "prefix_window_switcher", "suffix_window_switcher"), - ("Power Menu", "prefix_power", "suffix_power"), - ( - "Restart with inspector", - "prefix_restart_inspector", - "suffix_restart_inspector", - ), - ] - - # Populate the grid with entries - for i, (label_text, prefix_key, suffix_key) in enumerate(bindings): - row = i + 1 # Start at row 1 after headers - - # Action label - binding_label = Label(label=label_text, h_align="start") - keybind_grid.attach(binding_label, 0, row, 1, 1) - - # Prefix entry - prefix_entry = Entry(text=bind_vars[prefix_key]) - keybind_grid.attach(prefix_entry, 1, row, 1, 1) - - # Plus separator - plus_label = Label(label="+", h_align="center") - keybind_grid.attach(plus_label, 2, row, 1, 1) - - # Suffix entry - suffix_entry = Entry(text=bind_vars[suffix_key]) - keybind_grid.attach(suffix_entry, 3, row, 1, 1) - - self.entries.append((prefix_key, suffix_key, prefix_entry, suffix_entry)) - - main_vbox.add(keybind_grid) - return scrolled_window - - def create_appearance_tab(self): - """Create tab for appearance settings using Fabric widgets and Gtk.Grid.""" - scrolled_window = ScrolledWindow( - h_scrollbar_policy="never", - v_scrollbar_policy="automatic", - h_expand=True, - v_expand=True, - ) - scrolled_window.set_min_content_height(300) - scrolled_window.set_max_content_height(300) - - # Main container with padding - vbox = Box(orientation="v", spacing=15, style="margin: 15px;") - scrolled_window.add(vbox) - - # === WALLPAPERS SECTION === - wall_header = Label(markup="Wallpapers", h_align="start") - vbox.add(wall_header) - - wall_box = Box(orientation="h", spacing=10, style="margin-left: 10px;") - vbox.add(wall_box) - - wall_label = Label(label="Directory:", h_align="start", v_align="center") - wall_box.add(wall_label) - - self.wall_dir_chooser = Gtk.FileChooserButton( - title="Select a folder", action=Gtk.FileChooserAction.SELECT_FOLDER - ) - self.wall_dir_chooser.set_tooltip_text( - "Select the directory containing your wallpaper images" - ) - self.wall_dir_chooser.set_filename(bind_vars["wallpapers_dir"]) - self.wall_dir_chooser.set_size_request(180, -1) - wall_box.add(self.wall_dir_chooser) - - # === LAYOUT OPTIONS SECTION === - layout_header = Label(markup="Layout Options", h_align="start") - vbox.add(layout_header) - - # Create a grid for layout options - layout_grid = Gtk.Grid() - layout_grid.set_column_spacing(20) - layout_grid.set_row_spacing(15) - layout_grid.set_margin_start(10) - layout_grid.set_margin_top(5) - layout_grid.set_margin_bottom(10) - vbox.add(layout_grid) - - # Column 1 (Left) - # Vertical Layout - vertical_label = Label( - label="Vertical Layout", h_align="start", v_align="center" - ) - layout_grid.attach(vertical_label, 0, 0, 1, 1) - - vertical_switch_container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) - vertical_switch_container.set_halign(Gtk.Align.START) - vertical_switch_container.set_valign(Gtk.Align.CENTER) - - self.vertical_switch = Gtk.Switch() - self.vertical_switch.set_active(bind_vars.get("vertical", False)) - self.vertical_switch.connect("notify::active", self.on_vertical_changed) - vertical_switch_container.add(self.vertical_switch) - - layout_grid.attach(vertical_switch_container, 1, 0, 1, 1) - - # Vertical Right Align (new) - vertical_right_label = Label( - label="Right Side (Vertical)", h_align="start", v_align="center" - ) - layout_grid.attach(vertical_right_label, 0, 1, 1, 1) - - vertical_right_switch_container = Gtk.Box( - orientation=Gtk.Orientation.HORIZONTAL - ) - vertical_right_switch_container.set_halign(Gtk.Align.START) - vertical_right_switch_container.set_valign(Gtk.Align.CENTER) - - self.vertical_right_switch = Gtk.Switch() - self.vertical_right_switch.set_active( - bind_vars.get("vertical_right_align", True) - ) - self.vertical_right_switch.set_sensitive(self.vertical_switch.get_active()) - vertical_right_switch_container.add(self.vertical_right_switch) - layout_grid.attach(vertical_right_switch_container, 1, 1, 1, 1) - - # Bottom Layout - bottom_bar_label = Label( - label="Bottom Layout", h_align="start", v_align="center" - ) - layout_grid.attach(bottom_bar_label, 0, 2, 1, 1) - - bottom_bar_switch_container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) - bottom_bar_switch_container.set_halign(Gtk.Align.START) - bottom_bar_switch_container.set_valign(Gtk.Align.CENTER) - - self.bottom_bar_switch = Gtk.Switch() - self.bottom_bar_switch.set_active(bind_vars.get("bottom_bar", False)) - self.bottom_bar_switch.connect("notify::active", self.on_bottom_bar_changed) - bottom_bar_switch_container.add(self.bottom_bar_switch) - layout_grid.attach(bottom_bar_switch_container, 1, 2, 1, 1) - - # Dock Options - dock_label = Label(label="Show Dock", h_align="start", v_align="center") - layout_grid.attach(dock_label, 0, 3, 1, 1) - - dock_switch_container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) - dock_switch_container.set_halign(Gtk.Align.START) - dock_switch_container.set_valign(Gtk.Align.CENTER) - - self.dock_switch = Gtk.Switch() - self.dock_switch.set_active(bind_vars.get("dock_enabled", True)) - self.dock_switch.connect("notify::active", self.on_dock_enabled_changed) - dock_switch_container.add(self.dock_switch) - layout_grid.attach(dock_switch_container, 1, 3, 1, 1) - - # Column 2 (Right) - # Centered Bar - centered_label = Label( - label="Centered Bar (Vertical Only)", h_align="start", v_align="center" - ) - layout_grid.attach(centered_label, 2, 0, 1, 1) - - centered_switch_container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) - centered_switch_container.set_halign(Gtk.Align.START) - centered_switch_container.set_valign(Gtk.Align.CENTER) - - self.centered_switch = Gtk.Switch() - self.centered_switch.set_active(bind_vars.get("centered_bar", False)) - self.centered_switch.set_sensitive(self.vertical_switch.get_active()) - centered_switch_container.add(self.centered_switch) - layout_grid.attach(centered_switch_container, 3, 0, 1, 1) - - # Dock Hover - dock_hover_label = Label( - label="Show Dock Only on Hover", h_align="start", v_align="center" - ) - layout_grid.attach(dock_hover_label, 2, 1, 1, 1) - - dock_hover_switch_container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) - dock_hover_switch_container.set_halign(Gtk.Align.START) - dock_hover_switch_container.set_valign(Gtk.Align.CENTER) - - self.dock_hover_switch = Gtk.Switch() - self.dock_hover_switch.set_active(bind_vars.get("dock_always_occluded", False)) - self.dock_hover_switch.set_sensitive(self.dock_switch.get_active()) - dock_hover_switch_container.add(self.dock_hover_switch) - layout_grid.attach(dock_hover_switch_container, 3, 1, 1, 1) - - # Show OSD - osd_label = Label(label="Show OSD", h_align="start", v_align="center") - layout_grid.attach(osd_label, 2, 2, 1, 1) - - osd_hover_switch_container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) - osd_hover_switch_container.set_halign(Gtk.Align.START) - osd_hover_switch_container.set_valign(Gtk.Align.CENTER) - - self.osd_switch = Gtk.Switch() - self.osd_switch.set_active(bind_vars.get("osd_enabled", True)) - osd_hover_switch_container.add(self.osd_switch) - layout_grid.attach(osd_hover_switch_container, 3, 2, 1, 1) - - # Dock Icon Size (Full width) - dock_size_label = Label( - label="Dock Icon Size", h_align="start", v_align="center" - ) - layout_grid.attach(dock_size_label, 0, 4, 1, 1) - - self.dock_size_scale = Scale( - min_value=16, - max_value=48, - value=bind_vars.get("dock_icon_size", 28), - increments=(2, 4), - draw_value=True, - value_position="right", - digits=0, - h_expand=True, - ) - layout_grid.attach(self.dock_size_scale, 1, 4, 3, 1) - - # === BAR COMPONENTS SECTION === - components_header = Label(markup="Bar Components", h_align="start") - vbox.add(components_header) - - # Create a grid for bar components - components_grid = Gtk.Grid() - components_grid.set_column_spacing(15) - components_grid.set_row_spacing(8) - components_grid.set_margin_start(10) - components_grid.set_margin_top(5) - vbox.add(components_grid) - - self.component_switches = {} - component_display_names = { - "button_app": "App Launcher Button", - "tray": "System Tray", - "workspaces": "Workspaces", - "metrics": "System Metrics", - "language": "Language Indicator", - "date_time": "Date & Time", - "updates": "Update Indicator", - "indicators": "System Indicators", - } - - # Add corners visibility switch - self.corners_switch = Gtk.Switch() - self.corners_switch.set_active(bind_vars.get("corners_visible", True)) - - # Calculate number of rows needed (we'll use 2 columns) - num_components = len(component_display_names) + 1 # +1 for corners - rows_per_column = (num_components + 1) // 2 # Ceiling division - - # First add corners to the top of first column - corners_label = Label( - label="Rounded Corners", h_align="start", v_align="center" - ) - components_grid.attach(corners_label, 0, 0, 1, 1) - - switch_container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) - switch_container.set_halign(Gtk.Align.START) - switch_container.set_valign(Gtk.Align.CENTER) - switch_container.add(self.corners_switch) - components_grid.attach(switch_container, 1, 0, 1, 1) - - # Add components to grid in two columns - for i, (component_name, display_name) in enumerate( - component_display_names.items() - ): - # Determine position: first half in column 0, second half in column 2 - # Start at row 1 to account for corners at row 0 - row = (i + 1) % rows_per_column # +1 to start after corners - col = 0 if i < (rows_per_column - 1) else 2 # Adjust column calculation - - component_label = Label( - label=display_name, h_align="start", v_align="center" - ) - components_grid.attach(component_label, col, row, 1, 1) - - # Container for switch to prevent stretching - switch_container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) - switch_container.set_halign(Gtk.Align.START) - switch_container.set_valign(Gtk.Align.CENTER) - - component_switch = Gtk.Switch() - config_key = f"bar_{component_name}_visible" - component_switch.set_active(bind_vars.get(config_key, True)) - switch_container.add(component_switch) - - components_grid.attach(switch_container, col + 1, row, 1, 1) - - self.component_switches[component_name] = component_switch - - return scrolled_window - - def create_system_tab(self): - """Create tab for system configurations using Fabric widgets and Gtk.Grid.""" - scrolled_window = ScrolledWindow( - h_scrollbar_policy="never", - v_scrollbar_policy="automatic", - h_expand=True, - v_expand=True, - ) - # Remove fixed height constraints - scrolled_window.set_min_content_height(300) - scrolled_window.set_max_content_height(300) - - # Main container with padding - vbox = Box(orientation="v", spacing=15, style="margin: 15px;") - scrolled_window.add(vbox) - - # Create a grid for system settings - system_grid = Gtk.Grid() - system_grid.set_column_spacing(20) - system_grid.set_row_spacing(10) - system_grid.set_margin_bottom(15) - vbox.add(system_grid) - - # === TERMINAL SETTINGS === - terminal_header = Label(markup="Terminal Settings", h_align="start") - system_grid.attach(terminal_header, 0, 0, 2, 1) - - terminal_label = Label(label="Command:", h_align="start", v_align="center") - system_grid.attach(terminal_label, 0, 1, 1, 1) - - self.terminal_entry = Entry( - text=bind_vars["terminal_command"], - tooltip_text="Command used to launch terminal apps (e.g., 'kitty -e')", - h_expand=True, - ) - system_grid.attach(self.terminal_entry, 1, 1, 1, 1) - - hint_label = Label( - markup="Examples: 'kitty -e', 'alacritty -e', 'foot -e'", - h_align="start", - ) - system_grid.attach(hint_label, 0, 2, 2, 1) - - # === HYPRLAND INTEGRATION === - hypr_header = Label(markup="Hyprland Integration", h_align="start") - system_grid.attach(hypr_header, 2, 0, 2, 1) - - row = 1 - - # Hyprland locks and idle settings - self.lock_switch = None - if self.show_lock_checkbox: - lock_label = Label( - label="Replace Hyprlock config", h_align="start", v_align="center" - ) - system_grid.attach(lock_label, 2, row, 1, 1) - - # Container for switch to prevent stretching - lock_switch_container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) - lock_switch_container.set_halign(Gtk.Align.START) - lock_switch_container.set_valign(Gtk.Align.CENTER) - - self.lock_switch = Gtk.Switch() - self.lock_switch.set_tooltip_text( - "Replace Hyprlock configuration with Ax-Shell's custom config" - ) - lock_switch_container.add(self.lock_switch) - - system_grid.attach(lock_switch_container, 3, row, 1, 1) - row += 1 - - self.idle_switch = None - if self.show_idle_checkbox: - idle_label = Label( - label="Replace Hypridle config", h_align="start", v_align="center" - ) - system_grid.attach(idle_label, 2, row, 1, 1) - - # Container for switch to prevent stretching - idle_switch_container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) - idle_switch_container.set_halign(Gtk.Align.START) - idle_switch_container.set_valign(Gtk.Align.CENTER) - - self.idle_switch = Gtk.Switch() - self.idle_switch.set_tooltip_text( - "Replace Hypridle configuration with Ax-Shell's custom config" - ) - idle_switch_container.add(self.idle_switch) - - system_grid.attach(idle_switch_container, 3, row, 1, 1) - row += 1 - - if self.show_lock_checkbox or self.show_idle_checkbox: - note_label = Label( - markup="Existing configs will be backed up", - h_align="start", - ) - system_grid.attach(note_label, 2, row, 2, 1) - - # --- System Metrics Options (moved from appearance tab) --- - metrics_header = Label(markup="System Metrics Options", h_align="start") - vbox.add(metrics_header) - - # Metrics visibility toggles - metrics_grid = Gtk.Grid() - metrics_grid.set_column_spacing(15) - metrics_grid.set_row_spacing(8) - metrics_grid.set_margin_start(10) - metrics_grid.set_margin_top(5) - vbox.add(metrics_grid) - - self.metrics_switches = {} - - metric_names = { - "cpu": "CPU", - "ram": "RAM", - "disk": "Disk", - "swap": "Swap", - } - - # metrics toggles - metrics_grid.attach(Label(label="Show in Metrics", h_align="start"), 2, 0, 1, 1) - for i, (key, label) in enumerate(metric_names.items()): - switch = Gtk.Switch() - switch.set_active(bind_vars.get("metrics_visible", {}).get(key, True)) - self.metrics_switches[key] = switch - metrics_grid.attach(Label(label=label, h_align="start"), 2, i + 1, 1, 1) - metrics_grid.attach(switch, 4, i + 1, 1, 1) - - # Enforce minimum 4 enabled metrics - def enforce_minimum_metrics(switch_dict): - enabled = [k for k, s in switch_dict.items() if s.get_active()] - for k, s in switch_dict.items(): - if len(enabled) <= 4 and s.get_active(): - s.set_sensitive(False) - else: - s.set_sensitive(True) - - def on_metric_toggle(switch, gparam, which): - enforce_minimum_metrics(self.metrics_switches) - - for k, s in self.metrics_switches.items(): - s.connect("notify::active", on_metric_toggle, k) - enforce_minimum_metrics(self.metrics_switches) - - # Disk directories - disks_label = Label(label="Disk directories", h_align="start", v_align="center") - vbox.add(disks_label) - - self.disk_entries = Box(orientation="v", spacing=8, h_align="start") - - def create_disk_edit(path): - bar = Box(orientation="h", spacing=10, h_align="start") - entry = Entry(text=path, h_expand=True) - bar.add(entry) - x = Button(label="X", on_clicked=lambda _: self.disk_entries.remove(bar)) - bar.add(x) - self.disk_entries.add(bar) - - vbox.add(self.disk_entries) - for path in bind_vars.get("bar_metrics_disks"): - create_disk_edit(path) - add_container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) - add_container.set_halign(Gtk.Align.START) - add_container.set_valign(Gtk.Align.CENTER) - add = Button(label="Add new disk", on_clicked=lambda _: create_disk_edit("/")) - add_container.add(add) - vbox.add(add_container) - - return scrolled_window - - def create_about_tab(self): - """Create an About tab with project info, repo link, and Ko-Fi button.""" - vbox = Box(orientation="v", spacing=18, style="margin: 30px;") - # Project title - vbox.add( - Label( - markup=f"{APP_NAME_CAP}", - h_align="start", - style="font-size: 1.5em; margin-bottom: 8px;", - ) - ) - # Description - vbox.add( - Label( - label="A hackable shell for Hyprland, powered by Fabric.", - h_align="start", - style="margin-bottom: 12px;", - ) - ) - # Repo link - repo_box = Box(orientation="h", spacing=6, h_align="start") - repo_label = Label(label="GitHub:", h_align="start") - repo_link = Label() - repo_link.set_markup( - f'https://github.com/S4NKALP/Modus' - ) - repo_box.add(repo_label) - repo_box.add(repo_link) - vbox.add(repo_box) - - # Ko-Fi button - def on_kofi_clicked(_): - import webbrowser - - webbrowser.open("https://ko-fi.com/S4NKALP") - - kofi_btn = Button( - label="Support on Ko-Fi ❤️", - on_clicked=on_kofi_clicked, - tooltip_text="Support S4NKALP on Ko-Fi", - ) - kofi_btn.set_style("margin-top: 18px; min-width: 160px;") - vbox.add(kofi_btn) - # Spacer - vbox.add(Box(v_expand=True)) - return vbox - - def on_vertical_changed(self, switch, gparam): - """Callback for vertical switch.""" - is_active = switch.get_active() - self.centered_switch.set_sensitive(is_active) - self.vertical_right_switch.set_sensitive(is_active) - - if is_active: - # Disable bottom bar when vertical layout is enabled - bind_vars["bottom_bar"] = False - self.bottom_bar_switch.set_active(False) - - def on_dock_enabled_changed(self, switch, gparam): - """Callback for dock enabled switch.""" - is_active = switch.get_active() - self.dock_hover_switch.set_sensitive(is_active) - if not is_active: - self.dock_hover_switch.set_active(False) - - def on_bottom_bar_changed(self, switch, gparam): - """Handle bottom bar switch changes""" - bind_vars["bottom_bar"] = switch.get_active() - if switch.get_active(): - # Disable vertical layout - bind_vars["vertical"] = False - self.vertical_switch.set_active(False) - - # Only disable centered bar without turning it off - self.centered_switch.set_sensitive(False) - - # Disable right side option and reset it to False - self.vertical_right_switch.set_sensitive(False) - self.vertical_right_switch.set_active(False) - bind_vars["vertical_right_align"] = False - - def on_accept(self, widget): - """ - Save the configuration and update the necessary files without closing the window. - """ - for prefix_key, suffix_key, prefix_entry, suffix_entry in self.entries: - bind_vars[prefix_key] = prefix_entry.get_text() - bind_vars[suffix_key] = suffix_entry.get_text() - - bind_vars["wallpapers_dir"] = self.wall_dir_chooser.get_filename() - bind_vars["vertical"] = self.vertical_switch.get_active() - bind_vars["vertical_right_align"] = self.vertical_right_switch.get_active() - bind_vars["bottom_bar"] = self.bottom_bar_switch.get_active() - bind_vars["enabled_osd"] = self.osd_switch.get_active() - bind_vars["centered_bar"] = self.centered_switch.get_active() - bind_vars["dock_enabled"] = self.dock_switch.get_active() - bind_vars["dock_always_occluded"] = self.dock_hover_switch.get_active() - bind_vars["dock_icon_size"] = int(self.dock_size_scale.get_value()) - bind_vars["terminal_command"] = self.terminal_entry.get_text() - bind_vars["corners_visible"] = self.corners_switch.get_active() - - for component_name, switch in self.component_switches.items(): - config_key = f"bar_{component_name}_visible" - bind_vars[config_key] = switch.get_active() - - # Save metrics visibility - bind_vars["metrics_visible"] = { - k: s.get_active() for k, s in self.metrics_switches.items() - } - - bind_vars["bar_metrics_disks"] = [] - for entry in self.disk_entries.children: - bind_vars["bar_metrics_disks"].append(entry.children[0].get_text()) - - config_json = os.path.expanduser(f"~/{APP_NAME_CAP}/config/assets/config.json") - os.makedirs(os.path.dirname(config_json), exist_ok=True) - try: - with open(config_json, "w") as f: - json.dump(bind_vars, f, indent=4) - except Exception as e: - print(f"Error saving config.json: {e}") - - if self.lock_switch and self.lock_switch.get_active(): - src_lock = os.path.expanduser(f"~/{APP_NAME_CAP}/config/hypr/hyprlock.conf") - dest_lock = os.path.expanduser("~/.config/hypr/hyprlock.conf") - if os.path.exists(src_lock): - backup_and_replace(src_lock, dest_lock, "Hyprlock") - else: - print(f"Warning: Source hyprlock config not found at {src_lock}") - - if self.idle_switch and self.idle_switch.get_active(): - src_idle = os.path.expanduser(f"~/{APP_NAME_CAP}/config/hypr/hypridle.conf") - dest_idle = os.path.expanduser("~/.config/hypr/hypridle.conf") - if os.path.exists(src_idle): - backup_and_replace(src_idle, dest_idle, "Hypridle") - else: - print(f"Warning: Source hypridle config not found at {src_idle}") - - hyprland_config_path = os.path.expanduser("~/.config/hypr/hyprland.conf") - try: - needs_append = True - if os.path.exists(hyprland_config_path): - with open(hyprland_config_path, "r") as f: - content = f.read() - if SOURCE_STRING.strip() in content: - needs_append = False - else: - os.makedirs(os.path.dirname(hyprland_config_path), exist_ok=True) - - if needs_append: - with open(hyprland_config_path, "a") as f: - f.write("\n" + SOURCE_STRING) - print(f"Appended source string to {hyprland_config_path}") - - except Exception as e: - print(f"Error updating {hyprland_config_path}: {e}") - - start_config() - - # Restart Modus using fabric's async command executor - main_script_path = os.path.expanduser(f"~/{APP_NAME_CAP}/main.py") - kill_cmd = f"killall {APP_NAME}" - start_cmd = f"python {main_script_path}" - - try: - # Use fabric's helper to run the command asynchronously - exec_shell_command(kill_cmd) - exec_shell_command_async(start_cmd) - print(f"{APP_NAME_CAP} restart initiated.") - except Exception as e: - print(f"Error restarting {APP_NAME_CAP}: {e}") - - def on_reset(self, widget): - """ - Reset all settings to default values. Uses Gtk.MessageDialog. - """ - dialog = Gtk.MessageDialog( - transient_for=self.get_toplevel(), - flags=0, - message_type=Gtk.MessageType.QUESTION, - buttons=Gtk.ButtonsType.YES_NO, - text="Reset all settings to defaults?", - ) - dialog.format_secondary_text( - "This will reset all keybindings and appearance settings to their default values." - ) - response = dialog.run() - dialog.destroy() - - if response == Gtk.ResponseType.YES: - global bind_vars - bind_vars = DEFAULTS.copy() - - for prefix_key, suffix_key, prefix_entry, suffix_entry in self.entries: - prefix_entry.set_text(bind_vars[prefix_key]) - suffix_entry.set_text(bind_vars[suffix_key]) - - self.wall_dir_chooser.set_filename(bind_vars["wallpapers_dir"]) - self.vertical_switch.set_active(bind_vars.get("vertical", False)) - self.vertical_right_switch.set_active( - bind_vars.get("vertical_right_align", True) - ) - self.centered_switch.set_active(bind_vars.get("centered_bar", False)) - self.centered_switch.set_sensitive(self.vertical_switch.get_active()) - self.vertical_right_switch.set_sensitive(self.vertical_switch.get_active()) - self.dock_switch.set_active(bind_vars.get("dock_enabled", True)) - self.dock_hover_switch.set_active( - bind_vars.get("dock_always_occluded", False) - ) - self.dock_hover_switch.set_sensitive(self.dock_switch.get_active()) - self.dock_size_scale.value = bind_vars.get("dock_icon_size", 28) - self.terminal_entry.set_text(bind_vars["terminal_command"]) - self.osd_switch.set_active(bind_vars.get("osd_enabled", True)) - self.bottom_bar_switch.set_active(bind_vars.get("bottom_bar", False)) - - for component_name, switch in self.component_switches.items(): - config_key = f"bar_{component_name}_visible" - switch.set_active(bind_vars.get(config_key, True)) - - self.corners_switch.set_active(bind_vars.get("corners_visible", True)) - - # Reset metrics visibility - for k, s in self.metrics_switches.items(): - s.set_active(DEFAULTS["metrics_visible"][k]) - - # Reset disk entries - if True: - for i in self.disk_entries.children: - self.disk_entries.remove(i) - bar = Box(orientation="h", spacing=10, h_align="start") - entry = Entry(text="/", h_expand=True) - bar.add(entry) - x = Button( - label="X", on_clicked=lambda _: self.disk_entries.remove(bar) - ) - bar.add(x) - self.disk_entries.add(bar) - - if self.lock_switch: - self.lock_switch.set_active(False) - if self.idle_switch: - self.idle_switch.set_active(False) - - print("Settings reset to defaults.") - - def on_close(self, widget): - """Close the settings window.""" - if self.application: - self.application.quit() - else: - self.destroy() - - -def start_config(): - """ - Run final configuration steps: ensure necessary configs, write the hyprconf, and reload. - """ - ensure_matugen_config() - - hypr_config_dir = os.path.expanduser(f"~/{APP_NAME_CAP}/config/hypr/") - os.makedirs(hypr_config_dir, exist_ok=True) - hypr_conf_path = os.path.join(hypr_config_dir, f"{APP_NAME}.conf") - try: - with open(hypr_conf_path, "w") as f: - f.write(generate_hyprconf()) - print(f"Generated Hyprland config at {hypr_conf_path}") - except Exception as e: - print(f"Error writing Hyprland config: {e}") - - try: - subprocess.run(["hyprctl", "reload"], check=True, capture_output=True) - print("Hyprland configuration reloaded.") - except subprocess.CalledProcessError as e: - print(f"Error reloading Hyprland: {e}\nstderr: {e.stderr.decode()}") - except FileNotFoundError: - print("Error: hyprctl command not found. Cannot reload Hyprland.") - except Exception as e: - print(f"An unexpected error occurred during hyprctl reload: {e}") +if __name__ == "__main__" and (__package__ is None or __package__ == ""): + from config.data import APP_NAME, APP_NAME_CAP + from config.settings.gui import HyprConfGUI + from config.settings.utils import load_bind_vars +else: + from .data import APP_NAME, APP_NAME_CAP + from .settings.gui import HyprConfGUI + from .settings.utils import load_bind_vars def open_config(): @@ -1249,9 +39,9 @@ def open_config(): """ load_bind_vars() - dest_lock = os.path.expanduser("~/.config/hypr/hyprlock.conf") - src_lock = os.path.expanduser(f"~/{APP_NAME_CAP}/config/hypr/hyprlock.conf") show_lock_checkbox = True + dest_lock = os.path.expanduser("~/.config/hypr/hyprlock.conf") + src_lock = os.path.expanduser(f"~/.config/{APP_NAME_CAP}/config/hypr/hyprlock.conf") if not os.path.exists(dest_lock) and os.path.exists(src_lock): try: os.makedirs(os.path.dirname(dest_lock), exist_ok=True) @@ -1262,9 +52,9 @@ def open_config(): print(f"Error copying default hyprlock config: {e}") show_lock_checkbox = os.path.exists(src_lock) - dest_idle = os.path.expanduser("~/.config/hypr/hypridle.conf") - src_idle = os.path.expanduser(f"~/{APP_NAME_CAP}/config/hypr/hypridle.conf") show_idle_checkbox = True + dest_idle = os.path.expanduser("~/.config/hypr/hypridle.conf") + src_idle = os.path.expanduser(f"~/.config/{APP_NAME_CAP}/config/hypr/hypridle.conf") if not os.path.exists(dest_idle) and os.path.exists(src_idle): try: os.makedirs(os.path.dirname(dest_idle), exist_ok=True) diff --git a/config/data.py b/config/data.py index f3b3271..4e82928 100644 --- a/config/data.py +++ b/config/data.py @@ -1,120 +1,132 @@ -import os import json -import gi +import os -gi.require_version("Gtk", "3.0") +import gi +from fabric.utils.helpers import get_relative_path from gi.repository import Gdk, GLib -from fabric.utils import get_relative_path +gi.require_version("Gtk", "3.0") APP_NAME = "modus" APP_NAME_CAP = "Modus" -CACHE_DIR = str(GLib.get_user_cache_dir()) + f"/{APP_NAME}" -STATE_DIR = str(GLib.get_user_state_dir() + f"/{APP_NAME}") +ALLOWED_PLAYERS = ["vlc", "cmus", "firefox", "spotify", "chromium", "vivaldi", "brave"] +NOTIF_POS_KEY = "notif_pos" +NOTIF_POS_DEFAULT = "Top" + +CACHE_DIR = str(GLib.get_user_cache_dir()) + f"/{APP_NAME}" USERNAME = os.getlogin() HOSTNAME = os.uname().nodename HOME_DIR = os.path.expanduser("~") -CONFIG_DIR = os.path.expanduser(f"~/{APP_NAME}") +CONFIG_DIR = os.path.expanduser(f"~/.config/{APP_NAME}") screen = Gdk.Screen.get_default() CURRENT_WIDTH = screen.get_width() CURRENT_HEIGHT = screen.get_height() -WALLPAPERS_DIR_DEFAULT = get_relative_path("../assets/wallpaper") -MATUGEN_STATE_FILE = os.path.join(CONFIG_DIR, "matugen") + +WALLPAPERS_DIR_DEFAULT = get_relative_path("../assets/wallpapers_example") CONFIG_FILE = get_relative_path("../config/assets/config.json") +MATUGEN_STATE_FILE = os.path.join(CONFIG_DIR, "matugen") + +WORKSPACE_NUMS = False +WORKSPACE_USE_CHINESE_NUMERAL = False +WORKSPACE_DOTS = False + +DOCK_THEME = "Pills" + + +def load_config(): + """Load the configuration from config.json""" + config = {} + + if os.path.exists(CONFIG_FILE): + try: + with open(CONFIG_FILE, "r") as f: + config = json.load(f) + except Exception as e: + print(f"Error loading config: {e}") + + return config + if os.path.exists(CONFIG_FILE): with open(CONFIG_FILE, "r") as f: config = json.load(f) WALLPAPERS_DIR = config.get("wallpapers_dir", WALLPAPERS_DIR_DEFAULT) - VERTICAL = config.get("vertical", False) # Use saved value or False as default - VERTICAL_RIGHT_ALIGN = config.get("vertical_right_align", False) # Right side by default - BOTTOM_BAR = config.get("bottom_bar", False) # Use saved value or False as default - CENTERED_BAR = config.get("centered_bar", False) # Load centered bar setting - TERMINAL_COMMAND = config.get( - "terminal_command", "kitty -e" - ) # Load terminal command - DOCK_ENABLED = config.get("dock_enabled", True) # Load dock visibility setting - DOCK_ALWAYS_OCCLUDED = config.get( - "dock_always_occluded", False - ) # Load dock hover-only setting - DOCK_ICON_SIZE = config.get("dock_icon_size", 28) # Load dock icon size setting - OSD_ENABLED = config.get("osd_enabled", True) # Load OSD visibility setting + DOCK_POSITION = config.get("dock_position", "Bottom") + VERTICAL = DOCK_POSITION in ["Left", "Right"] + TERMINAL_COMMAND = config.get("terminal_command", "kitty -e") + DOCK_ENABLED = config.get("dock_enabled", True) + DOCK_AUTO_HIDE = config.get("dock_auto_hide", True) + DOCK_ALWAYS_OCCLUDED = config.get("dock_always_occluded", False) + DOCK_THEME = config.get("dock_theme", "Pills") + DOCK_ICON_SIZE = config.get("dock_icon_size", 20) + WORKSPACE_NUMS = config.get("workspace_nums", False) + WORKSPACE_USE_CHINESE_NUMERAL = config.get("workspace_use_chinese_numerals", False) + WORKSPACE_DOTS = config.get("workspace_dots", False) + WINDOW_SWITCHER_ITEMS_PER_ROW = config.get("window_switcher_items_per_row", 13) + DOCK_HIDE_SPECIAL_WORKSPACE = config.get("dock_hide_special_workspace", True) + DOCK_HIDE_SPECIAL_WORKSPACE_APPS = config.get( + "dock_hide_special_workspace_apps", True + ) - # Load bar component visibility settings - BAR_COMPONENTS_VISIBILITY = { - "button_app": config.get("bar_button_app_visible", True), - "tray": config.get("bar_tray_visible", True), - # "button_tools": config.get("bar_button_tools_visible", True), - "workspaces": config.get("bar_workspaes_visible", True), - "metrics": config.get("bar_metrics_visible", True), - "language": config.get("bar_language_visible", True), - "date_time": config.get("bar_date_time_visible", True), - "updates": config.get("bar_updates_visible", True), - "indicators": config.get("bar_indicators", True), + NOTIF_POS = config.get(NOTIF_POS_KEY, NOTIF_POS_DEFAULT) + + DOCK_COMPONENTS_VISIBILITY = { + "workspace": config.get("dock_workspace_visible", True), + "metrics": config.get("dock_metrics_visible", True), + "battery": config.get("dock_battery_visible", True), + "date_time": config.get("dock_date_time_visible", True), + "controls": config.get("dock_controls_visible", True), + "indicators": config.get("dock_indicators_visible", True), + "notifications": config.get("dock_notifications_visible", True), + "systray": config.get("dock_tray_visible", True), + "applications": config.get("dock_applications_visible", True), + "language": config.get("dock_language_visible", True), } - BAR_METRICS_DISKS = config.get("bar_metrics_disks", ["/"]) + METRICS_DISKS = config.get("metrics_disks", ["/"]) METRICS_VISIBLE = config.get( "metrics_visible", - {"cpu": True, "ram": True, "disk": True, "swap": True}, + {"cpu": True, "ram": True, "disk": True, "swap": True, "gpu": False}, ) else: WALLPAPERS_DIR = WALLPAPERS_DIR_DEFAULT - VERTICAL = False # Default value when no config exists - VERTICAL_RIGHT_ALIGN = False # Default to right alignment - BOTTOM_BAR = False # Default value for bottom bar - CENTERED_BAR = False # Default value for centered bar - DOCK_ENABLED = True # Default value for dock visibility - DOCK_ALWAYS_OCCLUDED = False # Default value for dock hover-only mode - OSD_ENABLED = True # Default value for OSD visibility - TERMINAL_COMMAND = "kitty -e" # Default terminal command when no config - DOCK_ICON_SIZE = 28 # Default dock icon size when no config + DOCK_POSITION = "Bottom" + VERTICAL = False + DOCK_ENABLED = True + DOCK_ALWAYS_OCCLUDED = False + DOCK_AUTO_HIDE = True + TERMINAL_COMMAND = "kitty -e" + DOCK_THEME = "Pills" + DOCK_ICON_SIZE = 20 + WINDOW_SWITCHER_ITEMS_PER_ROW = 13 + NOTIF_POS = NOTIF_POS_DEFAULT + DOCK_HIDE_SPECIAL_WORKSPACE = True + DOCK_HIDE_SPECIAL_WORKSPACE_APPS = True - # Default values for component visibility (all visible) - BAR_COMPONENTS_VISIBILITY = { - "button_aps": True, - "tray": True, - # "button_tools": True, - "workspaces": True, + DOCK_COMPONENTS_VISIBILITY = { + "workspace": True, "metrics": True, - "language": True, + "battery": True, "date_time": True, + "controls": True, "indicators": True, - "updates": True, + "notifications": True, + "systray": True, + "applications": True, + "language": True, } - BAR_METRICS_DISKS = ["/"] + METRICS_DISKS = ["/"] METRICS_VISIBLE = { "cpu": True, "ram": True, "disk": True, "swap": True, + "gpu": False, } - - -def read_config(): - config_path = get_relative_path("./assets/modus.json") - - with open(config_path, "r") as file: - # Load JSON data into a Python dictionary - data = json.load(file) - return data - - -def load_config(): - """Load the configuration from config.json""" - config_path = get_relative_path("./assets/config.json") - config = {} - if os.path.exists(config_path): - try: - with open(config_path, "r") as f: - config = json.load(f) - except Exception as e: - print(f"Error loading config: {e}") - return config diff --git a/config/hypr/colors.conf b/config/hypr/colors.conf index e839fe7..b077af8 100644 --- a/config/hypr/colors.conf +++ b/config/hypr/colors.conf @@ -1,11 +1,27 @@ -$wallpaper = /home/sankalp/Pictures/wallpapers/background.png -$background = 0e1514 -$foreground = dde4e2 +$wallpaper = /home/sankalp/Pictures/wallpapers/54.png +$background = 1a110f +$foreground = f1dfda -$primary = 81d5ca -$secondary = b1ccc7 -$tertiary = aec9e6 -$surface = 0e1514 -$surface_bright = 343a39 -$outline = 899390 +$primary = ffb59d +$secondary = e7bdb1 +$tertiary = d7c68d +$surface = 1a110f +$surface_bright = 423733 +$outline = a08d87 $error = ff5945 + +$shadow = 000000 + +$red = ffb4a2 +$green = b7d085 +$yellow = dec56e +$blue = cebdfe +$magenta = fcb0d5 +$cyan = 84d5c4 +$white = 82d3e0 +$red_dim = ff623c +$green_dim = 89ad42 +$yellow_dim = bb9b2b +$blue_dim = 8359fc +$magenta_dim = f84ea1 +$cyan_dim = 3eb59c diff --git a/config/hypr/hypridle.conf b/config/hypr/hypridle.conf index 48bd7d7..340ff14 100644 --- a/config/hypr/hypridle.conf +++ b/config/hypr/hypridle.conf @@ -1,12 +1,5 @@ - -listener { - timeout = 500 # in seconds. - on-timeout = notify-send "You are idle!" # command to run when timeout has passed. - on-resume = notify-send "Welcome back!" # command to run when activity is detected after timeout has fired. -} - general { - lock_cmd = pidof hyprlock || hyprlock # avoid starting multiple hyprlock instances. + lock_cmd = pidof hyprlock || hyprlock # avoid starting multiple hyprlock instances. before_sleep_cmd = loginctl lock-session # lock before suspend. after_sleep_cmd = hyprctl dispatch dpms on # to avoid having to press a key twice to turn on the display. } @@ -17,13 +10,6 @@ listener { on-resume = brightnessctl -r # monitor backlight restore. } -# turn off keyboard backlight, comment out this section if you dont have a keyboard backlight. -listener { - timeout = 150 # 2.5min. - on-timeout = brightnessctl -sd rgb:kbd_backlight set 0 # turn off keyboard backlight. - on-resume = brightnessctl -rd rgb:kbd_backlight # turn on keyboard backlight. -} - listener { timeout = 300 # 5min on-timeout = loginctl lock-session # lock screen when timeout has passed diff --git a/config/hypr/hyprlock.conf b/config/hypr/hyprlock.conf index 80977af..7da736f 100644 --- a/config/hypr/hyprlock.conf +++ b/config/hypr/hyprlock.conf @@ -1,150 +1,91 @@ +source = ~/.config/Ax-Shell/config/hypr/colors.conf +# BACKGROUND +background { + monitor = + path = ~/.current.wall #path to background image + blur_passes = 3 + blur_size = 3 + contrast = 1.0 + brightness = 0.5 + vibrancy = 0.0 + vibrancy_darkness = 0.0 +} -source = ~/Modus/config/hypr/colors.conf +# GENERAL +general { + no_fade_in = false + grace = 0 + disable_loading_bar = true + hide_cursor = true +} -# Configuration - # (general settings) - general { - grace = 2 - hide_cursor = true - ignore_empty_input = true - text_trim = true - } - # (background config) - background { - monitor = - path = $wallpaper # only png supported for now - # color = rgba(26,17,16, 1.0) - blur_passes = 3 # 0 disables blurring - blur_size = 1 - noise = 0.01 - contrast = 0.8916 - brightness = 0.7 - vibrancy = 0.1696 - vibrancy_darkness = 0.0 - } +# INPUT FIELD +input-field { + monitor = + size = 256, 48 + outline_thickness = 0 + dots_size = 0.2 # Scale of input-field height, 0.2 - 0.8 + dots_spacing = 0.5 # Scale of dots' absolute size, 0.0 - 1.0 + dots_center = true + outer_color = rgba(00000000) + inner_color = rgba(0, 0, 0, 1) + font_color = rgb($foreground) + fail_color = rgb($error) + check_color = rgb($tertiary) + capslock_color = rgb($secondary) + fade_on_empty = false + font_family = Iosevka Nerd Font + placeholder_text = ... #text for input password + hide_input = false + position = 0, -100 + halign = center + valign = center + shadow_passes = 1 + shadow_size = 5 + shadow_boost = 0.5 +} +# TIME +label { + monitor = + text = cmd[update:1000] echo "$(date +"%H:%M:%S")" + color = rgb($foreground) + font_size = 14 + font_family = Iosevka Nerd Font Bold + position = 0, -150 + halign = center + valign = center + shadow_passes = 1 + shadow_size = 5 + shadow_boost = 0.5 +} -# Password input - input-field { - monitor = - size = 256, 48 - outline_thickness = -1 - dots_size = 0.33 - dots_spacing = 0.45 - dots_center = true - dots_rounding = -1 - outer_color = rgba(0,0,0,0) - inner_color = rgba(0,0,0,0) - font_color = 0xff$foreground - fade_on_empty = true - fade_timeout = 1000 - font_family = Product Sans - placeholder_text = - hide_input = false - rounding = 4 - check_color = 0xff$tertiary - fail_color = 0xff$error - capslock_color = 0xff$secondary - fail_text = $FAIL ($ATTEMPTS) - fail_transition = 200 - capslock_color = -1 - numlock_color = -1 - bothlock_color = -1 - invert_numlock = false - swap_font_color = false - position = 0, 150 - halign = center - valign = bottom - } -# - - -# Time - # (hours) - label { - monitor = - text = cmd[update:1000] echo -e "$(date +"%H")" - color = 0xff$foreground - shadow_pass = 2 - shadow_size = 3 - shadow_color = rgb(0,0,0) - shadow_boost = 1.2 - font_size = 150 - font_family = Product Sans Light - position = 0, 90 - halign = center - valign = center - } - # (minutes) - label { - monitor = - text = cmd[update:1000] echo -e "$(date +"%M")" - color = 0xff$foreground - font_size = 150 - font_family = Product Sans Light - position = 0, -90 - halign = center - valign = center - } -# - -# Date - # (label) - label { - monitor = - text = cmd[update:1000] echo -e "$(date +"%A, %B %d")" - color = 0xff$foreground - font_size = 15 - font_family = Product Sans Medium - position = 100, -75 - shadow_passes = 5 - shadow_size = 10 - halign = left - valign = top - } - # (icon) - label { - monitor = - text = calendar_today - color = 0xff$foreground - font_size = 15 - font_family = Material Symbols Outlined - position = 75, -75 - shadow_passes = 5 - shadow_size = 10 - halign = left - valign = top - } -# - -# Input layout - # (label) - label { - monitor = - text = $LAYOUT - color = 0xff$foreground - font_size = 15 - font_family = Product Sans Medium - position = 100, -110 - shadow_passes = 5 - shadow_size = 10 - halign = left - valign = top - } - # (icon) - label { - monitor = - text = keyboard - color = 0xff$foreground - font_size = 15 - font_family = Material Symbols Outlined - position = 75, -112 - shadow_passes = 5 - shadow_size = 10 - halign = left - valign = top - } -# - +# USER +label { + monitor = + text = cmd[update:1000] echo "$USER@$(hostname)" + color = rgb($foreground) + font_size = 14 + font_family = Iosevka Nerd Font Bold Italic + position = 0, -50 + halign = center + valign = center + shadow_passes = 1 + shadow_size = 5 + shadow_boost = 0.5 +} +# PICTURE +image { + path = .face.icon + size = 200 + position = 0, 75 + halign = center + valign = center + border_size = 3 + border_color = rgb($primary) + shadow_passes = 1 + shadow_size = 5 + shadow_boost = 0.5 +} 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/config/matugen/templates/hyprland-colors.conf b/config/matugen/templates/hyprland-colors.conf index a375c24..9aeb090 100644 --- a/config/matugen/templates/hyprland-colors.conf +++ b/config/matugen/templates/hyprland-colors.conf @@ -9,3 +9,19 @@ $surface = {{colors.surface.default.hex_stripped}} $surface_bright = {{colors.surface_bright.default.hex_stripped}} $outline = {{colors.outline.default.hex_stripped}} $error = {{colors.error.default.hex_stripped | set_lightness: -20.0}} + +$shadow = {{colors.shadow.default.hex_stripped}} + +$red = {{colors.red.default.hex_stripped}} +$green = {{colors.green.default.hex_stripped}} +$yellow = {{colors.yellow.default.hex_stripped}} +$blue = {{colors.blue.default.hex_stripped}} +$magenta = {{colors.magenta.default.hex_stripped}} +$cyan = {{colors.cyan.default.hex_stripped}} +$white = {{colors.white.default.hex_stripped}} +$red_dim = {{colors.red.default.hex_stripped | set_lightness: -20.0}} +$green_dim = {{colors.green.default.hex_stripped | set_lightness: -20.0}} +$yellow_dim = {{colors.yellow.default.hex_stripped | set_lightness: -20.0}} +$blue_dim = {{colors.blue.default.hex_stripped | set_lightness: -20.0}} +$magenta_dim = {{colors.magenta.default.hex_stripped | set_lightness: -20.0}} +$cyan_dim = {{colors.cyan.default.hex_stripped | set_lightness: -20.0}} diff --git a/config/matugen/templates/modus.css b/config/matugen/templates/modus.css index db192d7..7d8c607 100644 --- a/config/matugen/templates/modus.css +++ b/config/matugen/templates/modus.css @@ -3,29 +3,18 @@ --background: {{colors.background.default.hex}}; --cursor: {{colors.on_background.default.hex}}; --primary: {{colors.primary.default.hex}}; - --primary-container: {{colors.primary_container.default.hex}}; --on-primary: {{colors.on_primary.default.hex}}; - --on-primary-container: {{colors.on_primary_container.default.hex}}; --secondary: {{colors.secondary.default.hex}}; --on-secondary: {{colors.on_secondary.default.hex}}; - --secondary-container: {{colors.secondary_container.default.hex}}; --tertiary: {{colors.tertiary.default.hex}}; - --tertiary-container: {{colors.tertiary_container.default.hex}}; --on-tertiary: {{colors.on_tertiary.default.hex}}; - --on-tertiary-container: {{colors.on_tertiary_container.default.hex}}; --surface: {{colors.surface.default.hex}}; - --surface-container: {{colors.surface_container.default.hex}}; --surface-bright: {{colors.surface_bright.default.hex}}; - --surface-variant: {{colors.surface_variant.default.hex}}; - --surface-container-low: {{colors.surface_container_low.default.hex}}; - --on-surface: {{colors.on_surface.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}}; - --on-error-container: {{colors.on_error_container.default.hex}}; --outline: {{colors.outline.default.hex}}; - --outline-variant: {{colors.outline_variant.default.hex}}; --shadow: {{colors.shadow.default.hex}}; --red: {{colors.red.default.hex}}; --red-dim: {{colors.red.default.hex | set_lightness: -10.0}}; @@ -40,5 +29,4 @@ --cyan: {{colors.cyan.default.hex}}; --cyan-dim: {{colors.cyan.default.hex | set_lightness: -10.0}}; --white: {{colors.white.default.hex}}; - } diff --git a/config/matugen/templates/modus.scss b/config/matugen/templates/modus.scss deleted file mode 100644 index aba4239..0000000 --- a/config/matugen/templates/modus.scss +++ /dev/null @@ -1,29 +0,0 @@ -@use "sass:color"; -<* for name, value in colors *> -${{name}}: {{value.default.hex}}; -<* endfor *> - -// Custom Color -$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}}; - -$theme: 'dark'; -$surface_transparent: color.scale($surface, $alpha: -20%); -$inverse_surface_transparent: color.scale($inverse_surface, $alpha: -92%); -$inverse_surface_transparent_variant: color.scale( - $inverse_surface, - $alpha: -82% -); -$inverse_surface_transparent_overlay: linear-gradient(0deg, $inverse_surface_transparent, $inverse_surface_transparent); -$inverse_surface_transparent_variant_overlay: linear-gradient(0deg, $inverse_surface_transparent_variant, $inverse_surface_transparent_variant); diff --git a/config/scripts/capture.sh b/config/scripts/capture.sh deleted file mode 100755 index 8204108..0000000 --- a/config/scripts/capture.sh +++ /dev/null @@ -1,166 +0,0 @@ -#!/bin/bash - -# Print usage information -print_usage() { - cat <<"EOF" -Usage: screen-tools.sh [mode] [option] -Modes: - screenshot [option] Take a screenshot - Options: - p Print all screens - s Snip current screen - sf Snip current screen (frozen) - m Print focused monitor - [mockup] Optional: Add mockup effect to screenshot - - record Start/Stop screen recording - - ocr Perform OCR on selected area -EOF -} - -# OCR functionality -do_ocr() { - sleep 0.25 - - ocr_text=$(grimblast --freeze save area - | tesseract -l eng - - 2>/dev/null) - - # Check if OCR was successful - if [[ -n "$ocr_text" ]]; then - echo -n "$ocr_text" | wl-copy - notify-send -a "Modus" "OCR Success" "Text Copied to Clipboard" - else - notify-send -a "Modus" "OCR Failed" "No text recognized or operation failed" - fi -} - -# Screen recording functionality -do_screenrecord() { - - if [ -z "$XDG_VIDEOS_DIR" ]; then - XDG_VIDEOS_DIR="$HOME/Videos" - - fi - local save_dir="${XDG_VIDEOS_DIR:-$HOME/Videos}/Recordings" - mkdir -p "$save_dir" - - if pgrep -f "gpu-screen-recorder" >/dev/null; then - pkill -SIGINT -f "gpu-screen-recorder" - sleep 1 - - local last_video=$(ls -t "$save_dir"/*.mp4 2>/dev/null | head -n 1) - local action=$(notify-send -a "Modus" "⬜ Recording stopped" \ - -A "view=View" -A "open=Open folder") - - if [ "$action" = "view" ] && [ -n "$last_video" ]; then - xdg-open "$last_video" - elif [ "$action" = "open" ]; then - xdg-open "$save_dir" - fi - exit 0 - fi - - local output_file="$save_dir/$(date +%Y-%m-%d-%H-%M-%S).mp4" - notify-send -a "Modus" "🔴 Recording started" - gpu-screen-recorder -w screen -q ultra -a default_output -ac opus -cr full -f 60 -o "$output_file" -} - -# Screenshot functionality -do_screenshot() { - sleep 0.25 - - if [ -z "$XDG_PICTURES_DIR" ]; then - XDG_PICTURES_DIR="$HOME/Pictures" - fi - - local save_dir="${3:-$XDG_PICTURES_DIR/Screenshots}" - local save_file=$(date +'%y%m%d_%Hh%Mm%Ss_screenshot.png') - local full_path="$save_dir/$save_file" - mkdir -p "$save_dir" - - local mockup_mode="$2" - - case $1 in - p) grimblast copysave screen "$full_path" ;; - s) grimblast copysave area "$full_path" ;; - sf) grimblast --freeze copysave area "$full_path" ;; - m) grimblast copysave output "$full_path" ;; - *) - echo "Invalid screenshot mode" - print_usage - exit 1 - ;; - esac - - if [ -f "$full_path" ]; then - if [ "$mockup_mode" = "mockup" ]; then - local temp_file="${full_path%.png}_temp.png" - cropped_file="${full_path%.png}_cropped.png" - local mockup_file="${full_path%.png}_mockup.png" - - # First crop the top pixel using a different approach - - # Use +0+1 to start at y-coordinate 1 (skipping the first pixel row) - - magick "$full_path" -crop +0+1 +repage "$cropped_file" - - magick "$cropped_file" \ - \( +clone -alpha extract -draw 'fill black polygon 0,0 0,20 20,0 fill white circle 20,20 20,0' \ - \( +clone -flip \) -compose Multiply -composite \ - \( +clone -flop \) -compose Multiply -composite \ - \) -alpha off -compose CopyOpacity -composite "$temp_file" - - magick "$temp_file" \ - \( +clone -background black -shadow 60x20+0+10 -alpha set -channel A -evaluate multiply 1 +channel \) \ - +swap -background none -layers merge +repage "$mockup_file" - - rm "$temp_file" "$cropped_file" - mv "$mockup_file" "$full_path" - - if command -v wl-copy >/dev/null 2>&1; then - wl-copy <"$full_path" - elif command -v xclip >/dev/null 2>&1; then - xclip -selection clipboard -t image/png <"$full_path" - fi - fi - - local action=$(notify-send -a "Modus" -i "$full_path" "Screenshot saved" "in $full_path" \ - -A "view=View" -A "edit=Edit" -A "open=Open Folder") - - case "$action" in - view) xdg-open "$full_path" ;; - edit) swappy -f "$full_path" ;; - open) xdg-open "$save_dir" ;; - esac - else - notify-send -a "Modus" "Screenshot Aborted" - fi -} - -# Handle command line arguments -if [ $# -eq 0 ]; then - print_usage - exit 1 -fi - -case "$1" in -screenshot) - if [ $# -lt 2 ]; then - echo "Error: Screenshot mode requires an option" - print_usage - exit 1 - fi - do_screenshot "$2" "$3" - ;; -record) - do_screenrecord - ;; -ocr) - do_ocr - ;; -*) - echo "Error: Invalid mode '$1'" - print_usage - exit 1 - ;; -esac diff --git a/config/scripts/systemupdates.sh b/config/scripts/systemupdates.sh deleted file mode 100755 index a2677ff..0000000 --- a/config/scripts/systemupdates.sh +++ /dev/null @@ -1,182 +0,0 @@ -#!/bin/bash - -# Word of caution: Changing this may break the update module, be careful! - -# Function to check updates for Arch-based distros -check_arch_updates() { - official_updates=0 - aur_updates=0 - flatpak_updates=0 - tooltip="" - - # Get the number of official updates - official_updates=$(checkupdates 2>/dev/null | wc -l) - - # Determine the AUR helper - if command -v paru &>/dev/null; then - aur_helper="paru" - else - aur_helper="yay" - fi - - # Get the number of AUR updates - aur_updates=$($aur_helper -Qum 2>/dev/null | wc -l) - - # Check for Flatpak updates - if command -v flatpak &>/dev/null; then - flatpak_updates=$(flatpak remote-ls --updates | wc -l) - fi - - # Calculate total updates - total_updates=$((official_updates + aur_updates + flatpak_updates)) - - # Build the tooltip - tooltip="󰣇 Official: $official_updates\n󰮯 AUR: $aur_updates\n Flatpak: $flatpak_updates" - - # Output JSON - echo "{\"total\":\"$total_updates\", \"tooltip\":\"$tooltip\"}" -} - -# Function to check updates for Ubuntu-based distros -check_ubuntu_updates() { - official_updates=0 - flatpak_updates=0 - tooltip="" - - # Get the number of official updates - official_updates=$(apt-get -s -o Debug::NoLocking=true upgrade | grep -c ^Inst) - - # Check for Flatpak updates - if command -v flatpak &>/dev/null; then - flatpak_updates=$(flatpak remote-ls --updates | wc -l) - fi - - # Calculate total updates - total_updates=$((official_updates + flatpak_updates)) - - # Build the tooltip - tooltip="󰕈 Official: $official_updates\n Flatpak: $flatpak_updates" - - # Output JSON - echo "{\"total\":\"$total_updates\", \"tooltip\":\"$tooltip\"}" -} - -# Function to check updates for Fedora-based distros -check_fedora_updates() { - official_updates=0 - flatpak_updates=0 - tooltip="" - - # Get the number of official updates - official_updates=$(dnf list updates -q | tail -n +2 | wc -l) - - # Check for Flatpak updates - if command -v flatpak &>/dev/null; then - flatpak_updates=$(flatpak remote-ls --updates | wc -l) - fi - - # Calculate total updates - total_updates=$((official_updates + flatpak_updates)) - - # Build the tooltip - tooltip="󰣛 Official: $official_updates\n Flatpak: $flatpak_updates" - - # Output JSON - echo "{\"total\":\"$total_updates\", \"tooltip\":\"$tooltip\"}" -} - -# Function to check updates for openSUSE-based distros -check_opensuse_updates() { - official_updates=0 - flatpak_updates=0 - tooltip="" - - # Get the number of official updates (skip header lines) - official_updates=$(zypper lu | tail -n +3 | wc -l) - - # Check for Flatpak updates - if command -v flatpak &>/dev/null; then - flatpak_updates=$(flatpak remote-ls --updates | wc -l) - fi - - # Calculate total updates - total_updates=$((official_updates + flatpak_updates)) - - # Build the tooltip - tooltip=" Official: $official_updates\n Flatpak: $flatpak_updates" - - # Output JSON - echo "{\"total\":\"$total_updates\", \"tooltip\":\"$tooltip\"}" -} - -# Function to update Arch-based distros -update_arch() { - kitty --title "System Update" sh -c " - fastfetch - yay -Syu - read -n 1 -p 'Press any key to continue...' - " -} - -# Function to update Ubuntu-based distros -update_ubuntu() { - kitty --title "System Update" sh -c " - neofetch - sudo apt update && sudo apt upgrade -y - read -n 1 -p 'Press any key to continue...' - " -} - -# Function to update Fedora-based distros -update_fedora() { - kitty --title "System Update" sh -c " - neofetch - sudo dnf upgrade -y - read -n 1 -p 'Press any key to continue...' - " -} - -# Function to update openSUSE-based distros -update_opensuse() { - kitty --title "System Update" sh -c " - neofetch - sudo zypper up -y - read -n 1 -p 'Press any key to continue...' - " -} - -# Main case handling for different distributions -case "$1" in --arch) - if [ -z "$2" ]; then - check_arch_updates - else - update_arch - fi - ;; --ubuntu) - if [ -z "$2" ]; then - check_ubuntu_updates - else - update_ubuntu - fi - ;; --fedora) - if [ -z "$2" ]; then - check_fedora_updates - else - update_fedora - fi - ;; --suse) - if [ -z "$2" ]; then - check_opensuse_updates - else - update_opensuse - fi - ;; -*) - echo "Usage: $0 [-arch|-ubuntu|-fedora|-suse] [up (optional)]" - exit 1 - ;; -esac diff --git a/config/settings/about_tab.py b/config/settings/about_tab.py new file mode 100644 index 0000000..b475efc --- /dev/null +++ b/config/settings/about_tab.py @@ -0,0 +1,40 @@ +from fabric.widgets.box import Box +from fabric.widgets.label import Label + +from config.data import APP_NAME_CAP + + +class AboutTab: + """About information tab for settings""" + + def __init__(self): + pass + + def create_about_tab(self): + """Create the About tab content""" + vbox = Box(orientation="v", spacing=18, style="margin: 30px;") + vbox.add( + Label( + markup=f"{APP_NAME_CAP}", + h_align="start", + style="font-size: 1.5em; margin-bottom: 8px;", + ) + ) + vbox.add( + Label( + label="A hackable shell for Hyprland, powered by Fabric.", + h_align="start", + style="margin-bottom: 12px;", + ) + ) + repo_box = Box(orientation="h", spacing=6, h_align="start") + repo_label = Label(label="GitHub:", h_align="start") + repo_link = Label( + markup='https://github.com/S4NKALP/Modus' + ) + repo_box.add(repo_label) + repo_box.add(repo_link) + vbox.add(repo_box) + + vbox.add(Box(v_expand=True)) + return vbox diff --git a/config/settings/appearance_tab.py b/config/settings/appearance_tab.py new file mode 100644 index 0000000..fcc29f0 --- /dev/null +++ b/config/settings/appearance_tab.py @@ -0,0 +1,667 @@ +import os + +import gi +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.grid import Grid +from fabric.widgets.image import Image +from fabric.widgets.label import Label +from fabric.widgets.scale import Scale +from fabric.widgets.scrolledwindow import ScrolledWindow +from gi.repository import GdkPixbuf, Gtk + +from config.data import NOTIF_POS_DEFAULT, NOTIF_POS_KEY +from config.settings.utils import bind_vars + +gi.require_version("Gtk", "3.0") + + +class AppearanceTab: + """Appearance and layout management tab for settings""" + + def __init__(self, themes, parent_window=None): + self.themes = themes + self.parent_window = parent_window + self.selected_face_icon = None + + # Widget references + self.wall_dir_chooser = None + self.face_image = None + self.face_status_label = None + self.ws_mode_combo = None + self.ws_chinese_switch = None + self.position_combo = None + self.dock_theme_combo = None + self.icon_size_scale = None + self.application_switcher_scale = None + self.dock_switch = None + self.dock_auto_hide_switch = None + self.dock_hover_switch = None + self.dock_hide_special_switch = None + self.dock_hide_special_apps_switch = None + self.notification_pos_combo = None + self.component_switches = {} + self.corners_switch = None + + def create_appearance_tab(self): + """Create the Appearance tab content""" + scrolled_window = ScrolledWindow( + h_scrollbar_policy="never", + v_scrollbar_policy="automatic", + h_expand=False, + v_expand=False, + propagate_width=False, + propagate_height=False, + ) + # Set fixed size to match tab stack dimensions + scrolled_window.set_size_request(580, 580) + + vbox = Box(orientation="v", spacing=15, style="margin: 15px;") + scrolled_window.add(vbox) + + # Create wallpaper and profile icon section + self._create_wallpaper_profile_section(vbox) + + # Add separator + separator1 = Box( + style="min-height: 1px; background-color: alpha(@fg_color, 0.2); margin: 5px 0px;", + h_expand=True, + ) + vbox.add(separator1) + + # Create layout options section + self._create_layout_options_section(vbox) + + # Add separator + separator2 = Box( + style="min-height: 1px; background-color: alpha(@fg_color, 0.2); margin: 5px 0px;", + h_expand=True, + ) + vbox.add(separator2) + + # Create modules section + self._create_modules_section(vbox) + + return scrolled_window + + def _create_wallpaper_profile_section(self, vbox): + """Create wallpaper and profile icon section""" + top_grid = Grid(column_spacing=20, row_spacing=5, style="margin-bottom: 10px;") + vbox.add(top_grid) + + # Wallpaper section + wall_header = Label(markup="Wallpapers", h_align="start") + top_grid.attach(wall_header, 0, 0, 1, 1) + wall_label = Label(label="Directory:", h_align="start", v_align="center") + top_grid.attach(wall_label, 0, 1, 1, 1) + + chooser_container = Box( + orientation="horizontal", h_align="start", v_align="center" + ) + self.wall_dir_chooser = Gtk.FileChooserButton( + title="Select a folder", action=Gtk.FileChooserAction.SELECT_FOLDER + ) + self.wall_dir_chooser.set_tooltip_text( + "Select the directory containing your wallpaper images" + ) + self.wall_dir_chooser.set_filename(bind_vars.get("wallpapers_dir", "")) + self.wall_dir_chooser.set_size_request(180, -1) + chooser_container.add(self.wall_dir_chooser) + top_grid.attach(chooser_container, 1, 1, 1, 1) + + # Profile icon section + face_header = Label(markup="Profile Icon", h_align="start") + top_grid.attach(face_header, 2, 0, 2, 1) + current_face = os.path.expanduser("~/.face.icon") + face_image_container = Box( + style_classes=["image-frame"], h_align="center", v_align="center" + ) + self.face_image = Image(size=64) + try: + if os.path.exists(current_face): + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size(current_face, 64, 64) + self.face_image.set_from_pixbuf(pixbuf) + else: + self.face_image.set_from_icon_name("user-info", Gtk.IconSize.DIALOG) + except Exception as e: + print(f"Error loading face icon: {e}") + self.face_image.set_from_icon_name("image-missing", Gtk.IconSize.DIALOG) + face_image_container.add(self.face_image) + top_grid.attach(face_image_container, 2, 1, 1, 1) + + browse_btn_container = Box( + orientation="horizontal", h_align="start", v_align="center" + ) + face_btn = Button( + label="Browse...", + tooltip_text="Select a square image for your profile icon", + on_clicked=self.on_select_face_icon, + ) + browse_btn_container.add(face_btn) + top_grid.attach(browse_btn_container, 3, 1, 1, 1) + self.face_status_label = Label(label="", h_align="start") + top_grid.attach(self.face_status_label, 2, 2, 2, 1) + + def _create_layout_options_section(self, vbox): + """Create layout options section""" + layout_header = Label(markup="Layout Options", h_align="start") + vbox.add(layout_header) + layout_grid = Grid( + column_spacing=20, + row_spacing=10, + style="margin-left: 10px; margin-top: 5px;", + ) + vbox.add(layout_grid) + + # Workspace mode + ws_mode_label = Label(label="Workspace Mode", h_align="start", v_align="center") + layout_grid.attach(ws_mode_label, 0, 0, 1, 1) + + ws_mode_combo_container = Box( + orientation="horizontal", h_align="start", v_align="center" + ) + self.ws_mode_combo = Gtk.ComboBoxText() + self.ws_mode_combo.set_tooltip_text("Select how workspaces are displayed") + ws_modes = ["Dots", "Numbers", "Single Button"] + for mode in ws_modes: + self.ws_mode_combo.append_text(mode) + + # Set current mode based on config + current_dots = bind_vars.get("workspace_dots", True) + current_nums = bind_vars.get("workspace_nums", False) + if current_dots: + current_mode = "Dots" + elif current_nums: + current_mode = "Numbers" + else: + current_mode = "Single Button" + + try: + self.ws_mode_combo.set_active(ws_modes.index(current_mode)) + except ValueError: + self.ws_mode_combo.set_active(0) + + self.ws_mode_combo.connect("changed", self.on_ws_mode_changed) + ws_mode_combo_container.add(self.ws_mode_combo) + layout_grid.attach(ws_mode_combo_container, 1, 0, 1, 1) + + # Chinese numerals option + ws_chinese_label = Label( + label="Use Chinese Numerals", h_align="start", v_align="center" + ) + layout_grid.attach(ws_chinese_label, 2, 0, 1, 1) + ws_chinese_switch_container = Box( + orientation="horizontal", h_align="start", v_align="center" + ) + self.ws_chinese_switch = Gtk.Switch( + active=bind_vars.get("workspace_use_chinese_numerals", False), + sensitive=current_nums, # Only enabled when numbers mode is selected + ) + self.ws_chinese_switch.set_tooltip_text( + "Use Chinese numerals (一, 二, 三...) instead of Arabic numbers" + ) + ws_chinese_switch_container.add(self.ws_chinese_switch) + layout_grid.attach(ws_chinese_switch_container, 3, 0, 1, 1) + + # Dock position + position_label = Label(label="Dock Position", h_align="start", v_align="center") + layout_grid.attach(position_label, 0, 1, 1, 1) + position_combo_container = Box( + orientation="horizontal", h_align="start", v_align="center" + ) + self.position_combo = Gtk.ComboBoxText() + self.position_combo.set_tooltip_text("Select the position of the dock") + positions = ["Bottom", "Top", "Left", "Right"] + for pos in positions: + self.position_combo.append_text(pos) + current_position = bind_vars.get("dock_position", "Bottom") + try: + self.position_combo.set_active(positions.index(current_position)) + except ValueError: + self.position_combo.set_active(0) + self.position_combo.connect("changed", self.on_position_changed) + position_combo_container.add(self.position_combo) + layout_grid.attach(position_combo_container, 1, 1, 1, 1) + + # Dock theme + dock_theme_label = Label(label="Dock Theme", h_align="start", v_align="center") + layout_grid.attach(dock_theme_label, 2, 1, 1, 1) + dock_theme_combo_container = Box( + orientation="horizontal", h_align="start", v_align="center" + ) + self.dock_theme_combo = Gtk.ComboBoxText() + self.dock_theme_combo.set_tooltip_text("Select the visual theme for the dock") + for theme in self.themes: + self.dock_theme_combo.append_text(theme) + current_dock_theme = bind_vars.get("dock_theme", "Pills") + try: + self.dock_theme_combo.set_active(self.themes.index(current_dock_theme)) + except ValueError: + self.dock_theme_combo.set_active(0) + dock_theme_combo_container.add(self.dock_theme_combo) + layout_grid.attach(dock_theme_combo_container, 3, 1, 1, 1) + + # Show Dock switch + dock_label = Label(label="Show Dock", h_align="start", v_align="center") + layout_grid.attach(dock_label, 0, 2, 1, 1) + dock_switch_container = Box( + orientation="horizontal", h_align="start", v_align="center" + ) + self.dock_switch = Gtk.Switch(active=bind_vars.get("dock_enabled", True)) + self.dock_switch.connect("notify::active", self.on_dock_enabled_changed) + dock_switch_container.add(self.dock_switch) + layout_grid.attach(dock_switch_container, 1, 2, 1, 1) + + # Show Dock Only on Hover + dock_hover_label = Label( + label="Show Dock Only on Hover", h_align="start", v_align="center" + ) + layout_grid.attach(dock_hover_label, 2, 2, 1, 1) + dock_hover_switch_container = Box( + orientation="horizontal", h_align="start", v_align="center" + ) + self.dock_hover_switch = Gtk.Switch( + active=bind_vars.get("dock_always_occluded", False), + sensitive=self.dock_switch.get_active(), + ) + dock_hover_switch_container.add(self.dock_hover_switch) + layout_grid.attach(dock_hover_switch_container, 3, 2, 1, 1) + + # Auto-hide Dock + dock_auto_hide_label = Label( + label="Auto-hide Dock", h_align="start", v_align="center" + ) + layout_grid.attach(dock_auto_hide_label, 0, 3, 1, 1) + dock_auto_hide_switch_container = Box( + orientation="horizontal", h_align="start", v_align="center" + ) + self.dock_auto_hide_switch = Gtk.Switch( + active=bind_vars.get("dock_auto_hide", False), + sensitive=self.dock_switch.get_active(), + ) + dock_auto_hide_switch_container.add(self.dock_auto_hide_switch) + layout_grid.attach(dock_auto_hide_switch_container, 1, 3, 1, 1) + + # Hide Special Workspace + dock_hide_special_label = Label( + label="Hide Special Workspace", h_align="start", v_align="center" + ) + layout_grid.attach(dock_hide_special_label, 2, 3, 1, 1) + dock_hide_special_switch_container = Box( + orientation="horizontal", h_align="start", v_align="center" + ) + self.dock_hide_special_switch = Gtk.Switch( + active=bind_vars.get("dock_hide_special_workspace", True), + sensitive=self.dock_switch.get_active(), + ) + self.dock_hide_special_switch.set_tooltip_text( + "Hide the dock when on special workspaces (scratchpad)" + ) + dock_hide_special_switch_container.add(self.dock_hide_special_switch) + layout_grid.attach(dock_hide_special_switch_container, 3, 3, 1, 1) + + # Icon size scale + icon_size_label = Label( + label="Taskbar Icon Size", h_align="start", v_align="center" + ) + layout_grid.attach(icon_size_label, 0, 4, 1, 1) + + self.icon_size_scale = Scale( + min_value=20, + max_value=30, + value=bind_vars.get("dock_icon_size", 20), + increments=(2, 4), + draw_value=True, + value_position="right", + digits=0, + h_expand=True, + ) + self.icon_size_scale.set_tooltip_text("Adjust the size of dock icons") + layout_grid.attach(self.icon_size_scale, 1, 4, 3, 1) # Span 3 columns + + # Application switcher items scale (below taskbar icon size) + application_switcher_label = Label( + label="Application Switcher Items", h_align="start", v_align="center" + ) + layout_grid.attach(application_switcher_label, 0, 5, 1, 1) + + self.application_switcher_scale = Scale( + min_value=5, + max_value=20, + value=bind_vars.get("window_switcher_items_per_row", 13), + increments=(2, 4), + draw_value=True, + value_position="right", + digits=0, + h_expand=True, + ) + self.application_switcher_scale.set_tooltip_text( + "Adjust the number of items per row in the application switcher" + ) + layout_grid.attach( + self.application_switcher_scale, 1, 5, 3, 1 + ) # Span 3 columns + + # Hide Special Workspace Apps + dock_hide_special_apps_label = Label( + label="Hide Special Workspace Apps", h_align="start", v_align="center" + ) + layout_grid.attach(dock_hide_special_apps_label, 0, 6, 1, 1) + dock_hide_special_apps_switch_container = Box( + orientation="horizontal", h_align="start", v_align="center" + ) + self.dock_hide_special_apps_switch = Gtk.Switch( + active=bind_vars.get("dock_hide_special_workspace_apps", True), + sensitive=self.dock_switch.get_active(), + ) + self.dock_hide_special_apps_switch.set_tooltip_text( + "Hide applications that are in special workspaces (scratchpad) from the dock" + ) + dock_hide_special_apps_switch_container.add(self.dock_hide_special_apps_switch) + layout_grid.attach(dock_hide_special_apps_switch_container, 1, 6, 1, 1) + + # Notification position + notification_pos_label = Label( + label="Notification Position", h_align="start", v_align="center" + ) + layout_grid.attach(notification_pos_label, 2, 6, 1, 1) + + notification_pos_combo_container = Box( + orientation="horizontal", h_align="start", v_align="center" + ) + + self.notification_pos_combo = Gtk.ComboBoxText() + self.notification_pos_combo.set_tooltip_text( + "Select where notifications appear on the screen." + ) + + notification_positions_list = ["Top", "Bottom"] + for pos in notification_positions_list: + self.notification_pos_combo.append_text(pos) + + current_notif_pos = bind_vars.get(NOTIF_POS_KEY, NOTIF_POS_DEFAULT) + try: + self.notification_pos_combo.set_active( + notification_positions_list.index(current_notif_pos) + ) + except ValueError: + self.notification_pos_combo.set_active(0) + + self.notification_pos_combo.connect( + "changed", self.on_notification_position_changed + ) + + notification_pos_combo_container.add(self.notification_pos_combo) + layout_grid.attach(notification_pos_combo_container, 3, 6, 1, 1) + + def _create_modules_section(self, vbox): + """Create modules section""" + components_header = Label(markup="Modules", h_align="start") + vbox.add(components_header) + components_grid = Grid( + column_spacing=15, + row_spacing=8, + style="margin-left: 10px; margin-top: 5px;", + ) + vbox.add(components_grid) + + self.component_switches = {} + component_display_names = { + "workspace": "Workspaces", + "metrics": "System Metrics", + "date_time": "Date & Time", + "battery": "Battery Indicator", + "controls": "Control Panel", + "indicators": "Indicators", + "music_player": "Music Player", + "applications": "Taskbar", + "language": "Language Indicator", + } + + self.corners_switch = Gtk.Switch(active=bind_vars.get("corners_visible", True)) + num_components = len(component_display_names) + 1 + rows_per_column = (num_components + 1) // 2 + + corners_label = Label( + label="Rounded Corners", h_align="start", v_align="center" + ) + components_grid.attach(corners_label, 0, 0, 1, 1) + switch_container_corners = Box( + orientation="horizontal", h_align="start", v_align="center" + ) + switch_container_corners.add(self.corners_switch) + components_grid.attach(switch_container_corners, 1, 0, 1, 1) + + item_idx = 0 + for name, display in component_display_names.items(): + if item_idx < (rows_per_column - 1): + row = item_idx + 1 + col = 0 + else: + row = item_idx - (rows_per_column - 1) + col = 2 + + component_label = Label(label=display, h_align="start", v_align="center") + components_grid.attach(component_label, col, row, 1, 1) + + switch_container = Box( + orientation="horizontal", h_align="start", v_align="center" + ) + component_switch = Gtk.Switch( + active=bind_vars.get(f"dock_{name}_visible", True) + ) + switch_container.add(component_switch) + components_grid.attach(switch_container, col + 1, row, 1, 1) + self.component_switches[name] = component_switch + item_idx += 1 + + # Event handlers + def on_ws_mode_changed(self, combo): + """Handle workspace mode change""" + mode = combo.get_active_text() + is_numbers_mode = mode == "Numbers" + self.ws_chinese_switch.set_sensitive(is_numbers_mode) + if not is_numbers_mode: + self.ws_chinese_switch.set_active(False) + + def on_position_changed(self, _combo): + """Handle dock position change""" + # Note: centered_switch is not used in appearance tab, + # this method is kept for compatibility + pass + + def on_dock_enabled_changed(self, switch, _gparam): + """Handle dock enabled/disabled change""" + is_active = switch.get_active() + self.dock_hover_switch.set_sensitive(is_active) + self.dock_auto_hide_switch.set_sensitive(is_active) + self.dock_hide_special_switch.set_sensitive(is_active) + self.dock_hide_special_apps_switch.set_sensitive(is_active) + if not is_active: + self.dock_hover_switch.set_active(False) + self.dock_auto_hide_switch.set_active(False) + self.dock_hide_special_switch.set_active(False) + self.dock_hide_special_apps_switch.set_active(False) + + def on_notification_position_changed(self, combo): + """Handle notification position change""" + selected_text = combo.get_active_text() + if selected_text: + bind_vars[NOTIF_POS_KEY] = selected_text + print( + f"Notification position updated in bind_vars: { + bind_vars[NOTIF_POS_KEY] + }" + ) + + def on_select_face_icon(self, _widget): + """Handle face icon selection""" + dialog = Gtk.FileChooserDialog( + title="Select Face Icon", + transient_for=self.parent_window, + action=Gtk.FileChooserAction.OPEN, + ) + dialog.add_buttons( + Gtk.STOCK_CANCEL, + Gtk.ResponseType.CANCEL, + Gtk.STOCK_OPEN, + Gtk.ResponseType.OK, + ) + image_filter = Gtk.FileFilter() + image_filter.set_name("Image files") + for mime in ["image/png", "image/jpeg"]: + image_filter.add_mime_type(mime) + for pattern in ["*.png", "*.jpg", "*.jpeg"]: + image_filter.add_pattern(pattern) + dialog.add_filter(image_filter) + if dialog.run() == Gtk.ResponseType.OK: + self.selected_face_icon = dialog.get_filename() + self.face_status_label.label = ( + f"Selected: {os.path.basename(self.selected_face_icon)}" + ) + try: + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size( + self.selected_face_icon, 64, 64 + ) + self.face_image.set_from_pixbuf(pixbuf) + except Exception as e: + print(f"Error loading selected face icon preview: {e}") + self.face_image.set_from_icon_name("image-missing", Gtk.IconSize.DIALOG) + dialog.destroy() + + def get_appearance_values(self): + """Get current appearance values from widgets""" + values = {} + + values["wallpapers_dir"] = self.wall_dir_chooser.get_filename() + values["dock_position"] = self.position_combo.get_active_text() + values["vertical"] = values["dock_position"] in ["Left", "Right"] + values["dock_enabled"] = self.dock_switch.get_active() + values["dock_auto_hide"] = self.dock_auto_hide_switch.get_active() + values["dock_always_occluded"] = self.dock_hover_switch.get_active() + values["dock_hide_special_workspace"] = ( + self.dock_hide_special_switch.get_active() + ) + values["dock_hide_special_workspace_apps"] = ( + self.dock_hide_special_apps_switch.get_active() + ) + values["dock_icon_size"] = int(self.icon_size_scale.get_value()) + values["window_switcher_items_per_row"] = int( + self.application_switcher_scale.get_value() + ) + values["corners_visible"] = self.corners_switch.get_active() + values["dock_theme"] = self.dock_theme_combo.get_active_text() + + selected_notif_pos_text = self.notification_pos_combo.get_active_text() + if selected_notif_pos_text: + values[NOTIF_POS_KEY] = selected_notif_pos_text + else: + values[NOTIF_POS_KEY] = NOTIF_POS_DEFAULT + + for component_name, switch in self.component_switches.items(): + values[f"dock_{component_name}_visible"] = switch.get_active() + + # Save workspace settings + ws_mode = self.ws_mode_combo.get_active_text() + values["workspace_dots"] = ws_mode == "Dots" + values["workspace_nums"] = ws_mode == "Numbers" + values["workspace_use_chinese_numerals"] = self.ws_chinese_switch.get_active() + + return values + + def get_selected_face_icon(self): + """Get the selected face icon path""" + return self.selected_face_icon + + def clear_selected_face_icon(self): + """Clear the selected face icon""" + self.selected_face_icon = None + self.face_status_label.label = "" + + def reset_to_defaults(self, defaults): + """Reset appearance settings to default values""" + self.wall_dir_chooser.set_filename(defaults.get("wallpapers_dir", "")) + + positions = ["Bottom", "Top", "Left", "Right"] + default_position = defaults.get("dock_position", "Bottom") + try: + self.position_combo.set_active(positions.index(default_position)) + except ValueError: + self.position_combo.set_active(0) + + self.dock_switch.set_active(defaults.get("dock_enabled", True)) + self.dock_auto_hide_switch.set_active(defaults.get("dock_auto_hide", False)) + self.dock_hover_switch.set_active(defaults.get("dock_always_occluded", False)) + self.dock_hide_special_switch.set_active( + defaults.get("dock_hide_special_workspace", True) + ) + self.dock_hide_special_apps_switch.set_active( + defaults.get("dock_hide_special_workspace_apps", True) + ) + + # Update sensitivity based on dock enabled state + dock_enabled = self.dock_switch.get_active() + self.dock_hover_switch.set_sensitive(dock_enabled) + self.dock_auto_hide_switch.set_sensitive(dock_enabled) + self.dock_hide_special_switch.set_sensitive(dock_enabled) + self.dock_hide_special_apps_switch.set_sensitive(dock_enabled) + + default_dock_theme_val = defaults.get("dock_theme", "Pills") + try: + self.dock_theme_combo.set_active(self.themes.index(default_dock_theme_val)) + except ValueError: + self.dock_theme_combo.set_active(0) + + default_notif_pos_val = defaults.get(NOTIF_POS_KEY, NOTIF_POS_DEFAULT) + notification_positions_list = ["Top", "Bottom"] + try: + self.notification_pos_combo.set_active( + notification_positions_list.index(default_notif_pos_val) + ) + except ValueError: + self.notification_pos_combo.set_active(0) + + for name, switch in self.component_switches.items(): + switch.set_active(defaults.get(f"dock_{name}_visible", True)) + + # Reset workspace settings + default_dots = defaults.get("workspace_dots", True) + default_nums = defaults.get("workspace_nums", False) + if default_dots: + default_mode = "Dots" + elif default_nums: + default_mode = "Numbers" + else: + default_mode = "Single Button" + + ws_modes = ["Dots", "Numbers", "Single Button"] + try: + self.ws_mode_combo.set_active(ws_modes.index(default_mode)) + except ValueError: + self.ws_mode_combo.set_active(0) + + self.ws_chinese_switch.set_active( + defaults.get("workspace_use_chinese_numerals", False) + ) + self.ws_chinese_switch.set_sensitive(default_nums) + self.corners_switch.set_active(defaults.get("corners_visible", True)) + + # Reset face icon + self.selected_face_icon = None + self.face_status_label.label = "" + current_face = os.path.expanduser("~/.face.icon") + try: + if os.path.exists(current_face): + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size(current_face, 64, 64) + self.face_image.set_from_pixbuf(pixbuf) + else: + self.face_image.set_from_icon_name("user-info", Gtk.IconSize.DIALOG) + except Exception as e: + print(f"Error loading face icon: {e}") + self.face_image.set_from_icon_name("image-missing", Gtk.IconSize.DIALOG) + + # Reset scales + self.icon_size_scale.set_value(defaults.get("dock_icon_size", 20)) + self.application_switcher_scale.set_value( + defaults.get("window_switcher_items_per_row", 13) + ) diff --git a/config/settings/constants.py b/config/settings/constants.py new file mode 100644 index 0000000..4b59b0f --- /dev/null +++ b/config/settings/constants.py @@ -0,0 +1,79 @@ +from config.data import ( + APP_NAME, + APP_NAME_CAP, +) + +SOURCE_STRING = f""" +# {APP_NAME_CAP} +source = ~/.config/{APP_NAME_CAP}/config/hypr/{APP_NAME}.conf +""" + +DEFAULTS = { + "prefix_restart": "ALT SHIFT", + "suffix_restart": "R", + "prefix_msg": "SUPER", + "suffix_msg": "A", + "prefix_application_switcher": "ALT", + "suffix_application_switcher": "TAB", + "prefix_kanban": "SUPER", + "suffix_kanban": "T", + "prefix_launcher": "SUPER", + "suffix_launcher": "SPACE", + "suffix_app_launcher": "D", + "prefix_app_launcher": "SUPER", + "prefix_cliphist": "SUPER", + "suffix_cliphist": "V", + "prefix_wallpapers": "SUPER", + "suffix_wallpapers": "W", + "prefix_randwall": "ALT SHIFT", + "suffix_randwall": "W", + "prefix_emoji": "SUPER", + "suffix_emoji": "E", + "prefix_power": "SUPER", + "suffix_power": "ESCAPE", + "prefix_caffeine": "SUPER SHIFT", + "suffix_caffeine": "M", + "prefix_restart_inspector": "SUPER CTRL ALT", + "suffix_restart_inspector": "B", + "prefix_settings": "SUPER", + "suffix_settings": "I", + "prefix_dashboard": "SUPER", + "suffix_dashboard": "Z", + "wallpapers_dir": "/home/sankalp/Pictures/wallpapers", + "vertical": True, + "dock_enabled": True, + "dock_always_occluded": False, + "dock_auto_hide": True, + "terminal_command": "kitty -e", + "corners_visible": False, + "dock_theme": "Pills", + "notif_pos": "Top", + "dock_position": "Bottom", + "dock_icon_size": 20, + "dock_workspaces_visible": True, + "dock_metrics_visible": True, + "dock_battery_visible": True, + "dock_date_time_visible": True, + "dock_controls_visible": True, + "dock_indicators_visible": True, + "dock_music_player_visible": True, + "dock_notifications_visible": True, + "dock_applications_visible": True, + "dock_language_visible": True, + "dock_hide_special_workspace": True, + "dock_hide_special_workspace_apps": True, + "workspace_nums": False, + "workspace_use_chinese_numerals": False, + "workspace_dots": False, + "window_switcher_items_per_row": 13, + "metrics_visible": { + "cpu": True, + "ram": True, + "disk": True, + "swap": True, + "gpu": False, + }, + "metrics_disks": ["/"], + "limited_apps_history": ["Spotify"], + "history_ignored_apps": ["Modus"], +} diff --git a/config/settings/gui.py b/config/settings/gui.py new file mode 100644 index 0000000..900eda7 --- /dev/null +++ b/config/settings/gui.py @@ -0,0 +1,341 @@ +import json +import os +import subprocess +import threading +import time + +import gi +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.stack import Stack +from fabric.widgets.window import Window +from gi.repository import Gdk, GdkPixbuf, GLib, Gtk +from PIL import Image + +from config.data import APP_NAME, APP_NAME_CAP +from config.settings.about_tab import AboutTab +from config.settings.appearance_tab import AppearanceTab +from config.settings.key_bindings_tab import KeyBindingsTab +from config.settings.system_tab import SystemTab +from config.settings.utils import backup_and_replace, start_config + +gi.require_version("Gtk", "3.0") + + +class HyprConfGUI(Window): + def __init__(self, show_lock_checkbox: bool, show_idle_checkbox: bool, **kwargs): + super().__init__( + title="Modus Settings", + name="modus-settings-window", + size=(640, 640), + **kwargs, + ) + + self.set_resizable(False) + # Set strict size constraints to prevent dynamic resizing + self.set_size_request(640, 640) + self.set_default_size(640, 640) + # Set geometry hints to enforce fixed size + geometry = Gdk.Geometry() + geometry.min_width = 640 + geometry.max_width = 640 + geometry.min_height = 640 + geometry.max_height = 640 + self.set_geometry_hints( + None, geometry, Gdk.WindowHints.MIN_SIZE | Gdk.WindowHints.MAX_SIZE + ) + + self.themes = ["Pills", "Dense", "Edge"] + self.selected_face_icon = None + self.show_lock_checkbox = show_lock_checkbox + self.show_idle_checkbox = show_idle_checkbox + + # Initialize all tabs + self.key_bindings_tab = KeyBindingsTab() + self.appearance_tab = AppearanceTab(self.themes, self) + self.system_tab = SystemTab( + show_lock_checkbox, show_idle_checkbox, self.enforce_window_size + ) + self.about_tab = AboutTab() + + root_box = Box(orientation="v", spacing=10, style="margin: 10px;") + # Set fixed size for root container + root_box.set_size_request(620, 620) + self.add(root_box) + + main_content_box = Box( + orientation="h", spacing=6, v_expand=False, h_expand=False + ) + # Set fixed size for main content to prevent expansion + main_content_box.set_size_request(620, 580) + root_box.add(main_content_box) + + self.tab_stack = Stack( + transition_type="slide-up-down", + transition_duration=250, + v_expand=False, + h_expand=False, + ) + # Set fixed size for tab stack + self.tab_stack.set_size_request(580, 580) + + self.key_bindings_tab_content = self.key_bindings_tab.create_key_bindings_tab() + self.appearance_tab_content = self.appearance_tab.create_appearance_tab() + self.system_tab_content = self.system_tab.create_system_tab() + self.about_tab_content = self.about_tab.create_about_tab() + + self.tab_stack.add_titled( + self.key_bindings_tab_content, "key_bindings", "Key Bindings" + ) + self.tab_stack.add_titled( + self.appearance_tab_content, "appearance", "Appearance" + ) + self.tab_stack.add_titled(self.system_tab_content, "system", "System") + self.tab_stack.add_titled(self.about_tab_content, "about", "About") + + tab_switcher = Gtk.StackSwitcher() + tab_switcher.set_stack(self.tab_stack) + tab_switcher.set_orientation(Gtk.Orientation.VERTICAL) + # Set fixed width for tab switcher + tab_switcher.set_size_request(40, -1) + main_content_box.add(tab_switcher) + main_content_box.add(self.tab_stack) + + button_box = Box(orientation="h", spacing=10, h_align="end") + # Set fixed height for button box + button_box.set_size_request(-1, 40) + reset_btn = Button(label="Reset to Defaults", on_clicked=self.on_reset) + button_box.add(reset_btn) + close_btn = Button(label="Close", on_clicked=self.on_close) + button_box.add(close_btn) + accept_btn = Button(label="Apply & Reload", on_clicked=self.on_accept) + button_box.add(accept_btn) + root_box.add(button_box) + + # Force window size after all content is added and on any size changes + self.connect("realize", self._force_window_size) + self.connect("size-allocate", self._on_size_allocate) + + def _force_window_size(self, widget): + """Force the window to respect the declared size""" + self.resize(640, 640) + self.set_size_request(640, 640) + + def _on_size_allocate(self, widget, allocation): + """Prevent window from changing size during allocation""" + if allocation.width != 640 or allocation.height != 640: + GLib.idle_add(lambda: self.resize(640, 640)) + + def enforce_window_size(self): + """Public method to enforce window size - can be called by tabs""" + self._force_window_size(self) + + # Event handlers moved to respective tab classes + + def on_accept(self, _widget): + current_bind_vars_snapshot = {} + + # Get values from key bindings tab + current_bind_vars_snapshot.update( + self.key_bindings_tab.get_key_binding_values() + ) + + # Get values from appearance tab + current_bind_vars_snapshot.update(self.appearance_tab.get_appearance_values()) + + # Get values from system tab + current_bind_vars_snapshot.update(self.system_tab.get_system_values()) + + # Get face icon and hyprland switches + selected_icon_path = self.appearance_tab.get_selected_face_icon() + hyprland_switches = self.system_tab.get_hyprland_switches() + replace_lock = hyprland_switches["replace_lock"] + replace_idle = hyprland_switches["replace_idle"] + + if selected_icon_path: + self.appearance_tab.clear_selected_face_icon() + + def _apply_and_reload_task_thread(): + nonlocal current_bind_vars_snapshot + + from . import utils + + utils.bind_vars.clear() + utils.bind_vars.update(current_bind_vars_snapshot) + + start_time = time.time() + print(f"{start_time:.4f}: Background task started.") + + config_json = os.path.expanduser( + f"~/.config/{APP_NAME_CAP}/config/assets/config.json" + ) + os.makedirs(os.path.dirname(config_json), exist_ok=True) + try: + with open(config_json, "w") as f: + json.dump(utils.bind_vars, f, indent=4) + print(f"{time.time():.4f}: Saved config.json.") + except Exception as e: + print(f"Error saving config.json: {e}") + + if selected_icon_path: + print(f"{time.time():.4f}: Processing face icon...") + try: + img = Image.open(selected_icon_path) + side = min(img.size) + left = (img.width - side) // 2 + top = (img.height - side) // 2 + cropped_img = img.crop((left, top, left + side, top + side)) + face_icon_dest = os.path.expanduser("~/.face.icon") + cropped_img.save(face_icon_dest, format="PNG") + print(f"{time.time():.4f}: Face icon saved to {face_icon_dest}") + GLib.idle_add(self._update_face_image_widget, face_icon_dest) + except Exception as e: + print(f"Error processing face icon: {e}") + print(f"{time.time():.4f}: Finished processing face icon.") + + if replace_lock: + print(f"{time.time():.4f}: Replacing hyprlock config...") + src = os.path.expanduser( + f"~/.config/{APP_NAME_CAP}/config/hypr/hyprlock.conf" + ) + dest = os.path.expanduser("~/.config/hypr/hyprlock.conf") + if os.path.exists(src): + backup_and_replace(src, dest, "Hyprlock") + else: + print(f"Warning: Source hyprlock config not found at {src}") + print(f"{time.time():.4f}: Finished replacing hyprlock config.") + + if replace_idle: + print(f"{time.time():.4f}: Replacing hypridle config...") + src = os.path.expanduser( + f"~/.config/{APP_NAME_CAP}/config/hypr/hypridle.conf" + ) + dest = os.path.expanduser("~/.config/hypr/hypridle.conf") + if os.path.exists(src): + backup_and_replace(src, dest, "Hypridle") + else: + print(f"Warning: Source hypridle config not found at {src}") + print(f"{time.time():.4f}: Finished replacing hypridle config.") + + print( + f"{time.time():.4f}: Checking/Appending hyprland.conf source string..." + ) + hypr_path = os.path.expanduser("~/.config/hypr/hyprland.conf") + try: + from .constants import SOURCE_STRING + + needs_append = True + if os.path.exists(hypr_path): + with open(hypr_path, "r") as f: + if SOURCE_STRING.strip() in f.read(): + needs_append = False + else: + os.makedirs(os.path.dirname(hypr_path), exist_ok=True) + + if needs_append: + with open(hypr_path, "a") as f: + f.write("\n" + SOURCE_STRING) + print(f"Appended source string to {hypr_path}") + except Exception as e: + print(f"Error updating {hypr_path}: {e}") + print( + f"{time.time():.4f}: Finished checking/appending hyprland.conf source string." + ) + + print(f"{time.time():.4f}: Running start_config()...") + start_config() + print(f"{time.time():.4f}: Finished start_config().") + + print(f"{time.time():.4f}: Initiating Modus restart using Popen...") + main_py = os.path.expanduser(f"~/.config/{APP_NAME_CAP}/main.py") + kill_cmd = f"killall {APP_NAME}" + start_cmd = ["uwsm", "app", "--", "python", main_py] + try: + kill_proc = subprocess.Popen( + kill_cmd, + shell=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + kill_proc.wait(timeout=2) + print(f"{time.time():.4f}: killall process finished (or timed out).") + except subprocess.TimeoutExpired: + print("Warning: killall command timed out.") + except Exception as e: + print(f"Error running killall: {e}") + + try: + subprocess.Popen( + start_cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + print(f"{APP_NAME_CAP} restart initiated via Popen.") + except FileNotFoundError as e: + print(f"Error restarting {APP_NAME_CAP}: Command not found ({e})") + except Exception as e: + print(f"Error restarting {APP_NAME_CAP} via Popen: {e}") + + print(f"{time.time():.4f}: Modus restart commands issued via Popen.") + end_time = time.time() + total_time = end_time - start_time + print( + f"{end_time:.4f}: Background task finished (Total: {total_time:.4f}s)." + ) + + thread = threading.Thread(target=_apply_and_reload_task_thread) + thread.daemon = True + thread.start() + print("Configuration apply/reload task started in background.") + + def _update_face_image_widget(self, icon_path): + try: + if self.face_image and self.face_image.get_window(): + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size(icon_path, 64, 64) + self.face_image.set_from_pixbuf(pixbuf) + except Exception as e: + print(f"Error reloading face icon preview: {e}") + if self.face_image and self.face_image.get_window(): + self.face_image.set_from_icon_name("image-missing", Gtk.IconSize.DIALOG) + return GLib.SOURCE_REMOVE + + def on_reset(self, _widget): + dialog = Gtk.MessageDialog( + transient_for=self.get_toplevel(), + flags=0, + message_type=Gtk.MessageType.QUESTION, + buttons=Gtk.ButtonsType.YES_NO, + text="Reset all settings to defaults?", + ) + dialog.format_secondary_text( + "This will reset all keybindings and appearance settings to their default values." + ) + response = dialog.run() + dialog.hide() # Hide the dialog first + dialog.destroy() # Then destroy it + + # Process any pending events + while Gtk.events_pending(): + Gtk.main_iteration() + + if response == Gtk.ResponseType.YES: + from . import utils + from .constants import DEFAULTS + + utils.bind_vars.clear() + utils.bind_vars.update(DEFAULTS.copy()) + + # Reset all tabs to defaults + self.key_bindings_tab.reset_to_defaults(utils.bind_vars) + self.appearance_tab.reset_to_defaults(utils.bind_vars) + self.system_tab.reset_to_defaults(utils.bind_vars) + + self._update_panel_position_sensitivity() + print("Settings reset to defaults.") + + def on_close(self, _widget): + if self.application: + self.application.quit() + else: + self.destroy() diff --git a/config/settings/key_bindings_tab.py b/config/settings/key_bindings_tab.py new file mode 100644 index 0000000..896cd0a --- /dev/null +++ b/config/settings/key_bindings_tab.py @@ -0,0 +1,107 @@ +from fabric.widgets.box import Box +from fabric.widgets.entry import Entry +from fabric.widgets.grid import Grid +from fabric.widgets.label import Label +from fabric.widgets.scrolledwindow import ScrolledWindow + +from config.data import APP_NAME_CAP +from config.settings.utils import bind_vars + + +class KeyBindingsTab: + """Key bindings management tab for settings""" + + def __init__(self): + self.entries = [] + + def create_key_bindings_tab(self): + """Create the Key Bindings tab content""" + scrolled_window = ScrolledWindow( + h_scrollbar_policy="never", + v_scrollbar_policy="automatic", + h_expand=False, + v_expand=False, + propagate_width=False, + propagate_height=False, + ) + # Set fixed size to match tab stack dimensions + scrolled_window.set_size_request(580, 580) + + main_vbox = Box(orientation="v", spacing=10, style="margin: 15px;") + scrolled_window.add(main_vbox) + + keybind_grid = Grid(column_spacing=10, row_spacing=8, style="margin: 5px;") + + action_label = Label( + markup="Action", h_align="start", style="margin-bottom: 5px;" + ) + modifier_label = Label( + markup="Modifier", h_align="start", style="margin-bottom: 5px;" + ) + separator_label = Label( + label="+", h_align="center", style="margin-bottom: 5px;" + ) + key_label = Label( + markup="Key", h_align="start", style="margin-bottom: 5px;" + ) + + keybind_grid.attach(action_label, 0, 0, 1, 1) + keybind_grid.attach(modifier_label, 1, 0, 1, 1) + keybind_grid.attach(separator_label, 2, 0, 1, 1) + keybind_grid.attach(key_label, 3, 0, 1, 1) + + self.entries = [] + bindings = [ + (f"Reload {APP_NAME_CAP}", "prefix_restart", "suffix_restart"), + ("Message", "prefix_msg", "suffix_msg"), + ( + "Application Switcher", + "prefix_application_switcher", + "suffix_application_switcher", + ), + ("Launcher", "prefix_launcher", "suffix_launcher"), + ("App Launcher", "prefix_app_launcher", "suffix_app_launcher"), + ("Clipboard History", "prefix_cliphist", "suffix_cliphist"), + ("Wallpapers", "prefix_wallpapers", "suffix_wallpapers"), + ("Random Wallpaper", "prefix_randwall", "suffix_randwall"), + ("Emoji Picker", "prefix_emoji", "suffix_emoji"), + ("Kanban", "prefix_kanban", "suffix_kanban"), + ("Power Menu", "prefix_power", "suffix_power"), + ("Toggle Caffeine", "prefix_caffeine", "suffix_caffeine"), + ("Dashboard", "prefix_dashboard", "suffix_dashboard"), + ("Settings", "prefix_settings", "suffix_settings"), + ( + "Restart with inspector", + "prefix_restart_inspector", + "suffix_restart_inspector", + ), + ] + + for i, (label_text, prefix_key, suffix_key) in enumerate(bindings): + row = i + 1 + binding_label = Label(label=label_text, h_align="start") + keybind_grid.attach(binding_label, 0, row, 1, 1) + prefix_entry = Entry(text=bind_vars.get(prefix_key, "")) + keybind_grid.attach(prefix_entry, 1, row, 1, 1) + plus_label = Label(label="+", h_align="center") + keybind_grid.attach(plus_label, 2, row, 1, 1) + suffix_entry = Entry(text=bind_vars.get(suffix_key, "")) + keybind_grid.attach(suffix_entry, 3, row, 1, 1) + self.entries.append((prefix_key, suffix_key, prefix_entry, suffix_entry)) + + main_vbox.add(keybind_grid) + return scrolled_window + + def get_key_binding_values(self): + """Get current key binding values from entries""" + values = {} + for prefix_key, suffix_key, prefix_entry, suffix_entry in self.entries: + values[prefix_key] = prefix_entry.get_text() + values[suffix_key] = suffix_entry.get_text() + return values + + def reset_to_defaults(self, defaults): + """Reset key bindings to default values""" + for prefix_key, suffix_key, prefix_entry, suffix_entry in self.entries: + prefix_entry.set_text(defaults.get(prefix_key, "")) + suffix_entry.set_text(defaults.get(suffix_key, "")) diff --git a/config/settings/system_tab.py b/config/settings/system_tab.py new file mode 100644 index 0000000..ddaf3de --- /dev/null +++ b/config/settings/system_tab.py @@ -0,0 +1,382 @@ +import gi +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.entry import Entry +from fabric.widgets.grid import Grid +from fabric.widgets.label import Label +from fabric.widgets.scrolledwindow import ScrolledWindow +from gi.repository import GLib, Gtk + +from config.settings.utils import bind_vars + +gi.require_version("Gtk", "3.0") + + +class SystemTab: + """System settings and metrics management tab for settings""" + + def __init__( + self, + show_lock_checkbox=False, + show_idle_checkbox=False, + window_size_enforcer=None, + ): + self.show_lock_checkbox = show_lock_checkbox + self.show_idle_checkbox = show_idle_checkbox + self.window_size_enforcer = window_size_enforcer + + # Widget references + self.terminal_entry = None + self.lock_switch = None + self.idle_switch = None + self.limited_apps_entry = None + self.ignored_apps_entry = None + self.metrics_switches = {} + self.disk_entries = None + self._create_disk_edit_entry_func = None + + def create_system_tab(self): + """Create the System tab content""" + scrolled_window = ScrolledWindow( + h_scrollbar_policy="never", + v_scrollbar_policy="automatic", + h_expand=False, + v_expand=False, + propagate_width=False, + propagate_height=False, + ) + # Set fixed size to match tab stack dimensions + scrolled_window.set_size_request(580, 580) + + vbox = Box(orientation="v", spacing=15, style="margin: 15px;") + scrolled_window.add(vbox) + + # Create terminal and hyprland settings section + self._create_terminal_hyprland_section(vbox) + + # Create notification settings section + self._create_notification_settings_section(vbox) + + # Create system metrics section + self._create_system_metrics_section(vbox) + + return scrolled_window + + def _create_terminal_hyprland_section(self, vbox): + """Create terminal and Hyprland integration section""" + system_grid = Grid( + column_spacing=20, row_spacing=10, style="margin-bottom: 15px;" + ) + vbox.add(system_grid) + + # Terminal settings + terminal_header = Label(markup="Terminal Settings", h_align="start") + system_grid.attach(terminal_header, 0, 0, 2, 1) + terminal_label = Label(label="Command:", h_align="start", v_align="center") + system_grid.attach(terminal_label, 0, 1, 1, 1) + self.terminal_entry = Entry( + text=bind_vars.get("terminal_command", "kitty -e"), + tooltip_text="Command used to launch terminal apps (e.g., 'kitty -e')", + h_expand=True, + ) + system_grid.attach(self.terminal_entry, 1, 1, 1, 1) + hint_label = Label( + markup="Examples: 'kitty -e', 'alacritty -e', 'foot -e'", + h_align="start", + ) + system_grid.attach(hint_label, 0, 2, 2, 1) + + # Hyprland integration + hypr_header = Label(markup="Hyprland Integration", h_align="start") + system_grid.attach(hypr_header, 2, 0, 2, 1) + row = 1 + self.lock_switch = None + if self.show_lock_checkbox: + lock_label = Label( + label="Replace Hyprlock config", h_align="start", v_align="center" + ) + system_grid.attach(lock_label, 2, row, 1, 1) + lock_switch_container = Box( + orientation="horizontal", h_align="start", v_align="center" + ) + self.lock_switch = Gtk.Switch( + tooltip_text="Replace Hyprlock configuration with Modus's custom config" + ) + lock_switch_container.add(self.lock_switch) + system_grid.attach(lock_switch_container, 3, row, 1, 1) + row += 1 + self.idle_switch = None + if self.show_idle_checkbox: + idle_label = Label( + label="Replace Hypridle config", h_align="start", v_align="center" + ) + system_grid.attach(idle_label, 2, row, 1, 1) + idle_switch_container = Box( + orientation="horizontal", h_align="start", v_align="center" + ) + self.idle_switch = Gtk.Switch( + tooltip_text="Replace Hypridle configuration with Modus's custom config" + ) + idle_switch_container.add(self.idle_switch) + system_grid.attach(idle_switch_container, 3, row, 1, 1) + row += 1 + if self.show_lock_checkbox or self.show_idle_checkbox: + note_label = Label( + markup="Existing configs will be backed up", + h_align="start", + ) + system_grid.attach(note_label, 2, row, 2, 1) + + def _create_notification_settings_section(self, vbox): + """Create notification settings section""" + notifications_header = Label( + markup="Notification Settings", h_align="start" + ) + vbox.add(notifications_header) + + notif_grid = Grid( + column_spacing=20, + row_spacing=10, + style="margin-left: 10px; margin-top: 5px; margin-bottom: 15px;", + ) + vbox.add(notif_grid) + + # Limited Apps History + limited_apps_label = Label( + label="Limited Apps History:", h_align="start", v_align="center" + ) + notif_grid.attach(limited_apps_label, 0, 0, 1, 1) + + limited_apps_list = bind_vars.get("limited_apps_history", ["Spotify"]) + limited_apps_text = ", ".join(f'"{app}"' for app in limited_apps_list) + self.limited_apps_entry = Entry( + text=limited_apps_text, + tooltip_text='Enter app names separated by commas, e.g: "Spotify", "Discord"', + h_expand=True, + ) + notif_grid.attach(self.limited_apps_entry, 1, 0, 1, 1) + + limited_apps_hint = Label( + markup='Apps with limited notification history (format: "App1", "App2")', + h_align="start", + ) + notif_grid.attach(limited_apps_hint, 0, 1, 2, 1) + + # History Ignored Apps + ignored_apps_label = Label( + label="History Ignored Apps:", h_align="start", v_align="center" + ) + notif_grid.attach(ignored_apps_label, 0, 2, 1, 1) + + ignored_apps_list = bind_vars.get("history_ignored_apps", ["Modus"]) + ignored_apps_text = ", ".join(f'"{app}"' for app in ignored_apps_list) + self.ignored_apps_entry = Entry( + text=ignored_apps_text, + tooltip_text='Enter app names separated by commas, e.g: "Modus", "Screenshot"', + h_expand=True, + ) + notif_grid.attach(self.ignored_apps_entry, 1, 2, 1, 1) + + ignored_apps_hint = Label( + markup='Apps whose notifications are ignored in history (format: "App1", "App2")', + h_align="start", + ) + notif_grid.attach(ignored_apps_hint, 0, 3, 2, 1) + + def _create_system_metrics_section(self, vbox): + """Create system metrics section""" + metrics_header = Label(markup="System Metrics Options", h_align="start") + vbox.add(metrics_header) + metrics_grid = Grid( + column_spacing=15, + row_spacing=8, + style="margin-left: 10px; margin-top: 5px;", + ) + vbox.add(metrics_grid) + + self.metrics_switches = {} + metric_names = { + "cpu": "CPU", + "ram": "RAM", + "swap": "Swap", + "disk": "Disk", + "gpu": "GPU", + } + + metrics_grid.attach(Label(label="Show in Metrics", h_align="start"), 0, 0, 1, 1) + for i, (key, label_text) in enumerate(metric_names.items()): + switch = Gtk.Switch( + active=bind_vars.get("metrics_visible", {}).get(key, True) + ) + self.metrics_switches[key] = switch + metrics_grid.attach( + Label(label=label_text, h_align="start"), 0, i + 1, 1, 1 + ) + metrics_grid.attach(switch, 1, i + 1, 1, 1) + + def enforce_minimum_metrics(switch_dict): + enabled_switches = [s for s in switch_dict.values() if s.get_active()] + can_disable = len(enabled_switches) > 1 + for s in switch_dict.values(): + s.set_sensitive(True if can_disable or not s.get_active() else False) + + def on_metric_toggle(_switch, _gparam, switch_dict): + enforce_minimum_metrics(switch_dict) + + for s_s in self.metrics_switches.values(): + s_s.connect("notify::active", on_metric_toggle, self.metrics_switches) + enforce_minimum_metrics(self.metrics_switches) + + # Disk directories section + disks_label = Label( + label="Disk directories for Metrics", h_align="start", v_align="center" + ) + vbox.add(disks_label) + + # Create a scrolled container for disk entries to prevent overflow + disk_entries_scrolled = ScrolledWindow( + h_scrollbar_policy="never", + v_scrollbar_policy="automatic", + h_expand=False, + v_expand=False, + propagate_width=False, + propagate_height=False, + ) + # Set fixed size for disk entries container + disk_entries_scrolled.set_size_request(550, 120) + + self.disk_entries = Box(orientation="v", spacing=8, h_align="start") + # Set size constraints for the disk entries box + self.disk_entries.set_size_request(530, -1) + disk_entries_scrolled.add(self.disk_entries) + + self._create_disk_edit_entry_func = lambda path: self._add_disk_entry_widget( + path + ) + + for p in bind_vars.get("metrics_disks", ["/"]): + self._create_disk_edit_entry_func(p) + vbox.add(disk_entries_scrolled) + + add_container = Box(orientation="horizontal", h_align="start", v_align="center") + add_btn = Button( + label="Add new disk", + on_clicked=lambda _: self._create_disk_edit_entry_func("/"), + ) + add_container.add(add_btn) + vbox.add(add_container) + + def _add_disk_entry_widget(self, path): + """Helper to add a disk entry row to the disk_entries Box.""" + bar = Box(orientation="h", spacing=10, h_align="start") + # Set fixed height for disk entry rows + bar.set_size_request(-1, 30) + entry = Entry(text=path, h_expand=True) + bar.add(entry) + x_btn = Button(label="X") + x_btn.connect( + "clicked", + lambda _, current_bar_to_remove=bar: self._remove_disk_entry( + current_bar_to_remove + ), + ) + bar.add(x_btn) + self.disk_entries.add(bar) + self.disk_entries.show_all() + # Enforce window size after adding content + if self.window_size_enforcer: + GLib.idle_add(self.window_size_enforcer) + + def _remove_disk_entry(self, bar_to_remove): + """Helper to remove a disk entry and enforce window size""" + self.disk_entries.remove(bar_to_remove) + # Enforce window size after removing content + if self.window_size_enforcer: + GLib.idle_add(self.window_size_enforcer) + + def get_system_values(self): + """Get current system values from widgets""" + values = {} + + values["terminal_command"] = self.terminal_entry.get_text() + + values["metrics_visible"] = { + k: s.get_active() for k, s in self.metrics_switches.items() + } + values["metrics_disks"] = [ + child.get_children()[0].get_text() + for child in self.disk_entries.get_children() + if isinstance(child, Box) + and child.get_children() + and isinstance(child.get_children()[0], Entry) + ] + + # Parse notification app lists + def parse_app_list(text): + """Parse comma-separated app names with quotes""" + if not text.strip(): + return [] + apps = [] + for app in text.split(","): + app = app.strip() + if app.startswith('"') and app.endswith('"'): + app = app[1:-1] + elif app.startswith("'") and app.endswith("'"): + app = app[1:-1] + if app: + apps.append(app) + return apps + + values["limited_apps_history"] = parse_app_list( + self.limited_apps_entry.get_text() + ) + values["history_ignored_apps"] = parse_app_list( + self.ignored_apps_entry.get_text() + ) + + return values + + def get_hyprland_switches(self): + """Get the state of Hyprland integration switches""" + return { + "replace_lock": self.lock_switch and self.lock_switch.get_active(), + "replace_idle": self.idle_switch and self.idle_switch.get_active(), + } + + def reset_to_defaults(self, defaults): + """Reset system settings to default values""" + self.terminal_entry.set_text(defaults.get("terminal_command", "kitty -e")) + + metrics_vis_defaults = defaults.get("metrics_visible", {}) + for k, s_widget in self.metrics_switches.items(): + s_widget.set_active(metrics_vis_defaults.get(k, True)) + + def enforce_minimum_metrics(switch_dict): + enabled_switches = [s for s in switch_dict.values() if s.get_active()] + can_disable = len(enabled_switches) > 1 + for s in switch_dict.values(): + s.set_sensitive(True if can_disable or not s.get_active() else False) + + enforce_minimum_metrics(self.metrics_switches) + + # Reset disk entries + for child in list(self.disk_entries.get_children()): + self.disk_entries.remove(child) + + for p in defaults.get("metrics_disks", ["/"]): + self._create_disk_edit_entry_func(p) + + # Reset notification app lists + limited_apps_list = defaults.get("limited_apps_history", ["Spotify"]) + limited_apps_text = ", ".join(f'"{app}"' for app in limited_apps_list) + self.limited_apps_entry.set_text(limited_apps_text) + + ignored_apps_list = defaults.get("history_ignored_apps", ["Modus"]) + ignored_apps_text = ", ".join(f'"{app}"' for app in ignored_apps_list) + self.ignored_apps_entry.set_text(ignored_apps_text) + + # Reset Hyprland switches + if self.lock_switch: + self.lock_switch.set_active(False) + if self.idle_switch: + self.idle_switch.set_active(False) diff --git a/config/settings/utils.py b/config/settings/utils.py new file mode 100644 index 0000000..e73fa9c --- /dev/null +++ b/config/settings/utils.py @@ -0,0 +1,416 @@ +import json +import os +import shutil +import subprocess +import time + +import gi +import toml +from fabric.utils.helpers import exec_shell_command_async + +from config.data import ( + APP_NAME, + APP_NAME_CAP, +) +from config.settings import constants as settings_constants + +gi.require_version("Gtk", "3.0") + + +# Global variable to store binding variables, managed by this module +bind_vars = {} + + +def deep_update(target: dict, update: dict) -> dict: + """ + Recursively update a nested dictionary with values from another dictionary. + Modifies target in-place. + """ + for key, value in update.items(): + if isinstance(value, dict) and key in target and isinstance(target[key], dict): + # If the value is a dictionary and the key already exists in target as a dictionary, + # then update recursively. + deep_update(target[key], value) + else: + # Otherwise, simply set/overwrite the value. + target[key] = value + return target # Although it modifies in-place, returning it is a common convention + + +def ensure_matugen_config(): + """ + Ensure that the matugen configuration file exists and is updated + with the expected settings. + """ + expected_config = { + "config": { + "reload_apps": True, + "wallpaper": { + "command": "swww", + "arguments": [ + "img", + "-t", + "outer", + "--transition-duration", + "1.5", + "--transition-step", + "255", + "--transition-fps", + "60", + "-f", + "Nearest", + ], + "set": True, + }, + "custom_colors": { + "red": {"color": "#FF0000", "blend": True}, + "green": {"color": "#00FF00", "blend": True}, + "yellow": {"color": "#FFFF00", "blend": True}, + "blue": {"color": "#0000FF", "blend": True}, + "magenta": {"color": "#FF00FF", "blend": True}, + "cyan": {"color": "#00FFFF", "blend": True}, + "white": {"color": "#FFFFFF", "blend": True}, + }, + }, + "templates": { + "hyprland": { + "input_path": f"~/.config/{APP_NAME_CAP}/config/matugen/templates/hyprland-colors.conf", + "output_path": f"~/.config/{APP_NAME_CAP}/config/hypr/colors.conf", + }, + f"{APP_NAME}": { + "input_path": f"~/.config/{APP_NAME_CAP}/config/matugen/templates/{APP_NAME}.css", + "output_path": f"~/.config/{APP_NAME_CAP}/styles/colors.css", + "post_hook": f"fabric-cli exec {APP_NAME} 'app.set_css()' &", + }, + }, + } + + config_path = os.path.expanduser("~/.config/matugen/config.toml") + os.makedirs(os.path.dirname(config_path), exist_ok=True) + + existing_config = {} + if os.path.exists(config_path): + try: + with open(config_path, "r") as f: + existing_config = toml.load(f) + shutil.copyfile(config_path, config_path + ".bak") + except toml.TomlDecodeError: + print( + f"Warning: Could not decode TOML from { + config_path + }. A new default config will be created." + ) + existing_config = {} # Reset if corrupted + except Exception as e: + print(f"Error reading or backing up {config_path}: {e}") + # existing_config could be partially loaded or empty. + # Continue to try merging with defaults. + + # We use a copy of existing_config for deep_update if we don't want to modify it directly + # or ensure that deep_update doesn't do it if not desired. + # The current implementation of deep_update modifies 'target'. + # To be safer, we can pass a copy if existing_config should not change. + # merged_config = deep_update(existing_config.copy(), expected_config) + # Or if existing_config can be modified: + # existing_config is modified in-place + merged_config = deep_update(existing_config, expected_config) + + try: + with open(config_path, "w") as f: + toml.dump(merged_config, f) + except Exception as e: + print(f"Error writing matugen config to {config_path}: {e}") + + current_wall = os.path.expanduser("~/.current.wall") + hypr_colors = os.path.expanduser( + f"~/.config/{APP_NAME_CAP}/config/hypr/colors.conf" + ) + css_colors = os.path.expanduser(f"~/.config/{APP_NAME_CAP}/styles/colors.css") + + if ( + not os.path.exists(current_wall) + or not os.path.exists(hypr_colors) + or not os.path.exists(css_colors) + ): + os.makedirs(os.path.dirname(hypr_colors), exist_ok=True) + os.makedirs(os.path.dirname(css_colors), exist_ok=True) + + image_path = "" + if not os.path.exists(current_wall): + example_wallpaper_path = os.path.expanduser( + f"~/.config/{APP_NAME_CAP}/assets/wallpapers_example/example-1.jpg" + ) + if os.path.exists(example_wallpaper_path): + try: + # If it already exists (possibly a broken link or regular file), remove and re-link + # lexists to not follow the link if it is one + if os.path.lexists(current_wall): + os.remove(current_wall) + os.symlink(example_wallpaper_path, current_wall) + image_path = example_wallpaper_path + except Exception as e: + print(f"Error creating symlink for wallpaper: {e}") + else: + image_path = ( + os.path.realpath(current_wall) + if os.path.islink(current_wall) + else current_wall + ) + + if image_path and os.path.exists(image_path): + print(f"Generating color theme from wallpaper: {image_path}") + try: + matugen_cmd = f"matugen image '{image_path}'" + exec_shell_command_async(matugen_cmd) + print("Matugen color theme generation initiated.") + except FileNotFoundError: + print("Error: matugen command not found. Please install matugen.") + except Exception as e: + print(f"Error initiating matugen: {e}") + elif not image_path: + print( + "Warning: No wallpaper path determined to generate matugen theme from." + ) + else: # image_path exists but the file doesn't + print( + f"Warning: Wallpaper at { + image_path + } not found. Cannot generate matugen theme." + ) + + +def load_bind_vars(): + """ + Load saved key binding variables from JSON, if available. + Populates the global `bind_vars` in-place. + """ + global bind_vars # Necessary to modify the global bind_vars object + + # 1. Clear the existing bind_vars dictionary. + bind_vars.clear() + # 2. Update it with a copy of DEFAULTS. + # Use .copy() to not accidentally modify DEFAULTS + bind_vars.update(settings_constants.DEFAULTS.copy()) + + config_json = os.path.expanduser( + f"~/.config/{APP_NAME_CAP}/config/assets/config.json" + ) + if os.path.exists(config_json): + try: + with open(config_json, "r") as f: + saved_vars = json.load(f) + # 3. Use deep_update to merge saved_vars into the existing bind_vars. + deep_update(bind_vars, saved_vars) + + # The logic to ensure the structure of nested dictionaries + # like 'metrics_visible' and 'metrics_small_visible' + # should operate on the already updated 'bind_vars'. + for vis_key in ["metrics_visible"]: + # Ensure that the key exists in DEFAULTS as a structure reference + if vis_key in settings_constants.DEFAULTS: + default_sub_dict = settings_constants.DEFAULTS[vis_key] + # If the key is not in bind_vars or is not a dictionary after deep_update, + # restore it from a copy of DEFAULTS for that key. + if not isinstance(bind_vars.get(vis_key), dict): + bind_vars[vis_key] = default_sub_dict.copy() + else: + # If it is a dictionary, ensure that all sub-keys from DEFAULTS are present. + current_sub_dict = bind_vars[vis_key] + for m_key, m_val in default_sub_dict.items(): + if m_key not in current_sub_dict: + current_sub_dict[m_key] = m_val + except json.JSONDecodeError: + print( + f"Warning: Could not decode JSON from { + config_json + }. Using defaults (already initialized)." + ) + # bind_vars is already populated with DEFAULTS, no additional action needed here. + except Exception as e: + print( + f"Error loading config from {config_json}: { + e + }. Using defaults (already initialized)." + ) + # bind_vars is already populated with DEFAULTS. + # else: + # If config_json doesn't exist, bind_vars is already populated with DEFAULTS. + # print(f"Config file {config_json} not found. Using defaults (already initialized).") + + +def generate_hyprconf() -> str: + """ + Generate the Hypr configuration string using the current bind_vars. + """ + home = os.path.expanduser("~") + # Determine animation type based on bar position + dock_position = bind_vars.get("dock_position", "Bottom") + is_vertical = dock_position in ["Left", "Right"] + animation_type = "slidefadevert" if is_vertical else "slidefade" + + return f"""exec-once = uwsm-app $(python {home}/.config/{APP_NAME_CAP}/main.py) +exec = pgrep -x "hypridle" > /dev/null || uwsm app -- hypridle +exec = uwsm app -- swww-daemon +exec-once = wl-paste --type text --watch cliphist store +exec-once = wl-paste --type image --watch cliphist store + +$fabricSend = fabric-cli exec {APP_NAME} +$Message = notify-send "Modus" "FIRE IN THE HOLE‼️🗣️🔥🕳️" -i "{home}/.config/{APP_NAME_CAP}/assets/modus.png" -A "🗣️" -A "🔥" -A "🕳️" -a "Source Code" + +# Reload {APP_NAME_CAP} +bind = {bind_vars.get("prefix_restart", "ALT SHIFT")}, {bind_vars.get("suffix_restart", "R")}, exec, killall {APP_NAME}; uwsm-app $(python {home}/.config/{APP_NAME_CAP}/main.py) +# Message +bind = {bind_vars.get("prefix_msg", "SUPER")}, {bind_vars.get("suffix_msg", "A")}, exec, $Message +# Application Switcher +bind = {bind_vars.get("prefix_application_switcher", "ALT")}, {bind_vars.get("suffix_application_switcher", "TAB")}, exec, $fabricSend 'switcher.show_switcher()' +# Kanban +bind = {bind_vars.get("prefix_kanban", "SUPER")}, {bind_vars.get("suffix_kanban", "T")}, exec, $fabricSend "launcher.show_launcher('kanban')" # Kanban +# App Launcher +bind = {bind_vars.get("prefix_launcher", "SUPER")}, {bind_vars.get("suffix_launcher", "SPACE")}, exec, $fabricSend "launcher.show_launcher()" +bind = {bind_vars.get("prefix_app_launcher", "SUPER")}, {bind_vars.get("suffix_app_launcher", "D")}, exec, $fabricSend "launcher.show_launcher('app')" +# Clipboard History +bind = {bind_vars.get("prefix_cliphist", "SUPER")}, {bind_vars.get("suffix_cliphist", "V")}, exec, $fabricSend "launcher.show_launcher('clip')" +# Wallpapers +bind = {bind_vars.get("prefix_wallpapers", "SUPER")}, {bind_vars.get("suffix_wallpapers", "W")}, exec, $fabricSend "launcher.show_launcher('wall')" +# Random Wallpaper +bind = {bind_vars.get("prefix_randwall", "SUPER")}, {bind_vars.get("suffix_randwall", "W")}, exec, $fabricSend "launcher.show_launcher('wall random', external=True)" +# Emoji Picker +bind = {bind_vars.get("prefix_emoji", "SUPER")}, {bind_vars.get("suffix_emoji", "E")}, exec, $fabricSend "launcher.show_launcher('em')" +# Power Menu +bind = {bind_vars.get("prefix_power", "SUPER")}, {bind_vars.get("suffix_power", "ESCAPE")}, exec, $fabricSend "launcher.show_launcher('power')" +# Toggle Caffeine +bind = {bind_vars.get("prefix_caffeine", "SUPER SHIFT")}, {bind_vars.get("suffix_caffeine", "M")}, exec, $fabricSend "launcher.show_launcher('caffeine on', external=True)" +# Settings +bind = {bind_vars.get("prefix_settings", "SUPER")}, {bind_vars.get("suffix_settings", "I")}, exec, uwsm-app $(python {home}/.config/{APP_NAME_CAP}/config/config.py) # Settings +# Dashboard +bind = {bind_vars.get("prefix_dashboard", "SUPER")}, {bind_vars.get("suffix_dashboard", "Z")}, exec, $fabricSend "dashboard.toggle_dashboard()" +# Restart Modus +bind = {bind_vars.get("prefix_restart_inspector", "SUPER CTRL ALT")}, {bind_vars.get("suffix_restart_inspector", "B")}, exec, killall {APP_NAME}; uwsm-app $(GTK_DEBUG=interactive python {home}/.config/{APP_NAME_CAP}/main.py) # Restart with inspector + +# Wallpapers directory: {bind_vars.get("wallpapers_dir", "~/Modus/assets/wallpapers_example")} + +source = {home}/.config/{APP_NAME_CAP}/config/hypr/colors.conf + +layerrule = noanim, fabric + +exec = cp $wallpaper ~/.current.wall + +general {{ + col.active_border = rgb($primary) + col.inactive_border = rgb($surface) + gaps_in = 2 + gaps_out = 4 + border_size = 2 + layout = dwindle +}} + +cursor {{ + no_warps=true +}} + +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 = yes + bezier = myBezier, 0.4, 0.0, 0.2, 1.0 + animation = windows, 1, 2.5, myBezier, popin 80% + animation = border, 1, 2.5, myBezier + animation = fade, 1, 2.5, myBezier + animation = workspaces, 1, 2.5, myBezier, {animation_type} 20% +}} +""" + + +def ensure_face_icon(): + """ + Ensure the face icon exists. If not, copy the default icon. + """ + face_icon_path = os.path.expanduser("~/.face.icon") + default_icon_path = os.path.expanduser( + f"~/.config/{APP_NAME_CAP}/assets/default.png" + ) + if not os.path.exists(face_icon_path) and os.path.exists(default_icon_path): + try: + shutil.copy(default_icon_path, face_icon_path) + except Exception as e: + print(f"Error copying default face icon: {e}") + + +def backup_and_replace(src: str, dest: str, config_name: str): + """ + Backup the existing configuration file and replace it with a new one. + """ + try: + if os.path.exists(dest): + backup_path = dest + ".bak" + # Ensure that the backup directory exists if it's different + # os.makedirs(os.path.dirname(backup_path), exist_ok=True) + shutil.copy(dest, backup_path) + print(f"{config_name} config backed up to {backup_path}") + # Ensure dest directory exists + os.makedirs(os.path.dirname(dest), exist_ok=True) + shutil.copy(src, dest) + print(f"{config_name} config replaced from {src}") + except Exception as e: + print(f"Error backing up/replacing {config_name} config: {e}") + + +def start_config(): + """ + Run final configuration steps: ensure necessary configs, write the hyprconf, and reload. + """ + print(f"{time.time():.4f}: start_config: Ensuring matugen config...") + ensure_matugen_config() + print(f"{time.time():.4f}: start_config: Ensuring face icon...") + ensure_face_icon() + print(f"{time.time():.4f}: start_config: Generating hypr conf...") + + hypr_config_dir = os.path.expanduser(f"~/.config/{APP_NAME_CAP}/config/hypr/") + os.makedirs(hypr_config_dir, exist_ok=True) + # Use APP_NAME for the .conf file name to match the corrected SOURCE_STRING + hypr_conf_path = os.path.join(hypr_config_dir, f"{APP_NAME}.conf") + try: + with open(hypr_conf_path, "w") as f: + f.write(generate_hyprconf()) + print(f"Generated Hyprland config at {hypr_conf_path}") + except Exception as e: + print(f"Error writing Hyprland config: {e}") + print(f"{time.time():.4f}: start_config: Finished generating hypr conf.") + + print(f"{time.time():.4f}: start_config: Initiating hyprctl reload...") + try: + # subprocess.run(["hyprctl", "reload"], check=True, capture_output=True, text=True) + # Keep async to not block + exec_shell_command_async("hyprctl reload") + print( + f"{time.time():.4f}: start_config: Hyprland configuration reload initiated." + ) + except FileNotFoundError: + print("Error: hyprctl command not found. Cannot reload Hyprland.") + except ( + subprocess.CalledProcessError + ) as e: # If we used subprocess.run with check=True + print( + f"Error reloading Hyprland with hyprctl: {e}\nOutput:\n{e.stdout}\n{ + e.stderr + }" + ) + except Exception as e: + print(f"An error occurred initiating hyprctl reload: {e}") + print(f"{time.time():.4f}: start_config: Finished initiating hyprctl reload.") diff --git a/install.sh b/install.sh old mode 100644 new mode 100755 index dc640c4..093cc9d --- a/install.sh +++ b/install.sh @@ -1,23 +1,46 @@ #!/bin/bash +# ███╗ ███╗ ██████╗ ██████╗ ██╗ ██╗███████╗ +# ████╗ ████║██╔═══██╗██╔══██╗██║ ██║██╔════╝ +# ██╔████╔██║██║ ██║██║ ██║██║ ██║███████╗ +# ██║╚██╔╝██║██║ ██║██║ ██║██║ ██║╚════██║ +# ██║ ╚═╝ ██║╚██████╔╝██████╔╝╚██████╔╝███████║ +# ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝ +# +# A hackable shell for Hyprland +# Installation Script for Arch Linux +# +# Repository: https://github.com/S4NKALP/Modus +# License: GPLv3 + set -e # Exit immediately if a command exits with a non-zero status set -u # Treat unset variables as an error set -o pipefail # Prevent errors in a pipeline from being masked -REPO_URL="https://github.com/S4NKALP/Modus" -INSTALL_DIR="$HOME/Modus" +REPO_URL="https://github.com/S4NKALP/Modus.git" +INSTALL_DIR="$HOME/.config/Modus" PACKAGES=( + python-fabric-git fabric-cli-git + gray-git + cliphist gnome-bluetooth-3.0 + gobject-introspection + slurp + ffmpeg grimblast hypridle hyprlock hyprpicker imagemagick libnotify + matugen-bin + noto-fonts-emoji + nvtop + playerctl python-fabric-git - python-materialyoucolor-git + python-gobject python-pillow python-setproctitle python-toml @@ -26,21 +49,25 @@ PACKAGES=( python-pywayland python-pyxdg python-ijson - sddm-theme-corners-git + python-watchdog + python-pyotp + python-pyzbar + ttf-nerd-fonts-symbols-mono swww swappy wl-clipboard + webp-pixbuf-loader wf-recorder - libadwaita acpi - adw-gtk-theme brightnessctl - wlinhibit power-profile-daemon - ttf-chakra-petch ttf-tabler-icons + uwsm ) +echo "Starting Modus installation..." +echo "==================================" + # Ensure running on Arch Linux if ! grep -q "arch" /etc/os-release; then echo "This script is designed to run on Arch Linux." @@ -70,6 +97,8 @@ else aur_helper="paru" fi +echo "Using AUR helper: $aur_helper" + # Clone or update the Modus repository if [ -d "$INSTALL_DIR" ]; then echo "Updating Modus..." @@ -94,43 +123,16 @@ for pkg in "${PACKAGES[@]}"; do done if [ ${#to_update[@]} -gt 0 ]; then - echo "Updating outdated packages..." + echo "⬆Updating outdated packages..." $aur_helper -S --noconfirm "${to_update[@]}" || true else echo "All required packages are up-to-date." fi -# Backup and replace GTK configurations -echo "Updating GTK configurations..." -mkdir -p "$HOME/.config" -[[ -d "$HOME/.config/gtk-3.0" ]] && mv "$HOME/.config/gtk-3.0" "$HOME/.config/gtk-3.0-bk-$(date +%s)" -[[ -d "$HOME/.config/gtk-4.0" ]] && mv "$HOME/.config/gtk-4.0" "$HOME/.config/gtk-4.0-bk-$(date +%s)" - -cp -r "$INSTALL_DIR/assets/gtk-3.0" "$HOME/.config/" || true -cp -r "$INSTALL_DIR/assets/gtk-4.0" "$HOME/.config/" || true - -# Install Icon Theme -echo "Installing Tela icon theme..." -tmpdir=$(mktemp -d) -git clone https://github.com/vinceliuice/Tela-icon-theme "$tmpdir/Tela-icon-theme" -cd "$tmpdir/Tela-icon-theme" -./install.sh nord -rm -rf "$tmpdir" - -# Install Vencord -echo "Installing Vencord..." -if command -v curl &>/dev/null; then - sh -c "$(curl -sS https://raw.githubusercontent.com/Vendicated/VencordInstaller/main/install.sh)" || true - mkdir -p "$HOME/.config/Vencord/settings/" - ln -sf "$HOME/.cache/material/material-discord.css" "$HOME/.config/Vencord/settings/quickCss.css" -else - echo "Skipping Vencord installation (curl not found)." -fi - -# Launch Modus +python "$INSTALL_DIR/config/config.py" echo "Starting Modus..." killall modus 2>/dev/null || true -python "$INSTALL_DIR/main.py" >/dev/null 2>&1 & +uwsm app -- python "$INSTALL_DIR/main.py" >/dev/null 2>&1 & disown -echo "Installation complete." +echo "Installation complete!" diff --git a/main.css b/main.css new file mode 100644 index 0000000..c1bf7a0 --- /dev/null +++ b/main.css @@ -0,0 +1,72 @@ +@import url("./styles/dock.css"); +@import url("./styles/colors.css"); +@import url("./styles/components.css"); +@import url("./styles/controls.css"); +@import url("./styles/indicators.css"); +@import url("./styles/workspaces.css"); +@import url("./styles/switcher.css"); +@import url("./styles/osd.css"); +@import url("./styles/launcher.css"); +@import url("./styles/kanban.css"); +@import url("./styles/calendar.css"); +@import url("./styles/notification.css"); +@import url("./styles/player.css"); +@import url("./styles/dashboard.css"); + +* { + all: unset; + color: var(--foreground); + font-size: unset; + font-family: unset; + border-radius: 16px; +} + +#corner { + background-color: var(--shadow); + border-radius: 0; +} + +#corner-container { + min-width: 20px; + min-height: 20px; +} + +tooltip { + border: solid 1px; + border-color: var(--surface); + background-color: var(--shadow); + animation: tooltipShow 0.25s cubic-bezier(0.5, 0.25, 0, 1); +} + +@keyframes tooltipShow { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +tooltip > * { + padding: 6px 10px; + border-radius: 10px; +} + +#workspaces, +#workspaces-container, +#launcher, +#date-time, +#music-player, +#osd, +#tray, +#battery, +#controls, +#notification-stack-box, +#metrics, +#language, +#applications-dock, +#dock { + box-shadow: 0 0 3px alpha(black, 0.7); +} + diff --git a/main.py b/main.py index 7488f5d..95bebf4 100644 --- a/main.py +++ b/main.py @@ -2,42 +2,58 @@ import os import setproctitle from fabric import Application -from fabric.utils import get_relative_path, monitor_file, exec_shell_command_async +from fabric.utils import exec_shell_command_async, get_relative_path, monitor_file from loguru import logger -from modules.bar.bar import Bar, Corners -from modules.launcher.launcher import Launcher - -from modules.dock import Dock +from config.data import APP_NAME, APP_NAME_CAP, CONFIG_FILE +from modules.corners import Corners +from modules.dock.main import Dock +from modules.launcher import Launcher from modules.notification_popup import NotificationPopup from modules.osd import OSD -from config.data import APP_NAME, CACHE_DIR, CONFIG_FILE, DOCK_ICON_SIZE, VERTICAL, APP_NAME_CAP +from modules.switcher import ApplicationSwitcher +from modules.dashboard.main import DashboardWindow for log in [ "fabric.hyprland.widgets", "fabric.audio.service", "fabric.bluetooth.service", + "services.network", + "services.mpris", ]: logger.disable(log) - - if __name__ == "__main__": setproctitle.setproctitle(APP_NAME) if not os.path.isfile(CONFIG_FILE): - exec_shell_command_async(f"python {get_relative_path('../config/config.py')}") - bar = Bar() - dock = Dock() - corners = Corners() - osd = OSD() - notif = NotificationPopup() - launcher = Launcher() - bar.launcher = launcher - launcher.bar = bar + config_script_path = get_relative_path("config/config.py") + exec_shell_command_async(f"python {config_script_path}") - app = Application(f"{APP_NAME}", bar, launcher, osd, dock) + current_wallpaper = os.path.expanduser("~/.current.wall") + if not os.path.exists(current_wallpaper): + example_wallpaper = os.path.expanduser( + f"~/.config/{APP_NAME_CAP}/assets/wallpapers_example/example-1.png" + ) + os.symlink(example_wallpaper, current_wallpaper) + + # Load configuration + from config.data import load_config + + config = load_config() + + corners = Corners() + dock = Dock() + switcher = ApplicationSwitcher() + osd = OSD() + launcher = Launcher() + nc = NotificationPopup() + dashboard = DashboardWindow() + + # Set corners visibility based on config + corners_visible = config.get("corners_visible", True) + corners.set_visible(corners_visible) # Monitor CSS files for changes css_file = monitor_file(get_relative_path("styles")) @@ -46,13 +62,14 @@ if __name__ == "__main__": color_css_file = monitor_file(get_relative_path("./styles/colors.css")) _ = color_css_file.connect("changed", lambda *_: set_css()) + app = Application( + f"{APP_NAME}", dock, corners, switcher, osd, launcher, nc, dashboard + ) # Make sure corners is added to the app + def set_css(): - logger.info("[Main] Applying CSS") - app.set_stylesheet_from_file(get_relative_path("styles/main.css"), - exposed_functions={ - "dock_nmargin": lambda: f"margin-bottom: -{32 + DOCK_ICON_SIZE}px;" if not VERTICAL else f"margin-right: -{32 + DOCK_ICON_SIZE}px;", - "dock_sep": lambda: f"margin: 8px 0;" if not VERTICAL else f"margin: 0 8px;", - },) + app.set_stylesheet_from_file( + get_relative_path("main.css"), + ) app.set_css = set_css diff --git a/modules/bar/bar.py b/modules/bar/bar.py deleted file mode 100644 index 61a2653..0000000 --- a/modules/bar/bar.py +++ /dev/null @@ -1,312 +0,0 @@ -from fabric.hyprland.widgets import Language, get_hyprland_connection -from fabric.hyprland.service import HyprlandEvent -from fabric.system_tray.widgets import SystemTray -from fabric.widgets.shapes import Corner -from fabric.widgets.box import Box -from fabric.widgets.button import Button -from fabric.widgets.centerbox import CenterBox -from fabric.widgets.datetime import DateTime -from fabric.widgets.label import Label -from fabric.widgets.wayland import WaylandWindow as Window -from modules.bar.components import Metrics, workspace, SystemIndicators, UpdatesWidget -import config.data as data -from gi.repository import Gdk, GLib -import utils.icons as icons -import os -import json - - -class Bar(Window): - def __init__(self, **kwargs): - super().__init__( - name="bar", - layer="top", - anchor="left top right" - if not data.VERTICAL and not data.BOTTOM_BAR - else "top right bottom" - if data.VERTICAL and data.VERTICAL_RIGHT_ALIGN - else "top left bottom" - if data.VERTICAL - else "bottom left right", - exclusivity="auto", - visible=True, - all_visible=True, - ) - - self.launcher = kwargs.get("launcher", None) - self.component_visibility = data.BAR_COMPONENTS_VISIBILITY - - # Determine if we should use vertical layout for components - is_vertical_layout = data.VERTICAL - - self.workspaces = Button( - child=workspace, - name="workspaces", - ) - - self.connection = get_hyprland_connection() - self.tray = SystemTray( - name="tray", - spacing=4, - icon_size=16, - orientation="h" if not is_vertical_layout else "v", - ) - - self.date_time = Button( - name="datetime", - child=DateTime( - formatters=["%-I:%M:%p 󰧞 %a %d %b"] - if not is_vertical_layout - else ["%I\n%M\n%p"], - h_align="center" if not is_vertical_layout else "fill", - v_align="center", - ), - on_clicked=lambda *_: self.calendar(), - ) - self.date_time.connect("enter_notify_event", self.on_button_enter) - self.date_time.connect("leave_notify_event", self.on_button_leave) - - self.button_app = Button( - name="button-bar", - child=Label(name="button-bar-label", markup=icons.apps), - on_clicked=lambda *_: self.search_apps(), - ) - self.button_app.connect("enter_notify_event", self.on_button_enter) - self.button_app.connect("leave_notify_event", self.on_button_leave) - - self.updates = UpdatesWidget() - self.updates.connect("enter_notify_event", self.on_button_enter) - self.updates.connect("leave_notify_event", self.on_button_leave) - - self.lang_label = Label(name="lang-label") - self.language = Button( - name="language", h_align="center", v_align="center", child=self.lang_label - ) - self.on_language_switch() - self.connection.connect("event::activelayout", self.on_language_switch) - - self.metrics = Metrics() - self.indicators = SystemIndicators(launcher=self.launcher) - - self.applets = Box( - name="system-indicator", - spacing=4, - orientation="h" if not is_vertical_layout else "v", - children=[self.language, self.indicators], - ) - - # Apply visibility settings to components - self.apply_component_visibility() - - self.h_start_children = [self.button_app, self.updates] - - self.h_center_children = [ - self.date_time, - self.workspaces, - self.metrics, - self.applets, - ] - - self.h_end_children = [self.tray] - - self.v_start_children = [ - self.button_app, - self.updates, - self.workspaces, - self.metrics, - ] - self.v_end_children = [self.tray, self.applets, self.date_time] - - self.v_all_children = [] - self.v_all_children.extend(self.v_start_children) - self.v_all_children.extend(self.v_end_children) - - # Use centered layout when both vertical and centered_bar are enabled - is_centered_bar = is_vertical_layout and getattr(data, "CENTERED_BAR", False) - - self.bar = CenterBox( - name="bar", - orientation="h" if not is_vertical_layout else "v", - h_align="fill", - v_align="fill", - start_children=None - if is_centered_bar - else Box( - name="start-container", - spacing=4, - orientation="h" if not is_vertical_layout else "v", - children=self.h_start_children - if not is_vertical_layout - else self.v_start_children, - ), - center_children=Box( - orientation="v", - spacing=4, - children=self.v_all_children - if is_centered_bar - else Box( - name="center-container", - spacing=4, - orientation="h" if not is_vertical_layout else "v", - children=self.h_center_children if not is_vertical_layout else None, - ), - ) - if is_vertical_layout - else Box( - name="center-container", - spacing=4, - orientation="h" if not is_vertical_layout else "v", - children=self.h_center_children if not is_vertical_layout else None, - ), - end_children=None - if is_centered_bar - else Box( - name="end-container", - spacing=4, - orientation="h" if not is_vertical_layout else "v", - children=self.h_end_children - if not is_vertical_layout - else self.v_end_children, - ), - ) - - self.children = self.bar - - def apply_component_visibility(self): - """Apply saved visibility settings to all components""" - components = { - "metrics": self.metrics, - "indicators": self.applets, - "updates": self.updates, - "workspaces": self.workspaces, - "button_app": self.button_app, - # "button_tools": self.button_tools, - "language": self.language, - "date_time": self.date_time, - "tray": self.tray, - } - - for component_name, widget in components.items(): - if component_name in self.component_visibility: - widget.set_visible(self.component_visibility[component_name]) - - def toggle_component_visibility(self, component_name): - """Toggle visibility for a specific component""" - components = { - "metrics": self.metrics, - "indicators": self.applets, - "updates": self.updates, - "workspaces": self.workspaces, - "button_app": self.button_app, - # "button_tools": self.button_tools, - "language": self.language, - "date_time": self.date_time, - "tray": self.tray, - } - - if component_name in components and component_name in self.component_visibility: - # Toggle the visibility state - self.component_visibility[component_name] = not self.component_visibility[ - component_name - ] - # Apply the new state - components[component_name].set_visible( - self.component_visibility[component_name] - ) - - # Update the configuration - config_file = os.path.expanduser( - f"~/{data.APP_NAME}/config/assets/config.json" - ) - try: - if os.path.exists(config_file): - with open(config_file, "r") as f: - config = json.load(f) - - # Update the config with the new visibility state - config[f"bar_{component_name}_visible"] = self.component_visibility[ - component_name - ] - - # Write the updated config back to file - with open(config_file, "w") as f: - json.dump(config, f, indent=4) - except (json.JSONDecodeError, IOError) as e: - print(f"Error updating config file: {e}") - # Revert the visibility change if config update fails - self.component_visibility[ - component_name - ] = not self.component_visibility[component_name] - components[component_name].set_visible( - self.component_visibility[component_name] - ) - - return self.component_visibility[component_name] - - return None - - def on_button_enter(self, widget, event): - window = widget.get_window() - if window: - window.set_cursor(Gdk.Cursor(Gdk.CursorType.HAND2)) - - def on_button_leave(self, widget, event): - window = widget.get_window() - if window: - window.set_cursor(None) - - def search_apps(self): - self.launcher.open("launcher") - - def calendar(self): - self.launcher.open("calendar") - - def on_language_switch(self, _=None, event: HyprlandEvent = None): - lang = event.data[1] if event else Language().get_label() - self.language.set_tooltip_text(lang) - if not data.VERTICAL: - self.lang_label.set_label(lang[:2].lower()) - else: - self.lang_label.add_style_class("icon") - self.lang_label.set_markup(icons.keyboard) - - -class MyCorner(Box): - def __init__(self, position): - super().__init__( - name="corner-container", - children=Corner( - name="corner", - orientation=position, - size=20, - ), - ) - - -class Corners(Window): - def __init__(self): - super().__init__( - layer="top", - anchor="top left bottom right", - pass_through=True, - child=Box( - orientation="vertical", - children=[ - Box( - children=[ - MyCorner("top-left"), - Box(h_expand=True), - MyCorner("top-right"), - ] - ), - Box(v_expand=True), - Box( - children=[ - MyCorner("bottom-left"), - Box(h_expand=True), - MyCorner("bottom-right"), - ] - ), - ], - ), - ) diff --git a/modules/bar/components/__init__.py b/modules/bar/components/__init__.py deleted file mode 100644 index 161c235..0000000 --- a/modules/bar/components/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .metrics import Metrics -from .workspace import workspace -from .system_indicators import SystemIndicators -from .updates import UpdatesWidget diff --git a/modules/bar/components/metrics.py b/modules/bar/components/metrics.py deleted file mode 100644 index b716b4d..0000000 --- a/modules/bar/components/metrics.py +++ /dev/null @@ -1,368 +0,0 @@ -import subprocess -import json -import psutil -from gi.repository import GLib, Gdk - -from fabric.widgets.label import Label -from fabric.widgets.box import Box -from fabric.widgets.circularprogressbar import CircularProgressBar -from fabric.widgets.revealer import Revealer -from fabric.core.fabricator import Fabricator -from fabric.widgets.button import Button -import utils.icons as icons -import config.data as data - - -class MetricsProvider: - def __init__(self): - self.cpu = 0.0 - self.mem = 0.0 - self.swap = 0.0 - self.disk = [] - self.bat_percent = 0.0 - self.bat_charging = None - - GLib.timeout_add_seconds(1, self._update) - - def _update(self): - # Get non-blocking usage percentages (interval=0) - # The first call may return 0, but subsequent calls will provide consistent values. - self.cpu = psutil.cpu_percent(interval=0) - mem = psutil.virtual_memory() - self.mem = mem.percent - swap = psutil.swap_memory() - self.swap = swap.percent - self.disk = [psutil.disk_usage(path).percent for path in data.BAR_METRICS_DISKS] - - battery = psutil.sensors_battery() - if battery is None: - self.bat_percent = 0.0 - self.bat_charging = None - else: - self.bat_percent = battery.percent - self.bat_charging = battery.power_plugged - return True - - def get_metrics(self): - return (self.cpu, self.mem, self.swap, self.disk) - - def get_battery(self): - return (self.bat_percent, self.bat_charging) - - -shared_provider = MetricsProvider() - - -class SingularMetric: - def __init__(self, id, name, icon): - self.is_vertical_layout = data.VERTICAL - self.name_markup = name - self.icon_markup = icon - - self.icon = Label(name="metrics-icon", markup=icon) - self.circle = CircularProgressBar( - name="metrics-circle", - value=0, - size=26, - line_width=2, - start_angle=150, - end_angle=390, - style_classes=id, - child=self.icon, - ) - - self.circle_button = Button( - name=f"metrics-{id}-button", - child=self.circle, - style="padding: 0; margin: 0; background: none;", - ) - - self.circle_button.add_events( - Gdk.EventMask.ENTER_NOTIFY_MASK | Gdk.EventMask.LEAVE_NOTIFY_MASK - ) - self.circle_button.connect("enter-notify-event", self.on_button_enter) - self.circle_button.connect("leave-notify-event", self.on_button_leave) - - self.circle_button.connect("clicked", self.on_circle_clicked) - - self.level = Label( - name="metrics-level", - style_classes=id, - label="0%", - style="margin: 4px 0px;" if self.is_vertical_layout else "margin: 0 4px;", - ) - self.revealer = Revealer( - name=f"metrics-{id}-revealer", - transition_duration=250, - transition_type="slide-up" if self.is_vertical_layout else "slide-left", - child=self.level, - child_revealed=False, - ) - - self.box = Box( - name=f"metrics-{id}-box", - orientation="v" if self.is_vertical_layout else "h", - spacing=0, - children=[self.circle_button, self.revealer], - ) - - # Connect events for cursor changes to the box as well - self.box.connect("enter-notify-event", self.on_button_enter) - self.box.connect("leave-notify-event", self.on_button_leave) - - # Set initial tooltip - moved after box is created - self.update_tooltip("0%") - - def update_tooltip(self, value_text): - tooltip = f"{self.icon_markup} {self.name_markup}: {value_text}" - # self.box.set_tooltip_markup(tooltip) - self.circle_button.set_tooltip_markup(tooltip) - self.circle.set_tooltip_markup(tooltip) - self.icon.set_tooltip_markup(tooltip) - - def markup(self): - """Get formatted text for tooltip display""" - return f"{self.icon_markup} {self.name_markup}: {self.level.get_label()}" - - def on_button_enter(self, widget, event): - window = widget.get_window() - if window: - window.set_cursor(Gdk.Cursor(Gdk.CursorType.HAND2)) - - def on_button_leave(self, widget, event): - window = widget.get_window() - if window: - window.set_cursor(None) - - def on_circle_clicked(self, button): - # Toggle the revealed state of the label - current_state = self.revealer.get_child_revealed() - self.revealer.set_reveal_child(not current_state) - - -class Metrics(Box): - def __init__(self, **kwargs): - # Determine if we should use vertical layout for components - self.is_vertical_layout = data.VERTICAL - - super().__init__( - name="metrics", - spacing=0, - orientation="h" if not self.is_vertical_layout else "v", - visible=True, - all_visible=True, - style="padding:4px 0px" if self.is_vertical_layout else "padding: 0px 4px;", - **kwargs, - ) - - self.batt_fabricator = Fabricator( - poll_from=lambda v: shared_provider.get_battery(), - on_changed=lambda f, v: self.update_battery, - interval=1000, - stream=False, - default_value=0, - ) - self.batt_fabricator.changed.connect(self.update_battery) - GLib.idle_add(self.update_battery, None, shared_provider.get_battery()) - - visible = getattr( - data, - "METRICS_VISIBLE", - {"cpu": True, "ram": True, "disk": True, "swap": True}, - ) - disks = ( - [ - SingularMetric( - "disk", - f"DISK ({path})" if len(data.BAR_METRICS_DISKS) != 1 else "DISK", - icons.disk, - ) - for path in data.BAR_METRICS_DISKS - ] - if visible.get("disk", True) - else [] - ) - - self.cpu = ( - SingularMetric("cpu", "CPU", icons.cpu) - if visible.get("cpu", True) - else None - ) - self.ram = ( - SingularMetric("ram", "RAM", icons.memory) - if visible.get("ram", True) - else None - ) - self.swap = ( - SingularMetric("swap", "SWAP", icons.swap) - if visible.get("swap", True) - else None - ) - self.bat = ( - SingularMetric("bat", "BATTERY", icons.battery) - if visible.get("bat", True) - else None - ) - self.disk = disks - - # Add only enabled metrics - for disk in self.disk: - self.add(disk.box) - self.add(Box(name="metrics-sep")) - if self.ram: - self.add(self.ram.box) - self.add(Box(name="metrics-sep")) - if self.cpu: - self.add(self.cpu.box) - if self.swap: - self.add(Box(name="metrics-sep")) - self.add(self.swap.box) - if self.bat: - self.add(Box(name="metrics-sep")) - self.add(self.bat.box) - - # Connect events directly to the button - self.connect("enter-notify-event", self.on_mouse_enter) - self.connect("leave-notify-event", self.on_mouse_leave) - - GLib.timeout_add_seconds(1, self.update_metrics) - - self.hide_timer = None - self.hover_counter = 0 - - def _format_percentage(self, value: int) -> str: - return f"{value}%" - - def on_mouse_enter(self, widget, event): - window = widget.get_window() - if window: - window.set_cursor(Gdk.Cursor(Gdk.CursorType.HAND2)) - - if not data.VERTICAL: - self.hover_counter += 1 - if self.hide_timer is not None: - GLib.source_remove(self.hide_timer) - self.hide_timer = None - if self.cpu: - self.cpu.revealer.set_reveal_child(True) - if self.ram: - self.ram.revealer.set_reveal_child(True) - for disk in self.disk: - disk.revealer.set_reveal_child(True) - if self.swap: - self.swap.revealer.set_reveal_child(True) - if self.bat: - self.bat.revealer.set_reveal_child(True) - return False - - def on_mouse_leave(self, widget, event): - window = widget.get_window() - if window: - window.set_cursor(None) - - if not data.VERTICAL: - if self.hover_counter > 0: - self.hover_counter -= 1 - if self.hover_counter == 0: - if self.hide_timer is not None: - GLib.source_remove(self.hide_timer) - self.hide_timer = GLib.timeout_add(500, self.hide_revealer) - return False - - def hide_revealer(self): - if not data.VERTICAL: - if self.cpu: - self.cpu.revealer.set_reveal_child(False) - if self.ram: - self.ram.revealer.set_reveal_child(False) - for disk in self.disk: - disk.revealer.set_reveal_child(False) - self.hide_timer = None - return False - - def update_metrics(self): - cpu, mem, swap, disk = shared_provider.get_metrics() - if self.cpu: - self.cpu.circle.set_value(cpu / 100.0) - value_text = self._format_percentage(int(cpu)) - self.cpu.level.set_label(value_text) - self.cpu.update_tooltip(value_text) - if self.ram: - self.ram.circle.set_value(mem / 100.0) - value_text = self._format_percentage(int(mem)) - self.ram.level.set_label(value_text) - self.ram.update_tooltip(value_text) - for i, disk_metric in enumerate(self.disk): - if i < len(disk): - value_text = self._format_percentage(int(disk[i])) - disk_metric.circle.set_value(disk[i] / 100.0) - disk_metric.level.set_label(value_text) - disk_metric.update_tooltip(value_text) - if self.swap: - self.swap.circle.set_value(swap / 100.0) - value_text = self._format_percentage(int(swap)) - self.swap.level.set_label(value_text) - self.swap.update_tooltip(value_text) - if self.bat: - bat_percent, _ = shared_provider.get_battery() - self.bat.circle.set_value(bat_percent / 100.0) - value_text = self._format_percentage(int(bat_percent)) - self.bat.level.set_label(value_text) - self.bat.update_tooltip(value_text) - - # Tooltip: only show enabled metrics - tooltip_metrics = [] - if self.disk: tooltip_metrics.extend(self.disk) - if self.swap: tooltip_metrics.append(self.swap) - if self.bat: tooltip_metrics.append(self.bat) - if self.ram: tooltip_metrics.append(self.ram) - if self.cpu: tooltip_metrics.append(self.cpu) - self.set_tooltip_markup((" - " if not data.VERTICAL else "\n").join([v.markup() for v in tooltip_metrics])) - - return True - - def update_battery(self, sender, battery_data): - if self.bat is None: - return - - value, charging = battery_data - if value == 0: - if self.bat: - self.bat.box.set_visible(False) - else: - if self.bat: - self.bat.box.set_visible(True) - self.bat.circle.set_value(value / 100) - percentage = int(value) - self.bat.level.set_label(self._format_percentage(percentage)) - - # Apply alert styling if battery is low AND not charging - if percentage <= 15 and charging == False: - self.bat.icon.add_style_class("alert") - self.bat.circle.add_style_class("alert") - else: - self.bat.icon.remove_style_class("alert") - self.bat.circle.remove_style_class("alert") - - # Choose the icon based on charging state first, then battery level - if percentage == 100: - self.bat.icon.set_markup(icons.battery) - charging_status = f"{icons.bat_full} Fully Charged" - elif charging == True: - self.bat.icon.set_markup(icons.charging) - charging_status = f"{icons.bat_charging} Charging" - elif percentage <= 15 and charging == False: - self.bat.icon.set_markup(icons.alert) - charging_status = f"{icons.bat_low} Low Battery" - elif charging == False: - self.bat.icon.set_markup(icons.discharging) - charging_status = f"{icons.bat_discharging} Discharging" - else: - self.bat.icon.set_markup(icons.battery) - charging_status = "Battery" - - # Update individual tooltip for battery - self.bat.update_tooltip(f"{charging_status}: {self._format_percentage(percentage)}") - - # Update the combined tooltip - self.update_metrics() diff --git a/modules/bar/components/system_indicators.py b/modules/bar/components/system_indicators.py deleted file mode 100644 index 7d9b453..0000000 --- a/modules/bar/components/system_indicators.py +++ /dev/null @@ -1,258 +0,0 @@ -import subprocess -from fabric import Fabricator -from fabric.widgets.label import Label -from fabric.widgets.box import Box -from fabric.widgets.button import Button -from fabric.bluetooth import BluetoothClient -from services import network_client, audio, notification_service -import utils.icons as icons -import config.data as data - - -class SystemIndicators(Box): - def __init__(self, **kwargs): - # Determine if we should use vertical layout for components - is_vertical_layout = data.VERTICAL - - super().__init__( - orientation="h" if not is_vertical_layout else "v", spacing=0, **kwargs - ) - self.launcher = kwargs.get("launcher", None) - - self.bluetooth_icon = Label(name="system-indicator-icon") - self.wifi_icon = Label(name="system-indicator-icon") - self.volume_icon_button = Label(name="system-indicator-icon") - self.microphone_icon = Label(name="system-indicator-icon") - self.idle_label = Label(name="system-indicator-icon") - self.night_label = Label(name="system-indicator-icon") - self.power_label = Label(name="system-indicator-icon") - self.notification_icon = Button(name="system-indicator-icon") - self.notification_label = Label(name="system-notif-label") - self.notification_icon.add(self.notification_label) - self.notification_icon.connect("clicked", self.open_notif_center) - - for widget in [ - self.bluetooth_icon, - self.wifi_icon, - self.volume_icon_button, - self.microphone_icon, - self.idle_label, - self.night_label, - self.power_label, - self.notification_icon, - ]: - self.add(widget) - - self.audio_service = audio - self.bluetooth_client = BluetoothClient() - self.network_client = network_client - self.notification_service = notification_service - - # Connect signals - self.bluetooth_client.connect("changed", self.update_bluetooth_status) - self.audio_service.connect("microphone_changed", self.update_mic_status) - self.audio_service.connect("changed", self.update_volume_status) - self.network_client.connect("device_ready", self.update_network_status) - self.notification_service.connect("dnd", self.update_notification_status) - self.notification_service.connect( - "notification_count", self.update_notification_status - ) - - if self.network_client.wifi_device: - self.network_client.wifi_device.connect( - "changed", self.update_network_status - ) - - if self.network_client.ethernet_device: - self.network_client.ethernet_device.connect( - "changed", self.update_network_status - ) - - Fabricator(interval=1000, poll_from=self.update_all_statuses) - - self.update_bluetooth_status() - self.update_notification_status() - - def update_all_statuses(self, *_args): - self.update_idle_night_status() - self.update_power_profile() - self.update_network_status() - - def update_idle_night_status(self, *_): - # Update idle status - has_idle = ( - subprocess.run(["pgrep", "-x", "wlinhibit"], capture_output=True).returncode - == 0 - ) - self.idle_label.set_visible(has_idle) - self.idle_label.set_markup(icons.coffee if has_idle else "") - - # Update night mode status - has_night = ( - subprocess.run( - ["pgrep", "-x", "hyprsunset"], capture_output=True - ).returncode - == 0 - ) - self.night_label.set_visible(has_night) - self.night_label.set_markup(icons.night if has_night else "") - - def update_volume_status(self, *_): - stream = self.audio_service.speaker - if not stream: - self.volume_icon_button.set_visible(False) - return - - self.volume_icon_button.set_visible(True) - # Normalize volume to integer percentage (0-100) - volume_level = stream.volume - if volume_level > 1: - volume_level = min(int(volume_level), 100) - else: - volume_level = int(volume_level * 100) - - is_muted = stream.muted - - if is_muted: - icon = icons.vol_off - elif volume_level > 74: - icon = icons.vol_high - elif volume_level > 0: - icon = icons.vol_medium - else: - icon = icons.vol_mute - - self.volume_icon_button.set_markup(icon) - - tooltip_text = "Muted" if is_muted else f"Volume: {volume_level}%" - self.volume_icon_button.set_tooltip_text(tooltip_text) - - def update_mic_status(self, *_): - mic = self.audio_service.microphone - if not mic: - self.microphone_icon.set_visible(False) - return - - self.microphone_icon.set_visible(True) - # Normalize volume to integer percentage (0-100) - volume_level = mic.volume - if volume_level > 1: - volume_level = min(int(volume_level), 100) - else: - volume_level = int(volume_level * 100) - - is_muted = mic.muted - icon = icons.mic_muted if is_muted else icons.mic - - self.microphone_icon.set_markup(icon) - tooltip_text = "Muted" if is_muted else f"Microphone: {volume_level}%" - self.microphone_icon.set_tooltip_text(tooltip_text) - - def update_bluetooth_status(self, *_): - if self.bluetooth_client.enabled: - self.bluetooth_icon.set_visible(True) - # Get a list of connected devices - connected_devices = [ - device.name - for device in self.bluetooth_client.devices - if device.connected - ] - - if connected_devices: - self.bluetooth_icon.set_markup(icons.bluetooth_connected) - self.bluetooth_icon.set_tooltip_text(f"{', '.join(connected_devices)}") - else: - self.bluetooth_icon.set_markup(icons.bluetooth) - self.bluetooth_icon.set_tooltip_text( - "Bluetooth is enabled, no devices connected" - ) - else: - self.bluetooth_icon.set_visible(True) # Keep it visible - self.bluetooth_icon.set_markup(icons.bluetooth_off) - self.bluetooth_icon.set_tooltip_text("Bluetooth is disabled") - - def update_network_status(self, *_): - primary_device = self.network_client.primary_device - self.wifi_icon.set_visible(True) - - if primary_device == "wifi" and self.network_client.wifi_device: - wifi_device = self.network_client.wifi_device - - self.wifi_icon.set_tooltip_text( - wifi_device.ssid if wifi_device.ssid else "No WiFi Connection" - ) - - if wifi_device.enabled: - strength = wifi_device.strength - - if strength > 0: - if strength < 25: - icon_label = icons.wifi_0 - elif strength < 50: - icon_label = icons.wifi_1 - elif strength < 75: - icon_label = icons.wifi_2 - else: - icon_label = icons.wifi_3 - else: - icon_label = icons.wifi_off - else: - icon_label = icons.wifi_off - - elif primary_device == "wired": - icon_label = icons.lan - self.wifi_icon.set_tooltip_text("Wired Connection") - - else: - icon_label = icons.wifi_off # No network - - self.wifi_icon.set_markup(icon_label) - - def update_power_profile(self, *_): - try: - result = subprocess.run( - ["powerprofilesctl", "get"], capture_output=True, text=True - ) - current_profile = result.stdout.strip() - - profile_icons = { - "performance": icons.power_performance, - "balanced": "", - "power-saver": icons.power_saving, - } - - icon = profile_icons.get(current_profile) - tooltip = ( - f"Power Profile: {current_profile.capitalize()}" - if current_profile - else "Unknown Profile" - ) - - self.power_label.set_visible(bool(icon)) # Only show if there's an icon - self.power_label.set_markup(icon) - self.power_label.set_tooltip_text(tooltip) - - except Exception as e: - self.power_label.set_visible(False) - self.power_label.set_tooltip_text(f"Error fetching power profile: {e}") - - def update_notification_status(self, *_): - """Update the notification icon based on DND state and notification count.""" - count = self.notification_service.count - - if count == 0 and not self.notification_service.dont_disturb: - self.notification_icon.set_visible(False) - return - - if self.notification_service.dont_disturb: - self.notification_label.set_markup(icons.notifications_off) - self.notification_icon.set_tooltip_text("Do Not Disturb: On") - self.notification_icon.set_visible(True) - else: - # At this point count must be > 0 - self.notification_label.set_markup(icons.notifications_clear) - self.notification_icon.set_tooltip_text(f"Notifications: {count}") - self.notification_icon.set_visible(True) - - def open_notif_center(self, *_): - self.launcher.open("notification-center") diff --git a/modules/bar/components/updates.py b/modules/bar/components/updates.py deleted file mode 100644 index f43add2..0000000 --- a/modules/bar/components/updates.py +++ /dev/null @@ -1,115 +0,0 @@ -import json -from fabric.utils import exec_shell_command_async, get_relative_path, invoke_repeater -from fabric.widgets.box import Box -from fabric.widgets.centerbox import CenterBox -from fabric.widgets.label import Label -from fabric.widgets.button import Button -from fabric.widgets.stack import Stack -from gi.repository import Gtk -import utils.icons as icons -import config.data as data - - -class UpdatesWidget(Button): - """A widget to display the number of available updates.""" - - def __init__( - self, - **kwargs, - ): - # Determine if we should use vertical layout for components - is_vertical_layout = data.VERTICAL - - # Initialize the button with specific name and style - super().__init__( - name="update", - orientation="h" if not is_vertical_layout else "v", - **kwargs, - ) - - self.script_file = get_relative_path("../../../config/scripts/systemupdates.sh") - - self.update_level_label = Label( - name="update-label", - label="0", - visible=False, # Initially hidden - ) - - self.update_icon = Label(name="update-icon", markup=icons.update) - self.updated_icon = Label(name="updated-icon", markup=icons.updated) - - # Create boxes with proper orientation - self.update_box = CenterBox( - orientation="v" if is_vertical_layout else "h", - center_children=[self.update_icon, self.update_level_label], - ) - - self.updated_box = CenterBox( - orientation="v" if is_vertical_layout else "h", - center_children=[self.updated_icon], - ) - - # Stack to switch between states - self.stack = Stack() - self.stack.add_named(self.update_box, "update_box") - self.stack.add_named(self.updated_box, "updated_box") - self.stack.set_visible_child_name("update_box") - - self.children = Box( - orientation="v" if is_vertical_layout else "h", - children=self.stack, - ) - - # Configure stack transitions - self.stack.set_homogeneous(False) - self.stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE) - - # Connect click handler - self.connect("button-press-event", self.on_button_press) - - # Check for updates every minute - invoke_repeater(60000, self.update, initial_call=True) - - def update_values(self, value: str) -> bool: - """Update the widget state based on update check results""" - try: - value = json.loads(value) - except json.JSONDecodeError: - return False - - update_count = str(value["total"]) - self.update_level_label.set_label(update_count) - - # Show appropriate state based on update count - if update_count == "0": - self.stack.set_visible_child_name("updated_box") - self.update_level_label.set_visible(False) - else: - self.stack.set_visible_child_name("update_box") - self.update_level_label.set_visible(True) - - self.set_tooltip_text(value.get("tooltip", "")) - return True - - def on_button_press(self, _, event) -> bool: - """Handle button press events""" - if event.button == 1: # Left click - # Run update command - exec_shell_command_async( - f"{self.script_file} -arch -up", - lambda _: None, - ) - self.update() - return True - else: - # Other clicks just refresh - self.update() - - def update(self) -> bool: - """Check for system updates""" - exec_shell_command_async( - f"{self.script_file} -arch", - lambda output: self.update_values(output), - ) - - return True diff --git a/modules/bar/components/workspace.py b/modules/bar/components/workspace.py deleted file mode 100644 index 072364b..0000000 --- a/modules/bar/components/workspace.py +++ /dev/null @@ -1,30 +0,0 @@ -import json - -from fabric.hyprland.service import Hyprland -from fabric.widgets.label import Label -import config.data as data - -connection = Hyprland() - -workspaceData = connection.send_command("j/activeworkspace").reply -activeWorkspace = json.loads(workspaceData.decode("utf-8"))["name"] - -# Create the workspace label with proper initialization -if not data.VERTICAL: - workspace = Label(label=f"Workspace {activeWorkspace}", name="workspace-name") -else: - workspace = Label( - v_align="center", - label="W\no\nr\nk\ns\np\na\nc\ne\n\n" + activeWorkspace, - name="workspace-name", - ) - -def on_workspace(obj, signal): - global activeWorkspace - activeWorkspace = json.loads(signal.data[0]) - if not data.VERTICAL: - workspace.set_label(f"Workspace {activeWorkspace}") - else: - workspace.set_label(f"W\no\nr\nk\ns\np\na\nc\ne\n\n{activeWorkspace}") - -connection.connect("event::workspace", on_workspace) diff --git a/modules/corners.py b/modules/corners.py new file mode 100644 index 0000000..9ec8e6e --- /dev/null +++ b/modules/corners.py @@ -0,0 +1,69 @@ +from fabric.widgets.box import Box +from fabric.widgets.shapes import Corner + +from utils.wayland import WaylandWindow as Window + + +class MyCorner(Box): + def __init__(self, corner): + super().__init__( + name="corner-container", + children=Corner( + name="corner", + orientation=corner, + h_expand=False, + v_expand=False, + h_align="center", + v_align="center", + size=20, + ), + ) + + +class Corners(Window): + def __init__(self): + super().__init__( + name="corners", + layer="bottom", + anchor="top bottom left right", + exclusivity="normal", + # pass_through=True, + visible=False, + all_visible=False, + ) + + self.all_corners = Box( + name="all-corners", + orientation="v", + h_expand=True, + v_expand=True, + h_align="fill", + v_align="fill", + children=[ + Box( + name="top-corners", + orientation="h", + h_align="fill", + children=[ + MyCorner("top-left"), + Box(h_expand=True), + MyCorner("top-right"), + ], + ), + Box(v_expand=True), + Box( + name="bottom-corners", + orientation="h", + h_align="fill", + children=[ + MyCorner("bottom-left"), + Box(h_expand=True), + MyCorner("bottom-right"), + ], + ), + ], + ) + + self.add(self.all_corners) + + self.show_all() diff --git a/modules/dashboard/bluetooth.py b/modules/dashboard/bluetooth.py new file mode 100644 index 0000000..d76e81f --- /dev/null +++ b/modules/dashboard/bluetooth.py @@ -0,0 +1,272 @@ +from fabric.bluetooth import BluetoothClient, BluetoothDevice +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.centerbox import CenterBox +from fabric.widgets.image import Image +from fabric.widgets.label import Label +from fabric.widgets.scrolledwindow import ScrolledWindow + +import utils.icons as icons +from modules.dashboard.tile import Tile, add_hover_cursor + + +class BluetoothDeviceSlot(CenterBox): + """A widget representing a single Bluetooth device in the dashboard.""" + + def __init__(self, device: BluetoothDevice, **kwargs): + super().__init__(name="bluetooth-device", **kwargs) + self.device = device + self.device.connect("changed", self.on_changed) + self.device.connect( + "notify::closed", lambda *_: self.device.closed and self.destroy() + ) + + self.connection_label = Label( + name="bluetooth-connection", markup=icons.bluetooth_disconnected + ) + self.connect_button = Button( + name="bluetooth-connect", + label="Connect", + on_clicked=lambda *_: self.device.set_connecting(not self.device.connected), + style_classes=["connected"] if self.device.connected else None, + ) + + self.start_children = [ + Box( + spacing=8, + h_expand=True, + h_align="fill", + children=[ + Image(icon_name=device.icon_name + "-symbolic", size=16), + Label( + label=device.name[:8] + "..." if len(device.name) > 8 else device.name, + h_expand=True, + h_align="start", + ), + self.connection_label, + ], + ) + ] + self.end_children = self.connect_button + + self.device.emit("changed") + + def on_changed(self, *_): + """Handle device state changes.""" + self.connection_label.set_markup( + icons.bluetooth_connected + if self.device.connected + else icons.bluetooth_disconnected + ) + if self.device.connecting: + self.connect_button.set_label( + "Connecting..." if not self.device.connecting else "..." + ) + else: + self.connect_button.set_label( + "Connect" if not self.device.connected else "Disconnect" + ) + if self.device.connected: + self.connect_button.add_style_class("connected") + else: + self.connect_button.remove_style_class("connected") + + +class Bluetooth(Tile): + """Bluetooth tile for the dashboard that shows Bluetooth status and devices.""" + + def __init__(self, **kwargs): + # Create status label + self.label = Label( + style_classes=["desc-label", "off"], + label="Off", + h_align="start", + ) + self.state = False + + super().__init__( + label="Bluetooth", + props=self.label, + markup=icons.bluetooth, + menu=True, + **kwargs, + ) + + # Initialize bluetooth client with device handling + self.bluetooth_client = BluetoothClient(on_device_added=self.on_device_added) + self.bluetooth_client.connect( + "notify::enabled", self._on_bluetooth_state_changed + ) + self.bluetooth_client.connect("notify::scanning", self._update_scan_label) + self.bluetooth_client.connect( + "device-added", self._on_device_connection_changed + ) + self.bluetooth_client.connect( + "device-removed", self._on_device_connection_changed + ) + + # Initialize state + self._update_tile_state() + + # Override the type_box button behavior to toggle Bluetooth + self._setup_content_button() + + def _update_tile_state(self): + """Update tile visual state based on Bluetooth status.""" + self.state = self.bluetooth_client.enabled + if self.state: + self.remove_style_class("off") + self.add_style_class("on") + self.label.set_label("On") + else: + self.remove_style_class("on") + self.add_style_class("off") + self.label.set_label("Off") + + # Update tile icon based on Bluetooth state + self._update_tile_icon() + + def _setup_content_button(self): + """Setup the content button to toggle Bluetooth instead of showing menu.""" + if hasattr(self, "type_box"): + # Disconnect the existing click handler + self.type_box.disconnect_by_func(self.handle_click) + # Connect to Bluetooth toggle handler + self.type_box.connect("clicked", self._on_bluetooth_toggle) + + def _on_bluetooth_toggle(self, *_): + """Handle Bluetooth toggle when clicking on the main content area.""" + # Toggle Bluetooth state + self.bluetooth_client.enabled = not self.bluetooth_client.enabled + + def _update_tile_icon(self): + """Update the tile icon based on Bluetooth status and connected devices.""" + if not self.bluetooth_client.enabled: + # Bluetooth is disabled + self.icon.set_markup(icons.bluetooth_off) + return + + # Bluetooth is enabled - check for connected devices + connected_devices = self.bluetooth_client.connected_devices + if connected_devices: + # Has connected devices + self.icon.set_markup(icons.bluetooth_connected) + else: + # Enabled but no connected devices + if self.bluetooth_client.scanning: + # Currently scanning - could add animation here if desired + self.icon.set_markup(icons.bluetooth) + else: + # Idle state + self.icon.set_markup(icons.bluetooth) + + def create_content(self): + """Create the detailed bluetooth content for the dashboard.""" + # Create back button with chevron left icon + self.back_button_icon = Label( + name="bluetooth-back-label", markup=icons.chevron_left + ) + self.back_button = Button( + name="bluetooth-back", + child=self.back_button_icon, + tooltip_text="Back to notifications", + on_clicked=self._on_back_clicked, + ) + add_hover_cursor(self.back_button) + + # Create scan button with icon + self.scan_label = Label(name="bluetooth-scan-label", markup=icons.radar) + self.scan_button = Button( + name="bluetooth-scan", + child=self.scan_label, + tooltip_text="Scan for Bluetooth devices", + on_clicked=lambda *_: self.bluetooth_client.toggle_scan(), + ) + add_hover_cursor(self.scan_button) + + # Create device containers + self.paired_box = Box(spacing=2, orientation="v") + self.available_box = Box(spacing=2, orientation="v") + + # Create content structure + content_box = Box(spacing=4, orientation="v") + content_box.add(self.paired_box) + content_box.add(Label(name="bluetooth-section", label="Available")) + content_box.add(self.available_box) + + # Create header + header_box = CenterBox( + name="bluetooth-header", + start_children=[self.back_button], + center_children=[Label(name="bluetooth-title", label="Bluetooth Devices")], + end_children=[self.scan_button], + ) + + # Main container + main_container = Box( + name="bluetooth-content", + orientation="v", + spacing=8, + h_expand=True, + v_expand=True, + h_align="fill", + style_classes=["tile-content"], + children=[ + header_box, + ScrolledWindow( + name="bluetooth-devices", + min_content_size=(-1, -1), + child=content_box, + v_expand=True, + propagate_width=False, + propagate_height=False, + ), + ], + ) + + # Initialize notifications + self.bluetooth_client.notify("scanning") + self.bluetooth_client.notify("enabled") + + return main_container + + def on_device_added(self, client: BluetoothClient, address: str): + """Handle new device discovery.""" + if not (device := client.get_device(address)): + return + + # Connect to device changes to update tile icon when connection state changes + device.connect("changed", self._on_device_connection_changed) + + slot = BluetoothDeviceSlot(device) + + if device.paired: + return self.paired_box.add(slot) + return self.available_box.add(slot) + + def _update_scan_label(self, *_): + """Update scan button appearance based on scanning state.""" + if self.bluetooth_client.scanning: + self.scan_label.add_style_class("scanning") + self.scan_button.add_style_class("scanning") + self.scan_button.set_tooltip_text("Stop scanning for Bluetooth devices") + else: + self.scan_label.remove_style_class("scanning") + self.scan_button.remove_style_class("scanning") + self.scan_button.set_tooltip_text("Scan for Bluetooth devices") + + # Update tile icon when scanning state changes + self._update_tile_icon() + + def _on_back_clicked(self, *_): + """Handle back button click - return to default (notifications) view.""" + if self.dashboard_instance: + self.dashboard_instance.reset_to_default() + + def _on_bluetooth_state_changed(self, *_): + """Handle bluetooth state change.""" + self._update_tile_state() + + def _on_device_connection_changed(self, *_): + """Handle device connection/disconnection changes.""" + self._update_tile_icon() diff --git a/modules/dashboard/main.py b/modules/dashboard/main.py new file mode 100644 index 0000000..c93e060 --- /dev/null +++ b/modules/dashboard/main.py @@ -0,0 +1,166 @@ +from fabric.core.service import Property +from fabric.widgets.box import Box +from fabric.widgets.stack import Stack + +from modules.dashboard.bluetooth import Bluetooth +from modules.dashboard.network import Network +from modules.dashboard.notifications import DashboardNotifications +from modules.dashboard.player_mini import PlayerContainerMini +from modules.dashboard.tile import TileSpecial +from utils.wayland import WaylandWindow as Window + + +class Dashboard(Box): + def __init__(self, **kwargs): + super().__init__( + name="dashboard-box", + visible=True, + orientation="v", + spacing=8, + all_visible=True, + **kwargs, + ) + self.wifi = Network(name="wifi-tile") + self.bluetooth = Bluetooth(name="bluetooth-tile") + self.mini_player = PlayerContainerMini() + self.mini_player_tile = TileSpecial( + props=self.mini_player, mini_props=self.mini_player.get_mini_view() + ) + + # Set dashboard instance reference for tiles that need it + self.wifi.dashboard_instance = self + self.bluetooth.dashboard_instance = self + + self.dashboard_notifications = DashboardNotifications() + self.active_tile = None + + self.tiles = Box( + children=[ + self.wifi, + self.bluetooth, + self.mini_player_tile, + ], + ) + + # Create content containers for each tile + self.wifi_content = self.wifi.create_content() + self.bluetooth_content = self.bluetooth.create_content() + + # Default content (notifications) + self.default_content = Box( + name="notification-history-popup-content", + orientation="v", + spacing=8, + h_expand=True, + v_expand=True, + children=[self.dashboard_notifications], + style_classes=["notification-history-popup"], + ) + + # Content stack to switch between different tile contents + self.content_stack = Stack( + transition_type="crossfade", + transition_duration=200, + h_expand=True, + v_expand=True, + h_align="fill", + children=[ + self.default_content, + self.wifi_content, + self.bluetooth_content, + ], + ) + + # Set default visible child + self.content_stack.set_visible_child(self.default_content) + + self.children = [ + Box(name="inner", children=self.tiles), + Box( + children=[ + self.content_stack, + ] + ), + ] + + def handle_tile_menu_expand(self, tile: str, toggle: bool): + print( + f"Tile menu expand: {tile}, toggle: {toggle}, active_tile: { + self.active_tile + }" + ) + + # If clicking the same tile that's already active, deactivate it + if self.active_tile == tile and toggle: + toggle = False + # Update the tile's toggle state + for i in self.tiles: + if i.get_name() == tile: + i.toggle = False + break + + if toggle: + # Show the specific tile's content in the content area + self.active_tile = tile + if tile == "wifi-tile": + self.content_stack.set_visible_child(self.wifi_content) + print("Showing WiFi content") + elif tile == "bluetooth-tile": + self.content_stack.set_visible_child(self.bluetooth_content) + print("Showing Bluetooth content") + else: + # Show default content (notifications) + self.active_tile = None + self.content_stack.set_visible_child(self.default_content) + print("Showing default content") + + def reset_to_default(self): + """Reset dashboard to default state (notifications visible, no active tile)""" + print("Resetting dashboard to default state") + self.active_tile = None + self.content_stack.set_visible_child(self.default_content) + + # Reset all tile toggle states + for tile in self.tiles: + if hasattr(tile, "toggle"): + tile.toggle = False + + +class DashboardWindow(Window): + visible = Property(bool, flags="read-write", default_value=False) + + def __init__(self, **kwargs): + # Create dashboard instance and store reference + self.dashboard = Dashboard() + + super().__init__( + name="dashboard", + layer="top", + anchor="bottom", + exclusivity="none", + keyboard_mode="on-demand", + child=self.dashboard, + visible=False, + all_visible=False, + **kwargs, + ) + + # Add escape key binding to close dashboard + self.add_keybinding("Escape", lambda *_: self.close_dashboard()) + self.hide() + + def show_dashboard(self): + self.show_all() + self.visible = True + + def close_dashboard(self): + # Reset dashboard to default state when closing + self.dashboard.reset_to_default() + self.hide() + self.visible = False + + def toggle_dashboard(self): + if self.visible: + self.close_dashboard() + else: + self.show_dashboard() diff --git a/modules/dashboard/network.py b/modules/dashboard/network.py new file mode 100644 index 0000000..248f719 --- /dev/null +++ b/modules/dashboard/network.py @@ -0,0 +1,428 @@ +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.centerbox import CenterBox +from fabric.widgets.image import Image +from fabric.widgets.label import Label +from fabric.widgets.scrolledwindow import ScrolledWindow +from gi.repository import GLib + +import utils.icons as icons +from modules.dashboard.tile import Tile, add_hover_cursor +from services.network import NetworkClient + + +class WifiAccessPointSlot(CenterBox): + """A widget representing a single WiFi access point in the dashboard.""" + + def __init__( + self, access_point, network_service: NetworkClient, wifi_service, **kwargs + ): + super().__init__(name="wifi-ap-slot", **kwargs) + self.access_point = access_point + self.network_service = network_service + self.wifi_service = wifi_service + + ssid = access_point.ssid + icon_name = access_point.icon + + self.is_active = access_point.is_active + + self.ap_icon = Image(icon_name=icon_name, size=24) + self.ap_label = Label( + label=ssid, h_expand=True, h_align="start", ellipsization="end" + ) + + self.connect_button = Button( + name="wifi-connect-button", + label="Connected" if self.is_active else "Connect", + sensitive=True, # Always clickable - can connect or disconnect + on_clicked=self._on_connect_clicked, + style_classes=["connected"] if self.is_active else None, + ) + add_hover_cursor(self.connect_button) + + self.set_start_children( + [ + Box( + spacing=8, + h_expand=True, + h_align="fill", + children=[ + self.ap_icon, + self.ap_label, + ], + ) + ] + ) + self.set_end_children([self.connect_button]) + + def _on_connect_clicked(self, *_): + """Handle connect button click.""" + if self.is_active: + # Disconnect from current network + self.connect_button.set_label("Disconnecting...") + self.connect_button.set_sensitive(False) + self.wifi_service.disconnect_wifi() + elif self.access_point.bssid: + # Connect to this network + self.connect_button.set_label("Connecting...") + self.connect_button.set_sensitive(False) + self.wifi_service.connect_to_wifi(self.access_point) + + +class Network(Tile): + """Network tile for the dashboard that shows WiFi status and networks.""" + + def __init__(self, **kwargs): + # Create status label + self.label = Label( + style_classes=["desc-label", "off"], + label="Disconnected", + h_align="start", + ) + self.state = False + + # Animation properties + self._animation_timeout_id = None + self._animation_step = 0 + self._animation_direction = 1 + + super().__init__( + label="Wi-Fi", + props=self.label, + markup=icons.wifi_3, + menu=True, + **kwargs, + ) + + # Initialize network client + self.network_client = NetworkClient() + self.network_client.connect("changed", self.handle_connection_change) + self.network_client.connect("ready", self.handle_connection_change) + self.network_client.connect("wifi_device_added", self._on_wifi_device_added) + + # Initialize state + self._update_tile_state() + + # Track if content has been created to avoid showing status during init + self._content_created = False + + # Check if WiFi device is already available + if self.network_client.wifi_device: + self._setup_wifi_device() + + # Override the type_box button behavior to toggle WiFi + self._setup_content_button() + + def handle_connection_change(self, *_): + """Handle network connection state changes.""" + self._update_tile_state() + + def _animate_searching(self): + """Animate wifi icon when searching for networks""" + wifi_icons = [ + icons.wifi_0, + icons.wifi_1, + icons.wifi_2, + icons.wifi_3, + icons.wifi_2, + icons.wifi_1, + ] + + wifi = self.network_client.wifi_device + if not self.icon or not wifi or not wifi.wireless_enabled: + self._stop_animation() + return False + + if wifi.active_access_point: + self._stop_animation() + return False + + GLib.idle_add(self.icon.set_markup, wifi_icons[self._animation_step]) + + self._animation_step = (self._animation_step + 1) % len(wifi_icons) + + return True + + def _start_animation(self): + if self._animation_timeout_id is None: + self._animation_step = 0 + self._animation_direction = 1 + self._animation_timeout_id = GLib.timeout_add(500, self._animate_searching) + + def _stop_animation(self): + if self._animation_timeout_id is not None: + GLib.source_remove(self._animation_timeout_id) + self._animation_timeout_id = None + + def _update_tile_state(self): + """Update tile visual state based on WiFi status.""" + connected = False + con_label = "Disconnected" + wifi = self.network_client.wifi_device + + if wifi and wifi.active_access_point: + connected = True + # Hacky truncation: limit to 8 characters and add ellipsis + ssid = wifi.active_access_point.ssid + if len(ssid) > 8: + con_label = ssid[:8] + "..." + else: + con_label = ssid + elif wifi and not wifi.wireless_enabled: + con_label = "Off" + + if self.state != connected: + self.state = connected + if self.state: + self.remove_style_class("off") + self.add_style_class("on") + else: + self.remove_style_class("on") + self.add_style_class("off") + print("WiFi state change:", connected) + + self.label.set_label(con_label) + + # Update tile icon based on WiFi state + self._update_tile_icon() + + def _update_tile_icon(self): + """Update the tile icon based on network status""" + wifi = self.network_client.wifi_device + ethernet = self.network_client.ethernet_device + + if wifi and not wifi.wireless_enabled: + self._stop_animation() + self.icon.set_markup(icons.wifi_off) + return + + if wifi and wifi.wireless_enabled: + if wifi.active_access_point: + self._stop_animation() + # Show signal strength icon + if wifi.active_access_point.strength > 0: + strength = wifi.active_access_point.strength + if strength < 25: + self.icon.set_markup(icons.wifi_0) + elif strength < 50: + self.icon.set_markup(icons.wifi_1) + elif strength < 75: + self.icon.set_markup(icons.wifi_2) + else: + self.icon.set_markup(icons.wifi_3) + else: + self.icon.set_markup(icons.wifi_1) + else: + # WiFi is enabled but not connected - start animation + self._start_animation() + + try: + primary_device = self.network_client.primary_device + except AttributeError: + primary_device = "wireless" + + if primary_device == "wired": + self._stop_animation() + if ethernet and ethernet.internet == "activated": + self.icon.set_markup(icons.world) + else: + self.icon.set_markup(icons.world_off) + else: + if not wifi: + self._stop_animation() + self.icon.set_markup(icons.wifi_off) + + def _setup_content_button(self): + """Setup the content button to toggle WiFi instead of showing menu.""" + if hasattr(self, "type_box"): + # Disconnect the existing click handler + self.type_box.disconnect_by_func(self.handle_click) + # Connect to WiFi toggle handler + self.type_box.connect("clicked", self._on_wifi_toggle) + + def _on_wifi_toggle(self, *_): + """Handle WiFi toggle when clicking on the main content area.""" + wifi = self.network_client.wifi_device + if wifi: + # Toggle WiFi state + wifi.wireless_enabled = not wifi.wireless_enabled + + def create_content(self): + """Create the detailed network content for the dashboard.""" + # Create status label + self.status_label = Label( + label="Initializing ", h_expand=True, visible=False, h_align="center" + ) + + # Create refresh button with icon + self.refresh_button_icon = Label( + name="network-refresh-label", markup=icons.reload + ) + self.refresh_button = Button( + name="network-refresh", + child=self.refresh_button_icon, + tooltip_text="Scan for Wi-Fi networks", + on_clicked=self._refresh_access_points, + ) + add_hover_cursor(self.refresh_button) + + # Create back button with chevron left icon + self.back_button_icon = Label( + name="network-back-label", markup=icons.chevron_left + ) + self.back_button = Button( + name="network-back", + child=self.back_button_icon, + tooltip_text="Back to notifications", + on_clicked=self._on_back_clicked, + ) + add_hover_cursor(self.back_button) + + # Create header + header_box = CenterBox( + name="network-header", + start_children=[self.back_button], + center_children=[Label(name="network-title", label="Wi-Fi Networks")], + end_children=[self.refresh_button], + ) + + # Create access points list + self.ap_list_box = Box(orientation="v", spacing=4) + scrolled_window = ScrolledWindow( + name="network-ap-scrolled-window", + child=self.ap_list_box, + h_expand=True, + v_expand=True, + propagate_width=False, + propagate_height=False, + ) + + # Main container + main_container = Box( + name="network-content", + orientation="v", + spacing=8, + h_expand=True, + v_expand=True, + h_align="fill", + style_classes=["tile-content"], + children=[ + header_box, + self.status_label, + scrolled_window, + ], + ) + + # Initialize button states + self.refresh_button.set_sensitive(False) + + # Mark content as created + self._content_created = True + + # Now update UI state since content is ready + if self.network_client.wifi_device: + self._update_wifi_status_ui() + + return main_container + + def _on_wifi_device_added(self, *_): + """Handle when WiFi device is added.""" + self._setup_wifi_device() + + def _setup_wifi_device(self): + """Setup WiFi device connections and initial state.""" + if self.network_client.wifi_device: + self.network_client.wifi_device.connect("changed", self._load_access_points) + self.network_client.wifi_device.connect( + "notify::wireless-enabled", self._update_wifi_status_ui + ) + self.network_client.wifi_device.connect( + "notify::active-access-point", self._update_tile_state + ) + + # Only update UI if content has been created + if self._content_created and hasattr(self, "refresh_button"): + self._update_wifi_status_ui() + + # Update tile icon immediately + self._update_tile_icon() + else: + # Only show error if content has been created + if self._content_created and hasattr(self, "status_label"): + self.status_label.set_label("Wi-Fi device not available.") + self.status_label.set_visible(True) + if hasattr(self, "refresh_button"): + self.refresh_button.set_sensitive(False) + + def _update_wifi_status_ui(self, *_): + """Update WiFi status UI elements.""" + if not hasattr(self, "refresh_button") or not self._content_created: + return # Content not created yet + + if self.network_client.wifi_device: + enabled = self.network_client.wifi_device.wireless_enabled + self.refresh_button.set_sensitive(enabled) + + if not enabled: + self.status_label.set_label("Wi-Fi disabled.") + self.status_label.set_visible(True) + self._clear_ap_list() + else: + self.refresh_button.set_sensitive(False) + + def _on_back_clicked(self, *_): + """Handle back button click - return to default (notifications) view.""" + if self.dashboard_instance: + self.dashboard_instance.reset_to_default() + + def _refresh_access_points(self, *_): + """Refresh access points list.""" + if ( + self.network_client.wifi_device + and self.network_client.wifi_device.wireless_enabled + ): + self.status_label.set_label("Scanning for Wi-Fi networks...") + self.status_label.set_visible(True) + self._clear_ap_list() + self.network_client.wifi_device.scan() + return False + + def _clear_ap_list(self): + """Clear the access points list.""" + for child in self.ap_list_box.get_children(): + child.destroy() + + def _load_access_points(self, *_): + """Load and display access points.""" + if not hasattr(self, "ap_list_box"): + return # Content not created yet + + if ( + not self.network_client.wifi_device + or not self.network_client.wifi_device.wireless_enabled + ): + self._clear_ap_list() + self.status_label.set_label("Wi-Fi disabled.") + self.status_label.set_visible(True) + return + + self._clear_ap_list() + + access_points = self.network_client.wifi_device.access_points + + if not access_points: + self.status_label.set_label("No Wi-Fi networks found.") + self.status_label.set_visible(True) + else: + self.status_label.set_visible(False) + # Sort access points by connection status (connected first) then by signal strength + sorted_aps = sorted( + access_points, key=lambda x: (not x.is_active, -x.strength) + ) + for access_point in sorted_aps: + slot = WifiAccessPointSlot( + access_point, self.network_client, self.network_client.wifi_device + ) + self.ap_list_box.add(slot) + self.ap_list_box.show_all() diff --git a/modules/dashboard/notifications.py b/modules/dashboard/notifications.py new file mode 100644 index 0000000..ed17e16 --- /dev/null +++ b/modules/dashboard/notifications.py @@ -0,0 +1,394 @@ +""" +Dashboard notification components for Modus. +Provides notification display functionality for the dashboard. +""" + +import json +import os +from datetime import datetime + +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.centerbox import CenterBox +from fabric.widgets.label import Label +from fabric.widgets.scrolledwindow import ScrolledWindow +from gi.repository import GLib, Gtk + +import utils.icons as icons +from utils.notification_utils import ( + PERSISTENT_HISTORY_FILE, + compute_time_label, + create_historical_notification_from_data, + get_shared_notification_history, +) + + +class DashboardNotificationItem(Box): + """Individual notification item for dashboard""" + + def __init__(self, notification_data, **kwargs): + super().__init__( + name="notification-container", + orientation="v", + h_align="fill", + h_expand=True, + **kwargs, + ) + + self.notification_data = notification_data + + # Create historical notification from data + hist_notif = create_historical_notification_from_data(notification_data) + + # Create timestamp + try: + arrival_time = datetime.fromisoformat(hist_notif.timestamp) + except Exception: + arrival_time = datetime.now() + + # Create time label + self.time_label = Label( + name="notification-timestamp", + markup=compute_time_label(arrival_time), + h_align="start", + ellipsization="end", + ) + + # Create notification image box + # For dashboard, we'll use a simple icon instead of trying to load cached images + self.notif_image_box = Box( + name="notification-image", + orientation="v", + children=[ + Label(name="notification-icon", markup=icons.notifications), + Box(v_expand=True), + ], + ) + + # Create summary label + self.notif_summary_label = Label( + name="notification-summary", + markup=hist_notif.summary, + h_align="start", + ellipsization="end", + ) + + # Create app name label + self.notif_app_name_label = Label( + name="notification-app-name", + markup=f"{hist_notif.app_name}", + h_align="start", + ellipsization="end", + ) + + # Create body label + self.notif_body_label = ( + Label( + name="notification-body", + markup=hist_notif.body, + h_align="start", + ellipsization="end", + line_wrap="word-char", + ) + if hist_notif.body + else Box() + ) + if hist_notif.body: + self.notif_body_label.set_single_line_mode(True) + + # Create summary box with separators + self.notif_summary_box = Box( + name="notification-summary-box", + orientation="h", + children=[ + self.notif_summary_label, + Box( + name="notif-sep", + h_expand=False, + v_expand=False, + h_align="center", + v_align="center", + ), + self.notif_app_name_label, + Box( + name="notif-sep", + h_expand=False, + v_expand=False, + h_align="center", + v_align="center", + ), + self.time_label, + ], + ) + + # Create text box + self.notif_text_box = Box( + name="notification-text", + orientation="v", + v_align="center", + h_expand=True, + children=[ + self.notif_summary_box, + self.notif_body_label, + ], + ) + + # Create close button + self.notif_close_button = Button( + name="notif-close-button", + child=Label(name="notif-close-label", markup=icons.cancel), + on_clicked=lambda *_: self.remove_notification(), + ) + + self.notif_close_button_box = Box( + orientation="v", + children=[ + self.notif_close_button, + Box(v_expand=True), + ], + ) + + # Create main content box + content_box = Box( + name="notification-box-hist", + spacing=8, + children=[ + self.notif_image_box, + self.notif_text_box, + self.notif_close_button_box, + ], + ) + + self.add(content_box) + + def remove_notification(self): + """Remove this notification from the dashboard""" + if self.get_parent(): + self.get_parent().remove(self) + self.destroy() + + +class DashboardNotifications(Box): + """Dashboard notifications container that shows real notifications""" + + def __init__(self, **kwargs): + super().__init__(name="notification-history", orientation="v", **kwargs) + + self.notification_items = [] + self.max_notifications = 5 # Limit to 5 notifications in dashboard + + # Create header components (matching dock notifications) + self.header_label = Label( + name="nhh", + label="Notifications", + h_align="start", + h_expand=True, + ) + self.header_switch = Gtk.Switch(name="dnd-switch") + self.header_switch.set_vexpand(False) + self.header_switch.set_valign(Gtk.Align.CENTER) + self.header_switch.set_active(False) + self.header_clean = Button( + name="nhh-button", + child=Label(name="nhh-button-label", markup=icons.trash), + on_clicked=self.clear_history, + ) + self.do_not_disturb_enabled = False + self.header_switch.connect("notify::active", self.on_do_not_disturb_changed) + self.dnd_label = Label(name="dnd-label", markup=icons.notifications_off) + + self.history_header = CenterBox( + name="notification-history-header", + spacing=8, + start_children=[self.header_switch, self.dnd_label], + center_children=[self.header_label], + end_children=[self.header_clean], + ) + + # Create notifications list + self.notifications_list = Box( + name="notifications-list", + orientation="v", + spacing=4, + h_expand=True, + v_expand=True, + h_align="fill", + v_align="fill", + ) + + # Create "no notifications" label + self.no_notifications_label = Label( + name="no-notif", + markup=icons.notifications_clear, + v_align="fill", + h_align="fill", + v_expand=True, + h_expand=True, + justification="center", + ) + self.no_notifications_box = Box( + name="no-notifications-box", + v_align="fill", + h_align="fill", + v_expand=True, + h_expand=True, + children=[self.no_notifications_label], + ) + + # Create scrolled window for notifications list only + self.notifications_scrolled = ScrolledWindow( + name="notification-history-scrolled-window", + child=self.notifications_list, + h_scrollbar_policy="never", + v_scrollbar_policy="automatic", + min_content_size=(400, 200), + max_content_size=(450, 300), + h_expand=True, + v_expand=True, + h_align="fill", + v_align="fill", + propagate_width=False, + propagate_height=False, + ) + + # Add components to main container - header outside, scrolled list inside + self.add(self.history_header) + self.add(self.notifications_scrolled) + + # Add the no notifications box initially to the notifications list + self.notifications_list.add(self.no_notifications_box) + + # Connect to shared notification history + self._connect_to_notifications() + + # Load and sync DND state + self._load_and_sync_dnd_state() + + # Load initial notifications + self._load_recent_notifications() + + def _connect_to_notifications(self): + """Connect to the shared notification history for real-time updates""" + try: + self.shared_history = get_shared_notification_history() + self.shared_history.connect( + "notification-added", self._on_notification_added + ) + self.shared_history.connect("dnd-state-changed", self._on_dnd_state_changed) + except Exception as e: + print(f"Could not connect to notification history: {e}") + + def _on_notification_added(self, shared_history): + """Handle new notification added""" + GLib.idle_add(self._refresh_notifications) + + def _on_dnd_state_changed(self, notification_history, dnd_enabled): + """Handle DND state change""" + self.do_not_disturb_enabled = dnd_enabled + self.header_switch.set_active(dnd_enabled) + + def _load_and_sync_dnd_state(self): + """Load and sync DND state from shared history""" + try: + if hasattr(self, "shared_history"): + dnd_enabled = self.shared_history.do_not_disturb_enabled + self.header_switch.set_active(dnd_enabled) + self.do_not_disturb_enabled = dnd_enabled + except Exception as e: + print(f"Could not sync DND state: {e}") + + def on_do_not_disturb_changed(self, switch, pspec): + """Handle DND switch toggle""" + self.do_not_disturb_enabled = switch.get_active() + + try: + if hasattr(self, "shared_history"): + self.shared_history.set_do_not_disturb_enabled( + self.do_not_disturb_enabled + ) + except Exception as e: + print(f"Could not update shared DND state: {e}") + + def clear_history(self, *args): + """Clear all notifications""" + # Clear notification items + for item in self.notification_items[:]: + self.notifications_list.remove(item) + item.destroy() + self.notification_items.clear() + + # Clear persistent history + try: + if hasattr(self, "shared_history"): + self.shared_history.clear_history() + except Exception as e: + print(f"Could not clear shared history: {e}") + + # Update visibility + self._update_no_notifications_visibility() + + def _load_recent_notifications(self): + """Load recent notifications from persistent storage""" + try: + if os.path.exists(PERSISTENT_HISTORY_FILE): + with open(PERSISTENT_HISTORY_FILE, "r") as f: + notifications = json.load(f) + + # Get the most recent notifications (up to max_notifications) + recent_notifications = notifications[-self.max_notifications :] + + for notif_data in reversed(recent_notifications): + self._add_notification_item(notif_data) + + except Exception as e: + print(f"Error loading recent notifications: {e}") + + def _refresh_notifications(self): + """Refresh the notification list""" + # Clear existing notifications + for item in self.notification_items[:]: + self.notifications_list.remove(item) + item.destroy() + self.notification_items.clear() + + # Reload recent notifications + self._load_recent_notifications() + + # Update visibility + self._update_no_notifications_visibility() + + def _add_notification_item(self, notification_data): + """Add a notification item to the dashboard""" + # Remove no notifications box if it's showing + if self.no_notifications_box in self.notifications_list.get_children(): + self.notifications_list.remove(self.no_notifications_box) + + if len(self.notification_items) >= self.max_notifications: + # Remove oldest notification + oldest = self.notification_items.pop(0) + self.notifications_list.remove(oldest) + oldest.destroy() + + # Create new notification item + item = DashboardNotificationItem(notification_data) + self.notification_items.append(item) + self.notifications_list.add(item) + + # Update visibility + self._update_no_notifications_visibility() + + # Show all to ensure visibility + self.show_all() + + def _update_no_notifications_visibility(self): + """Update visibility of no notifications label""" + has_notifications = bool(self.notification_items) + + if has_notifications: + # Hide no notifications box + if self.no_notifications_box in self.notifications_list.get_children(): + self.notifications_list.remove(self.no_notifications_box) + else: + # Show no notifications box + if self.no_notifications_box not in self.notifications_list.get_children(): + self.notifications_list.add(self.no_notifications_box) diff --git a/modules/dashboard/player_mini.py b/modules/dashboard/player_mini.py new file mode 100644 index 0000000..f842b44 --- /dev/null +++ b/modules/dashboard/player_mini.py @@ -0,0 +1,462 @@ +import gi +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.centerbox import CenterBox +from fabric.widgets.eventbox import EventBox +from fabric.widgets.label import Label +from fabric.widgets.stack import Stack +from loguru import logger + +import config.data as data +import utils.icons as icons +from services.mpris import PlayerManager, PlayerService +from utils.wiggle_bar import WigglyWidget + +gi.require_version("Gtk", "3.0") + + +class PlayerMini(Box): + def __init__(self, player, **kwargs): + super().__init__(style_classes="player-mini", **kwargs) + + if not data.VERTICAL: + self.remove_style_class( + "vertical" + ) # vertical class binding from unknown source + + self._player = PlayerService(player=player) + + self.duration = 0.0 + + self._player.connect("pause", self.on_pause) + self._player.connect("play", self.on_play) + self._player.connect("meta-change", self.on_metadata) + self._player.connect("shuffle-toggle", self.on_shuffle) + self._player.connect("track-position", self.on_update_track_position) + + self.player_name = Label( + name=player.props.player_name, + style_classes=["player-icon", "mini"], + markup=getattr(icons, player.props.player_name, icons.disc), + ) + + self.set_style(f"background-image:url('{data.HOME_DIR}/.current.wall')") + + self.song = Label( + name="song", + label="song", + style_classes="mini", + justification="left", + h_align="start", + ellipsization="end", + max_chars_width=15, + ) + self.artist = Label( + name="artist", + label="artist", + style_classes="mini", + justification="left", + h_align="start", + ) + self.music = Box( + name="music", + h_expand=True, + v_expand=True, + children=[self.song], + ) + + self.play_pause_button = Button( + name="play-pause-button", + child=Label(name="play-pause-label", markup=icons.play), + style_classes="mini", + tooltip_text="Play/Pause", + on_clicked=lambda b, *_: self.handle_play_pause(player), + ) + + self.shuffle_button = Button( + name="shuffle-button", + child=Label(name="shuffle", markup=icons.shuffle), + on_clicked=lambda b, *_: self.handle_shuffle(b, player), + ) + + self.wiggly = WigglyWidget() + self.wiggly.connect("on-seek", self.on_seek) + self.wiggly_bar = Box( + orientation="v", + h_expand=True, + v_expand=True, + h_align="fill", + v_align="fill", + children=self.wiggly, + ) + + self.album_cover = Box(style_classes="album-image") + self.album_cover.set_style( + f"background-image:url('{data.HOME_DIR}/.current.wall')" + ) + + self.children = [ + self.album_cover, + Box( + name="source", + style_classes="mini", + v_align="end", + children=self.player_name, + ), + # CenterBox( + # name="progress-container", + # h_expand=True, + # v_expand=True, + # orientation="v", + # center_children=[self.wiggly_bar], + # ), + Box( + orientation="v", + v_expand=True, + h_expand=True, + style="padding-left:10px;", + children=[ + CenterBox( + name="details", + center_children=self.music, + ), + Box( + name="controls", + style_classes="horizontal" if not data.VERTICAL else "vertical", + spacing=5, + h_expand=True, + children=[ + CenterBox( + h_expand=True, + v_expand=True, + center_children=[ + Button( + name="prev-button", + child=Label( + name="play-previous", + style_classes="mini", + markup=icons.previous_fill, + ), + on_clicked=lambda b, *_: self.handle_prev( + player + ), + ), + self.play_pause_button, + Button( + name="next-button", + child=Label( + name="play-next", + style_classes="mini", + markup=icons.next_fill, + ), + on_clicked=lambda b, *_: self.handle_next( + player + ), + ), + # self.shuffle_button, + ], + ) + ], + ), + ], + ), + ] + + self.on_metadata(self._player, metadata=player.props.metadata, player=player) + + def on_update_track_position(self, sender, pos, dur): + if dur == 0: + return + self.duration = dur + self.wiggly.update_value_from_signal(pos / dur) + + def on_seek(self, sender, ratio): + pos = ratio * self.duration # duration in seconds + print(f"Seeking to {pos:.2f}s") + self._player.set_position(int(pos)) + + def skip_forward(self, seconds=10): + self._player._player.seek(seconds * 1000000) + + def skip_backward(self, seconds=10): + self._player._player.seek(-1 * seconds * 1000000) + + def on_metadata(self, sender, metadata, player): + keys = metadata.keys() + if "xesam:artist" in keys and "xesam:title" in keys: + # _max_chars = 20 if not data.VERTICAL else 30 + song_title = metadata["xesam:title"] + # if len(song_title) > _max_chars: + # song_title = song_title[: _max_chars - 1] + "…" + self.song.set_label(song_title) + + artist_list = metadata["xesam:artist"] + artist_name = artist_list[0] if artist_list else "Unknown Artist" + # if len(artist_name) > _max_chars: + # artist_name = artist_name[: _max_chars - 1] + "…" + self.artist.set_label(artist_name) + if "mpris:artUrl" in keys: + self.set_style(f"background-image:url('{metadata['mpris:artUrl']}')") + self.album_cover.set_style( + f"background-image:url('{metadata['mpris:artUrl']}')" + ) + + if ( + player.props.playback_status.value_name + == "PLAYERCTL_PLAYBACK_STATUS_PLAYING" + ): + self.on_play(self._player) + + if player.props.shuffle == True: + self.shuffle_button.get_child().set_markup(icons.disable_shuffle) + self.shuffle_button.get_child().set_name("disable-shuffle") + else: + self.shuffle_button.get_child().set_markup(icons.shuffle) + self.shuffle_button.get_child().set_name("shuffle") + + def on_pause(self, sender): + self.play_pause_button.get_child().set_markup(icons.play) + self.wiggly.dragging = True + self.wiggly.update_amplitude(True) + self.wiggly.pause = True + # self.play_pause_button.add_style_class("pause-track") + + def on_play(self, sender): + self.play_pause_button.get_child().set_markup(icons.pause) + self.wiggly.pause = False + self.wiggly.dragging = False + self.wiggly.update_amplitude(False) + # self.play_pause_button.remove_style_class("pause-track") + + def on_shuffle(self, sender, player, status): + print("callback status", status) + if status == False: + self.shuffle_button.get_child().set_markup(icons.shuffle) + self.shuffle_button.get_child().set_name("shuffle") + else: + self.shuffle_button.get_child().set_markup(icons.disable_shuffle) + self.shuffle_button.get_child().set_name("disable-shuffle") + + self.shuffle_button.get_child().set_style("color: white") + + def handle_next(self, player): + self._player._player.next() + + def handle_prev(self, player): + self._player._player.previous() + + def handle_play_pause(self, player): + is_playing = ( + self._player._player.props.playback_status.value_name + == "PLAYERCTL_PLAYBACK_STATUS_PLAYING" + ) + + def _set_play_ui(): + self.play_pause_button.get_child().set_markup(icons.pause) + # self.play_pause_button.remove_style_class("pause-track") + # self.play_pause_button.get_child().set_name("pause-label") + + def _set_pause_ui(): + self.play_pause_button.get_child().set_markup(icons.play) + # self.play_pause_button.add_style_class("pause-track") + # self.play_pause_button.get_child().set_name("play-label") + + if is_playing: + _set_pause_ui() + else: + _set_play_ui() + + try: + self._player._player.play_pause() + except Exception as e: + # revert if signal failed + if is_playing: + _set_pause_ui() + else: + _set_play_ui() + logger.warning("Failed to toggle playback:", e) + + def handle_shuffle(self, shuffle_button, player): + print("shuffle", player.props.shuffle) + if player.props.shuffle == False: + player.set_shuffle(True) + print("setting to true", player.props.player_name) + else: + player.set_shuffle(False) + shuffle_button.get_child().set_style("color: var(--outline)") + + +class PlacheholderMini(Box): + def __init__(self, **kwargs): + super().__init__(style_classes="player-mini", **kwargs) + + self.set_style(f"background-image:url('{data.HOME_DIR}/.current.wall')") + self.album_cover = Box(style_classes="album-image") + self.album_cover.set_style( + f"background-image:url('{data.HOME_DIR}/.current.wall')" + ) + self.player_name = Label( + style_classes=["player-icon", "mini"], markup=icons.disc + ) + + self.children = [ + self.album_cover, + Box( + name="source", + style_classes="mini", + v_align="end", + children=self.player_name, + ), + Box( + orientation="v", + v_expand=True, + h_expand=True, + style="padding-left:10px;", + v_align="center", + children=[ + CenterBox( + name="details", + center_children=Label( + label="Nothing Playing", style="color:black;" + ), + ) + ], + ), + ] + + +class PlayerContainerMini(Box): + def __init__(self, **kwargs): + super().__init__(name="player-container", style_classes="mini", **kwargs) + + self.manager = PlayerManager() + self.manager.connect("new-player", self.new_player) + self.manager.connect("player-vanish", self.on_player_vanish) + self.placeholder = PlacheholderMini() + self.stack = Stack( + # name="player-container", + transition_type="crossfade", + transition_duration=100, + children=[self.placeholder], + ) + + self.player_stack = Stack( + name="player-stack", + transition_type="crossfade", + transition_duration=100, + children=[], + ) + + self.player_switch_container = CenterBox( + name="player-switch-container", + orientation="v", + style_classes=( + ["horizontal-player", "mini"] + if not data.VERTICAL + else "vertical-player" + ), + center_children=[], + ) + self.mini_tile_icon = Label( + name="disc", + style_classes=["tile-icon"], + style="font-size:30px; margin:0px; padding:0px;", + markup=icons.disc, + justification="center", + ) + + self.mini_tile_view = CenterBox( + style_classes=["tile", "mini", "on"], + v_expand=False, + center_children=self.mini_tile_icon, + ) + self.event_box = EventBox( + events=["scroll"], + child=Box(children=[self.stack, self.player_switch_container]), + ) + self.children = self.event_box + self.players = [] + self.manager.init_all_players() + self.event_box.connect("scroll-event", self.on_scroll) + self.stack.connect("notify::visible-child", self.on_visible_child_changed) + + def new_player(self, manager, player): + print(player.props.player_name, "new player") + print(player) + new_player = PlayerMini(player=player) + new_player.wiggly_bar.queue_draw() + new_player.set_name(player.props.player_name) + self.players.append(new_player) + print("stacking mini", player.props.player_name) + self.stack.add_named(new_player, player.props.player_name) + if len(self.players) == 1: + self.stack.remove(self.placeholder) + + self.player_switch_container.add_center( + Button( + name=player.props.player_name, + style_classes=["player-button", "mini"], + on_clicked=lambda b: self.switch_player(player.props.player_name, b), + ) + ) + self.update_player_list() + + def switch_player(self, player_name, button): + self.stack.set_visible_child_name(player_name) + + for btn in self.player_switch_container.center_children: + btn.remove_style_class("active") + button.add_style_class("active") + + def on_player_vanish(self, manager, player): + for player_instance in self.players: + if player_instance.get_name() == player.props.player_name: + self.stack.remove(player_instance) + self.players.remove(player_instance) + for btn in self.player_switch_container.center_children: + if btn.get_name() == player_instance.get_name(): + self.player_switch_container.remove_center(btn) + self.update_player_list() + break + + if len(self.players) == 0: + self.stack.add_named(self.placeholder, "placeholder") + + def update_player_list(self): + curr = self.stack.get_visible_child() + for btn in self.player_switch_container.center_children: + print(btn.get_name()) + if btn.get_name() == curr.get_name(): + btn.add_style_class("active") + else: + btn.remove_style_class("active") + + def on_scroll(self, widget, event): + match event.direction: + case 0: + self.switch_relative_player(forward=False) + case 1: + self.switch_relative_player(forward=True) + + def switch_relative_player(self, forward=True): + if not self.players: + return + + current_player = self.stack.get_visible_child() + current_index = self.players.index(current_player) + + next_index = (current_index + (1 if forward else -1)) % len(self.players) + next_player = self.players[next_index] + + for btn in self.player_switch_container.center_children: + if btn.get_name() == next_player.get_name(): + self.switch_player(next_player.get_name(), btn) + break + + def on_visible_child_changed(self, *args): + curr_player = self.stack.get_visible_child().get_name() + self.mini_tile_icon.set_name(curr_player) + self.mini_tile_icon.set_markup(getattr(icons, curr_player, icons.disc)) + + def get_mini_view(self): + return self.mini_tile_view diff --git a/modules/dashboard/tile.py b/modules/dashboard/tile.py new file mode 100644 index 0000000..f04e2f1 --- /dev/null +++ b/modules/dashboard/tile.py @@ -0,0 +1,216 @@ +""" +Dashboard Tile Components + +This module provides tile widgets for the dashboard interface. + +Overview: +- Tiles are interactive components that display an icon, label, and status +- When clicked, tiles show their detailed content in the dashboard's content area +- Tiles can switch between mini (compact) and maxi (full) views +- Two tile types are available: Tile (standard) and TileSpecial (custom layouts) + +Architecture: +- Tile: Standard tile with icon, label, properties, and optional menu button +- TileSpecial: Custom tile that can switch between different view layouts +- Both tiles communicate with the parent dashboard to show content + +Usage: +- Create tiles with appropriate properties and add them to the dashboard +- The dashboard handles content switching and tile state management +- Tiles automatically notify the dashboard when clicked +""" + +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.label import Label +from fabric.widgets.revealer import Revealer +from fabric.widgets.stack import Stack +from gi.repository import Gdk + +import utils.icons as icons + + +def add_hover_cursor(widget): + """Add hover cursor functionality to a widget.""" + widget.add_events(Gdk.EventMask.ENTER_NOTIFY_MASK | Gdk.EventMask.LEAVE_NOTIFY_MASK) + widget.connect( + "enter-notify-event", + lambda w, e: w.get_window().set_cursor( + Gdk.Cursor.new_from_name(w.get_display(), "pointer") + ) + if w.get_window() + else None, + ) + widget.connect( + "leave-notify-event", + lambda w, e: w.get_window().set_cursor(None) if w.get_window() else None, + ) + + +class Tile(Box): + """ + A dashboard tile widget that displays an icon, label, and properties. + + When clicked, the tile notifies the dashboard to show its detailed content + in the dashboard's content area instead of expanding inline. + + Args: + menu (bool): Whether this tile has a menu button + markup (str): Icon markup to display + label (str): Text label for the tile + props (Label): Properties widget to show tile status + **kwargs: Additional widget arguments + """ + + def __init__(self, *, menu: bool, markup: str, label: str, props: Label, **kwargs): + # Setup CSS classes + default_classes = ["tile"] + extra_classes = kwargs.pop("style_classes", []) + all_classes = default_classes + extra_classes + + super().__init__(style_classes=all_classes, v_align="start", **kwargs) + + # Store tile properties + self.props = props + self.toggle = False # Track if tile is active + self.dashboard_instance = None # Reference to parent dashboard + + # Create tile components + self._create_tile_icon(markup) + self._create_tile_content(label) + self._create_tile_layout(menu) + + def _create_tile_icon(self, markup: str): + """Create the tile's icon label.""" + self.icon = Label(style_classes="tile-icon", markup=markup) + + def _create_tile_content(self, label: str): + """Create the tile's text content area.""" + self.tile_label = Label( + style_classes="tile-label", label=label, h_align="start" + ) + + # Container for label and properties + content_box = Box( + style_classes="tile-type", + orientation="v", + v_expand=True, + h_expand=True, + v_align="center", + children=[self.tile_label, self.props], + ) + + # Make the type_box clickable using a button + self.type_box = Button( + style_classes="tile-content-button", + h_expand=True, + v_expand=True, + child=content_box, + on_clicked=self.handle_click, + ) + # Add hover cursor to the content area + add_hover_cursor(self.type_box) + + def _create_tile_layout(self, has_menu: bool): + """Create the tile's layout structure.""" + # Create menu button if needed + if has_menu: + self.menu_button = Button( + style_classes="tile-button", + h_expand=True, + child=Label(style_classes="tile-icon", markup=icons.chevron_right), + on_clicked=self.handle_click, + ) + # Add hover cursor to menu button + add_hover_cursor(self.menu_button) + content_children = [self.type_box, self.menu_button] + else: + content_children = [self.type_box] + + # Create revealer for smooth transitions + self.content_button = Revealer( + transition_duration=150, + transition_type="slide-left", + h_expand=True, + child=Box(children=content_children), + child_revealed=True, + ) + + # Create the main tile view + self.normal_view = Box(children=[self.icon, self.content_button]) + + # Create placeholder menu (content is shown in dashboard content area) + self.menu = Box( + style_classes="tile-menu", + children=[ + Label(label="Tile content will be shown in dashboard content area") + ], + ) + + # Stack to switch between normal and menu views + self.stack = Stack( + transition_type="crossfade", + transition_duration=150, + h_expand=True, + children=[self.normal_view, self.menu], + ) + + self.children = self.stack + + def handle_click(self, *_): + """ + Handle tile click events. + + When clicked, the tile toggles its active state and notifies the dashboard + to show/hide its content in the dashboard's content area. + """ + self.toggle = not self.toggle + print(f"Tile {self.get_name()} clicked, toggle: {self.toggle}") + + # Notify dashboard to handle content switching + if self.dashboard_instance: + self.dashboard_instance.handle_tile_menu_expand( + self.get_name(), self.toggle + ) + + +class TileSpecial(Box): + """ + A special tile widget that can switch between normal and mini views. + + This tile type is used for components that need different layouts + in normal vs compact modes (e.g., media player). + + Args: + mini_props: Widget(s) to show in mini view + props: Widget(s) to show in normal view + **kwargs: Additional widget arguments + """ + + def __init__(self, *, mini_props, props, **kwargs): + super().__init__(**kwargs) + + # Store view components + self.props = props + self.mini_props = mini_props + self.toggle = False + + # Create view containers + self._create_views() + self._setup_layout() + + def _create_views(self): + """Create the normal and collapsed view containers.""" + self.normal_view = Box(children=self.props) + self.collapsed_view = Box(children=self.mini_props) + + def _setup_layout(self): + """Setup the stack layout for switching between views.""" + self.stack = Stack( + transition_type="crossfade", + transition_duration=150, + h_expand=True, + v_align="start", + children=[self.normal_view, self.collapsed_view], + ) + self.children = self.stack diff --git a/modules/dock.py b/modules/dock.py deleted file mode 100644 index fb89eb7..0000000 --- a/modules/dock.py +++ /dev/null @@ -1,1133 +0,0 @@ -import json -from gi.repository import GLib, Gtk, Gdk -import cairo -from utils.icon_resolver import IconResolver -from utils.occlusion import check_occlusion -from fabric.widgets.box import Box -from fabric.widgets.button import Button -from fabric.widgets.image import Image -from fabric.widgets.eventbox import EventBox -from fabric.widgets.wayland import WaylandWindow as Window -from fabric.hyprland.widgets import get_hyprland_connection -from fabric.utils import ( - exec_shell_command, - exec_shell_command_async, - idle_add, - remove_handler, - get_relative_path, -) -from fabric.utils.helpers import get_desktop_applications -from modules.bar.bar import MyCorner # Add this import for corners - -import config.data as data -import logging - -OCCLUSION = 36 + data.DOCK_ICON_SIZE - - -# Credit to Aylur for the createSurfaceFromWidget code -def createSurfaceFromWidget(widget: Gtk.Widget) -> cairo.ImageSurface: - alloc = widget.get_allocation() - surface = cairo.ImageSurface( - cairo.Format.ARGB32, - alloc.width, - alloc.height, - ) - cr = cairo.Context(surface) - cr.set_source_rgba(255, 255, 255, 0) - cr.rectangle(0, 0, alloc.width, alloc.height) - cr.fill() - widget.draw(cr) - return surface - - -class Dock(Window): - # Static registry to track all active dock instances - _instances = [] - - def __init__(self, **kwargs): - super().__init__( - name="dock-window", - layer="top", - anchor="bottom center" - if not data.VERTICAL and not data.BOTTOM_BAR - else "left center" - if data.VERTICAL_RIGHT_ALIGN - else "right center", - margin="-8px 0 -8px 0" - if not data.VERTICAL and not data.BOTTOM_BAR - else "-8px 0 -80px 0" - if data.VERTICAL_RIGHT_ALIGN - else "0 -80px 0 -8px", - exclusivity="none", - **kwargs, - ) - # Add this instance to the registry - Dock._instances.append(self) - - # Initialize configuration - self.config = data.read_config() - self.conn = get_hyprland_connection() - self.icon = IconResolver() - self.pinned = self.config.get("pinned_apps", []) - self.config_path = get_relative_path("../config/assets/modus.json") - self.app_map = {} # Initialize the app map - self._all_apps = get_desktop_applications() # Get all apps for lookup - # Create app identifiers mapping - self.app_identifiers = self._build_app_identifiers_map() - self.is_hidden = False - self.hide_id = None - self._arranger_handler = None - self._drag_in_progress = False # Drag lock flag - self.is_hovered = False - self.always_occluded = ( - data.DOCK_ALWAYS_OCCLUDED - ) # Track the always occluded setting - - # Set up UI containers - self.view = Box( - name="viewport", - orientation="h" - if not data.VERTICAL - and not data.VERTICAL_RIGHT_ALIGN - and not data.BOTTOM_BAR - else "v", - spacing=4, - ) - self.wrapper = Box(name="dock", orientation="v", children=[self.view]) - - # Main dock container with hover handling - self.dock_eventbox = EventBox() - self.dock_eventbox.add(self.wrapper) - self.dock_eventbox.connect("enter-notify-event", self._on_dock_enter) - self.dock_eventbox.connect("leave-notify-event", self._on_dock_leave) - - # Helper to create corner boxes - def create_corner_box(name, orientation, align, corner_position): - return Box( - name=name, - orientation=orientation, - **align, - children=[ - Box( - **( - {"v_expand": True, "v_align": "fill"} - if orientation == "v" - else {"h_expand": True, "h_align": "fill"} - ) - ), - MyCorner(corner_position), - ], - ) - - # Determine dock layout based on orientation - if not data.VERTICAL and not data.BOTTOM_BAR: - # Horizontal layout: corners at the bottom - self.corner_left = create_corner_box( - name="dock-corner-left", - orientation="v", - align={"h_align": "start"}, - corner_position="bottom-right", - ) - - self.corner_right = create_corner_box( - name="dock-corner-right", - orientation="v", - align={"h_align": "end"}, - corner_position="bottom-left", - ) - - self.dock_full = Box( - name="dock-full", - orientation="h", - h_expand=True, - h_align="fill", - children=[ - self.corner_left, - self.dock_eventbox, - self.corner_right, - ], - ) - - elif data.VERTICAL_RIGHT_ALIGN: - # Vertical right-aligned layout: corners at top and bottom (left-angled corners) - self.corner_top = create_corner_box( - name="dock-corner-top", - orientation="h", - align={"v_align": "start"}, - corner_position="bottom-left", - ) - - self.corner_bottom = create_corner_box( - name="dock-corner-bottom", - orientation="h", - align={"v_align": "end"}, - corner_position="top-left", - ) - - self.dock_full = Box( - name="dock-full", - orientation="v", - v_expand=True, - v_align="fill", - children=[ - self.corner_top, - self.dock_eventbox, - self.corner_bottom, - ], - ) - - else: - # Vertical left/default alignment: corners at top and bottom (right-angled corners) - self.corner_top = create_corner_box( - name="dock-corner-top", - orientation="h", - align={"v_align": "start"}, - corner_position="bottom-right", - ) - - self.corner_bottom = create_corner_box( - name="dock-corner-bottom", - orientation="h", - align={"v_align": "end"}, - corner_position="top-right", - ) - - self.dock_full = Box( - name="dock-full", - orientation="v", - v_expand=True, - v_align="fill", - children=[ - self.corner_top, - self.dock_eventbox, - self.corner_bottom, - ], - ) - # Bottom/side hover activation area - self.hover_activator = EventBox() - self.hover_activator.set_size_request( - -1 - if not data.VERTICAL - and not data.VERTICAL_RIGHT_ALIGN - and not data.BOTTOM_BAR - else 1, - 1 - if not data.VERTICAL - and not data.VERTICAL_RIGHT_ALIGN - and not data.BOTTOM_BAR - else -1, - ) - self.hover_activator.connect("enter-notify-event", self._on_hover_enter) - self.hover_activator.connect("leave-notify-event", self._on_hover_leave) - - # Update main_box to include the dock_full and activator - self.main_box = Box( - orientation="v" - if not data.VERTICAL - and not data.VERTICAL_RIGHT_ALIGN - and not data.BOTTOM_BAR - else "h", - children=[self.dock_full, self.hover_activator], - ) - self.add(self.main_box) - - # Drag-and-drop setup for the viewport - self.view.drag_source_set( - Gdk.ModifierType.BUTTON1_MASK, - [Gtk.TargetEntry.new("text/plain", Gtk.TargetFlags.SAME_APP, 0)], - Gdk.DragAction.MOVE, - ) - self.view.drag_dest_set( - Gtk.DestDefaults.ALL, - [Gtk.TargetEntry.new("text/plain", Gtk.TargetFlags.SAME_APP, 0)], - Gdk.DragAction.MOVE, - ) - self.view.connect("drag-data-get", self.on_drag_data_get) - self.view.connect("drag-data-received", self.on_drag_data_received) - self.view.connect("drag-begin", self.on_drag_begin) - self.view.connect("drag-end", self.on_drag_end) - - # Initialization - if self.conn.ready: - self.update_dock() - GLib.timeout_add(500, self.check_hide) - else: - self.conn.connect("event::ready", self.update_dock) - self.conn.connect("event::ready", self.check_hide) - - for ev in ("activewindow", "openwindow", "closewindow", "changefloatingmode"): - self.conn.connect(f"event::{ev}", self.update_dock) - self.conn.connect("event::workspace", self.check_hide) - - if data.VERTICAL: - self.wrapper.add_style_class("vertical") - else: - self.wrapper.remove_style_class("vertical") - - if data.BOTTOM_BAR: - self.wrapper.add_style_class("vertical-right") - else: - self.wrapper.remove_style_class("vertical-right") - - if data.VERTICAL_RIGHT_ALIGN: - self.wrapper.add_style_class("vertical-left") - else: - self.wrapper.remove_style_class("vertical-left") - - GLib.timeout_add(250, self.check_occlusion_state) - # Monitor modus.json for changes - GLib.timeout_add_seconds(1, self.check_config_change) - - # Check if dock should be visible based on configuration - if not data.DOCK_ENABLED: - self.set_visible(False) - - # Apply always occluded state if enabled in settings - if self.always_occluded: - self.dock_full.add_style_class("occluded") - - def _build_app_identifiers_map(self): - """Build a mapping of app identifiers (class names, executables, names) to DesktopApp objects""" - identifiers = {} - for app in self._all_apps: - # Map by name (lowercase) - if app.name: - identifiers[app.name.lower()] = app - - # Map by display name - if app.display_name: - identifiers[app.display_name.lower()] = app - - # Map by window class if available - if app.window_class: - identifiers[app.window_class.lower()] = app - - # Map by executable name if available - if app.executable: - exe_basename = app.executable.split("/")[-1].lower() - identifiers[exe_basename] = app - - # Map by command line if available (without parameters) - if app.command_line: - cmd_base = app.command_line.split()[0].split("/")[-1].lower() - identifiers[cmd_base] = app - - return identifiers - - def _normalize_window_class(self, class_name): - """Normalize window class by removing common suffixes and lowercase.""" - if not class_name: - return "" - - normalized = class_name.lower() - - # Remove common suffixes - suffixes = [".bin", ".exe", ".so", "-bin", "-gtk"] - for suffix in suffixes: - if normalized.endswith(suffix): - normalized = normalized[: -len(suffix)] - - return normalized - - def _classes_match(self, class1, class2): - """Check if two window class names match with stricter comparison.""" - if not class1 or not class2: - return False - - # Normalize both classes - norm1 = self._normalize_window_class(class1) - norm2 = self._normalize_window_class(class2) - - # Direct match after normalization - if norm1 == norm2: - return True - - # Don't do substring matching as it's too error-prone - # This avoids incorrectly matching flatpak apps and others - return False - - def on_drag_begin(self, widget, drag_context): - """Handle drag begin event by setting the drag lock flag.""" - self._drag_in_progress = True - # Set custom drag icon using the widget surface - Gtk.drag_set_icon_surface(drag_context, createSurfaceFromWidget(widget)) - - def _on_hover_enter(self, *args): - """Handle hover over bottom activation area""" - self.toggle_dock(show=True) - - def _on_hover_leave(self, *args): - """Handle leave from bottom activation area""" - self.delay_hide() - - def _on_dock_enter(self, widget, event): - """Handle hover over dock content""" - self.is_hovered = True - self.dock_full.remove_style_class("occluded") - # Cancel any pending hide operations - if self.hide_id: - GLib.source_remove(self.hide_id) - self.hide_id = None - self.toggle_dock(show=True) - return True # Important: Stop event propagation - - def _on_dock_leave(self, widget, event): - """Handle leave from dock content""" - # Only trigger if mouse actually left the entire dock area - if event.detail == Gdk.NotifyType.INFERIOR: - return False # Ignore child-to-child mouse movements - - self.is_hovered = False - self.delay_hide() - # Immediate occlusion check on true leave using simplified format - occlusion_region = ( - ("bottom", OCCLUSION) - if not data.VERTICAL - and not data.VERTICAL_RIGHT_ALIGN - and not data.BOTTOM_BAR - else ("left", OCCLUSION) - if data.VERTICAL_RIGHT_ALIGN - else ("right", OCCLUSION) - ) - # Add occlusion style if not dragging an icon or if always_occluded is enabled - if self.always_occluded or ( - not self._drag_in_progress - and (check_occlusion(occlusion_region) or not self.view.get_children()) - ): - self.dock_full.add_style_class("occluded") - return True - - # Enhanced app lookup methods - def find_app(self, app_identifier): - """Return the DesktopApp object by matching any app identifier. - - If app_identifier is a dict, it will use all available keys for matching. - """ - if not app_identifier: - return None - - # If we got a dict object with app data (new format) - if isinstance(app_identifier, dict): - # Try to find by all available identifiers in priority order - for key in [ - "window_class", - "executable", - "command_line", - "name", - "display_name", - ]: - if key in app_identifier and app_identifier[key]: - app = self.find_app_by_key(app_identifier[key]) - if app: - return app - return None - - # Simple string identifier (backward compatibility) - return self.find_app_by_key(app_identifier) - - def find_app_by_key(self, key_value): - """Find app by a single identifier value""" - if not key_value: - return None - - # Try direct lookup in our identifiers map - normalized_id = str(key_value).lower() - if normalized_id in self.app_identifiers: - return self.app_identifiers[normalized_id] - - # Try fuzzy matching - find apps where the identifier is part of their names - for app in self._all_apps: - if app.name and normalized_id in app.name.lower(): - return app - if app.display_name and normalized_id in app.display_name.lower(): - return app - if app.window_class and normalized_id in app.window_class.lower(): - return app - if app.executable and normalized_id in app.executable.lower(): - return app - if app.command_line and normalized_id in app.command_line.lower(): - return app - - return None - - # Update the dock's app map using DesktopApp objects from the system. - def update_app_map(self): - """Updates the mapping of commands to DesktopApp objects.""" - self._all_apps = get_desktop_applications() # Refresh all apps - self.app_map = { - app.name: app for app in self._all_apps if app.name - } # Map app names to DesktopApp objects - self.app_identifiers = ( - self._build_app_identifiers_map() - ) # Rebuild identifiers map - - def create_button(self, app_identifier, instances): - """Create dock application button""" - desktop_app = self.find_app(app_identifier) # Find app by identifier - icon_img = None - display_name = None - - if desktop_app: - icon_img = desktop_app.get_icon_pixbuf(size=data.DOCK_ICON_SIZE) - display_name = desktop_app.display_name or desktop_app.name - - # Extract identifier for fallback - id_value = ( - app_identifier["name"] - if isinstance(app_identifier, dict) - else app_identifier - ) - - if not icon_img: - # Fallback to IconResolver with the app command - icon_img = self.icon.get_icon_pixbuf( - id_value, data.DOCK_ICON_SIZE - ) # Use identifier for fallback - - if not icon_img: # Double check after exec path try - # Fallback icon if no DesktopApp is found - icon_img = self.icon.get_icon_pixbuf( - "application-x-executable-symbolic", data.DOCK_ICON_SIZE - ) - # Final fallback - if not icon_img: - icon_img = self.icon.get_icon_pixbuf( - "image-missing", data.DOCK_ICON_SIZE - ) - - items = [Image(pixbuf=icon_img)] - - tooltip = display_name or (id_value if isinstance(id_value, str) else "Unknown") - if not display_name and instances and instances[0].get("title"): - tooltip = instances[0]["title"] - - button = Button( - child=Box( - name="dock-icon", - orientation="v", - h_align="center", - children=items, - ), - on_clicked=lambda *a: self.handle_app( - app_identifier, instances, desktop_app - ), # Pass desktop_app as well - tooltip_text=tooltip, - name="dock-app-button", - ) - - # Store app data with the button for future reference - button.app_identifier = app_identifier - button.desktop_app = desktop_app - button.instances = instances - - if instances: - button.add_style_class("instance") # Style running apps - - # Enable DnD for ALL apps - button.drag_source_set( - Gdk.ModifierType.BUTTON1_MASK, - [Gtk.TargetEntry.new("text/plain", Gtk.TargetFlags.SAME_APP, 0)], - Gdk.DragAction.MOVE, - ) - button.connect("drag-begin", self.on_drag_begin) - button.connect("drag-end", self.on_drag_end) # Connect drag-end to ALL buttons - - # Make all buttons drop targets for better reordering - button.drag_dest_set( - Gtk.DestDefaults.ALL, - [Gtk.TargetEntry.new("text/plain", Gtk.TargetFlags.SAME_APP, 0)], - Gdk.DragAction.MOVE, - ) - button.connect("drag-data-get", self.on_drag_data_get) - button.connect("drag-data-received", self.on_drag_data_received) - - button.connect("enter-notify-event", self._on_child_enter) - return button - - # Enhanced app launching with multiple fallbacks - def handle_app(self, app_identifier, instances, desktop_app=None): - """Handle application button clicks with improved fallbacks""" - if not instances: - # Try to launch the app with multiple fallback methods - if not desktop_app: - desktop_app = self.find_app(app_identifier) - - if desktop_app: - # Try the standard launch method first - launch_success = desktop_app.launch() - - # If that fails, try command line or executable - if not launch_success: - if desktop_app.command_line: - exec_shell_command_async(f"nohup {desktop_app.command_line}") - elif desktop_app.executable: - exec_shell_command_async(f"nohup {desktop_app.executable}") - else: - # No desktop entry found, try direct execution - if isinstance(app_identifier, dict): - # Try command_line first, then executable, then name as last resort - if ( - "command_line" in app_identifier - and app_identifier["command_line"] - ): - exec_shell_command_async( - f"nohup {app_identifier['command_line']}" - ) - elif ( - "executable" in app_identifier and app_identifier["executable"] - ): - exec_shell_command_async( - f"nohup {app_identifier['executable']}" - ) - elif "name" in app_identifier and app_identifier["name"]: - exec_shell_command_async(f"nohup {app_identifier['name']}") - elif isinstance(app_identifier, str): - # Try direct execution with the identifier as fallback - exec_shell_command_async(f"nohup {app_identifier}") - else: - # Handle window switching for running instances - focused = self.get_focused() - idx = next( - (i for i, inst in enumerate(instances) if inst["address"] == focused), - -1, - ) - next_inst = instances[(idx + 1) % len(instances)] - exec_shell_command( - f"hyprctl dispatch focuswindow address:{next_inst['address']}" - ) - - def _on_child_enter(self, widget, event): - """Maintain hover state when entering child widgets""" - self.is_hovered = True - if self.hide_id: - GLib.source_remove(self.hide_id) - self.hide_id = None - return False # Continue event propagation - - def toggle_dock(self, show): - """Show or hide the dock immediately""" - if show: - if self.is_hidden: - self.is_hidden = False - self.dock_full.add_style_class("show-dock") - self.dock_full.remove_style_class("hide-dock") - if self.hide_id: - GLib.source_remove(self.hide_id) - self.hide_id = None - else: - if not self.is_hidden: - self.is_hidden = True - self.dock_full.add_style_class("hide-dock") - self.dock_full.remove_style_class("show-dock") - - def delay_hide(self): - """Schedule hiding after short delay""" - if self.hide_id: - GLib.source_remove(self.hide_id) - self.hide_id = GLib.timeout_add(1000, self.hide_dock) - - def hide_dock(self): - """Finalize hiding procedure""" - self.toggle_dock(show=False) - self.hide_id = None - return False - - def check_hide(self, *args): - """Determine if dock should auto-hide""" - clients = self.get_clients() - current_ws = self.get_workspace() - ws_clients = [w for w in clients if w["workspace"]["id"] == current_ws] - - if not ws_clients: - self.toggle_dock(show=True) - elif any(not w.get("floating") and not w.get("fullscreen") for w in ws_clients): - self.delay_hide() - else: - self.toggle_dock(show=True) - - def update_dock(self, *args): - """Refresh dock contents and clear drag lock.""" - self.update_app_map() # Update app map before creating buttons - arranger_handler = getattr(self, "_arranger_handler", None) - if arranger_handler: - remove_handler(arranger_handler) - clients = self.get_clients() - - # Create a mapping of window class to instances - running_windows = {} - for c in clients: - # Try multiple identification methods in order of reliability - window_id = None - - # Try initialClass first (most reliable) - if class_name := c.get("initialClass", "").lower(): - window_id = class_name - - # Try class second (fallback) - elif class_name := c.get("class", "").lower(): - window_id = class_name - - # Use title as last resort if both class identifiers are missing - elif title := c.get("title", "").lower(): - # Extract app name from title (common format: "App Name - Document") - possible_name = title.split(" - ")[0].strip() - if ( - possible_name and len(possible_name) > 1 - ): # Avoid single letter app names - window_id = possible_name - else: - window_id = title # Use full title if we can't extract a good name - - # Use generic identifier as absolute fallback - if not window_id: - window_id = "unknown-app" - - # Log window for debugging purposes - logging.debug( - f"Window detected: {window_id} (from {c.get('initialClass', '')}/{c.get('class', '')}/{c.get('title', '')})" - ) - - # Add to running windows - store with both original and normalized keys - running_windows.setdefault(window_id, []).append(c) - - # Also store with normalized key for more flexible matching - normalized_id = self._normalize_window_class(window_id) - if normalized_id != window_id: - running_windows.setdefault(normalized_id, []).extend( - running_windows[window_id] - ) - - # Map pinned apps to their running instances - pinned_buttons = [] - used_window_classes = set() # Track which window classes we've already assigned - - for app_data in self.pinned: - app = self.find_app(app_data) - - # Try to find running instances for this pinned app - instances = [] - matched_class = None - - # Extract all possible identifiers from app_data for matching - possible_identifiers = [] - - if isinstance(app_data, dict): - for key in [ - "window_class", - "executable", - "command_line", - "name", - "display_name", - ]: - if key in app_data and app_data[key]: - possible_identifiers.append(app_data[key].lower()) - elif isinstance(app_data, str): - possible_identifiers.append(app_data.lower()) - - # Add identifiers from DesktopApp if available - if app: - if app.window_class: - possible_identifiers.append(app.window_class.lower()) - if app.executable: - possible_identifiers.append(app.executable.split("/")[-1].lower()) - if app.command_line: - cmd_parts = app.command_line.split() - if cmd_parts: - possible_identifiers.append(cmd_parts[0].split("/")[-1].lower()) - if app.name: - possible_identifiers.append(app.name.lower()) - if app.display_name: - possible_identifiers.append(app.display_name.lower()) - - # Remove duplicates - possible_identifiers = list(set(possible_identifiers)) - - # Try each identifier for matching - for identifier in possible_identifiers: - # Try direct match - if identifier in running_windows: - instances = running_windows[identifier] - matched_class = identifier - break - - # Try normalized version - normalized = self._normalize_window_class(identifier) - if normalized in running_windows: - instances = running_windows[normalized] - matched_class = normalized - break - - # Try substring matching for window classes (less reliable but helpful) - for window_class in running_windows: - # For substring matching, ensure the identifier is at least 3 chars - # to avoid too broad matches - if len(identifier) >= 3 and identifier in window_class: - instances = running_windows[window_class] - matched_class = window_class - logging.debug( - f"Substring match: {identifier} in {window_class}" - ) - break - - if matched_class: - break - - # Mark the matched class as used - if matched_class: - used_window_classes.add(matched_class) - # Also mark the normalized version as used - used_window_classes.add(self._normalize_window_class(matched_class)) - logging.debug( - f"Matched pinned app {app_data} to running instances via {matched_class}" - ) - - # Create button for this pinned app with any found instances - pinned_buttons.append(self.create_button(app_data, instances)) - - # For any remaining window classes that aren't assigned to pinned apps - open_buttons = [] - for class_name, instances in running_windows.items(): - if class_name not in used_window_classes: - # Enhanced app identification for running windows - app = None - - # Try multiple methods to find the correct app - # 1. Direct lookup by class name - app = self.app_identifiers.get(class_name) - - # 2. Try with normalized class name - if not app: - norm_class = self._normalize_window_class(class_name) - app = self.app_identifiers.get(norm_class) - - # 3. Try with our more robust find_app method - if not app: - app = self.find_app_by_key(class_name) - - # 4. Try using window title which often contains app name - if not app and instances and instances[0].get("title"): - title = instances[0].get("title", "") - # Extract potential app name from title (common format: "App Name - Document") - possible_name = title.split(" - ")[0].strip() - if len(possible_name) > 2: # Avoid very short names - app = self.find_app_by_key(possible_name) - - # Create comprehensive app data if app was found - if app: - app_data = { - "name": app.name, - "display_name": app.display_name, - "window_class": app.window_class, - "executable": app.executable, - "command_line": app.command_line, - } - identifier = app_data - else: - # Fallback to just class name - identifier = class_name - - open_buttons.append(self.create_button(identifier, instances)) - - # Assemble dock layout - children = pinned_buttons - # Only add separator if both pinned and open buttons exist - if pinned_buttons and open_buttons: - children += [ - Box( - orientation="v" - if not data.VERTICAL - and not data.VERTICAL_RIGHT_ALIGN - and not data.BOTTOM_BAR - else "h", - v_expand=not data.VERTICAL - and not data.VERTICAL_RIGHT_ALIGN - and not data.BOTTOM_BAR, - h_expand=data.VERTICAL - and data.VERTICAL_RIGHT_ALIGN - and data.BOTTOM_BAR, - name="dock-separator", - ) - ] - children += open_buttons - - self.view.children = children - idle_add(self._update_size) - self._drag_in_progress = False # Clear the drag lock - - def _update_size(self): - """Update window size based on content""" - width, _ = self.view.get_preferred_width() - self.set_size_request(width, -1) - return False - - def get_clients(self): - """Get current client list""" - try: - return json.loads(self.conn.send_command("j/clients").reply.decode()) - except json.JSONDecodeError: - return [] - - def get_focused(self): - """Get focused window address""" - try: - return json.loads( - self.conn.send_command("j/activewindow").reply.decode() - ).get("address", "") - except json.JSONDecodeError: - return "" - - def get_workspace(self): - """Get current workspace ID""" - try: - return json.loads( - self.conn.send_command("j/activeworkspace").reply.decode() - ).get("id", 0) - except json.JSONDecodeError: - return 0 - - def check_occlusion_state(self): - """Periodic occlusion check""" - # Skip occlusion check if hovered or dragging an icon - if self.is_hovered or self._drag_in_progress: - self.dock_full.remove_style_class("occluded") - return True - - # If always_occluded is enabled, keep the dock occluded - if self.always_occluded: - self.dock_full.add_style_class("occluded") - return True - - occlusion_region = ( - ("bottom", OCCLUSION) - if not data.VERTICAL - and not data.VERTICAL_RIGHT_ALIGN - and not data.BOTTOM_BAR - else ("left", OCCLUSION) - if data.VERTICAL_RIGHT_ALIGN - else ("right", OCCLUSION) - ) - if check_occlusion(occlusion_region) or not self.view.get_children(): - self.dock_full.add_style_class("occluded") - else: - self.dock_full.remove_style_class("occluded") - return True - - def _find_drag_target(self, widget): - """Find valid drag target in viewport""" - children = self.view.get_children() - while widget is not None and widget not in children: - widget = widget.get_parent() if hasattr(widget, "get_parent") else None - return widget - - def on_drag_data_get(self, widget, drag_context, data, info, time): - """Handle drag start""" - target = self._find_drag_target( - widget.get_parent() if isinstance(widget, Box) else widget - ) - if target is not None: - index = self.view.get_children().index(target) - data.set_text(str(index), -1) - - def on_drag_data_received(self, widget, drag_context, x, y, data, info, time): - """Handle drop event""" - target = self._find_drag_target( - widget.get_parent() if isinstance(widget, Box) else widget - ) - if target is None: - return - try: - source_index = int(data.get_text()) - except (TypeError, ValueError): - return - - children = self.view.get_children() - try: - target_index = children.index(target) - except ValueError: - return - - if source_index != target_index: - # Check for separator to detect cross-section drag - separator_index = -1 - for i, child in enumerate(children): - if child.get_name() == "dock-separator": - separator_index = i - break - - # Detect if we're dragging across the separator (between pinned/unpinned) - cross_section_drag = separator_index != -1 and ( - (source_index < separator_index and target_index > separator_index) - or (source_index > separator_index and target_index < separator_index) - ) - - # Move the item in the children list - child = children.pop(source_index) - children.insert(target_index, child) - self.view.children = children # Update view immediately - - # Update pinned apps configuration - self.update_pinned_apps(skip_update=not cross_section_drag) - - # Force immediate update if we dragged across sections to update separator visibility - if cross_section_drag: - GLib.idle_add(self.update_dock) - - def on_drag_end(self, widget, drag_context): - """Handles drag end, for unpinning and closing apps.""" - # If a drag is already in progress, ignore duplicate calls. - if not self._drag_in_progress: - return - - def process_drag_end(): - """Inner function to handle drag end, safe to call with idle_add.""" - if self.get_mapped(): # Check if the window is mapped - # Get window geometry *before* any modifications - display = Gdk.Display.get_default() - _, x, y, _ = display.get_pointer() # Get pointer relative to the screen - window = self.get_window() - if window: # Check if we got a window - win_x, win_y, width, height = window.get_geometry() - if not ( - win_x <= x <= win_x + width and win_y <= y <= win_y + height - ): - # Drag ended outside the dock - get app data from the button - app_id = widget.app_identifier - instances = widget.instances - - # Find this app in pinned list - app_index = -1 - for i, pinned_app in enumerate(self.pinned): - # Match by identifier - if pinned_app == app_id: - app_index = i - break - # For dict format, match by name - elif isinstance(pinned_app, dict) and isinstance( - app_id, dict - ): - if pinned_app.get("name") == app_id.get("name"): - app_index = i - break - - # If it's a pinned app, remove from pinned - if app_index >= 0: - self.pinned.pop(app_index) - self.config["pinned_apps"] = self.pinned - self.update_pinned_apps_file() - self.update_dock() # Update dock after file save - elif instances: # Close if running and unpinned - # Close running app (if not pinned) - address = instances[0].get("address") - if address: - exec_shell_command( - f"hyprctl dispatch closewindow address:{address}" - ) - self.update_dock() # Update dock after closing window - self._drag_in_progress = False # Clear the drag lock - - GLib.idle_add(process_drag_end) # Deferred execution with idle_add - - def check_config_change(self): - """Check if the config file has been modified.""" - new_config = data.read_config() - if new_config.get("pinned_apps", []) != self.config.get("pinned_apps", []): - self.config = new_config - self.pinned = self.config.get("pinned_apps", []) - self.update_app_map() # Update app_map when config changes - self.update_dock() - return True # Continue the timeout - - def update_pinned_apps_file(self): - """Writes the updated pinned apps list to modus.json""" - config_path = get_relative_path("../config/assets/modus.json") - try: - with open(config_path, "w") as file: - json.dump(self.config, file, indent=4) - return True - except Exception as e: - logging.error(f"Failed to write dock config: {e}") - return False - - def update_pinned_apps(self, skip_update=False): - """Update pinned apps configuration by collecting all items before the separator""" - pinned_children = [] - for child in self.view.get_children(): - if child.get_name() == "dock-separator": - break # Stop at separator - - # Use stored app identifier from the button - if hasattr(child, "app_identifier"): - # If the button has a desktop_app, use it to create comprehensive data - if hasattr(child, "desktop_app") and child.desktop_app: - app = child.desktop_app - app_data = { - "name": app.name, - "display_name": app.display_name, - "window_class": app.window_class, - "executable": app.executable, - "command_line": app.command_line, - } - pinned_children.append(app_data) - else: - # Keep existing app_identifier (might be dict or string) - pinned_children.append(child.app_identifier) - - # Directly update both config and local pinned list - self.config["pinned_apps"] = pinned_children - self.pinned = pinned_children # Update local state immediately - - # Write to file first to ensure persistence - file_updated = self.update_pinned_apps_file() - - # Only update the dock if needed and file was successfully written - if file_updated and not skip_update: - self.update_dock() # Refresh dock only after file is saved - - # Static method to update all dock instances - @staticmethod - def notify_config_change(): - """Notify all dock instances to reload their configuration immediately.""" - for dock in Dock._instances: - # Use idle_add to avoid potential issues with direct calls - GLib.idle_add(dock.check_config_change_immediate) - - def check_config_change_immediate(self): - """Immediately check for configuration changes and update if needed.""" - new_config = data.read_config() - - # Check if always_occluded setting has changed - previous_always_occluded = self.always_occluded - self.always_occluded = data.DOCK_ALWAYS_OCCLUDED - - # If always_occluded changed, update the dock appearance - if previous_always_occluded != self.always_occluded: - if self.always_occluded and not self.is_hovered: - self.dock_full.add_style_class("occluded") - elif not self.always_occluded and not self.is_hovered: - # Perform a normal occlusion check - occlusion_region = ( - ("bottom", OCCLUSION) - if not data.VERTICAL and not data.BOTTOM_BAR - else ("left", OCCLUSION) - if data.VERTICAL_RIGHT_ALIGN - else ("right", OCCLUSION) - ) - if check_occlusion(occlusion_region) or not self.view.get_children(): - self.dock_full.add_style_class("occluded") - else: - self.dock_full.remove_style_class("occluded") - - if new_config.get("pinned_apps", []) != self.config.get("pinned_apps", []): - self.config = new_config - self.pinned = self.config.get("pinned_apps", []) - self.update_app_map() - self.update_dock() - return False # Don't repeat - - # Add a static method to handle dock visibility changes - @staticmethod - def update_visibility(visible): - """Update visibility for all dock instances.""" - for dock in Dock._instances: - dock.set_visible(visible) diff --git a/modules/dock/components/__init__.py b/modules/dock/components/__init__.py new file mode 100644 index 0000000..9410f86 --- /dev/null +++ b/modules/dock/components/__init__.py @@ -0,0 +1,184 @@ +from gi.repository import Gtk +from fabric.system_tray.widgets import SystemTrayItem +import json +import os + +import gi +from fabric.hyprland.service import HyprlandEvent +from fabric.hyprland.widgets import HyprlandLanguage, get_hyprland_connection +from fabric.utils import get_relative_path +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.datetime import DateTime +from fabric.widgets.label import Label + +import config.data as data +import utils.icons as icons + +from .applications import Applications +from .battery import Battery +from .controls import Controls +from .indicators import Indicators +from .metrics import Metrics +from .workspaces import workspace +from .systemtray import SystemTray + +gi.require_version("Gtk", "3.0") +# Monkey patch SystemTray to use GTK theme icons + + +class DockComponents(Box): + def __init__(self, orientation_val="h", dock_instance=None, **kwargs): + super().__init__( + name="dock-components", orientation=orientation_val, spacing=8, **kwargs + ) + + self.dock_instance = dock_instance + + # Create applications with dock reference + self.applications = Applications( + orientation_val=orientation_val, dock_instance=dock_instance + ) + + self.connection = get_hyprland_connection() + self.lang_label = Label(name="lang-label") + self.language = Button( + name="language", h_align="center", v_align="center", child=self.lang_label + ) + self.on_language_switch() + self.connection.connect("event::activelayout", self.on_language_switch) + + # Initialize component visibility from data + self.component_visibility = data.DOCK_COMPONENTS_VISIBILITY + + # Create components + self.workspaces = workspace + self.metrics = Metrics() + self.battery = Battery() + self.date_time = DateTime( + name="date-time", + formatters=["%I:%M"] if not data.VERTICAL else ["%I\n%M"], + h_align="center" if not data.VERTICAL else "fill", + v_align="center", + h_expand=True, + v_expand=True, + ) + self.controls = Controls() + self.indicators = Indicators() + self.systray = SystemTray() + + # Create list of components to add + self.themed_children = [ + self.workspaces, + self.metrics, + self.controls, + self.applications, + self.indicators, + self.battery, + self.date_time, + self.language, + self.systray, + ] + + # Add each child individually + for child in self.themed_children: + self.add(child) + + # Apply initial visibility + self.apply_component_props() + + # Apply invert style for specific themes and positions + should_invert = data.DOCK_THEME in ["Dense", "Edge"] or ( + data.DOCK_THEME == "Pills" + and data.DOCK_POSITION in ["Left", "Right", "Top"] + ) + + if should_invert: + for child in self.themed_children: + if hasattr(child, "add_style_class"): + child.add_style_class("invert") + + def apply_component_props(self): + components = { + "workspace": self.workspaces, + "metrics": self.metrics, + "battery": self.battery, + "date_time": self.date_time, + "controls": self.controls, + "indicators": self.indicators, + "systray": self.systray, + "applications": self.applications, + "language": self.language, + } + + for component_name, widget in components.items(): + if component_name in self.component_visibility: + visibility = self.component_visibility[component_name] + # Special handling for applications component - let it manage its own visibility + if component_name == "applications": + # Only apply config visibility if the component has content + if hasattr(widget, "_update_visibility_based_on_content"): + widget._update_visibility_based_on_content() + else: + widget.set_visible(visibility) + # Special handling for battery - let it manage its own visibility based on config and battery state + elif component_name == "battery": + # Battery component manages its own visibility based on config and battery presence + # Don't override its visibility here, let it handle it internally + pass + else: + widget.set_visible(visibility) + + def toggle_component_visibility(self, component_name): + components = { + "workspace": self.workspaces, + "metrics": self.metrics, + "battery": self.battery, + "date_time": self.date_time, + "controls": self.controls, + "indicators": self.indicators, + "systray": self.systray, + "applications": self.applications, + "language": self.language, + } + + if component_name in components and component_name in self.component_visibility: + self.component_visibility[component_name] = not self.component_visibility[ + component_name + ] + + components[component_name].set_visible( + self.component_visibility[component_name] + ) + + config_file = get_relative_path("../../../config/assets/config.json") + if os.path.exists(config_file): + try: + with open(config_file, "r") as f: + config = json.load(f) + + config[f"dock_{component_name}_visible"] = ( + self.component_visibility[component_name] + ) + + with open(config_file, "w") as f: + json.dump(config, f, indent=4) + except Exception as e: + print(f"Error updating config file: {e}") + + return self.component_visibility[component_name] + + return None + + def on_language_switch(self, _=None, event: HyprlandEvent = None): + lang_data = ( + event.data[1] + if event and event.data and len(event.data) > 1 + else HyprlandLanguage().get_label() + ) + self.language.set_tooltip_text(lang_data) + if not data.VERTICAL: + self.lang_label.set_label(lang_data[:2].lower()) + else: + self.lang_label.add_style_class("icon") + self.lang_label.set_markup(icons.keyboard) diff --git a/modules/dock/components/applications.py b/modules/dock/components/applications.py new file mode 100644 index 0000000..ffde4c8 --- /dev/null +++ b/modules/dock/components/applications.py @@ -0,0 +1,717 @@ +import json +import logging + +import cairo +from fabric.hyprland.widgets import get_hyprland_connection +from fabric.utils import exec_shell_command, exec_shell_command_async, get_relative_path +from fabric.utils.helpers import get_desktop_applications +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.image import Image +from gi.repository import Gdk, GLib, Gtk + +import config.data as data +from utils.icon_resolver import IconResolver + + +def read_config(): + config_path = get_relative_path("../../../config/assets/dock.json") + try: + with open(config_path, "r") as file: + config_data = json.load(file) + + if ( + "pinned_apps" in config_data + and config_data["pinned_apps"] + and isinstance(config_data["pinned_apps"][0], str) + ): + all_apps = get_desktop_applications() + app_map = {app.name: app for app in all_apps if app.name} + + old_pinned = config_data["pinned_apps"] + config_data["pinned_apps"] = [] + + for app_id in old_pinned: + app = app_map.get(app_id) + if app: + app_data_obj = { + "name": app.name, + "display_name": app.display_name, + "window_class": app.window_class, + "executable": app.executable, + "command_line": app.command_line, + } + config_data["pinned_apps"].append(app_data_obj) + else: + config_data["pinned_apps"].append({"name": app_id}) + + except (FileNotFoundError, json.JSONDecodeError): + config_data = {"pinned_apps": []} + return config_data + + +def createSurfaceFromWidget(widget: Gtk.Widget) -> cairo.ImageSurface: + alloc = widget.get_allocation() + surface = cairo.ImageSurface( + cairo.Format.ARGB32, + alloc.width, + alloc.height, + ) + cr = cairo.Context(surface) + cr.set_source_rgba(255, 255, 255, 0) + cr.rectangle(0, 0, alloc.width, alloc.height) + cr.fill() + widget.draw(cr) + return surface + + +class Applications(Box): + def __init__(self, orientation_val="h", dock_instance=None, **kwargs): + super().__init__( + name="applications-dock", orientation=orientation_val, spacing=4, **kwargs + ) + + self.hide() + + self.dock_instance = dock_instance + + self.config = read_config() + self.conn = get_hyprland_connection() + self.icon_resolver = IconResolver() + self.pinned = self.config.get("pinned_apps", []) + self.config_path = get_relative_path("../../../config/assets/dock.json") + self.app_map = {} + self._all_apps = get_desktop_applications() + self.app_identifiers = self._build_app_identifiers_map() + self._drag_in_progress = False + + self.update_applications() + + if self.conn.ready: + pass + else: + self.conn.connect("event::ready", lambda *args: self.update_applications()) + + for ev in ("activewindow", "openwindow", "closewindow", "changefloatingmode"): + self.conn.connect(f"event::{ev}", lambda *args: self.update_applications()) + + GLib.timeout_add_seconds(1, self.check_config_change) + + def _build_app_identifiers_map(self): + identifiers = {} + for app in self._all_apps: + if app.name: + identifiers[app.name.lower()] = app + if app.display_name: + identifiers[app.display_name.lower()] = app + if app.window_class: + identifiers[app.window_class.lower()] = app + if app.executable: + identifiers[app.executable.split("/")[-1].lower()] = app + if app.command_line: + identifiers[app.command_line.split()[0].split("/")[-1].lower()] = app + return identifiers + + def find_app_by_key(self, key): + if not key: + return None + key_lower = key.lower() if isinstance(key, str) else "" + return self.app_identifiers.get(key_lower) + + def find_app(self, app_identifier): + if not app_identifier: + return None + if isinstance(app_identifier, dict): + for key in [ + "window_class", + "executable", + "command_line", + "name", + "display_name", + ]: + if key in app_identifier and app_identifier[key]: + app = self.find_app_by_key(app_identifier[key]) + if app: + return app + return None + return self.find_app_by_key(app_identifier) + + def create_button(self, app_identifier, instances): + desktop_app = self.find_app(app_identifier) + icon_img = None + display_name = None + icon_size = data.DOCK_ICON_SIZE + + if desktop_app: + icon_img = desktop_app.get_icon_pixbuf(size=icon_size) + display_name = desktop_app.display_name or desktop_app.name + + id_value = ( + app_identifier["name"] + if isinstance(app_identifier, dict) + else app_identifier + ) + + if not icon_img: + icon_img = self.icon_resolver.get_icon_pixbuf(id_value, icon_size) + + if not icon_img: + icon_img = self.icon_resolver.get_icon_pixbuf( + "application-x-executable-symbolic", icon_size + ) + if not icon_img: + icon_img = self.icon_resolver.get_icon_pixbuf( + "image-missing", icon_size + ) + + items = [Image(pixbuf=icon_img)] + tooltip = display_name or (id_value if isinstance(id_value, str) else "Unknown") + if not display_name and instances and instances[0].get("title"): + tooltip = instances[0]["title"] + + button = Button( + child=Box( + name="dock-icon", orientation="v", h_align="center", children=items + ), + on_clicked=lambda *a: self.handle_app( + app_identifier, instances, desktop_app + ), + tooltip_text=tooltip, + name="dock-app-button", + ) + button.app_identifier = app_identifier + button.desktop_app = desktop_app + button.instances = instances + if instances: + button.add_style_class("instance") + + button.drag_source_set( + Gdk.ModifierType.BUTTON1_MASK, + [Gtk.TargetEntry.new("text/plain", Gtk.TargetFlags.SAME_APP, 0)], + Gdk.DragAction.MOVE, + ) + + button.drag_dest_set( + Gtk.DestDefaults.ALL, + [Gtk.TargetEntry.new("text/plain", Gtk.TargetFlags.SAME_APP, 0)], + Gdk.DragAction.MOVE, + ) + + button.connect("drag-begin", self.on_drag_begin) + button.connect("drag-data-get", self.on_drag_data_get) + button.connect("drag-data-received", self.on_drag_data_received) + button.connect("drag-end", self.on_drag_end) + button.connect("button-press-event", self.on_button_press) + + return button + + def handle_app(self, app_identifier, instances, desktop_app=None): + if not instances: + if not desktop_app: + desktop_app = self.find_app(app_identifier) + if desktop_app: + launch_success = desktop_app.launch() + if not launch_success: + if desktop_app.command_line: + exec_shell_command_async(f"nohup {desktop_app.command_line} &") + elif desktop_app.executable: + exec_shell_command_async(f"nohup {desktop_app.executable} &") + else: + cmd_to_run = None + if isinstance(app_identifier, dict): + if ( + "command_line" in app_identifier + and app_identifier["command_line"] + ): + cmd_to_run = app_identifier["command_line"] + elif ( + "executable" in app_identifier and app_identifier["executable"] + ): + cmd_to_run = app_identifier["executable"] + elif "name" in app_identifier and app_identifier["name"]: + cmd_to_run = app_identifier["name"] + elif isinstance(app_identifier, str): + cmd_to_run = app_identifier + if cmd_to_run: + exec_shell_command_async(f"nohup {cmd_to_run} &") + else: + focused = self.get_focused() + idx = next( + (i for i, inst in enumerate(instances) if inst["address"] == focused), + -1, + ) + next_inst = instances[(idx + 1) % len(instances)] + exec_shell_command( + f"hyprctl dispatch focuswindow address:{next_inst['address']}" + ) + + def update_pinned_apps_file(self): + config_path = get_relative_path("../../../config/assets/dock.json") + try: + with open(config_path, "w") as file: + json.dump(self.config, file, indent=4) + return True + except Exception as e: + logging.error(f"Failed to write dock config: {e}") + return False + + def check_config_change(self): + new_config = read_config() + if new_config.get("pinned_apps", []) != self.config.get("pinned_apps", []): + self.config = new_config + self.pinned = self.config.get("pinned_apps", []) + self.update_app_map() + self.update_applications() + return True + + def update_app_map(self): + self._all_apps = get_desktop_applications() + self.app_identifiers = self._build_app_identifiers_map() + + def _normalize_window_class(self, class_name): + if not class_name: + return "" + normalized = class_name.lower() + suffixes = [".bin", ".exe", ".so", "-bin", "-gtk"] + for suffix in suffixes: + if normalized.endswith(suffix): + normalized = normalized[: -len(suffix)] + return normalized + + def _is_special_workspace(self, client): + """Check if a client is in a special workspace""" + if "workspace" not in client: + return False + + workspace = client["workspace"] + if "name" in workspace: + workspace_name = str(workspace["name"]) + # Special workspaces typically start with "special:" or have negative IDs + if workspace_name.startswith("special:"): + return True + + if "id" in workspace: + workspace_id = workspace["id"] + # Special workspaces have negative IDs + if workspace_id < 0: + return True + + return False + + def update_applications(self): + for child in self.get_children(): + self.remove(child) + + clients = [] + try: + clients_data = self.conn.send_command("j/clients").reply + if clients_data: + clients = json.loads(clients_data.decode("utf-8")) + except Exception as e: + logging.error(f"Failed to get clients: {e}") + + running_windows = {} + for client in clients: + # Skip clients in special workspaces if the setting is enabled + if data.DOCK_HIDE_SPECIAL_WORKSPACE_APPS and self._is_special_workspace(client): + continue + + class_name = client.get("class", "").lower() + if class_name: + if class_name not in running_windows: + running_windows[class_name] = [] + running_windows[class_name].append(client) + + pinned_buttons = [] + used_window_classes = set() + + for app_data_item in self.pinned: + app = self.find_app(app_data_item) + instances = [] + matched_class = None + possible_identifiers = [] + + if isinstance(app_data_item, dict): + for key in [ + "window_class", + "executable", + "command_line", + "name", + "display_name", + ]: + if key in app_data_item and app_data_item[key]: + possible_identifiers.append(app_data_item[key].lower()) + elif isinstance(app_data_item, str): + possible_identifiers.append(app_data_item.lower()) + + for class_name, window_instances in running_windows.items(): + for identifier in possible_identifiers: + if identifier in class_name or class_name in identifier: + instances = window_instances + matched_class = class_name + break + if matched_class: + break + + if matched_class: + used_window_classes.add(matched_class) + + pinned_buttons.append(self.create_button(app_data_item, instances)) + + open_buttons = [] + for class_name, instances in running_windows.items(): + if class_name not in used_window_classes: + app = self.find_app_by_key(class_name) + + if app: + app_data_obj = { + "name": app.name, + "display_name": app.display_name, + "window_class": app.window_class, + "executable": app.executable, + "command_line": app.command_line, + } + identifier = app_data_obj + else: + identifier = class_name + + open_buttons.append(self.create_button(identifier, instances)) + + children = pinned_buttons + + if pinned_buttons and open_buttons: + separator_orientation = ( + Gtk.Orientation.VERTICAL + if self.get_orientation() == Gtk.Orientation.HORIZONTAL + else Gtk.Orientation.HORIZONTAL + ) + + children.append( + Box( + orientation=separator_orientation, + v_expand=False, + h_expand=False, + h_align="center", + v_align="center", + name="dock-separator", + ) + ) + + children += open_buttons + + for child in children: + self.add(child) + + if len(children) > 0: + self.show_all() + else: + self.hide() + + self._update_visibility_based_on_content() + + return True + + def _update_visibility_based_on_content(self): + has_content = len(self.get_children()) > 0 + + if not has_content: + self.hide() + else: + if data.DOCK_COMPONENTS_VISIBILITY.get("applications", True): + self.show_all() + else: + self.hide() + + def update_pinned_apps(self, app_identifier, index=None): + is_pinned = False + for pinned_app in self.pinned: + if ( + isinstance(app_identifier, dict) + and isinstance(pinned_app, dict) + and app_identifier.get("name") == pinned_app.get("name") + ): + is_pinned = True + break + elif app_identifier == pinned_app: + is_pinned = True + break + + if not is_pinned: + if hasattr(app_identifier, "desktop_app") and app_identifier.desktop_app: + app = app_identifier.desktop_app + app_data_obj = { + "name": app.name, + "display_name": app.display_name, + "window_class": app.window_class, + "executable": app.executable, + "command_line": app.command_line, + } + if index is not None: + self.pinned.insert(index, app_data_obj) + else: + self.pinned.append(app_data_obj) + else: + if index is not None: + self.pinned.insert(index, app_identifier) + else: + self.pinned.append(app_identifier) + + self.config["pinned_apps"] = self.pinned + self.update_pinned_apps_file() + self.update_applications() + + def create_context_menu(self, widget, event): + """Create and show context menu for application""" + menu = Gtk.Menu() + + app_identifier = widget.app_identifier + instances = widget.instances + is_pinned = self.is_app_pinned(app_identifier) + + # Send to workspace submenu + workspace_menu = Gtk.Menu() + workspace_item = Gtk.MenuItem(label="Send to workspace") + workspace_item.set_submenu(workspace_menu) + + # Get available workspaces (1-10) + for i in range(1, 11): + workspace_menu_item = Gtk.MenuItem(label=f"Workspace {i}") + workspace_menu_item.connect("activate", lambda item, ws=i: self.send_to_workspace(instances, ws)) + workspace_menu.append(workspace_menu_item) + + menu.append(workspace_item) + + # Pin/Unpin app + pin_label = "Unpin app" if is_pinned else "Pin app" + pin_item = Gtk.MenuItem(label=pin_label) + pin_item.connect("activate", lambda item: self.toggle_pin_app(app_identifier)) + menu.append(pin_item) + + # Close app (only if instances exist) + if instances: + close_item = Gtk.MenuItem(label="Close app") + close_item.connect("activate", lambda item: self.close_app(instances)) + menu.append(close_item) + + # Float window + float_item = Gtk.MenuItem(label="Float window") + float_item.connect("activate", lambda item: self.float_window(instances)) + menu.append(float_item) + + # Fullscreen + fullscreen_item = Gtk.MenuItem(label="Fullscreen") + fullscreen_item.connect("activate", lambda item: self.fullscreen_window(instances)) + menu.append(fullscreen_item) + + menu.show_all() + + # Attach menu to widget for proper positioning + menu.attach_to_widget(widget, None) + + # Use popup_at_pointer for reliable positioning near the widget + try: + # Modern GTK approach - popup at the event location + menu.popup_at_pointer(event) + except AttributeError: + # Fallback for older GTK versions + menu.popup(None, None, None, None, event.button, event.time) + + return True + + def on_button_press(self, widget, event): + if event.button == 3: # Right mouse button + return self.create_context_menu(widget, event) + return False + + def on_drag_begin(self, widget, drag_context): + self._drag_in_progress = True + Gtk.drag_set_icon_surface(drag_context, createSurfaceFromWidget(widget)) + + # Prevent dock from hiding during drag + if self.dock_instance: + self.dock_instance.prevent_hiding(True) + + # Store the source widget for later use + self._dragged_widget = widget + + def on_drag_data_get(self, widget, drag_context, data_obj, info, time): + children = self.get_children() + index = children.index(widget) + data_obj.set_text(str(index), -1) + + def on_drag_data_received(self, widget, drag_context, x, y, data_obj, info, time): + try: + source_index = int(data_obj.get_text()) + except (TypeError, ValueError): + return + + children = self.get_children() + target_index = children.index(widget) + + if source_index != target_index: + separator_index = -1 + for i, child in enumerate(children): + if child.get_name() == "dock-separator": + separator_index = i + break + + cross_section_drag_to_pin = separator_index != -1 and ( + source_index > separator_index and target_index < separator_index + ) + + cross_section_drag_to_unpin = separator_index != -1 and ( + source_index < separator_index and target_index > separator_index + ) + + if cross_section_drag_to_pin: + source_widget = children[source_index] + app_identifier = source_widget.app_identifier + + if hasattr(source_widget, "desktop_app") and source_widget.desktop_app: + app = source_widget.desktop_app + app_data_obj = { + "name": app.name, + "display_name": app.display_name, + "window_class": app.window_class, + "executable": app.executable, + "command_line": app.command_line, + } + self.pinned.insert(target_index, app_data_obj) + else: + self.pinned.insert(target_index, app_identifier) + + self.config["pinned_apps"] = self.pinned + self.update_pinned_apps_file() + self.update_applications() + elif cross_section_drag_to_unpin: + if source_index < len(self.pinned): + self.pinned.pop(source_index) + self.config["pinned_apps"] = self.pinned + self.update_pinned_apps_file() + self.update_applications() + else: + if source_index < separator_index and target_index < separator_index: + item = self.pinned.pop(source_index) + self.pinned.insert(target_index, item) + self.config["pinned_apps"] = self.pinned + self.update_pinned_apps_file() + self.update_applications() + elif source_index > separator_index and target_index > separator_index: + child_to_move = children.pop(source_index) + children.insert(target_index, child_to_move) + + for child in self.get_children(): + self.remove(child) + for child in children: + self.add(child) + self.show_all() + + def on_drag_end(self, widget, drag_context): + if not self._drag_in_progress: + return + + self._drag_in_progress = False + if self.dock_instance: + self.dock_instance.prevent_hiding(False) + + if hasattr(self, "_dragged_widget") and self._dragged_widget: + children = self.get_children() + source_index = children.index(self._dragged_widget) + + separator_index = -1 + for i, child in enumerate(children): + if child.get_name() == "dock-separator": + separator_index = i + break + + if separator_index != -1 and source_index < separator_index: + if source_index < len(self.pinned): + self.pinned.pop(source_index) + self.config["pinned_apps"] = self.pinned + self.update_pinned_apps_file() + self.update_applications() + + if hasattr(self, "_dragged_widget"): + delattr(self, "_dragged_widget") + + def get_focused(self): + try: + return json.loads( + self.conn.send_command("j/activewindow").reply.decode() + ).get("address", "") + except json.JSONDecodeError: + return "" + + def is_app_pinned(self, app_identifier): + """Check if an app is pinned""" + for pinned_app in self.pinned: + if ( + isinstance(app_identifier, dict) + and isinstance(pinned_app, dict) + and app_identifier.get("name") == pinned_app.get("name") + ): + return True + elif app_identifier == pinned_app: + return True + return False + + def toggle_pin_app(self, app_identifier): + """Toggle pin state of an app""" + if self.is_app_pinned(app_identifier): + self.unpin_app(app_identifier) + else: + self.update_pinned_apps(app_identifier) + + def unpin_app(self, app_identifier): + """Remove app from pinned apps""" + for i, pinned_app in enumerate(self.pinned): + if ( + isinstance(app_identifier, dict) + and isinstance(pinned_app, dict) + and app_identifier.get("name") == pinned_app.get("name") + ): + self.pinned.pop(i) + break + elif app_identifier == pinned_app: + self.pinned.pop(i) + break + + self.config["pinned_apps"] = self.pinned + self.update_pinned_apps_file() + self.update_applications() + + def send_to_workspace(self, instances, workspace): + """Send window(s) to specified workspace""" + if not instances: + return + + # Send the first instance (focused window) to the workspace + address = instances[0]["address"] + exec_shell_command_async(f"hyprctl dispatch movetoworkspace {workspace},address:{address}") + + def close_app(self, instances): + """Close all instances of the app""" + if not instances: + return + + for instance in instances: + address = instance["address"] + exec_shell_command_async(f"hyprctl dispatch closewindow address:{address}") + + def float_window(self, instances): + """Toggle float state of the window""" + if not instances: + return + + # Toggle float for the first instance + address = instances[0]["address"] + exec_shell_command_async(f"hyprctl dispatch togglefloating address:{address}") + + def fullscreen_window(self, instances): + """Toggle fullscreen state of the window""" + if not instances: + return + + # Toggle fullscreen for the first instance + address = instances[0]["address"] + exec_shell_command_async(f"hyprctl dispatch fullscreen address:{address}") diff --git a/modules/dock/components/battery.py b/modules/dock/components/battery.py new file mode 100644 index 0000000..ec2ad50 --- /dev/null +++ b/modules/dock/components/battery.py @@ -0,0 +1,182 @@ +from gi.repository import GLib + +from fabric.widgets.label import Label +from fabric.widgets.box import Box +from fabric.widgets.circularprogressbar import CircularProgressBar +from fabric.widgets.button import Button +from services.battery import Battery as BatteryService +import utils.icons as icons +import config.data as data + + +class Battery(Box): + def __init__(self, **kwargs): + orientation = "v" if data.VERTICAL else "h" + super().__init__( + name="battery", spacing=0, orientation=orientation, visible=True, **kwargs + ) + + # Initialize battery service + self._battery = BatteryService() + self._battery.changed.connect(self.update_battery) + + # Create the battery icon and circular progress + self.icon = Label(name="battery-icon", markup=icons.battery) + self.circle = CircularProgressBar( + name="battery-circle", + value=0, + size=26, + line_width=2, + start_angle=150, + end_angle=390, + style_classes="battery", + child=self.icon, + ) + + # Create button to hold the circle + self.circle_button = Button( + name="battery-button", + child=self.circle, + ) + + # Create percentage label + self.level = Label( + name="battery-level", + style_classes="battery", + label="0%", + ) + + # Create the container box + self.battery_box = Box( + name="battery-box", + orientation=orientation, + spacing=0, + children=[self.circle_button], + ) + + # Only add level label in horizontal mode + if not data.VERTICAL: + self.battery_box.add(self.level) + + # Enable tooltips + self.circle_button.set_has_tooltip(True) + self.battery_box.set_has_tooltip(True) + self.level.set_has_tooltip(True) + + # Connect tooltip signals + self.circle_button.connect("query-tooltip", self.on_query_tooltip) + self.battery_box.connect("query-tooltip", self.on_query_tooltip) + self.level.connect("query-tooltip", self.on_query_tooltip) + + # Add the battery box to self + self.add(self.battery_box) + + # Initial update + GLib.idle_add(self.update_battery) + + def on_query_tooltip(self, _widget, _x, _y, _keyboard_mode, tooltip): + """Handle tooltip query""" + tooltip.set_markup(self.get_tooltip_text()) + return True + + def get_tooltip_text(self): + """Get formatted tooltip text in bullet points""" + if not self._battery.is_present: + return "No battery detected" + + percentage = self._battery.percentage + state = self._battery.state + # Get capacity as integer by converting from float + capacity = int(float(self._battery.capacity.rstrip("%"))) + tooltip_points = [] + + # Status and percentage + if state == "FULLY_CHARGED": + status = f"{icons.bat_full} Fully Charged" + elif state == "CHARGING": + status = f"{icons.bat_charging} Charging" + elif percentage <= 15 and state == "DISCHARGING": + status = f"{icons.bat_low} Low Battery" + elif state == "DISCHARGING": + status = f"{icons.bat_discharging} Discharging" + else: + status = "Battery" + tooltip_points.append(f"• Status: {status}") + + # Battery level and capacity + tooltip_points.append(f"• Level: {int(percentage)}%") + tooltip_points.append(f"• Battery Health: {capacity}%") + + # Add power profile if available + power_profile = self._battery.power_profile + if power_profile: + # Add icon based on profile + if power_profile == "power-saver": + profile_icon = icons.power_saving + elif power_profile == "performance": + profile_icon = icons.power_performance + else: # balanced + profile_icon = icons.power_balanced + tooltip_points.append(f"• Profile: {profile_icon} {power_profile}") + + # Add temperature if available + temp = self._battery.temperature + if temp != "N/A": + tooltip_points.append(f"• Temperature: {temp}") + + # Add time estimates + if state == "CHARGING": + time_to_full = self._battery.time_to_full + if time_to_full: + tooltip_points.append(f"• Time until full: {time_to_full}") + elif state == "DISCHARGING": + time_to_empty = self._battery.time_to_empty + if time_to_empty: + tooltip_points.append(f"• Time remaining: {time_to_empty}") + + return "\n".join(tooltip_points) + + def update_battery(self, *args): + """Update battery status and UI""" + if not self._battery.is_present: + self.set_visible(False) + return True + + percentage = self._battery.percentage + state = self._battery.state + charging = state in ["CHARGING", "FULLY_CHARGED"] + power_profile = self._battery.power_profile + + # Update circle progress + self.circle.set_value(percentage / 100.0) + + # Only show percentage label if not at 100% + if percentage < 100: + self.level.set_label(f"{int(percentage)}%") + self.level.set_visible(True) + else: + self.level.set_visible(False) + + # Apply alert styling if battery is low AND not charging + if percentage <= 15 and not charging: + self.icon.add_style_class("alert") + self.circle.add_style_class("alert") + else: + self.icon.remove_style_class("alert") + self.circle.remove_style_class("alert") + + # Update icon based on battery state if not in power profile mode + if not power_profile: + if state == "FULLY_CHARGED": + self.icon.set_markup(icons.battery) + elif state == "CHARGING": + self.icon.set_markup(icons.charging) + elif percentage <= 15 and state == "DISCHARGING": + self.icon.set_markup(icons.alert) + elif state == "DISCHARGING": + self.icon.set_markup(icons.discharging) + else: + self.icon.set_markup(icons.battery) + + self.set_visible(True) + return True diff --git a/modules/dock/components/controls.py b/modules/dock/components/controls.py new file mode 100644 index 0000000..53f0db2 --- /dev/null +++ b/modules/dock/components/controls.py @@ -0,0 +1,300 @@ +from fabric.audio.service import Audio +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.circularprogressbar import CircularProgressBar +from fabric.widgets.eventbox import EventBox +from fabric.widgets.label import Label +from fabric.widgets.overlay import Overlay +from gi.repository import Gdk, GLib + +import config.data as data +import utils.icons as icons +from services.brightness import Brightness as BrightnessService + + +class Brightness(Box): + def __init__(self, **kwargs): + super().__init__(name="button-bar-brightness", **kwargs) + self.brightness = BrightnessService.get_initial() + if self.brightness.screen_brightness == -1: + self.destroy() + return + + self.progress_bar = CircularProgressBar( + name="button-brightness", + size=28, + line_width=2, + start_angle=150, + end_angle=390, + ) + self.brightness_label = Label( + name="brightness-label", markup=icons.brightness_high + ) + self.brightness_button = Button(child=self.brightness_label) + self.event_box = EventBox( + events=["scroll", "smooth-scroll"], + child=Overlay(child=self.progress_bar, overlays=self.brightness_button), + ) + self.event_box.connect("scroll-event", self.on_scroll) + self.add(self.event_box) + self.add_events(Gdk.EventMask.SCROLL_MASK | Gdk.EventMask.SMOOTH_SCROLL_MASK) + + self._updating_from_brightness = False + self._pending_value = None + self._update_source_id = None + + self.progress_bar.connect("notify::value", self.on_progress_value_changed) + self.brightness.connect("screen", self.on_brightness_changed) + self.on_brightness_changed() + + def on_scroll(self, _, event): + if self.brightness.max_screen == -1: + return + + current_brightness = self.brightness.screen_brightness + max_brightness = self.brightness.max_screen + step = max(1, int(max_brightness * 0.01)) # 1% of max brightness + + if event.direction == Gdk.ScrollDirection.SMOOTH: + if hasattr(event, "delta_y") and abs(event.delta_y) > 0: + new_brightness = current_brightness - int(event.delta_y * step) + self.brightness.screen_brightness = new_brightness + elif event.direction == Gdk.ScrollDirection.UP: + self.brightness.screen_brightness = current_brightness + step + elif event.direction == Gdk.ScrollDirection.DOWN: + self.brightness.screen_brightness = current_brightness - step + + def on_progress_value_changed(self, widget, pspec): + if self._updating_from_brightness: + return + new_norm = widget.value + new_brightness = int(new_norm * self.brightness.max_screen) + self._pending_value = new_brightness + if self._update_source_id is None: + self._update_source_id = GLib.timeout_add( + 50, self._update_brightness_callback + ) + + def _update_brightness_callback(self): + if ( + self._pending_value is not None + and self._pending_value != self.brightness.screen_brightness + ): + self.brightness.screen_brightness = self._pending_value + self._pending_value = None + return True + else: + self._update_source_id = None + return False + + def on_brightness_changed(self, *args): + if self.brightness.max_screen == -1: + return + normalized = self.brightness.screen_brightness / self.brightness.max_screen + self._updating_from_brightness = True + self.progress_bar.value = normalized + self._updating_from_brightness = False + + brightness_percentage = int(normalized * 100) + if brightness_percentage >= 75: + self.brightness_label.set_markup(icons.brightness_high) + elif brightness_percentage >= 24: + self.brightness_label.set_markup(icons.brightness_medium) + else: + self.brightness_label.set_markup(icons.brightness_low) + self.set_tooltip_text(f"{brightness_percentage}%") + + def destroy(self): + if self._update_source_id is not None: + GLib.source_remove(self._update_source_id) + super().destroy() + + +class Volume(Box): + def __init__(self, **kwargs): + super().__init__(name="button-bar-vol", **kwargs) + self.audio = Audio() + self.progress_bar = CircularProgressBar( + name="button-volume", + size=28, + line_width=2, + start_angle=150, + end_angle=390, + ) + self.vol_label = Label(name="vol-label", markup=icons.vol_high) + self.vol_button = Button(on_clicked=self.toggle_mute, child=self.vol_label) + self.event_box = EventBox( + events=["scroll", "smooth-scroll"], + child=Overlay(child=self.progress_bar, overlays=self.vol_button), + ) + self.audio.connect("notify::speaker", self.on_new_speaker) + if self.audio.speaker: + self.audio.speaker.connect("changed", self.on_speaker_changed) + self.event_box.connect("scroll-event", self.on_scroll) + self.add(self.event_box) + self.on_speaker_changed() + self.add_events(Gdk.EventMask.SCROLL_MASK | Gdk.EventMask.SMOOTH_SCROLL_MASK) + + def on_new_speaker(self, *args): + if self.audio.speaker: + self.audio.speaker.connect("changed", self.on_speaker_changed) + self.on_speaker_changed() + + def toggle_mute(self, event): + current_stream = self.audio.speaker + if current_stream: + current_stream.muted = not current_stream.muted + if current_stream.muted: + self.on_speaker_changed() + self.progress_bar.add_style_class("muted") + self.vol_label.add_style_class("muted") + else: + self.on_speaker_changed() + self.progress_bar.remove_style_class("muted") + self.vol_label.remove_style_class("muted") + + def on_scroll(self, _, event): + if not self.audio.speaker: + return + + current_volume = self.audio.speaker.volume + step = 1 + + if event.direction == Gdk.ScrollDirection.SMOOTH: + if hasattr(event, "delta_y") and abs(event.delta_y) > 0: + new_volume = current_volume - int(event.delta_y * step) + self.audio.speaker.volume = new_volume + # Remove delta_x handling + elif event.direction == Gdk.ScrollDirection.UP: + self.audio.speaker.volume = current_volume + step + elif event.direction == Gdk.ScrollDirection.DOWN: + self.audio.speaker.volume = current_volume - step + + def on_speaker_changed(self, *_): + if not self.audio.speaker: + return + + vol_high_icon = icons.vol_high + vol_medium_icon = icons.vol_medium + vol_mute_icon = icons.vol_off + vol_off_icon = icons.vol_mute + + if "bluetooth" in self.audio.speaker.icon_name: + vol_high_icon = icons.bluetooth_connected + vol_medium_icon = icons.bluetooth + vol_mute_icon = icons.bluetooth_off + vol_off_icon = icons.bluetooth_disconnected + + self.progress_bar.value = self.audio.speaker.volume / 100 + + if self.audio.speaker.muted: + self.vol_label.set_markup(vol_mute_icon) + self.progress_bar.add_style_class("muted") + self.vol_label.add_style_class("muted") + self.set_tooltip_text("Muted") + return + else: + self.progress_bar.remove_style_class("muted") + self.vol_label.remove_style_class("muted") + self.set_tooltip_text(f"{round(self.audio.speaker.volume)}%") + if self.audio.speaker.volume > 74: + self.vol_label.set_markup(vol_high_icon) + elif self.audio.speaker.volume > 0: + self.vol_label.set_markup(vol_medium_icon) + else: + self.vol_label.set_markup(vol_off_icon) + + +class MicroPhone(Box): + def __init__(self, **kwargs): + super().__init__(name="button-bar-mic", **kwargs) + self.audio = Audio() + self.progress_bar = CircularProgressBar( + name="button-mic", + size=28, + line_width=2, + start_angle=150, + end_angle=390, + ) + self.mic_label = Label(name="mic-label", markup=icons.mic) + self.mic_button = Button(on_clicked=self.toggle_mute, child=self.mic_label) + self.event_box = EventBox( + events=["scroll", "smooth-scroll"], + child=Overlay(child=self.progress_bar, overlays=self.mic_button), + ) + self.audio.connect("notify::microphone", self.on_new_microphone) + if self.audio.microphone: + self.audio.microphone.connect("changed", self.on_microphone_changed) + self.event_box.connect("scroll-event", self.on_scroll) + self.add_events(Gdk.EventMask.SCROLL_MASK | Gdk.EventMask.SMOOTH_SCROLL_MASK) + self.add(self.event_box) + self.on_microphone_changed() + + def on_new_microphone(self, *args): + if self.audio.microphone: + self.audio.microphone.connect("changed", self.on_microphone_changed) + self.on_microphone_changed() + + def toggle_mute(self, event): + current_stream = self.audio.microphone + if current_stream: + current_stream.muted = not current_stream.muted + if current_stream.muted: + self.mic_label.set_markup(icons.mic_mute) + self.progress_bar.add_style_class("muted") + self.mic_label.add_style_class("muted") + else: + self.on_microphone_changed() + self.progress_bar.remove_style_class("muted") + self.mic_label.remove_style_class("muted") + + def on_scroll(self, _, event): + if not self.audio.microphone: + return + if event.direction == Gdk.ScrollDirection.SMOOTH: + if hasattr(event, "delta_y") and abs(event.delta_y) > 0: + self.audio.microphone.volume -= event.delta_y + elif event.direction == Gdk.ScrollDirection.UP: + self.audio.microphone.volume += 1 + elif event.direction == Gdk.ScrollDirection.DOWN: + self.audio.microphone.volume -= 1 + + def on_microphone_changed(self, *_): + if not self.audio.microphone: + return + + self.progress_bar.value = self.audio.microphone.volume / 100 + + if self.audio.microphone.muted: + self.mic_label.set_markup(icons.mic_mute) + self.progress_bar.add_style_class("muted") + self.mic_label.add_style_class("muted") + self.set_tooltip_text("Muted") + return + else: + self.progress_bar.remove_style_class("muted") + self.mic_label.remove_style_class("muted") + self.set_tooltip_text(f"{round(self.audio.microphone.volume)}%") + if self.audio.microphone.volume > 74: + self.mic_label.set_markup(icons.mic) + elif self.audio.microphone.volume > 0: + self.mic_label.set_markup(icons.mic) + else: + self.mic_label.set_markup(icons.mic_mute) + + +class Controls(Box): + def __init__(self, **kwargs): + brightness = BrightnessService.get_initial() + children = [] + if brightness.screen_brightness != -1: + children.append(Brightness()) + children.extend([Volume(), MicroPhone()]) + super().__init__( + name="control", + orientation="h" if not data.VERTICAL else "v", + spacing=4, + children=children, + **kwargs, + ) + self.show_all() diff --git a/modules/dock/components/indicators.py b/modules/dock/components/indicators.py new file mode 100644 index 0000000..3874ce7 --- /dev/null +++ b/modules/dock/components/indicators.py @@ -0,0 +1,289 @@ +import subprocess +import time + +from fabric.bluetooth import BluetoothClient +from fabric.utils import get_relative_path +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.circularprogressbar import CircularProgressBar +from fabric.widgets.label import Label +from fabric.widgets.overlay import Overlay +from gi.repository import GLib + +import config.data as data +import utils.icons as icons +from services.network import NetworkClient + + +class WifiIndicator(Button): + def __init__(self, **kwargs): + super().__init__(name="button-bar-network", **kwargs) + + self.network = NetworkClient() + self.progress_bar = CircularProgressBar( + name="button-network", + size=28, + line_width=2, + start_angle=150, + end_angle=390, + ) + self.wifi_label = Label(name="network-label", markup=icons.loader) + + self.add(Overlay(child=self.progress_bar, overlays=self.wifi_label)) + + self.network.connect("ready", self.on_network_ready) + + if self.network.is_ready: + self.setup_signals() + + def on_network_ready(self, *args): + self.setup_signals() + + def setup_signals(self): + self.network.connect("changed", self.on_network_changed) + self.network.connect("wifi-device-added", self.on_wifi_device_added) + self.network.connect("wifi-device-removed", self.on_wifi_device_removed) + self.network.connect("ethernet-device-added", self.on_ethernet_device_added) + self.network.connect("ethernet-device-removed", self.on_ethernet_device_removed) + + if self.network.wifi_device: + self.network.wifi_device.connect("changed", self.on_wifi_changed) + self.network.wifi_device.connect("ap-added", self.on_wifi_changed) + self.network.wifi_device.connect("ap-removed", self.on_wifi_changed) + + if self.network.ethernet_device: + self.network.ethernet_device.connect("changed", self.on_ethernet_changed) + + self.update_state() + + def update_state(self): + if ( + self.network.ethernet_device + and self.network.ethernet_device.state == "activated" + ): + self.wifi_label.set_markup(icons.world) + self.progress_bar.value = 1.0 # Full circle for ethernet + tooltip = f"Connected to Ethernet ({self.network.ethernet_device.speed})" + self.set_tooltip_text(tooltip) + return + + if not self.network.wifi_device: + self.wifi_label.set_markup(icons.cloud_off) + self.progress_bar.value = 0 + tooltip = "No WiFi device found" + elif not self.network.wifi_device.wireless_enabled: + self.wifi_label.set_markup(icons.cloud_off) + self.progress_bar.value = 0 + tooltip = "WiFi disabled" + else: + active_ap = self.network.wifi_device.active_access_point + if active_ap: + strength = active_ap.strength + self.progress_bar.value = strength / 100 + if strength >= 75: + self.wifi_label.set_markup(icons.wifi_3) + elif strength >= 50: + self.wifi_label.set_markup(icons.wifi_2) + elif strength >= 25: + self.wifi_label.set_markup(icons.wifi_1) + else: + self.wifi_label.set_markup(icons.wifi_0) + tooltip = f"Connected to {active_ap.ssid} ({strength}%)" + else: + self.wifi_label.set_markup(icons.world_off) + self.progress_bar.value = 0 + tooltip = "Not connected" + + self.set_tooltip_text(tooltip) + + def on_wifi_device_added(self, *args): + self.network.wifi_device.connect("changed", self.on_wifi_changed) + self.network.wifi_device.connect("ap-added", self.on_wifi_changed) + self.network.wifi_device.connect("ap-removed", self.on_wifi_changed) + self.update_state() + + def on_wifi_device_removed(self, *args): + self.update_state() + + def on_network_changed(self, *args): + self.update_state() + + def on_wifi_changed(self, *args): + self.update_state() + + def on_ethernet_device_added(self, *args): + if self.network.ethernet_device: + self.network.ethernet_device.connect("changed", self.on_ethernet_changed) + self.update_state() + + def on_ethernet_device_removed(self, *args): + self.update_state() + + def on_ethernet_changed(self, *args): + self.update_state() + + +class BluetoothIndicator(Button): + def __init__(self, **kwargs): + super().__init__(name="button-bar-bluetooth", **kwargs) + + self.bluetooth = BluetoothClient() + + self.bt_label = Label(name="bluetooth-label", markup=icons.loader) + self.add(self.bt_label) + + self.bluetooth.connect("changed", self.on_bluetooth_changed) + self.bluetooth.connect("device-added", self.on_device_added) + self.bluetooth.connect("device-removed", self.on_device_removed) + + self.update_state() + + def update_state(self): + if not self.bluetooth.enabled: + self.bt_label.set_markup(icons.bluetooth_off) + tooltip = "Bluetooth disabled" + else: + connected_devices = self.bluetooth.connected_devices + if connected_devices: + self.bt_label.set_markup(icons.bluetooth_connected) + if len(connected_devices) == 1: + device = connected_devices[0] + tooltip = f"Connected to {device.alias}" + if device.battery_percentage > 0: + tooltip += f" ({device.battery_percentage:.0f}%)" + else: + tooltip = f"Connected to {len(connected_devices)} devices" + else: + if self.bluetooth.scanning: + self.bt_label.set_markup(icons.loader) + tooltip = "Scanning for devices..." + else: + self.bt_label.set_markup(icons.bluetooth) + tooltip = "No devices connected" + + self.set_tooltip_text(tooltip) + + def on_bluetooth_changed(self, *args): + self.update_state() + + def on_device_added(self, _, address): + self.update_state() + + def on_device_removed(self, _, address): + self.update_state() + + +class RecordingIndicator(Button): + def __init__(self, **kwargs): + super().__init__(name="button-bar-recording", visible=False, **kwargs) + + self.script_path = get_relative_path("../../../scripts/screen-capture.sh") + self.recording_start_time = None + + self.recording_label = Label(name="recording-label", markup=icons.screenrecord) + self.time_label = Label(name="recording-time-label", markup="00:00") + + # Adapt layout based on dock position + if data.VERTICAL: + # Vertical dock: show only icon + self.recording_box = Box( + orientation="v", spacing=2, children=[self.recording_label] + ) + else: + # Horizontal dock: place icon and time side by side + self.recording_box = Box( + orientation="h", + spacing=4, + children=[self.recording_label, self.time_label], + ) + self.add(self.recording_box) + + self.connect("clicked", self.on_stop_recording) + + self.hide() + + # Delay initial check to prevent showing during dock initialization + GLib.timeout_add(1, self._delayed_init) + + def check_recording_status(self): + try: + result = subprocess.run( + [self.script_path, "status"], capture_output=True, text=True, timeout=2 + ) + is_recording = result.stdout.strip() == "true" + + if is_recording: + if not self.get_visible(): + self.show() + + # Get the recording start time if we don't have it + if self.recording_start_time is None: + self.recording_start_time = self.get_recording_start_time() + + # Update the recording time display + if self.recording_start_time: + elapsed_seconds = int(time.time() - self.recording_start_time) + minutes = elapsed_seconds // 60 + seconds = elapsed_seconds % 60 + # Only update time label in horizontal mode + if not data.VERTICAL: + time_text = f"{minutes:02d}:{seconds:02d}" + self.time_label.set_markup(time_text) + self.set_tooltip_text( + f"Recording in progress ({minutes:02d}:{ + seconds:02d}) - Click to stop" + ) + else: + self.set_tooltip_text("Recording in progress - Click to stop") + else: + if self.get_visible(): + self.hide() + self.recording_start_time = None + + except Exception: + # If we can't check status, hide the indicator + if self.get_visible(): + self.hide() + self.recording_start_time = None + + return True # Continue the timeout + + def get_recording_start_time(self): + """Get the recording start time from the file""" + try: + with open("/tmp/recording_start_time.txt", "r") as f: + return float(f.read().strip()) + except Exception: + return None + + def on_stop_recording(self, *args): + try: + subprocess.Popen([self.script_path, "record", "stop"]) + except Exception as e: + print(f"Error stopping recording: {e}") + + def _delayed_init(self): + try: + self.check_recording_status() + self.timeout_id = GLib.timeout_add(1000, self.check_recording_status) + except Exception as e: + print(f"[DEBUG] Error in delayed recording indicator init: {e}") + return False # Don't repeat this timeout + + +class Indicators(Box): + def __init__(self, **kwargs): + self.recording_indicator = RecordingIndicator() + + super().__init__( + name="indicator", + orientation="h" if not data.VERTICAL else "v", + spacing=4, + children=[ + WifiIndicator(), + BluetoothIndicator(), + self.recording_indicator, + ], + **kwargs, + ) + self.show_all() diff --git a/modules/dock/components/metrics.py b/modules/dock/components/metrics.py new file mode 100644 index 0000000..fa62818 --- /dev/null +++ b/modules/dock/components/metrics.py @@ -0,0 +1,328 @@ +import json +import subprocess + +import psutil +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.circularprogressbar import CircularProgressBar +from fabric.widgets.label import Label +from fabric.widgets.revealer import Revealer +from gi.repository import GLib + +import config.data as data +import utils.icons as icons + + +class MetricsProvider: + def __init__(self): + self.gpu = [] + self.cpu = 0.0 + self.mem = 0.0 + self.swap = 0.0 + self.disk = [] + + self.bat_percent = 0.0 + self.bat_charging = None + self.bat_time = 0 + + self._gpu_update_running = False + + GLib.timeout_add_seconds(1, self._update) + + def _update(self): + self.cpu = psutil.cpu_percent(interval=0) + self.mem = psutil.virtual_memory().percent + self.swap = psutil.swap_memory().percent + self.disk = [psutil.disk_usage(path).percent for path in data.METRICS_DISKS] + + if not self._gpu_update_running: + self._start_gpu_update_async() + + return True + + def _start_gpu_update_async(self): + self._gpu_update_running = True + + GLib.Thread.new("nvtop-thread", lambda _: self._run_nvtop_in_thread(), None) + + def _run_nvtop_in_thread(self): + output = None + error_message = None + try: + result = subprocess.check_output(["nvtop", "-s"], text=True, timeout=10) + output = result + except FileNotFoundError: + error_message = "nvtop command not found." + except subprocess.CalledProcessError as e: + error_message = ( + f"nvtop failed with exit code {e.returncode}: {e.stderr.strip()}" + ) + except subprocess.TimeoutExpired: + error_message = "nvtop command timed out." + except Exception as e: + error_message = f"Unexpected error running nvtop: {e}" + + GLib.idle_add(self._process_gpu_output, output, error_message) + self._gpu_update_running = False + + def _process_gpu_output(self, output, error_message): + try: + if error_message: + self.gpu = [] + elif output: + try: + info = json.loads(output) + if not info or not isinstance(info, list): + self.gpu = [] + return False + + gpu_utils = [] + for gpu in info: + if not isinstance(gpu, dict): + continue + util = gpu.get("gpu_util", "0%") + if not isinstance(util, str): + continue + try: + # Remove % and convert to int, default to 0 if fails + util_value = int(util.rstrip("%") or 0) + gpu_utils.append(util_value) + except (ValueError, TypeError): + continue + + self.gpu = gpu_utils if gpu_utils else [] + except (KeyError, ValueError, TypeError, AttributeError): + self.gpu = [] + else: + self.gpu = [] + except json.JSONDecodeError: + self.gpu = [] + except Exception: + self.gpu = [] + + return False + + def get_metrics(self): + return (self.cpu, self.mem, self.swap, self.disk, self.gpu) + + def get_battery(self): + return (self.bat_percent, self.bat_charging, self.bat_time) + + def get_gpu_info(self): + try: + result = subprocess.check_output(["nvtop", "-s"], text=True, timeout=5) + if not result.strip(): + return [] + + info = json.loads(result) + if not info or not isinstance(info, list): + return [] + + valid_gpus = [] + for gpu in info: + if not isinstance(gpu, dict): + continue + + gpu_data = { + "device_name": gpu.get("device_name", "GPU"), + "gpu_util": gpu.get("gpu_util", "0%"), + "mem_util": gpu.get("mem_util", "0%"), + "temperature": gpu.get("temperature", "0 C"), + "power": gpu.get("power", "0 W"), + } + valid_gpus.append(gpu_data) + + return valid_gpus + + except FileNotFoundError: + return [] + except subprocess.CalledProcessError: + return [] + except subprocess.TimeoutExpired: + return [] + except json.JSONDecodeError: + return [] + except Exception: + return [] + + +shared_provider = MetricsProvider() + + +class SingularMetric: + def __init__(self, id, name, icon): + self.name_markup = name + self.icon_markup = icon + + self.icon = Label(name="metrics-icon", markup=icon) + self.circle = CircularProgressBar( + name="metrics-circle", + value=0, + size=28, + line_width=2, + start_angle=150, + end_angle=390, + style_classes=id, + child=self.icon, + ) + + self.level = Label(name="metrics-level", style_classes=id, label="0%") + self.revealer = Revealer( + name=f"metrics-{id}-revealer", + transition_duration=250, + transition_type="slide-left", + child=self.level, + child_revealed=False, + ) + + self.box = Box( + name=f"metrics-{id}-box", + orientation="h", + spacing=0, + children=[self.circle, self.revealer], + ) + + def markup(self): + return f"{self.icon_markup} {self.name_markup}: {self.level.get_label()}" + + +class Metrics(Button): + def __init__(self, **kwargs): + super().__init__(name="metrics", **kwargs) + + main_box = Box( + spacing=0, + orientation="h" if not data.VERTICAL else "v", + visible=True, + all_visible=True, + ) + + visible = getattr( + data, + "METRICS_VISIBLE", + {"cpu": True, "ram": True, "swap": True, "disk": True, "gpu": True}, + ) + disks = ( + [ + SingularMetric( + "disk", + f"DISK ({path})" if len(data.METRICS_DISKS) != 1 else "DISK", + icons.disk, + ) + for path in data.METRICS_DISKS + ] + if visible.get("disk", True) + else [] + ) + + gpu_info = shared_provider.get_gpu_info() + gpus = ( + [ + SingularMetric( + "gpu", + f"GPU ({v['device_name']})" if len(gpu_info) != 1 else "GPU", + icons.gpu, + ) + for v in gpu_info + ] + if visible.get("gpu", True) + else [] + ) + + self.cpu = ( + SingularMetric("cpu", "CPU", icons.cpu) + if visible.get("cpu", True) + else None + ) + self.ram = ( + SingularMetric("ram", "RAM", icons.memory) + if visible.get("ram", True) + else None + ) + self.swap = ( + SingularMetric("swap", "SWAP", icons.swap) + if visible.get("swap", True) + else None + ) + self.disk = disks + self.gpu = gpus + + for disk in self.disk: + main_box.add(disk.box) + main_box.add(Box(name="metrics-sep")) + if self.ram: + main_box.add(self.ram.box) + main_box.add(Box(name="metrics-sep")) + if self.swap: + main_box.add(self.swap.box) + main_box.add(Box(name="metrics-sep")) + if self.cpu: + main_box.add(self.cpu.box) + for gpu in self.gpu: + main_box.add(Box(name="metrics-sep")) + main_box.add(gpu.box) + + self.add(main_box) + + self.connect("clicked", self.on_click) + self.labels_visible = False + + GLib.timeout_add_seconds(1, self.update_metrics) + + def _format_percentage(self, value: int) -> str: + """Formato natural del porcentaje sin forzar ancho fijo.""" + return f"{value}%" + + def on_click(self, widget): + if not data.VERTICAL: + self.labels_visible = not self.labels_visible + if self.cpu: + self.cpu.revealer.set_reveal_child(self.labels_visible) + if self.ram: + self.ram.revealer.set_reveal_child(self.labels_visible) + if self.swap: + self.swap.revealer.set_reveal_child(self.labels_visible) + for disk in self.disk: + disk.revealer.set_reveal_child(self.labels_visible) + for gpu in self.gpu: + gpu.revealer.set_reveal_child(self.labels_visible) + + def update_metrics(self): + cpu, mem, swap, disks, gpus = shared_provider.get_metrics() + + if self.cpu: + self.cpu.circle.set_value(cpu / 100.0) + self.cpu.level.set_label(self._format_percentage(int(cpu))) + if self.ram: + self.ram.circle.set_value(mem / 100.0) + self.ram.level.set_label(self._format_percentage(int(mem))) + if self.swap: + self.swap.circle.set_value(swap / 100.0) + self.swap.level.set_label(self._format_percentage(int(swap))) + for i, disk in enumerate(self.disk): + if i < len(disks): + disk.circle.set_value(disks[i] / 100.0) + disk.level.set_label(self._format_percentage(int(disks[i]))) + for i, gpu in enumerate(self.gpu): + if i < len(gpus): + gpu.circle.set_value(gpus[i] / 100.0) + gpu.level.set_label(self._format_percentage(int(gpus[i]))) + + tooltip_metrics = [] + if self.disk: + tooltip_metrics.extend(self.disk) + if self.ram: + tooltip_metrics.append(self.ram) + if self.swap: + tooltip_metrics.append(self.swap) + if self.cpu: + tooltip_metrics.append(self.cpu) + if self.gpu: + tooltip_metrics.extend(self.gpu) + self.set_tooltip_markup( + (" - " if not data.VERTICAL else "\n").join( + [v.markup() for v in tooltip_metrics] + ) + ) + + return True diff --git a/modules/dock/components/player.py b/modules/dock/components/player.py new file mode 100644 index 0000000..9e4b55f --- /dev/null +++ b/modules/dock/components/player.py @@ -0,0 +1,447 @@ +import re + +from gi.repository import Playerctl +from fabric.core.service import Signal +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.centerbox import CenterBox +from fabric.widgets.label import Label +from fabric.widgets.stack import Stack +from loguru import logger + +import config.data as data +import utils.icons as icons +from services.mpris import PlayerManager, PlayerService +from utils.wiggle_bar import WigglyWidget + + +class Player(Box): + def __init__(self, player, **kwargs): + super().__init__(style_classes="player", orientation="v", **kwargs) + + # Check if we're in vertical mode based on dock position + vertical_mode = ( + data.DOCK_POSITION in ["Left", "Right"] + if hasattr(data, "DOCK_POSITION") + else False + ) + + if not vertical_mode: + # vertical class binding from unknown source + self.remove_style_class("vertical") + + self._player = PlayerService(player=player) + + self.duration = 0.0 + + self._player.connect("pause", self.on_pause) + self._player.connect("play", self.on_play) + self._player.connect("meta-change", self.on_metadata) + self._player.connect("shuffle-toggle", self.on_shuffle) + self._player.connect("track-position", self.on_update_track_position) + + self.player_name = Label( + name=player.props.player_name, + style_classes="player-icon", + markup=getattr(icons, player.props.player_name, icons.disc), + ) + + # Use a fallback background image + self.set_style(f"background-image:url('{data.HOME_DIR}/.current.wall')") + + self.song = Label( + name="song", + label="song", + justification="left", + h_align="start", + max_chars_width=10, + ) + self.artist = Label( + name="artist", label="artist", justification="left", h_align="start" + ) + self.music = Box( + name="music", + orientation="v", + h_expand=True, + v_expand=True, + children=[self.song, self.artist], + ) + + self.play_pause_button = Button( + name="pause-button", + child=Label(name="pause-label", markup=icons.play), + style_classes="pause-track", + tooltip_text="Play/Pause", + on_clicked=lambda b, *_: self.handle_play_pause(player), + ) + + self.shuffle_button = Button( + name="shuffle-button", + child=Label(name="shuffle", markup=icons.shuffle), + on_clicked=lambda b, *_: self.handle_shuffle(b, player), + ) + + self.wiggly = WigglyWidget() + self.wiggly.connect("on-seek", self.on_seek) + self.gtk_wrapper = Box( + orientation="v", + h_expand=True, + v_expand=True, + h_align="fill", + v_align="fill", + children=self.wiggly, + ) + + self.children = [ + Box(name="source", h_expand=True, v_expand=True, children=self.player_name), + CenterBox( + name="details", + start_children=self.music, + end_children=self.play_pause_button if not vertical_mode else [], + ), + Box( + name="controls", + style_classes="horizontal" if not vertical_mode else "vertical", + spacing=5, + children=[ + Button( + name="prev-button", + child=Label(name="play-previous", markup=icons.prev), + on_clicked=lambda b, *_: self.handle_prev(player), + ), + CenterBox( + name="progress-container", + h_expand=True, + v_expand=True, + orientation="v", + center_children=[self.gtk_wrapper], + ), + Button( + name="next-button", + child=Label(name="play-next", markup=icons.next), + on_clicked=lambda b, *_: self.handle_next(player), + ), + self.shuffle_button, + ] + if not vertical_mode + else [ + CenterBox( + orientation="v", + h_expand=True, + start_children=[ + CenterBox( + h_expand=True, + v_expand=True, + v_align="end", + start_children=[ + Button( + name="prev-button", + child=Label( + name="play-previous", markup=icons.prev + ), + on_clicked=lambda b, *_: self.handle_prev( + player + ), + ), + Button( + name="next-button", + child=Label( + name="play-next", markup=icons.next + ), + on_clicked=lambda b, *_: self.handle_next( + player + ), + ), + self.shuffle_button, + ], + end_children=self.play_pause_button, + ) + ], + end_children=CenterBox( + name="progress-container", + h_expand=True, + v_expand=True, + orientation="v", + center_children=[self.gtk_wrapper], + ), + ) + ], + ), + ] + + self.on_metadata(self._player, metadata=player.props.metadata, player=player) + + def on_update_track_position(self, sender, pos, dur): + self.duration = dur + if dur > 0: + self.wiggly.update_value_from_signal(pos / dur) + else: + # Duration is 0 or unknown - show some activity but don't update progress + # Keep the current progress or set to a small value to indicate activity + if pos > 0: + # Show minimal progress to indicate playback is active + self.wiggly.update_value_from_signal(0.1) + else: + self.wiggly.update_value_from_signal(0.0) + + def on_seek(self, sender, ratio): + pos = ratio * self.duration # duration in seconds + self._player.set_position(int(pos)) + + def on_metadata(self, sender, metadata, player): + keys = metadata.keys() + if "xesam:artist" in keys and "xesam:title" in keys: + # Check if we're in vertical mode + vertical_mode = ( + data.DOCK_POSITION in ["Left", "Right"] + if hasattr(data, "DOCK_POSITION") + else False + ) + _max_chars = 43 if not vertical_mode else 30 + + song_title = metadata["xesam:title"] + if len(song_title) > _max_chars: + song_title = song_title[: _max_chars - 1] + "…" + self.song.set_label(song_title) + + artist_list = metadata["xesam:artist"] + artist_name = artist_list[0] if artist_list else "Unknown Artist" + if len(artist_name) > _max_chars: + artist_name = artist_name[: _max_chars - 1] + "…" + self.artist.set_label(artist_name) + + if "mpris:artUrl" in keys: + self.set_style(f"background-image:url('{metadata['mpris:artUrl']}')") + elif "xesam:url" in keys and "youtube.com" in metadata["xesam:url"]: + # Simple YouTube thumbnail extraction for Firefox + url = metadata["xesam:url"] + match = re.search(r"(?:v=|/)([a-zA-Z0-9_-]{11})", url) + if match: + video_id = match.group(1) + thumbnail_url = ( + f"https://img.youtube.com/vi/{video_id}/hqdefault.jpg" + ) + self.set_style(f"background-image:url('{thumbnail_url}')") + + else: + # Fallback to default wallpaper + self.set_style( + f"background-image:url('{data.HOME_DIR}/.current.wall')" + ) + else: + # Fallback to default wallpaper when no artwork is available + self.set_style(f"background-image:url('{data.HOME_DIR}/.current.wall')") + + if ( + player.props.playback_status.value_name + == "PLAYERCTL_PLAYBACK_STATUS_PLAYING" + ): + self.on_play(self._player) + + if player.props.shuffle == True: + self.shuffle_button.get_child().set_markup( + icons.shuffle + ) # Use same icon for now + self.shuffle_button.get_child().set_name("disable-shuffle") + else: + self.shuffle_button.get_child().set_markup(icons.shuffle) + self.shuffle_button.get_child().set_name("shuffle") + + def on_pause(self, sender): + self.play_pause_button.get_child().set_markup(icons.play) + self.play_pause_button.get_child().set_name("pause-label") + self.wiggly.dragging = True + self.wiggly.update_amplitude(True) + self.wiggly.pause = True + self.play_pause_button.add_style_class("pause-track") + + def on_play(self, sender): + self.play_pause_button.get_child().set_markup(icons.pause) + self.play_pause_button.get_child().set_name("play-label") + self.wiggly.pause = False + self.wiggly.dragging = False + self.wiggly.update_amplitude(False) + self.play_pause_button.remove_style_class("pause-track") + + def on_shuffle(self, sender, player, status): + if status == False: + self.shuffle_button.get_child().set_markup(icons.shuffle) + self.shuffle_button.get_child().set_name("shuffle") + else: + self.shuffle_button.get_child().set_markup( + icons.shuffle + ) # Use same icon for now + self.shuffle_button.get_child().set_name("disable-shuffle") + + self.shuffle_button.get_child().set_style("color: white") + + def handle_next(self, player): + try: + player.next() + except Exception: + # Next is not available for this player (common with browsers) + pass + + def handle_prev(self, player): + try: + player.previous() + except Exception: + # Previous is not available for this player (common with browsers) + pass + + def handle_play_pause(self, player): + is_playing = self.play_pause_button.get_child().get_name() == "play-label" + + def _set_play_ui(): + self.play_pause_button.get_child().set_markup(icons.pause) + self.play_pause_button.remove_style_class("pause-track") + self.play_pause_button.get_child().set_name("pause-label") + + def _set_pause_ui(): + self.play_pause_button.get_child().set_markup(icons.play) + self.play_pause_button.add_style_class("pause-track") + self.play_pause_button.get_child().set_name("play-label") + + if is_playing: + _set_pause_ui() + else: + _set_play_ui() + + try: + player.play_pause() + except Exception as e: + # revert if signal failed + if is_playing: + _set_pause_ui() + else: + _set_play_ui() + logger.warning("Failed to toggle playback:", e) + + def handle_shuffle(self, shuffle_button, player): + try: + if player.props.shuffle == False: + player.set_shuffle(True) + else: + player.set_shuffle(False) + shuffle_button.get_child().set_style("color: var(--outline)") + except Exception: + # Shuffle is not available for this player (common with browsers) + pass + + +class PlayerContainer(Box): + @Signal + def active_player_changed(self, player: Playerctl.Player) -> None: ... + + def __init__(self, **kwargs): + super().__init__(name="player-container", orientation="v", **kwargs) + + self.manager = PlayerManager() + self.manager.connect("new-player", self.new_player) + self.manager.connect("player-vanish", self.on_player_vanish) + self.stack = Stack( + name="player-container", + transition_type="crossfade", + transition_duration=100, + children=[], + ) + + self.player_stack = Stack( + name="player-stack", + transition_type="crossfade", + transition_duration=100, + children=[], + ) + + # Check if we're in vertical mode + vertical_mode = ( + data.DOCK_POSITION in ["Left", "Right"] + if hasattr(data, "DOCK_POSITION") + else False + ) + + self.player_switch_container = CenterBox( + name="player-switch-container", + orientation="h", + style_classes="horizontal-player" + if not vertical_mode + else "vertical-player", + center_children=[], + ) + self.children = [self.stack, self.player_switch_container] + self.player = [] + self.player_objects = {} # Map player names to player objects + self.active_player = None # Track the currently active player + self.manager.init_all_players() + + def new_player(self, manager, player): + new_player = Player(player=player) + new_player.gtk_wrapper.queue_draw() + new_player.set_name(player.props.player_name) + self.player.append(new_player) + # Store the player object + self.player_objects[player.props.player_name] = player + self.stack.add_named(new_player, player.props.player_name) + + self.player_switch_container.add_center( + Button( + name=player.props.player_name, + style_classes="player-button", + on_clicked=lambda b: self.switch_player(player.props.player_name, b), + ) + ) + + # If this is the first player, make it active + if not self.active_player: + self.active_player = player + self.active_player_changed(player) + + self.update_player_list() + + def switch_player(self, player_name, button): + self.stack.set_visible_child_name(player_name) + + for btn in self.player_switch_container.center_children: + btn.remove_style_class("active") + button.add_style_class("active") + + # Get the player object and emit signal + if player_name in self.player_objects: + self.active_player = self.player_objects[player_name] + self.active_player_changed(self.active_player) + + def on_player_vanish(self, manager, player): + player_name = player.props.player_name + + for player_instance in self.player: + if player_instance.get_name() == player_name: + self.stack.remove(player_instance) + self.player.remove(player_instance) + for btn in self.player_switch_container.center_children: + if btn.get_name() == player_name: + self.player_switch_container.remove_center(btn) + break + + # Clean up player objects dictionary + if player_name in self.player_objects: + del self.player_objects[player_name] + + # If the active player vanished, switch to another one + if self.active_player and self.active_player.props.player_name == player_name: + self.active_player = None + # Try to find another player to make active + if self.player_objects: + first_player_name = next(iter(self.player_objects)) + self.active_player = self.player_objects[first_player_name] + self.active_player_changed(self.active_player) + + self.update_player_list() + + def update_player_list(self): + curr = self.stack.get_visible_child() + if curr: + for btn in self.player_switch_container.center_children: + if btn.get_name() == curr.get_name(): + btn.add_style_class("active") + else: + btn.remove_style_class("active") \ No newline at end of file diff --git a/modules/dock/components/systemtray.py b/modules/dock/components/systemtray.py new file mode 100644 index 0000000..bdc3052 --- /dev/null +++ b/modules/dock/components/systemtray.py @@ -0,0 +1,170 @@ +import config.data as data +from gi.repository import Gdk, GdkPixbuf, GLib, Gray, Gtk +from fabric.widgets.box import Box +import logging +import gi + +gi.require_version("Gray", "0.1") + + +logger = logging.getLogger(__name__) + + +class SystemTray(Box): + def __init__( + self, pixel_size: int = 16, refresh_interval: int = 1, **kwargs + ) -> None: + orientation = ( + Gtk.Orientation.HORIZONTAL + if not data.VERTICAL + else Gtk.Orientation.VERTICAL + ) + super().__init__(name="tray", orientation=orientation, spacing=4, **kwargs) + self.enabled = True + super().set_visible(False) + self.pixel_size = pixel_size + self.refresh_interval = refresh_interval + + self.buttons_by_id = {} + self.items_by_id = {} + + self.watcher = Gray.Watcher() + self.watcher.connect("item-added", self.on_watcher_item_added) + + GLib.timeout_add_seconds(self.refresh_interval, self._refresh_all_items) + + def set_visible(self, visible: bool): + self.enabled = visible + self._update_visibility() + + def _update_visibility(self): + has = len(self.get_children()) > 0 + super().set_visible(self.enabled and has) + + def _get_item_pixbuf(self, item: Gray.Item) -> GdkPixbuf.Pixbuf: + try: + pm = Gray.get_pixmap_for_pixmaps(item.get_icon_pixmaps(), self.pixel_size) + if pm: + return pm.as_pixbuf(self.pixel_size, GdkPixbuf.InterpType.HYPER) + + name = item.get_icon_name() + theme = Gtk.IconTheme.get_default() + path = item.get_icon_theme_path() + if path: + theme.prepend_search_path(path) + return theme.load_icon( + name, self.pixel_size, Gtk.IconLookupFlags.FORCE_SIZE + ) + except GLib.Error as e: + logger.debug(f"Icon load error {e}") + return Gtk.IconTheme.get_default().load_icon( + "image-missing", self.pixel_size, Gtk.IconLookupFlags.FORCE_SIZE + ) + + def _refresh_item_ui(self, identifier: str, item: Gray.Item, button: Gtk.Button): + pixbuf = self._get_item_pixbuf(item) + img = button.get_image() + if isinstance(img, Gtk.Image): + img.set_from_pixbuf(pixbuf) + else: + new = Gtk.Image.new_from_pixbuf(pixbuf) + button.set_image(new) + new.show() + tip = None + if hasattr(item, "get_tooltip_text"): + tip = item.get_tooltip_text() + elif hasattr(item, "get_title"): + tip = item.get_title() + if tip: + button.set_tooltip_text(tip) + else: + button.set_has_tooltip(False) + + def _refresh_all_items(self) -> bool: + for ident, item in self.items_by_id.items(): + btn = self.buttons_by_id.get(ident) + if btn: + self._refresh_item_ui(ident, item, btn) + return True + + def on_watcher_item_added(self, _, identifier: str): + item = self.watcher.get_item_for_identifier(identifier) + if not item: + return + + if identifier in self.buttons_by_id: + self.buttons_by_id[identifier].destroy() + del self.buttons_by_id[identifier] + del self.items_by_id[identifier] + + btn = self.do_bake_item_button(item) + self.buttons_by_id[identifier] = btn + self.items_by_id[identifier] = item + + item.connect( + "notify::icon-pixmaps", + lambda itm, pspec: self._refresh_item_ui(identifier, itm, btn), + ) + item.connect( + "notify::icon-name", + lambda itm, pspec: self._refresh_item_ui(identifier, itm, btn), + ) + + try: + item.connect( + "updated", lambda itm: self._refresh_item_ui(identifier, itm, btn) + ) + except TypeError: + pass + + item.connect( + "removed", lambda itm: self.on_item_instance_removed(identifier, itm) + ) + + self.add(btn) + btn.show_all() + self._update_visibility() + + def do_bake_item_button(self, item: Gray.Item) -> Gtk.Button: + btn = Gtk.Button() + btn.connect("button-press-event", lambda b, e: self.on_button_click(b, item, e)) + img = Gtk.Image.new_from_pixbuf(self._get_item_pixbuf(item)) + btn.set_image(img) + tip = ( + item.get_tooltip_text() + if hasattr(item, "get_tooltip_text") + else getattr(item, "get_title", lambda: None)() + ) + if tip: + btn.set_tooltip_text(tip) + return btn + + def on_item_instance_removed(self, identifier: str, removed_item: Gray.Item): + if self.items_by_id.get(identifier) is removed_item: + btn = self.buttons_by_id.pop(identifier, None) + self.items_by_id.pop(identifier, None) + if btn: + btn.destroy() + self._update_visibility() + + def on_button_click( + self, button: Gtk.Button, item: Gray.Item, event: Gdk.EventButton + ): + if event.button == Gdk.BUTTON_PRIMARY: + try: + item.activate(int(event.x_root), int(event.y_root)) + except Exception as e: + logger.error(f"Activate error: {e}") + elif event.button == Gdk.BUTTON_SECONDARY: + menu = getattr(item, "get_menu", lambda: None)() + if isinstance(menu, Gtk.Menu): + menu.popup_at_widget( + button, Gdk.Gravity.SOUTH_WEST, Gdk.Gravity.NORTH_WEST, event + ) + else: + cm = getattr(item, "context_menu", None) + if cm: + try: + cm(int(event.x_root), int(event.y_root)) + except Exception as e: + logger.error(f"ContextMenu error: {e}") diff --git a/modules/dock/components/workspaces.py b/modules/dock/components/workspaces.py new file mode 100644 index 0000000..0ba3de3 --- /dev/null +++ b/modules/dock/components/workspaces.py @@ -0,0 +1,147 @@ +import json +import subprocess + +from fabric.hyprland.service import Hyprland +from fabric.hyprland.widgets import WorkspaceButton, HyprlandWorkspaces +from fabric.widgets.box import Box +from fabric.widgets.button import Button + +import config.data as data + +CHINESE_NUMERALS = ["一", "二", "三", "四", "五", "六", "七", "八", "九", "〇"] + + +class Workspace(Button): + def __init__(self): + should_invert = data.DOCK_THEME in ["Dense", "Edge"] or ( + data.DOCK_THEME == "Pills" and data.DOCK_POSITION in ["Left", "Right"] + ) + + super().__init__( + name="workspace-single", + can_focus=True, + receives_default=True, + relief="none", + style_classes="invert" if should_invert else None, + ) + + self.connection = Hyprland() + self.activeWorkspace = str(self._get_active_workspace()) + + self.set_label(str(self.activeWorkspace)) + self.connect("clicked", self._on_click) + self.connection.connect("event::workspace", self._on_workspace_changed) + + self.set_sensitive(True) + self.show_all() + + def _get_active_workspace(self): + active = self.connection.send_command("j/activeworkspace").reply + return json.loads(active.decode("utf-8"))["name"] + + def _get_active_workspaces(self): + clients_data = self.connection.send_command("j/clients").reply + clients = json.loads(clients_data.decode("utf-8")) + + active_workspaces = set() + for client in clients: + if "workspace" in client and "name" in client["workspace"]: + workspace_name = str(client["workspace"]["name"]) + active_workspaces.add(workspace_name) + + return sorted( + list(active_workspaces), key=lambda x: int(x) if x.isdigit() else x + ) + + def _on_click(self, _widget): + self._cycle_workspace() + + def _cycle_workspace(self): + active_workspaces = self._get_active_workspaces() + + if not active_workspaces: + return + + if str(self.activeWorkspace) not in active_workspaces: + active_workspaces.append(str(self.activeWorkspace)) + active_workspaces.sort(key=lambda x: int(x) if x.isdigit() else x) + + current_index = active_workspaces.index(self.activeWorkspace) + next_index = (current_index + 1) % len(active_workspaces) + next_workspace = active_workspaces[next_index] + + cmd = ["hyprctl", "dispatch", "workspace", str(next_workspace)] + subprocess.run(cmd, capture_output=True, text=True, timeout=2) + + self.activeWorkspace = str(next_workspace) + self.set_label(str(self.activeWorkspace)) + + def _on_workspace_changed(self, _obj, signal): + """Handle workspace change events from Hyprland""" + workspace_data = json.loads(signal.data[0]) + self.activeWorkspace = str(workspace_data) + self.set_label(str(self.activeWorkspace)) + + +workspaces = HyprlandWorkspaces( + name="workspaces", + invert_scroll=True, + empty_scroll=True, + v_align="fill", + orientation="h" if not data.VERTICAL else "v", + spacing=8, + buttons=[ + WorkspaceButton( + h_expand=False, + v_expand=False, + h_align="center", + v_align="center", + id=i, + label=None, + style_classes=["vertical"] if data.VERTICAL else None, + ) + for i in range(1, 11) + ], + buttons_factory=None + if data.DOCK_HIDE_SPECIAL_WORKSPACE + else HyprlandWorkspaces.default_buttons_factory, +) + +workspaces_num = HyprlandWorkspaces( + name="workspaces-num", + invert_scroll=True, + empty_scroll=True, + v_align="fill", + orientation="h" if not data.VERTICAL else "v", + spacing=0 if not data.WORKSPACE_USE_CHINESE_NUMERAL else 4, + buttons=[ + WorkspaceButton( + h_expand=False, + v_expand=False, + h_align="center", + v_align="center", + id=i, + label=CHINESE_NUMERALS[i - 1] + if data.WORKSPACE_USE_CHINESE_NUMERAL and 1 <= i <= len(CHINESE_NUMERALS) + else str(i), + ) + for i in range(1, 11) + ], + buttons_factory=None + if data.DOCK_HIDE_SPECIAL_WORKSPACE + else HyprlandWorkspaces.default_buttons_factory, +) + + +workspaceactive = Workspace() + +workspace = Box( + name="workspaces-container", + children=[ + workspaces + if data.WORKSPACE_DOTS + else workspaces_num + if data.WORKSPACE_NUMS + else workspaceactive + ], +) diff --git a/modules/dock/main.py b/modules/dock/main.py new file mode 100644 index 0000000..9aae8a2 --- /dev/null +++ b/modules/dock/main.py @@ -0,0 +1,511 @@ +import json + +import config.data as data +from fabric.hyprland.widgets import get_hyprland_connection +from fabric.widgets.box import Box +from fabric.widgets.eventbox import EventBox +from fabric.widgets.revealer import Revealer +from gi.repository import Gdk, GLib, Gtk +from modules.corners import MyCorner +from modules.dock.components import DockComponents +from utils.occlusion import check_occlusion +from utils.wayland import WaylandWindow as Window + + +class Dock(Window): + _instances = [] + + def __init__(self, **kwargs): + self.icon_size = 28 + self.effective_occlusion_size = 36 + self.icon_size + + anchor_to_set: str + revealer_transition_type: str + + self.actual_dock_is_horizontal: bool + main_box_orientation_val: Gtk.Orientation + main_box_h_align_val: str + dock_wrapper_orientation_val: Gtk.Orientation + + self.actual_dock_is_horizontal = not data.VERTICAL + + if self.actual_dock_is_horizontal: + if data.DOCK_POSITION == "Top": + anchor_to_set = "top" + revealer_transition_type = "slide-down" + else: # Bottom + anchor_to_set = "bottom" + revealer_transition_type = "slide-up" + main_box_orientation_val = Gtk.Orientation.VERTICAL + main_box_h_align_val = "center" + dock_wrapper_orientation_val = Gtk.Orientation.HORIZONTAL + else: + if data.DOCK_POSITION == "Left": + anchor_to_set = "left" + revealer_transition_type = "slide-right" + elif data.DOCK_POSITION == "Right": + anchor_to_set = "right" + revealer_transition_type = "slide-left" + else: + anchor_to_set = "right" + revealer_transition_type = "slide-left" + + main_box_orientation_val = Gtk.Orientation.HORIZONTAL + main_box_h_align_val = "end" if anchor_to_set == "right" else "start" + dock_wrapper_orientation_val = Gtk.Orientation.VERTICAL + + super().__init__( + name="dock-window", + layer="top", + anchor=anchor_to_set, + margin="0px 0px 0px 0px", + exclusivity="auto" if not data.DOCK_AUTO_HIDE else "none", + **kwargs, + ) + Dock._instances.append(self) + + self.conn = get_hyprland_connection() + + self.hide_id = None + self._arranger_handler = None + self._drag_in_progress = False + self.always_occluded = data.DOCK_ALWAYS_OCCLUDED + self.is_mouse_over_dock_area = False + self._prevent_occlusion = False + + self.view = Box(name="viewport", spacing=4) + self.wrapper = Box( + name="dock", + children=[self.view], + style_classes=["left"] + if data.DOCK_POSITION == "Left" + else ["right"] + if data.DOCK_POSITION == "Right" + else ["top"] + if data.DOCK_POSITION == "Top" + else [], + ) + + self.wrapper.set_orientation(dock_wrapper_orientation_val) + self.view.set_orientation(dock_wrapper_orientation_val) + + if dock_wrapper_orientation_val == Gtk.Orientation.VERTICAL: + self.wrapper.add_style_class("vertical") + else: + self.wrapper.remove_style_class("vertical") + + match data.DOCK_THEME: + case "Pills": + self.wrapper.add_style_class("pills") + case "Dense": + self.wrapper.add_style_class("dense") + case "Edge": + self.wrapper.add_style_class("edge") + case _: + self.wrapper.add_style_class("pills") + + self.dock_eventbox = EventBox() + self.dock_eventbox.add(self.wrapper) + self.dock_eventbox.connect("enter-notify-event", self._on_dock_enter) + self.dock_eventbox.connect("leave-notify-event", self._on_dock_leave) + + self.components = DockComponents( + orientation_val="h" if not data.VERTICAL else "v", dock_instance=self + ) + + if self.actual_dock_is_horizontal: + self.view.add(self.components) + elif data.DOCK_POSITION == "Left": + self.view.add(self.components) + else: # Right position + self.view.add(self.components) + + self.corner_left = Box() + self.corner_right = Box() + self.corner_top = Box() + self.corner_bottom = Box() + + if self.actual_dock_is_horizontal: + if data.DOCK_POSITION == "Top": + self.corner_left = Box( + name="dock-corner-left", + orientation=Gtk.Orientation.VERTICAL, + h_align="start", + children=[MyCorner("top-right"), Box(v_expand=True, v_align="fill")], + ) + self.corner_right = Box( + name="dock-corner-right", + orientation=Gtk.Orientation.VERTICAL, + h_align="end", + children=[MyCorner("top-left"), Box(v_expand=True, v_align="fill")], + ) + else: # Bottom + self.corner_left = Box( + name="dock-corner-left", + orientation=Gtk.Orientation.VERTICAL, + h_align="start", + children=[Box(v_expand=True, v_align="fill"), MyCorner("bottom-right")], + ) + self.corner_right = Box( + name="dock-corner-right", + orientation=Gtk.Orientation.VERTICAL, + h_align="end", + children=[Box(v_expand=True, v_align="fill"), MyCorner("bottom-left")], + ) + self.dock_full = Box( + name="dock-full", + orientation=Gtk.Orientation.HORIZONTAL, + h_expand=True, + h_align="fill", + children=[self.corner_left, self.dock_eventbox, self.corner_right], + ) + else: + if anchor_to_set == "right": + self.corner_top = Box( + name="dock-corner-top", + orientation=Gtk.Orientation.HORIZONTAL, + v_align="start", + children=[ + Box(h_expand=True, h_align="fill"), + MyCorner("bottom-right"), + ], + ) + self.corner_bottom = Box( + name="dock-corner-bottom", + orientation=Gtk.Orientation.HORIZONTAL, + v_align="end", + children=[ + Box(h_expand=True, h_align="fill"), + MyCorner("top-right"), + ], + ) + else: + self.corner_top = Box( + name="dock-corner-top", + orientation=Gtk.Orientation.HORIZONTAL, + v_align="start", + children=[ + MyCorner("bottom-left"), + Box(h_expand=True, h_align="fill"), + ], + ) + self.corner_bottom = Box( + name="dock-corner-bottom", + orientation=Gtk.Orientation.HORIZONTAL, + v_align="end", + children=[MyCorner("top-left"), Box(h_expand=True, h_align="fill")], + ) + + self.dock_full = Box( + name="dock-full", + orientation=Gtk.Orientation.VERTICAL, + v_expand=True, + v_align="fill", + margin=0, + children=[self.corner_top, self.dock_eventbox, self.corner_bottom], + ) + + self.dock_revealer = Revealer( + name="dock-revealer", + transition_type=revealer_transition_type, + transition_duration=250, + child_revealed=False, + child=self.dock_full, + ) + + self.hover_activator = EventBox() + + if self.actual_dock_is_horizontal: + # Bottom dock + self.hover_activator.set_size_request(-1, 1) + else: + # Vertical dock (Left or Right) + self.hover_activator.set_size_request(1, -1) + + self.hover_activator.connect("enter-notify-event", self._on_hover_enter) + self.hover_activator.connect("leave-notify-event", self._on_hover_leave) + + if self.actual_dock_is_horizontal: + if data.DOCK_POSITION == "Top": + # Top dock - revealer first, then hover activator + self.main_box = Box( + orientation=main_box_orientation_val, + children=[self.dock_revealer, self.hover_activator], + h_align=main_box_h_align_val, + ) + else: + # Bottom dock - hover activator first, then revealer + self.main_box = Box( + orientation=main_box_orientation_val, + children=[self.hover_activator, self.dock_revealer], + h_align=main_box_h_align_val, + ) + else: + # Vertical dock + if data.DOCK_POSITION == "Left": + # Left dock - revealer first, then hover activator + self.main_box = Box( + orientation=main_box_orientation_val, + children=[self.dock_revealer, self.hover_activator], + h_align=main_box_h_align_val, + ) + else: + # Right dock - hover activator first, then revealer + self.main_box = Box( + orientation=main_box_orientation_val, + children=[self.hover_activator, self.dock_revealer], + h_align=main_box_h_align_val, + ) + self.add(self.main_box) + + if data.DOCK_THEME in ["Edge", "Dense"]: + for corner in [ + self.corner_left, + self.corner_right, + self.corner_top, + self.corner_bottom, + ]: + corner.set_visible(False) + + if not data.DOCK_ENABLED: + self.set_visible(False) + + if self.always_occluded: + self.dock_full.add_style_class("occluded") + + if self.conn.ready: + GLib.timeout_add(250, self.check_occlusion_state) + else: + self.conn.connect( + "event::ready", + lambda *args: GLib.timeout_add(250, self.check_occlusion_state), + ) + + self.conn.connect("event::workspace", self.check_hide) + + GLib.timeout_add_seconds(1, self.check_config_change) + + def _on_hover_enter(self, *args): + self.is_mouse_over_dock_area = True + if self.hide_id: + GLib.source_remove(self.hide_id) + self.hide_id = None + self.dock_revealer.set_reveal_child(True) + if not self.always_occluded: + self.dock_full.remove_style_class("occluded") + + def _on_hover_leave(self, *args): + self.is_mouse_over_dock_area = False + self.delay_hide() + + def _on_dock_enter(self, widget, event): + self.is_mouse_over_dock_area = True + if self.hide_id: + GLib.source_remove(self.hide_id) + self.hide_id = None + self.dock_revealer.set_reveal_child(True) + if not self.always_occluded: + self.dock_full.remove_style_class("occluded") + return True + + def _on_dock_leave(self, widget, event): + if event.detail == Gdk.NotifyType.INFERIOR: + return False + + self.is_mouse_over_dock_area = False + self.delay_hide() + + if self.always_occluded: + self.dock_full.add_style_class("occluded") + return True + + def delay_hide(self): + if self.hide_id: + GLib.source_remove(self.hide_id) + self.hide_id = GLib.timeout_add(250, self.hide_dock_if_not_hovered) + + def hide_dock_if_not_hovered(self): + self.hide_id = None + if ( + not self.is_mouse_over_dock_area + and not self._drag_in_progress + and not self._prevent_occlusion + ): + if self.always_occluded: + self.dock_revealer.set_reveal_child(False) + else: + if self.actual_dock_is_horizontal: + occlusion_region = ( + ("top", self.effective_occlusion_size) + if data.DOCK_POSITION == "Top" + else ("bottom", self.effective_occlusion_size) + ) + else: + occlusion_region = ( + ("left", self.effective_occlusion_size) + if data.DOCK_POSITION == "Left" + else ("right", self.effective_occlusion_size) + ) + if check_occlusion(occlusion_region) or not self.view.get_children(): + self.dock_revealer.set_reveal_child(False) + return False + + def check_hide(self, *args): + if ( + self.is_mouse_over_dock_area + or self._drag_in_progress + or self._prevent_occlusion + ): + return + + clients = self.get_clients() + current_ws = self.get_workspace() + ws_clients = [w for w in clients if w["workspace"]["id"] == current_ws] + + if not self.always_occluded: + if not ws_clients: + if not self.dock_revealer.get_reveal_child(): + self.dock_revealer.set_reveal_child(True) + self.dock_full.remove_style_class("occluded") + elif any( + not w.get("floating") and not w.get("fullscreen") for w in ws_clients + ): + self.check_occlusion_state() + else: + if not self.dock_revealer.get_reveal_child(): + self.dock_revealer.set_reveal_child(True) + self.dock_full.remove_style_class("occluded") + else: + if self.dock_revealer.get_reveal_child(): + self.dock_revealer.set_reveal_child(False) + self.dock_full.add_style_class("occluded") + + def get_clients(self): + try: + return json.loads(self.conn.send_command("j/clients").reply.decode()) + except json.JSONDecodeError: + return [] + + def get_focused(self): + try: + return json.loads( + self.conn.send_command("j/activewindow").reply.decode() + ).get("address", "") + except json.JSONDecodeError: + return "" + + def get_workspace(self): + try: + return json.loads( + self.conn.send_command("j/activeworkspace").reply.decode() + ).get("id", 0) + except json.JSONDecodeError: + return 0 + + def check_occlusion_state(self): + if ( + self.is_mouse_over_dock_area + or self._drag_in_progress + or self._prevent_occlusion + ): + if not self.dock_revealer.get_reveal_child(): + self.dock_revealer.set_reveal_child(True) + if not self.always_occluded: + self.dock_full.remove_style_class("occluded") + return True + + if not data.DOCK_AUTO_HIDE: + if not self.dock_revealer.get_reveal_child(): + self.dock_revealer.set_reveal_child(True) + if not self.always_occluded: + self.dock_full.remove_style_class("occluded") + return True + + if self.always_occluded: + if self.dock_revealer.get_reveal_child(): + self.dock_revealer.set_reveal_child(False) + self.dock_full.add_style_class("occluded") + return True + + if self.actual_dock_is_horizontal: + occlusion_region = ( + ("top", self.effective_occlusion_size) + if data.DOCK_POSITION == "Top" + else ("bottom", self.effective_occlusion_size) + ) + else: + occlusion_region = ( + ("left", self.effective_occlusion_size) + if data.DOCK_POSITION == "Left" + else ("right", self.effective_occlusion_size) + ) + is_occluded_by_window = check_occlusion(occlusion_region) + is_empty = not self.view.get_children() + + if is_occluded_by_window or is_empty: + if self.dock_revealer.get_reveal_child(): + self.dock_revealer.set_reveal_child(False) + self.dock_full.add_style_class("occluded") + else: + if not self.dock_revealer.get_reveal_child(): + self.dock_revealer.set_reveal_child(True) + self.dock_full.remove_style_class("occluded") + + return True + + def _update_size(self): + width, _ = self.view.get_preferred_width() + self.set_size_request(width, -1) + return False + + def check_config_change(self): + new_always_occluded = data.DOCK_ALWAYS_OCCLUDED + if self.always_occluded != new_always_occluded: + self.always_occluded = new_always_occluded + self.check_occlusion_state() + return True + + def check_config_change_immediate(self): + previous_always_occluded = self.always_occluded + self.always_occluded = data.DOCK_ALWAYS_OCCLUDED + + if previous_always_occluded != self.always_occluded: + self.check_occlusion_state() + return False + + @staticmethod + def notify_config_change(): + for dock_instance in Dock._instances: + GLib.idle_add(dock_instance.check_config_change_immediate) + + @staticmethod + def update_visibility(visible): + for dock in Dock._instances: + dock.set_visible(visible) + if visible: + GLib.idle_add(dock.check_occlusion_state) + else: + if ( + hasattr(dock, "dock_revealer") + and dock.dock_revealer.get_reveal_child() + ): + dock.dock_revealer.set_reveal_child(False) + + def on_app_drag_begin(self): + self._drag_in_progress = True + self.dock_revealer.set_reveal_child(True) + if not self.always_occluded: + self.dock_full.remove_style_class("occluded") + + def on_app_drag_end(self): + self._drag_in_progress = False + GLib.idle_add(self.check_occlusion_state) + + def prevent_hiding(self, prevent=True): + self._prevent_occlusion = prevent + if prevent: + self.dock_revealer.set_reveal_child(True) + if not self.always_occluded: + self.dock_full.remove_style_class("occluded") + else: + GLib.idle_add(self.check_occlusion_state) diff --git a/modules/launcher/__init__.py b/modules/launcher/__init__.py new file mode 100644 index 0000000..547fa73 --- /dev/null +++ b/modules/launcher/__init__.py @@ -0,0 +1,8 @@ +""" +Plugin-based launcher module for Fabric. +Similar to Albert Launcher with extensible plugin system. +""" + +from .main import Launcher + +__all__ = ["Launcher"] diff --git a/modules/launcher/components/__init__.py b/modules/launcher/components/__init__.py deleted file mode 100644 index da2ef07..0000000 --- a/modules/launcher/components/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -from .applauncher import * -from .bluetooth import * -from .cliphist import * -from .dashboard import * -from .emoji import * -from .power import * -from .sh import * -from .wallpapers import * -from .wifi import * -from .calendar import * -from .dashboard import * -from .tools import * -from .kanban import * -from .tmux import * -from .window_switcher import * -from .notification_center import * diff --git a/modules/launcher/components/applauncher.py b/modules/launcher/components/applauncher.py deleted file mode 100644 index 83380de..0000000 --- a/modules/launcher/components/applauncher.py +++ /dev/null @@ -1,630 +0,0 @@ -import operator -from collections.abc import Iterator -from fabric.widgets.box import Box -from fabric.widgets.label import Label -from fabric.widgets.button import Button -from fabric.widgets.entry import Entry -from fabric.widgets.image import Image -from fabric.widgets.scrolledwindow import ScrolledWindow -from fabric.utils import ( - DesktopApp, - get_desktop_applications, - idle_add, - remove_handler, - exec_shell_command_async, -) -from fabric.utils.helpers import get_relative_path -from gi.repository import GLib, Gdk -import utils.icons as icons -import config.data as data -import json -import os -import re -import math -import numpy as np -import subprocess -from modules.dock import Dock # Import the Dock class - - -class AppLauncher(Box): - def __init__(self, **kwargs): - super().__init__( - name="app-launcher", - visible=False, - all_visible=False, - **kwargs, - ) - - self.launcher = kwargs["launcher"] - self.selected_index = -1 # Track the selected item index - - self._arranger_handler: int = 0 - self._all_apps = get_desktop_applications() - self.config = data.read_config() - - # Calculator history initialization - self.calc_history_path = f"{data.CACHE_DIR}/calc.json" - if os.path.exists(self.calc_history_path): - with open(self.calc_history_path, "r") as f: - self.calc_history = json.load(f) - else: - self.calc_history = [] - - self.viewport = Box(name="viewport", spacing=4, orientation="v") - self.search_entry = Entry( - name="search-entry", - placeholder="Search Applications...", - h_expand=True, - notify_text=self.notify_text, # Use the method instead of lambda - on_activate=lambda entry, *_: self.on_search_entry_activate( - entry.get_text() - ), - on_key_press_event=self.on_search_entry_key_press, # Handle key presses - ) - self.search_entry.props.xalign = 0.5 - self.scrolled_window = ScrolledWindow( - name="scrolled-window", - spacing=10, - min_content_size=(450, 105), - max_content_size=(450, 105), - child=self.viewport, - ) - - self.header_box = Box( - name="header_box", - spacing=10, - orientation="h", - children=[ - Button( - name="config-button", - child=Label(name="config-label", markup=icons.config), - on_clicked=lambda *_: ( - exec_shell_command_async( - f"python {get_relative_path('../../../config/config.py')}" - ), - self.close_launcher(), - ), - ), - self.search_entry, - ], - ) - - self.launcher_box = Box( - name="launcher-box", - spacing=10, - h_expand=True, - orientation="v", - children=[ - self.header_box, - self.scrolled_window, - ], - ) - - self.resize_viewport() - - self.add(self.launcher_box) - self.show_all() - - def close_launcher(self): - self.viewport.children = [] - self.selected_index = -1 # Reset selection - self.launcher.close() - - def open_launcher(self): - self._all_apps = get_desktop_applications() - self.arrange_viewport() - - def ensure_initialized(self): - """Make sure the launcher is initialized with apps list before opening""" - if not hasattr(self, "_initialized"): - # Force pre-loading apps - self._all_apps = get_desktop_applications() - self._initialized = True - return True # Was initialized for first time - return False # Was already initialized - - def arrange_viewport(self, query: str = ""): - if query.startswith("="): - # In calculator mode, update history view once (not per keystroke) - self.show_scrolled_window() - self.launcher.dashboard.hide() - self.update_calculator_viewport() - return - remove_handler(self._arranger_handler) if self._arranger_handler else None - self.viewport.children = [] - self.selected_index = -1 # Clear selection when viewport changes - - if query.startswith("/"): - self.launcher.dashboard.hide() - return - - if query.strip(): - self.launcher.dashboard.hide() - self.show_scrolled_window() - else: - self.launcher.dashboard.show() - self.hide_scrolled_window() - return False - - filtered_apps_iter = iter( - sorted( - [ - app - for app in self._all_apps - if query.casefold() - in ( - (app.display_name or "") - + (" " + app.name + " ") - + (app.generic_name or "") - ).casefold() - ], - key=lambda app: (app.display_name or "").casefold(), - ) - ) - should_resize = operator.length_hint(filtered_apps_iter) == len(self._all_apps) - - self._arranger_handler = idle_add( - lambda apps_iter: self.add_next_application(apps_iter) - or self.handle_arrange_complete(should_resize, query), - filtered_apps_iter, - pin=True, - ) - - def handle_arrange_complete(self, should_resize, query): - if should_resize: - self.resize_viewport() - # Only auto-select first item if query exists - if query.strip() != "" and self.viewport.get_children(): - self.update_selection(0) - return False - - def add_next_application(self, apps_iter: Iterator[DesktopApp]): - if not (app := next(apps_iter, None)): - return False - self.viewport.add(self.bake_application_slot(app)) - return True - - def show_scrolled_window(self): - if hasattr(self, "scrolled_window") and self.scrolled_window: - self.scrolled_window.show_all() - - def hide_scrolled_window(self): - if hasattr(self, "scrolled_window") and self.scrolled_window: - self.scrolled_window.hide() - - def resize_viewport(self): - self.scrolled_window.set_min_content_width( - self.viewport.get_allocation().width # type: ignore - ) - return False - - def bake_application_slot(self, app: DesktopApp, **kwargs) -> Button: - button = Button( - name="slot-button", - child=Box( - name="slot-box", - orientation="h", - spacing=10, - children=[ - Image( - name="app-icon", - pixbuf=app.get_icon_pixbuf(size=24), - h_align="start", - ), - Label( - name="app-label", - label=app.display_name or "Unknown", - ellipsization="end", - v_align="center", - h_align="center", - ), - Label( - name="app-desc", - label=app.description or "", - ellipsization="end", - v_align="center", - h_align="start", - h_expand=True, - ), - ], - ), - tooltip_text=app.description, - on_clicked=lambda *_: (app.launch(), self.close_launcher()), - **kwargs, - ) - return button - - def update_selection(self, new_index: int): - # Unselect current - if self.selected_index != -1 and self.selected_index < len( - self.viewport.get_children() - ): - current_button = self.viewport.get_children()[self.selected_index] - current_button.get_style_context().remove_class("selected") - # Select new - if new_index != -1 and new_index < len(self.viewport.get_children()): - new_button = self.viewport.get_children()[new_index] - new_button.get_style_context().add_class("selected") - self.selected_index = new_index - self.scroll_to_selected(new_button) - else: - self.selected_index = -1 - - def scroll_to_selected(self, button): - def scroll(): - adj = self.scrolled_window.get_vadjustment() - alloc = button.get_allocation() - if alloc.height == 0: - return False # Retry if allocation isn't ready - - y = alloc.y - height = alloc.height - page_size = adj.get_page_size() - current_value = adj.get_value() - - # Calculate visible boundaries - visible_top = current_value - visible_bottom = current_value + page_size - - if y < visible_top: - # Item above viewport - align to top - adj.set_value(y) - elif y + height > visible_bottom: - # Item below viewport - align to bottom - new_value = y + height - page_size - adj.set_value(new_value) - # No action if already fully visible - return False - - GLib.idle_add(scroll) - - def on_search_entry_activate(self, text): - # This method is now only used for non-Enter key activations - commands = self.config.get("commands", {}) - - # Check if text is a mathematical expression - if text and (text[0].isdigit()): - self.evaluate_calculator_expression(text) - return - - if text in commands: - command = commands[text] - eval(command, {"launcher": self.launcher}) - else: - children = self.viewport.get_children() - if children: - # Only activate if we have selection or non-empty query - if text.strip() == "" and self.selected_index == -1: - return # Prevent accidental activation when empty - selected_index = self.selected_index if self.selected_index != -1 else 0 - if 0 <= selected_index < len(children): - children[selected_index].clicked() - - def on_search_entry_key_press(self, widget, event): - text = widget.get_text() - - # Check if we're in calculator mode - if text.startswith("="): - if event.keyval == Gdk.KEY_Down: - self.move_selection(1) - return True - elif event.keyval == Gdk.KEY_Up: - self.move_selection(-1) - return True - elif event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter): - # In calculator mode, if a history item is highlighted: - if self.selected_index != -1 and self.selected_index < len( - self.calc_history - ): - if event.state & Gdk.ModifierType.SHIFT_MASK: - # Shift+Enter deletes the selected calculator history item - self.delete_selected_calc_history() - else: - # Normal Enter copies the result - selected_text = self.calc_history[self.selected_index] - self.copy_text_to_clipboard(selected_text) - # Clear selection so new expressions are evaluated on further Return presses - self.selected_index = -1 - else: - # Force reset selection index - self.selected_index = -1 - # No item selected, evaluate the expression - self.evaluate_calculator_expression(text) - return True - elif event.keyval == Gdk.KEY_Escape: - self.close_launcher() - return True - return False - else: - # Normal app mode behavior - if event.keyval == Gdk.KEY_Down: - self.move_selection(1) - return True - elif event.keyval == Gdk.KEY_Up: - self.move_selection(-1) - return True - elif event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter) and ( - event.state & Gdk.ModifierType.SHIFT_MASK - ): - # Only add to dock when in app mode (not calculator mode) - self.add_selected_app_to_dock() - return True - elif event.keyval == Gdk.KEY_Escape: - self.close_launcher() - return True - return False - - def notify_text(self, entry, *_): - """Handle text changes in the search entry""" - text = entry.get_text() - if text.startswith("="): - # In calculator mode, show scrolled window and update viewport - self.show_scrolled_window() - self.launcher.dashboard.hide() - self.update_calculator_viewport() - # Always reset selection when typing a new expression - self.selected_index = -1 - else: - self.arrange_viewport(text) - - def add_selected_app_to_dock(self): - """Adds the currently selected application to the modus.json file with comprehensive metadata.""" - children = self.viewport.get_children() - if ( - not children - or self.selected_index == -1 - or self.selected_index >= len(children) - ): - return # No app selected - - selected_button = children[self.selected_index] - # Extract the app's display name from the label to find the DesktopApp object - selected_app = next( - ( - app - for app in self._all_apps - if app.display_name - == selected_button.get_child().get_children()[1].props.label - ), - None, - ) - if not selected_app: - return - - # Create comprehensive app data dictionary - Include all available properties - # Filter out None values to keep the JSON clean - app_data = { - k: v - for k, v in { - "name": selected_app.name, - "display_name": selected_app.display_name, - "window_class": selected_app.window_class, - "executable": selected_app.executable, - "command_line": selected_app.command_line, - "icon_name": selected_app.icon_name, - }.items() - if v is not None - } - - config_path = get_relative_path("../../../config/assets/modus.json") - try: - with open(config_path, "r") as file: - data = json.load(file) - except (FileNotFoundError, json.JSONDecodeError): - data = {"pinned_apps": []} - - # Check if app is already pinned (by name) - already_pinned = False - for pinned_app in data.get("pinned_apps", []): - if ( - isinstance(pinned_app, dict) - and pinned_app.get("name") == app_data["name"] - ): - already_pinned = True - # Update existing entry with latest app data - pinned_app.update(app_data) - break - elif isinstance(pinned_app, str) and pinned_app == app_data["name"]: - already_pinned = True - # Replace string format with new comprehensive format - data["pinned_apps"].remove(pinned_app) - data["pinned_apps"].append(app_data) - break - - # Add to pinned apps if not already there - if not already_pinned: - data.setdefault("pinned_apps", []).append(app_data) - - # Write the updated config - with open(config_path, "w") as file: - json.dump(data, file, indent=4) - - # Notify dock instances of the configuration change immediately - Dock.notify_config_change() - - def move_selection(self, delta: int): - children = self.viewport.get_children() - if not children: - return - # Allow starting selection from nothing when empty - if self.selected_index == -1 and delta == 1: - new_index = 0 - else: - new_index = self.selected_index + delta - new_index = max(0, min(new_index, len(children) - 1)) - self.update_selection(new_index) - - def save_calc_history(self): - with open(self.calc_history_path, "w") as f: - json.dump(self.calc_history, f) - - def evaluate_calculator_expression(self, text: str): - # Add debug print - print(f"Evaluating calculator expression: {text}") - - # Remove the '=' prefix and extra spaces - expr = text.lstrip("=").strip() - if not expr: - return - - # Common replacements to make expressions more natural - replacements = { - "^": "**", # Power operator - "×": "*", # Multiplication - "÷": "/", # Division - "π": "np.pi", # Pi constant - "pi": "np.pi", # Pi constant (text) - "e": "np.e", # Euler's number - "sin(": "np.sin(", # Sine - "cos(": "np.cos(", # Cosine - "tan(": "np.tan(", # Tangent - "log(": "np.log10(", # Log base 10 - "ln(": "np.log(", # Natural log - "sqrt(": "np.sqrt(", # Square root - "abs(": "np.abs(", # Absolute value - "exp(": "np.exp(", # Exponential - } - - # Apply all replacements - for old, new in replacements.items(): - expr = expr.replace(old, new) - - # Replace factorial: e.g. 5! -> np.factorial(5) - expr = re.sub(r"(\d+)!", r"np.factorial(\1)", expr) - - # Replace brackets: allow [] and {} as () - for old, new in [("[", "("), ("]", ")"), ("{", "("), ("}", ")")]: - expr = expr.replace(old, new) - - # Define the safe execution environment - safe_dict = { - "np": np, - "math": math, - "arange": np.arange, - "linspace": np.linspace, - "array": np.array, - } - - try: - # Evaluate the expression in the safe environment - result = eval(expr, {"__builtins__": None}, safe_dict) - - # Format the result based on its type - if isinstance(result, np.ndarray): - if result.size > 10: # Truncate large arrays - result_str = f"Array of shape {result.shape}" - else: - result_str = str(result) - elif isinstance(result, (int, float, np.number)): - # Format numbers nicely - integers as integers, floats with limited precision - if isinstance(result, (int, np.integer)) or result.is_integer(): - result_str = str(int(result)) - else: - result_str = f"{float(result):.10g}" # Remove trailing zeros - else: - result_str = str(result) - - except Exception as e: - result_str = f"Error: {str(e)}" - - # Prepend to history (newest first) - self.calc_history.insert(0, f"{text} => {result_str}") - self.save_calc_history() - self.update_calculator_viewport() - - def update_calculator_viewport(self): - self.viewport.children = [] - for item in self.calc_history: - btn = self.create_calc_history_button(item) - self.viewport.add(btn) - # Don't reset selection index here automatically - # Ensure selection state stays valid - if self.selected_index >= len(self.calc_history): - self.selected_index = -1 - - def create_calc_history_button(self, text: str) -> Button: - # Parse the result to create a more readable display - if "=>" in text: - parts = text.split("=>") - expression = parts[0].strip() - result = parts[1].strip() - - # For very long results, truncate for display but keep full in tooltip - display_text = text - if len(result) > 50: # Truncate long results - display_text = f"{expression} => {result[:47]}..." - - btn = Button( - name="slot-button", # reuse existing CSS styling - child=Box( - name="calc-slot-box", - orientation="h", - spacing=10, - children=[ - Label( - name="calc-label", - label=display_text, - ellipsization="end", - v_align="center", - h_align="center", - ), - ], - ), - tooltip_text=text, - on_clicked=lambda *_: self.copy_text_to_clipboard(text), - ) - else: - # Fallback for non-calculation entries - btn = Button( - name="slot-button", - child=Box( - name="calc-slot-box", - orientation="h", - spacing=10, - children=[ - Label( - name="calc-label", - label=text, - ellipsization="end", - v_align="center", - h_align="center", - ), - ], - ), - tooltip_text=text, - on_clicked=lambda *_: self.copy_text_to_clipboard(text), - ) - return btn - - def copy_text_to_clipboard(self, text: str): - # Split the text on "=>" and copy only the result part if available - parts = text.split("=>", 1) - copy_text = parts[1].strip() if len(parts) > 1 else text - try: - subprocess.run(["wl-copy"], input=copy_text.encode(), check=True) - except subprocess.CalledProcessError as e: - print(f"Clipboard copy failed: {e}") - - def delete_selected_calc_history(self): - if self.selected_index != -1 and self.selected_index < len(self.calc_history): - # Store the current index before deletion - current_index = self.selected_index - - # Delete the item - del self.calc_history[current_index] - self.save_calc_history() - - # Determine the new selection index - # If we deleted the first item, stay at index 0 - # Otherwise, move to the previous item - new_index = 0 if current_index == 0 else current_index - 1 - - # Reset selection before updating viewport - self.selected_index = -1 - - # Update the viewport - self.update_calculator_viewport() - - # If we still have items, select the determined index - if len(self.calc_history) > 0: - self.update_selection(min(new_index, len(self.calc_history) - 1)) diff --git a/modules/launcher/components/bluetooth.py b/modules/launcher/components/bluetooth.py deleted file mode 100644 index c3f6930..0000000 --- a/modules/launcher/components/bluetooth.py +++ /dev/null @@ -1,170 +0,0 @@ -from fabric.widgets.box import Box -from fabric.widgets.label import Label -from fabric.widgets.image import Image -from fabric.widgets.button import Button -from fabric.widgets.centerbox import CenterBox -from fabric.widgets.scrolledwindow import ScrolledWindow -from fabric.bluetooth import BluetoothClient, BluetoothDevice -import utils.icons as icons - - -class BluetoothDeviceSlot(CenterBox): - def __init__(self, device: BluetoothDevice, **kwargs): - super().__init__(name="bluetooth-device", **kwargs) - self.device = device - self.device.connect("changed", self.on_changed) - self.device.connect( - "notify::closed", lambda *_: self.device.closed and self.destroy() - ) - - self.connection_label = Label( - name="bluetooth-connection", markup=icons.bluetooth_disconnected - ) - self.connect_button = Button( - name="bluetooth-connect", - label="Connect", - on_clicked=lambda *_: self.device.set_connecting(not self.device.connected), - ) - - self.start_children = [ - Box( - spacing=8, - children=[ - Image(icon_name=device.icon_name + "-symbolic", size=32), - Label(label=device.name), - self.connection_label, - ], - ) - ] - self.end_children = self.connect_button - - self.device.emit("changed") # to update display status - - def on_changed(self, *_): - self.connection_label.set_markup( - icons.bluetooth_connected - if self.device.connected - else icons.bluetooth_disconnected - ) - if self.device.connecting: - self.connect_button.set_label( - "Connecting..." if not self.device.connecting else "Disconnecting..." - ) - else: - self.connect_button.set_label( - "Connect" if not self.device.connected else "Disconnect" - ) - return - - -class BluetoothConnections(Box): - def __init__(self, **kwargs): - super().__init__( - name="bluetooth", - spacing=4, - orientation="vertical", - **kwargs, - ) - - self.launcher = kwargs["launcher"] - - self.buttons = self.launcher.dashboard.bluetooth_button - self.bt_status_text = self.buttons.bluetooth_status_text - self.bt_status_button = self.buttons.bluetooth_status_button - self.bt_icon = self.buttons.bluetooth_icon - self.bt_label = self.buttons.bluetooth_label - self.bt_menu_button = self.buttons.bluetooth_menu_button - self.bt_menu_label = self.buttons.bluetooth_menu_label - - self.client = BluetoothClient(on_device_added=self.on_device_added) - self.scan_label = Label(name="bluetooth-scan-label", markup=icons.radar) - self.scan_button = Button( - name="bluetooth-scan", - child=self.scan_label, - tooltip_text="Scan for Bluetooth devices", - on_clicked=lambda *_: self.client.toggle_scan(), - ) - self.back_button = Button( - name="bluetooth-back", - child=Label(name="bluetooth-back-label", markup=icons.chevron_left), - on_clicked=lambda *_: self.launcher.open("launcher"), - ) - - self.client.connect("notify::enabled", lambda *_: self.status_label()) - self.client.connect("notify::scanning", lambda *_: self.update_scan_label()) - - self.paired_box = Box(spacing=2, orientation="vertical") - self.available_box = Box(spacing=2, orientation="vertical") - - # Create a single content container with the required structure: - # [Paired label] [Paired devices list] [Available label] [Available devices list] - content_box = Box(spacing=4, orientation="vertical") - content_box.add(self.paired_box) - content_box.add(Label(name="bluetooth-section", label="Available")) - content_box.add(self.available_box) - - self.children = [ - CenterBox( - name="bluetooth-header", - start_children=self.back_button, - center_children=Label(name="bluetooth-text", label="Bluetooth Devices"), - end_children=self.scan_button, - ), - ScrolledWindow( - name="bluetooth-devices", - min_content_size=(-1, -1), - child=content_box, - v_expand=True, - ), - ] - - # Trigger initial notifications to update status without delay. - self.client.notify("scanning") - self.client.notify("enabled") - - def status_label(self): - print(self.client.enabled) - if self.client.enabled: - self.bt_status_text.set_label("Enabled") - for i in [ - self.bt_status_button, - self.bt_status_text, - self.bt_icon, - self.bt_label, - self.bt_menu_button, - self.bt_menu_label, - ]: - i.remove_style_class("disabled") - self.bt_icon.set_markup(icons.bluetooth) - else: - self.bt_status_text.set_label("Disabled") - for i in [ - self.bt_status_button, - self.bt_status_text, - self.bt_icon, - self.bt_label, - self.bt_menu_button, - self.bt_menu_label, - ]: - i.add_style_class("disabled") - self.bt_icon.set_markup(icons.bluetooth_off) - - def on_device_added(self, client: BluetoothClient, address: str): - if not (device := client.get_device(address)): - return - slot = BluetoothDeviceSlot(device) - - if device.paired: - return self.paired_box.add(slot) - return self.available_box.add(slot) - - def update_scan_label(self): - if self.client.scanning: - self.scan_label.add_style_class("scanning") - self.scan_button.add_style_class("scanning") - self.scan_button.set_tooltip_text("Stop scanning for Bluetooth devices") - else: - self.scan_label.remove_style_class("scanning") - self.scan_button.remove_style_class("scanning") - self.scan_button.set_tooltip_text("Scan for Bluetooth devices") - diff --git a/modules/launcher/components/calendar.py b/modules/launcher/components/calendar.py deleted file mode 100644 index 263e572..0000000 --- a/modules/launcher/components/calendar.py +++ /dev/null @@ -1,217 +0,0 @@ -import calendar -from datetime import datetime -import gi -import utils.icons as icons -from fabric.widgets.label import Label -from fabric.widgets.box import Box -from fabric.widgets.centerbox import CenterBox - -gi.require_version("Gtk", "3.0") -from gi.repository import Gtk, GLib - - -class Calendar(Gtk.Box): - def __init__(self): - super().__init__( - orientation=Gtk.Orientation.VERTICAL, spacing=8, name="calendar" - ) - - self.current_year = datetime.now().year - self.current_month = datetime.now().month - self.current_day = datetime.now().day - # Store previous month/year to determine transition direction. - self.previous_key = (self.current_year, self.current_month) - - # Cache threshold in number of months away from the current view. - self.cache_threshold = 1 # Allow current month +/- 1 month - - # Dictionary to store built views for each month. - self.month_views = {} - - self.prev_month_button = Gtk.Button( - name="prev-month-button", - child=Label(name="month-button-label", markup=icons.chevron_left), - ) - self.prev_month_button.connect("clicked", self.on_prev_month_clicked) - - self.month_label = Gtk.Label(name="month-label") - - self.next_month_button = Gtk.Button( - name="next-month-button", - child=Label(name="month-button-label", markup=icons.chevron_right), - ) - self.next_month_button.connect("clicked", self.on_next_month_clicked) - - self.header = CenterBox( - spacing=4, - name="header", - start_children=[self.prev_month_button], - center_children=[ - self.month_label, - ], - end_children=[self.next_month_button], - ) - - self.add(self.header) - - self.weekday_row = Gtk.Box(spacing=4, name="weekday-row") - self.pack_start(self.weekday_row, False, False, 0) - - # Create a stack to hold month days views. - self.stack = Gtk.Stack(name="calendar-stack") - self.stack.set_transition_duration(250) - self.pack_start(self.stack, True, True, 0) - - self.update_header() - self.update_calendar() - GLib.timeout_add_seconds(60, self.check_date_change) - - def update_header(self): - # Update header month label and weekday row. - self.month_label.set_text( - datetime(self.current_year, self.current_month, 1) - .strftime("%B %Y") - .capitalize() - ) - # Clear existing children from weekday_row. - for child in self.weekday_row.get_children(): - self.weekday_row.remove(child) - days = self.get_weekday_initials() - for day in days: - label = Gtk.Label(label=day.upper(), name="weekday-label") - self.weekday_row.pack_start(label, True, True, 0) - self.weekday_row.show_all() - - def update_calendar(self): - new_key = (self.current_year, self.current_month) - # Set the transition type based on whether we're moving to a later or earlier month. - if new_key > self.previous_key: - self.stack.set_transition_type(Gtk.StackTransitionType.SLIDE_LEFT) - elif new_key < self.previous_key: - self.stack.set_transition_type(Gtk.StackTransitionType.SLIDE_RIGHT) - # Update previous key for next comparison. - self.previous_key = new_key - - # Create the month view if it doesn't exist. - if new_key not in self.month_views: - month_view = self.create_month_view(self.current_year, self.current_month) - self.month_views[new_key] = month_view - self.stack.add_titled( - month_view, - f"{self.current_year}_{self.current_month}", - f"{self.current_year}_{self.current_month}", - ) - # Switch the visible child in the stack. - self.stack.set_visible_child_name(f"{self.current_year}_{self.current_month}") - self.update_header() - self.stack.show_all() - - # Purge any cached month views that are too far away. - self.prune_cache() - - def prune_cache(self): - # Compute a numerical value for a (year, month) key. - def month_index(key): - year, month = key - return year * 12 + (month - 1) - - current_index = month_index((self.current_year, self.current_month)) - keys_to_remove = [] - for key in self.month_views: - if abs(month_index(key) - current_index) > self.cache_threshold: - keys_to_remove.append(key) - for key in keys_to_remove: - widget = self.month_views.pop(key) - self.stack.remove(widget) - - def create_month_view(self, year, month): - grid = Gtk.Grid( - column_homogeneous=True, row_homogeneous=False, name="calendar-grid" - ) - - cal = calendar.Calendar(firstweekday=6) - month_days = cal.monthdayscalendar(year, month) - - prev_month = month - 1 if month > 1 else 12 - next_month = month + 1 if month < 12 else 1 - prev_year = year if month > 1 else year - 1 - next_year = year if month < 12 else year + 1 - - prev_month_days_count = calendar.monthrange(prev_year, prev_month)[1] - next_month_day = 1 - - for row, week in enumerate(month_days): - for col, day in enumerate(week): - day_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, name="day-box") - top_spacer = Gtk.Box(hexpand=True, vexpand=True) - middle_box = Gtk.Box(hexpand=True, vexpand=True) - bottom_spacer = Gtk.Box(hexpand=True, vexpand=True) - - if day == 0: - if row == 0: - day_num = prev_month_days_count - week[:col].count(0) - label = Gtk.Label(label=str(day_num), name="prev-month-day") - else: - label = Gtk.Label( - label=str(next_month_day), name="next-month-day" - ) - next_month_day += 1 - else: - label = Gtk.Label(label=str(day), name="day-label") - if ( - day == self.current_day - and month == datetime.now().month - and year == datetime.now().year - ): - label.get_style_context().add_class("current-day") - - middle_box.pack_start( - Gtk.Box(hexpand=True, vexpand=True), True, True, 0 - ) - middle_box.pack_start(label, False, False, 0) - middle_box.pack_start( - Gtk.Box(hexpand=True, vexpand=True), True, True, 0 - ) - - day_box.pack_start(top_spacer, True, True, 0) - day_box.pack_start(middle_box, True, True, 0) - day_box.pack_start(bottom_spacer, True, True, 0) - - grid.attach(day_box, col, row, 1, 1) - - grid.show_all() - return grid - - def get_weekday_initials(self): - # Returns localized weekday initials. - return [datetime(2023, 1, i + 1).strftime("%a")[:1] for i in range(7)] - - def on_prev_month_clicked(self, widget): - if self.current_month == 1: - self.current_month = 12 - self.current_year -= 1 - else: - self.current_month -= 1 - self.update_calendar() - - def on_next_month_clicked(self, widget): - if self.current_month == 12: - self.current_month = 1 - self.current_year += 1 - else: - self.current_month += 1 - self.update_calendar() - - def check_date_change(self): - now = datetime.now() - if ( - now.day != self.current_day - or now.month != self.current_month - or now.year != self.current_year - ): - self.current_day = now.day - self.current_month = now.month - self.current_year = now.year - self.update_calendar() - return True - diff --git a/modules/launcher/components/cliphist.py b/modules/launcher/components/cliphist.py deleted file mode 100644 index 5747747..0000000 --- a/modules/launcher/components/cliphist.py +++ /dev/null @@ -1,504 +0,0 @@ -from fabric.widgets.box import Box -from fabric.widgets.label import Label -from fabric.widgets.button import Button -from fabric.widgets.entry import Entry -from fabric.widgets.image import Image -from fabric.widgets.scrolledwindow import ScrolledWindow -from fabric.utils import idle_add, remove_handler -from fabric.utils.helpers import get_relative_path -from gi.repository import GLib, Gdk, GdkPixbuf -import subprocess -import os -import re -import sys -import tempfile - -import utils.icons as icons - - -class ClipHistory(Box): - def __init__(self, **kwargs): - super().__init__( - name="clip-history", - visible=False, - all_visible=False, - **kwargs, - ) - - # Create a temporary directory for image icons - self.tmp_dir = tempfile.mkdtemp(prefix="cliphist-") - self.image_cache = {} # Cache for image previews - - self.launcher = kwargs["launcher"] - self.selected_index = -1 # Track the selected item index - self._arranger_handler = 0 - self.clipboard_items = [] - self._loading = False - self._pending_updates = False - - self.viewport = Box(name="viewport", spacing=4, orientation="v") - self.search_entry = Entry( - name="search-entry", - placeholder="Search Clipboard History...", - h_expand=True, - notify_text=self.filter_items, - on_activate=lambda entry, *_: self.use_selected_item(), - on_key_press_event=self.on_search_entry_key_press, - ) - self.search_entry.props.xalign = 0.5 - - self.scrolled_window = ScrolledWindow( - name="scrolled-window", - spacing=10, - min_content_size=(450, 105), - max_content_size=(450, 105), - child=self.viewport, - ) - - self.header_box = Box( - name="header_box", - spacing=10, - orientation="h", - children=[ - Button( - name="clear-button", - child=Label(name="clear-label", markup=icons.trash), - on_clicked=lambda *_: self.clear_history(), - ), - self.search_entry, - ], - ) - - self.history_box = Box( - name="launcher-box", - spacing=10, - h_expand=True, - orientation="v", - children=[ - self.header_box, - self.scrolled_window, - ], - ) - - self.add(self.history_box) - self.show_all() - - def close(self): - """Close the clipboard history panel""" - self.viewport.children = [] - self.selected_index = -1 # Reset selection - self.launcher.close() - - def open(self): - """Open the clipboard history panel and load items""" - self.viewport.set_visible(True) - if self._loading: - return - self._loading = True - self.search_entry.set_text("") # Clear search - self.search_entry.grab_focus() - # Start loading in background using GLib.idle_add - GLib.idle_add(self._load_clipboard_items_thread) - - def _load_clipboard_items_thread(self): - """Worker for loading clipboard items, now runs in main loop via idle_add""" - try: - result = subprocess.run( - ["cliphist", "list"], capture_output=True, text=True, check=True - ) - lines = result.stdout.strip().split("\n") - new_items = [] - for line in lines: - if not line or " 1 else "0" - content = parts[1] if len(parts) > 1 else item - - # Truncate content for display - display_text = content.strip() - if len(display_text) > 100: - display_text = display_text[:97] + "..." - - # Check if this is an image by examining the content - is_image = self.is_image_data(content) - - if is_image: - # For images, create item with image preview - button = Button( - name="slot-button", - child=Box( - name="slot-box", - orientation="h", - spacing=10, - children=[ - Image(name="clip-icon", h_align="start"), # Placeholder - Label( - name="clip-label", - label="[Image]", - ellipsization="end", - v_align="center", - h_align="start", - h_expand=True, - ), - ], - ), - tooltip_text="Image in clipboard", - on_clicked=lambda *_, id=item_id: self.paste_item(id), - ) - # Load image preview in background - self._load_image_preview_async(item_id, button) - else: - # For text, create regular item - button = self.create_text_item_button(item_id, display_text) - - # Add key press event handler for Enter key - button.connect( - "key-press-event", - lambda widget, event, id=item_id: self.on_item_key_press(widget, event, id), - ) - - # Make sure button can receive focus and key events - button.set_can_focus(True) - button.add_events(Gdk.EventMask.KEY_PRESS_MASK) - - return button - - def _load_image_preview_async(self, item_id, button): - """Load image preview using GLib.idle_add instead of threads""" - - def load_image(): - try: - if item_id in self.image_cache: - pixbuf = self.image_cache[item_id] - else: - result = subprocess.run( - ["cliphist", "decode", item_id], capture_output=True, check=True - ) - loader = GdkPixbuf.PixbufLoader() - loader.write(result.stdout) - loader.close() - pixbuf = loader.get_pixbuf() - width, height = pixbuf.get_width(), pixbuf.get_height() - max_size = 72 - if width > height: - new_width = max_size - new_height = int(height * (max_size / width)) - else: - new_height = max_size - new_width = int(width * (max_size / height)) - pixbuf = pixbuf.scale_simple( - new_width, new_height, GdkPixbuf.InterpType.BILINEAR - ) - self.image_cache[item_id] = pixbuf - self._update_image_button(button, pixbuf) - except Exception as e: - print(f"Error loading image preview: {e}", file=sys.stderr) - return False - - GLib.idle_add(load_image) - - def _update_image_button(self, button, pixbuf): - """Update the button with the loaded image preview""" - box = button.get_child() - if box and len(box.get_children()) > 0: - image_widget = box.get_children()[0] - if isinstance(image_widget, Image): - image_widget.set_from_pixbuf(pixbuf) - - def create_text_item_button(self, item_id, display_text): - """Create a button for a text clipboard item""" - return Button( - name="slot-button", - child=Box( - name="slot-box", - orientation="h", - spacing=10, - children=[ - Label( - name="clip-icon", - markup=icons.clip_text, - h_align="start", - ), - Label( - name="clip-label", - label=display_text, - ellipsization="end", - v_align="center", - h_align="start", - h_expand=True, - ), - ], - ), - tooltip_text=display_text, - on_clicked=lambda *_: self.paste_item(item_id), - ) - - def is_image_data(self, content): - """Determine if clipboard content is likely an image""" - # Check for common image data patterns - return ( - content.startswith("data:image/") - or content.startswith("\x89PNG") - or content.startswith("GIF8") - or content.startswith("\xff\xd8\xff") # JPEG - or re.match(r"^\s* visible_bottom: - # Item below viewport - align to bottom - new_value = y + height - page_size - adj.set_value(new_value) - return False - - GLib.idle_add(scroll) - - def use_selected_item(self): - """Use (paste) the selected clipboard item""" - children = self.viewport.get_children() - if ( - not children - or self.selected_index == -1 - or self.selected_index >= len(self.clipboard_items) - ): - return - - # Get the item ID from the first part before the tab - item_line = self.clipboard_items[self.selected_index] - item_id = item_line.split("\t", 1)[0] - self.paste_item(item_id) - - def delete_selected_item(self): - """Delete the selected clipboard item""" - children = self.viewport.get_children() - if not children or self.selected_index == -1: - return - - # Get the item ID from the first part before the tab - item_line = self.clipboard_items[self.selected_index] - item_id = item_line.split("\t", 1)[0] - self.delete_item(item_id) - - def on_item_key_press(self, widget, event, item_id): - """Handle key press events on clipboard items""" - if event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter): - # Copy item to clipboard and close - self.paste_item(item_id) - return True - return False - - def __del__(self): - """Clean up temporary files on destruction""" - try: - if hasattr(self, "tmp_dir") and os.path.exists(self.tmp_dir): - import shutil - - shutil.rmtree(self.tmp_dir) - self.image_cache.clear() - except Exception as e: - print(f"Error cleaning up temporary files: {e}", file=sys.stderr) diff --git a/modules/launcher/components/dashboard.py b/modules/launcher/components/dashboard.py deleted file mode 100644 index 49b8ad8..0000000 --- a/modules/launcher/components/dashboard.py +++ /dev/null @@ -1,605 +0,0 @@ -import subprocess -from fabric.widgets.box import Box -from fabric.widgets.label import Label -from fabric.widgets.button import Button -from fabric.utils.helpers import exec_shell_command_async -import gi -from gi.repository import Gtk, Gdk, GLib -from services import network_client -from services.powerprofile import PowerProfiles -from fabric import Fabricator - -gi.require_version("Gtk", "3.0") -import utils.icons as icons - - -def add_hover_cursor(widget): - # Add enter/leave events to change the cursor - widget.add_events(Gdk.EventMask.ENTER_NOTIFY_MASK | Gdk.EventMask.LEAVE_NOTIFY_MASK) - widget.connect( - "enter-notify-event", - lambda w, e: w.get_window().set_cursor( - Gdk.Cursor.new_from_name(w.get_display(), "pointer") - ) - if w.get_window() - else None, - ) - widget.connect( - "leave-notify-event", - lambda w, e: w.get_window().set_cursor(None) if w.get_window() else None, - ) - - -class NetworkButton(Box): - def __init__(self): - self.network_client = network_client - self._animation_timeout_id = None - self._animation_step = 0 - self._animation_direction = 1 - - self.network_icon = Label( - name="network-icon", - markup=None, - ) - self.network_label = Label( - name="network-label", - label="Wi-Fi", - justification="left", - ) - self.network_label_box = Box(children=[self.network_label, Box(h_expand=True)]) - self.network_ssid = Label( - name="network-ssid", - justification="left", - ) - self.network_ssid_box = Box(children=[self.network_ssid, Box(h_expand=True)]) - self.network_text = Box( - name="network-text", - orientation="v", - h_align="start", - v_align="center", - children=[self.network_label_box, self.network_ssid_box], - ) - self.network_status_box = Box( - h_align="start", - v_align="center", - spacing=10, - children=[self.network_icon, self.network_text], - ) - self.network_status_button = Button( - name="network-status-button", - h_expand=True, - child=self.network_status_box, - on_clicked=lambda *_: self.network_client.wifi_device.toggle_wifi() - if self.network_client.wifi_device - else None, - ) - add_hover_cursor(self.network_status_button) # <-- Added hover - - self.network_menu_label = Label( - name="network-menu-label", - markup=icons.chevron_right, - ) - self.network_menu_button = Button( - name="network-menu-button", - child=self.network_menu_label, - ) - add_hover_cursor(self.network_menu_button) # <-- Added hover - - super().__init__( - name="network-button", - orientation="h", - h_align="fill", - v_align="fill", - h_expand=True, - v_expand=True, - spacing=0, - children=[self.network_status_button, self.network_menu_button], - ) - - self.widgets = [ - self, - self.network_icon, - self.network_label, - self.network_ssid, - self.network_status_button, - self.network_menu_button, - self.network_menu_label, - ] - - # Connect to wifi device signals when ready - self.network_client.connect("device-ready", self._on_wifi_ready) - - # Check initial state using idle_add to defer until GTK loop is running - GLib.idle_add(self._initial_update) - - def _initial_update(self): - self.update_state() - return False # Run only once - - def _on_wifi_ready(self, *args): - if self.network_client.wifi_device: - self.network_client.wifi_device.connect( - "notify::enabled", self.update_state - ) - self.network_client.wifi_device.connect("notify::ssid", self.update_state) - self.update_state() - - def _animate_searching(self): - """Animate wifi icon when searching for networks""" - wifi_icons = [ - icons.wifi_0, - icons.wifi_1, - icons.wifi_2, - icons.wifi_3, - icons.wifi_2, - icons.wifi_1, - ] - - # Si el widget no existe o el WiFi está desactivado, detener la animación - wifi = self.network_client.wifi_device - if not self.network_icon or not wifi or not wifi.enabled: - self._stop_animation() - return False - - # Si estamos conectados, detener la animación - if wifi.state == "activated" and wifi.ssid != "Disconnected": - self._stop_animation() - return False - - GLib.idle_add(self.network_icon.set_markup, wifi_icons[self._animation_step]) - - # Reiniciar al principio cuando llegamos al final - self._animation_step = (self._animation_step + 1) % len(wifi_icons) - - return True # Mantener la animación activa - - def _start_animation(self): - if self._animation_timeout_id is None: - self._animation_step = 0 - self._animation_direction = 1 - # Ejecuta la animación cada 500ms sin usar idle_add - self._animation_timeout_id = GLib.timeout_add(500, self._animate_searching) - - def _stop_animation(self): - if self._animation_timeout_id is not None: - GLib.source_remove(self._animation_timeout_id) - self._animation_timeout_id = None - - def update_state(self, *args): - """Update the button state based on network status""" - wifi = self.network_client.wifi_device - ethernet = self.network_client.ethernet_device - - # Update enabled/disabled state - if wifi and not wifi.enabled: - self._stop_animation() - self.network_icon.set_markup(icons.wifi_off) - for widget in self.widgets: - widget.add_style_class("disabled") - self.network_ssid.set_label("Disabled") - return - - # Remove disabled class if we got here - for widget in self.widgets: - widget.remove_style_class("disabled") - - # Update text and animation based on state - if wifi and wifi.enabled: - if wifi.state == "activated" and wifi.ssid != "Disconnected": - self._stop_animation() - self.network_ssid.set_label(wifi.ssid) - # Update icon based on signal strength - if wifi.strength > 0: - strength = wifi.strength - if strength < 25: - self.network_icon.set_markup(icons.wifi_0) - elif strength < 50: - self.network_icon.set_markup(icons.wifi_1) - elif strength < 75: - self.network_icon.set_markup(icons.wifi_2) - else: - self.network_icon.set_markup(icons.wifi_3) - else: - self.network_ssid.set_label("Enabled") - self._start_animation() - - # Handle primary device check safely - try: - primary_device = self.network_client.primary_device - except AttributeError: - primary_device = "wireless" # Default to wireless if error occurs - - # Handle wired connection case - if primary_device == "wired": - self._stop_animation() - if ethernet and ethernet.internet == "activated": - self.network_icon.set_markup(icons.world) - else: - self.network_icon.set_markup(icons.world_off) - else: - if not wifi: - self._stop_animation() - self.network_icon.set_markup(icons.wifi_off) - elif ( - wifi.state == "activated" - and wifi.ssid != "Disconnected" - and wifi.strength > 0 - ): - self._stop_animation() - strength = wifi.strength - if strength < 25: - self.network_icon.set_markup(icons.wifi_0) - elif strength < 50: - self.network_icon.set_markup(icons.wifi_1) - elif strength < 75: - self.network_icon.set_markup(icons.wifi_2) - else: - self.network_icon.set_markup(icons.wifi_3) - else: - self._start_animation() - - -class BluetoothButton(Box): - def __init__(self, launcher): - super().__init__( - name="bluetooth-button", - orientation="h", - h_align="fill", - v_align="fill", - h_expand=True, - v_expand=True, - ) - self.launcher = launcher - - self.bluetooth_icon = Label( - name="bluetooth-icon", - markup=icons.bluetooth, - ) - self.bluetooth_label = Label( - name="bluetooth-label", - label="Bluetooth", - justification="left", - ) - self.bluetooth_label_box = Box( - children=[self.bluetooth_label, Box(h_expand=True)] - ) - self.bluetooth_status_text = Label( - name="bluetooth-status", - label="Disabled", - justification="left", - ) - self.bluetooth_status_box = Box( - children=[self.bluetooth_status_text, Box(h_expand=True)] - ) - self.bluetooth_text = Box( - orientation="v", - h_align="start", - v_align="center", - children=[self.bluetooth_label_box, self.bluetooth_status_box], - ) - self.bluetooth_status_container = Box( - h_align="start", - v_align="center", - spacing=10, - children=[self.bluetooth_icon, self.bluetooth_text], - ) - self.bluetooth_status_button = Button( - name="bluetooth-status-button", - h_expand=True, - child=self.bluetooth_status_container, - on_clicked=lambda *_: self.launcher.bluetooth.client.toggle_power(), - ) - add_hover_cursor(self.bluetooth_status_button) # <-- Added hover - self.bluetooth_menu_label = Label( - name="bluetooth-menu-label", - markup=icons.chevron_right, - ) - self.bluetooth_menu_button = Button( - name="bluetooth-menu-button", - on_clicked=lambda *_: self.launcher.open("bluetooth"), - child=self.bluetooth_menu_label, - ) - add_hover_cursor(self.bluetooth_menu_button) # <-- Added hover - - self.add(self.bluetooth_status_button) - self.add(self.bluetooth_menu_button) - - -class NightModeButton(Button): - def __init__(self): - self.night_mode_icon = Label( - name="night-mode-icon", - markup=icons.night, - ) - self.night_mode_label = Label( - name="night-mode-label", - label="Night Mode", - justification="left", - ) - self.night_mode_label_box = Box( - children=[self.night_mode_label, Box(h_expand=True)] - ) - self.night_mode_status = Label( - name="night-mode-status", - label="Enabled", - justification="left", - ) - self.night_mode_status_box = Box( - children=[self.night_mode_status, Box(h_expand=True)] - ) - self.night_mode_text = Box( - name="night-mode-text", - orientation="v", - h_align="start", - v_align="center", - children=[self.night_mode_label_box, self.night_mode_status_box], - ) - self.night_mode_box = Box( - h_align="start", - v_align="center", - spacing=10, - children=[self.night_mode_icon, self.night_mode_text], - ) - - super().__init__( - name="night-mode-button", - h_expand=True, - child=self.night_mode_box, - on_clicked=self.toggle_hyprsunset, - ) - add_hover_cursor(self) # <-- Added hover - - self.widgets = [ - self, - self.night_mode_label, - self.night_mode_status, - self.night_mode_icon, - ] - # self.check_hyprsunset() - # **Monitor external changes every 3 seconds** - self.hyprsunset_watcher = Fabricator( - self.is_hyprsunset_running, - interval=3000, - stream=False, - default_value=False, - ) - self.hyprsunset_watcher.changed.connect(self.update_night_mode_status) - self.update_night_mode_status(None, self.is_hyprsunset_running()) - - def toggle_hyprsunset(self, *args): - """ - Toggle the 'hyprsunset' process: - - If running, kill it and mark as 'Disabled'. - - If not running, start it and mark as 'Enabled'. - """ - try: - subprocess.check_output(["pgrep", "hyprsunset"]) - exec_shell_command_async("pkill hyprsunset") - self.night_mode_status.set_label("Disabled") - for widget in self.widgets: - widget.add_style_class("disabled") - except subprocess.CalledProcessError: - exec_shell_command_async("hyprsunset -t 3500") - self.night_mode_status.set_label("Enabled") - for widget in self.widgets: - widget.remove_style_class("disabled") - - def is_hyprsunset_running(self, *args): - """Returns True if hyprsunset is running.""" - try: - subprocess.check_output(["pgrep", "hyprsunset"]) - return True - except subprocess.CalledProcessError: - return False - - def update_night_mode_status(self, _, is_running): - """Update UI based on hyprsunset status.""" - if is_running: - self.night_mode_status.set_label("Enabled") - for widget in self.widgets: - widget.remove_style_class("disabled") - else: - self.night_mode_status.set_label("Disabled") - for widget in self.widgets: - widget.add_style_class("disabled") - - -class CaffeineButton(Button): - def __init__(self): - self.caffeine_icon = Label( - name="caffeine-icon", - markup=icons.coffee, - ) - self.caffeine_label = Label( - name="caffeine-label", - label="Caffeine", - justification="left", - ) - self.caffeine_label_box = Box( - children=[self.caffeine_label, Box(h_expand=True)] - ) - self.caffeine_status = Label( - name="caffeine-status", - label="Enabled", - justification="left", - ) - self.caffeine_status_box = Box( - children=[self.caffeine_status, Box(h_expand=True)] - ) - self.caffeine_text = Box( - name="caffeine-text", - orientation="v", - h_align="start", - v_align="center", - children=[self.caffeine_label_box, self.caffeine_status_box], - ) - self.caffeine_box = Box( - h_align="start", - v_align="center", - spacing=10, - children=[self.caffeine_icon, self.caffeine_text], - ) - super().__init__( - name="caffeine-button", - h_expand=True, - child=self.caffeine_box, - on_clicked=self.toggle_wlinhibit, - ) - add_hover_cursor(self) # <-- Added hover - - self.widgets = [ - self, - self.caffeine_label, - self.caffeine_status, - self.caffeine_icon, - ] - # **Monitor external changes every 3 seconds** - self.wlinhibit_watcher = Fabricator( - self.is_wlinhibit_running, - interval=3000, - stream=False, - default_value=False, - ) - self.wlinhibit_watcher.changed.connect(self.update_wlinhibit_status) - self.update_wlinhibit_status(None, self.is_wlinhibit_running()) - - def toggle_wlinhibit(self, *args): - """Toggle the 'wlinhibit' process.""" - if self.is_wlinhibit_running(): - exec_shell_command_async("pkill wlinhibit") - else: - exec_shell_command_async("wlinhibit") - - def is_wlinhibit_running(self, *args): - """Returns True if wlinhibit is running. Accepts extra arguments to avoid Fabricator errors.""" - try: - subprocess.check_output(["pgrep", "wlinhibit"]) - return True - except subprocess.CalledProcessError: - return False - - def update_wlinhibit_status(self, _, is_running): - """Update UI based on wlinhibit status.""" - if is_running: - self.caffeine_status.set_label("Enabled") - for widget in self.widgets: - widget.remove_style_class("disabled") - else: - self.caffeine_status.set_label("Disabled") - for widget in self.widgets: - widget.add_style_class("disabled") - - -class PowerProfileButton(Button): - def __init__(self): - self.power_profile_service = PowerProfiles.get_default() - - self.power_profile_icon = Label( - name="power-profile-icon", - markup=icons.power_balanced, # Default icon - ) - self.power_profile_label = Label( - name="power-profile-label", - label="Power Profile", - justification="left", - ) - self.power_profile_label_box = Box( - children=[self.power_profile_label, Box(h_expand=True)] - ) - self.power_profile_status = Label( - name="power-profile-status", - label="Balanced", - justification="left", - ) - self.power_profile_status_box = Box( - children=[self.power_profile_status, Box(h_expand=True)] - ) - self.power_profile_text = Box( - name="power-profile-text", - orientation="v", - h_align="start", - v_align="center", - children=[self.power_profile_label_box, self.power_profile_status_box], - ) - self.power_profile_box = Box( - h_align="start", - v_align="center", - spacing=10, - children=[self.power_profile_icon, self.power_profile_text], - ) - - super().__init__( - name="power-profile-button", - h_expand=True, - child=self.power_profile_box, - on_clicked=self.toggle_power_profile, - ) - add_hover_cursor(self) - - self.widgets = [ - self, - self.power_profile_label, - self.power_profile_status, - self.power_profile_icon, - ] - - # Connect to power profile service signals - self.power_profile_service.connect("profile", self.update_power_profile_status) - - # Initial update - self.update_power_profile_status(None, self.power_profile_service.get_current_profile()) - - def toggle_power_profile(self, *args): - current_profile = self.power_profile_service.get_current_profile() - profiles = ["power-saver", "balanced", "performance"] - current_index = profiles.index(current_profile) - next_profile = profiles[(current_index + 1) % len(profiles)] - self.power_profile_service.set_power_profile(next_profile) - - def update_power_profile_status(self, _, profile): - # Map profiles to their display names and icons - profile_map = { - "power-saver": {"name": "Power Saver", "icon": icons.power_saving}, - "balanced": {"name": "Balanced", "icon": icons.power_balanced}, - "performance": {"name": "Performance", "icon": icons.power_performance}, - } - - profile_info = profile_map.get(profile, profile_map["balanced"]) - self.power_profile_status.set_label(profile_info["name"]) - self.power_profile_icon.set_markup(profile_info["icon"]) - - if profile == "balanced": - for widget in self.widgets: - widget.add_style_class("disabled") - else: - for widget in self.widgets: - widget.remove_style_class("disabled") - - - -class Dashboard(Gtk.Grid): - def __init__(self, **kwargs): - super().__init__(name="buttons-grid") - self.set_row_homogeneous(True) - self.set_column_homogeneous(True) - self.set_row_spacing(8) - self.set_column_spacing(8) - self.set_vexpand(True) # Prevent vertical expansion - - self.launcher = kwargs["launcher"] - - # Instantiate each button - self.network_button = NetworkButton() - self.bluetooth_button = BluetoothButton(self.launcher) - self.night_mode_button = NightModeButton() - self.caffeine_button = CaffeineButton() - self.power_profile_button = PowerProfileButton() - - # Attach buttons into the grid (two rows, three columns) - self.attach(self.network_button, 0, 0, 1, 1) - self.attach(self.bluetooth_button, 1, 0, 1, 1) - self.attach(self.power_profile_button, 2, 0, 1, 1) - self.attach(self.night_mode_button, 0, 1, 1, 1) - self.attach(self.caffeine_button, 1, 1, 1, 1) - - self.show_all() diff --git a/modules/launcher/components/emoji.py b/modules/launcher/components/emoji.py deleted file mode 100644 index b69355e..0000000 --- a/modules/launcher/components/emoji.py +++ /dev/null @@ -1,330 +0,0 @@ -from fabric.widgets.box import Box -from fabric.widgets.label import Label -from fabric.widgets.button import Button -from fabric.widgets.entry import Entry -from fabric.widgets.stack import Stack -from fabric.utils import remove_handler, get_relative_path -from gi.repository import Gdk -import utils.icons as icons -import os -import subprocess -import ijson - - -class EmojiPicker(Box): - def __init__(self, **kwargs): - super().__init__( - name="emoji", - visible=False, - all_visible=False, - **kwargs, - ) - - self.launcher = kwargs["launcher"] - self.selected_index = -1 - self.emojis_per_page = 9 * 3 - self.current_page_index = 0 - self.filtered_emojis = [] - self.total_pages = 0 - - self._arranger_handler: int = 0 - self._all_emojis = self._load_emoji_data() - - self.stack = Stack( - name="viewport", - spacing=4, - orientation="v", - transition_type="slide-up-down", - transition_duration=200, - ) - self.search_entry = Entry( - name="search-entry", - placeholder="Search Emojis...", - h_expand=True, - notify_text=lambda entry, *_: self.arrange_viewport(entry.get_text()), - on_activate=lambda entry, *_: self.on_search_entry_activate( - entry.get_text() - ), - on_key_press_event=self.on_search_entry_key_press, - ) - self.search_entry.props.xalign = 0.5 - self.header_box = Box( - name="header_box", - spacing=10, - orientation="h", - children=[ - self.search_entry, - # Button( - # name="close-button", - # child=Label(name="close-label", markup=icons.cancel), - # tooltip_text="Exit", - # on_clicked=lambda *_: self.close_picker(), - # ), - ], - ) - - self.picker_box = Box( - name="picker-box", - spacing=10, - h_expand=True, - orientation="v", - children=[ - self.header_box, - self.stack, - ], - ) - - self.resize_viewport() - - self.add(self.picker_box) - self.show_all() - - def _load_emoji_data(self): - emoji_data = {} - emoji_file_path = get_relative_path("../../../config/assets/emoji.json") - if not os.path.exists(emoji_file_path): - print(f"Emoji JSON file not found at: {emoji_file_path}") - return {} - - with open(emoji_file_path, "r") as f: - for emoji_char, emoji_info in ijson.kvitems(f, ""): - emoji_data[emoji_char] = emoji_info - return emoji_data - - def close_picker(self): - self.stack.children = [] - self.selected_index = -1 - self.launcher.close() - - def open_picker(self): - self.search_entry.set_text("") - self.current_page_index = 0 - self.arrange_viewport() - self.search_entry.grab_focus() - - def arrange_viewport(self, query: str = ""): - remove_handler(self._arranger_handler) if self._arranger_handler else None - self.stack.children = [] - self.selected_index = -1 - self.current_page_index = 0 - - self.filtered_emojis = [ - (emoji_char, emoji_info) - for emoji_char, emoji_info in self._all_emojis.items() - if query.casefold() - in ( - emoji_info.get("name", "") + " " + emoji_info.get("group", "") - ).casefold() - ] - self.total_pages = ( - (len(self.filtered_emojis) + self.emojis_per_page - 1) - // self.emojis_per_page - if self.filtered_emojis - else 0 - ) - - self.load_page(self.current_page_index) - - should_resize = not query - - if should_resize: - self.resize_viewport() - if query.strip() != "" and self.get_all_emoji_buttons(): - self.update_selection(0) - - def load_page(self, page_index): - self.update_selection(-1) - page_box = Box(name=f"page-box-{page_index}", orientation="v", spacing=4) - start_index = page_index * self.emojis_per_page - end_index = min( - (page_index + 1) * self.emojis_per_page, len(self.filtered_emojis) - ) - page_emojis = self.filtered_emojis[start_index:end_index] - - grid_box = Box(name="emoji-grid-box", orientation="v", spacing=2) - - row_box = None - for i, (emoji_char, emoji_info) in enumerate(page_emojis): - if i % 9 == 0: - row_box = Box(name="emoji-row-box", orientation="h", spacing=2) - grid_box.add(row_box) - if row_box is not None: - row_box.add(self.bake_emoji_slot(emoji_char, emoji_info)) - page_box.add(grid_box) - self.stack.add_named(page_box, f"page-{page_index}") - self.stack.set_visible_child_name(f"page-{page_index}") - page_box.show_all() - - buttons = self.get_all_emoji_buttons() - if buttons and self.selected_index != -1: - page_relative_index = self.selected_index % self.emojis_per_page - if page_relative_index < len(buttons): - self.update_selection(page_relative_index) - else: - self.update_selection(len(buttons) - 1) - - def resize_viewport(self): - return False - - def bake_emoji_slot(self, emoji_char: str, emoji_info: dict, **kwargs) -> Button: - button = Button( - name="emoji-slot-button", - child=Box( - name="emoji-slot-box", - orientation="horizontal", - halign="center", - valign="center", - children=[ - Label( - name="emoji-char-label", - label=emoji_char, - use_markup=True, - v_align="center", - h_align="center", - css_name="emoji-char-label", - ), - ], - ), - tooltip_text=emoji_info.get("name", "Unknown"), - on_clicked=lambda *_: ( - self.copy_emoji_to_clipboard(emoji_char), - self.close_picker(), - ), - **kwargs, - ) - return button - - def update_selection(self, new_index: int): - buttons = self.get_all_emoji_buttons() - if not buttons: - self.selected_index = -1 - return - - if self.selected_index != -1 and self.selected_index < len(buttons): - current_button = buttons[self.selected_index] - current_button.get_style_context().remove_class("selected") - if not buttons or current_button not in buttons: - self.selected_index = -1 - - if 0 <= new_index < len(buttons): - new_button = buttons[new_index] - new_button.get_style_context().add_class("selected") - self.selected_index = new_index - else: - self.selected_index = -1 - - def get_all_emoji_buttons(self): - buttons = [] - current_page = self.stack.get_visible_child() - if current_page and current_page.get_children(): - if current_page.get_children()[0].get_children(): - for row_box in current_page.get_children()[0].get_children(): - buttons.extend(row_box.get_children()) - return buttons - - def on_search_entry_activate(self, text): - buttons = self.get_all_emoji_buttons() - if buttons: - if self.selected_index != -1: - buttons[self.selected_index].clicked() - elif buttons and text.strip() != "": - buttons[0].clicked() - - def on_search_entry_key_press(self, widget, event): - if event.keyval in (Gdk.KEY_Up, Gdk.KEY_Down, Gdk.KEY_Left, Gdk.KEY_Right): - self.move_selection_2d(event.keyval) - return True - elif event.keyval == Gdk.KEY_Escape: - self.close_picker() - return True - return False - - def move_selection_2d(self, keyval): - buttons = self.get_all_emoji_buttons() - total_items_current_page = len(buttons) - if total_items_current_page == 0: - return - - rows = 3 - columns = 9 - - if self.selected_index == -1: - if keyval in (Gdk.KEY_Down, Gdk.KEY_Right): - new_index = 0 - elif keyval in (Gdk.KEY_Up, Gdk.KEY_Left): - new_index = total_items_current_page - 1 - else: - return - else: - current_index_page = self.selected_index - row = current_index_page // columns - col = current_index_page % columns - - if keyval == Gdk.KEY_Right: - new_col = (col + 1) % columns - new_row = row - if new_col == 0: - new_row = row + 1 - elif keyval == Gdk.KEY_Left: - new_col = (col - 1) % columns - new_row = row - if new_col == (columns - 1): - new_row = row - 1 - elif keyval == Gdk.KEY_Down: - new_row = row + 1 - new_col = col - elif keyval == Gdk.KEY_Up: - new_row = row - 1 - new_col = col - else: - return - - if new_row >= rows: - if self.current_page_index < self.total_pages - 1: - current_col = col # Keep track of current column - self.current_page_index += 1 - self.load_page(self.current_page_index) - new_index = current_col # Try to keep the same column - if ( - new_index >= total_items_current_page - ): # if column is out of bound, select last - new_index = total_items_current_page - 1 - self.selected_index = -1 - self.update_selection(new_index) - return - else: - new_index = total_items_current_page - 1 - elif new_row < 0: - if self.current_page_index > 0: - current_col = col # Keep track of current column - self.current_page_index -= 1 - self.load_page(self.current_page_index) - new_index = ( - rows - 1 - ) * columns + current_col # Select last row, same column - if ( - new_index >= total_items_current_page - ): # if column is out of bound, select last - new_index = total_items_current_page - 1 - self.selected_index = -1 - self.update_selection(new_index) - return - else: - new_index = 0 - else: - new_index = new_row * columns + new_col - if new_index >= total_items_current_page: - new_index = total_items_current_page - 1 - - if new_index < 0: - new_index = 0 - elif new_index >= total_items_current_page: - new_index = total_items_current_page - 1 - - self.update_selection(new_index) - - def copy_emoji_to_clipboard(self, emoji_char: str): - try: - subprocess.run(["wl-copy"], input=emoji_char.encode("utf-8"), check=True) - except subprocess.CalledProcessError as e: - print(f"Clipboard copy failed: {e}") diff --git a/modules/launcher/components/kanban.py b/modules/launcher/components/kanban.py deleted file mode 100644 index 8c2bf8a..0000000 --- a/modules/launcher/components/kanban.py +++ /dev/null @@ -1,365 +0,0 @@ -import gi -import json -import os -from pathlib import Path -import cairo # For rendering the drag preview - -from fabric.widgets.label import Label -from fabric.widgets.box import Box -from fabric.widgets.centerbox import CenterBox -import utils.icons as icons - -gi.require_version("Gtk", "3.0") -from gi.repository import Gtk, Gdk, GObject, GLib - - -def createSurfaceFromWidget(widget: Gtk.Widget) -> cairo.ImageSurface: - alloc = widget.get_allocation() - surface = cairo.ImageSurface(cairo.Format.ARGB32, alloc.width, alloc.height) - cr = cairo.Context(surface) - # Use a transparent background. - cr.set_source_rgba(0, 0, 0, 0) - cr.rectangle(0, 0, alloc.width, alloc.height) - cr.fill() - widget.draw(cr) - return surface - - -class InlineEditor(Gtk.Box): - __gsignals__ = { - "confirmed": (GObject.SignalFlags.RUN_LAST, None, (str,)), - "canceled": (GObject.SignalFlags.RUN_LAST, None, ()), - } - - def __init__(self, initial_text=""): - super().__init__(name="inline-editor", spacing=4) - # Replace Gtk.Entry with a Gtk.TextView for multiline editing. - self.text_view = Gtk.TextView() - self.text_view.set_wrap_mode(Gtk.WrapMode.WORD) - buffer = self.text_view.get_buffer() - buffer.set_text(initial_text) - # Connect key press events to handle Return and SHIFT+Return. - self.text_view.connect("key-press-event", self.on_key_press) - - confirm_btn = Gtk.Button( - name="kanban-btn", child=Label(name="kanban-btn-label", markup=icons.accept) - ) - confirm_btn.connect("clicked", self.on_confirm) - confirm_btn.get_style_context().add_class("flat") - - cancel_btn = Gtk.Button( - name="kanban-btn", child=Label(name="kanban-btn-neg", markup=icons.cancel) - ) - cancel_btn.connect("clicked", self.on_cancel) - cancel_btn.get_style_context().add_class("flat") - - # Pack the TextView inside a ScrolledWindow for better appearance. - sw = Gtk.ScrolledWindow() - sw.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) - sw.set_min_content_height(50) - sw.add(self.text_view) - - self.button_box = Box(children=[confirm_btn, cancel_btn], spacing=4) - self.center_box = CenterBox(center_children=[self.button_box], orientation="v") - - self.pack_start(sw, True, True, 0) - self.pack_start(self.center_box, False, False, 0) - self.show_all() - - def on_confirm(self, widget): - buffer = self.text_view.get_buffer() - start, end = buffer.get_bounds() - text = buffer.get_text(start, end, True).strip() - if text: - self.emit("confirmed", text) - else: - self.emit("canceled") - - def on_cancel(self, widget): - self.emit("canceled") - - def on_key_press(self, widget, event): - # Check for Escape to cancel. - if event.keyval == Gdk.KEY_Escape: - self.emit("canceled") - return True - - # If Return is pressed... - if event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter): - state = event.get_state() - if state & Gdk.ModifierType.SHIFT_MASK: - # SHIFT+Return: insert a newline. - buffer = self.text_view.get_buffer() - cursor_iter = buffer.get_iter_at_mark(buffer.get_insert()) - buffer.insert(cursor_iter, "\n") - return True # Prevent further handling. - else: - # Plain Return: confirm the edit. - self.on_confirm(widget) - return True - return False - - -class KanbanNote(Gtk.EventBox): - __gsignals__ = { - "changed": (GObject.SignalFlags.RUN_LAST, None, ()), - } - - def __init__(self, text): - super().__init__() - self.text = text - # Variables to store the click offset for drag preview. - self.setup_ui() - self.setup_dnd() - self.connect("button-press-event", self.on_button_press) - - def setup_ui(self): - self.box = Gtk.Box(name="kanban-note", spacing=4) - self.label = Gtk.Label(label=self.text) - self.label.set_line_wrap(True) - # Wrap long lines. - self.label.set_line_wrap_mode(Gtk.WrapMode.WORD) - - self.delete_btn = Gtk.Button( - name="kanban-btn", child=Label(name="kanban-btn-neg", markup=icons.trash) - ) - self.delete_btn.connect("clicked", self.on_delete_clicked) - - self.center_btn = CenterBox(orientation="v", start_children=[self.delete_btn]) - - self.box.pack_start(self.label, True, True, 0) - self.box.pack_start(self.center_btn, False, False, 0) - self.add(self.box) - self.show_all() - - def setup_dnd(self): - self.drag_source_set( - Gdk.ModifierType.BUTTON1_MASK, - [Gtk.TargetEntry.new("UTF8_STRING", Gtk.TargetFlags.SAME_APP, 0)], - Gdk.DragAction.MOVE, - ) - self.connect("drag-data-get", self.on_drag_data_get) - self.connect("drag-data-delete", self.on_drag_data_delete) - # Set up drag-begin to display a preview of the card. - self.connect("drag-begin", self.on_drag_begin) - - def on_button_press(self, widget, event): - if event.type != Gdk.EventType._2BUTTON_PRESS: - return True - self.start_edit() - return False - - def on_drag_begin(self, widget, context): - surface = createSurfaceFromWidget(self) - Gtk.drag_set_icon_surface(context, surface) - - def on_drag_data_get(self, widget, drag_context, data, info, time): - data.set_text(self.label.get_text(), -1) - - def on_drag_data_delete(self, widget, drag_context): - self.get_parent().destroy() - - def on_delete_clicked(self, button): - self.get_parent().destroy() - - def start_edit(self): - row = self.get_parent() - editor = InlineEditor(self.label.get_text()) - - def on_confirmed(editor, text): - self.label.set_text(text) - row.remove(editor) - row.add(self) - row.show_all() - self.emit("changed") - - def on_canceled(editor): - row.remove(editor) - row.add(self) - row.show_all() - - editor.connect("confirmed", on_confirmed) - editor.connect("canceled", on_canceled) - - row.remove(self) - row.add(editor) - row.show_all() - # Usamos un retardo de 50 milisegundos: - GLib.timeout_add(50, lambda: (editor.text_view.grab_focus(), False)) - - -class KanbanColumn(Gtk.Frame): - __gsignals__ = { - "changed": (GObject.SignalFlags.RUN_LAST, None, ()), - } - - def __init__(self, title): - super().__init__(name="kanban-column") - self.title = title - self.setup_ui() - self.setup_dnd() - self.set_hexpand(True) - self.set_vexpand(True) - - def setup_ui(self): - self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) - self.listbox = Gtk.ListBox() - self.listbox.set_selection_mode(Gtk.SelectionMode.NONE) - - self.add_btn = Gtk.Button( - name="kanban-btn-add", - child=Label(name="kanban-btn-label", markup=icons.add), - ) - header = CenterBox( - name="kanban-header", - center_children=[Label(name="column-header", label=self.title)], - end_children=[self.add_btn], - ) - self.box.pack_start(header, False, False, 0) - - self.add_btn.connect("clicked", self.on_add_clicked) - - scrolled = Gtk.ScrolledWindow(name="kanban-scroll") - scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) - scrolled.add(self.listbox) - scrolled.set_vexpand(True) - - self.box.pack_start(scrolled, True, True, 0) - self.box.pack_start(self.add_btn, False, False, 0) - self.add(self.box) - self.show_all() - - def setup_dnd(self): - self.listbox.drag_dest_set( - Gtk.DestDefaults.ALL, - [Gtk.TargetEntry.new("UTF8_STRING", Gtk.TargetFlags.SAME_APP, 0)], - Gdk.DragAction.MOVE, - ) - - self.listbox.connect("drag-data-received", self.on_drag_data_received) - self.listbox.connect("drag-motion", self.on_drag_motion) - self.listbox.connect("drag-leave", self.on_drag_leave) - - def on_add_clicked(self, button): - editor = InlineEditor() - row = Gtk.ListBoxRow(name="kanban-row") - row.add(editor) - self.listbox.add(row) - self.listbox.show_all() - editor.text_view.grab_focus() - - def on_confirmed(editor, text): - note = KanbanNote(text) - note.connect("changed", lambda x: self.emit("changed")) - row.remove(editor) - row.add(note) - self.listbox.show_all() - self.emit("changed") # Emit on add - - def on_canceled(editor): - row.destroy() - - editor.connect("confirmed", on_confirmed) - editor.connect("canceled", on_canceled) - - def add_note(self, text, suppress_signal=False): - note = KanbanNote(text) - note.connect("changed", lambda x: self.emit("changed")) - row = Gtk.ListBoxRow(name="kanban-row") - row.add(note) - row.connect("destroy", lambda x: self.emit("changed")) - self.listbox.add(row) - self.listbox.show_all() - if not suppress_signal: - self.emit("changed") - - def get_notes(self): - return [ - row.get_children()[0].label.get_text() - for row in self.listbox.get_children() - if isinstance(row.get_children()[0], KanbanNote) - ] - - def clear_notes(self, suppress_signal=False): - for row in self.listbox.get_children(): - row.destroy() - if not suppress_signal: - self.emit("changed") - - def on_drag_data_received(self, widget, drag_context, x, y, data, info, time): - text = data.get_text() - if text: - row = self.listbox.get_row_at_y(y) - new_note = KanbanNote(text) - new_note.connect("changed", lambda x: self.emit("changed")) - new_row = Gtk.ListBoxRow(name="kanban-row") - new_row.add(new_note) - new_row.connect("destroy", lambda x: self.emit("changed")) - - if row: - self.listbox.insert(new_row, row.get_index()) - else: - self.listbox.add(new_row) - - self.listbox.show_all() - drag_context.finish(True, False, time) - self.emit("changed") # Emit on move - - def on_drag_motion(self, widget, drag_context, x, y, time): - Gdk.drag_status(drag_context, Gdk.DragAction.MOVE, time) - return True - - def on_drag_leave(self, widget, drag_context, time): - widget.get_parent().get_parent().drag_unhighlight() - - -class Kanban(Gtk.Box): - STATE_FILE = Path(os.path.expanduser("~/.local/state/modus/kanban.json")) - - def __init__(self): - super().__init__(name="kanban") - - self.grid = Gtk.Grid(column_spacing=4, column_homogeneous=True) - self.grid.set_vexpand(True) - self.add(self.grid) - - self.columns = [ - KanbanColumn("To Do"), - KanbanColumn("In Progress"), - KanbanColumn("Done"), - ] - - for i, column in enumerate(self.columns): - self.grid.attach(column, i, 0, 1, 1) - column.connect("changed", lambda x: self.save_state()) - - self.load_state() - self.show_all() - - def save_state(self): - state = { - "columns": [ - {"title": col.title, "notes": col.get_notes()} for col in self.columns - ] - } - try: - with open(self.STATE_FILE, "w") as f: - json.dump(state, f, indent=2) - except Exception as e: - print(f"Error saving state: {e}") - - def load_state(self): - try: - with open(self.STATE_FILE, "r") as f: - state = json.load(f) - for col_data in state["columns"]: - for column in self.columns: - if column.title == col_data["title"]: - column.clear_notes(suppress_signal=True) - for note_text in col_data["notes"]: - column.add_note(note_text, suppress_signal=True) - break - except FileNotFoundError: - pass - except Exception as e: - print(f"Error loading state: {e}") diff --git a/modules/launcher/components/notification_center.py b/modules/launcher/components/notification_center.py deleted file mode 100644 index 86ade48..0000000 --- a/modules/launcher/components/notification_center.py +++ /dev/null @@ -1,409 +0,0 @@ -from fabric.notifications import Notification -from services import notification_service -from fabric.widgets.box import Box -from fabric.widgets.button import Button -from fabric.widgets.label import Label -from fabric.widgets.image import Image -from fabric.widgets.scrolledwindow import ScrolledWindow -from fabric.widgets.revealer import Revealer -from loguru import logger -import utils.icons as icons -from gi.repository import GdkPixbuf, GLib -from utils import CustomImage -import os -from gi.repository import Gtk -from fabric.widgets.centerbox import CenterBox - - -class NotificationCenter(Box): - def __init__(self, **kwargs): - super().__init__( - name="notification-center", - visible=False, - all_visible=False, - **kwargs, - ) - - self.launcher = kwargs["launcher"] - self.notification_service = notification_service - - # Main container for notifications - self.notifications = Box( - v_expand=True, - h_expand=True, - orientation="v", - spacing=8, - ) - self.header_switch = Gtk.Switch(name="matugen-switcher") - self.header_switch.set_vexpand(False) - self.header_switch.set_valign(Gtk.Align.CENTER) - self.header_switch.set_active(self.notification_service.dont_disturb) - self.do_not_disturb_enabled = self.notification_service.dont_disturb - self.header_switch.connect("notify::active", self.on_do_not_disturb_changed) - self.dnd_label = Label(name="dnd-label", markup=icons.notifications_off) - # Header with clear all button - header = CenterBox( - h_expand=True, - start_children=[self.header_switch, self.dnd_label], - center_children=[ - Label( - label="Notifications", - name="nhh", - ) - ], - end_children=[ - Button( - name="nhh-button", - child=Label(name="nhh-button-label", markup=icons.trash), - on_clicked=lambda *args: self.clear_all_notifications(), - ) - ], - ) - - self.scrolledwindow = ScrolledWindow( - name="scrolled-window", - child=self.notifications, - min_content_size=(400, 300), - max_content_size=(400, 300), - v_expand=True, - h_expand=True, - ) - - # Main container - main_container = Box( - v_expand=True, - h_expand=True, - orientation="v", - children=[header, self.scrolledwindow], - ) - - self.add(main_container) - - # Connect to notification service signals - self.notification_service.connect( - "notification-added", self.on_new_notification - ) - self.notification_service.connect("clear_all", self.on_clear_all) - - # Load existing notifications - self.load_notifications() - - def load_notifications(self): - """Load existing notifications from the service.""" - notifications = self.notification_service.get_deserialized() - if not notifications: - # Create a container box to better center the message - container = Box( - name="no-notifications-container", - orientation="v", - h_align="center", - v_align="center", - h_expand=True, - v_expand=True, - ) - - # Show a message if no notifications - label = Label( - name="no-notifications-label", - markup=icons.notifications_clear, - v_align="fill", - h_align="fill", - v_expand=True, - h_expand=True, - justification="center", - ) - - container.add(label) - self.notifications.add(container) - return - - for notification in notifications: - self.add_notification(notification) - - def toggle_notification_body(self, revealer, chevron_label): - """Toggle the visibility of notification body.""" - is_revealed = revealer.get_reveal_child() - revealer.set_reveal_child(not is_revealed) - # Update chevron icon - chevron_label.set_markup( - icons.chevron_up if not is_revealed else icons.chevron_down - ) - - def add_notification(self, notification: Notification): - """Add a notification to the center.""" - # Remove empty state container if it exists - for child in self.notifications.get_children(): - if child.get_name() == "no-notifications-container": - self.notifications.remove(child) - break - - # Get timestamp from notification object - timestamp = getattr(notification, 'timestamp', GLib.get_real_time() / 1000000) - time_str = GLib.DateTime.new_from_unix_local(timestamp).format("%I:%M %p") - - # Create the notification header (summary) box - header_box = Box( - name="notification-header", - # h_expand=True, - spacing=8, - children=[ - Box( - orientation="h", - h_expand=True, - spacing=8, - style="min-width: 0;", - children=[ - # Summary - Label( - name="notification-app-name", - markup=notification.app_name + " | ", - h_align="start", - ellipsization="end", - ), - Label( - markup=notification.summary.replace("\n", " "), - h_align="start", - ellipsization="end", - ), - Label( - name="notification-time", - markup=f" | {time_str}", - h_align="end", - style="color: var(--outline);", - ), - ], - ), - # Add chevron button if there's content to show - *( - [ - Button( - name="chevron-button", - child=Label( - name="chevron-label", markup=icons.chevron_down - ), - ) - ] - if notification.body or notification.image_pixbuf - else [] - ), - Button( - name="notif-close-button", - child=Label(name="notif-close-label", markup=icons.cancel), - on_clicked=lambda *args: self.remove_notification(notification), - ), - ], - ) - - # Create content box that will be revealed - # Create content box in vertical layout for body + actions - content_box = Box( - name="notification-content", - spacing=8, - orientation="v", - ) - - # Create horizontal box for image and body - body_box = Box( - name="notification-body-box", - spacing=8, - orientation="h", - ) - - # Add image if available - if notification.image_pixbuf: - image_box = Box( - name="notification-image", - children=[ - CustomImage( - pixbuf=notification.image_pixbuf.scale_simple( - 48, 48, GdkPixbuf.InterpType.BILINEAR - ) - if notification.image_pixbuf - else self.get_pixbuf(notification.app_icon, 48, 48) - ), - ], - ) - body_box.add(image_box) - - # Add body text if available - if notification.body: - body_box.add( - Label( - markup=notification.body.replace("\n", " "), - name="notification-body", - h_align="start", - ellipsization="end", - ) - ) - - # Add body box to content - content_box.add(body_box) - - # Add action buttons if available - if notification.actions: - action_box = Box( - name="notification-action-buttons", - spacing=4, - h_expand=True, - ) - for i, action in enumerate(notification.actions): - style_class = ( - "start-action" - if i == 0 - else "end-action" - if i == len(notification.actions) - 1 - else "middle-action" - ) - button = Button( - name="action-button", - h_expand=True, - child=Label(name="button-label", label=action.label), - on_clicked=lambda btn, act=action, notif=notification: [ - act.invoke(), - self.notification_service.remove_notification(notif.id), - self.remove_notification(notif) - ], - ) - button.add_style_class(style_class) - action_box.add(button) - content_box.add(action_box) - - # Create revealer for content - body_revealer = None - if notification.body or notification.image_pixbuf: - body_revealer = Revealer( - name="body-revealer", - transition_type="slide-down", - reveal_child=False, - child=content_box, - ) - - # Create main notification box - notification_box = Box( - name="notification-box", - orientation="v", - h_expand=True, - style="margin: 4px; padding: 16px; border-radius: 20px; background-color: var(--surface-container-low); min-width: 200px;", - children=[header_box], - ) - - # Add body revealer if exists - if body_revealer: - notification_box.add(body_revealer) - # Get the chevron button from header_box children - chevron_button = [ - c for c in header_box.get_children() if c.get_name() == "chevron-button" - ][0] - chevron_label = chevron_button.get_child() - chevron_button.connect( - "clicked", - lambda *args: self.toggle_notification_body( - body_revealer, chevron_label - ), - ) - - self.notifications.add(notification_box) - - def on_do_not_disturb_changed(self, switch, pspec): - """Handle Do Not Disturb toggle.""" - self.do_not_disturb_enabled = switch.get_active() - self.notification_service.dont_disturb = self.do_not_disturb_enabled - - def on_new_notification(self, fabric_notif, id): - """Handle new notification event.""" - notification = fabric_notif.get_notification_from_id(id) - self.add_notification(notification) - - def remove_notification(self, notification: Notification): - """Remove a notification from the center.""" - self.notification_service.remove_notification(notification.id) - # Find and remove the notification box - for child in self.notifications.get_children(): - if child.get_name() == "notification-box": - # Get the header box - header_box = child.get_children()[0] - # Get the text container box - text_box = header_box.get_children()[0] - # Get the summary label (second child of text box) - summary_label = text_box.get_children()[1] - if summary_label.get_text() == notification.summary: - self.notifications.remove(child) - break - - def clear_all_notifications(self, *args): - """Clear all notifications.""" - self.notification_service.clear_all_notifications() - # Remove all child widgets - for child in self.notifications.get_children(): - self.notifications.remove(child) - - # Add empty state - container = Box( - name="no-notifications-container", - orientation="v", - h_align="center", - v_align="center", - h_expand=True, - v_expand=True, - ) - - label = Label( - name="no-notifications-label", - markup=icons.notifications_clear, - v_align="fill", - h_align="fill", - v_expand=True, - h_expand=True, - justification="center", - ) - - container.add(label) - self.notifications.add(container) - - def on_clear_all(self, *args): - """Handle clear all event from service.""" - # Remove all child widgets - for child in self.notifications.get_children(): - self.notifications.remove(child) - - # Add empty state - container = Box( - name="no-notifications-container", - orientation="v", - h_align="center", - v_align="center", - h_expand=True, - v_expand=True, - ) - - label = Label( - name="no-notifications-label", - markup=icons.notifications_clear, - v_align="fill", - h_align="fill", - v_expand=True, - h_expand=True, - justification="center", - ) - - container.add(label) - self.notifications.add(container) - - def get_pixbuf(self, icon_path, width, height): - if icon_path.startswith("file://"): - icon_path = icon_path[7:] - - if not os.path.exists(icon_path): - logger.warning(f"Icon path does not exist: {icon_path}") - return None - - try: - pixbuf = GdkPixbuf.Pixbuf.new_from_file(icon_path) - return pixbuf.scale_simple(width, height, GdkPixbuf.InterpType.BILINEAR) - except Exception as e: - logger.error(f"Failed to load or scale icon: {e}") - return None - - def open_center(self): - """Open the notification center.""" - self.show_all() diff --git a/modules/launcher/components/power.py b/modules/launcher/components/power.py deleted file mode 100644 index b441e25..0000000 --- a/modules/launcher/components/power.py +++ /dev/null @@ -1,88 +0,0 @@ -from fabric.widgets.box import Box -from fabric.widgets.label import Label -from fabric.widgets.button import Button -from fabric.utils.helpers import exec_shell_command_async -import utils.icons as icons - - -class PowerMenu(Box): - def __init__(self, **kwargs): - super().__init__( - name="power-menu", - orientation="h", - spacing=10, - v_align="center", - h_align="center", - v_expand=True, - h_expand=True, - visible=True, - **kwargs, - ) - - self.launcher = kwargs["launcher"] - - self.btn_lock = Button( - name="power-menu-button", - child=Label(name="button-label", markup=icons.lock), - on_clicked=self.lock, - ) - - self.btn_suspend = Button( - name="power-menu-button", - child=Label(name="button-label", markup=icons.suspend), - on_clicked=self.suspend, - ) - - self.btn_logout = Button( - name="power-menu-button", - child=Label(name="button-label", markup=icons.logout), - on_clicked=self.logout, - ) - - self.btn_reboot = Button( - name="power-menu-button", - child=Label(name="button-label", markup=icons.reboot), - on_clicked=self.reboot, - ) - - self.btn_shutdown = Button( - name="power-menu-button", - child=Label(name="button-label", markup=icons.shutdown), - on_clicked=self.poweroff, - ) - - self.buttons = [ - self.btn_lock, - self.btn_suspend, - self.btn_logout, - self.btn_reboot, - self.btn_shutdown, - ] - - for button in self.buttons: - self.add(button) - - self.show_all() - - def close_menu(self): - self.launcher.close() - - def lock(self, *args): - exec_shell_command_async("hyprlock --immediate") - self.close_menu() - - def suspend(self, *args): - exec_shell_command_async("systemctl suspend") - self.close_menu() - - def logout(self, *args): - exec_shell_command_async("hyprctl dispatch exit") - self.close_menu() - - def reboot(self, *args): - exec_shell_command_async("systemctl reboot") - self.close_menu() - - def poweroff(self, *args): - exec_shell_command_async("systemctl poweroff") - self.close_menu() diff --git a/modules/launcher/components/sh.py b/modules/launcher/components/sh.py deleted file mode 100644 index 6fef79a..0000000 --- a/modules/launcher/components/sh.py +++ /dev/null @@ -1,139 +0,0 @@ -import os -import subprocess -from typing import List -from fabric.widgets.box import Box -from fabric.widgets.button import Button -from fabric.widgets.entry import Entry -from fabric.widgets.label import Label -from fabric.widgets.scrolledwindow import ScrolledWindow -import config.data as data - -BINS = f"{data.CACHE_DIR}/binaries" - - -class Sh(Box): - def __init__(self, **kwargs): - super().__init__( - all_visible=False, - visible=False, - **kwargs, - ) - - self.launcher = kwargs["launcher"] - self._arranger_handler: int = 0 - self.shell_command_manager = ShellCommandManager(self) - self.viewport = None - - self.search_entry = Entry( - name="search-entry", - placeholder="Search Shell Commands...", - h_expand=True, - notify_text=lambda entry, *_: self.handle_search_input(entry.get_text()), - on_activate=lambda entry, *_: self.handle_search_input(entry.get_text()), - ) - self.search_entry.props.xalign = 0.5 - - self.header_box = Box( - spacing=10, - orientation="h", - children=[self.search_entry], - ) - - self.launcher_box = Box( - spacing=10, - orientation="v", - h_expand=True, - children=[self.header_box], - ) - - self.add(self.launcher_box) - - def open_sh(self): - if not self.viewport: - self.viewport = Box(name="viewport", spacing=10, orientation="v") - self.scrolled_window = ScrolledWindow( - name="scrolled-window", - spacing=10, - h_scrollbar_policy="never", - v_scrollbar_policy="never", - # min_content_size=(-1, -1), - child=self.viewport, - ) - self.launcher_box.add(self.scrolled_window) - - self.viewport.children = [] - self.shell_command_manager.show_shell_commands(self.viewport, "") - - self.viewport.show() - self.search_entry.grab_focus() - - def handle_search_input(self, text: str): - self.shell_command_manager.show_shell_commands(self.viewport, text) - - -class ShellCommandManager: - def __init__(self, launcher): - self.launcher = launcher - self.binaries = set() - self.reload_binaries() - - def reload_binaries(self): - paths = os.getenv("PATH", "").split(":") - self.binaries = set() - - for path in paths: - self.binaries.update(self.list_binaries_in_path(path)) - - with open(BINS, "w") as f: - f.write("\n".join(self.binaries)) - - def list_binaries_in_path(self, path): - if not os.path.exists(path): - return [] - try: - return [ - entry - for entry in os.listdir(path) - if os.access(os.path.join(path, entry), os.X_OK) - ] - except OSError: - return [] - - def show_shell_commands(self, viewport, search_query: str): - if not search_query: - return - - viewport.children = [] - - results = self.query_binaries(search_query) - self.display_results(viewport, results) - - def query_binaries(self, filter_str: str) -> List[str]: - filter_str = filter_str.lower() - filtered_bins = (cmd for cmd in self.binaries if filter_str in cmd) - return list(dict.fromkeys(filtered_bins))[:8] - - def display_results(self, viewport, results: List[str]): - for command in results: - button = Button( - child=Label( - label=command, - v_align="center", - h_align="start", - ), - on_clicked=lambda _, cmd=command: self.execute_command(cmd), - name="app-slot-button", - ) - viewport.add(button) - - def close_menu(self): - self.launcher.close() - - def execute_command(self, cmd: str): - try: - output = subprocess.check_output(cmd, shell=True, text=True) - print(output) - except subprocess.CalledProcessError as e: - print(e.output) - finally: - self.close_menu() diff --git a/modules/launcher/components/tmux.py b/modules/launcher/components/tmux.py deleted file mode 100644 index 583e477..0000000 --- a/modules/launcher/components/tmux.py +++ /dev/null @@ -1,540 +0,0 @@ -import subprocess -import os -from fabric.widgets.box import Box -from fabric.widgets.label import Label -from fabric.widgets.button import Button -from fabric.widgets.entry import Entry -from fabric.widgets.scrolledwindow import ScrolledWindow -from fabric.utils import idle_add, remove_handler, exec_shell_command_async -from gi.repository import GLib, Gdk, Gtk -import utils.icons as icons -import config.data as data - -class TmuxManager(Box): - def __init__(self, **kwargs): - super().__init__( - name="tmux-manager", - visible=False, - all_visible=False, - **kwargs, - ) - - self.launcher = kwargs["launcher"] - self.selected_index = -1 # Track the selected item index - - self._arranger_handler: int = 0 - - self.viewport = Box(name="viewport", spacing=4, orientation="v") - self.session_name_entry = Entry( - name="session-name-entry", - placeholder="Create Tmux Session...", - h_expand=True, - on_activate=lambda entry, *_: self.create_session(entry.get_text()), - on_key_press_event=self.on_entry_key_press, - ) - self.session_name_entry.props.xalign = 0.5 - self.scrolled_window = ScrolledWindow( - name="scrolled-window", - spacing=10, - min_content_size=(450, 105), - max_content_size=(450, 105), - child=self.viewport, - ) - - self.header_box = Box( - name="header_box", - spacing=10, - orientation="h", - children=[ - Button( - name="new-session-button", - child=Label(name="new-session-label", markup=icons.add), - tooltip_text="Create New Session", - on_clicked=lambda *_: self.create_session(self.session_name_entry.get_text()), - ), - self.session_name_entry, - ], - ) - - self.tmux_box = Box( - name="tmux-box", - spacing=10, - h_expand=True, - orientation="v", - children=[ - self.header_box, - self.scrolled_window, - ], - ) - - self.add(self.tmux_box) - self.show_all() - - def close_manager(self): - """Close the tmux manager""" - self.viewport.children = [] - self.selected_index = -1 # Reset selection - self.launcher.close() - - def open_manager(self): - """Open the tmux manager and refresh sessions""" - self.viewport.set_visible(True) - self.refresh_sessions() - self.session_name_entry.set_text("") - self.session_name_entry.grab_focus() - - def refresh_sessions(self): - """Get tmux sessions and populate the viewport""" - remove_handler(self._arranger_handler) if self._arranger_handler else None - self.viewport.children = [] - self.selected_index = -1 # Clear selection when viewport changes - - # Get tmux sessions - sessions = self.get_tmux_sessions() - if not sessions: - # Create a container box to better center the message - container = Box( - name="no-tmux-container", - orientation="v", - h_align="center", - v_align="center", - h_expand=True, - v_expand=True - ) - - # Show a message if no sessions - label = Label( - name="no-tmux", - markup=icons.terminal, - h_align="center", - v_align="center", - ) - - container.add(label) - - self.viewport.add(container) - return - - # Add session slots to viewport - for session in sessions: - self.viewport.add(self.create_session_slot(session)) - - def get_tmux_sessions(self): - """Get list of tmux sessions""" - try: - result = subprocess.run( - ["tmux", "list-sessions", "-F", "#{session_name}"], - capture_output=True, - text=True - ) - if result.returncode == 0: - return [s.strip() for s in result.stdout.strip().split('\n') if s.strip()] - return [] - except Exception as e: - print(f"Error getting tmux sessions: {e}") - return [] - - def create_session_slot(self, session_name): - """Create a button for a tmux session""" - # Create an entry for inline editing (initially hidden) - name_entry = Entry( - name="session-name-entry", - text=session_name, - visible=False, - on_activate=lambda entry, *_: self.finish_rename(button, session_name, entry), - on_key_press_event=self.on_rename_key_press, - ) - - # Create the label showing the session name - name_label = Label( - name="app-label", - label=session_name, - ellipsization="end", - v_align="center", - h_align="center", - ) - - # Session slot content box - slot_box = Box( - name="slot-box", - orientation="h", - spacing=10, - children=[ - Label( - name="tmux-icon", - markup=icons.terminal, # Use existing terminal icon - ), - name_label, - name_entry, - ], - ) - - button = Button( - name="slot-button", # reuse existing CSS styling - child=slot_box, - tooltip_text=f"Attach to session: {session_name}", - on_clicked=lambda *_: self.attach_to_session(session_name), - can_focus=True, # Ensure the button can receive focus - ) - - # Add double-click handler to start renaming - button.connect("button-press-event", self.on_session_click, session_name, name_label, name_entry) - - # Add key press handler for 'r' to rename - button.connect("key-press-event", self.on_slot_key_press, session_name, name_label, name_entry) - - # Store reference to entry and label in button for later access - button.name_entry = name_entry - button.name_label = name_label - button.session_name = session_name - - return button - - def on_session_click(self, button, event, session_name, label, entry): - """Handle clicks on session buttons""" - # Handle double-click to rename - if event.type == Gdk.EventType.DOUBLE_BUTTON_PRESS and event.button == 1: - self.start_rename(button, session_name, label, entry) - return True - # Handle right click for context menu - elif event.button == 3: - menu = Gtk.Menu() - - # Rename option - rename_item = Gtk.MenuItem(label="Rename") - rename_item.connect("activate", lambda _: self.start_rename(button, session_name, label, entry)) - menu.append(rename_item) - - # Kill option - kill_item = Gtk.MenuItem(label="Kill Session") - kill_item.connect("activate", lambda _: self.kill_session(session_name)) - menu.append(kill_item) - - menu.show_all() - menu.popup_at_pointer(event) - return True - - return False - - def start_rename(self, button, session_name, label, entry): - """Start inline renaming of a session""" - # Hide label, show entry - label.set_visible(False) - entry.set_visible(True) - - # Focus entry and select all text - entry.grab_focus() - entry.select_region(0, -1) - - # Mark button as being edited - button.get_style_context().add_class("editing") - - def finish_rename(self, button, old_name, entry): - """Finish renaming a session""" - new_name = entry.get_text().strip() - - # Only rename if the name changed and isn't empty - if new_name and new_name != old_name: - self.rename_session(old_name, new_name) - - # Reset UI state - self.cancel_rename(button) - - def cancel_rename(self, button): - """Cancel renaming operation""" - # Restore original view - button.name_entry.set_visible(False) - button.name_label.set_visible(True) - - # Remove editing style - button.get_style_context().remove_class("editing") - - # Return focus to session name entry - self.session_name_entry.grab_focus() - - def on_rename_key_press(self, entry, event): - """Handle key presses in the rename entry""" - if event.keyval == Gdk.KEY_Escape: - # Find the parent button - parent = entry.get_parent() - while parent and not isinstance(parent, Button): - parent = parent.get_parent() - - if parent: - self.cancel_rename(parent) - return True - - return False - - def on_session_right_click(self, button, event, session_name): - """Handle right-click on a session button to show context menu""" - if event.button == 3: # Right click - menu = Gtk.Menu() - - # Rename option - rename_item = Gtk.MenuItem(label="Rename") - rename_item.connect("activate", lambda _: self.start_rename( - button, - session_name, - button.name_label, - button.name_entry - )) - menu.append(rename_item) - - # Kill option - kill_item = Gtk.MenuItem(label="Kill Session") - kill_item.connect("activate", lambda _: self.kill_session(session_name)) - menu.append(kill_item) - - menu.show_all() - menu.popup_at_pointer(event) - return True - - return False - - def on_entry_key_press(self, widget, event): - """Handle key press events in the entry""" - if event.keyval == Gdk.KEY_Escape: - self.close_manager() - return True - - # Custom navigation with UP/DOWN keys removed - return False - - def scroll_to_selected(self, button): - """Scroll to ensure the selected button is visible""" - def scroll(): - adj = self.scrolled_window.get_vadjustment() - alloc = button.get_allocation() - if alloc.height == 0: - return False # Retry if allocation isn't ready - - y = alloc.y - height = alloc.height - page_size = adj.get_page_size() - current_value = adj.get_value() - - # Calculate visible boundaries - visible_top = current_value - visible_bottom = current_value + page_size - - if y < visible_top: - # Item above viewport - align to top - adj.set_value(y) - elif y + height > visible_bottom: - # Item below viewport - align to bottom - new_value = y + height - page_size - adj.set_value(new_value) - # No action if already fully visible - return False - GLib.idle_add(scroll) - - def create_session(self, session_name): - """Create a new tmux session""" - if not session_name: - # Get existing session names - existing_sessions = self.get_tmux_sessions() - - # Find the next available number - counter = 0 - while str(counter) in existing_sessions: - counter += 1 - - session_name = str(counter) - - try: - # Clean the session name (replace spaces with underscores) - clean_name = session_name.strip().replace(" ", "_") - - # Create session - subprocess.run( - ["tmux", "new-session", "-d", "-s", clean_name], - check=True - ) - - # Refresh the session list - self.refresh_sessions() - - # Clear entry - self.session_name_entry.set_text("") - - # Launch a terminal and attach to this session - terminal_cmd = self.get_terminal_command(f"tmux attach-session -t {clean_name}") - exec_shell_command_async(terminal_cmd) - - # Close manager - self.close_manager() - - except Exception as e: - print(f"Error creating tmux session: {e}") - - def attach_to_session(self, session_name): - """Attach to an existing tmux session""" - try: - # Launch a terminal and attach to this session - terminal_cmd = self.get_terminal_command(f"tmux attach-session -t {session_name}") - exec_shell_command_async(terminal_cmd) - self.close_manager() - except Exception as e: - print(f"Error attaching to tmux session: {e}") - - def get_terminal_command(self, cmd): - """Get terminal command based on configured terminal or available terminals""" - # First try to use the configured terminal command - if hasattr(data, 'TERMINAL_COMMAND') and data.TERMINAL_COMMAND: - parts = data.TERMINAL_COMMAND.split() - terminal = parts[0] - - try: - # Check if the configured terminal is available - subprocess.run(["which", terminal], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - return f"{data.TERMINAL_COMMAND} {cmd}" - except subprocess.CalledProcessError: - # If configured terminal is not available, fall back to defaults - pass - - # Fallback to checking available terminals - terminals = [ - ("kitty", f"kitty -e {cmd}"), - ("alacritty", f"alacritty -e {cmd}"), - ("foot", f"foot {cmd}"), - ("gnome-terminal", f"gnome-terminal -- {cmd}"), - ("konsole", f"konsole -e {cmd}"), - ("xfce4-terminal", f"xfce4-terminal -e '{cmd}'"), - ] - - for term, term_cmd in terminals: - try: - # Check if terminal is available - subprocess.run(["which", term], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - return term_cmd - except subprocess.CalledProcessError: - continue - - # Default fallback - return f"kitty -e {cmd}" - - def rename_session_dialog(self, old_name): - """Show dialog to rename a session""" - dialog = Gtk.Dialog( - title="Rename Session", - transient_for=None, - flags=0 - ) - - dialog.add_buttons( - Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, - Gtk.STOCK_OK, Gtk.ResponseType.OK - ) - - content_area = dialog.get_content_area() - entry = Gtk.Entry() - entry.set_text(old_name) - entry.set_activates_default(True) - content_area.add(entry) - - dialog.set_default_response(Gtk.ResponseType.OK) - dialog.show_all() - - response = dialog.run() - if response == Gtk.ResponseType.OK: - new_name = entry.get_text() - if new_name and new_name != old_name: - self.rename_session(old_name, new_name) - - dialog.destroy() - - def rename_session(self, old_name, new_name): - """Rename a tmux session""" - try: - # Clean the session name (replace spaces with underscores) - clean_name = new_name.strip().replace(" ", "_") - - # Rename session - subprocess.run( - ["tmux", "rename-session", "-t", old_name, clean_name], - check=True - ) - - # Refresh the session list - self.refresh_sessions() - - except Exception as e: - print(f"Error renaming tmux session: {e}") - - def kill_session(self, session_name): - """Kill a tmux session""" - try: - # Kill session - subprocess.run( - ["tmux", "kill-session", "-t", session_name], - check=True - ) - - # Refresh the session list - self.refresh_sessions() - - # Close the launcher after killing session - self.close_manager() - - except Exception as e: - print(f"Error killing tmux session: {e}") - - # Add new method to handle key presses on session slots - def on_slot_key_press(self, button, event, session_name, label, entry): - """Handle key presses on session buttons""" - # Print debugging info - print(f"Key pressed: {event.keyval}, State: {event.state}") - - # Check if 'r' key was pressed for renaming - if event.keyval == Gdk.KEY_r: - self.start_rename(button, session_name, label, entry) - return True - # Check for 'K' (capital K) which indicates Shift is pressed - elif event.keyval == Gdk.KEY_K: - print("Shift+K detected - killing session without confirmation") - self.kill_session(session_name) - return True - # Check for lowercase 'k' - elif event.keyval == Gdk.KEY_k: - print("Regular k detected - showing confirmation") - self.show_kill_confirmation_menu(button, session_name) - return True - # Check if Delete key was pressed for killing session - elif event.keyval == Gdk.KEY_Delete: - self.show_kill_confirmation_menu(button, session_name) - return True - return False - - def show_kill_confirmation_menu(self, button, session_name): - """Show a confirmation menu for killing a session""" - menu = Gtk.Menu() - - # Confirmation message as a disabled menu item - msg_item = Gtk.MenuItem(label=f"Kill session '{session_name}'?") - msg_item.set_sensitive(False) - menu.append(msg_item) - - # Separator - menu.append(Gtk.SeparatorMenuItem()) - - # Confirm option - confirm_item = Gtk.MenuItem(label="Confirm") - confirm_item.connect("activate", lambda _: self.kill_session(session_name)) - menu.append(confirm_item) - - # Cancel option - cancel_item = Gtk.MenuItem(label="Cancel") - # Close launcher on cancel - cancel_item.connect("activate", lambda _: self.close_manager()) - menu.append(cancel_item) - - menu.show_all() - - # Show the menu positioned at the button - menu.popup_at_widget( - button, - Gdk.Gravity.SOUTH_WEST, - Gdk.Gravity.NORTH_WEST, - None - ) \ No newline at end of file diff --git a/modules/launcher/components/tools.py b/modules/launcher/components/tools.py deleted file mode 100644 index d04c39d..0000000 --- a/modules/launcher/components/tools.py +++ /dev/null @@ -1,370 +0,0 @@ -from fabric.widgets.box import Box -from fabric.widgets.label import Label -from fabric.widgets.button import Button -from fabric.utils.helpers import exec_shell_command_async, get_relative_path -import utils.icons as icons -from gi.repository import Gdk, GLib -import os -import subprocess -import threading - - -CAPTURE_SCRIPT = get_relative_path("../../../config/scripts/capture.sh") -GAMEMODE_SCRIPT = get_relative_path("../../../config/scripts/gamemode.sh") - - -class Toolbox(Box): - def __init__(self, **kwargs): - super().__init__( - name="toolbox", - orientation="h", - spacing=4, - v_align="center", - h_align="center", - visible=True, - **kwargs, - ) - - self.launcher = kwargs["launcher"] - - self.btn_ssregion = Button( - name="toolbox-button", - child=Label(name="button-label", markup=icons.ssregion), - on_clicked=self.ssregion, - h_expand=False, - v_expand=False, - h_align="center", - v_align="center", - ) - # Enable keyboard focus and connect events - self.btn_ssregion.set_can_focus(True) - self.btn_ssregion.connect("button-press-event", self.on_ssregion_click) - self.btn_ssregion.connect("key-press-event", self.on_ssregion_key) - - self.btn_ssfull = Button( - name="toolbox-button", - child=Label(name="button-label", markup=icons.ssfull), - on_clicked=self.ssfull, - h_expand=False, - v_expand=False, - h_align="center", - v_align="center", - ) - # Enable keyboard focus and connect events - self.btn_ssfull.set_can_focus(True) - self.btn_ssfull.connect("button-press-event", self.on_ssfull_click) - self.btn_ssfull.connect("key-press-event", self.on_ssfull_key) - - self.btn_screenrecord = Button( - name="toolbox-button", - child=Label(name="button-label", markup=icons.screenrecord), - on_clicked=self.screenrecord, - h_expand=False, - v_expand=False, - h_align="center", - v_align="center", - ) - - self.btn_ocr = Button( - name="toolbox-button", - child=Label(name="button-label", markup=icons.ocr), - on_clicked=self.ocr, - h_expand=False, - v_expand=False, - h_align="center", - v_align="center", - ) - - self.btn_color = Button( - name="toolbox-button", - tooltip_text="Color Picker\nLeft Click: HEX\nMiddle Click: HSV\nRight Click: RGB\n\nKeyboard:\nEnter: HEX\nShift+Enter: RGB\nCtrl+Enter: HSV", - child=Label(name="button-bar-label", markup=icons.colorpicker), - h_expand=False, - v_expand=False, - h_align="center", - v_align="center", - ) - - self.btn_gamemode = Button( - name="toolbox-button", - child=Label(name="button-label", markup=icons.gamemode), - on_clicked=self.gamemode, - h_expand=False, - v_expand=False, - h_align="center", - v_align="center", - ) - - # Enable keyboard focus for the colorpicker button. - self.btn_color.set_can_focus(True) - # Connect both mouse and keyboard events. - self.btn_color.connect("button-press-event", self.colorpicker) - self.btn_color.connect("key_press_event", self.colorpicker_key) - - self.btn_emoji = Button( - name="toolbox-button", - child=Label(name="button-label", markup=icons.emoji), - on_clicked=self.emoji, - h_expand=False, - v_expand=False, - h_align="center", - v_align="center", - ) - - self.btn_screenshots_folder = Button( - name="toolbox-button", - child=Label(name="button-label", markup=icons.screenshots), - on_clicked=self.open_screenshots_folder, - h_expand=False, - v_expand=False, - h_align="center", - v_align="center", - ) - - self.btn_recordings_folder = Button( - name="toolbox-button", - child=Label(name="button-label", markup=icons.recordings), - on_clicked=self.open_recordings_folder, - h_expand=False, - v_expand=False, - h_align="center", - v_align="center", - ) - - self.buttons = [ - self.btn_ssregion, - self.btn_ssfull, - self.btn_screenshots_folder, - Box( - name="tool-sep", - h_expand=False, - v_expand=False, - h_align="center", - v_align="center", - ), - self.btn_screenrecord, - self.btn_recordings_folder, - Box( - name="tool-sep", - h_expand=False, - v_expand=False, - h_align="center", - v_align="center", - ), - self.btn_ocr, - self.btn_color, - Box( - name="tool-sep", - h_expand=False, - v_expand=False, - h_align="center", - v_align="center", - ), - self.btn_gamemode, - self.btn_emoji, - ] - - for button in self.buttons: - self.add(button) - - self.show_all() - - # Start polling for process state every second. - self.recorder_timer_id = GLib.timeout_add_seconds( - 1, self.update_screenrecord_state - ) - - def close_menu(self): - self.launcher.close() - - # Action methods - def ssfull(self, *args): - exec_shell_command_async(f"bash {CAPTURE_SCRIPT} screenshot p") - self.close_menu() - - def on_ssfull_click(self, button, event): - if event.type == Gdk.EventType.BUTTON_PRESS: - if event.button == 1: # Left click - self.ssfull() - elif event.button == 3: # Right click - exec_shell_command_async( - f"bash {CAPTURE_SCRIPT} screenshot p mockup") - self.close_menu() - return True - return False - - def on_ssfull_key(self, widget, event): - if event.keyval in {Gdk.KEY_Return, Gdk.KEY_KP_Enter}: - modifiers = event.get_state() - if modifiers & Gdk.ModifierType.SHIFT_MASK: - exec_shell_command_async( - f"bash {CAPTURE_SCRIPT} screenshot p mockup") - self.close_menu() - else: - self.ssfull() - return True - return False - - def ssregion(self, *args): - exec_shell_command_async(f"bash {CAPTURE_SCRIPT} screenshot sf") - self.close_menu() - - def on_ssregion_click(self, button, event): - if event.type == Gdk.EventType.BUTTON_PRESS: - if event.button == 1: # Left click - self.ssregion() - elif event.button == 3: # Right click - exec_shell_command_async( - f"bash {CAPTURE_SCRIPT} screenshot sf mockup") - self.close_menu() - return True - return False - - def on_ssregion_key(self, widget, event): - if event.keyval in {Gdk.KEY_Return, Gdk.KEY_KP_Enter}: - modifiers = event.get_state() - if modifiers & Gdk.ModifierType.SHIFT_MASK: - exec_shell_command_async( - f"bash {CAPTURE_SCRIPT} screenshot sf mockup") - self.close_menu() - else: - self.ssregion() - return True - return False - - def screenrecord(self, *args): - # Launch screenrecord script in detached mode so that it remains running independently of this program. - exec_shell_command_async( - f"bash -c 'nohup bash {CAPTURE_SCRIPT} record > /dev/null 2>&1 & disown'" - ) - self.close_menu() - - def ocr(self, *args): - exec_shell_command_async(f"bash {CAPTURE_SCRIPT} ocr sf") - self.close_menu() - - def gamemode(self, *args): - exec_shell_command_async(f"bash {GAMEMODE_SCRIPT}") - self.gamemode_check() - self.close_menu() - - def gamemode_check(self): - def check(): - try: - result = subprocess.run( - f"bash {GAMEMODE_SCRIPT} check", - shell=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - enabled = result.stdout == b"t\n" - except Exception: - enabled = False - - def update_ui(): - if enabled: - self.btn_gamemode.get_child().set_markup(icons.gamemode_off) - else: - self.btn_gamemode.get_child().set_markup(icons.gamemode) - return False - - GLib.idle_add(update_ui) - return False - - GLib.idle_add(lambda: threading.Thread(target=check).start()) - return True - - def ssregion(self, *args): - exec_shell_command_async(f"bash {CAPTURE_SCRIPT} screenshot sf") - self.close_menu() - - def colorpicker(self, button, event): - if event.type == Gdk.EventType.BUTTON_PRESS: - cmd = { - 1: "-hex", # Left click - 2: "-hsv", # Middle click - 3: "-rgb", # Right click - }.get(event.button) - - if cmd: - exec_shell_command_async( - f"bash { - get_relative_path( - '../../../config/scripts/hyprpicker.sh') - } {cmd}" - ) - self.close_menu() - - def colorpicker_key(self, widget, event): - if event.keyval in {Gdk.KEY_Return, Gdk.KEY_KP_Enter}: - modifiers = event.get_state() - cmd = "-hex" # Default - - match modifiers & ( - Gdk.ModifierType.SHIFT_MASK | Gdk.ModifierType.CONTROL_MASK - ): - case Gdk.ModifierType.SHIFT_MASK: - cmd = "-rgb" - case Gdk.ModifierType.CONTROL_MASK: - cmd = "-hsv" - - exec_shell_command_async( - f"bash {get_relative_path('../../../config/scripts/hyprpicker.sh')} { - cmd - }" - ) - self.close_menu() - return True - return False - - def update_screenrecord_state(self): - def check(): - try: - result = subprocess.run( - "pgrep -f gpu-screen-recorder", - shell=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - running = result.returncode == 0 - except Exception: - running = False - - def update_ui(): - if running: - self.btn_screenrecord.get_child().set_markup(icons.stop) - self.btn_screenrecord.add_style_class("recording") - else: - self.btn_screenrecord.get_child().set_markup(icons.screenrecord) - self.btn_screenrecord.remove_style_class("recording") - return False - - GLib.idle_add(update_ui) - return False - - GLib.idle_add(lambda: threading.Thread(target=check).start()) - return True - - def open_screenshots_folder(self, *args): - screenshots_dir = os.path.join( - os.environ.get("XDG_PICTURES_DIR", - os.path.expanduser("~/Pictures")), - "Screenshots", - ) - # Create directory if it doesn't exist - os.makedirs(screenshots_dir, exist_ok=True) - exec_shell_command_async(f"xdg-open {screenshots_dir}") - self.close_menu() - - def open_recordings_folder(self, *args): - recordings_dir = os.path.join( - os.environ.get("XDG_VIDEOS_DIR", os.path.expanduser("~/Videos")), - "Recordings", - ) - # Create directory if it doesn't exist - os.makedirs(recordings_dir, exist_ok=True) - exec_shell_command_async(f"xdg-open {recordings_dir}") - self.close_menu() - - def emoji(self, *args): - self.launcher.open("emoji") diff --git a/modules/launcher/components/wallpapers.py b/modules/launcher/components/wallpapers.py deleted file mode 100644 index 45c05b4..0000000 --- a/modules/launcher/components/wallpapers.py +++ /dev/null @@ -1,485 +0,0 @@ -import os -import hashlib -import shutil -import colorsys -from gi.repository import GdkPixbuf, Gtk, GLib, Gio, Gdk, Pango -from fabric.widgets.box import Box -from fabric.widgets.button import Button -from fabric.widgets.centerbox import CenterBox -from fabric.widgets.entry import Entry -from fabric.widgets.scrolledwindow import ScrolledWindow -from fabric.widgets.label import Label -from fabric.utils.helpers import exec_shell_command_async -import utils.icons as icons -import config.data as data -import config.config -from PIL import Image -import concurrent.futures -from concurrent.futures import ThreadPoolExecutor - -class WallpaperSelector(Box): - CACHE_DIR = f"{data.CACHE_DIR}/thumbnails" # Changed from wallpapers to thumbs - - def __init__(self, **kwargs): - - super().__init__(name="wallpapers", spacing=4, orientation="v", h_expand=False, v_expand=False, **kwargs) - os.makedirs(self.CACHE_DIR, exist_ok=True) - - # Process old wallpapers: use os.scandir for efficiency and only loop - # over image files that actually need renaming (they're not already lowercase - # and with hyphens instead of spaces) - with os.scandir(data.WALLPAPERS_DIR) as entries: - for entry in entries: - if entry.is_file() and self._is_image(entry.name): - # Check if the file needs renaming: file should be lowercase and have hyphens instead of spaces - if entry.name != entry.name.lower() or " " in entry.name: - new_name = entry.name.lower().replace(" ", "-") - full_path = os.path.join(data.WALLPAPERS_DIR, entry.name) - new_full_path = os.path.join(data.WALLPAPERS_DIR, new_name) - try: - os.rename(full_path, new_full_path) - print(f"Renamed old wallpaper '{full_path}' to '{new_full_path}'") - except Exception as e: - print(f"Error renaming file {full_path}: {e}") - - # Refresh the file list after potential renaming - self.files = sorted([f for f in os.listdir(data.WALLPAPERS_DIR) if self._is_image(f)]) - self.thumbnails = [] - self.thumbnail_queue = [] - self.executor = ThreadPoolExecutor(max_workers=4) # Shared executor - - # Variable to control the selection (similar to AppLauncher) - self.selected_index = -1 - - # Initialize UI components - self.viewport = Gtk.IconView(name="wallpaper-icons") - self.viewport.set_model(Gtk.ListStore(GdkPixbuf.Pixbuf, str)) - self.viewport.set_pixbuf_column(0) - # Hide text column so only the image is shown - self.viewport.set_text_column(-1) - self.viewport.set_item_width(0) - self.viewport.connect("item-activated", self.on_wallpaper_selected) - # self.viewport.connect("selection-changed", self._on_selection_changed) # Removed connection - - self.scrolled_window = ScrolledWindow( - name="scrolled-window", - spacing=10, - h_expand=True, - v_expand=True, - child=self.viewport, - ) - - self.search_entry = Entry( - name="search-entry-walls", - placeholder="Search Wallpapers...", - h_expand=True, - notify_text=lambda entry, *_: self.arrange_viewport(entry.get_text()), - on_key_press_event=self.on_search_entry_key_press, - ) - self.search_entry.props.xalign = 0.5 - self.search_entry.connect("focus-out-event", self.on_search_entry_focus_out) - - self.schemes = { - "scheme-tonal-spot": "Tonal Spot", - "scheme-content": "Content", - "scheme-expressive": "Expressive", - "scheme-fidelity": "Fidelity", - "scheme-fruit-salad": "Fruit Salad", - "scheme-monochrome": "Monochrome", - "scheme-neutral": "Neutral", - "scheme-rainbow": "Rainbow", - } - - self.scheme_dropdown = Gtk.ComboBoxText() - self.scheme_dropdown.set_name("scheme-dropdown") - self.scheme_dropdown.set_tooltip_text("Select color scheme") - for key, display_name in self.schemes.items(): - self.scheme_dropdown.append(key, display_name) - self.scheme_dropdown.set_active_id("scheme-tonal-spot") - self.scheme_dropdown.connect("changed", self.on_scheme_changed) - - # Load matugen state from the dedicated file - self.matugen_enabled = True # Default to True - try: - with open(data.MATUGEN_STATE_FILE, 'r') as f: - content = f.read().strip().lower() - if content == "false": - self.matugen_enabled = False - elif content == "true": - self.matugen_enabled = True - # Any other content defaults to True - except FileNotFoundError: - # File doesn't exist, keep default True and create it on first toggle - pass - except Exception as e: - print(f"Error reading matugen state file: {e}") - # Keep default True on error - - # Create a switcher to enable/disable Matugen (enabled by default) - self.matugen_switcher = Gtk.Switch(name="matugen-switcher") - self.matugen_switcher.set_vexpand(False) - self.matugen_switcher.set_hexpand(False) - self.matugen_switcher.set_valign(Gtk.Align.CENTER) - self.matugen_switcher.set_halign(Gtk.Align.CENTER) - self.matugen_switcher.set_active(self.matugen_enabled) - self.matugen_switcher.connect("notify::active", self.on_switch_toggled) - - self.mat_icon = Label(name="mat-label", markup=icons.palette) - - # Add the switcher to the header_box's start_children - self.header_box = CenterBox( - name="header-box", - spacing=8, - orientation="h", - # Removed color button and label from here - start_children=[self.matugen_switcher, self.mat_icon], - center_children=[self.search_entry], - end_children=[self.scheme_dropdown], - ) - - self.add(self.header_box) - - # Create the custom color selector components - self.hue_slider = Gtk.Scale( - orientation=Gtk.Orientation.HORIZONTAL, # Changed from VERTICAL - adjustment=Gtk.Adjustment(value=0, lower=0, upper=360, step_increment=1, page_increment=10), - draw_value=False, # Hide the default value text - digits=0, - # inverted=True, # Removed inverted for horizontal - name="hue-slider", # For CSS styling - ) - - # Changed expand/align for horizontal orientation - self.hue_slider.set_hexpand(True) - self.hue_slider.set_halign(Gtk.Align.FILL) - self.hue_slider.set_vexpand(False) # Ensure it doesn't expand vertically - self.hue_slider.set_valign(Gtk.Align.CENTER) # Center vertically within its box - - self.apply_color_button = Button(name="apply-color-button", child=Label(name="apply-color-label", markup=icons.accept)) - self.apply_color_button.connect("clicked", self.on_apply_color_clicked) - self.apply_color_button.set_vexpand(False) # Ensure button doesn't expand vertically - self.apply_color_button.set_valign(Gtk.Align.CENTER) # Center button vertically - - self.custom_color_selector_box = Box( - orientation="h", spacing=5, name="custom-color-selector-box", # Changed orientation to horizontal - h_align="center" # Center the horizontal box - ) - self.custom_color_selector_box.add(self.hue_slider) - self.custom_color_selector_box.add(self.apply_color_button) - self.custom_color_selector_box.set_halign(Gtk.Align.FILL) - - # Add the scrolled window (grid) and the custom color selector box directly - # to the main WallpaperSelector box (which is already vertical) - self.pack_start(self.scrolled_window, True, True, 0) # Add grid, expand - self.pack_start(self.custom_color_selector_box, False, False, 0) # Add custom selector, don't expand - - # Removed the old main_content_box and its add - - self._start_thumbnail_thread() - self.connect("map", self.on_map) # Connect the map signal - # Set initial sensitivity based on loaded state - # self.scheme_dropdown.set_sensitive(self.matugen_enabled) # Ensure sensitivity is set correctly on load - self.setup_file_monitor() # Initialize file monitoring - self.show_all() - # Ensure the search entry gets focus when starting - self.search_entry.grab_focus() - - def setup_file_monitor(self): - gfile = Gio.File.new_for_path(data.WALLPAPERS_DIR) - self.file_monitor = gfile.monitor_directory(Gio.FileMonitorFlags.NONE, None) - self.file_monitor.connect("changed", self.on_directory_changed) - - def on_directory_changed(self, monitor, file, other_file, event_type): - file_name = file.get_basename() - if event_type == Gio.FileMonitorEvent.DELETED: - if file_name in self.files: - self.files.remove(file_name) - cache_path = self._get_cache_path(file_name) - if os.path.exists(cache_path): - try: - os.remove(cache_path) - except Exception as e: - print(f"Error deleting cache {cache_path}: {e}") - self.thumbnails = [(p, n) for p, n in self.thumbnails if n != file_name] - GLib.idle_add(self.arrange_viewport, self.search_entry.get_text()) - elif event_type == Gio.FileMonitorEvent.CREATED: - if self._is_image(file_name): - # Convert filename to lowercase and replace spaces with "-" - new_name = file_name.lower().replace(" ", "-") - full_path = os.path.join(data.WALLPAPERS_DIR, file_name) - new_full_path = os.path.join(data.WALLPAPERS_DIR, new_name) - if new_name != file_name: - try: - os.rename(full_path, new_full_path) - file_name = new_name - print(f"Renamed file '{full_path}' to '{new_full_path}')") - except Exception as e: - print(f"Error renaming file {full_path}: {e}") - if file_name not in self.files: - self.files.append(file_name) - self.files.sort() - self.executor.submit(self._process_file, file_name) - elif event_type == Gio.FileMonitorEvent.CHANGED: - if self._is_image(file_name) and file_name in self.files: - cache_path = self._get_cache_path(file_name) - if os.path.exists(cache_path): - try: - os.remove(cache_path) - except Exception as e: - print(f"Error deleting cache for changed file {file_name}: {e}") - self.executor.submit(self._process_file, file_name) - - def arrange_viewport(self, query: str = ""): - model = self.viewport.get_model() - model.clear() - filtered_thumbnails = [ - (thumb, name) - for thumb, name in self.thumbnails - if query.casefold() in name.casefold() - ] - filtered_thumbnails.sort(key=lambda x: x[1].lower()) - for pixbuf, file_name in filtered_thumbnails: - model.append([pixbuf, file_name]) - # If the search entry is empty, no icon is selected; otherwise, select the first one. - if query.strip() == "": - self.viewport.unselect_all() - self.selected_index = -1 - elif len(model) > 0: - self.update_selection(0) - - def on_wallpaper_selected(self, iconview, path): - model = iconview.get_model() - file_name = model[path][1] - full_path = os.path.join(data.WALLPAPERS_DIR, file_name) - selected_scheme = self.scheme_dropdown.get_active_id() - current_wall = os.path.expanduser(f"~/.current.wall") - if os.path.isfile(current_wall): - os.remove(current_wall) - os.symlink(full_path, current_wall) - if self.matugen_switcher.get_active(): - # Matugen is enabled: run the normal command. - exec_shell_command_async(f'matugen image {full_path} -t {selected_scheme}') - else: - # Matugen is disabled: run the alternative swww command. - exec_shell_command_async( - f'swww img {full_path} -t outer --transition-duration 1.5 --transition-step 255 --transition-fps 60 -f Nearest' - ) - - def on_scheme_changed(self, combo): - selected_scheme = combo.get_active_id() - print(f"Color scheme selected: {selected_scheme}") - - def on_search_entry_key_press(self, widget, event): - if event.state & Gdk.ModifierType.SHIFT_MASK: - if event.keyval in (Gdk.KEY_Up, Gdk.KEY_Down): - schemes_list = list(self.schemes.keys()) - current_id = self.scheme_dropdown.get_active_id() - current_index = schemes_list.index(current_id) if current_id in schemes_list else 0 - new_index = (current_index - 1) % len(schemes_list) if event.keyval == Gdk.KEY_Up else (current_index + 1) % len(schemes_list) - self.scheme_dropdown.set_active(new_index) - return True - elif event.keyval == Gdk.KEY_Right: - self.scheme_dropdown.popup() - return True - - if event.keyval in (Gdk.KEY_Up, Gdk.KEY_Down, Gdk.KEY_Left, Gdk.KEY_Right): - self.move_selection_2d(event.keyval) - return True - elif event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter): - if self.selected_index != -1: - path = Gtk.TreePath.new_from_indices([self.selected_index]) - self.on_wallpaper_selected(self.viewport, path) - return True - return False - - # Removed _on_selection_changed method - - def move_selection_2d(self, keyval): - model = self.viewport.get_model() - total_items = len(model) - if total_items == 0: - return - - # --- Determine Column Count --- - columns = self.viewport.get_columns() - - # If get_columns returns 0 or -1 (auto), try to estimate by checking item rows - if columns <= 0 and total_items > 0: - estimated_cols = 0 - try: - # Check the row of the first item (should be 0) - first_item_path = Gtk.TreePath.new_from_indices([0]) - base_row = self.viewport.get_item_row(first_item_path) - - # Find the index of the first item in the *next* row - for i in range(1, total_items): - path = Gtk.TreePath.new_from_indices([i]) - row = self.viewport.get_item_row(path) - if row > base_row: - estimated_cols = i # The number of items in the first row - break - - # If loop finished without finding a new row, all items are in one row - if estimated_cols == 0: - estimated_cols = total_items - - columns = max(1, estimated_cols) - except Exception: - # Fallback if get_item_row fails (e.g., widget not realized) - columns = 1 - elif columns <= 0 and total_items == 0: - columns = 1 # Should not happen due to early return, but safe - - # Ensure columns is at least 1 after all checks - columns = max(1, columns) - - # --- Navigation Logic --- - current_index = self.selected_index - new_index = current_index - - if current_index == -1: - # If nothing is selected, select the first or last item based on direction - if keyval in (Gdk.KEY_Down, Gdk.KEY_Right): - new_index = 0 - elif keyval in (Gdk.KEY_Up, Gdk.KEY_Left): - new_index = total_items - 1 - if total_items == 0: new_index = -1 # Handle edge case - - else: - # Calculate potential new index based on key press - if keyval == Gdk.KEY_Up: - potential_new_index = current_index - columns - # Only update if the new index is valid (>= 0) - if potential_new_index >= 0: - new_index = potential_new_index - elif keyval == Gdk.KEY_Down: - potential_new_index = current_index + columns - # Only update if the new index is valid (< total_items) - if potential_new_index < total_items: - new_index = potential_new_index - elif keyval == Gdk.KEY_Left: - # Only update if not already in the first column (index % columns != 0) - # and the index is greater than 0 - if current_index > 0 and current_index % columns != 0: - new_index = current_index - 1 - elif keyval == Gdk.KEY_Right: - # Only update if not in the last column ((index + 1) % columns != 0) - # and not the very last item (index < total_items - 1) - if current_index < total_items - 1 and (current_index + 1) % columns != 0: - new_index = current_index + 1 - - # Only update if the index actually changed and is valid - if new_index != self.selected_index and 0 <= new_index < total_items: - self.update_selection(new_index) - elif total_items > 0 and self.selected_index == -1 and 0 <= new_index < total_items: - # Handle selecting the first item when starting from -1 - self.update_selection(new_index) - - def update_selection(self, new_index: int): - self.viewport.unselect_all() - path = Gtk.TreePath.new_from_indices([new_index]) - self.viewport.select_path(path) - self.viewport.scroll_to_path(path, False, 0.5, 0.5) # Ensure the selected icon is visible - self.selected_index = new_index - - def _start_thumbnail_thread(self): - thread = GLib.Thread.new("thumbnail-loader", self._preload_thumbnails, None) - - def _preload_thumbnails(self, _data): - futures = [self.executor.submit(self._process_file, file_name) for file_name in self.files] - concurrent.futures.wait(futures) - GLib.idle_add(self._process_batch) - - def _process_file(self, file_name): - full_path = os.path.join(data.WALLPAPERS_DIR, file_name) - cache_path = self._get_cache_path(file_name) - if not os.path.exists(cache_path): - try: - with Image.open(full_path) as img: - width, height = img.size - side = min(width, height) - left = (img.width - side) // 2 - top = (height - side) // 2 - right = left + side - bottom = top + side - img_cropped = img.crop((left, top, right, bottom)) - img_cropped.thumbnail((96, 96), Image.Resampling.LANCZOS) - img_cropped.save(cache_path, "PNG") - except Exception as e: - print(f"Error processing {file_name}: {e}") - return - self.thumbnail_queue.append((cache_path, file_name)) - GLib.idle_add(self._process_batch) - - def _process_batch(self): - batch = self.thumbnail_queue[:10] - del self.thumbnail_queue[:10] - for cache_path, file_name in batch: - try: - pixbuf = GdkPixbuf.Pixbuf.new_from_file(cache_path) - self.thumbnails.append((pixbuf, file_name)) - self.viewport.get_model().append([pixbuf, file_name]) - except Exception as e: - print(f"Error loading thumbnail {cache_path}: {e}") - if self.thumbnail_queue: - GLib.idle_add(self._process_batch) - return False - - def _get_cache_path(self, file_name: str) -> str: - file_hash = hashlib.md5(file_name.encode("utf-8")).hexdigest() - return os.path.join(self.CACHE_DIR, f"{file_hash}.png") - - @staticmethod - def _is_image(file_name: str) -> bool: - return file_name.lower().endswith(('.png', '.jpg', '.jpeg', '.bmp', '.gif', '.webp')) - - def on_search_entry_focus_out(self, widget, event): - if self.get_mapped(): - widget.grab_focus() - return False - - def on_map(self, widget): - """Handles the map signal to set initial visibility of the color selector.""" - # Set visibility based on the loaded state when the widget becomes visible - self.custom_color_selector_box.set_visible(not self.matugen_enabled) - - def hsl_to_rgb_hex(self, h: float, s: float = 1.0, l: float = 0.5) -> str: - """Converts HSL color value to RGB HEX string.""" - # colorsys uses HLS, not HSL, and expects values between 0.0 and 1.0 - hue = h / 360.0 - r, g, b = colorsys.hls_to_rgb(hue, l, s) # Note the order: H, L, S - r_int, g_int, b_int = int(r * 255), int(g * 255), int(b * 255) - return f"#{r_int:02X}{g_int:02X}{b_int:02X}" - - def rgba_to_hex(self, rgba: Gdk.RGBA) -> str: - """Converts Gdk.RGBA to a HEX color string.""" - r = int(rgba.red * 255) - g = int(rgba.green * 255) - b = int(rgba.blue * 255) - return f"#{r:02X}{g:02X}{b:02X}" - - def on_switch_toggled(self, switch, gparam): - """Handles the toggling of the Matugen switch.""" - is_active = switch.get_active() - self.matugen_enabled = is_active - # self.scheme_dropdown.set_sensitive(is_active) - self.custom_color_selector_box.set_visible(not is_active) # Toggle visibility - - # Save the state to the dedicated file - try: - with open(data.MATUGEN_STATE_FILE, 'w') as f: - f.write(str(is_active)) - except Exception as e: - print(f"Error writing matugen state file: {e}") - - def on_apply_color_clicked(self, button): - """Applies the color selected by the hue slider via matugen.""" - hue_value = self.hue_slider.get_value() # Get value from 0-360 - hex_color = self.hsl_to_rgb_hex(hue_value) # Convert HSL(hue, 1.0, 0.5) to HEX - print(f"Applying color from slider: H={hue_value}, HEX={hex_color}") - selected_scheme = self.scheme_dropdown.get_active_id() - # Run matugen with the chosen hex color and selected scheme - exec_shell_command_async(f'matugen color hex "{hex_color}" -t {selected_scheme}') - # Optionally save the chosen color to config if needed later - # config.config.bind_vars["matugen_hex_color"] = hex_color - # config.config.save_config() # Removed as save_config doesn't exist diff --git a/modules/launcher/components/wifi.py b/modules/launcher/components/wifi.py deleted file mode 100644 index 94e52d6..0000000 --- a/modules/launcher/components/wifi.py +++ /dev/null @@ -1,153 +0,0 @@ -from fabric.widgets.box import Box -from fabric.widgets.label import Label -from fabric.widgets.image import Image -from fabric.widgets.button import Button -from fabric.widgets.centerbox import CenterBox -from fabric.widgets.scrolledwindow import ScrolledWindow -from services import network_client -import utils.icons as icons - - -class WifiNetworkSlot(CenterBox): - def __init__(self, network, client: network_client, **kwargs): - super().__init__(name="wifi-device", **kwargs) - self.network = network - self.client = client - self.connecting = False # Tracks if we are connecting/disconnecting - - self.connection_icon = Label(name="wifi-connection", markup=icons.wifi_off) - self.connect_button = Button( - name="wifi-connect", - label="Connect", - on_clicked=self.on_connect_clicked, - ) - - self.start_children = [ - Box( - spacing=8, - children=[ - Image(icon_name=network.get("icon-name"), size=32), - Label(label=network.get("ssid")), - self.connection_icon, - ], - ) - ] - self.end_children = self.connect_button - - self.visible = True - self.show_all() - - # Listen to WiFi state changes - self.client.wifi_device.connect("changed", lambda *_: self.update_status()) - - self.update_status() - - def on_connect_clicked(self, *_): - connected = self.network.get("ssid") == self.client.wifi_device.ssid - if connected: - self.connect_button.set_label("Disconnecting...") - self.client.wifi_device.enabled = False # Disables WiFi to disconnect - else: - self.connect_button.set_label("Connecting...") - self.client.connect_wifi_bssid(self.network.get("bssid")) - - self.connecting = True - self.connect_button.sensitive = False - - def update_status(self): - state = self.client.wifi_device.state - connected = self.network.get("ssid") == self.client.wifi_device.ssid - - if self.connecting: - if state in ["activating"]: - self.connect_button.set_label("Connecting...") - elif state in ["deactivating"]: - self.connect_button.set_label("Disconnecting...") - else: - self.connecting = False - self.connect_button.sensitive = True - - if not self.connecting: - self.connection_icon.set_markup(icons.wifi if connected else icons.wifi_off) - self.connect_button.set_label("Disconnect" if connected else "Connect") - - -class WifiManager(Box): - def __init__(self, **kwargs): - super().__init__( - name="wifi", - spacing=8, - orientation="vertical", - **kwargs, - ) - - self.client = network_client - self.scan_icon = Label(name="wifi-scan-icon", markup=icons.scan) - self.toggle_icon = Label(name="wifi-toggle-icon", markup=icons.wifi_off) - - self.scan_button = Button( - name="wifi-scan", - child=self.scan_icon, - tooltip_text="Scan", - on_clicked=lambda *_: self.client.wifi_device - and self.client.wifi_device.scan(), - ) - - self.toggle_button = Button( - name="wifi-toggle", - tooltip_text="Toggle WiFi", - child=self.toggle_icon, - on_clicked=lambda *_: self.client.wifi_device - and self.client.wifi_device.toggle_wifi(), - ) - - self.available_box = Box(spacing=2, orientation="vertical") - - self.children = [ - CenterBox( - name="wifi-header", - start_children=self.scan_button, - center_children=Label(name="wifi-text", label="WiFi Networks"), - end_children=self.toggle_button, - ), - ScrolledWindow( - name="wifi-available", - v_expand=True, - h_scrollbar_policy="never", - min_content_size=(-1, -1), - child=self.available_box, - ), - ] - - self.client.connect("device-ready", lambda *_: self.on_device_ready()) - - def on_device_ready(self): - if not self.client.wifi_device: - return - - self.update_toggle_icon() - - self.client.wifi_device.connect( - "notify::enabled", - lambda *_: self.update_toggle_icon(), - ) - - self.build_wifi_options() - - def update_toggle_icon(self): - self.toggle_icon.set_markup( - icons.wifi if self.client.wifi_device.enabled else icons.wifi_off - ) - - def build_wifi_options(self): - self.available_box.children = [] - if not self.client.wifi_device: - return - - aps = self.client.wifi_device.access_points - seen_ssids = set() - for ap in aps: - ssid = ap.get("ssid") - if ap.get("ssid") != "Unknown" and ssid not in seen_ssids: - seen_ssids.add(ssid) - self.available_box.add(WifiNetworkSlot(ap, self.client)) diff --git a/modules/launcher/components/window_switcher.py b/modules/launcher/components/window_switcher.py deleted file mode 100644 index c2ed906..0000000 --- a/modules/launcher/components/window_switcher.py +++ /dev/null @@ -1,312 +0,0 @@ -import subprocess -import json -from fabric.widgets.box import Box -from fabric.widgets.label import Label -from fabric.widgets.button import Button -from fabric.widgets.entry import Entry -from fabric.widgets.scrolledwindow import ScrolledWindow -from fabric.widgets.image import Image -from fabric.utils import idle_add, remove_handler, exec_shell_command_async -from utils import IconResolver -from gi.repository import GLib, Gdk, Gtk -import utils.icons as icons -import config.data as data - -class WindowSwitcher(Box): - def __init__(self, **kwargs): - super().__init__( - name="window-switcher", - visible=False, - all_visible=False, - **kwargs, - ) - - self.launcher = kwargs["launcher"] - self.selected_index = -1 # Track the selected item index - self.icon_resolver = IconResolver() - - self.viewport = Box(name="viewport", spacing=4, orientation="v") - self.search_entry = Entry( - name="search-entry", - placeholder="Search Windows...", - h_expand=True, - notify_text=lambda entry, *_: self.handle_search(entry.get_text()), - on_activate=lambda entry, *_: self.handle_search(entry.get_text()), - on_key_press_event=self.on_entry_key_press, - ) - self.search_entry.props.xalign = 0.5 - self.scrolled_window = ScrolledWindow( - name="scrolled-window", - spacing=10, - min_content_size=(450, 305), - max_content_size=(450, 305), - child=self.viewport, - ) - - self.header_box = Box( - name="header_box", - spacing=10, - orientation="h", - children=[ - self.search_entry, - ], - ) - - self.window_switcher_box = Box( - name="window-switcher-box", - spacing=10, - h_expand=True, - orientation="v", - children=[ - self.header_box, - self.scrolled_window, - ], - ) - - self.add(self.window_switcher_box) - self.show_all() - - def close_switcher(self): - """Close the window switcher""" - self.viewport.hide() - self.selected_index = -1 # Reset selection - self.launcher.close() - - def open_switcher(self): - """Open the window switcher and refresh windows""" - self.refresh_windows() - self.search_entry.set_text("") - self.search_entry.grab_focus() - self.viewport.show() - - def refresh_windows(self): - """Get Hyprland windows and populate the viewport""" - remove_handler(self._arranger_handler) if hasattr(self, '_arranger_handler') else None - self.viewport.children = [] - self.selected_index = -1 # Clear selection when viewport changes - - # Get Hyprland windows - windows = self.get_hyprland_windows() - if not windows: - # Create a container box to better center the message - container = Box( - name="no-windows-container", - orientation="v", - h_align="center", - v_align="center", - h_expand=True, - v_expand=True - ) - - # Show a message if no windows - label = Label( - name="no-windows", - markup=icons.window, - h_align="center", - v_align="center", - ) - - container.add(label) - - self.viewport.add(container) - return - - # Add window slots to viewport - for window in windows: - self.viewport.add(self.create_window_slot(window)) - - def get_hyprland_windows(self): - """Get list of windows from Hyprland""" - try: - # Get clients info from Hyprland - result = subprocess.run( - ["hyprctl", "clients", "-j"], - capture_output=True, - text=True, - check=True - ) - clients = json.loads(result.stdout) - - # Filter out special windows and sort by workspace - windows = [] - for client in clients: - if client.get("class") and not client.get("class").startswith("special"): - windows.append(client) - - # Sort windows by workspace number - windows.sort(key=lambda x: int(x.get("workspace", {}).get("id", 0))) - return windows - - except Exception as e: - print(f"Error getting Hyprland windows: {e}") - return [] - - def create_window_slot(self, window): - """Create a button slot for a window""" - # Get window title or class as fallback - title = window.get("title", window.get("class", "Unknown")) - workspace = window.get("workspace", {}).get("id", 0) - window_class = window.get("class", "") - - # Get window icon using IconResolver - icon = self.get_window_icon(window_class) - - # Create the button with window info - button = Button( - name="slot-button", - child=Box( - name="window-slot-box", - orientation="h", - spacing=10, - children=[ - Image( - name="window-icon", - pixbuf=icon, - h_align="start", - ), - Label( - name="workspace-label", - label=f"Workspace {workspace}", - h_align="start", - ), - Label( - name="window-title", - label=title, - ellipsization="end", - v_align="center", - h_align="center", - ), - ], - ), - on_clicked=lambda *_: self.focus_window(window.get("address")), - on_key_press_event=lambda btn, event, addr=window.get("address"): self.on_slot_key_press(btn, event, addr), - ) - return button - - def get_window_icon(self, window_class): - """Get the appropriate icon for a window class using IconResolver""" - try: - # Try to get icon from IconResolver - icon = self.icon_resolver.get_icon_pixbuf(window_class, size=24) - if icon: - return icon - except Exception as e: - print(f"Error resolving icon for {window_class}: {e}") - - # Fallback to default window icon if resolution fails - return icons.window - - def focus_window(self, address): - """Focus a window by its address""" - try: - subprocess.run( - ["hyprctl", "dispatch", "focuswindow", f"address:{address}"], - check=True - ) - self.close_switcher() - except Exception as e: - print(f"Error focusing window: {e}") - - def handle_search(self, query): - """Handle search input""" - if not query: - self.refresh_windows() - return - - # Filter windows based on search query - windows = self.get_hyprland_windows() - filtered_windows = [ - window for window in windows - if query.lower() in window.get("title", "").lower() or - query.lower() in window.get("class", "").lower() - ] - - # Update viewport with filtered results - self.viewport.children = [] - self.selected_index = -1 - - for window in filtered_windows: - self.viewport.add(self.create_window_slot(window)) - - def on_entry_key_press(self, widget, event): - """Handle key presses on the search entry""" - if event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter): - if self.selected_index != -1: - children = self.viewport.get_children() - if 0 <= self.selected_index < len(children): - children[self.selected_index].clicked() - return True - elif event.keyval == Gdk.KEY_Down: - self.move_selection(1) - return True - elif event.keyval == Gdk.KEY_Up: - self.move_selection(-1) - return True - elif event.keyval == Gdk.KEY_Escape: - self.close_switcher() - return True - return False - - def on_slot_key_press(self, button, event, address): - """Handle key presses on window slots""" - if event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter): - self.focus_window(address) - return True - elif event.keyval == Gdk.KEY_Escape: - self.close_switcher() - return True - return False - - def move_selection(self, direction): - """Move the selection up or down""" - children = self.viewport.get_children() - if not children: - return - - new_index = self.selected_index + direction - if 0 <= new_index < len(children): - self.update_selection(new_index) - - def update_selection(self, new_index): - """Update the selected item""" - # Unselect current - if self.selected_index != -1 and self.selected_index < len(self.viewport.get_children()): - current_button = self.viewport.get_children()[self.selected_index] - current_button.get_style_context().remove_class("selected") - - # Select new - if new_index != -1 and new_index < len(self.viewport.get_children()): - new_button = self.viewport.get_children()[new_index] - new_button.get_style_context().add_class("selected") - self.selected_index = new_index - self.scroll_to_selected(new_button) - else: - self.selected_index = -1 - - def scroll_to_selected(self, button): - """Scroll to ensure the selected button is visible""" - def scroll(): - adj = self.scrolled_window.get_vadjustment() - alloc = button.get_allocation() - if alloc.height == 0: - return False # Retry if allocation isn't ready - - y = alloc.y - height = alloc.height - page_size = adj.get_page_size() - current_value = adj.get_value() - - # Calculate visible boundaries - visible_top = current_value - visible_bottom = current_value + page_size - - if y < visible_top: - # Item above viewport - align to top - adj.set_value(y) - elif y + height > visible_bottom: - # Item below viewport - align to bottom - new_value = y + height - page_size - adj.set_value(new_value) - # No action if already fully visible - return False - GLib.idle_add(scroll) \ No newline at end of file diff --git a/modules/launcher/launcher.py b/modules/launcher/launcher.py deleted file mode 100644 index 4704e75..0000000 --- a/modules/launcher/launcher.py +++ /dev/null @@ -1,201 +0,0 @@ -from fabric.widgets.centerbox import CenterBox -from fabric.widgets.stack import Stack -from fabric.widgets.wayland import WaylandWindow as Window -from fabric.widgets.box import Box -from modules.launcher.components import ( - AppLauncher, - BluetoothConnections, - EmojiPicker, - PowerMenu, - Sh, - WallpaperSelector, - WifiManager, - Calendar, - Dashboard, - Toolbox, - Kanban, - TmuxManager, - WindowSwitcher, - NotificationCenter, - ClipHistory, -) - - -class Launcher(Window): - def __init__(self, **kwargs): - super().__init__( - layer="top", - anchor="center", - keyboard_mode="none", - exclusivity="normal", - visible=False, - all_visible=False, - **kwargs, - ) - - self.dashboard = Dashboard(launcher=self) - self.wallpapers = WallpaperSelector(launcher=self) - self.power = PowerMenu(launcher=self) - self.emoji = EmojiPicker(launcher=self) - self.bluetooth = BluetoothConnections(launcher=self) - self.sh = Sh(launcher=self) - self.wifi = WifiManager() - self.calendar = Calendar() - self.tools = Toolbox(launcher=self) - self.kanban = Kanban() - self.tmux = TmuxManager(launcher=self) - self.window_switcher = WindowSwitcher(launcher=self) - self.notification_center = NotificationCenter(launcher=self) - self.cliphist = ClipHistory(launcher=self) - - # Wrap the dashboard in a Box container - self.dashboard = Box( - name="dashboard", - orientation="h", - spacing=10, - children=[self.dashboard], - ) - self.launcher = AppLauncher(launcher=self) - - self.stack = Stack( - name="launcher-content", - v_expand=True, - h_expand=True, - transition_type="crossfade", - transition_duration=100, - children=[ - self.launcher, - self.wallpapers, - self.power, - self.emoji, - self.cliphist, - self.bluetooth, - self.sh, - self.wifi, - self.calendar, - self.tools, - self.kanban, - self.tmux, - self.window_switcher, - self.notification_center, - ], - ) - - self.launcher_box = CenterBox( - name="launcher", - orientation="v", - start_children=self.stack, - end_children=self.dashboard, - ) - - self.add(self.launcher_box) - self.show_all() - self.hide() - self.add_keybinding("Escape", lambda *_: self.close()) - - def close(self): - self.set_keyboard_mode("none") - self.hide() - - for widget in [ - self.launcher, - self.wallpapers, - self.power, - self.emoji, - self.cliphist, - self.bluetooth, - self.sh, - self.wifi, - self.calendar, - self.tools, - self.kanban, - self.tmux, - self.window_switcher, - self.notification_center, - ]: - if hasattr(widget, "viewport") and widget.viewport: - widget.viewport.hide() - - for style in [ - "launcher", - "wallpapers", - "power", - "emoji", - "cliphist", - "bluetooth", - "sh", - "wifi", - "calendar", - "tools", - "kanban", - "tmux", - "window-switcher", - "notification-center", - ]: - self.stack.remove_style_class(style) - - return True - - def open(self, widget): - widgets = { - "launcher": self.launcher, - "wallpapers": self.wallpapers, - "power": self.power, - "emoji": self.emoji, - "cliphist": self.cliphist, - "bluetooth": self.bluetooth, - "sh": self.sh, - "wifi": self.wifi, - "calendar": self.calendar, - "tools": self.tools, - "kanban": self.kanban, - "tmux": self.tmux, - "window-switcher": self.window_switcher, - "notification-center": self.notification_center, - } - self.set_keyboard_mode("exclusive") - self.show() - - for w in widgets.values(): - w.hide() - self.dashboard.hide() - for style in widgets.keys(): - self.stack.remove_style_class(style) - - if widget in widgets: - self.stack.get_style_context().add_class(widget) - self.stack.set_visible_child(widgets[widget]) - widgets[widget].show() - - if widget == "launcher": - self.launcher.open_launcher() - self.launcher.search_entry.set_text("") - self.launcher.search_entry.grab_focus() - self.dashboard.show() - - elif widget == "wallpapers": - self.wallpapers.search_entry.set_text("") - self.wallpapers.search_entry.grab_focus() - self.wallpapers.viewport.show() - - elif widget == "tmux": - self.tmux.open_manager() - self.tmux.search_entry.set_text("") - self.tmux.search_entry.grab_focus() - - elif widget == "emoji": - self.emoji.open_picker() - self.emoji.search_entry.set_text("") - self.emoji.search_entry.grab_focus() - - elif widget == "cliphist": - self.cliphist.open() - - elif widget == "sh": - self.sh.open_sh() - - elif widget == "window-switcher": - self.window_switcher.open_switcher() - - elif widget == "notification-center": - self.notification_center.open_center() diff --git a/modules/launcher/main.py b/modules/launcher/main.py new file mode 100644 index 0000000..46a9dca --- /dev/null +++ b/modules/launcher/main.py @@ -0,0 +1,1299 @@ +from typing import List, Optional, Tuple + +import utils.icons as icons +from fabric.core.service import Property +from fabric.utils import exec_shell_command_async, get_relative_path +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.entry import Entry +from fabric.widgets.label import Label +from fabric.widgets.scrolledwindow import ScrolledWindow +from gi.repository import Gdk, GLib +from modules.launcher.plugin_manager import PluginManager +from modules.launcher.result import Result +from modules.launcher.result_item import ResultItem +from modules.launcher.trigger_config import TriggerConfig +from utils.wayland import WaylandWindow as Window + +# Constants +SEARCH_DEBOUNCE_MS = 150 +TRIGGER_SEARCH_DEBOUNCE_MS = 50 +CURSOR_POSITION_DELAY_MS = 10 +SCROLL_PADDING = 10 +DEFAULT_ITEM_HEIGHT = 60 +PAGE_NAVIGATION_STEP = 5 +LAUNCHER_WIDTH = 550 +LAUNCHER_HEIGHT = 260 + + +class Launcher(Window): + """ + Main launcher window with search functionality and plugin system. + Similar to Albert Launcher interface. + """ + + # Properties + query = Property(str, flags="read-write", default_value="") + visible = Property(bool, flags="read-write", default_value=False) + active_trigger = Property(str, flags="read-write", default_value="") + + def __init__(self, **kwargs): + super().__init__( + name="launcher-window", + layer="top", + anchor="center", + exclusivity="none", + keyboard_mode="exclusive", + **kwargs, + ) + + # Initialization flag to prevent callbacks during setup + self._initializing = True + + # Flag to prevent recursion when automatically adding spaces + self._auto_adding_space = False + + # Flag to prevent search change handler interference during backspace processing + self._processing_backspace = False + + # Initialize plugin manager + self.plugin_manager = PluginManager() + + # Initialize trigger configuration + self.trigger_config = TriggerConfig() + + # Current results and selection + self.results: List[Result] = [] + self.selected_index = 0 + self.max_results = 10 + + # Trigger system + self.triggered_plugin = None # Currently active triggered plugin + self.active_trigger = "" # Currently active trigger keyword + self.query = "" # Current search query + self.visible = False # Launcher visibility state + self.opened_with_trigger = False # Whether launcher was opened with a trigger + + # Focus management + self.focus_mode = "search" # "search", "results" + + # Setup UI + main_box = Box( + name="launcher", + orientation="v", + spacing=0, + h_align="center", + v_align="center", + ) + self.add(main_box) + + # Search entry with overlay for trigger indication + self.search_entry = Entry( + name="launcher-search", + placeholder="Type to search...", + h_expand=True, + h_align="fill", + notify_text=lambda entry, *_: self._on_search_changed(entry), + ) + self.search_entry.connect("changed", self._on_search_changed) + self.search_entry.connect("activate", self._on_entry_activate) + + self.header_box = Box( + name="header_box", + spacing=10, + orientation="h", + children=[ + self.search_entry, + ], + ) + + main_box.add(self.header_box) + + # Results container + self.results_scroll = ScrolledWindow( + name="launcher-results-scroll", + h_scrollbar_policy="never", + min_content_size=(LAUNCHER_WIDTH, LAUNCHER_HEIGHT), + max_content_size=(LAUNCHER_WIDTH, LAUNCHER_HEIGHT), + propagate_width=False, + propagate_height=False, + ) + + self.results_box = Box( + name="launcher-results", + orientation="v", + spacing=0, + ) + self.results_scroll.add(self.results_box) + main_box.add(self.results_scroll) + + # Initially hide the results container + self.results_scroll.hide() + + self.connect("key-press-event", self._on_key_press) + self.hide() + + # Mark initialization as complete + self._initializing = False + + # Hide trigger suggestions at startup + self._clear_results() + + def show_launcher(self, trigger_keyword: str = None, external: bool = False): + """Show the launcher and focus the search entry, or execute command externally. + + Args: + trigger_keyword: Optional trigger keyword to activate immediately (e.g., "google", "calc", "app") + external: If True, execute the command without showing the launcher UI + """ + if external and trigger_keyword: + # Execute command externally without showing launcher + return self._execute_external_command(trigger_keyword) + + self.show_all() + + if trigger_keyword: + # Set flag to track that launcher was opened with a trigger keyword + self.opened_with_trigger = True + + # Set the trigger keyword with a space and activate trigger mode + trigger_text = f"{trigger_keyword} " + self.search_entry.set_text(trigger_text) + + # Detect and activate the trigger + triggered_plugin, detected_trigger = self._detect_trigger(trigger_text) + if triggered_plugin: + self.triggered_plugin = triggered_plugin + self.active_trigger = detected_trigger + + # Query the plugin with empty string to show default options + try: + results = triggered_plugin.query("") + self.results = results + self.selected_index = 0 + self._update_results_display() + except Exception as e: + print( + f"Error querying triggered plugin {triggered_plugin.name}: {e}" + ) + self._clear_results() + else: + # Trigger not found, clear and show error or fallback + self.search_entry.set_text("") + self._clear_results() + else: + # Normal launcher opening - clear everything + self.opened_with_trigger = False + self.search_entry.set_text("") + self._clear_results() + + # Reset focus mode to search + self.focus_mode = "search" + + # Focus search entry without selecting text + if trigger_keyword: + # For trigger keywords, we want the cursor at the end + self.search_entry.grab_focus() + + def position_cursor(): + if hasattr(self.search_entry, "set_position"): + self.search_entry.set_position(-1) # Move caret to end + return False # Only run once + + GLib.idle_add(position_cursor) + else: + # For normal opening, use our method that prevents text selection + self._focus_search_entry_without_selection() + + self.visible = True + + def _position_cursor_at_end(self, text_length: Optional[int] = None) -> None: + """Position cursor at the end of search entry text.""" + if text_length is None: + text_length = len(self.search_entry.get_text()) + + def position_cursor(): + if hasattr(self.search_entry, "set_position"): + self.search_entry.set_position(-1) # Move caret to end + if hasattr(self.search_entry, "select_region"): + self.search_entry.select_region( + text_length, text_length + ) # No selection + return False # Only run once + + GLib.idle_add(position_cursor) + + def _add_space_to_trigger(self, trigger_word: str) -> None: + """Add space after trigger keyword and position cursor.""" + trigger_text_with_space = f"{trigger_word} " + + # Temporarily disable search change handling to prevent recursion + self._auto_adding_space = True + self.search_entry.set_text(trigger_text_with_space) + + # Position cursor at the end + def position_cursor(): + if hasattr(self.search_entry, "set_position"): + self.search_entry.set_position(-1) # Move caret to end + if hasattr(self.search_entry, "select_region"): + self.search_entry.select_region( + len(trigger_text_with_space), len(trigger_text_with_space) + ) # No selection + self._auto_adding_space = False + return False # Only run once + + GLib.idle_add(position_cursor) + + # Update query + self.query = trigger_text_with_space + return trigger_text_with_space + + def close_launcher(self): + """Hide the launcher and clear search.""" + self.hide() + self.search_entry.set_text("") + self._clear_results() + self.triggered_plugin = None + self.active_trigger = "" + self.visible = False + self.opened_with_trigger = False + + def _on_search_changed(self, entry): + """Handle search text changes.""" + # Skip if still initializing + if getattr(self, "_initializing", True): + return + + # Skip if we're automatically adding a space to prevent recursion + if getattr(self, "_auto_adding_space", False): + return + + # Skip if we're processing a backspace to prevent interference + if getattr(self, "_processing_backspace", False): + return + + # Reset focus to search when user types + if self.focus_mode != "search": + self.focus_mode = "search" + + query = entry.get_text().strip() + self.query = query + + # If query is exactly ':', show all triggers + if query == ":": + self._show_available_triggers() + # If query matches a trigger exactly (with or without space), handle trigger activation + elif any( + query == trig or query == f"{trig} " + for trig in [ + t.strip() + for p in self.plugin_manager.get_active_plugins() + for t in p.get_triggers() + ] + ): + # Check if we need to add space immediately for exact trigger matches + if not query.endswith(" "): + # This is an exact trigger match without space - add space immediately + trigger_text_with_space = self._add_space_to_trigger(query) + GLib.timeout_add( + TRIGGER_SEARCH_DEBOUNCE_MS, + self._perform_search, + trigger_text_with_space, + ) + else: + # Already has space, proceed with normal search + GLib.timeout_add(SEARCH_DEBOUNCE_MS, self._perform_search, query) + elif query: + # Debounce search to avoid too many queries + GLib.timeout_add(SEARCH_DEBOUNCE_MS, self._perform_search, query) + else: + # Hide results when query is empty + self._clear_results() + + def _perform_search(self, query: str) -> bool: + """Perform search across all plugins.""" + # Only search if query hasn't changed + if query != self.query: + return False + + if not query: + # Empty query - reset trigger mode and hide results + self.triggered_plugin = None + self.active_trigger = "" + self._clear_results() + return False + + # Check if we're already in trigger mode + if self.triggered_plugin and self.active_trigger: + # We're in trigger mode - search within the triggered plugin + try: + # Extract the search query after the trigger + remaining_query = self._extract_query_after_trigger( + query, self.active_trigger + ) + all_results = self.triggered_plugin.query(remaining_query) + except Exception as e: + print(f"Error in triggered plugin {self.triggered_plugin.name}: {e}") + all_results = [] + else: + # Check for trigger activation + triggered_plugin, trigger = self._detect_trigger(query) + + if triggered_plugin: + # New trigger detected - check if we need to add space automatically + trigger_word = trigger.strip() + current_text = self.search_entry.get_text() + + # If the current text is exactly the trigger word (no space), add space automatically + if ( + current_text.strip() == trigger_word + and not current_text.endswith(" ") + and not getattr(self, "_auto_adding_space", False) + ): + query = self._add_space_to_trigger(trigger_word) + + # Enter trigger mode + self.triggered_plugin = triggered_plugin + self.active_trigger = trigger + + # Extract search query after trigger + remaining_query = self._extract_query_after_trigger(query, trigger) + + # Always call the plugin's query method, even with empty remaining query + # This allows plugins to show default options when just the trigger is typed + try: + all_results = triggered_plugin.query(remaining_query) + except Exception as e: + print(f"Error in triggered plugin {triggered_plugin.name}: {e}") + all_results = [] + else: + # No trigger detected - only show trigger suggestions + self.triggered_plugin = None + self.active_trigger = "" + + # Show trigger suggestions if query matches trigger prefixes + trigger_suggestions = self._get_trigger_suggestions(query) + all_results = trigger_suggestions + + # Sort results by relevance score + all_results.sort(key=lambda r: r.relevance, reverse=True) + + # Check if any results have bypass_max_results flag + has_bypass = any( + hasattr(r, "data") and r.data and r.data.get("bypass_max_results") + for r in all_results + ) + + # Don't limit results for triggered plugin queries, only for global searches and trigger suggestions + if self.triggered_plugin and self.active_trigger: + # In trigger mode - show all results from the triggered plugin + self.results = all_results + elif not has_bypass: + # Global search or trigger suggestions - apply max_results limit + self.results = all_results[: self.max_results] + else: + # Has bypass flag - show all results + self.results = all_results + self.selected_index = 0 + + # Update UI + self._update_results_display() + + return False # Don't repeat timeout + + def _extract_query_after_trigger(self, query: str, trigger: str) -> str: + """ + Extract the search query after removing the trigger. + + Args: + query: The full query string + trigger: The trigger keyword + + Returns: + The remaining query after the trigger + """ + if not query or not trigger: + return "" + + query_lower = query.lower() + trigger_lower = trigger.lower() + + # Handle trigger with space (e.g., "app ") + if trigger_lower.endswith(" ") and query_lower.startswith(trigger_lower): + return query[len(trigger) :].strip() + + # Handle trigger without space (e.g., "app") + trigger_word = trigger.strip().lower() + if query_lower.startswith(trigger_word): + # Check if it's followed by space or end of string + if len(query) == len(trigger_word): + return "" + elif len(query) > len(trigger_word) and query[len(trigger_word)] == " ": + return query[len(trigger_word) + 1 :].strip() + elif len(query) > len(trigger_word): + # No space after trigger word, extract rest + return query[len(trigger_word) :].strip() + + return "" + + def _show_available_triggers(self): + """Show available triggers when launcher is first opened.""" + trigger_suggestions = self._get_trigger_suggestions("") + self.results = trigger_suggestions # Show all available triggers without limit + self.selected_index = 0 + self._update_results_display() + + def _detect_trigger(self, query: str) -> Tuple[Optional[object], str]: + """ + Detect if query starts with a trigger keyword. + + Args: + query: The search query + + Returns: + Tuple of (plugin, trigger) if triggered, (None, "") otherwise + """ + if not query.strip(): + return None, "" + + # Check all plugins for triggers + for plugin in self.plugin_manager.get_active_plugins(): + trigger = plugin.get_active_trigger(query) + if trigger: + return plugin, trigger + + return None, "" + + def _get_trigger_suggestions(self, query: str) -> List[Result]: + """ + Get trigger suggestions based on the current query. + + Args: + query: The search query + + Returns: + List of Result objects showing available triggers + """ + suggestions = [] + query_lower = query.lower().strip() + + # Get all available triggers from plugins + all_triggers = {} + for plugin in self.plugin_manager.get_active_plugins(): + triggers = plugin.get_triggers() + for trigger in triggers: + trigger_clean = trigger.strip() + if trigger_clean not in all_triggers: + all_triggers[trigger_clean] = { + "plugin": plugin, + "trigger": trigger, + "examples": [], + } + + # Get max examples to show from configuration + max_examples = self.trigger_config.settings.get("max_examples_shown", 2) + + # Show trigger suggestions based on query + if query_lower: + # Show triggers that match the query + for trigger_clean, _ in all_triggers.items(): + if trigger_clean.lower().startswith(query_lower): + result = self._create_trigger_result(trigger_clean, max_examples) + suggestions.append(result) + else: + # Empty query - show all available triggers + for trigger_clean, _ in all_triggers.items(): + result = self._create_trigger_result(trigger_clean, max_examples) + suggestions.append(result) + + return suggestions # Return all trigger suggestions without limit + + def _create_trigger_result(self, trigger_clean: str, max_examples: int) -> Result: + """Create a Result object for a trigger suggestion.""" + examples = self.trigger_config.get_trigger_examples(trigger_clean) + icon_name = self.trigger_config.get_trigger_icon(trigger_clean) + description = self.trigger_config.get_trigger_description(trigger_clean) + + return Result( + title=f"{trigger_clean}", + subtitle=f"{description} - {', '.join(examples[:max_examples])}", + icon_markup=icon_name, + action=lambda t=trigger_clean: self._activate_trigger(t), + # Shorter triggers get higher relevance + relevance=100 - len(trigger_clean), + data={"type": "trigger_suggestion", "trigger": trigger_clean}, + ) + + def _activate_trigger(self, trigger: str): + """ + Activate a trigger by setting it in the search entry. + + Args: + trigger: The trigger keyword to activate + """ + # Set the trigger text in the search entry + trigger_text = f"{trigger} " + self.search_entry.set_text(trigger_text) + self.search_entry.grab_focus() + + def clear_selection(): + if hasattr(self.search_entry, "set_position"): + self.search_entry.set_position(-1) # Move caret to end + if hasattr(self.search_entry, "select_region"): + self.search_entry.select_region( + len(trigger_text), len(trigger_text) + ) # No selection + return False # Only run once + + GLib.idle_add(clear_selection) + + # Manually set the trigger mode to avoid search processing issues + triggered_plugin, detected_trigger = self._detect_trigger(trigger_text) + if triggered_plugin: + self.triggered_plugin = triggered_plugin + self.active_trigger = detected_trigger + + # Clear results and show trigger ready state + self.results = [] + self.selected_index = 0 + self._update_results_display() + + # Focus back to the search entry for immediate typing + self.search_entry.grab_focus() + + # Don't hide the launcher - user should be able to continue typing + + def _execute_external_command(self, command_string: str): + """Execute a command externally without showing the launcher UI. + + Args: + command_string: Full command string (e.g., "wall random", "calc 2+2") + + Returns: + Result of the command execution or None if failed + """ + try: + # Parse the command to extract trigger and query + parts = command_string.strip().split(" ", 1) + if not parts: + return None + + trigger_part = parts[0] + query_part = parts[1] if len(parts) > 1 else "" + + # Find the plugin that handles this trigger + triggered_plugin = None + + for plugin in self.plugin_manager.get_active_plugins(): + trigger = plugin.get_active_trigger(f"{trigger_part} ") + if trigger: + triggered_plugin = plugin + break + + if not triggered_plugin: + print(f"No plugin found for trigger: {trigger_part}") + return None + + # Query the plugin with the remaining query + try: + results = triggered_plugin.query(query_part) + if not results: + print(f"No results found for query: {query_part}") + return None + + # Find the first result that matches the query exactly or has highest relevance + best_result = None + for result in results: + # For exact matches like "random", execute immediately + if ( + hasattr(result, "data") + and result.data + and result.data.get("action") == query_part.strip() + ): + best_result = result + break + # For partial matches, take the first high-relevance result + elif not best_result and result.relevance >= 0.9: + best_result = result + + # If no exact match, take the first result + if not best_result and results: + best_result = results[0] + + if best_result: + # Execute the result action + try: + result_value = best_result.activate() + print(f"External command executed: {command_string}") + return result_value + except Exception as e: + print(f"Error executing result action: {e}") + return None + else: + print(f"No suitable result found for: {command_string}") + return None + + except Exception as e: + print(f"Error querying plugin {triggered_plugin.name}: {e}") + return None + + except Exception as e: + print(f"Error executing external command '{command_string}': {e}") + return None + + def _update_results_display(self): + """Update the results display.""" + # Skip if still initializing or results_box not ready + if getattr(self, "_initializing", True) or not hasattr(self, "results_box"): + return + + # Update input field with trigger indication (Albert-style) + self._update_input_action_text() + + # Clear existing results + for child in self.results_box.get_children(): + self.results_box.remove(child) + + # Add new results + for i, result in enumerate(self.results): + # Check if this result has a custom widget + if result.custom_widget: + # Ensure the widget is not already parented + parent = result.custom_widget.get_parent() + if parent: + parent.remove(result.custom_widget) + + result.custom_widget.show_all() # Ensure widget is visible + self.results_box.add(result.custom_widget) + else: + # Create normal result item + result_item = ResultItem( + result=result, selected=(i == self.selected_index), index=i + ) + result_item.clicked.connect( + lambda _, idx=i: self._on_result_clicked(result_item, idx) + ) + result_item.hovered.connect( + lambda _, idx=i: self._on_result_hovered(idx) + ) + self.results_box.add(result_item) + + self.results_box.show_all() + + # Show/hide scroll container based on results + if self.results: + self.results_scroll.show() + else: + self.results_scroll.hide() + + def _update_input_action_text(self): + """Update the input field with action text (Albert-style).""" + # Check if search_entry is initialized + if not hasattr(self, "search_entry") or self.search_entry is None: + return + + # Get the current input text + current_text = self.search_entry.get_text() + + if self.triggered_plugin: + if self.results: + # Show the first result as action text + first_result = self.results[0] + action_text = first_result.title + + # For calculator results, show the evaluation + if hasattr(first_result, "data") and "result" in first_result.data: + action_text = str(first_result.data["result"]) + + # Set placeholder to show the action text + if action_text and action_text != current_text: + self.search_entry.set_placeholder_text( + f"{current_text} → {action_text}" + ) + else: + self.search_entry.set_placeholder_text( + f"[{self.active_trigger.strip()}] searching..." + ) + else: + # In trigger mode but no results yet + if current_text == self.active_trigger.strip(): + # Just the trigger keyword + self.search_entry.set_placeholder_text( + f"[{self.active_trigger.strip()}] ready - type to search" + ) + else: + # Searching within trigger + self.search_entry.set_placeholder_text( + f"[{self.active_trigger.strip()}] searching..." + ) + else: + # Not in trigger mode - show trigger help + if current_text == ":": + self.search_entry.set_placeholder_text( + "Showing all available triggers." + ) + elif current_text: + self.search_entry.set_placeholder_text( + "Type trigger keyword (calc, app, file, system...)" + ) + else: + self.search_entry.set_placeholder_text( + "Type trigger keyword: calc, app, file, system..." + ) + + def _clear_results(self): + """Clear all results.""" + self.results = [] + self.selected_index = 0 + for child in self.results_box.get_children(): + self.results_box.remove(child) + self.results_scroll.hide() + + def _handle_escape_key(self) -> bool: + """Handle escape key press.""" + # First check if there's a password entry widget that should handle Escape + password_entry_widget = self._find_password_entry_widget() + if password_entry_widget: + # Cancel the password entry + password_entry_widget.cancel_password_entry() + return True + elif self.opened_with_trigger: + # If launcher was opened with a trigger keyword, close directly + self.close_launcher() + return True + elif self.triggered_plugin: + # Exit trigger mode + self.triggered_plugin = None + self.active_trigger = "" + self.search_entry.set_text("") + self._clear_results() + return True + else: + # Hide launcher + self.close_launcher() + return True + + def _handle_backspace_key(self) -> bool: + """Handle backspace key press in trigger mode.""" + if self.triggered_plugin: + trigger_text = self.active_trigger.strip() + current_text = self.search_entry.get_text() + + # Set flag to prevent search change handler from interfering + self._processing_backspace = True + + # Allow normal backspace behavior first, then check if we need to exit trigger mode + # Don't intercept the backspace - let GTK handle it normally + + # Schedule a check after the backspace is processed + def check_trigger_after_backspace(): + try: + # Get the text after backspace has been processed + new_text = self.search_entry.get_text() + + # Check if we should exit trigger mode + # We need to check if the text still matches the trigger pattern + should_exit_trigger = False + + # If the active trigger ends with a space (like "calc "), + # we should exit if the text doesn't contain that space anymore + if self.active_trigger.endswith(" "): + # For triggers like "calc ", exit if text is just "calc" or doesn't start with "calc " + trigger_with_space = self.active_trigger.lower() + if not new_text.lower().startswith(trigger_with_space): + should_exit_trigger = True + else: + # For triggers without space, exit if text doesn't start with trigger + if not new_text.lower().startswith(trigger_text.lower()): + should_exit_trigger = True + + if should_exit_trigger: + self.triggered_plugin = None + self.active_trigger = "" + self._clear_results() + # Don't clear the text - let the user's edit stand + + # If we're still in trigger mode but the text changed, update the search + elif self.triggered_plugin and new_text != current_text: + # Trigger a search with the new text + self.query = new_text + GLib.timeout_add(50, self._perform_search, new_text) + + finally: + # Clear the backspace processing flag + self._processing_backspace = False + + return False # Don't repeat + + # Use idle_add to check after the backspace is processed + GLib.idle_add(check_trigger_after_backspace) + + # Allow the normal backspace to proceed + return False + + # Let normal backspace behavior continue for other cases + return False + + def _on_key_press(self, _widget, event): + """Handle key press events.""" + keyval = event.keyval + + # Escape - handle password entry, exit trigger mode, or hide launcher + if keyval == Gdk.KEY_Escape: + return self._handle_escape_key() + + # Backspace - handle trigger mode backspace behavior + if keyval == Gdk.KEY_BackSpace: + return self._handle_backspace_key() + + # Up/Down - navigate results (alternative to Tab) + if keyval == Gdk.KEY_Up: + if self.focus_mode == "results" and self.results: + if self.selected_index > 0: + # Move to previous result + self.selected_index -= 1 + self._update_selection() + else: + # At first result, go back to search entry + self.focus_mode = "search" + self._focus_search_entry_without_selection() + elif self.results: + # If not in results mode but have results, enter results mode at last item + self.focus_mode = "results" + self.selected_index = len(self.results) - 1 + self._update_selection() + return True + + if keyval == Gdk.KEY_Down: + if self.focus_mode == "results" and self.results: + if self.selected_index < len(self.results) - 1: + # Move to next result + self.selected_index += 1 + self._update_selection() + else: + # At last result, wrap around to first result + self.selected_index = 0 + self._update_selection() + elif self.results: + # If not in results mode but have results, enter results mode at first item + self.focus_mode = "results" + self.selected_index = 0 + self._update_selection() + return True + + # Enter - activate selected result + if keyval == Gdk.KEY_Return: + + # Check for Shift+Enter for alternative actions + if event.state & Gdk.ModifierType.SHIFT_MASK: + if self.results and 0 <= self.selected_index < len(self.results): + result = self.results[self.selected_index] + if result.data: + # Check for generic alternative action first + if result.data.get("alt_action"): + result.data["alt_action"]() + return True + # Fallback to pin_action for backward compatibility + elif result.data.get("pin_action"): + result.data["pin_action"]() + return True + + # Check if the selected result has a custom widget with Entry fields + if self.results and 0 <= self.selected_index < len(self.results): + result = self.results[self.selected_index] + if result.custom_widget: + # Check if the custom widget contains Entry widgets that should handle Enter + if self._custom_widget_has_entry(result.custom_widget): + # Let the custom widget handle the Enter key + # Find the focused Entry widget and trigger its activate signal + focused_entry = self._find_focused_entry_in_widget( + result.custom_widget + ) + if focused_entry: + focused_entry.emit("activate") + return True + + # Normal Enter behavior + self._activate_selected() + return True + + # Tab - cycle through focus areas and results + if keyval == Gdk.KEY_Tab: + if event.state & Gdk.ModifierType.SHIFT_MASK: + # Shift+Tab - reverse direction + if self.focus_mode == "results": + # Navigate through results in reverse + self._navigate_results_backward() + else: + self._cycle_focus_backward() + else: + # Tab - forward direction + if self.focus_mode == "results": + # Navigate through results forward + self._navigate_results_forward() + else: + self._cycle_focus_forward() + return True + + # Page Up/Page Down - navigate results faster + if keyval == Gdk.KEY_Page_Up: + if self.results: + self.selected_index = max(0, self.selected_index - PAGE_NAVIGATION_STEP) + self._update_selection() + return True + + if keyval == Gdk.KEY_Page_Down: + if self.results: + self.selected_index = min( + len(self.results) - 1, self.selected_index + PAGE_NAVIGATION_STEP + ) + self._update_selection() + return True + + # Home/End - go to first/last result + if keyval == Gdk.KEY_Home: + if self.results: + self.selected_index = 0 + self._update_selection() + return True + + if keyval == Gdk.KEY_End: + if self.results: + self.selected_index = len(self.results) - 1 + self._update_selection() + return True + + # Forward other keys to custom widgets if they can handle them + if self.results and 0 <= self.selected_index < len(self.results): + result = self.results[self.selected_index] + if result.custom_widget and hasattr(result.custom_widget, "on_key_press"): + # Try to forward the key event to the custom widget + if result.custom_widget.on_key_press(result.custom_widget, event): + return True + + return False + + def _custom_widget_has_entry(self, widget): + """Check if a custom widget contains Entry widgets.""" + + if isinstance(widget, Entry): + return True + + # Check children recursively + if hasattr(widget, "get_children"): + for child in widget.get_children(): + if self._custom_widget_has_entry(child): + return True + + return False + + def _find_focused_entry_in_widget(self, widget): + """Find the focused Entry widget within a custom widget.""" + + if isinstance(widget, Entry): + # Try multiple ways to check if this Entry is focused + try: + if widget.has_focus() or widget.is_focus(): + return widget + # Also check if this is the only Entry in the widget (likely to be the target) + return widget + except: + # If focus checking fails, assume this Entry should handle the event + return widget + + # Check children recursively + if hasattr(widget, "get_children"): + for child in widget.get_children(): + focused_entry = self._find_focused_entry_in_widget(child) + if focused_entry: + return focused_entry + + return None + + def _find_password_entry_widget(self): + """Find a NetworkPasswordEntry widget in the current results.""" + for result in self.results: + if result.custom_widget: + # Check if this is a NetworkPasswordEntry widget + if ( + hasattr(result.custom_widget, "__class__") + and result.custom_widget.__class__.__name__ + == "NetworkPasswordEntry" + ): + return result.custom_widget + # Also check if it has the cancel_password_entry method (duck typing) + elif hasattr(result.custom_widget, "cancel_password_entry"): + return result.custom_widget + return None + + def _on_entry_activate(self, _entry): + """Handle entry activation (Enter key).""" + self._activate_selected() + + def _on_result_clicked(self, _result_item, index): + """Handle result item click.""" + self.selected_index = index + self._activate_selected() + + def _on_result_hovered(self, index): + """Handle result item hover.""" + # Update selection when mouse hovers over a result (without scrolling) + if 0 <= index < len(self.results): + self.selected_index = index + self.focus_mode = "results" # Switch to results focus mode + self._update_selection_visual_only() + + def _is_mouse_over_results(self): + """Check if mouse is over the results area.""" + # Simple check - if we have results visible, assume user might be interacting + return len(self.results) > 0 and self.results_scroll.get_visible() + + def _update_selection(self): + """Update the visual selection of results with scrolling (for keyboard navigation).""" + children = self.results_box.get_children() + selected_widget = None + + for i, child in enumerate(children): + if isinstance(child, ResultItem): + is_selected = i == self.selected_index + child.set_selected(is_selected) + if is_selected: + selected_widget = child + # For custom widgets, we don't need to handle selection visually + # since they manage their own interaction + + # Focus custom widgets when selected for keyboard interaction + if self.results and 0 <= self.selected_index < len(self.results): + result = self.results[self.selected_index] + if result.custom_widget and result.custom_widget.get_can_focus(): + # Give focus to the custom widget for keyboard interaction + result.custom_widget.grab_focus() + + # Scroll to make the selected item visible + if selected_widget and self.results_scroll.get_visible(): + # Use immediate scrolling for better responsiveness + self._scroll_to_widget(selected_widget) + # Also schedule a more accurate scroll after layout is complete + GLib.idle_add(self._ensure_selected_visible) + + def _update_selection_visual_only(self): + """Update the visual selection of results without scrolling (for mouse hover).""" + children = self.results_box.get_children() + + for i, child in enumerate(children): + if isinstance(child, ResultItem): + is_selected = i == self.selected_index + child.set_selected(is_selected) + # For custom widgets, we don't need to handle selection visually + # since they manage their own interaction + + # Focus custom widgets when selected for keyboard interaction + if self.results and 0 <= self.selected_index < len(self.results): + result = self.results[self.selected_index] + if result.custom_widget and result.custom_widget.get_can_focus(): + # Give focus to the custom widget for keyboard interaction + result.custom_widget.grab_focus() + + def _scroll_to_widget(self, widget): + """Scroll the results container to make the widget visible.""" + if not widget or not self.results_scroll.get_visible(): + return + + # Debug output (can be removed in production) + # print(f"Scrolling to selected index: {self.selected_index}") + + # Get the scrolled window's vertical adjustment + vadjustment = self.results_scroll.get_vadjustment() + if not vadjustment: + return + + # Use a simpler approach: scroll to the selected item index + if self.results and 0 <= self.selected_index < len(self.results): + # Get all children to work with actual widgets + children = self.results_box.get_children() + if not children or self.selected_index >= len(children): + return + + # Get the selected child widget + selected_child = children[self.selected_index] + + # Try to get actual allocation, fallback to estimation + try: + allocation = selected_child.get_allocation() + item_height = allocation.height if allocation.height > 0 else 60 + item_y = allocation.y + except: + # Fallback to estimation + item_height = DEFAULT_ITEM_HEIGHT + item_y = self.selected_index * item_height + + # Get current scroll info + current_scroll = vadjustment.get_value() + page_size = vadjustment.get_page_size() + max_scroll = vadjustment.get_upper() - page_size + + # Calculate visible area + visible_top = current_scroll + visible_bottom = current_scroll + page_size + + # Check if selected item is visible + item_top = item_y + item_bottom = item_y + item_height + + # Add some padding for better visibility + # Scroll if needed + if item_top < visible_top + SCROLL_PADDING: + # Item is above visible area - scroll up + new_scroll = max(0, item_top - SCROLL_PADDING) + vadjustment.set_value(new_scroll) + elif item_bottom > visible_bottom - SCROLL_PADDING: + # Item is below visible area - scroll down + new_scroll = min(max_scroll, item_bottom - page_size + SCROLL_PADDING) + vadjustment.set_value(new_scroll) + + def _ensure_selected_visible(self): + """Alternative method to ensure selected item is visible using GTK methods.""" + if ( + not self.results + or self.selected_index < 0 + or self.selected_index >= len(self.results) + ): + return False + + children = self.results_box.get_children() + if self.selected_index < len(children): + selected_child = children[self.selected_index] + + # Try to use widget's allocation for more accurate scrolling + try: + allocation = selected_child.get_allocation() + if allocation.height > 0: + vadjustment = self.results_scroll.get_vadjustment() + if vadjustment: + # Calculate the position to center the selected item + page_size = vadjustment.get_page_size() + target_pos = ( + allocation.y - (page_size / 2) + (allocation.height / 2) + ) + target_pos = max( + 0, min(target_pos, vadjustment.get_upper() - page_size) + ) + vadjustment.set_value(target_pos) + except Exception as e: + print(f"Error in _ensure_selected_visible: {e}") + + return False # Don't repeat the idle callback + + def _cycle_focus_forward(self): + """Cycle focus forward: search -> results""" + if self.focus_mode == "search": + if self.results: + self.focus_mode = "results" + self._update_selection() + + def _cycle_focus_backward(self): + """Cycle focus backward: results -> search""" + if self.focus_mode == "results": + self.focus_mode = "search" + self._focus_search_entry_without_selection() + + + + def _focus_search_entry_without_selection(self): + """Focus search entry and position cursor at end without selecting text.""" + # First grab focus + self.search_entry.grab_focus() + + # Use multiple approaches to prevent text selection + def clear_selection(): + try: + text_length = len(self.search_entry.get_text()) + + # Method 1: Set position and clear selection + if hasattr(self.search_entry, "set_position"): + self.search_entry.set_position(text_length) + if hasattr(self.search_entry, "select_region"): + self.search_entry.select_region(text_length, text_length) + + # Method 2: Try to access underlying GTK widget + try: + # For fabric Entry widgets, try to get the actual GTK Entry + if hasattr(self.search_entry, "_entry"): + gtk_entry = self.search_entry._entry + elif ( + hasattr(self.search_entry, "get_children") + and self.search_entry.get_children() + ): + gtk_entry = self.search_entry.get_children()[0] + else: + gtk_entry = self.search_entry + + if hasattr(gtk_entry, "set_position"): + gtk_entry.set_position(text_length) + if hasattr(gtk_entry, "select_region"): + gtk_entry.select_region(text_length, text_length) + + except Exception: + pass + + except Exception as e: + print(f"Could not clear selection: {e}") + return False + + # Schedule clearing selection after focus is established + GLib.idle_add(clear_selection) + # Also try with a small delay as backup + GLib.timeout_add(CURSOR_POSITION_DELAY_MS, clear_selection) + + + + def _navigate_results_forward(self): + """Navigate to next result or wrap around to first result.""" + if self.results and self.selected_index < len(self.results) - 1: + # Move to next result + self.selected_index += 1 + self._update_selection() + else: + # At last result, wrap around to first result + self.selected_index = 0 + self._update_selection() + + def _navigate_results_backward(self): + """Navigate to previous result or exit results mode.""" + if self.results and self.selected_index > 0: + # Move to previous result + self.selected_index -= 1 + self._update_selection() + else: + # Exit results mode and go to search + self.focus_mode = "search" + self._focus_search_entry_without_selection() + + def _activate_selected(self): + """Activate the currently selected result.""" + if self.results and 0 <= self.selected_index < len(self.results): + result = self.results[self.selected_index] + try: + # Check if this result has a custom widget + if result.custom_widget: + # For custom widgets, we don't activate them since they're already displayed + # The widget handles its own interactions + return + + # Check if this is a trigger suggestion or should keep launcher open + is_trigger_suggestion = ( + result.data and result.data.get("type") == "trigger_suggestion" + ) + keep_launcher_open = result.data and result.data.get( + "keep_launcher_open", False + ) + + # Activate the result + result.activate() + + # Only hide launcher if it's not a trigger suggestion and doesn't have keep_launcher_open flag + if not is_trigger_suggestion and not keep_launcher_open: + self.close_launcher() + # For trigger suggestions and keep_launcher_open actions, the launcher stays open + except Exception as e: + print(f"Error activating result: {e}") diff --git a/modules/launcher/plugin_base.py b/modules/launcher/plugin_base.py new file mode 100644 index 0000000..13bd1fc --- /dev/null +++ b/modules/launcher/plugin_base.py @@ -0,0 +1,172 @@ +from abc import ABC, abstractmethod +from typing import List + +from modules.launcher.result import Result + + +class PluginBase(ABC): + """ + Abstract base class for launcher plugins. + All plugins must inherit from this class. + """ + + def __init__(self): + self.name = self.__class__.__name__.lower() + self.display_name = self.__class__.__name__ + self.description = "A launcher plugin" + self.version = "1.0.0" + self.enabled = True + self._triggers = [] # List of trigger keywords + + @abstractmethod + def initialize(self): + """ + Initialize the plugin. + Called when the plugin is activated. + """ + pass + + @abstractmethod + def cleanup(self): + """ + Cleanup the plugin. + Called when the plugin is deactivated. + """ + pass + + @abstractmethod + def query(self, query_string: str) -> List[Result]: + """ + Process a search query and return results. + + Args: + query_string: The search query from the user + + Returns: + List of Result objects + """ + pass + + def get_triggers(self) -> List[str]: + """ + Get list of trigger keywords for this plugin. + If the query starts with any of these, this plugin gets priority. + + Returns: + List of trigger strings (e.g., ["calc", "=", "math"]) + """ + return self._triggers + + def set_triggers(self, triggers: List[str]): + """ + Set the trigger keywords for this plugin. + + Args: + triggers: List of trigger keywords + """ + self._triggers = triggers + + def handles_query(self, query_string: str) -> bool: + """ + Check if this plugin should handle the given query. + + Args: + query_string: The search query + + Returns: + True if this plugin should process the query + """ + if not self.enabled: + return False + + # Check triggers + triggers = self.get_triggers() + if triggers: + query_lower = query_string.lower().strip() + return any(query_lower.startswith(trigger.lower()) for trigger in triggers) + + # Default: handle all queries + return True + + def get_active_trigger(self, query_string: str) -> str: + """ + Get the active trigger for the given query. + + Args: + query_string: The search query + + Returns: + The trigger keyword if found, empty string otherwise + """ + if not self.enabled: + return "" + + triggers = self.get_triggers() + if triggers: + query_lower = query_string.lower().strip() + + # Sort triggers by length (longest first) to match more specific triggers first + sorted_triggers = sorted(triggers, key=len, reverse=True) + + for trigger in sorted_triggers: + trigger_lower = trigger.lower() + + # Exact match with trigger (including space if present) + if query_lower.startswith(trigger_lower): + return trigger + + # Match trigger word followed by space + trigger_word = trigger.strip().lower() + if ( + query_lower.startswith(trigger_word + " ") + or query_lower == trigger_word + ): + return trigger + + return "" + + def query_triggered(self, query_string: str, trigger: str) -> List[Result]: + """ + Process a triggered query (when plugin is in sticky mode). + Default implementation removes trigger and calls query(). + + Args: + query_string: The full query string including trigger + trigger: The trigger that activated this plugin + + Returns: + List of Result objects + """ + # Remove trigger from query and process remaining text + remaining_query = query_string[len(trigger) :].strip() + return self.query(remaining_query) + + def get_config(self) -> dict: + """ + Get plugin configuration. + + Returns: + Dictionary of configuration options + """ + return { + "name": self.name, + "display_name": self.display_name, + "description": self.description, + "version": self.version, + "enabled": self.enabled, + } + + def set_config(self, config: dict): + """ + Set plugin configuration. + + Args: + config: Dictionary of configuration options + """ + self.enabled = config.get("enabled", self.enabled) + + def __str__(self): + return f"Plugin({self.name})" + + def __repr__(self): + return self.__str__() diff --git a/modules/launcher/plugin_manager.py b/modules/launcher/plugin_manager.py new file mode 100644 index 0000000..3f9f9d0 --- /dev/null +++ b/modules/launcher/plugin_manager.py @@ -0,0 +1,186 @@ +import importlib +import importlib.util +import os +from typing import Dict, List, Type + +from modules.launcher.plugin_base import PluginBase + + +class PluginManager: + """ + Manages launcher plugins. + """ + + def __init__(self): + self.plugins: Dict[str, PluginBase] = {} + self.plugin_classes: Dict[str, Type[PluginBase]] = {} + self.active_plugins: List[str] = [] + + # Load built-in plugins + self._load_builtin_plugins() + + # Load external plugins + self._load_external_plugins() + + # Activate default plugins + self._activate_default_plugins() + + def _load_builtin_plugins(self): + """Load built-in plugins from the plugins directory.""" + plugins_dir = os.path.join(os.path.dirname(__file__), "plugins") + + if not os.path.exists(plugins_dir): + return + + for filename in os.listdir(plugins_dir): + if filename.endswith(".py") and not filename.startswith("_"): + plugin_name = filename[:-3] # Remove .py extension + self._load_plugin_from_file(plugins_dir, plugin_name) + + def _load_external_plugins(self): + """Load external plugins from user directory.""" + # Could be implemented to load from ~/.config/launcher/plugins/ + pass + + def _load_plugin_from_file(self, plugins_dir: str, plugin_name: str): + """Load a plugin from a Python file.""" + try: + plugin_path = os.path.join(plugins_dir, f"{plugin_name}.py") + spec = importlib.util.spec_from_file_location( + f"modules.launcher.plugins.{plugin_name}", plugin_path + ) + + if spec and spec.loader: + module = importlib.util.module_from_spec(spec) + + # Add the module to sys.modules to support relative imports + import sys + + sys.modules[f"modules.launcher.plugins.{plugin_name}"] = module + + spec.loader.exec_module(module) + + # Look for plugin class + for attr_name in dir(module): + attr = getattr(module, attr_name) + if ( + isinstance(attr, type) + and issubclass(attr, PluginBase) + and attr != PluginBase + ): + self.plugin_classes[plugin_name] = attr + break + + except Exception as e: + print(f"Failed to load plugin {plugin_name}: {e}") + + def _activate_default_plugins(self): + """Activate default plugins.""" + default_plugins = [ + "applications", + "calculator", + "system", + "clipboard", + "power", + "caffeine", + "screencapture", + "emoji", + "wallpaper", + "websearch", + "kanban", + "calendar", + "reminders", + "otp", + "password", + "bookmarks", + "process", + "bash_scripts", + "tmux", + ] + + for plugin_name in default_plugins: + self.activate_plugin(plugin_name) + + def activate_plugin(self, plugin_name: str) -> bool: + """Activate a plugin by name.""" + if plugin_name in self.plugins: + # Already activated + return True + + if plugin_name not in self.plugin_classes: + return False + + try: + # Instantiate plugin + plugin_class = self.plugin_classes[plugin_name] + plugin_instance = plugin_class() + + # Initialize plugin + plugin_instance.initialize() + + # Store plugin + self.plugins[plugin_name] = plugin_instance + self.active_plugins.append(plugin_name) + + return True + + except Exception as e: + print(f"Failed to activate plugin {plugin_name}: {e}") + return False + + def deactivate_plugin(self, plugin_name: str) -> bool: + """Deactivate a plugin by name.""" + if plugin_name not in self.plugins: + return False + + try: + # Cleanup plugin + plugin = self.plugins[plugin_name] + plugin.cleanup() + + # Remove from active plugins + del self.plugins[plugin_name] + if plugin_name in self.active_plugins: + self.active_plugins.remove(plugin_name) + + return True + + except Exception as e: + print(f"Failed to deactivate plugin {plugin_name}: {e}") + return False + + def get_active_plugins(self) -> List[PluginBase]: + """Get list of active plugin instances.""" + return [ + self.plugins[name] for name in self.active_plugins if name in self.plugins + ] + + def get_plugin_names(self) -> List[str]: + """Get list of available plugin names.""" + return list(self.plugin_classes.keys()) + + def get_active_plugin_names(self) -> List[str]: + """Get list of active plugin names.""" + return self.active_plugins.copy() + + def reload_plugin(self, plugin_name: str) -> bool: + """Reload a plugin.""" + was_active = plugin_name in self.active_plugins + + # Deactivate if active + if was_active: + self.deactivate_plugin(plugin_name) + + # Remove from classes + if plugin_name in self.plugin_classes: + del self.plugin_classes[plugin_name] + + # Reload from file + plugins_dir = os.path.join(os.path.dirname(__file__), "plugins") + self._load_plugin_from_file(plugins_dir, plugin_name) + + # Reactivate if it was active + if was_active and plugin_name in self.plugin_classes: + return self.activate_plugin(plugin_name) + + return plugin_name in self.plugin_classes diff --git a/modules/launcher/plugins/__init__.py b/modules/launcher/plugins/__init__.py new file mode 100644 index 0000000..666274b --- /dev/null +++ b/modules/launcher/plugins/__init__.py @@ -0,0 +1,3 @@ +""" +Built-in plugins for the launcher. +""" diff --git a/modules/launcher/plugins/applications.py b/modules/launcher/plugins/applications.py new file mode 100644 index 0000000..3f47137 --- /dev/null +++ b/modules/launcher/plugins/applications.py @@ -0,0 +1,163 @@ +import json +import re +from typing import List + +from fabric.utils import DesktopApp +from fabric.utils.helpers import get_desktop_applications, get_relative_path + +from modules.dock.main import Dock +from modules.launcher.plugin_base import PluginBase +from modules.launcher.result import Result + + +class ApplicationsPlugin(PluginBase): + def __init__(self): + super().__init__() + self.display_name = "Applications" + self.description = "Search and launch desktop applications" + + def initialize(self): + # Set up triggers for applications - both with and without spaces + self.set_triggers(["app"]) + + def cleanup(self): + pass + + def _pin_application(self, app): + """Pin an application to the dock.""" + app_data = { + k: v + for k, v in { + "name": app.name, + "display_name": app.display_name, + "window_class": app.window_class, + "executable": app.executable, + "command_line": app.command_line, + "icon_name": app.icon_name, + }.items() + if v is not None + } + + config_path = get_relative_path("../../../config/assets/dock.json") + try: + with open(config_path, "r") as file: + data = json.load(file) + except (FileNotFoundError, json.JSONDecodeError): + data = {"pinned_apps": []} + + already_pinned = False + for pinned_app in data.get("pinned_apps", []): + if ( + isinstance(pinned_app, dict) + and pinned_app.get("name") == app_data["name"] + ): + already_pinned = True + pinned_app.update(app_data) + break + elif isinstance(pinned_app, str) and pinned_app == app_data["name"]: + already_pinned = True + data["pinned_apps"].remove(pinned_app) + data["pinned_apps"].append(app_data) + break + + if not already_pinned: + data.setdefault("pinned_apps", []).append(app_data) + + with open(config_path, "w") as file: + json.dump(data, file, indent=4) + + Dock.notify_config_change() + + def query(self, query_string: str) -> List[Result]: + """Search applications based on query.""" + if not query_string.strip(): + return [] + + # Get fresh applications list each time (like examples/app-launcher) + try: + applications = get_desktop_applications(include_hidden=False) + except Exception as e: + print(f"Failed to load applications: {e}") + applications = [] + + query = query_string.lower().strip() + results = [] + + for app in applications: + relevance = self._calculate_relevance(app, query) + if relevance > 0: + # Truncate description to prevent overflow beyond 550px window + description = app.description or app.generic_name or "" + if len(description) > 80: # Limit to ~80 characters to fit in 550px + description = description[:70] + "..." + + result = Result( + title=app.display_name or app.name, + subtitle=description, + icon=app.get_icon_pixbuf(size=48), + action=lambda a=app: self._launch_application(a), + relevance=relevance, + plugin_name=self.display_name, + data={ + "app": app, + "pin_action": lambda a=app: self._pin_application(a), + }, + ) + results.append(result) + + return results + + def _calculate_relevance(self, app, query: str) -> float: + """Calculate relevance score for an application.""" + if not query: + return 0.0 + + # Get searchable text + name = (app.name or "").lower() + display_name = (app.display_name or "").lower() + description = (app.description or "").lower() + generic_name = (app.generic_name or "").lower() + executable = (app.executable or "").lower() + + # Exact matches get highest score + if query == name or query == display_name: + return 1.0 + + # Starts with matches get high score + if ( + name.startswith(query) + or display_name.startswith(query) + or generic_name.startswith(query) + ): + return 0.9 + + # Contains matches get medium score + if ( + query in name + or query in display_name + or query in description + or query in generic_name + ): + return 0.7 + + # Fuzzy matching for partial matches + if self._fuzzy_match(query, name) or self._fuzzy_match(query, display_name): + return 0.5 + + # Executable name matching + if executable and query in executable: + return 0.4 + + return 0.0 + + def _fuzzy_match(self, query: str, text: str) -> bool: + """Simple fuzzy matching algorithm.""" + if not query or not text: + return False + + # Create regex pattern for fuzzy matching + pattern = ".*".join(re.escape(char) for char in query) + return bool(re.search(pattern, text, re.IGNORECASE)) + + def _launch_application(self, app: DesktopApp): + app.launch() diff --git a/modules/launcher/plugins/bash_scripts.py b/modules/launcher/plugins/bash_scripts.py new file mode 100644 index 0000000..7a242a6 --- /dev/null +++ b/modules/launcher/plugins/bash_scripts.py @@ -0,0 +1,552 @@ +import json +import os +import threading +import time +from typing import Dict, List + +from fabric.utils import exec_shell_command_async + +import config.data as data +import utils.icons as icons +from modules.launcher.plugin_base import PluginBase +from modules.launcher.result import Result + + +class BashScriptsPlugin(PluginBase): + """ + Plugin for managing and executing bash scripts. + """ + + def __init__(self): + super().__init__() + self.display_name = "Bash Scripts" + self.description = "Manage and execute bash scripts" + + # Configuration + self.scripts_cache_file = os.path.join(data.CACHE_DIR, "bash_scripts.json") + + # Default script directory to scan (only Modus scripts) + self.modus_scripts_dir = os.path.expanduser("~/.config/Modus/scripts") + + # Scripts to exclude from discovery + self.excluded_scripts = { + "screen-capture.sh" # Exclude screen-capture.sh as it's handled by screencapture plugin + } + + # Built-in power manager commands + self.power_manager_commands = { + "pm balanced": { + "description": "Switch to balanced power profile", + "profile": "balanced", + "icon": icons.power_balanced, + }, + "pm performance": { + "description": "Switch to performance power profile", + "profile": "performance", + "icon": icons.power_performance, + }, + "pm saver": { + "description": "Switch to power saver profile", + "profile": "power-saver", + "icon": icons.power_saving, + }, + } + + # In-memory cache + self._scripts_cache: Dict[str, Dict] = {} + self._last_cache_update = 0 + self._cache_update_interval = 300 # 5 minutes + + # Background cache building + self._cache_building = False + self._cache_thread = None + + # Battery service reference + self._battery_service = None + + def initialize(self): + """Initialize the bash scripts plugin.""" + self.set_triggers(["sh"]) + self._load_scripts_cache() + self._start_background_cache_update() + self._get_battery_service() + + def cleanup(self): + """Cleanup the bash scripts plugin.""" + self._scripts_cache.clear() + if self._cache_thread and self._cache_thread.is_alive(): + # Note: We don't join the thread to avoid blocking cleanup + pass + + def _get_battery_service(self): + """Get reference to the battery service.""" + try: + from services.battery import Battery + self._battery_service = Battery() + except Exception as e: + print(f"BashScriptsPlugin: Error getting battery service: {e}") + self._battery_service = None + + def _set_power_profile(self, profile: str) -> bool: + """Set the power profile using the battery service.""" + if not self._battery_service: + print("BashScriptsPlugin: Battery service not available") + return False + + try: + # Check if profile proxy is available + if not hasattr(self._battery_service, '_profile_proxy') or not self._battery_service._profile_proxy: + print("BashScriptsPlugin: Power profile proxy not available") + return False + + # Get available profiles + profiles = self._battery_service._profile_proxy.Profiles + available_profiles = [] + for p in profiles: + if isinstance(p, dict) and "Profile" in p: + available_profiles.append(p["Profile"]) + elif hasattr(p, "Profile"): + available_profiles.append(p.Profile) + elif isinstance(p, str): + available_profiles.append(p) + + # Map profile types to actual profile names + profile_mapping = { + "power-saver": ["power-saver", "powersave", "power_saver"], + "balanced": ["balanced", "balance"], + "performance": ["performance", "performance-mode"] + } + + # Find the actual profile name + actual_profile = None + if profile in profile_mapping: + for candidate in profile_mapping[profile]: + if candidate in available_profiles: + actual_profile = candidate + break + elif profile in available_profiles: + actual_profile = profile + + if actual_profile: + self._battery_service._profile_proxy.ActiveProfile = actual_profile + print(f"BashScriptsPlugin: Successfully set power profile to {actual_profile}") + return True + else: + print(f"BashScriptsPlugin: No matching profile found for '{profile}' in available profiles: {available_profiles}") + return False + + except Exception as e: + print(f"BashScriptsPlugin: Error setting power profile: {e}") + return False + + + + def _load_scripts_cache(self): + """Load scripts cache from JSON file.""" + try: + if os.path.exists(self.scripts_cache_file): + with open(self.scripts_cache_file, "r", encoding="utf-8") as f: + cache_data = json.load(f) + self._scripts_cache = cache_data.get("scripts", {}) + self._last_cache_update = cache_data.get("last_update", 0) + else: + print("BashScriptsPlugin: No cache file found, will build cache in background") + except Exception as e: + print(f"BashScriptsPlugin: Error loading scripts cache: {e}") + self._scripts_cache = {} + self._last_cache_update = 0 + + def _save_scripts_cache(self): + """Save scripts cache to JSON file.""" + try: + os.makedirs(data.CACHE_DIR, exist_ok=True) + cache_data = { + "scripts": self._scripts_cache, + "last_update": self._last_cache_update, + } + with open(self.scripts_cache_file, "w", encoding="utf-8") as f: + json.dump(cache_data, f, indent=2) + except Exception as e: + print(f"BashScriptsPlugin: Error saving scripts cache: {e}") + + def _start_background_cache_update(self): + """Start background thread to update scripts cache.""" + current_time = time.time() + + # Check if cache needs updating + if (current_time - self._last_cache_update > self._cache_update_interval or + not self._scripts_cache): + + if not self._cache_building: + self._cache_building = True + self._cache_thread = threading.Thread( + target=self._build_scripts_cache_background, + daemon=True + ) + self._cache_thread.start() + + def _build_scripts_cache_background(self): + """Build scripts cache in background thread.""" + try: + + new_cache = {} + + # Scan Modus scripts directory for discovered scripts + if os.path.exists(self.modus_scripts_dir) and os.path.isdir(self.modus_scripts_dir): + try: + self._scan_directory_for_scripts(self.modus_scripts_dir, new_cache) + except (PermissionError, FileNotFoundError, OSError) as e: + print(f"BashScriptsPlugin: Error scanning Modus scripts directory: {e}") + + # Update cache atomically + self._scripts_cache = new_cache + self._last_cache_update = time.time() + + # Save to disk + self._save_scripts_cache() + + except Exception as e: + print(f"BashScriptsPlugin: Error building scripts cache: {e}") + finally: + self._cache_building = False + + def _scan_directory_for_scripts(self, directory: str, cache: Dict): + """Scan a directory for bash scripts and add them to cache.""" + try: + with os.scandir(directory) as entries: + for entry in entries: + if entry.is_file(follow_symlinks=False): + script_path = entry.path + script_name = entry.name + + # Skip excluded scripts + if script_name in self.excluded_scripts: + continue + + # Check if it's a script file + if self._is_script_file(script_path): + cache[script_name] = { + "path": script_path, + "name": script_name, + "description": self._get_script_description(script_path), + "type": "discovered", + "executable": os.access(script_path, os.X_OK), + "args": [], + "category": os.path.basename(directory) + } + + except (PermissionError, FileNotFoundError, OSError) as e: + print(f"BashScriptsPlugin: Error scanning directory {directory}: {e}") + except Exception as e: + print(f"BashScriptsPlugin: Unexpected error scanning {directory}: {e}") + + def _is_script_file(self, file_path: str) -> bool: + """Check if a file is a bash script.""" + try: + # Check file extension first (most common case) + if file_path.endswith(('.sh', '.bash')): + return True + + # For files without extension, check shebang + try: + with open(file_path, 'rb') as f: + first_line = f.readline(100).decode('utf-8', errors='ignore') + if first_line.startswith('#!') and ('bash' in first_line or 'sh' in first_line): + return True + except (PermissionError, FileNotFoundError, UnicodeDecodeError): + pass + + return False + except Exception: + return False + + def _get_script_description(self, script_path: str) -> str: + """Extract description from script comments.""" + try: + with open(script_path, 'r', encoding='utf-8', errors='ignore') as f: + lines = f.readlines() + + # Look for description in first few comment lines + for line in lines[:10]: + line = line.strip() + if line.startswith('#') and not line.startswith('#!'): + # Remove leading # and whitespace + desc = line[1:].strip() + if desc and len(desc) > 5: # Meaningful description + return desc + + return f"Script: {os.path.basename(script_path)}" + except (PermissionError, FileNotFoundError, UnicodeDecodeError): + return f"Script: {os.path.basename(script_path)}" + + def query(self, query_string: str) -> List[Result]: + """Search for bash scripts matching the query.""" + query = query_string.strip() + + # Start background update if needed (non-blocking) + if not self._scripts_cache or (time.time() - self._last_cache_update > self._cache_update_interval): + self._start_background_cache_update() + + results = [] + + # Check for power manager commands first + power_results = self._search_power_commands(query) + if power_results: + results.extend(power_results) + + # Handle special commands + if not query: + # Show all scripts when no query + results.extend(self._list_all_scripts()) + # Also show power manager commands when no query + if not power_results: + results.extend(self._list_all_power_commands()) + else: + # Search for scripts + results.extend(self._search_scripts(query)) + + return results + + def _list_all_scripts(self) -> List[Result]: + """List all available scripts.""" + results = [] + max_results = 20 + + # Sort scripts by name for consistent ordering + sorted_scripts = sorted(self._scripts_cache.items(), key=lambda x: x[1].get("name", "")) + + # Add scripts (limit to max_results) + for script_name, script_info in sorted_scripts: + script_results = self._create_script_results_with_args(script_name, script_info, 0.8) + for script_result in script_results: + if len(results) < max_results: + results.append(script_result) + else: + break + if len(results) >= max_results: + break + + return results + + def _search_scripts(self, query: str) -> List[Result]: + """Search for scripts matching the query.""" + results = [] + query_lower = query.lower() + max_results = 15 + + # Categorize matches for better sorting + exact_matches = [] + prefix_matches = [] + partial_matches = [] + description_matches = [] + + for script_name, script_info in self._scripts_cache.items(): + script_name_lower = script_name.lower() + description_lower = script_info.get("description", "").lower() + + # Skip if no match at all + if query_lower not in script_name_lower and query_lower not in description_lower: + continue + + # Categorize matches + if script_name_lower == query_lower: + exact_matches.append((script_name, script_info, 1.0)) + elif script_name_lower.startswith(query_lower): + prefix_matches.append((script_name, script_info, 0.9)) + elif query_lower in script_name_lower: + partial_matches.append((script_name, script_info, 0.7)) + elif query_lower in description_lower: + description_matches.append((script_name, script_info, 0.5)) + + # Combine results in priority order + all_matches = exact_matches + prefix_matches + partial_matches + description_matches + + # Convert to Result objects + for script_name, script_info, relevance in all_matches: + script_results = self._create_script_results_with_args(script_name, script_info, relevance) + for script_result in script_results: + if len(results) < max_results: + results.append(script_result) + else: + break + if len(results) >= max_results: + break + + return results + + def _search_power_commands(self, query: str) -> List[Result]: + """Search for power manager commands matching the query.""" + if not query: + return [] + + results = [] + query_lower = query.lower() + + for cmd, info in self.power_manager_commands.items(): + cmd_lower = cmd.lower() + description_lower = info["description"].lower() + + # Check for exact match or partial match + if (query_lower == cmd_lower or + cmd_lower.startswith(query_lower) or + query_lower in cmd_lower or + query_lower in description_lower): + + relevance = 1.0 if query_lower == cmd_lower else 0.9 + + result = Result( + title=cmd, + subtitle=info["description"], + icon_markup=info["icon"], + action=self._create_power_action(info["profile"]), + relevance=relevance, + plugin_name=self.display_name, + data={"command": cmd, "profile": info["profile"], "type": "power_manager"} + ) + results.append(result) + + return results + + def _list_all_power_commands(self) -> List[Result]: + """List all power manager commands.""" + results = [] + + for cmd, info in self.power_manager_commands.items(): + result = Result( + title=cmd, + subtitle=info["description"], + icon_markup=info["icon"], + action=self._create_power_action(info["profile"]), + relevance=0.8, + plugin_name=self.display_name, + data={"command": cmd, "profile": info["profile"], "type": "power_manager"} + ) + results.append(result) + + return results + + def _create_power_action(self, profile: str): + """Create an action function for setting power profile.""" + def action(): + success = self._set_power_profile(profile) + if success: + # Clear the search query after successful execution + try: + from fabric import Application + from gi.repository import GLib + + app = Application.get_default() + if app and hasattr(app, "launcher"): + launcher = app.launcher + if launcher and hasattr(launcher, "search_entry"): + def clear_search(): + launcher.search_entry.set_text("") + return False + # Use a small delay to ensure the action completes first + GLib.timeout_add(50, clear_search) + except Exception as e: + print(f"BashScriptsPlugin: Could not clear search query: {e}") + return action + + def _create_script_result(self, script_name: str, script_info: Dict, relevance: float) -> Result: + """Create a Result object for a script.""" + script_path = script_info.get("path", "") + description = script_info.get("description", "") + script_type = script_info.get("type", "discovered") + executable = script_info.get("executable", False) + category = script_info.get("category", "") + + # Create subtitle with additional info + subtitle_parts = [] + if description: + subtitle_parts.append(description) + if category: + subtitle_parts.append(f"[{category}]") + if not executable: + subtitle_parts.append("(not executable)") + + subtitle = " | ".join(subtitle_parts) if subtitle_parts else f"Execute: {script_name}" + + # Choose icon based on script type and status + if not executable: + icon_markup = icons.file + elif script_type == "custom": + icon_markup = icons.star + else: + icon_markup = icons.terminal + + return Result( + title=script_name, + subtitle=subtitle, + icon_markup=icon_markup, + action=self._create_script_action(script_name, script_info), + relevance=relevance, + plugin_name=self.display_name, + data={"script_name": script_name, "script_path": script_path, "type": script_type} + ) + + def _create_script_results_with_args(self, script_name: str, script_info: Dict, relevance: float) -> List[Result]: + """Create multiple Result objects for scripts that support arguments.""" + results = [] + + # Check for special scripts that need argument variants + if script_name == "hyprpicker.sh": + # For hyprpicker, only show the argument variants (skip basic version) + variants = [ + ("-rgb", "Pick RGB color"), + ("-hex", "Pick HEX color"), + ("-hsv", "Pick HSV color") + ] + + for arg, desc in variants: + variant_result = Result( + title=f"{script_name} {arg}", + subtitle=f"{desc} | [scripts]", + icon_markup=icons.terminal, + action=self._create_script_action_with_args(script_name, script_info, [arg]), + relevance=relevance + 0.1, # Slightly higher relevance for specific variants + plugin_name=self.display_name, + data={"script_name": script_name, "script_path": script_info.get("path", ""), "type": script_info.get("type", "discovered"), "args": [arg]} + ) + results.append(variant_result) + else: + # For other scripts, create the basic result + basic_result = self._create_script_result(script_name, script_info, relevance) + results.append(basic_result) + + return results + + def _create_script_action(self, script_name: str, script_info: Dict): + """Create an action function for executing a script.""" + def action(): + self._execute_script(script_name, script_info) + return action + + def _create_script_action_with_args(self, script_name: str, script_info: Dict, args: List[str]): + """Create an action function for executing a script with specific arguments.""" + def action(): + # Create a modified script_info with the specific arguments + modified_script_info = script_info.copy() + modified_script_info["args"] = args + self._execute_script(script_name, modified_script_info) + return action + + def _execute_script(self, script_name: str, script_info: Dict): + """Execute a bash script.""" + try: + script_path = script_info.get("path", "") + script_args = script_info.get("args", []) + + if not os.path.exists(script_path): + return + + if not script_info.get("executable", False): + return + + # Build command + command = [script_path] + script_args + exec_shell_command_async(command) + + except Exception as e: + print(f"BashScriptsPlugin: Error executing script '{script_name}': {e}") + diff --git a/modules/launcher/plugins/bookmarks.py b/modules/launcher/plugins/bookmarks.py new file mode 100644 index 0000000..424a80e --- /dev/null +++ b/modules/launcher/plugins/bookmarks.py @@ -0,0 +1,735 @@ +import json +import subprocess +import threading +import time +from pathlib import Path +from typing import Dict, List, Optional +from urllib.parse import urlparse + +from fabric.utils.helpers import get_relative_path +from thefuzz import fuzz + +import utils.icons as icons +from modules.launcher.plugin_base import PluginBase +from modules.launcher.result import Result + + +class BookmarkManager: + """Manages user's custom bookmarks.""" + + def __init__(self, storage_file: Path): + self.storage_file = storage_file + self.bookmarks: List[Dict] = [] + self.cache_lock = threading.Lock() + self.last_loaded = 0 + self.cache_ttl = 30 # Cache for 30 seconds + self._load_bookmarks() + + def _get_favicon_url(self, url: str) -> str: + """Generate favicon URL for a given website URL.""" + try: + parsed = urlparse(url) + return f"{parsed.scheme}://{parsed.netloc}/favicon.ico" + except: + return "" + + def _extract_domain(self, url: str) -> str: + """Extract domain from URL.""" + try: + parsed = urlparse(url) + domain = parsed.netloc + # Remove www. prefix + if domain.startswith("www."): + domain = domain[4:] + return domain + except: + return url + + def _normalize_url(self, url: str) -> str: + """Normalize URL by adding protocol if missing.""" + url = url.strip() + if not url.startswith(("http://", "https://")): + if url.startswith("www."): + url = "https://" + url + else: + url = "https://" + url + return url + + def _load_bookmarks(self): + """Load bookmarks from JSON file with caching.""" + with self.cache_lock: + current_time = time.time() + + # Check if cache is still valid + if ( + current_time - self.last_loaded + ) < self.cache_ttl and self.last_loaded > 0: + return + + try: + if self.storage_file.exists(): + with open(self.storage_file, "r", encoding="utf-8") as f: + data = json.load(f) + self.bookmarks = data.get("bookmarks", []) + else: + # File doesn't exist, start with empty list but don't save yet + self.bookmarks = [] + + self.last_loaded = current_time + except Exception as e: + print(f"Error loading bookmarks: {e}") + self.bookmarks = [] + + def get_bookmarks(self) -> List[Dict]: + """Get bookmarks, loading from file if needed.""" + self._load_bookmarks() + return self.bookmarks + + def _save_bookmarks_unlocked(self): + """Save bookmarks to JSON file without acquiring lock.""" + try: + self.storage_file.parent.mkdir(parents=True, exist_ok=True) + data = { + "bookmarks": self.bookmarks, + "last_updated": time.time(), + } + with open(self.storage_file, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + + # Update cache timestamp + self.last_loaded = time.time() + except Exception as e: + print(f"Error saving bookmarks: {e}") + + def _save_bookmarks(self): + """Save bookmarks to JSON file.""" + with self.cache_lock: + self._save_bookmarks_unlocked() + + def add_bookmark( + self, title: str, url: str, description: str = "", tags: List[str] = None + ) -> bool: + """Add a new bookmark.""" + try: + url = self._normalize_url(url) + + # Check if bookmark already exists + for bookmark in self.bookmarks: + if bookmark["url"] == url: + return False # Already exists + + new_bookmark = { + "title": title.strip(), + "url": url, + "description": description.strip(), + "tags": tags or [], + "created": time.time(), + "accessed": 0, + } + + self.bookmarks.append(new_bookmark) + self._save_bookmarks() + + # Clear cache to force reload + self.last_loaded = 0 + + return True + + except Exception as e: + print(f"Error adding bookmark: {e}") + return False + + def remove_bookmark(self, identifier: str) -> bool: + """Remove a bookmark by title or URL.""" + try: + identifier = identifier.lower().strip() + + for i, bookmark in enumerate(self.bookmarks): + if ( + bookmark["title"].lower() == identifier + or bookmark["url"].lower() == identifier + or self._extract_domain(bookmark["url"]).lower() == identifier + ): + self.bookmarks.pop(i) + self._save_bookmarks() + + # Clear cache to force reload + self.last_loaded = 0 + + return True + + return False + + except Exception as e: + print(f"Error removing bookmark: {e}") + return False + + def update_access_time(self, url: str): + """Update the last accessed time for a bookmark.""" + try: + for bookmark in self.bookmarks: + if bookmark["url"] == url: + bookmark["accessed"] = time.time() + self._save_bookmarks() + break + except Exception as e: + print(f"Error updating access time: {e}") + + def get_bookmark_count(self) -> int: + """Get total number of bookmarks.""" + return len(self.get_bookmarks()) + + +class BookmarksPlugin(PluginBase): + """ + User bookmarks plugin for the launcher. + Allows users to add, remove, and search their own bookmarks. + """ + + def __init__(self): + super().__init__() + self.display_name = "Bookmarks" + self.description = "Manage and search your personal bookmarks" + + # Initialize bookmark manager with storage file + self.bookmark_file = Path( + get_relative_path("../../../config/assets/bookmarks.json") + ) + self.bookmark_manager = BookmarkManager(self.bookmark_file) + self.max_results = 15 + + # Cache for results + self._results_cache = {} + self._cache_timestamps = {} + self._cache_ttl = 30 # 30 seconds + + # Launcher instance for refreshing + self._launcher_instance = None + self._original_close_launcher = None + + def initialize(self): + """Initialize the bookmarks plugin.""" + self.set_triggers(["bm"]) + self._setup_launcher_hooks() + + def cleanup(self): + """Cleanup the bookmarks plugin.""" + self._results_cache.clear() + self._cache_timestamps.clear() + self._cleanup_launcher_hooks() + + def query(self, query_string: str) -> List[Result]: + """Process bookmark queries with caching.""" + query_key = query_string.strip() + current_time = time.time() + + # Check cache first (except for add/remove commands which should always execute) + if ( + not query_key.startswith(("add ", "remove ", "delete ", "rm ")) + and query_key in self._results_cache + and (current_time - self._cache_timestamps.get(query_key, 0)) + < self._cache_ttl + ): + return self._results_cache[query_key] + + query = query_key.lower() + results = [] + + if not query: + # Show recent/popular bookmarks when no query + results = self._get_recent_bookmarks() + elif query.startswith("add "): + # Add new bookmark (don't cache) + results = self._handle_add_command(query[4:].strip()) + elif query.startswith(("remove ", "delete ", "rm ")): + # Remove bookmark (don't cache) + command_parts = query_key.split(" ", 1) + if len(command_parts) > 1: + results = self._handle_remove_command(command_parts[1].strip()) + else: + results = self._show_remove_help() + else: + # Search bookmarks + results = self._search_bookmarks(query) + + # Cache results (except for add/remove commands) + if not query.startswith(("add ", "remove ", "delete ", "rm ")): + self._results_cache[query_key] = results + self._cache_timestamps[query_key] = current_time + + return results + + def _search_bookmarks(self, query: str) -> List[Result]: + """Search through bookmarks.""" + bookmarks = self.bookmark_manager.get_bookmarks() + + if not bookmarks: + return [ + Result( + title="No Bookmarks Found", + subtitle="Use 'add <url>' to add your first bookmark", + icon_markup=icons.info, + action=lambda: None, + relevance=1.0, + plugin_name=self.display_name, + data={"type": "info", "keep_launcher_open": True}, + ) + ] + + # Search bookmarks + results = [] + for bookmark in bookmarks: + relevance = self._calculate_relevance(bookmark, query) + if relevance > 0.3: # Only show relevant results + result = self._create_bookmark_result(bookmark, relevance) + if result: + results.append(result) + + # Sort by relevance and limit results + results.sort(key=lambda r: r.relevance, reverse=True) + return results[: self.max_results] + + def _get_recent_bookmarks(self) -> List[Result]: + """Get recent/popular bookmarks when no query is provided.""" + bookmarks = self.bookmark_manager.get_bookmarks() + + if not bookmarks: + return [ + Result( + title="No Bookmarks Yet", + subtitle="Use 'add <title> <url>' to add your first bookmark", + icon_markup=icons.info, + action=lambda: None, + relevance=1.0, + plugin_name=self.display_name, + data={"type": "help", "keep_launcher_open": True}, + ), + Result( + title="Example: Add Google", + subtitle="add Google https://google.com", + icon_markup=icons.bulb, + action=lambda: None, + relevance=0.9, + plugin_name=self.display_name, + data={"type": "example", "keep_launcher_open": True}, + ), + ] + + # Sort by access time (most recent first) and show top 10 + sorted_bookmarks = sorted( + bookmarks, key=lambda b: b.get("accessed", 0), reverse=True + ) + results = [] + for bookmark in sorted_bookmarks[:10]: + result = self._create_bookmark_result(bookmark, 0.8) + if result: + results.append(result) + + return results + + def _handle_add_command(self, args: str) -> List[Result]: + """Handle add bookmark command.""" + if not args: + return [ + Result( + title="Add Bookmark", + subtitle="Usage: add <title> <url> [description]", + icon_markup=icons.info, + action=lambda: None, + relevance=1.0, + plugin_name=self.display_name, + data={"type": "help", "keep_launcher_open": True}, + ), + Result( + title="Example", + subtitle="add Google https://google.com Search engine", + icon_markup=icons.bulb, + action=lambda: None, + relevance=0.9, + plugin_name=self.display_name, + data={"type": "example", "keep_launcher_open": True}, + ), + ] + + # Parse arguments: title url [description] + parts = args.split() + if len(parts) < 2: + return [ + Result( + title="Invalid Format", + subtitle="Usage: add <title> <url> [description]", + icon_markup=icons.alert, + action=lambda: None, + relevance=1.0, + plugin_name=self.display_name, + data={"type": "error", "keep_launcher_open": True}, + ) + ] + + title = parts[0] + url = parts[1] + description = " ".join(parts[2:]) if len(parts) > 2 else "" + + # Check if bookmark already exists + normalized_url = self.bookmark_manager._normalize_url(url) + existing_bookmarks = self.bookmark_manager.get_bookmarks() + already_exists = any( + bookmark["url"] == normalized_url for bookmark in existing_bookmarks + ) + + if already_exists: + return [ + Result( + title="Bookmark Already Exists", + subtitle=f"A bookmark with URL '{normalized_url}' already exists", + icon_markup=icons.alert, + action=lambda: None, + relevance=1.0, + plugin_name=self.display_name, + data={"type": "error", "keep_launcher_open": True}, + ) + ] + + # Show add action - will execute on Enter + domain = self.bookmark_manager._extract_domain(normalized_url) + subtitle = f"Click to add bookmark: {domain}" + if description: + subtitle += f" • {description}" + + return [ + Result( + title=f"Add bookmark for '{title}'", + subtitle=subtitle, + icon_markup=icons.plus, + action=lambda: self._add_bookmark_action(title, url, description), + relevance=1.0, + plugin_name=self.display_name, + data={"type": "add", "name": title, "keep_launcher_open": True}, + ) + ] + + def _handle_remove_command(self, identifier: str) -> List[Result]: + """Handle remove bookmark command.""" + if not identifier: + return self._show_remove_help() + + # Find matching bookmarks + bookmarks = self.bookmark_manager.get_bookmarks() + identifier_lower = identifier.lower().strip() + + matching_bookmarks = [] + for bookmark in bookmarks: + if ( + bookmark["title"].lower() == identifier_lower + or bookmark["url"].lower() == identifier_lower + or self.bookmark_manager._extract_domain(bookmark["url"]).lower() + == identifier_lower + ): + matching_bookmarks.append(bookmark) + + if not matching_bookmarks: + return [ + Result( + title="Bookmark Not Found", + subtitle=f"No bookmark found matching '{identifier}'", + icon_markup=icons.alert, + action=lambda: None, + relevance=1.0, + plugin_name=self.display_name, + data={"type": "error", "keep_launcher_open": True}, + ) + ] + + # Show remove action - will execute on Enter + bookmark = matching_bookmarks[0] # Take first match + title = bookmark.get("title", "Untitled") + domain = self.bookmark_manager._extract_domain(bookmark.get("url", "")) + + return [ + Result( + title=f"Remove bookmark '{title}'?", + subtitle=f"Click to confirm deletion: {domain}", + icon_markup=icons.trash, + action=lambda: self._remove_bookmark_action(identifier), + relevance=1.0, + plugin_name=self.display_name, + data={"type": "remove", "name": title, "keep_launcher_open": True}, + ) + ] + + def _show_remove_help(self) -> List[Result]: + """Show help for remove command.""" + bookmarks = self.bookmark_manager.get_bookmarks() + results = [ + Result( + title="Remove Bookmark", + subtitle="Usage: remove <title|url|domain>", + icon_markup=icons.info, + action=lambda: None, + relevance=1.0, + plugin_name=self.display_name, + data={"type": "help", "keep_launcher_open": True}, + ) + ] + + # Show available bookmarks to remove + if bookmarks: + results.append( + Result( + title="Available Bookmarks:", + subtitle=f"{len(bookmarks)} bookmarks available to remove", + icon_markup=icons.bookmark, + action=lambda: None, + relevance=0.9, + plugin_name=self.display_name, + data={"type": "info", "keep_launcher_open": True}, + ) + ) + + # Show first few bookmarks as examples + for bookmark in bookmarks[:3]: + title = bookmark.get("title", "Untitled") + domain = self._extract_domain(bookmark.get("url", "")) + results.append( + Result( + title=f"remove {title}", + subtitle=f"Click to remove: {title} ({domain})", + icon_markup=icons.trash, + action=lambda t=title: self._remove_bookmark_action(t), + relevance=0.8, + plugin_name=self.display_name, + data={ + "type": "remove_option", + "bookmark": bookmark, + "keep_launcher_open": True, + }, + ) + ) + + return results + + def _add_bookmark_action(self, title: str, url: str, description: str = ""): + """Execute the add bookmark action.""" + success = self.bookmark_manager.add_bookmark(title, url, description) + if success: + print(f"✓ Added bookmark '{title}' - {url}") + # Clear cache to force refresh + self._results_cache.clear() + self._cache_timestamps.clear() + # Reset to trigger word and refresh + self._reset_to_trigger() + else: + print(f"✗ Failed to add bookmark '{title}' - already exists") + + def _remove_bookmark_action(self, identifier: str): + """Execute the remove bookmark action.""" + success = self.bookmark_manager.remove_bookmark(identifier) + if success: + print(f"✓ Removed bookmark '{identifier}'") + # Clear cache to force refresh + self._results_cache.clear() + self._cache_timestamps.clear() + # Reset to trigger word and refresh + self._reset_to_trigger() + else: + print(f"✗ Failed to remove bookmark '{identifier}' - not found") + + def _remove_bookmark_with_reset(self, identifier: str): + """Execute the remove bookmark action via alt_action (Shift+Enter) and reset to trigger.""" + success = self.bookmark_manager.remove_bookmark(identifier) + if success: + print(f"✓ Removed bookmark '{identifier}'") + # Clear cache to force refresh + self._results_cache.clear() + self._cache_timestamps.clear() + # Reset to trigger word and refresh + self._reset_to_trigger() + else: + print(f"✗ Failed to remove bookmark '{identifier}' - not found") + + def _calculate_relevance(self, bookmark: Dict, query: str) -> float: + """Calculate relevance score for a bookmark.""" + title = bookmark.get("title", "").lower() + url = bookmark.get("url", "").lower() + description = bookmark.get("description", "").lower() + + # Exact title match + if query == title: + return 1.0 + + # Title starts with query + if title.startswith(query): + return 0.95 + + # Query in title + if query in title: + position = title.index(query) + position_score = 1.0 - (position / len(title)) + return 0.8 + (position_score * 0.1) + + # Query in URL + if query in url: + return 0.7 + + # Query in description + if query in description: + return 0.6 + + # Fuzzy matching for title + if len(query) >= 3: + fuzzy_score = fuzz.partial_ratio(query, title) / 100.0 + if fuzzy_score >= 0.7: + return fuzzy_score * 0.6 + + return 0.0 + + def _create_bookmark_result( + self, bookmark: Dict, relevance: float + ) -> Optional[Result]: + """Create a Result object for a bookmark.""" + try: + title = bookmark.get("title", "Untitled") + url = bookmark.get("url", "") + description = bookmark.get("description", "") + + # Truncate long titles + if len(title) > 60: + title = title[:57] + "..." + + # Create subtitle with domain and description + domain = self._extract_domain(url) + if description: + subtitle = f"{domain} • {description}" + else: + subtitle = domain + + return Result( + title=title, + subtitle=subtitle, + icon_markup=icons.bookmark, + action=lambda u=url: self._open_bookmark(u), + relevance=relevance, + plugin_name=self.display_name, + data={ + "type": "bookmark", + "url": url, + "domain": domain, + "description": description, + "keep_launcher_open": False, + "alt_action": lambda t=title: self._remove_bookmark_with_reset(t), + }, + ) + except Exception as e: + print(f"Error creating bookmark result: {e}") + return None + + def _extract_domain(self, url: str) -> str: + """Extract domain from URL.""" + try: + parsed = urlparse(url) + domain = parsed.netloc + # Remove www. prefix + if domain.startswith("www."): + domain = domain[4:] + return domain + except: + return url + + def _open_bookmark(self, url: str): + """Open bookmark URL in default browser and update access time.""" + try: + # Update access time + self.bookmark_manager.update_access_time(url) + + # Open URL + subprocess.Popen( + ["xdg-open", url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL + ) + except Exception as e: + print(f"Failed to open bookmark: {e}") + + def _setup_launcher_hooks(self): + """Setup hooks to monitor launcher state.""" + try: + # Try to find the launcher instance + import gc + + for obj in gc.get_objects(): + if ( + hasattr(obj, "__class__") + and obj.__class__.__name__ == "Launcher" + and hasattr(obj, "close_launcher") + ): + self._launcher_instance = obj + break + except Exception as e: + print(f"Warning: Could not setup launcher hooks: {e}") + + def _cleanup_launcher_hooks(self): + """Cleanup launcher hooks.""" + try: + self._launcher_instance = None + except Exception as e: + print(f"Warning: Could not cleanup launcher hooks: {e}") + + def _reset_to_trigger(self): + """Reset launcher to trigger word and refresh.""" + try: + if self._launcher_instance and hasattr( + self._launcher_instance, "search_entry" + ): + # Get the current trigger (bookmark or bm) + current_text = self._launcher_instance.search_entry.get_text() + trigger = "bookmark " + + # Determine which trigger was used + if current_text.lower().startswith("bm "): + trigger = "bm " + + # Reset to trigger word with space + try: + from gi.repository import GLib + + def reset_and_refresh(): + # Set text to trigger word + self._launcher_instance.search_entry.set_text(trigger) + # Position cursor at end + self._launcher_instance.search_entry.set_position(-1) + # Trigger search to show default bookmarks + self._launcher_instance._perform_search(trigger) + return False + + GLib.timeout_add(50, reset_and_refresh) + except ImportError: + # Fallback: direct call if GLib not available + self._launcher_instance.search_entry.set_text(trigger) + self._launcher_instance.search_entry.set_position(-1) + self._launcher_instance._perform_search(trigger) + except Exception as e: + print(f"Could not reset to trigger: {e}") + + def _force_launcher_refresh(self): + """Force the launcher to refresh and show updated results.""" + try: + if self._launcher_instance and hasattr( + self._launcher_instance, "_perform_search" + ): + # Get current search text + current_text = "" + if hasattr(self._launcher_instance, "search_entry"): + current_text = self._launcher_instance.search_entry.get_text() + + # Trigger a search to refresh results + try: + from gi.repository import GLib + + def refresh(): + self._launcher_instance._perform_search(current_text) + return False + + GLib.timeout_add(50, refresh) + except ImportError: + # Fallback: direct call if GLib not available + self._launcher_instance._perform_search(current_text) + except Exception as e: + print(f"Could not force launcher refresh: {e}") diff --git a/modules/launcher/plugins/caffeine.py b/modules/launcher/plugins/caffeine.py new file mode 100644 index 0000000..433d0c1 --- /dev/null +++ b/modules/launcher/plugins/caffeine.py @@ -0,0 +1,209 @@ +import subprocess +from threading import Timer +from typing import List + +import gi +from fabric.utils import get_relative_path +from fabric.utils.helpers import exec_shell_command_async + +import config.data as data +import utils.icons as icons +from modules.launcher.plugin_base import PluginBase +from modules.launcher.result import Result + +gi.require_version("Gtk", "3.0") + + +class CaffeinePlugin(PluginBase): + """ + Plugin for preventing system idle using the inhibit script. + """ + + def __init__(self): + super().__init__() + self.display_name = "Caffeine" + self.description = "Prevent system from going idle" + self.inhibit_script = get_relative_path("../../../utils/inhibit.py") + + # Predefined durations + self.durations = { + "30m": "30 minutes", + "1h": "1 hour", + "2h": "2 hours", + "4h": "4 hours", + "8h": "8 hours", + "on": "On", + "off": "Off", + } + + def initialize(self): + """Initialize the caffeine plugin.""" + self.set_triggers(["caffeine"]) + + def cleanup(self): + """Cleanup the caffeine plugin.""" + pass + + def _create_inhibit_action(self, duration: str): + """Create an inhibit action that properly captures the duration.""" + + def action(): + try: + # Run the script in the background without waiting + subprocess.Popen( + ["python3", self.inhibit_script, duration], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, # Run in a new session to prevent hanging + ) + + # Send notification based on duration + if duration.lower() == "off": + # Deactivation notification + exec_shell_command_async( + f"notify-send '☕ Caffeine' 'Deactivated' -a '{ + data.APP_NAME_CAP + }' -e" + ) + else: + # Activation notification with duration + duration_text = self.durations.get(duration, duration) + exec_shell_command_async( + f"notify-send '☕ Caffeine' 'Activated for { + duration_text + }' -a '{data.APP_NAME_CAP}' -e" + ) + + # Schedule expiration notification for timed durations + if duration.lower() not in ["on", "off"]: + self._schedule_expiration_notification(duration, duration_text) + + except Exception as e: + print(f"Error starting inhibit script: {e}") + + return action + + def _parse_duration_to_seconds(self, duration_str: str) -> int: + """Parse duration string into seconds. Same logic as inhibit.py""" + try: + if duration_str.lower() in ["on", "off"]: + return 0 + elif duration_str.endswith("h"): + return int(float(duration_str[:-1]) * 3600) + elif duration_str.endswith("m"): + return int(float(duration_str[:-1]) * 60) + elif duration_str.endswith("s"): + return int(float(duration_str[:-1])) + else: + return int(duration_str) + except ValueError: + return 0 + + def _schedule_expiration_notification(self, duration: str, duration_text: str): + """Schedule a notification for when the caffeine effect expires.""" + seconds = self._parse_duration_to_seconds(duration) + if seconds > 0: + + def send_expiration_notification(): + exec_shell_command_async( + f"notify-send '☕ Caffeine' 'Expired after {duration_text}' -a '{ + data.APP_NAME_CAP + }' -e" + ) + + # Schedule the notification + timer = Timer(seconds, send_expiration_notification) + timer.daemon = True # Don't prevent program exit + timer.start() + + def _is_valid_duration(self, query: str) -> bool: + """Check if the query is a valid duration format.""" + if query in self.durations: + return True + if query.isdigit(): + return True + if query.endswith(("h", "m", "s")) and query[:-1].replace(".", "").isdigit(): + return True + return False + + def _get_default_action(self, query: str): + """Get the default action for a direct duration input.""" + if self._is_valid_duration(query): + return self._create_inhibit_action(query) + return None + + def query(self, query_string: str) -> List[Result]: + """Search caffeine durations based on query.""" + # For empty queries, show all available durations + if not query_string.strip(): + query_string = "" # Will match all durations in the loop below + + query = query_string.lower().strip() + results = [] + + # Handle direct search entry (e.g., "caffeine 30m" or just "30m") + if query.startswith("caffeine "): + query = query[9:].strip() # Remove "caffeine " prefix + # If query becomes empty after removing prefix, show all durations + if not query: + query = "" # Will match all durations in the loop below + elif query == "caffeine": + # Handle just "caffeine" without space - show all durations + query = "" + elif not query: + # Handle empty query - show all durations + pass + elif ( + query in self.durations + or query.isdigit() + or ( + query.endswith(("h", "m", "s")) + and query[:-1].replace(".", "").isdigit() + ) + ): + # If query is a valid duration without prefix, use it directly + pass + else: + return [] + + # Add custom duration option + if query.isdigit() or ( + query.endswith(("h", "m", "s")) and query[:-1].replace(".", "").isdigit() + ): + result = Result( + title=f"Custom: {query}", + subtitle="Set custom duration", + icon_markup=icons.coffee, + action=self._create_inhibit_action(query), + relevance=1.0, + plugin_name=self.display_name, + data={"duration": query}, + ) + results.append(result) + + # Add predefined durations + for duration, description in self.durations.items(): + # If query is empty, show all durations; otherwise filter by query + if not query or query in duration or query in description.lower(): + # Special handling for "off" - it should stop inhibition + if duration.lower() == "off": + subtitle = "Stop idle inhibition" + else: + subtitle = f"Prevent idle for {description.lower()}" + + result = Result( + title=description, + subtitle=subtitle, + icon_markup=icons.coffee, + action=self._create_inhibit_action(duration), + relevance=0.9 if query == duration else 0.7, + plugin_name=self.display_name, + data={"duration": duration}, + ) + results.append(result) + + # Set default action for direct duration input + if results: + results[0].default_action = self._get_default_action(query) + + return results diff --git a/modules/launcher/plugins/calculator.py b/modules/launcher/plugins/calculator.py new file mode 100644 index 0000000..1468154 --- /dev/null +++ b/modules/launcher/plugins/calculator.py @@ -0,0 +1,166 @@ +import math +import re +import subprocess +import time +from typing import List + +import utils.icons as icons +from modules.launcher.plugin_base import PluginBase +from modules.launcher.result import Result +from utils.conversion import Conversion + + +class CalculatorPlugin(PluginBase): + """ + Plugin for calculating mathematical expressions and converting units. + """ + + def __init__(self): + super().__init__() + self.display_name = "Calculator" + self.description = "Evaluate mathematical expressions and convert units" + + # Safe functions for evaluation + self.safe_functions = { + "abs": abs, + "round": round, + "min": min, + "max": max, + "sum": sum, + "pow": pow, + "sqrt": math.sqrt, + "sin": math.sin, + "cos": math.cos, + "tan": math.tan, + "asin": math.asin, + "acos": math.acos, + "atan": math.atan, + "log": math.log, + "log10": math.log10, + "exp": math.exp, + "pi": math.pi, + "e": math.e, + } + + # Initialize conversion utility + self.converter = Conversion() + + # Pre-compiled regex patterns + self.expression_pattern = re.compile(r"[\d+\-*/^()=]") + self.number_pattern = re.compile(r"\d") + self.conversion_pattern = re.compile( + r"(\d+(?:\.\d+)?)\s*([a-zA-Z]+)\s*(?:to|in|=)\s*([a-zA-Z]+)" + ) + + # Cache for conversion results + self._conversion_cache = {} + self._last_cache_cleanup = time.time() + self._cache_cleanup_interval = 300 # 5 minutes + + def initialize(self): + """Initialize the files plugin.""" + self.set_triggers(["="]) + + def _cleanup_cache(self): + """Clean up old cache entries.""" + current_time = time.time() + if current_time - self._last_cache_cleanup > self._cache_cleanup_interval: + self._conversion_cache.clear() + self._last_cache_cleanup = current_time + + def query(self, query: str) -> List[Result]: + """Process a query and return results.""" + if not query: + return [] + + # Clean up cache periodically + self._cleanup_cache() + + # Check if it's a conversion query + conversion_match = self.conversion_pattern.match(query) + if conversion_match: + try: + value, from_unit, to_unit = conversion_match.groups() + value = float(value) + + # Check cache first + cache_key = f"{value}_{from_unit}_{to_unit}" + if cache_key in self._conversion_cache: + result = self._conversion_cache[cache_key] + subtitle = f"{value} {from_unit} = {result:.6g} {to_unit}" + else: + # Use the conversion utility + result = self.converter.convert(value, from_unit, to_unit) + # Cache the result + self._conversion_cache[cache_key] = result + subtitle = f"{value} {from_unit} = {result:.6g} {to_unit}" + + + + return [ + Result( + title=f"{result:.6g} {to_unit}", + subtitle=subtitle, + icon_markup=icons.calculator, + action=lambda r=f"{result:.6g}": self._copy_to_clipboard(r), + relevance=1.0, + plugin_name=self.display_name, + data={"from": (value, from_unit), "to": (result, to_unit)}, + ) + ] + except ValueError as e: + return [ + Result( + title="Invalid conversion", + subtitle=str(e), + icon_markup=icons.calculator, + relevance=0.0, + plugin_name=self.display_name, + ) + ] + + # Check if it's a math expression + if self.expression_pattern.search(query): + try: + # Evaluate the expression + result = eval(query, {"__builtins__": {}}, self.safe_functions) + if isinstance(result, (int, float)): + return [ + Result( + title=f"{result:.6g}", + subtitle=f"{query} = {result:.6g}", + icon_markup=icons.calculator, + action=lambda r=f"{result:.6g}": self._copy_to_clipboard(r), + relevance=1.0, + plugin_name=self.display_name, + ) + ] + except Exception: + pass + + return [] + + def _format_cache_age(self, age_seconds: float) -> str: + """Format cache age for display.""" + if age_seconds < 60: + return f"{int(age_seconds)}s ago" + elif age_seconds < 3600: + return f"{int(age_seconds // 60)}m ago" + else: + return f"{int(age_seconds // 3600)}h ago" + + def _copy_to_clipboard(self, text: str): + """Copy text to clipboard using cliphist.""" + try: + # First copy to clipboard + subprocess.run(["wl-copy"], input=text.encode(), check=True) + # Then store in cliphist + subprocess.run(["cliphist", "store"], input=text.encode(), check=True) + except subprocess.CalledProcessError: + # If cliphist fails, at least we have the text in clipboard + pass + + def cleanup(self): + """Clean up resources.""" + self._conversion_cache.clear() + self.converter.cleanup() diff --git a/modules/launcher/plugins/calendar.py b/modules/launcher/plugins/calendar.py new file mode 100644 index 0000000..5d75ae5 --- /dev/null +++ b/modules/launcher/plugins/calendar.py @@ -0,0 +1,404 @@ +import calendar +import datetime +from typing import List + +import gi +from fabric.widgets.label import Label +from gi.repository import Gtk + +import utils.icons as icons +from modules.launcher.plugin_base import PluginBase +from modules.launcher.result import Result + +gi.require_version("Gtk", "3.0") + + +class CalendarWidget(Gtk.Box): + """Visual calendar widget showing month grid with dates and navigation buttons.""" + + def __init__(self, month: int, year: int, plugin_instance=None): + super().__init__( + name="calendar-widget", orientation=Gtk.Orientation.VERTICAL, spacing=2 + ) + + # Set size constraints to fit within launcher (following CSS) + self.set_size_request(540, 240) + self.set_vexpand(False) + self.set_hexpand(True) + + self.display_month = month + self.display_year = year + self.today = datetime.date.today() + self.plugin = plugin_instance + + # Make widget focusable for keyboard events + self.set_can_focus(True) + self.connect("key-press-event", self._on_key_press) + + self.setup_ui() + + def setup_ui(self): + """Setup the calendar UI with navigation buttons.""" + # Header with navigation buttons + header_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + header_box.set_halign(Gtk.Align.CENTER) + header_box.set_name("calendar-header-box") + + # Previous button + prev_button = Gtk.Button( + child=Label(markup=icons.chevron_left, name="calendar-nav-button") + ) + prev_button.connect("clicked", self._on_previous_clicked) + + # Month/Year label (following CSS naming) + month_name = calendar.month_name[self.display_month] + self.month_label = Label( + name="calendar-month-year", + label=f"{month_name} {self.display_year}", + h_align="center", + ) + # Fixed width for consistent layout + self.month_label.set_size_request(150, -1) + + # Next button + next_button = Gtk.Button( + child=Label(markup=icons.chevron_right, name="calendar-nav-button") + ) + next_button.connect("clicked", self._on_next_clicked) + + # Add to header box + header_box.pack_start(prev_button, False, False, 0) + header_box.pack_start(self.month_label, True, True, 0) + header_box.pack_start(next_button, False, False, 0) + + # Calendar grid (following CSS naming) + grid = Gtk.Grid( + name="calendar-grid", + column_spacing=1, + row_spacing=1, + column_homogeneous=True, + row_homogeneous=True, + ) + + # Day headers (following CSS naming) + day_names = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] + for i, day_name in enumerate(day_names): + day_header = Label( + name="calendar-day-header", label=day_name, h_align="center" + ) + grid.attach(day_header, i, 0, 1, 1) + + # Calendar days (following CSS naming) + cal = calendar.monthcalendar(self.display_year, self.display_month) + for week_num, week in enumerate(cal, start=1): + for day_num, day in enumerate(week): + if day == 0: + # Empty cell (following CSS naming) + empty = Label(name="calendar-day-empty", label="") + grid.attach(empty, day_num, week_num, 1, 1) + else: + # Day label (following CSS naming) + day_label = Label( + name="calendar-day-label", label=str(day), h_align="center" + ) + + # Highlight today (CSS class) + if ( + day == self.today.day + and self.display_month == self.today.month + and self.display_year == self.today.year + ): + day_label.get_style_context().add_class("today") + + # Highlight weekends (CSS class) + if day_num in [6]: # Saturday + day_label.get_style_context().add_class("weekend") + + grid.attach(day_label, day_num, week_num, 1, 1) + + # Add to main box + self.pack_start(header_box, False, False, 0) + self.pack_start(grid, True, True, 0) + self.show_all() + + def _on_previous_clicked(self, button): + """Handle previous month button click.""" + if self.plugin: + self.plugin._navigate_to_previous() + + def _on_next_clicked(self, button): + """Handle next month button click.""" + if self.plugin: + self.plugin._navigate_to_next() + + def _on_key_press(self, widget, event): + """Handle keyboard navigation for calendar.""" + from gi.repository import Gdk + + keyval = event.keyval + + # Left arrow - previous month + if keyval == Gdk.KEY_Left: + if self.plugin: + self.plugin._navigate_to_previous() + return True + + # Right arrow - next month + if keyval == Gdk.KEY_Right: + if self.plugin: + self.plugin._navigate_to_next() + return True + + # Home - go to current month + if keyval == Gdk.KEY_Home: + if self.plugin: + self.plugin._reset_to_current_month() + self.plugin._force_launcher_refresh() + return True + + return False + + def update_display(self, month: int, year: int): + """Update the calendar to show a different month/year.""" + self.display_month = month + self.display_year = year + + # Update month label + month_name = calendar.month_name[month] + self.month_label.set_label(f"{month_name} {year}") + + # Rebuild the calendar grid + # Find and remove the old grid + for child in self.get_children(): + if isinstance(child, Gtk.Grid): + self.remove(child) + child.destroy() + break + + # Create new grid (following CSS naming) + grid = Gtk.Grid( + name="calendar-grid", + column_spacing=1, + row_spacing=1, + column_homogeneous=True, + row_homogeneous=True, + ) + + # Day headers (following CSS naming) + day_names = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] + for i, day_name in enumerate(day_names): + day_header = Label( + name="calendar-day-header", label=day_name, h_align="center" + ) + grid.attach(day_header, i, 0, 1, 1) + + # Calendar days (following CSS naming) + cal = calendar.monthcalendar(year, month) + for week_num, week in enumerate(cal, start=1): + for day_num, day in enumerate(week): + if day == 0: + # Empty cell (following CSS naming) + empty = Label(name="calendar-day-empty", label="") + grid.attach(empty, day_num, week_num, 1, 1) + else: + # Day label (following CSS naming) + day_label = Label( + name="calendar-day-label", label=str(day), h_align="center" + ) + + # Highlight today (CSS class) + if ( + day == self.today.day + and month == self.today.month + and year == self.today.year + ): + day_label.get_style_context().add_class("today") + + # Highlight weekends (CSS class) + if day_num in [6]: # Saturday and Sunday + day_label.get_style_context().add_class("weekend") + + grid.attach(day_label, day_num, week_num, 1, 1) + + # Add new grid + self.pack_start(grid, True, True, 0) + grid.show_all() + + +class CalendarPlugin(PluginBase): + """ + Calendar plugin for the launcher. + Simple text-based calendar with working Enter key navigation. + """ + + def __init__(self): + super().__init__() + self.display_name = "Calendar" + self.description = "Calendar with visual dates and navigation buttons" + self._current_widget = None + # Current display state + self._current_month = None + self._current_year = None + + def initialize(self): + """Initialize the Calendar plugin.""" + self.set_triggers(["cal"]) + self.description = "Calendar with visual dates and navigation. Use ← → arrows for months, Home for current month" + + def cleanup(self): + """Cleanup the Calendar plugin.""" + if self._current_widget: + if self._current_widget.get_parent(): + self._current_widget.get_parent().remove(self._current_widget) + self._current_widget.destroy() + self._current_widget = None + # Reset to current month when cleaning up + self._reset_to_current_month() + + def query(self, query_string: str) -> List[Result]: + """Process Calendar queries.""" + results = [] + query = query_string.strip().lower() + today = datetime.date.today() + + # Handle date queries + if query in ["today", "now"]: + date_str = today.strftime("%A, %B %d, %Y") + results.append( + Result( + title=date_str, + subtitle="Today's date • Click to copy", + icon_markup=icons.calendar, + action=lambda: self._copy_to_clipboard(date_str), + relevance=1.0, + plugin_name=self.display_name, + ) + ) + return results + + elif query == "tomorrow": + tomorrow = today + datetime.timedelta(days=1) + date_str = tomorrow.strftime("%A, %B %d, %Y") + results.append( + Result( + title=date_str, + subtitle="Tomorrow's date • Click to copy", + icon_markup=icons.calendar, + action=lambda: self._copy_to_clipboard(date_str), + relevance=1.0, + plugin_name=self.display_name, + ) + ) + return results + + elif query == "yesterday": + yesterday = today - datetime.timedelta(days=1) + date_str = yesterday.strftime("%A, %B %d, %Y") + results.append( + Result( + title=date_str, + subtitle="Yesterday's date • Click to copy", + icon_markup=icons.calendar, + action=lambda: self._copy_to_clipboard(date_str), + relevance=1.0, + plugin_name=self.display_name, + ) + ) + return results + + # Clean up previous widget + self.cleanup() + + # Check if we should reset to current month + # This happens when the query is empty or just the trigger + if not query or query in ["", "cal"]: + self._reset_to_current_month() + + # Determine which month to show + if self._current_month is None: + # First time - show current month + display_month = today.month + display_year = today.year + else: + # Use stored month/year + display_month = self._current_month + display_year = self._current_year + + # Update stored state + self._current_month = display_month + self._current_year = display_year + + # Create calendar widget with navigation buttons + calendar_widget = CalendarWidget(display_month, display_year, self) + self._current_widget = calendar_widget + + month_name = calendar.month_name[display_month] + results.append( + Result( + title=f"Calendar - {month_name} {display_year}", + subtitle="← → navigate months, Home for current month", + icon_markup=icons.calendar, + action=lambda: None, + relevance=1.0, + plugin_name=self.display_name, + custom_widget=calendar_widget, + data={"type": "calendar_display"}, + ) + ) + + return results + + def _navigate_to_next(self): + """Navigate to next month (called by button click).""" + if self._current_month and self._current_year: + next_month, next_year = self._advance_month( + self._current_month, self._current_year, 1 + ) + self._current_month = next_month + self._current_year = next_year + + # Update the calendar widget display + if self._current_widget: + self._current_widget.update_display(next_month, next_year) + + def _navigate_to_previous(self): + """Navigate to previous month (called by button click).""" + if self._current_month and self._current_year: + prev_month, prev_year = self._advance_month( + self._current_month, self._current_year, -1 + ) + self._current_month = prev_month + self._current_year = prev_year + + # Update the calendar widget display + if self._current_widget: + self._current_widget.update_display(prev_month, prev_year) + + def _advance_month(self, month: int, year: int, direction: int) -> tuple[int, int]: + """Advance month by direction (1 for next, -1 for previous).""" + if direction > 0: # Next + if month == 12: + return 1, year + 1 + else: + return month + 1, year + else: # Previous + if month == 1: + return 12, year - 1 + else: + return month - 1, year + + def _reset_to_current_month(self): + """Reset calendar to current month.""" + today = datetime.date.today() + self._current_month = today.month + self._current_year = today.year + + def _copy_to_clipboard(self, text: str): + """Copy text to clipboard.""" + try: + import subprocess + + subprocess.run(["wl-copy"], input=text.encode(), check=True) + print(f"Copied to clipboard: {text}") + except subprocess.CalledProcessError: + print(f"Failed to copy to clipboard: {text}") diff --git a/modules/launcher/plugins/clipboard.py b/modules/launcher/plugins/clipboard.py new file mode 100644 index 0000000..d86bd5a --- /dev/null +++ b/modules/launcher/plugins/clipboard.py @@ -0,0 +1,438 @@ +import os +import subprocess +import sys +import tempfile +import threading +import time +from concurrent.futures import ThreadPoolExecutor +from typing import Dict, List, Optional + +from gi.repository import GdkPixbuf, GLib + +from modules.launcher.plugin_base import PluginBase +from modules.launcher.result import Result + + +class ClipboardPlugin(PluginBase): + def __init__(self): + super().__init__() + self.name = "clipboard" + self.display_name = "Clipboard History" + self.description = "Search and manage clipboard history using cliphist" + + # Performance settings - show more history like example_cliphist.py + self.max_results = 50 + # Cache clipboard items for 5 seconds (more responsive) + self.cache_ttl = 5 + + # Initialize cache and temp directory + self.tmp_dir = tempfile.mkdtemp(prefix="cliphist-") + # Cache images forever like example_cliphist.py + self.image_cache: Dict[str, GdkPixbuf.Pixbuf] = {} + self.clipboard_items_cache: List[str] = [] + self.cache_timestamp = 0 + + # Threading + self.executor = ThreadPoolExecutor( + max_workers=2, thread_name_prefix="clipboard" + ) + self.cache_lock = threading.Lock() + + # State tracking + self._loading = False + self._pending_updates = False + + def initialize(self): + """Initialize the plugin.""" + self.set_triggers(["clip"]) + try: + subprocess.run( + ["cliphist", "list"], capture_output=True, check=True, timeout=5 + ) + except (subprocess.SubprocessError, FileNotFoundError): + raise RuntimeError("cliphist is not installed or not working properly") + + # Pre-warm cache in background + self.executor.submit(self._load_clipboard_items_cached) + + def cleanup(self): + """Cleanup the plugin.""" + try: + # Shutdown executor + if hasattr(self, "executor"): + self.executor.shutdown(wait=False) + + # Clean up temp files + if os.path.exists(self.tmp_dir): + import shutil + + shutil.rmtree(self.tmp_dir) + + # Clear caches + with self.cache_lock: + self.image_cache.clear() + self.clipboard_items_cache.clear() + except Exception as e: + print(f"Error cleaning up temporary files: {e}", file=sys.stderr) + + def invalidate_cache(self): + """Force invalidation of the clipboard cache.""" + with self.cache_lock: + self.clipboard_items_cache.clear() + self.cache_timestamp = 0 + # Keep image cache forever like example_cliphist.py + + def _force_launcher_refresh(self): + """Force the launcher to refresh its results.""" + try: + from gi.repository import GLib + + def trigger_refresh(): + try: + # Try to access the launcher through the fabric Application + from fabric import Application + + app = Application.get_default() + + if app and hasattr(app, "launcher"): + launcher = app.launcher + if ( + launcher + and hasattr(launcher, "search_entry") + and hasattr(launcher, "_perform_search") + ): + # Get current search text to preserve the query + current_text = launcher.search_entry.get_text() + # Trigger the search to refresh results + launcher._perform_search(current_text) + return False + + # Fallback: try to find launcher instance through other means + import gc + + for obj in gc.get_objects(): + if ( + hasattr(obj, "__class__") + and obj.__class__.__name__ == "Launcher" + ): + if hasattr(obj, "search_entry") and hasattr( + obj, "_perform_search" + ): + current_text = obj.search_entry.get_text() + obj._perform_search(current_text) + return False + + except Exception as e: + print(f"Error forcing launcher refresh: {e}") + + return False # Don't repeat + + # Use immediate refresh + GLib.timeout_add(10, trigger_refresh) + + except Exception as e: + print(f"Could not trigger refresh: {e}") + + def _load_clipboard_items_cached(self) -> List[str]: + """Load clipboard items from cliphist with caching and change detection.""" + current_time = time.time() + + # Always load fresh data to check for changes + try: + result = subprocess.run( + ["cliphist", "list"], capture_output=True, check=True, timeout=5 + ) + stdout_str = result.stdout.decode("utf-8", errors="replace") + if stdout_str.strip(): + lines = stdout_str.strip().split("\n") + items = [ + line for line in lines if line and "<meta http-equiv" not in line + ] + else: + items = [] + + # Check if data has changed + with self.cache_lock: + data_changed = ( + not self.clipboard_items_cache + or len(items) != len(self.clipboard_items_cache) + or items != self.clipboard_items_cache + ) + + # If cache is still valid and data hasn't changed, return cached data + if ( + not data_changed + and self.clipboard_items_cache + and current_time - self.cache_timestamp < self.cache_ttl + ): + return self.clipboard_items_cache.copy() + + # Update cache with fresh data + self.clipboard_items_cache = items + self.cache_timestamp = current_time + + # Keep image cache forever like example_cliphist.py - don't clear on data changes + + return items + except subprocess.CalledProcessError as e: + print(f"Error loading clipboard history: {e}", file=sys.stderr) + return [] + except Exception as e: + print(f"Unexpected error: {e}", file=sys.stderr) + return [] + + def _load_clipboard_items(self) -> List[str]: + """Load clipboard items (legacy method for compatibility).""" + return self._load_clipboard_items_cached() + + def _create_pixbuf_from_bytes( + self, image_data: bytes, max_size: int = 100 + ) -> GdkPixbuf.Pixbuf: + """Create a GdkPixbuf from image bytes with size limit.""" + try: + loader = GdkPixbuf.PixbufLoader() + loader.write(image_data) + loader.close() + pixbuf = loader.get_pixbuf() + + # Scale image if needed + width, height = pixbuf.get_width(), pixbuf.get_height() + if width > height: + new_width = max_size + new_height = int(height * (max_size / width)) + else: + new_height = max_size + new_width = int(width * (max_size / height)) + + return pixbuf.scale_simple( + new_width, new_height, GdkPixbuf.InterpType.BILINEAR + ) + except GLib.Error: + return None + + def _is_image_data(self, content: str) -> bool: + """Determine if clipboard content is likely an image (like example_cliphist.py).""" + return ( + content.startswith("data:image/") + or content.startswith("\x89PNG") + or content.startswith("GIF8") + or content.startswith("\xff\xd8\xff") + or "binary" in content.lower() + and any( + ext in content.lower() for ext in ["jpg", "jpeg", "png", "bmp", "gif"] + ) + ) + + def _get_text_preview(self, content: str) -> str: + """Get a text preview of the content.""" + if len(content) > 50: + return content[:37] + "..." + return content + + def _load_image_preview_cached(self, item_id: str) -> Optional[GdkPixbuf.Pixbuf]: + """Load image preview with forever caching (like example_cliphist.py).""" + # Check cache first - cache forever like example_cliphist.py + with self.cache_lock: + if item_id in self.image_cache: + return self.image_cache[item_id] + + try: + result = subprocess.run( + ["cliphist", "decode", item_id], + capture_output=True, + check=True, + timeout=3, + ) + pixbuf = self._create_pixbuf_from_bytes(result.stdout) + if pixbuf: + with self.cache_lock: + self.image_cache[item_id] = pixbuf + + return pixbuf + except Exception as e: + print(f"Error loading image preview: {e}", file=sys.stderr) + return None + + def _load_image_preview_async(self, item_id: str) -> Optional[GdkPixbuf.Pixbuf]: + """Load image preview (legacy method for compatibility).""" + return self._load_image_preview_cached(item_id) + + def query(self, query_string: str) -> List[Result]: + """Search clipboard history using cliphist with optimized performance.""" + results = [] + + # Handle query string + if query_string.lower() == "clip": + query_string = "" # Show all items + + try: + # Load clipboard items from cache + clipboard_items = self._load_clipboard_items_cached() + + # Early exit if no items + if not clipboard_items: + return results + + # Filter items based on query - search through ALL items first + filtered_items = [] + query_lower = query_string.lower() if query_string else "" + + for item in clipboard_items: + parts = item.split("\t", 1) + content = parts[1] if len(parts) > 1 else item + content_lower = content.lower() + + # Fast filtering - search through all items, don't limit here + if not query_lower or query_lower in content_lower: + # Calculate relevance score for better sorting + relevance = 1.0 + if query_lower: + if content_lower.startswith(query_lower): + relevance = 1.0 # Exact start match + elif query_lower in content_lower: + # Position-based scoring: earlier matches get higher scores + position = content_lower.find(query_lower) + relevance = max( + 0.5, 1.0 - (position / len(content_lower)) * 0.4 + ) + + filtered_items.append((item, relevance)) + + # Sort by relevance (highest first) and then limit results + filtered_items.sort(key=lambda x: x[1], reverse=True) + filtered_items = filtered_items[: self.max_results] + + # Process items with lazy image loading + for i, (item, relevance) in enumerate(filtered_items): + parts = item.split("\t", 1) + item_id = parts[0] if len(parts) > 1 else str(i) + content = parts[1] if len(parts) > 1 else item + + # Handle image content like example_cliphist.py + if self._is_image_data(content): + # Check if image is already cached (forever cache like example_cliphist.py) + cached_pixbuf = None + with self.cache_lock: + cached_pixbuf = self.image_cache.get(item_id) + + if cached_pixbuf: + # Use cached image immediately + result = Result( + title="Image from clipboard", + subtitle="Click to copy image to clipboard", + description="Image content", + icon=cached_pixbuf, + relevance=relevance, + plugin_name=self.name, + action=lambda id=item_id: self._copy_to_clipboard(id), + data={"bypass_max_results": True}, + ) + else: + # Try to load image immediately like example_cliphist.py + try: + immediate_pixbuf = self._load_image_preview_cached(item_id) + if immediate_pixbuf: + # Successfully loaded immediately + result = Result( + title="Image from clipboard", + subtitle="Click to copy image to clipboard", + description="Image content", + icon=immediate_pixbuf, + relevance=relevance, + plugin_name=self.name, + action=lambda id=item_id: self._copy_to_clipboard( + id + ), + data={"bypass_max_results": True}, + ) + else: + # Show placeholder if loading failed + result = Result( + title="Image from clipboard", + subtitle="Click to copy image to clipboard", + description="Image content", + icon_name="image-x-generic", + relevance=relevance, + plugin_name=self.name, + action=lambda id=item_id: self._copy_to_clipboard( + id + ), + data={"bypass_max_results": True}, + ) + except Exception: + # Fallback to placeholder + result = Result( + title="Image from clipboard", + subtitle="Click to copy image to clipboard", + description="Image content", + icon_name="image-x-generic", + relevance=relevance, + plugin_name=self.name, + action=lambda id=item_id: self._copy_to_clipboard(id), + data={"bypass_max_results": True}, + ) + + results.append(result) + continue + + # Handle text content + display_text = self._get_text_preview(content) + result = Result( + title=display_text, + subtitle="Text from clipboard", + description=content + if len(content) <= 100 + else content[:97] + "...", + icon_name="edit-paste", + relevance=relevance, + plugin_name=self.name, + action=lambda id=item_id: self._copy_to_clipboard(id), + data={"bypass_max_results": True}, + ) + results.append(result) + + except Exception as e: + # Handle errors gracefully + results.append( + Result( + title="Error accessing clipboard history", + subtitle=str(e), + icon_name="dialog-error", + relevance=0.0, + plugin_name=self.name, + data={"bypass_max_results": True}, + ) + ) + + return results + + def _copy_to_clipboard(self, entry_id: str): + """Copy entry to clipboard using cliphist with timeout.""" + try: + result = subprocess.run( + ["cliphist", "decode", entry_id], + capture_output=True, + check=True, + timeout=5, + ) + # Use wl-copy for Wayland or xclip for X11 + try: + subprocess.run(["wl-copy"], input=result.stdout, check=True, timeout=3) + except subprocess.SubprocessError: + subprocess.run( + ["xclip", "-selection", "clipboard"], + input=result.stdout, + check=True, + timeout=3, + ) + + # Don't invalidate image cache - keep images cached forever like example_cliphist.py + # Only invalidate clipboard items cache + with self.cache_lock: + self.clipboard_items_cache.clear() + self.cache_timestamp = 0 + + except subprocess.SubprocessError as e: + print(f"Error copying to clipboard: {e}", file=sys.stderr) + except subprocess.TimeoutExpired as e: + print(f"Timeout copying to clipboard: {e}", file=sys.stderr) diff --git a/modules/launcher/plugins/emoji.py b/modules/launcher/plugins/emoji.py new file mode 100644 index 0000000..834d673 --- /dev/null +++ b/modules/launcher/plugins/emoji.py @@ -0,0 +1,188 @@ +import json +import os +import subprocess +import time +from collections import OrderedDict +from typing import Dict, List + +from fabric.utils import get_relative_path + +import config.data as data +from modules.launcher.plugin_base import PluginBase +from modules.launcher.result import Result + + +class EmojiPlugin(PluginBase): + """ + Plugin for searching and copying emojis. + """ + + def __init__(self): + super().__init__() + self.name = "emoji" + self.display_name = "Emoji" + self.description = "Search and copy emojis" + self.emoji_data = {} + self.emoji_path = get_relative_path("../../../config/assets/emoji.json") + + # Use cache directory for recent emojis (save directly in cache dir) + self.recent_emoji_path = os.path.join(data.CACHE_DIR, "recent_emoji.json") + self.recent_emojis = OrderedDict() + self.max_recent_emojis = 20 # Maximum number of recent emojis to track + + def initialize(self): + """Initialize the emoji plugin.""" + self.set_triggers(["em"]) + self._load_emoji_data() + self._load_recent_emojis() + + def cleanup(self): + """Cleanup the emoji plugin.""" + pass + + def _load_emoji_data(self): + """Load emoji data from JSON file.""" + try: + if os.path.exists(self.emoji_path): + with open(self.emoji_path, "r", encoding="utf-8") as f: + self.emoji_data = json.load(f) + else: + print(f"Emoji file not found: {self.emoji_path}") + except Exception as e: + print(f"Error loading emoji data: {e}") + + def _load_recent_emojis(self): + """Load recently used emojis from JSON file.""" + try: + if os.path.exists(self.recent_emoji_path): + with open(self.recent_emoji_path, "r", encoding="utf-8") as f: + recent_data = json.load(f) + # Convert to OrderedDict to maintain order + self.recent_emojis = OrderedDict(recent_data) + else: + # Create empty recent emojis file + self.recent_emojis = OrderedDict() + self._save_recent_emojis() + except Exception as e: + print(f"Error loading recent emoji data: {e}") + self.recent_emojis = OrderedDict() + + def _save_recent_emojis(self): + """Save recently used emojis to JSON file.""" + try: + # Ensure the cache directory exists + os.makedirs(data.CACHE_DIR, exist_ok=True) + + with open(self.recent_emoji_path, "w", encoding="utf-8") as f: + json.dump(dict(self.recent_emojis), f, ensure_ascii=False, indent=2) + except Exception as e: + print(f"Error saving recent emoji data: {e}") + + def _add_to_recent(self, emoji: str): + """Add an emoji to the recent list.""" + # Remove if already exists (to move it to front) + if emoji in self.recent_emojis: + del self.recent_emojis[emoji] + + # Add to front with current timestamp + self.recent_emojis[emoji] = time.time() + + # Keep only the most recent emojis + while len(self.recent_emojis) > self.max_recent_emojis: + # Remove the oldest item + self.recent_emojis.popitem(last=False) + + # Save to file + self._save_recent_emojis() + + def _copy_to_clipboard(self, emoji: str): + """Copy emoji to clipboard and track usage.""" + try: + # Try Wayland first + try: + subprocess.run(["wl-copy"], input=emoji.encode(), check=True) + except subprocess.SubprocessError: + # Fall back to X11 + subprocess.run( + ["xclip", "-selection", "clipboard"], + input=emoji.encode(), + check=True, + ) + + # Track this emoji as recently used + self._add_to_recent(emoji) + + except Exception as e: + print(f"Failed to copy to clipboard: {e}") + + def query(self, query_string: str) -> List[Result]: + """Search emojis based on query.""" + results = [] + query = query_string.lower().strip() + + # If no query, show recently used emojis + if not query: + if self.recent_emojis: + # Show recent emojis in reverse order (most recent first) + for emoji in reversed(list(self.recent_emojis.keys())): + if emoji in self.emoji_data: + emoji_info = self.emoji_data[emoji] + results.append( + self._create_emoji_result(emoji, emoji_info, 1.0) + ) + else: + # If no recent emojis, show some popular ones as fallback + popular_emojis = ["😀", "👍", "❤️", "🎉", "🔥", "✨", "🚀", "🌈"] + for emoji in popular_emojis: + if emoji in self.emoji_data: + emoji_info = self.emoji_data[emoji] + results.append( + self._create_emoji_result(emoji, emoji_info, 1.0) + ) + return results + + # Search by name, group, or the emoji itself + for emoji, info in self.emoji_data.items(): + relevance = 0 + name = info.get("name", "").lower() + group = info.get("group", "").lower() + slug = info.get("slug", "").lower() + + # Exact match with emoji + if query == emoji: + relevance = 1.0 + # Name contains query + elif query in name: + relevance = 0.9 + # Slug contains query + elif query in slug: + relevance = 0.8 + # Group contains query + elif query in group: + relevance = 0.7 + + if relevance > 0: + results.append(self._create_emoji_result(emoji, info, relevance)) + + # Sort by relevance + results.sort(key=lambda x: x.relevance, reverse=True) + return results[:20] # Limit to 20 results + + def _create_emoji_result(self, emoji: str, info: Dict, relevance: float) -> Result: + """Create a Result object for an emoji.""" + name = info.get("name", "") + group = info.get("group", "") + + # Check if this is a recently used emoji + is_recent = emoji in self.recent_emojis + subtitle = f"{group}" + (" • Recently used" if is_recent else "") + + return Result( + title=name, # Show only the name, not the emoji + subtitle=subtitle, + icon_markup=emoji, # Use the emoji itself as the icon + action=lambda e=emoji: self._copy_to_clipboard(e), + relevance=relevance, + plugin_name=self.display_name, + data={"emoji": emoji, "name": name, "group": group, "recent": is_recent}, + ) diff --git a/modules/launcher/plugins/kanban.py b/modules/launcher/plugins/kanban.py new file mode 100644 index 0000000..fdaad8b --- /dev/null +++ b/modules/launcher/plugins/kanban.py @@ -0,0 +1,959 @@ +""" +Kanban Plugin for Modus Launcher + +A maintainable kanban board implementation with the following features: +- Three columns: To Do, In Progress, Done +- Drag and drop between columns +- Inline editing of notes +- Keyboard navigation and shortcuts +- Persistent state storage +- Command-based interaction (add, move, done) + +Architecture: +- KanbanEditor: Handles inline text editing +- KanbanNote: Individual note widget with editing and drag/drop +- KanbanColumn: Column container with notes and drag/drop handling +- KanbanBoard: Main board widget with keyboard handling and state management +- KanbanPlugin: Plugin interface and command processing +""" + +import json +import os +from pathlib import Path +from typing import List, Optional, Tuple + +import cairo +import gi +from fabric.widgets.centerbox import CenterBox +from fabric.widgets.label import Label +from fabric.widgets.scrolledwindow import ScrolledWindow +from gi.repository import Gdk, GLib, GObject, Gtk + +import utils.icons as icons +from modules.launcher.plugin_base import PluginBase +from modules.launcher.result import Result + +gi.require_version("Gtk", "3.0") + +# Constants +KANBAN_STATE_FILE = Path(os.path.expanduser("~/.kanban.json")) +BOARD_HEIGHT = 500 +COLUMN_HEIGHT = 150 +EDITOR_MIN_HEIGHT = 50 + + +def create_drag_surface(widget: Gtk.Widget) -> cairo.ImageSurface: + """Create a cairo surface from a widget for drag operations.""" + alloc = widget.get_allocation() + surface = cairo.ImageSurface(cairo.Format.ARGB32, alloc.width, alloc.height) + cr = cairo.Context(surface) + cr.set_source_rgba(0, 0, 0, 0) + cr.rectangle(0, 0, alloc.width, alloc.height) + cr.fill() + widget.draw(cr) + return surface + + +class KanbanEditor(Gtk.Box): + """Inline text editor for kanban notes with confirm/cancel functionality.""" + + __gsignals__ = { + "confirmed": (GObject.SignalFlags.RUN_LAST, None, (str,)), + "canceled": (GObject.SignalFlags.RUN_LAST, None, ()), + } + + def __init__(self, initial_text=""): + super().__init__(name="inline-editor", spacing=4) + self._setup_text_view(initial_text) + self._setup_buttons() + self._setup_layout() + self.show_all() + + def _setup_text_view(self, initial_text: str): + """Setup the text view component.""" + self.text_view = Gtk.TextView() + self.text_view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) + self.text_view.connect("key-press-event", self._on_key_press) + + buffer = self.text_view.get_buffer() + buffer.set_text(initial_text) + + def _setup_buttons(self): + """Setup confirm and cancel buttons.""" + # Confirm button + confirm_label = Label(name="kanban-btn-label", markup=icons.accept) + self.confirm_btn = Gtk.Button(name="kanban-btn") + self.confirm_btn.add(confirm_label) + self.confirm_btn.connect("clicked", self._on_confirm_clicked) + self.confirm_btn.get_style_context().add_class("flat") + + # Cancel button + cancel_label = Label(name="kanban-btn-neg", markup=icons.cancel) + self.cancel_btn = Gtk.Button(name="kanban-btn") + self.cancel_btn.add(cancel_label) + self.cancel_btn.connect("clicked", self._on_cancel_clicked) + self.cancel_btn.get_style_context().add_class("flat") + + def _setup_layout(self): + """Setup the widget layout.""" + # Scrolled window for text view + scrolled_window = Gtk.ScrolledWindow(name="scrolled-window") + scrolled_window.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + scrolled_window.set_min_content_height(EDITOR_MIN_HEIGHT) + scrolled_window.add(self.text_view) + + # Button container + button_box = Gtk.Box(spacing=4) + button_box.pack_start(self.confirm_btn, False, False, 0) + button_box.pack_start(self.cancel_btn, False, False, 0) + + # Center the buttons + center_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + center_box.set_halign(Gtk.Align.CENTER) + center_box.pack_start(button_box, False, False, 0) + + # Main layout + self.pack_start(scrolled_window, True, True, 0) + self.pack_start(center_box, False, False, 0) + + def _get_text(self) -> str: + """Get the current text from the editor.""" + buffer = self.text_view.get_buffer() + start, end = buffer.get_bounds() + return buffer.get_text(start, end, True).strip() + + def _on_confirm_clicked(self, _widget): + """Handle confirm button click.""" + text = self._get_text() + if text: + self.emit("confirmed", text) + else: + self.emit("canceled") + + def _on_cancel_clicked(self, _widget): + """Handle cancel button click.""" + self.emit("canceled") + + def _on_key_press(self, _widget, event): + """Handle keyboard events.""" + if event.keyval == Gdk.KEY_Escape: + self.emit("canceled") + return True + + if event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter): + if event.get_state() & Gdk.ModifierType.SHIFT_MASK: + # Shift+Enter: Insert newline + buffer = self.text_view.get_buffer() + cursor_iter = buffer.get_iter_at_mark(buffer.get_insert()) + buffer.insert(cursor_iter, "\n") + return True + else: + # Enter: Confirm + self._on_confirm_clicked(None) + return True + return False + + +class KanbanNote(Gtk.EventBox): + """Individual kanban note widget with editing, deletion, and drag/drop support.""" + + __gsignals__ = { + "changed": (GObject.SignalFlags.RUN_LAST, None, ()), + } + + def __init__(self, text: str): + super().__init__() + self.text = text + self.set_can_focus(True) # Enable keyboard events + + self._setup_ui() + self._setup_drag_and_drop() + self._connect_signals() + self.show_all() + + def _setup_ui(self): + """Setup the note's user interface.""" + self.box = Gtk.Box(name="kanban-note", spacing=4) + + # Text label + self.label = Gtk.Label(label=self.text) + self.label.set_line_wrap(True) + self.label.set_line_wrap_mode(Gtk.WrapMode.WORD) + + # Delete button + delete_label = Label(name="kanban-btn-neg", markup=icons.trash) + self.delete_btn = Gtk.Button(name="kanban-btn", child=delete_label) + self.delete_btn.connect("clicked", self._on_delete_clicked) + + # Layout + button_container = CenterBox(orientation="v", start_children=[self.delete_btn]) + self.box.pack_start(self.label, True, True, 0) + self.box.pack_start(button_container, False, False, 0) + self.add(self.box) + + def _setup_drag_and_drop(self): + """Setup drag and drop functionality.""" + self.drag_source_set( + Gdk.ModifierType.BUTTON1_MASK, + [Gtk.TargetEntry.new("UTF8_STRING", Gtk.TargetFlags.SAME_APP, 0)], + Gdk.DragAction.MOVE, + ) + + def _connect_signals(self): + """Connect all signal handlers.""" + self.connect("button-press-event", self._on_button_press) + self.connect("key-press-event", self._on_key_press) + self.connect("drag-data-get", self._on_drag_data_get) + self.connect("drag-data-delete", self._on_drag_data_delete) + self.connect("drag-begin", self._on_drag_begin) + + def _on_button_press(self, _widget, event): + """Handle mouse button press events.""" + if event.type == Gdk.EventType._2BUTTON_PRESS: + self.start_edit() + return False + return True + + def _on_key_press(self, _widget, event): + """Handle keyboard events for kanban notes.""" + keyval = event.keyval + + # Enter or F2 - start editing + if keyval in (Gdk.KEY_Return, Gdk.KEY_F2): + self.start_edit() + return True + + # Delete - remove note + if keyval == Gdk.KEY_Delete: + self._delete_note() + return True + + return False + + def _on_drag_begin(self, _widget, context): + """Handle drag begin event.""" + surface = create_drag_surface(self) + Gtk.drag_set_icon_surface(context, surface) + + def _on_drag_data_get(self, _widget, _drag_context, data, _info, _time): + """Provide data for drag operation.""" + data.set_text(self.label.get_text(), -1) + + def _on_drag_data_delete(self, _widget, _drag_context): + """Handle drag data deletion.""" + self._delete_note() + + def _on_delete_clicked(self, _button): + """Handle delete button click.""" + self._delete_note() + + def _delete_note(self): + """Delete this note from its parent.""" + parent = self.get_parent() + if parent: + parent.destroy() + + def start_edit(self): + """Start inline editing of this note.""" + row = self.get_parent() + if not row: + return + + editor = KanbanEditor(self.label.get_text()) + editor.connect("confirmed", self._on_edit_confirmed) + editor.connect("canceled", self._on_edit_canceled) + + # Replace self with editor + row.remove(self) + row.add(editor) + row.show_all() + + # Focus the editor after a short delay + GLib.timeout_add(50, lambda: (editor.text_view.grab_focus(), False)) + + def _on_edit_confirmed(self, editor, text): + """Handle edit confirmation.""" + self.label.set_text(text) + self._replace_editor_with_self(editor) + self.emit("changed") + + def _on_edit_canceled(self, editor): + """Handle edit cancellation.""" + self._replace_editor_with_self(editor) + + def _replace_editor_with_self(self, editor): + """Replace the editor with this note widget.""" + row = editor.get_parent() + if row: + row.remove(editor) + row.add(self) + row.show_all() + + +class KanbanColumn(Gtk.Frame): + """A kanban column containing notes with drag/drop support.""" + + __gsignals__ = { + "changed": (GObject.SignalFlags.RUN_LAST, None, ()), + } + + def __init__(self, title: str): + super().__init__(name="kanban-column") + self.title = title + self._setup_ui() + self._setup_drag_and_drop() + self._configure_size() + self.show_all() + + def _configure_size(self): + """Configure size constraints for launcher compatibility.""" + self.set_hexpand(True) + self.set_vexpand(True) + self.set_size_request(-1, COLUMN_HEIGHT) + + def _setup_ui(self): + """Setup the column's user interface.""" + # Main container + self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) + + # Notes list + self.listbox = Gtk.ListBox() + self.listbox.set_selection_mode(Gtk.SelectionMode.NONE) + + # Add button + add_label = Label(name="kanban-btn-label", markup=icons.add) + self.add_btn = Gtk.Button(name="kanban-btn-add", child=add_label) + self.add_btn.connect("clicked", self._on_add_clicked) + + # Header with title and add button + title_label = Label(name="column-header", label=self.title) + header = CenterBox( + name="kanban-header", + center_children=[title_label], + end_children=[self.add_btn], + ) + + # Scrolled window for notes + self.scroller = ScrolledWindow( + name="scrolled-window", propagate_height=False, propagate_width=False + ) + self.scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + self.scroller.add(self.listbox) + self.scroller.set_vexpand(True) + + # Layout + self.box.pack_start(header, False, False, 0) + self.box.pack_start(self.scroller, True, True, 0) + self.box.pack_start(self.add_btn, False, False, 0) + self.add(self.box) + + def _setup_drag_and_drop(self): + """Setup drag and drop functionality.""" + self.listbox.drag_dest_set( + Gtk.DestDefaults.ALL, + [Gtk.TargetEntry.new("UTF8_STRING", Gtk.TargetFlags.SAME_APP, 0)], + Gdk.DragAction.MOVE, + ) + + # Connect drag and drop signals + self.listbox.connect("drag-data-received", self._on_drag_data_received) + self.listbox.connect("drag-motion", self._on_drag_motion) + self.listbox.connect("drag-leave", self._on_drag_leave) + + def _on_add_clicked(self, _button): + """Handle add button click to create a new note.""" + editor = KanbanEditor() + row = Gtk.ListBoxRow(name="kanban-row") + row.add(editor) + self.listbox.add(row) + self.listbox.show_all() + editor.text_view.grab_focus() + + # Connect editor signals + editor.connect("confirmed", self._on_editor_confirmed) + editor.connect("canceled", self._on_editor_canceled) + + # Scroll to bottom after row is loaded + GLib.idle_add(self._scroll_to_bottom) + + def _on_editor_confirmed(self, editor, text): + """Handle editor confirmation for new note.""" + note = KanbanNote(text) + note.connect("changed", lambda _: self.emit("changed")) + + # Replace editor with note + row = editor.get_parent() + if row: + row.remove(editor) + row.add(note) + self.listbox.show_all() + self.emit("changed") + + def _on_editor_canceled(self, editor): + """Handle editor cancellation for new note.""" + row = editor.get_parent() + if row: + row.destroy() + + def _scroll_to_bottom(self): + """Scroll the column to the bottom.""" + adj = self.scroller.get_vadjustment() + adj.set_value(adj.get_upper()) + return False # Don't repeat + + def add_note(self, text: str, suppress_signal: bool = False): + """Add a new note to this column.""" + note = KanbanNote(text) + note.connect("changed", lambda _: self.emit("changed")) + + row = Gtk.ListBoxRow(name="kanban-row") + row.add(note) + row.connect("destroy", lambda _: self.emit("changed")) + + self.listbox.add(row) + self.listbox.show_all() + + if not suppress_signal: + self.emit("changed") + + def get_notes(self) -> List[str]: + """Get all note texts in this column.""" + return [ + row.get_children()[0].label.get_text() + for row in self.listbox.get_children() + if row.get_children() and isinstance(row.get_children()[0], KanbanNote) + ] + + def clear_notes(self, suppress_signal: bool = False): + """Clear all notes from this column.""" + for row in self.listbox.get_children(): + row.destroy() + if not suppress_signal: + self.emit("changed") + + def _on_drag_data_received(self, _widget, drag_context, _x, y, data, _info, time): + """Handle drag data received event.""" + text = data.get_text() + if not text: + return + + # Find insertion point + row = self.listbox.get_row_at_y(y) + + # Create new note + new_note = KanbanNote(text) + new_note.connect("changed", lambda _: self.emit("changed")) + new_row = Gtk.ListBoxRow(name="kanban-row") + new_row.add(new_note) + new_row.connect("destroy", lambda _: self.emit("changed")) + + # Insert at appropriate position + if row: + self.listbox.insert(new_row, row.get_index()) + else: + self.listbox.add(new_row) + + self.listbox.show_all() + drag_context.finish(True, False, time) + self.emit("changed") + + def _on_drag_motion(self, _widget, drag_context, _x, _y, time): + """Handle drag motion event.""" + Gdk.drag_status(drag_context, Gdk.DragAction.MOVE, time) + return True + + def _on_drag_leave(self, widget, _drag_context, _time): + """Handle drag leave event.""" + widget.get_parent().get_parent().drag_unhighlight() + + +class KanbanBoard(Gtk.Box): + """Main kanban board widget with three columns and command handling.""" + + __gsignals__ = { + "todo-added": (GObject.SignalFlags.RUN_LAST, None, ()), + } + + def __init__(self): + super().__init__(name="kanban", orientation=Gtk.Orientation.VERTICAL) + + # Command state + self.pending_add_text = None + self.pending_command = None + + self._setup_ui() + self._setup_columns() + self._load_state() + self.show_all() + + def _setup_ui(self): + """Setup the board's user interface.""" + # Configure size for launcher compatibility + self.set_size_request(-1, BOARD_HEIGHT) + self.set_vexpand(False) + self.set_hexpand(True) + self.set_can_focus(True) + + # Connect keyboard events + self.connect("key-press-event", self._on_key_press) + + # Hidden entry for launcher Enter key handling + from fabric.widgets.entry import Entry + + self.hidden_entry = Entry(name="kanban-hidden-entry") + self.hidden_entry.set_size_request(1, 1) + self.hidden_entry.set_opacity(0.0) + self.hidden_entry.connect("activate", self._on_hidden_entry_activate) + self.add(self.hidden_entry) + + # Grid for columns + self.grid = Gtk.Grid( + column_spacing=4, + column_homogeneous=True, + row_spacing=4, + row_homogeneous=True, + ) + self.grid.set_vexpand(True) + self.grid.set_hexpand(True) + self.add(self.grid) + + def _setup_columns(self): + """Setup the kanban columns.""" + self.columns = [ + KanbanColumn("To Do"), + KanbanColumn("In Progress"), + KanbanColumn("Done"), + ] + + # Arrange columns vertically (stacked) + for i, column in enumerate(self.columns): + self.grid.attach(column, 0, i, 1, 1) + column.connect("changed", lambda _: self._save_state()) + + def _load_state(self): + """Load kanban state from file.""" + try: + with open(KANBAN_STATE_FILE, "r") as f: + state = json.load(f) + for col_data in state["columns"]: + for column in self.columns: + if column.title == col_data["title"]: + column.clear_notes(suppress_signal=True) + for note_text in col_data["notes"]: + column.add_note(note_text, suppress_signal=True) + break + except FileNotFoundError: + pass # No state file yet + except Exception as e: + print(f"Error loading kanban state: {e}") + + def _save_state(self): + """Save kanban state to file.""" + state = { + "columns": [ + {"title": col.title, "notes": col.get_notes()} for col in self.columns + ] + } + try: + with open(KANBAN_STATE_FILE, "w") as f: + json.dump(state, f, indent=2) + except Exception as e: + print(f"Error saving kanban state: {e}") + + def _on_key_press(self, _widget, event): + """Handle key press events for the kanban widget.""" + keyval = event.keyval + + # Enter - handle pending commands + if keyval == Gdk.KEY_Return: + return self._handle_enter_key() + + # Ctrl+N - add new note to first column + if keyval == Gdk.KEY_n and event.state & Gdk.ModifierType.CONTROL_MASK: + self.columns[0]._on_add_clicked(None) + return True + + # Ctrl+1, Ctrl+2, Ctrl+3 - add note to specific column + if event.state & Gdk.ModifierType.CONTROL_MASK: + column_shortcuts = {Gdk.KEY_1: 0, Gdk.KEY_2: 1, Gdk.KEY_3: 2} + if keyval in column_shortcuts: + column_idx = column_shortcuts[keyval] + if column_idx < len(self.columns): + self.columns[column_idx]._on_add_clicked(None) + return True + + return False + + def _handle_enter_key(self) -> bool: + """Handle Enter key press for command execution.""" + if self.pending_command: + command, text = self.pending_command + success = False + + if command == "add": + self.columns[0].add_note(text) + success = True + elif command == "move": + success = self._execute_move_command(text) + elif command == "done": + success = self._execute_done_command(text) + elif command == "remove": + success = self._execute_remove_command(text) + elif command == "clear": + success = self._execute_clear_command() + + if success: + self._save_state() + + # Clear pending command and notify plugin to reset + self._clear_pending_commands() + self.emit("todo-added") # Signal for plugin reset + return True + + elif self.pending_add_text: + # Fallback for old add system + self.columns[0].add_note(self.pending_add_text) + self._save_state() + self._clear_pending_commands() + self.emit("todo-added") + return True + + return False + + def _on_hidden_entry_activate(self, _entry): + """Handle activation of the hidden entry (Enter key from launcher).""" + self._handle_enter_key() + + def _clear_pending_commands(self): + """Clear all pending command state.""" + self.pending_command = None + self.pending_add_text = None + + def set_pending_add_text(self, text: str): + """Set the pending add text for Enter key handling.""" + self.pending_add_text = text + + def _execute_move_command(self, todo_text: str) -> bool: + """Execute move command to advance todo to next state.""" + search_text = todo_text.lower().strip() + + # Find todo across all columns + for col_idx, column in enumerate(self.columns): + notes = column.get_notes() + for note_text in notes: + if search_text in note_text.lower(): + return self._move_note_to_next_column(column, note_text, col_idx) + return False + + def _execute_done_command(self, todo_text: str) -> bool: + """Execute done command to move todo directly to Done column.""" + search_text = todo_text.lower().strip() + + # Find todo in To Do and In Progress columns only + for column in self.columns[:2]: + notes = column.get_notes() + for note_text in notes: + if search_text in note_text.lower(): + return self._move_note_to_done_column(column, note_text) + + # Check if already in Done column + done_notes = self.columns[2].get_notes() + return not any(search_text in note.lower() for note in done_notes) + + def _move_note_to_next_column( + self, current_column: KanbanColumn, note_text: str, col_idx: int + ) -> bool: + """Move a note to the next column in sequence.""" + # Determine next column + if col_idx == 0: # To Do -> In Progress + next_col_idx = 1 + elif col_idx == 1: # In Progress -> Done + next_col_idx = 2 + else: # Already in Done + return False + + # Remove from current column and add to next + self._remove_note_from_column(current_column, note_text) + self.columns[next_col_idx].add_note(note_text) + return True + + def _move_note_to_done_column( + self, current_column: KanbanColumn, note_text: str + ) -> bool: + """Move a note directly to the Done column.""" + self._remove_note_from_column(current_column, note_text) + self.columns[2].add_note(note_text) # Done column + return True + + def _execute_remove_command(self, todo_text: str) -> bool: + """Execute remove command to delete a specific todo.""" + search_text = todo_text.lower().strip() + + # Find todo across all columns + for column in self.columns: + notes = column.get_notes() + for note_text in notes: + if search_text in note_text.lower(): + self._remove_note_from_column(column, note_text) + return True + return False + + def _execute_clear_command(self) -> bool: + """Execute clear command to remove all todos from all columns.""" + for column in self.columns: + column.clear_notes(suppress_signal=True) + return True + + def _remove_note_from_column(self, column: KanbanColumn, note_text: str): + """Remove a specific note from a column.""" + for row in column.listbox.get_children(): + if row.get_children(): + note_widget = row.get_children()[0] + if ( + isinstance(note_widget, KanbanNote) + and note_widget.label.get_text() == note_text + ): + row.destroy() + break + + +class KanbanPlugin(PluginBase): + """Kanban board plugin for the launcher providing task management interface.""" + + def __init__(self): + super().__init__() + self.display_name = "Kanban" + self.description = "Kanban board for task management" + self._current_widget = None + self._launcher_instance = None + + def initialize(self): + """Initialize the Kanban plugin.""" + self.set_triggers(["kanban"]) + self.description = ( + "Kanban board for task management. " + "Commands: 'add <text>', 'move <text>', 'done <text>', 'remove <text>', 'clear'. " + "Keyboard: Enter/F2 to edit, Del to delete, Ctrl+N to add note" + ) + self._setup_launcher_hooks() + + def cleanup(self): + """Cleanup the Kanban plugin.""" + if self._current_widget: + self._destroy_current_widget() + self._cleanup_launcher_hooks() + + def _destroy_current_widget(self): + """Safely destroy the current widget.""" + if self._current_widget.get_parent(): + self._current_widget.get_parent().remove(self._current_widget) + self._current_widget.destroy() + self._current_widget = None + + def _setup_launcher_hooks(self): + """Setup hooks to monitor launcher state.""" + try: + import gc + + # Find the launcher instance using garbage collection + for obj in gc.get_objects(): + if ( + hasattr(obj, "__class__") + and obj.__class__.__name__ == "Launcher" + and hasattr(obj, "close_launcher") + ): + self._launcher_instance = obj + break + except Exception as e: + print(f"Warning: Could not setup launcher hooks: {e}") + + def _cleanup_launcher_hooks(self): + """Cleanup launcher hooks.""" + self._launcher_instance = None + + def _reset_to_trigger(self): + """Reset launcher to trigger word and refresh.""" + if not ( + self._launcher_instance and hasattr(self._launcher_instance, "search_entry") + ): + return + + trigger = "kanban " + try: + + def reset_and_refresh(): + self._launcher_instance.search_entry.set_text(trigger) + self._launcher_instance.search_entry.set_position(-1) + self._launcher_instance._perform_search(trigger) + return False + + GLib.timeout_add(50, reset_and_refresh) + except Exception as e: + print(f"Could not reset to trigger: {e}") + + def _delayed_reset_for_add(self): + """Reset to trigger with minimal delay for add operations.""" + try: + GLib.timeout_add(50, lambda: (self._reset_to_trigger(), False)) + except ImportError: + self._reset_to_trigger() + + def _find_todo_in_columns( + self, search_text: str + ) -> Tuple[Optional[int], Optional[str]]: + """Find a todo by partial text match across all columns.""" + if not self._current_widget: + return None, None + + search_text = search_text.lower().strip() + for col_idx, column in enumerate(self._current_widget.columns): + notes = column.get_notes() + for note_text in notes: + if search_text in note_text.lower(): + return col_idx, note_text + return None, None + + def _move_todo_to_next_state(self, todo_text: str) -> Tuple[bool, str]: + """Move a todo to the next state (To Do -> In Progress -> Done).""" + if not self._current_widget: + return False, "Kanban board not initialized" + + col_idx, found_text = self._find_todo_in_columns(todo_text) + if col_idx is None: + return False, f"Todo '{todo_text}' not found" + + # Determine next column + state_transitions = { + 0: (1, "In Progress"), # To Do -> In Progress + 1: (2, "Done"), # In Progress -> Done + } + + if col_idx not in state_transitions: + return False, f"Todo '{found_text}' is already completed" + + next_col_idx, next_state = state_transitions[col_idx] + + # Move the note + self._current_widget._remove_note_from_column( + self._current_widget.columns[col_idx], found_text + ) + self._current_widget.columns[next_col_idx].add_note(found_text) + self._current_widget._save_state() + + return True, f"Moved '{found_text}' to {next_state}" + + def _execute_complete_action(self, todo_text: str): + """Execute the complete action (move directly to Done) and reset to trigger.""" + if not self._current_widget: + return + + col_idx, found_text = self._find_todo_in_columns(todo_text) + if col_idx is None or col_idx == 2: # Not found or already in Done + return + + # Move to Done column + self._current_widget._remove_note_from_column( + self._current_widget.columns[col_idx], found_text + ) + self._current_widget.columns[2].add_note(found_text) + self._current_widget._save_state() + self._delayed_reset_for_add() + + def _execute_add_action(self, todo_text: str): + """Execute the add action and reset to trigger.""" + if not self._current_widget: + return + + self._current_widget.columns[0].add_note(todo_text) + self._current_widget._save_state() + self._delayed_reset_for_add() + + def _execute_remove_action(self, todo_text: str): + """Execute the remove action and reset to trigger.""" + if not self._current_widget: + return + + success = self._current_widget._execute_remove_command(todo_text) + if success: + self._current_widget._save_state() + self._delayed_reset_for_add() + + def _execute_clear_action(self): + """Execute the clear action and reset to trigger.""" + if not self._current_widget: + return + + self._current_widget._execute_clear_command() + self._current_widget._save_state() + self._delayed_reset_for_add() + + def query(self, query_string: str) -> List[Result]: + """Process Kanban queries and return results.""" + query = query_string.strip() + + # Get or create kanban widget + kanban_widget = self._get_or_create_widget() + + # Process command and set pending state + self._process_command(kanban_widget, query) + + # Return kanban board result + return [self._create_kanban_result(kanban_widget)] + + def _get_or_create_widget(self) -> KanbanBoard: + """Get existing widget or create a new one.""" + if not self._current_widget: + self._current_widget = KanbanBoard() + self._current_widget.connect( + "todo-added", lambda _: self._delayed_reset_for_add() + ) + return self._current_widget + + def _process_command(self, widget: KanbanBoard, query: str): + """Process the query command and set pending state.""" + # Clear previous state + widget.set_pending_add_text(None) + widget.pending_command = None + + # Parse commands + if query.startswith("add "): + note_text = query[4:].strip() + if note_text: + widget.set_pending_add_text(note_text) + widget.pending_command = ("add", note_text) + + elif query.startswith(("move ", "progress ")): + todo_text = self._extract_command_text(query) + if todo_text: + widget.pending_command = ("move", todo_text) + + elif query.startswith(("done ", "complete ")): + todo_text = self._extract_command_text(query) + if todo_text: + widget.pending_command = ("done", todo_text) + + elif query.startswith("remove "): + todo_text = self._extract_command_text(query) + if todo_text: + widget.pending_command = ("remove", todo_text) + + elif query.strip() == "clear": + widget.pending_command = ("clear", "") + + def _extract_command_text(self, query: str) -> Optional[str]: + """Extract text from command query.""" + parts = query.split(" ", 1) + return parts[1].strip() if len(parts) > 1 else None + + def _create_kanban_result(self, widget: KanbanBoard) -> Result: + """Create the kanban board result.""" + return Result( + title="Kanban Board", + subtitle="Task management with kanban-style columns", + icon_markup=icons.kanban, + action=lambda: None, # Widget handles Enter key + relevance=1.0, + plugin_name=self.display_name, + custom_widget=widget, + data={"type": "kanban_board", "keep_launcher_open": True}, + ) diff --git a/modules/launcher/plugins/otp.py b/modules/launcher/plugins/otp.py new file mode 100644 index 0000000..d9f95c4 --- /dev/null +++ b/modules/launcher/plugins/otp.py @@ -0,0 +1,851 @@ +import json +import subprocess +import threading +import time +from pathlib import Path +from typing import Dict, List, Optional + +from fabric.utils import get_relative_path + +import utils.icons as icons +from modules.launcher.plugin_base import PluginBase +from modules.launcher.result import Result +from services.auth import ( + generate_totp, + get_time_remaining_with_blink, + parse_otpauth_uri, + scan_qr_and_add_account, + validate_base32_secret, +) + + +class OTPPlugin(PluginBase): + """Plugin for managing TOTP (Time-based One-Time Password) codes.""" + + def __init__(self): + super().__init__() + self.display_name = "OTP Manager" + self.description = "Manage TOTP codes and 2FA authentication" + + self.secrets_file = Path( + get_relative_path("../../../config/assets/accounts.json") + ) + self.secrets: Dict[str, Dict] = {} + self.last_update = 0 + + # Threading for auto-refresh + self.refresh_thread = None + self.stop_refresh = threading.Event() + + def initialize(self): + """Initialize the OTP plugin.""" + self.set_triggers(["otp"]) + self._load_secrets() + self._ensure_config_file() + self._start_refresh_thread() + + def cleanup(self): + """Cleanup the OTP plugin.""" + if self.refresh_thread and self.refresh_thread.is_alive(): + self.stop_refresh.set() + self.refresh_thread.join(timeout=1) + + def _load_secrets(self): + """Load secrets from JSON file.""" + try: + if self.secrets_file.exists(): + with open(self.secrets_file, "r", encoding="utf-8") as f: + self.secrets = json.load(f) + else: + self.secrets = {} + except Exception as e: + print(f"Error loading OTP secrets: {e}") + self.secrets = {} + + def _save_secrets(self): + """Save secrets to JSON file.""" + try: + self.secrets_file.parent.mkdir(parents=True, exist_ok=True) + with open(self.secrets_file, "w", encoding="utf-8") as f: + json.dump(self.secrets, f, indent=2) + except Exception as e: + print(f"Error saving OTP secrets: {e}") + + def _ensure_config_file(self): + """Ensure the config file exists.""" + if not self.secrets_file.exists(): + self.secrets_file.parent.mkdir(parents=True, exist_ok=True) + with open(self.secrets_file, "w", encoding="utf-8") as f: + json.dump({}, f, indent=2) + + def _start_refresh_thread(self): + """Start background thread for auto-refreshing tokens.""" + + def refresh_loop(): + while not self.stop_refresh.wait(1): + current_time = time.time() + if current_time - self.last_update >= 1: + self.last_update = current_time + if self.secrets: + try: + self._selective_force_refresh() + except Exception: + pass + + self.refresh_thread = threading.Thread(target=refresh_loop, daemon=True) + self.refresh_thread.start() + + def _selective_force_refresh(self): + """Update time display in existing OTP result items.""" + try: + import gc + + from gi.repository import GLib + + def do_update(): + try: + for obj in gc.get_objects(): + if ( + hasattr(obj, "__class__") + and obj.__class__.__name__ == "Launcher" + and hasattr(obj, "results_box") + and hasattr(obj, "visible") + and obj.visible + and hasattr(obj, "results") + and obj.results + ): + has_otp_results = any( + result.data and result.data.get("type") == "totp" + for result in obj.results + if hasattr(result, "data") and result.data + ) + + if has_otp_results: + self._update_existing_result_labels(obj.results_box) + return False + except Exception: + pass + return False + + GLib.idle_add(do_update) + except Exception: + pass + + def _update_existing_result_labels(self, results_box): + """Update subtitle labels in existing ResultItem widgets.""" + try: + time_display = self._get_time_remaining_with_blink() + for child in results_box.get_children(): + if ( + hasattr(child, "__class__") + and child.__class__.__name__ == "ResultItem" + and hasattr(child, "result") + and hasattr(child.result, "data") + and child.result.data + and child.result.data.get("type") == "totp" + ): + self._update_result_item_content(child, time_display) + except Exception as e: + print(f"Error updating result labels: {e}") + + def _update_result_item_content(self, result_item, time_display): + """Update both the title (OTP code) and subtitle (time display) of a specific ResultItem.""" + try: + account_name = result_item.result.data.get("account", "") + if not account_name or account_name not in self.secrets: + return + + account_data = self.secrets[account_name] + secret = account_data.get("secret", "") + issuer = account_data.get("issuer", "") + display_name = f"{issuer} - {account_name}" if issuer else account_name + + current_totp_code = self._generate_totp(secret) + if not current_totp_code: + return + + old_code = result_item.result.data.get("code", "") + if current_totp_code != old_code: + result_item.result.data["code"] = current_totp_code + self._find_and_update_title_label(result_item, current_totp_code) + result_item.result.action = ( + lambda code=current_totp_code: self._copy_to_clipboard(code) + ) + + new_subtitle_markup = f"{display_name} • {time_display} remaining" + self._find_and_update_subtitle_label(result_item, new_subtitle_markup) + except Exception as e: + print(f"Error updating result item: {e}") + + def _find_and_update_title_label(self, result_item, new_title): + """Find the title label widget and update its text.""" + + def find_title_label(widget): + if hasattr(widget, "get_name") and widget.get_name() == "result-item-title": + return widget + if hasattr(widget, "get_children"): + for child in widget.get_children(): + found = find_title_label(child) + if found: + return found + return None + + title_label = find_title_label(result_item) + if title_label and hasattr(title_label, "set_label"): + title_label.set_label(new_title) + + def _find_and_update_subtitle_label(self, result_item, new_markup): + """Find the subtitle label widget and update its markup.""" + + def find_subtitle_label(widget): + if ( + hasattr(widget, "get_name") + and widget.get_name() == "result-item-subtitle" + ): + return widget + if hasattr(widget, "get_children"): + for child in widget.get_children(): + found = find_subtitle_label(child) + if found: + return found + return None + + subtitle_label = find_subtitle_label(result_item) + if subtitle_label and hasattr(subtitle_label, "set_markup"): + subtitle_label.set_markup(new_markup) + + def _copy_to_clipboard(self, text: str): + """Copy text to clipboard.""" + try: + try: + subprocess.run(["wl-copy"], input=text.encode(), check=True) + except subprocess.SubprocessError: + subprocess.run( + ["xclip", "-selection", "clipboard"], + input=text.encode(), + check=True, + ) + except Exception as e: + print(f"Failed to copy to clipboard: {e}") + + def _trigger_refresh(self): + """Trigger launcher refresh to return to default OTP view.""" + try: + from gi.repository import GLib + + # Use a small delay to ensure the action completes first + def trigger_refresh(): + try: + # Try to access the launcher through the fabric Application + from fabric import Application + + app = Application.get_default() + + if app and hasattr(app, "launcher"): + launcher = app.launcher + if launcher and hasattr(launcher, "search_entry"): + # Clear the search entry and set it to just "otp " + launcher.search_entry.set_text("otp ") + # Position cursor at the end + launcher.search_entry.set_position(-1) + # Trigger the search to show default OTP view + if hasattr(launcher, "_perform_search"): + launcher._perform_search("otp ") + return False + + # Fallback: try to find launcher instance through other means + import gc + + for obj in gc.get_objects(): + if ( + hasattr(obj, "__class__") + and obj.__class__.__name__ == "Launcher" + ): + if hasattr(obj, "search_entry") and hasattr( + obj, "_perform_search" + ): + obj.search_entry.set_text("otp ") + obj.search_entry.set_position(-1) + obj._perform_search("otp ") + return False + + except Exception as e: + print(f"Error forcing launcher refresh: {e}") + + return False # Don't repeat + + # Use a small delay to ensure the action completes first + GLib.timeout_add(50, trigger_refresh) + + except Exception as e: + print(f"Could not trigger refresh: {e}") + + def _remove_account_and_refresh(self, account_name: str): + """Remove an account and trigger refresh to return to default OTP view.""" + try: + if account_name in self.secrets: + # Remove the account + del self.secrets[account_name] + self._save_secrets() + + # Trigger refresh to return to default OTP view + self._trigger_refresh() + except Exception as e: + print(f"Error removing account {account_name}: {e}") + + def _generate_totp(self, secret: str) -> Optional[str]: + """Generate TOTP code from secret.""" + return generate_totp(secret) + + def _get_time_remaining_with_blink(self) -> str: + """Get time remaining with blinking effect.""" + return get_time_remaining_with_blink() + + def query(self, query_string: str) -> List[Result]: + """Process OTP queries.""" + query = query_string.strip() + + if not query: + return self._list_otp_codes() + + query_lower = query.lower() + if query_lower.startswith("add "): + add_content = query[4:].strip() + + # Handle both formats: with ``` and without ``` + if "```" in add_content: + # Old format: add account```secret``` + parts = add_content.split("```", 1) + if len(parts) == 2: + account_name = parts[0].strip() + secret_or_uri = parts[1].strip() + return self._handle_direct_add(account_name, secret_or_uri) + elif " " in add_content: + # New format: add account secret + parts = add_content.split(" ", 1) + if len(parts) == 2: + account_name = parts[0].strip() + secret_or_uri = parts[1].strip() + return self._handle_direct_add(account_name, secret_or_uri) + + return self._handle_add_command(add_content) + elif query_lower == "remove" or query_lower.startswith("remove "): + # Handle both "remove" and "remove accountname" + if query_lower == "remove": + remove_content = "" + else: + remove_content = query[7:].strip() + return self._handle_remove_command(remove_content) + elif query_lower == "qr" or query_lower.startswith("qr "): + # Handle QR scanning command + if query_lower == "qr": + qr_content = "" + else: + qr_content = query[3:].strip() + return self._handle_qr_command(qr_content) + else: + return self._search_accounts(query) + + def _handle_direct_add(self, account_name: str, secret_or_uri: str) -> List[Result]: + """Handle direct addition of OTP account.""" + if not account_name or not secret_or_uri: + return [ + Result( + title="Invalid format", + subtitle="Usage: add <account_name> <secret> or add <account_name>```<secret>```", + icon_markup=icons.question, + action=lambda: None, + relevance=1.0, + plugin_name=self.display_name, + data={"type": "help", "keep_launcher_open": True}, + ) + ] + + try: + if secret_or_uri.startswith("otpauth://"): + return self._handle_otpauth_uri(account_name, secret_or_uri) + else: + return self._handle_base32_secret(account_name, secret_or_uri) + except Exception as e: + print(f"OTP Debug: Error in _handle_direct_add: {e}") + return [ + Result( + title="Error adding account", + subtitle=f"Debug: {str(e)}", + icon_markup=icons.cancel, + action=lambda: None, + relevance=0.5, + plugin_name=self.display_name, + data={"type": "error", "keep_launcher_open": True}, + ) + ] + + def _list_otp_codes(self) -> List[Result]: + """List all OTP codes with current tokens.""" + results = [] + + if not self.secrets: + results.append( + Result( + title="No OTP accounts configured", + subtitle="Use 'add <account> <secret>' to add your first account", + icon_markup=icons.key, + action=lambda: None, + relevance=1.0, + plugin_name=self.display_name, + data={"type": "empty", "keep_launcher_open": True}, + ) + ) + results.append( + Result( + title="Available commands:", + subtitle="add <account> <secret> | remove <account> | qr <account>", + icon_markup=icons.info, + action=lambda: None, + relevance=0.9, + plugin_name=self.display_name, + data={"type": "help", "keep_launcher_open": True}, + ) + ) + return results + + time_display = self._get_time_remaining_with_blink() + + for account_name, account_data in self.secrets.items(): + secret = account_data.get("secret", "") + issuer = account_data.get("issuer", "") + totp_code = self._generate_totp(secret) + + if totp_code: + display_name = f"{issuer} - {account_name}" if issuer else account_name + results.append( + Result( + title=f"{totp_code}", + subtitle_markup=f"{display_name} • { + time_display + } remaining • Shift+Enter: remove", + icon_markup=icons.key, + action=lambda code=totp_code: self._copy_to_clipboard(code), + relevance=1.0, + plugin_name=self.display_name, + data={ + "type": "totp", + "account": account_name, + "code": totp_code, + "alt_action": lambda acc=account_name: self._remove_account_and_refresh( + acc + ), + }, + ) + ) + else: + results.append( + Result( + title=f"Error: {account_name}", + subtitle="Invalid secret or configuration", + icon_markup=icons.cancel, + action=lambda: None, + relevance=0.5, + plugin_name=self.display_name, + data={ + "type": "error", + "account": account_name, + "keep_launcher_open": True, + }, + ) + ) + + return results + + def _search_accounts(self, query: str) -> List[Result]: + """Search accounts by name or issuer.""" + results = [] + query_lower = query.lower() + + for account_name, account_data in self.secrets.items(): + issuer = account_data.get("issuer", "").lower() + account_lower = account_name.lower() + + if query_lower in account_lower or query_lower in issuer: + secret = account_data.get("secret", "") + totp_code = self._generate_totp(secret) + + if totp_code: + display_name = ( + f"{account_data.get('issuer', '')} - {account_name}" + if account_data.get("issuer") + else account_name + ) + time_display = self._get_time_remaining_with_blink() + + results.append( + Result( + title=f"{totp_code}", + subtitle_markup=f"{display_name} • { + time_display + } remaining • Shift+Enter: remove", + icon_markup=icons.key, + action=lambda code=totp_code: self._copy_to_clipboard(code), + relevance=1.0, + plugin_name=self.display_name, + data={ + "type": "totp", + "account": account_name, + "code": totp_code, + "alt_action": lambda acc=account_name: self._remove_account_and_refresh( + acc + ), + }, + ) + ) + + if not results: + results.append( + Result( + title=f"No accounts found for '{query}'", + subtitle="Use 'add <account> <secret>' to create new account", + icon_markup=icons.magnifier, + action=lambda: None, + relevance=0.5, + plugin_name=self.display_name, + data={"type": "no_results", "keep_launcher_open": True}, + ) + ) + results.append( + Result( + title="Available commands:", + subtitle="add <account> <secret> | remove <account> | qr <account>", + icon_markup=icons.info, + action=lambda: None, + relevance=0.4, + plugin_name=self.display_name, + data={"type": "help", "keep_launcher_open": True}, + ) + ) + + return results + + def _handle_add_command(self, account_name: str) -> List[Result]: + """Handle manual addition of OTP secret.""" + if not account_name: + return [ + Result( + title="Enter account name", + subtitle="Usage: add <account_name> <secret>", + icon_markup=icons.question, + action=lambda: None, + relevance=1.0, + plugin_name=self.display_name, + data={"type": "help", "keep_launcher_open": True}, + ) + ] + + return [ + Result( + title=f"To add '{account_name}':", + subtitle=f"Type: add {account_name} <secret>", + icon_markup=icons.bulb, + action=lambda: None, + relevance=1.0, + plugin_name=self.display_name, + data={ + "type": "instruction", + "account": account_name, + "keep_launcher_open": True, + }, + ), + Result( + title="Base32 Secret Format:", + subtitle="Example: add gmail JBSWY3DPEHPK3PXP", + icon_markup=icons.info, + action=lambda: None, + relevance=0.9, + plugin_name=self.display_name, + data={"type": "help", "keep_launcher_open": True}, + ), + Result( + title="otpauth URI Format:", + subtitle="Example: add github otpauth://totp/GitHub:user?secret=JBSWY3DPEHPK3PXP", + icon_markup=icons.info, + action=lambda: None, + relevance=0.9, + plugin_name=self.display_name, + data={"type": "help", "keep_launcher_open": True}, + ), + Result( + title="Remove Account:", + subtitle="Example: remove gmail", + icon_markup=icons.trash, + action=lambda: None, + relevance=0.8, + plugin_name=self.display_name, + data={"type": "help", "keep_launcher_open": True}, + ), + ] + + def _handle_qr_command(self, account_name: str) -> List[Result]: + """Handle QR scanning command.""" + if not account_name: + return [ + Result( + title="Scan QR Code", + subtitle="Click to scan QR code from screen", + icon_markup=icons.connect, + action=lambda: self._scan_qr_and_add_account(""), + relevance=1.0, + plugin_name=self.display_name, + data={"type": "qr_scan"}, + ), + Result( + title="QR Scan Instructions:", + subtitle="Use 'qr <account_name>' to specify account name", + icon_markup=icons.info, + action=lambda: None, + relevance=0.9, + plugin_name=self.display_name, + data={"type": "help", "keep_launcher_open": True}, + ), + ] + else: + return [ + Result( + title=f"Scan QR Code for '{account_name}'", + subtitle="Click to scan QR code from screen", + icon_markup=icons.connect, + action=lambda name=account_name: self._scan_qr_and_add_account( + name + ), + relevance=1.0, + plugin_name=self.display_name, + data={"type": "qr_scan"}, + ), + ] + + def _scan_qr_and_add_account(self, account_name: str): + """Scan QR code and add OTP account.""" + print(f"QR scan action called for account: '{account_name}'") + print("Starting QR scan process asynchronously...") + + # Run QR scanning asynchronously so launcher closes immediately + import threading + + thread = threading.Thread(target=self._scan_qr_async, args=(account_name,)) + thread.daemon = True + thread.start() + + def _scan_qr_async(self, account_name: str): + """Async QR scanning process.""" + result = scan_qr_and_add_account(account_name, str(self.secrets_file)) + + if result["success"]: + print(result["message"]) + # Reload secrets from file + self._load_secrets() + # Trigger refresh to show the new account + self._trigger_refresh() + else: + print(f"QR scan failed: {result['error']}") + + def _handle_remove_command(self, account_name: str) -> List[Result]: + """Handle removal of OTP account.""" + if not account_name: + # Show all available accounts for removal + results = [] + + if not self.secrets: + results.append( + Result( + title="No OTP accounts to remove", + subtitle="Use 'add <account> <secret>' to add accounts first", + icon_markup=icons.info, + action=lambda: None, + relevance=1.0, + plugin_name=self.display_name, + data={"type": "empty", "keep_launcher_open": True}, + ) + ) + else: + results.append( + Result( + title="Select account to remove:", + subtitle="Type: remove <account_name> to remove an account", + icon_markup=icons.trash, + action=lambda: None, + relevance=1.0, + plugin_name=self.display_name, + data={"type": "help", "keep_launcher_open": True}, + ) + ) + + # Get time display for consistency with main OTP view + time_display = self._get_time_remaining_with_blink() + + # Show all accounts with their current OTP codes and remove actions + for acc_name, account_data in self.secrets.items(): + secret = account_data.get("secret", "") + issuer = account_data.get("issuer", "") + display_name = f"{issuer} - {acc_name}" if issuer else acc_name + + # Generate current TOTP code + totp_code = self._generate_totp(secret) + + if totp_code: + results.append( + Result( + title=f"{totp_code}", + subtitle_markup=f"Press Enter to remove • { + time_display + } remaining", + icon_markup=icons.trash, + action=lambda acc=acc_name: self._remove_account_and_refresh( + acc + ), + relevance=0.9, + plugin_name=self.display_name, + data={ + "type": "remove_instruction", + "account": acc_name, + "code": totp_code, + "keep_launcher_open": True, + }, + ) + ) + else: + results.append( + Result( + title=f"Error: {acc_name}", + subtitle="Press Enter to remove (Invalid secret)", + icon_markup=icons.trash, + action=lambda acc=acc_name: self._remove_account_and_refresh( + acc + ), + relevance=0.8, + plugin_name=self.display_name, + data={ + "type": "remove_instruction", + "account": acc_name, + "keep_launcher_open": True, + }, + ) + ) + + return results + + # Check if account exists + if account_name not in self.secrets: + return [ + Result( + title=f"Account '{account_name}' not found", + subtitle="Use 'remove' to see all available accounts", + icon_markup=icons.cancel, + action=lambda: None, + relevance=0.5, + plugin_name=self.display_name, + data={"type": "error", "keep_launcher_open": True}, + ) + ] + + # Get account info for confirmation + account_data = self.secrets[account_name] + issuer = account_data.get("issuer", "") + display_name = f"{issuer} - {account_name}" if issuer else account_name + + # Show confirmation for removal + return [ + Result( + title=f"Remove '{display_name}'?", + subtitle="Press Enter to confirm removal", + icon_markup=icons.trash, + action=lambda acc=account_name: self._remove_account_and_refresh(acc), + relevance=1.0, + plugin_name=self.display_name, + data={ + "type": "remove_confirm", + "account": account_name, + "keep_launcher_open": True, + }, + ) + ] + + def _handle_base32_secret(self, account_name: str, secret: str) -> List[Result]: + """Handle raw Base32 secret.""" + result = validate_base32_secret(secret) + + if not result["success"]: + return [ + Result( + title="Invalid Base32 secret", + subtitle=result["error"], + icon_markup=icons.cancel, + action=lambda: None, + relevance=0.5, + plugin_name=self.display_name, + data={"type": "error", "keep_launcher_open": True}, + ) + ] + + self.secrets[account_name] = { + "secret": result["secret"], + "issuer": "", + "algorithm": "SHA1", + "digits": 6, + "period": 30, + } + self._save_secrets() + + return [ + Result( + title=f"✓ Added '{account_name}'", + subtitle=f"OTP account added successfully (secret: { + result['secret'][:4] + }...)", + icon_markup=icons.check, + action=lambda: self._trigger_refresh(), + relevance=1.0, + plugin_name=self.display_name, + data={"type": "success", "keep_launcher_open": True}, + ) + ] + + def _handle_otpauth_uri(self, account_name: str, uri: str) -> List[Result]: + """Handle otpauth:// URI.""" + result = parse_otpauth_uri(uri, account_name) + + if not result["success"]: + return [ + Result( + title="Error parsing otpauth URI", + subtitle=result["error"], + icon_markup=icons.cancel, + action=lambda: None, + relevance=0.5, + plugin_name=self.display_name, + data={"type": "error", "keep_launcher_open": True}, + ) + ] + + self.secrets[result["account_name"]] = { + "secret": result["secret"], + "issuer": result["issuer"], + "algorithm": result["algorithm"], + "digits": result["digits"], + "period": result["period"], + } + self._save_secrets() + + display_name = ( + f"{result['issuer']} - {result['account_name']}" + if result["issuer"] + else result["account_name"] + ) + return [ + Result( + title=f"✓ Added '{display_name}'", + subtitle="OTP account added from URI", + icon_markup=icons.check, + action=lambda: self._trigger_refresh(), + relevance=1.0, + plugin_name=self.display_name, + data={"type": "success", "keep_launcher_open": True}, + ) + ] diff --git a/modules/launcher/plugins/password.py b/modules/launcher/plugins/password.py new file mode 100644 index 0000000..18f1f65 --- /dev/null +++ b/modules/launcher/plugins/password.py @@ -0,0 +1,692 @@ +import base64 +import json +import subprocess +import threading +import time +from datetime import datetime +from pathlib import Path +from typing import Dict, List, Optional + +from fabric.utils import get_relative_path + +import utils.icons as icons +from modules.launcher.plugin_base import PluginBase +from modules.launcher.result import Result + + +class PasswordManager: + """Simple password manager with basic encryption and caching.""" + + def __init__(self, storage_file: Path): + self.storage_file = storage_file + self.passwords: Dict[str, Dict] = {} + self._cache_lock = threading.Lock() + self._last_loaded = 0 + self._cache_ttl = 30 # Cache for 30 seconds + self._load_passwords() + + def _simple_encrypt(self, text: str, key: str = "modus_pass") -> str: + """Simple encryption using XOR with base64 encoding.""" + key_bytes = key.encode("utf-8") + text_bytes = text.encode("utf-8") + + # XOR encryption + encrypted = bytearray() + for i, byte in enumerate(text_bytes): + encrypted.append(byte ^ key_bytes[i % len(key_bytes)]) + + # Base64 encode + return base64.b64encode(encrypted).decode("utf-8") + + def _simple_decrypt(self, encrypted_text: str, key: str = "modus_pass") -> str: + """Simple decryption using XOR with base64 decoding.""" + try: + key_bytes = key.encode("utf-8") + + # Base64 decode + encrypted_bytes = base64.b64decode(encrypted_text.encode("utf-8")) + + # XOR decryption + decrypted = bytearray() + for i, byte in enumerate(encrypted_bytes): + decrypted.append(byte ^ key_bytes[i % len(key_bytes)]) + + return decrypted.decode("utf-8") + except Exception: + return encrypted_text # Return as-is if decryption fails + + def _load_passwords(self): + """Load passwords from JSON file with caching.""" + with self._cache_lock: + current_time = time.time() + + # Check if cache is still valid + if (current_time - self._last_loaded) < self._cache_ttl and self.passwords: + return + + try: + if self.storage_file.exists(): + with open(self.storage_file, "r", encoding="utf-8") as f: + data = json.load(f) + self.passwords = data.get("passwords", {}) + else: + self.passwords = {} + + self._last_loaded = current_time + except Exception as e: + print(f"Error loading passwords: {e}") + self.passwords = {} + + def _save_passwords(self): + """Save passwords to JSON file.""" + with self._cache_lock: + try: + self.storage_file.parent.mkdir(parents=True, exist_ok=True) + data = { + "passwords": self.passwords, + "last_modified": datetime.now().isoformat(), + } + with open(self.storage_file, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + + # Update cache timestamp + self._last_loaded = time.time() + except Exception as e: + print(f"Error saving passwords: {e}") + + def add_password(self, name: str, password: str, description: str = "") -> bool: + """Add a new password entry.""" + try: + encrypted_password = self._simple_encrypt(password) + self.passwords[name] = { + "password": encrypted_password, + "description": description, + "created": datetime.now().isoformat(), + "last_accessed": None, + } + self._save_passwords() + return True + except Exception as e: + print(f"Error adding password: {e}") + return False + + def get_password(self, name: str, update_access_time: bool = True) -> Optional[str]: + """Get decrypted password by name.""" + # Ensure we have fresh data + self._load_passwords() + + if name in self.passwords: + try: + encrypted = self.passwords[name]["password"] + decrypted = self._simple_decrypt(encrypted) + + # Update last accessed time only if requested (to avoid frequent saves) + if update_access_time: + self.passwords[name]["last_accessed"] = datetime.now().isoformat() + # Don't save immediately - batch saves for better performance + + return decrypted + except Exception as e: + print(f"Error decrypting password: {e}") + return None + return None + + def remove_password(self, name: str) -> bool: + """Remove a password entry.""" + if name in self.passwords: + del self.passwords[name] + self._save_passwords() + return True + return False + + def list_passwords(self) -> List[str]: + """Get list of all password names.""" + # Ensure we have fresh data + self._load_passwords() + return list(self.passwords.keys()) + + def get_password_info(self, name: str) -> Optional[Dict]: + """Get password metadata without decrypting.""" + if name in self.passwords: + info = self.passwords[name].copy() + info.pop("password", None) # Remove encrypted password + return info + return None + + +class PasswordPlugin(PluginBase): + """ + Password manager plugin for the launcher. + Stores passwords securely and allows easy access. + """ + + def __init__(self): + super().__init__() + self.display_name = "Password Manager" + self.description = "Secure password storage and management" + + # Initialize password manager + self.password_file = Path( + get_relative_path("../../../config/assets/passwords.json") + ) + self.password_manager = PasswordManager(self.password_file) + + # State for password visibility + self.revealed_passwords: Dict[str, str] = {} + + # Cache for results to avoid repeated queries + self._results_cache: Dict[str, List[Result]] = {} + self._cache_timestamps: Dict[str, float] = {} + self._cache_ttl = 5 # Cache results for 5 seconds + + # Track launcher state for auto-hiding passwords + self._launcher_instance = None + + def initialize(self): + """Initialize the password plugin.""" + self.set_triggers(["pass"]) + self._setup_launcher_hooks() + + def cleanup(self): + """Cleanup the password plugin.""" + self.revealed_passwords.clear() + self._results_cache.clear() + self._cache_timestamps.clear() + self._cleanup_launcher_hooks() + + def query(self, query_string: str) -> List[Result]: + """Process password manager queries with caching.""" + query_key = query_string.strip() + current_time = time.time() + + # Check cache first (except for add/remove commands which should always execute) + if ( + not query_key.startswith(("add ", "remove ", "delete ")) + and query_key in self._results_cache + and (current_time - self._cache_timestamps.get(query_key, 0)) + < self._cache_ttl + ): + return self._results_cache[query_key] + + results = [] + query = query_key.lower() + + # Handle different commands + if not query: + # Show all passwords + results.extend(self._list_all_passwords()) + elif query.startswith("add "): + # Add new password (don't cache) + results.extend(self._handle_add_command(query_string)) + elif query.startswith("remove ") or query.startswith("delete "): + # Remove password (don't cache) + results.extend(self._handle_remove_command(query_string)) + else: + # Search for specific password + results.extend(self._search_passwords(query)) + + # Cache results (except for add/remove commands) + if not query.startswith(("add ", "remove ", "delete ")): + self._results_cache[query_key] = results + self._cache_timestamps[query_key] = current_time + + return results + + def _list_all_passwords(self) -> List[Result]: + """List all stored passwords.""" + results = [] + password_names = self.password_manager.list_passwords() + + if not password_names: + results.append( + Result( + title="No passwords stored", + subtitle="Use 'pass add <name> <password>' to add your first password", + icon_markup=icons.key, + action=lambda: None, + relevance=1.0, + plugin_name=self.display_name, + data={"type": "empty", "keep_launcher_open": True}, + ) + ) + results.append( + Result( + title="Available commands:", + subtitle="add <name> <password> | remove <name> | <name> (to search)", + icon_markup=icons.info, + action=lambda: None, + relevance=0.9, + plugin_name=self.display_name, + data={"type": "help", "keep_launcher_open": True}, + ) + ) + return results + + # Sort passwords alphabetically + password_names.sort() + + for name in password_names: + info = self.password_manager.get_password_info(name) + description = info.get("description", "") if info else "" + + # Check if password is revealed + if name in self.revealed_passwords: + title = f"{name}: {self.revealed_passwords[name]}" + subtitle = "Password revealed - Enter: copy | Shift+Enter: hide" + else: + title = f"{name}: {'*' * 8}" + subtitle = "Enter: copy | Shift+Enter: reveal password" + + if description: + subtitle += f" | {description}" + + results.append( + Result( + title=title, + subtitle=subtitle, + icon_markup=icons.key, + action=lambda n=name: self._copy_password_to_clipboard(n), + relevance=1.0, + plugin_name=self.display_name, + data={ + "type": "password", + "name": name, + "keep_launcher_open": False, + "alt_action": lambda n=name: self._toggle_password_visibility( + n + ), + }, + ) + ) + + return results + + def _handle_add_command(self, query_string: str) -> List[Result]: + """Handle add password command.""" + results = [] + parts = query_string.strip().split(" ", 3) + + if len(parts) < 3: + results.append( + Result( + title="Add Password - Invalid format", + subtitle="Usage: add <name> <password> [description]", + icon_markup=icons.cancel, + action=lambda: None, + relevance=1.0, + plugin_name=self.display_name, + data={"type": "error", "keep_launcher_open": True}, + ) + ) + return results + + name = parts[1] + password = parts[2] + description = parts[3] if len(parts) > 3 else "" + + # Check if password already exists + if name in self.password_manager.list_passwords(): + results.append( + Result( + title=f"Update password for '{name}'?", + subtitle="Password already exists. Click to update it.", + icon_markup=icons.key, + action=lambda: self._add_password_action( + name, password, description, update=True + ), + relevance=1.0, + plugin_name=self.display_name, + data={"type": "update", "name": name, "keep_launcher_open": False}, + ) + ) + else: + results.append( + Result( + title=f"Add password for '{name}'", + subtitle="Click to save password" + + (f" | {description}" if description else ""), + icon_markup=icons.plus, + action=lambda: self._add_password_action( + name, password, description + ), + relevance=1.0, + plugin_name=self.display_name, + data={"type": "add", "name": name, "keep_launcher_open": False}, + ) + ) + + return results + + def _handle_remove_command(self, query_string: str) -> List[Result]: + """Handle remove password command.""" + results = [] + parts = query_string.strip().split(" ", 1) + + if len(parts) < 2: + results.append( + Result( + title="Remove Password - Invalid format", + subtitle="Usage: remove <name>", + icon_markup=icons.cancel, + action=lambda: None, + relevance=1.0, + plugin_name=self.display_name, + data={"type": "error", "keep_launcher_open": True}, + ) + ) + return results + + name = parts[1] + + if name not in self.password_manager.list_passwords(): + results.append( + Result( + title=f"Password '{name}' not found", + subtitle="Check the name and try again", + icon_markup=icons.cancel, + action=lambda: None, + relevance=1.0, + plugin_name=self.display_name, + data={"type": "error", "keep_launcher_open": True}, + ) + ) + else: + results.append( + Result( + title=f"Remove password '{name}'?", + subtitle="Click to confirm deletion (this cannot be undone)", + icon_markup=icons.trash, + action=lambda: self._remove_password_action(name), + relevance=1.0, + plugin_name=self.display_name, + data={"type": "remove", "name": name, "keep_launcher_open": False}, + ) + ) + + return results + + def _search_passwords(self, query: str) -> List[Result]: + """Search for passwords by name.""" + results = [] + password_names = self.password_manager.list_passwords() + + # Filter passwords that match the query + matching_passwords = [ + name for name in password_names if query.lower() in name.lower() + ] + + if not matching_passwords: + results.append( + Result( + title=f"No passwords found matching '{query}'", + subtitle="Try a different search term or use 'pass' to see all passwords", + icon_markup=icons.magnifier, + action=lambda: None, + relevance=1.0, + plugin_name=self.display_name, + data={"type": "no_results", "keep_launcher_open": True}, + ) + ) + return results + + # Sort by relevance (exact match first, then starts with, then contains) + def get_relevance(name: str) -> float: + name_lower = name.lower() + query_lower = query.lower() + + if name_lower == query_lower: + return 1.0 + elif name_lower.startswith(query_lower): + return 0.9 + else: + return 0.7 + + matching_passwords.sort(key=get_relevance, reverse=True) + + for name in matching_passwords: + info = self.password_manager.get_password_info(name) + description = info.get("description", "") if info else "" + + # Check if password is revealed + if name in self.revealed_passwords: + title = f"{name}: {self.revealed_passwords[name]}" + subtitle = "Password revealed - Enter: copy | Shift+Enter: hide" + else: + title = f"{name}: {'*' * 8}" + subtitle = "Enter: copy | Shift+Enter: reveal password" + + if description: + subtitle += f" | {description}" + + results.append( + Result( + title=title, + subtitle=subtitle, + icon_markup=icons.key, + action=lambda n=name: self._copy_password_to_clipboard(n), + relevance=get_relevance(name), + plugin_name=self.display_name, + data={ + "type": "password", + "name": name, + "keep_launcher_open": False, + "alt_action": lambda n=name: self._toggle_password_visibility( + n + ), + }, + ) + ) + + return results + + def _add_password_action( + self, name: str, password: str, description: str = "", update: bool = False + ): + """Action to add/update a password.""" + try: + success = self.password_manager.add_password(name, password, description) + if success: + action_word = "updated" if update else "added" + # Clear cache to force refresh + self._results_cache.clear() + + # Send notification if available (non-blocking) + try: + subprocess.Popen( + [ + "notify-send", + "Password Manager", + f"Password '{name}' {action_word} successfully", + ] + ) + except: + pass + else: + print(f"Failed to add password '{name}'") + except Exception as e: + print(f"Error adding password: {e}") + + def _remove_password_action(self, name: str): + """Action to remove a password.""" + try: + success = self.password_manager.remove_password(name) + if success: + # Remove from revealed passwords if present + self.revealed_passwords.pop(name, None) + + # Clear cache to force refresh + self._results_cache.clear() + + # Send notification if available (non-blocking) + try: + subprocess.Popen( + [ + "notify-send", + "Password Manager", + f"Password '{name}' removed successfully", + ] + ) + except: + pass + else: + print(f"Failed to remove password '{name}'") + except Exception as e: + print(f"Error removing password: {e}") + + def _copy_password_to_clipboard(self, name: str): + """Copy password to clipboard and reveal it temporarily.""" + try: + password = self.password_manager.get_password( + name, update_access_time=False + ) + if password: + # Copy to clipboard (use timeout to avoid hanging) + try: + subprocess.run( + ["wl-copy"], input=password.encode(), check=True, timeout=2 + ) + except subprocess.SubprocessError: + # Fall back to X11 + subprocess.run( + ["xclip", "-selection", "clipboard"], + input=password.encode(), + check=True, + timeout=2, + ) + + # Reveal password temporarily + self.revealed_passwords[name] = password + + # Send notification if available (non-blocking) + try: + subprocess.Popen( + [ + "notify-send", + "Password Manager", + f"Password for '{name}' copied to clipboard", + ] + ) + except: + pass + + # Clear cache to force refresh + self._results_cache.clear() + + else: + print(f"Failed to retrieve password for '{name}'") + except Exception as e: + print(f"Error copying password: {e}") + + def _reveal_password(self, name: str): + """Reveal password without copying to clipboard.""" + try: + password = self.password_manager.get_password( + name, update_access_time=False + ) + if password: + self.revealed_passwords[name] = password + # Clear cache to force refresh with revealed password + self._results_cache.clear() + else: + print(f"Failed to retrieve password for '{name}'") + except Exception as e: + print(f"Error revealing password: {e}") + + def _hide_password(self, name: str): + """Hide revealed password.""" + self.revealed_passwords.pop(name, None) + + def _hide_all_passwords(self): + """Hide all revealed passwords.""" + if self.revealed_passwords: + self.revealed_passwords.clear() + # Clear cache to force refresh with hidden passwords + self._results_cache.clear() + + def _setup_launcher_hooks(self): + """Setup hooks to monitor launcher state.""" + try: + # Try to find the launcher instance + import gc + + for obj in gc.get_objects(): + if ( + hasattr(obj, "__class__") + and obj.__class__.__name__ == "Launcher" + and hasattr(obj, "close_launcher") + ): + self._launcher_instance = obj + # Store original close_launcher method + self._original_close_launcher = obj.close_launcher + # Replace with our wrapper + obj.close_launcher = self._wrapped_close_launcher + break + except Exception as e: + print(f"Warning: Could not setup launcher hooks: {e}") + + def _cleanup_launcher_hooks(self): + """Cleanup launcher hooks.""" + try: + if self._launcher_instance and hasattr(self, "_original_close_launcher"): + # Restore original close_launcher method + self._launcher_instance.close_launcher = self._original_close_launcher + self._launcher_instance = None + except Exception as e: + print(f"Warning: Could not cleanup launcher hooks: {e}") + + def _wrapped_close_launcher(self): + """Wrapper for launcher close that hides passwords.""" + # Hide all passwords when launcher closes + self._hide_all_passwords() + # Call original close_launcher method + if hasattr(self, "_original_close_launcher"): + self._original_close_launcher() + + def _toggle_password_visibility(self, name: str): + """Toggle password visibility when Shift+Enter is pressed.""" + if name in self.revealed_passwords: + # Hide password + self.revealed_passwords.pop(name, None) + self._results_cache.clear() + else: + # Reveal password + try: + password = self.password_manager.get_password( + name, update_access_time=False + ) + if password: + self.revealed_passwords[name] = password + self._results_cache.clear() + else: + print(f"Failed to retrieve password for '{name}'") + except Exception as e: + print(f"Error revealing password: {e}") + + # Force refresh of the launcher to show updated state + self._force_launcher_refresh() + + def _force_launcher_refresh(self): + """Force the launcher to refresh and show updated results.""" + try: + if self._launcher_instance and hasattr( + self._launcher_instance, "_perform_search" + ): + # Get current search text + current_text = "" + if hasattr(self._launcher_instance, "search_entry"): + current_text = self._launcher_instance.search_entry.get_text() + + # Trigger a search to refresh results + try: + from gi.repository import GLib + + def refresh(): + self._launcher_instance._perform_search(current_text) + return False + + GLib.timeout_add(50, refresh) + except ImportError: + # Fallback: direct call if GLib not available + self._launcher_instance._perform_search(current_text) + except Exception as e: + print(f"Could not force launcher refresh: {e}") diff --git a/modules/launcher/plugins/power.py b/modules/launcher/plugins/power.py new file mode 100644 index 0000000..ffdc919 --- /dev/null +++ b/modules/launcher/plugins/power.py @@ -0,0 +1,103 @@ +from typing import List + +from fabric.utils import exec_shell_command_async + +import utils.icons as icons +from modules.launcher.plugin_base import PluginBase +from modules.launcher.result import Result + + +class PowerPlugin(PluginBase): + """ + Plugin for system power management operations. + """ + + def __init__(self): + super().__init__() + self.display_name = "Power" + self.description = "System power management and control" + self.commands = { + "shutdown": { + "description": "Shutdown the system", + "icon": icons.shutdown, + "action": self.shutdown, + }, + "restart": { + "description": "Restart the system", + "icon": icons.reboot, + "action": self.restart, + }, + "lock": { + "description": "Lock the screen", + "icon": icons.lock, + "action": self.lock, + }, + "suspend": { + "description": "Suspend the system", + "icon": icons.suspend, + "action": self.suspend, + }, + "logout": { + "description": "Logout from current session", + "icon": icons.logout, + "action": self.logout, + }, + } + + def initialize(self): + """Initialize the power plugin.""" + self.set_triggers(["power"]) + + def cleanup(self): + """Cleanup the power plugin.""" + pass + + def query(self, query_string: str) -> List[Result]: + """Search power commands based on query.""" + query = query_string.lower().strip() + results = [] + + # If no query, show all power commands + if not query: + for cmd, info in self.commands.items(): + result = Result( + title=cmd.capitalize(), + subtitle=info["description"], + icon_markup=info["icon"], + action=info["action"], + relevance=1.0, + plugin_name=self.display_name, + data={"command": cmd}, + ) + results.append(result) + else: + # Filter commands based on query + for cmd, info in self.commands.items(): + if query in cmd.lower() or query in info["description"].lower(): + result = Result( + title=cmd.capitalize(), + subtitle=info["description"], + icon_markup=info["icon"], + action=info["action"], + relevance=1.0 if query == cmd else 0.7, + plugin_name=self.display_name, + data={"command": cmd}, + ) + results.append(result) + + return results + + def shutdown(self, *args) -> None: + exec_shell_command_async("systemctl poweroff") + + def restart(self, *args) -> None: + exec_shell_command_async("systemctl reboot") + + def lock(self, *args) -> None: + exec_shell_command_async("loginctl lock-session") + + def suspend(self, *args) -> None: + exec_shell_command_async("systemctl suspend") + + def logout(self, *args) -> None: + exec_shell_command_async("hyprctl dispatch exit") diff --git a/modules/launcher/plugins/process.py b/modules/launcher/plugins/process.py new file mode 100644 index 0000000..d94bfbe --- /dev/null +++ b/modules/launcher/plugins/process.py @@ -0,0 +1,524 @@ +import threading +import time +from typing import List + +import psutil + +import utils.icons as icons +from modules.launcher.plugin_base import PluginBase +from modules.launcher.result import Result + + +class ProcessPlugin(PluginBase): + """ + Plugin for viewing and managing running processes with live CPU/RAM updates. + """ + + def __init__(self): + super().__init__() + self.display_name = "Process Manager" + self.description = "View and manage running processes" + + # Threading for auto-refresh + self.refresh_thread = None + self.stop_refresh = threading.Event() + self.last_update = 0 + + # CPU monitoring cache to get meaningful readings + self._cpu_cache = {} + self._last_cpu_update = 0 + + def initialize(self): + """Initialize the process plugin.""" + self.set_triggers(["ps"]) + # Pre-populate CPU cache for immediate meaningful readings + self._initialize_cpu_monitoring() + self._start_refresh_thread() + + def _initialize_cpu_monitoring(self): + """Initialize CPU monitoring for all processes to get meaningful readings immediately.""" + try: + for proc in psutil.process_iter(["pid"]): + try: + pid = proc.info["pid"] + # Initialize CPU monitoring - this establishes baseline for future readings + proc.cpu_percent() + self._cpu_cache[pid] = proc + except ( + psutil.NoSuchProcess, + psutil.AccessDenied, + psutil.ZombieProcess, + ): + continue + self._last_cpu_update = time.time() + except Exception as e: + print(f"ProcessPlugin: Error initializing CPU monitoring: {e}") + + def cleanup(self): + """Cleanup the process plugin.""" + self.stop_refresh.set() + if self.refresh_thread: + self.refresh_thread.join(timeout=1) + # Clear CPU cache + self._cpu_cache.clear() + + def _get_live_process_data(self): + """Get fresh live process data with CPU/RAM information, grouped by application.""" + try: + current_time = time.time() + + # Initialize CPU monitoring if this is the first call or enough time has passed + if current_time - self._last_cpu_update > 0.1: # Update every 100ms + # Pre-populate CPU cache for all processes + for proc in psutil.process_iter(["pid"]): + try: + pid = proc.info["pid"] + # Initialize CPU monitoring - this call establishes baseline + proc.cpu_percent() + self._cpu_cache[pid] = proc + except ( + psutil.NoSuchProcess, + psutil.AccessDenied, + psutil.ZombieProcess, + ): + continue + self._last_cpu_update = current_time + + # Group processes by application name + app_groups = {} + + for proc in psutil.process_iter(["pid", "name", "status", "ppid"]): + try: + info = proc.info + if info["status"] == psutil.STATUS_ZOMBIE: + continue + + pid = info["pid"] + name = info["name"] or "Unknown" + + # Get CPU percentage - use cached process if available for better readings + if pid in self._cpu_cache: + try: + cpu_percent = self._cpu_cache[pid].cpu_percent() + except (psutil.NoSuchProcess, psutil.AccessDenied): + cpu_percent = proc.cpu_percent() + self._cpu_cache[pid] = proc + else: + cpu_percent = proc.cpu_percent() + self._cpu_cache[pid] = proc + + # Normalize CPU to 0-100% range (divide by number of cores) + cpu_cores = psutil.cpu_count() + if cpu_cores and cpu_percent > 0: + cpu_percent = min(100.0, cpu_percent / cpu_cores) + + memory_info = proc.memory_info() + memory_percent = proc.memory_percent() + memory_mb = memory_info.rss / (1024 * 1024) if memory_info else 0 + + process_data = { + "pid": pid, + "ppid": info["ppid"], + "name": name, + "cpu_percent": cpu_percent, + "memory_percent": memory_percent, + "memory_mb": memory_mb, + "status": info["status"], + } + + # Group processes by application name + app_name = self._get_app_name(name) + if app_name not in app_groups: + app_groups[app_name] = [] + app_groups[app_name].append(process_data) + + except ( + psutil.NoSuchProcess, + psutil.AccessDenied, + psutil.ZombieProcess, + ): + continue + + # Create grouped results + grouped_processes = [] + for app_name, app_processes in app_groups.items(): + if len(app_processes) == 1: + # Single process - show as individual + proc = app_processes[0] + proc["process_count"] = 1 + grouped_processes.append(proc) + else: + # Multiple processes - group them and sum resources + total_cpu = sum(p["cpu_percent"] for p in app_processes) + total_memory_mb = sum(p["memory_mb"] for p in app_processes) + total_memory_percent = sum( + p["memory_percent"] for p in app_processes + ) + + # Find the main process (usually the one with lowest PID) + main_process = min(app_processes, key=lambda x: x["pid"]) + + grouped_process = { + # Use main process PID for killing + "pid": main_process["pid"], + "name": app_name, + "cpu_percent": total_cpu, + "memory_percent": total_memory_percent, + "memory_mb": total_memory_mb, + "status": main_process["status"], + "process_count": len(app_processes), + "child_pids": [p["pid"] for p in app_processes], + } + grouped_processes.append(grouped_process) + + # Clean up old entries from cache + all_pids = set() + for app_processes in app_groups.values(): + all_pids.update(p["pid"] for p in app_processes) + self._cpu_cache = { + pid: proc for pid, proc in self._cpu_cache.items() if pid in all_pids + } + + # Sort by combined CPU and memory usage for better display + grouped_processes.sort( + key=lambda x: (x["cpu_percent"] + x["memory_percent"]), reverse=True + ) + return grouped_processes + + except Exception as e: + print(f"ProcessPlugin: Error getting process data: {e}") + return [] + + def _get_app_name(self, process_name: str) -> str: + """Extract application name from process name, handling common patterns.""" + # Remove common suffixes and normalize + name = process_name.lower() + + # Handle common browser patterns + if "firefox" in name: + return "Firefox" + elif "chrome" in name or "chromium" in name: + return "Chrome" + elif "zen" in name or name in [ + "socket process", + "privileged cont", + "rdd process", + "isolated web co", + "web content", + "webextensions", + "utility process", + "isolated servic", + ]: + return "Zen Browser" + elif "code" in name and ("helper" in name or "oss" in name or name == "code"): + return "VS Code" + elif name.startswith("python") and len(name) > 6: + return "Python" + elif name.startswith("node") and len(name) > 4: + return "Node.js" + elif "electron" in name: + return "Electron" + elif "java" in name: + return "Java" + elif "gnome" in name: + return "GNOME" + elif "gtk" in name: + return "GTK App" + + # Remove common suffixes + suffixes = [ + "-bin", + "-real", + "-wrapped", + ".bin", + ".exe", + "-helper", + "-gpu", + "-renderer", + ] + for suffix in suffixes: + if name.endswith(suffix): + name = name[: -len(suffix)] + break + + # Capitalize first letter + return name.capitalize() + + def query(self, query_string: str) -> List[Result]: + """Search for processes matching the query with live updates - ALWAYS FRESH DATA.""" + # ALWAYS get completely fresh data - NO CACHING EVER + processes = self._get_live_process_data() + + query = query_string.strip().lower() + results = [] + + # Filter processes based on query + filtered_processes = processes + if query: + filtered_processes = [ + proc + for proc in processes + if query in proc["name"].lower() or query in str(proc["pid"]) + ] + + # Limit to top 20 processes to avoid overwhelming the UI + for proc in filtered_processes[:20]: + result = self._create_process_result(proc) + if result: + results.append(result) + + return results + + def _create_process_result(self, proc_data: dict) -> Result: + """Create a Result object for a process or grouped application.""" + try: + pid = proc_data["pid"] + name = proc_data["name"] + cpu_percent = proc_data["cpu_percent"] + memory_percent = proc_data["memory_percent"] + memory_mb = proc_data["memory_mb"] + process_count = proc_data.get("process_count", 1) + + # Format the title with process count if grouped + if process_count > 1: + title = f"{name} ({process_count} processes)" + else: + title = f"{name} (PID: {pid})" + + # Add visual indicators for high usage + cpu_indicator = ( + "🔥" if cpu_percent > 80 else "⚡" if cpu_percent > 50 else "" + ) + mem_indicator = "💾" if memory_percent > 80 else "" + + subtitle = f"CPU: {cpu_percent:.1f}%{cpu_indicator} | Memory: {memory_mb:.1f}MB ({memory_percent:.1f}%){mem_indicator}" + + # Choose icon based on CPU usage + if cpu_percent > 50: + icon_markup = icons.cpu + elif memory_percent > 50: + icon_markup = icons.memory + else: + icon_markup = icons.terminal + + return Result( + title=title, + subtitle=subtitle, + icon_markup=icon_markup, + action=lambda: None, # No action on Enter - just show process info + relevance=min(1.0, (cpu_percent + memory_percent) / 100.0), + plugin_name=self.display_name, + data={ + "type": "process", + "pid": pid, + "name": name, + "cpu_percent": cpu_percent, + "memory_percent": memory_percent, + "memory_mb": memory_mb, + "process_count": process_count, + "child_pids": proc_data.get("child_pids", [pid]), + "keep_launcher_open": True, # Keep launcher open to see live updates + "alt_action": lambda data=proc_data: self._kill_process_group(data), + }, + ) + except Exception as e: + print(f"ProcessPlugin: Error creating process result: {e}") + return None + + def _kill_process_group(self, proc_data: dict): + """Kill a process group (application with all its subprocesses).""" + try: + process_count = proc_data.get("process_count", 1) + child_pids = proc_data.get("child_pids", [proc_data["pid"]]) + app_name = proc_data["name"] + + if process_count == 1: + # Single process + self._kill_process(proc_data["pid"]) + else: + # Multiple processes - kill all + killed_count = 0 + for pid in child_pids: + if self._kill_process(pid, silent=True): + killed_count += 1 + + + except Exception as e: + print(f"✗ Error killing process group: {e}") + + def _kill_process(self, pid: int, silent: bool = False) -> bool: + """Kill a process by PID. Returns True if successful.""" + try: + proc = psutil.Process(pid) + proc_name = proc.name() + + # Try graceful termination first + proc.terminate() + + # Wait a bit for graceful termination + try: + proc.wait(timeout=3) + return True + except psutil.TimeoutExpired: + # Force kill if graceful termination failed + proc.kill() + if not silent: + print(f"✓ Force killed process '{proc_name}' (PID: {pid})") + return True + + except psutil.NoSuchProcess: + if not silent: + print(f"✗ Process {pid} not found - may have already been terminated") + return False + except psutil.AccessDenied: + if not silent: + print( + f"✗ Access denied - cannot kill process {pid} (insufficient permissions)" + ) + return False + except Exception as e: + if not silent: + print(f"✗ Error killing process {pid}: {e}") + return False + + def _start_refresh_thread(self): + """Start background thread for auto-refreshing process data in milliseconds.""" + + def refresh_loop(): + # Check every 500ms for real-time updates + while not self.stop_refresh.wait(0.5): + current_time = time.time() + if current_time - self.last_update >= 0.5: # Update every 500ms + self.last_update = current_time + # Update existing process labels without full refresh + try: + self._selective_force_refresh() + except Exception: + pass + + self.refresh_thread = threading.Thread(target=refresh_loop, daemon=True) + self.refresh_thread.start() + + def _selective_force_refresh(self): + """Update process data in existing result items (like OTP plugin).""" + try: + import gc + + from gi.repository import GLib + + def do_update(): + try: + for obj in gc.get_objects(): + if ( + hasattr(obj, "__class__") + and obj.__class__.__name__ == "Launcher" + and hasattr(obj, "results_box") + and hasattr(obj, "visible") + and obj.visible + and hasattr(obj, "results") + and obj.results + ): + has_process_results = any( + result.data and result.data.get("type") == "process" + for result in obj.results + if hasattr(result, "data") and result.data + ) + + if has_process_results: + self._update_existing_process_labels(obj.results_box) + return False + except Exception: + pass + return False + + GLib.idle_add(do_update) + except Exception: + pass + + def _update_existing_process_labels(self, results_box): + """Update subtitle labels in existing ResultItem widgets with FRESH data (optimized).""" + try: + # Get fresh process data in background thread - optimized + def get_data_async(): + return self._get_live_process_data() + + # Run data collection in background to avoid blocking UI + import concurrent.futures + + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: + future = executor.submit(get_data_async) + try: + current_processes = future.result(timeout=1.0) # 1 second timeout + process_dict = {proc["pid"]: proc for proc in current_processes} + + for child in results_box.get_children(): + if ( + hasattr(child, "__class__") + and child.__class__.__name__ == "ResultItem" + and hasattr(child, "result") + and hasattr(child.result, "data") + and child.result.data + and child.result.data.get("type") == "process" + ): + self._update_process_result_item(child, process_dict) + except concurrent.futures.TimeoutError: + # Skip update if it takes too long + pass + except Exception as e: + print(f"Error updating process labels: {e}") + + def _update_process_result_item(self, result_item, process_dict): + """Update both the title and subtitle of a specific process ResultItem.""" + try: + pid = result_item.result.data.get("pid") + if not pid or pid not in process_dict: + return + + proc_data = process_dict[pid] + cpu_percent = proc_data["cpu_percent"] + memory_percent = proc_data["memory_percent"] + memory_mb = proc_data["memory_mb"] + + # CPU is already normalized in _get_live_process_data, so no need to normalize again + + # Update the subtitle with live data + cpu_indicator = ( + "🔥" if cpu_percent > 80 else "⚡" if cpu_percent > 50 else "" + ) + mem_indicator = "💾" if memory_percent > 80 else "" + new_subtitle = f"CPU: {cpu_percent:.1f}%{cpu_indicator} | Memory: {memory_mb:.1f}MB ({memory_percent:.1f}%){mem_indicator}" + + # Find and update the subtitle label widget + self._find_and_update_subtitle_label(result_item, new_subtitle) + + # Update the result data for consistency + result_item.result.data.update( + { + "cpu_percent": cpu_percent, + "memory_percent": memory_percent, + "memory_mb": memory_mb, + } + ) + + except Exception as e: + print(f"Error updating process result item: {e}") + + def _find_and_update_subtitle_label(self, result_item, new_subtitle): + """Find the subtitle label widget and update its text.""" + + def find_subtitle_label(widget): + if ( + hasattr(widget, "get_name") + and widget.get_name() == "result-item-subtitle" + ): + return widget + if hasattr(widget, "get_children"): + for child in widget.get_children(): + found = find_subtitle_label(child) + if found: + return found + return None + + subtitle_label = find_subtitle_label(result_item) + if subtitle_label and hasattr(subtitle_label, "set_text"): + subtitle_label.set_text(new_subtitle) diff --git a/modules/launcher/plugins/reminders.py b/modules/launcher/plugins/reminders.py new file mode 100644 index 0000000..bcfbb26 --- /dev/null +++ b/modules/launcher/plugins/reminders.py @@ -0,0 +1,448 @@ +import re +import subprocess +import threading +from datetime import datetime, timedelta +from typing import Dict, List, Optional + +import utils.icons as icons +from modules.launcher.plugin_base import PluginBase +from modules.launcher.result import Result + + +class Reminder: + """ + Represents a single reminder with its timer and metadata. + """ + + def __init__( + self, + reminder_id: int, + message: str, + target_time: datetime, + timer: threading.Timer, + ): + self.id = reminder_id + self.message = message + self.target_time = target_time + self.timer = timer + self.created_time = datetime.now() + + def cancel(self): + """Cancel this reminder.""" + if self.timer: + self.timer.cancel() + + def get_time_remaining(self) -> str: + """Get formatted time remaining until reminder.""" + now = datetime.now() + if self.target_time <= now: + return "Overdue" + + delta = self.target_time - now + total_seconds = int(delta.total_seconds()) + + if total_seconds < 60: + return f"{total_seconds}s" + elif total_seconds < 3600: + minutes = total_seconds // 60 + seconds = total_seconds % 60 + return f"{minutes}m {seconds}s" if seconds > 0 else f"{minutes}m" + else: + hours = total_seconds // 3600 + minutes = (total_seconds % 3600) // 60 + return f"{hours}h {minutes}m" if minutes > 0 else f"{hours}h" + + def get_target_time_str(self) -> str: + """Get formatted target time.""" + return self.target_time.strftime("%H:%M") + + +class RemindersPlugin(PluginBase): + """ + Time-based reminders plugin for the launcher. + """ + + def __init__(self): + super().__init__() + self.display_name = "Reminders" + self.description = "Set time-based reminders with notifications" + self.reminders: Dict[int, Reminder] = {} + self.next_id = 1 + + # Regex patterns for time parsing + self.time_patterns = { + "relative_time": re.compile(r"^(\d+)([smhd])$"), # 5m, 30s, 2h, 1d + "absolute_time": re.compile(r"^(\d{1,2}):(\d{2})$"), # 14:30, 9:15 + "relative_with_unit": re.compile( + r"^(\d+)\s*(min|mins|minute|minutes|hour|hours|sec|seconds|day|days)$", + re.IGNORECASE, + ), + } + + def initialize(self): + """Initialize the reminders plugin.""" + self.set_triggers(["remind"]) + + def cleanup(self): + """Cleanup the reminders plugin.""" + # Cancel all active reminders + for reminder in self.reminders.values(): + reminder.cancel() + self.reminders.clear() + + def _send_notification(self, title: str, message: str): + """Send a desktop notification using notify-send.""" + try: + subprocess.run( + ["notify-send", "-a", "Reminders", "-i", "alarm-clock", title, message], + check=False, + ) + except Exception as e: + print(f"Failed to send notification: {e}") + + def _parse_time_input(self, time_str: str) -> Optional[datetime]: + """ + Parse various time input formats and return target datetime. + + Supported formats: + - 5m, 30s, 2h, 1d (relative time) + - 14:30, 9:15 (absolute time today) + - 5 minutes, 2 hours (relative with full unit names) + """ + time_str = time_str.strip().lower() + + # Try relative time format (5m, 30s, 2h, 1d) + match = self.time_patterns["relative_time"].match(time_str) + if match: + value, unit = match.groups() + value = int(value) + + if unit == "s": + delta = timedelta(seconds=value) + elif unit == "m": + delta = timedelta(minutes=value) + elif unit == "h": + delta = timedelta(hours=value) + elif unit == "d": + delta = timedelta(days=value) + else: + return None + + return datetime.now() + delta + + # Try absolute time format (14:30, 9:15) + match = self.time_patterns["absolute_time"].match(time_str) + if match: + hour, minute = map(int, match.groups()) + if 0 <= hour <= 23 and 0 <= minute <= 59: + target = datetime.now().replace( + hour=hour, minute=minute, second=0, microsecond=0 + ) + # If the time has already passed today, schedule for tomorrow + if target <= datetime.now(): + target += timedelta(days=1) + return target + + # Try relative time with full unit names + match = self.time_patterns["relative_with_unit"].match(time_str) + if match: + value, unit = match.groups() + value = int(value) + unit = unit.lower() + + if unit in ["sec", "seconds"]: + delta = timedelta(seconds=value) + elif unit in ["min", "mins", "minute", "minutes"]: + delta = timedelta(minutes=value) + elif unit in ["hour", "hours"]: + delta = timedelta(hours=value) + elif unit in ["day", "days"]: + delta = timedelta(days=value) + else: + return None + + return datetime.now() + delta + + return None + + def _create_reminder(self, time_str: str, message: str) -> Optional[Reminder]: + """Create a new reminder with the given time and message.""" + target_time = self._parse_time_input(time_str) + if not target_time: + return None + + # Calculate delay in seconds + delay = (target_time - datetime.now()).total_seconds() + if delay <= 0: + return None + + # Create timer that will trigger the notification + timer = threading.Timer(delay, self._trigger_reminder, [self.next_id, message]) + + # Create reminder object + reminder = Reminder(self.next_id, message, target_time, timer) + + # Store reminder and start timer + self.reminders[self.next_id] = reminder + timer.start() + + # Increment ID for next reminder + self.next_id += 1 + + return reminder + + def _trigger_reminder(self, reminder_id: int, message: str): + """Trigger a reminder notification and remove it from active reminders.""" + # Send notification + self._send_notification("⏰ Reminder", message) + + # Remove from active reminders + if reminder_id in self.reminders: + del self.reminders[reminder_id] + + def _cancel_reminder(self, reminder_id: Optional[int] = None) -> int: + """Cancel a specific reminder or all reminders. Returns number of cancelled reminders.""" + if reminder_id is not None: + if reminder_id in self.reminders: + self.reminders[reminder_id].cancel() + del self.reminders[reminder_id] + return 1 + return 0 + else: + # Cancel all reminders + count = len(self.reminders) + for reminder in self.reminders.values(): + reminder.cancel() + self.reminders.clear() + return count + + def _format_time_remaining(self, total_seconds: float) -> str: + """Format time remaining in a human-readable way.""" + total_seconds = int(total_seconds) + + if total_seconds < 60: + return f"{total_seconds}s" + elif total_seconds < 3600: + minutes = total_seconds // 60 + seconds = total_seconds % 60 + return f"{minutes}m {seconds}s" if seconds > 0 else f"{minutes}m" + else: + hours = total_seconds // 3600 + minutes = (total_seconds % 3600) // 60 + return f"{hours}h {minutes}m" if minutes > 0 else f"{hours}h" + + def _create_and_confirm_reminder(self, time_str: str, message: str): + """Actually create the reminder when the user presses Enter.""" + reminder = self._create_reminder(time_str, message) + if reminder: + time_remaining = reminder.get_time_remaining() + self._send_notification( + "✅ Reminder Created", f"Reminder set for {time_remaining}: {message}" + ) + else: + self._send_notification( + "❌ Failed to Create Reminder", "The specified time may be in the past" + ) + + def query(self, query_string: str) -> List[Result]: + """Process reminder queries.""" + results = [] + query = query_string.strip() + + if not query: + # Show help and active reminders count + active_count = len(self.reminders) + results.append( + Result( + title="Reminders Help", + subtitle=f"Active reminders: { + active_count + } | Usage: remind 5m Take a break", + icon_markup=icons.timer_on, + action=lambda: None, + relevance=1.0, + plugin_name=self.display_name, + data={"type": "help"}, + ) + ) + + # Show quick examples + examples = [ + ("remind 5m Take a break", "Set 5 minute reminder"), + ("remind 14:30 Meeting", "Set reminder for 2:30 PM"), + ("remind list", "List active reminders"), + ("remind cancel", "Cancel all reminders"), + ] + + for example, desc in examples: + results.append( + Result( + title=example, + subtitle=desc, + icon_markup=icons.timer_on, + action=lambda: None, + relevance=0.8, + plugin_name=self.display_name, + data={"type": "example"}, + ) + ) + + return results + + # Handle list command + if query.lower() in ["list", "ls", "show"]: + if not self.reminders: + results.append( + Result( + title="No Active Reminders", + subtitle="Use 'remind 5m message' to set a reminder", + icon_markup=icons.timer_off, + action=lambda: None, + relevance=1.0, + plugin_name=self.display_name, + data={"type": "empty_list"}, + ) + ) + else: + for reminder in sorted( + self.reminders.values(), key=lambda r: r.target_time + ): + time_remaining = reminder.get_time_remaining() + target_time = reminder.get_target_time_str() + + results.append( + Result( + title=f"#{reminder.id}: {reminder.message}", + subtitle=f"In {time_remaining} (at {target_time})", + icon_markup=icons.timer_on, + action=lambda rid=reminder.id: self._cancel_reminder(rid), + relevance=1.0, + plugin_name=self.display_name, + data={"type": "active_reminder", "id": reminder.id}, + ) + ) + + return results + + # Handle cancel command + if query.lower().startswith("cancel") or query.lower().startswith("stop"): + parts = query.split() + if len(parts) == 1: + # Cancel all reminders + count = self._cancel_reminder() + results.append( + Result( + title=f"Cancelled {count} Reminders", + subtitle="All active reminders have been cancelled", + icon_markup=icons.timer_off, + action=lambda: None, + relevance=1.0, + plugin_name=self.display_name, + data={"type": "cancel_all"}, + ) + ) + else: + # Try to cancel specific reminder by ID + try: + reminder_id = int(parts[1]) + count = self._cancel_reminder(reminder_id) + if count > 0: + results.append( + Result( + title=f"Cancelled Reminder #{reminder_id}", + subtitle="Reminder has been cancelled", + icon_markup=icons.timer_off, + action=lambda: None, + relevance=1.0, + plugin_name=self.display_name, + data={"type": "cancel_specific"}, + ) + ) + else: + results.append( + Result( + title="Reminder Not Found", + subtitle=f"No reminder with ID #{reminder_id}", + icon_markup=icons.alert, + action=lambda: None, + relevance=0.5, + plugin_name=self.display_name, + data={"type": "error"}, + ) + ) + except ValueError: + results.append( + Result( + title="Invalid Reminder ID", + subtitle="Please provide a valid reminder ID number", + icon_markup=icons.alert, + action=lambda: None, + relevance=0.5, + plugin_name=self.display_name, + data={"type": "error"}, + ) + ) + + return results + + # Handle setting new reminders + parts = query.split(None, 1) + if len(parts) >= 1: + time_str = parts[0] + message = parts[1] if len(parts) > 1 else "Reminder" + + # Try to parse the time (but don't create the reminder yet!) + target_time = self._parse_time_input(time_str) + if target_time: + # Calculate delay and check if it's valid + delay = (target_time - datetime.now()).total_seconds() + if delay > 0: + # Show what would happen, but don't create the reminder yet + time_remaining = self._format_time_remaining(delay) + target_time_str = target_time.strftime("%H:%M") + + results.append( + Result( + title=f"Set Reminder: {message}", + subtitle=f"Will remind in {time_remaining} (at { + target_time_str + })", + icon_markup=icons.timer_on, + action=lambda ts=time_str, + msg=message: self._create_and_confirm_reminder(ts, msg), + relevance=1.0, + plugin_name=self.display_name, + data={ + "type": "preview", + "time_str": time_str, + "message": message, + }, + ) + ) + else: + results.append( + Result( + title="Time is in the Past", + subtitle="Please specify a future time", + icon_markup=icons.alert, + action=lambda: None, + relevance=0.5, + plugin_name=self.display_name, + data={"type": "error"}, + ) + ) + else: + # Invalid time format + results.append( + Result( + title="Invalid Time Format", + subtitle="Use formats like: 5m, 30s, 2h, 14:30, or '5 minutes'", + icon_markup=icons.alert, + action=lambda: None, + relevance=0.5, + plugin_name=self.display_name, + data={"type": "error"}, + ) + ) + + return results diff --git a/modules/launcher/plugins/screencapture.py b/modules/launcher/plugins/screencapture.py new file mode 100644 index 0000000..3db965b --- /dev/null +++ b/modules/launcher/plugins/screencapture.py @@ -0,0 +1,685 @@ +import subprocess +from typing import List + +from fabric.utils import get_relative_path + +from modules.launcher.plugin_base import PluginBase +from modules.launcher.result import Result +from utils.icons import screenrecord, screenshots, ssfull, ssregion, stop + + +class ScreencapturePlugin(PluginBase): + """ + Plugin for taking screenshots and screen recordings using screen-capture.sh script. + """ + + def __init__(self): + super().__init__() + self.display_name = "Screencapture" + self.description = "Take screenshots and screen recordings" + # Path to the screen-capture script + self.script_path = get_relative_path("../../../scripts/screen-capture.sh") + + def initialize(self): + """Initialize the screencapture plugin.""" + self.set_triggers(["sc"]) + + def cleanup(self): + """Cleanup the screencapture plugin.""" + pass + + def get_commands(self): + """Return available commands for this plugin.""" + return { + # Screenshot commands + "screenshot": "Take a screenshot of the main display", + "ss": "Take a screenshot of the main display", + "screenshot-region": "Take a screenshot of selected region", + "ss-region": "Take a screenshot of selected region", + "screenshot-both": "Take a screenshot of both displays", + "ss-both": "Take a screenshot of both displays", + "screenshot-hdmi": "Take a screenshot of HDMI display", + "ss-hdmi": "Take a screenshot of HDMI display", + # Recording commands (with audio) + "record": "Start recording main display with audio", + "rec": "Start recording main display with audio", + "record-region": "Start recording selected region with audio", + "rec-region": "Start recording selected region with audio", + "record-hdmi": "Start recording HDMI display with audio", + "rec-hdmi": "Start recording HDMI display with audio", + # Recording commands (no audio) + "record-noaudio": "Start recording main display without audio", + "rec-noaudio": "Start recording main display without audio", + "record-noaudio-region": "Start recording selected region without audio", + "rec-noaudio-region": "Start recording selected region without audio", + "record-noaudio-hdmi": "Start recording HDMI display without audio", + "rec-noaudio-hdmi": "Start recording HDMI display without audio", + # High-quality recording commands + "record-hq": "Start high-quality recording of main display", + "rec-hq": "Start high-quality recording of main display", + "record-hq-region": "Start high-quality recording of selected region", + "rec-hq-region": "Start high-quality recording of selected region", + "record-hq-hdmi": "Start high-quality recording of HDMI display", + "rec-hq-hdmi": "Start high-quality recording of HDMI display", + # GIF recording commands + "record-gif": "Start GIF recording of main display", + "rec-gif": "Start GIF recording of main display", + "record-gif-region": "Start GIF recording of selected region", + "rec-gif-region": "Start GIF recording of selected region", + # Control commands + "stop": "Stop current recording", + # Conversion commands + "convert-webm": "Convert latest MKV recording to WebM format", + "conv-webm": "Convert latest MKV recording to WebM format", + "convert-iphone": "Convert latest MKV recording for iPhone compatibility", + "conv-iphone": "Convert latest MKV recording for iPhone compatibility", + "convert-youtube": "Convert latest recording for YouTube upload", + "conv-youtube": "Convert latest recording for YouTube upload", + "convert-gif": "Convert latest recording to GIF format", + "conv-gif": "Convert latest recording to GIF format", + # Conversion commands with file input + "convert-webm-file": "Convert specific MKV file to WebM format", + "conv-webm-file": "Convert specific MKV file to WebM format", + "convert-iphone-file": "Convert specific MKV file for iPhone compatibility", + "conv-iphone-file": "Convert specific MKV file for iPhone compatibility", + "convert-youtube-file": "Convert specific video file for YouTube upload", + "conv-youtube-file": "Convert specific video file for YouTube upload", + "convert-gif-file": "Convert specific video file to GIF format", + "conv-gif-file": "Convert specific video file to GIF format", + } + + def _run_script(self, *args): + """Execute the screen-capture script with given arguments.""" + try: + subprocess.Popen([self.script_path] + list(args)) + except Exception as e: + print(f"Error running screen-capture script: {e}") + + def _run_script_with_file(self, format_type: str, file_path: str): + """Execute the screen-capture script with file parameter.""" + try: + subprocess.Popen([self.script_path, "convert", format_type, file_path]) + except Exception as e: + print(f"Error running screen-capture script with file: {e}") + + def _is_recording(self): + """Check if recording is currently active.""" + try: + result = subprocess.run( + [self.script_path, "status"], capture_output=True, text=True + ) + return result.stdout.strip() == "true" + except Exception: + return False + + def _get_command_result(self, command: str) -> Result: + """Get a Result object for a specific command.""" + # Import here to avoid circular imports + + command_info = { + # Screenshot commands + "screenshot": ( + "Take Screenshot (eDP-1)", + "Capture the main display", + ssfull, + lambda: self._run_script("screenshot", "eDP-1"), + ), + "ss": ( + "Take Screenshot (eDP-1)", + "Capture the main display", + ssfull, + lambda: self._run_script("screenshot", "eDP-1"), + ), + "screenshot-region": ( + "Take Region Screenshot", + "Capture a selected region", + ssregion, + lambda: self._run_script("screenshot", "selection"), + ), + "ss-region": ( + "Take Region Screenshot", + "Capture a selected region", + ssregion, + lambda: self._run_script("screenshot", "selection"), + ), + "screenshot-both": ( + "Take Screenshot (Both Displays)", + "Capture both displays combined", + screenshots, + lambda: self._run_script("screenshot", "both"), + ), + "ss-both": ( + "Take Screenshot (Both Displays)", + "Capture both displays combined", + screenshots, + lambda: self._run_script("screenshot", "both"), + ), + "screenshot-hdmi": ( + "Take Screenshot (HDMI-A-1)", + "Capture HDMI display", + ssfull, + lambda: self._run_script("screenshot", "HDMI-A-1"), + ), + "ss-hdmi": ( + "Take Screenshot (HDMI-A-1)", + "Capture HDMI display", + ssfull, + lambda: self._run_script("screenshot", "HDMI-A-1"), + ), + # Recording commands (with audio) + "record": ( + "Start Recording (eDP-1)", + "Record the main display with audio", + screenrecord, + lambda: self._run_script("record", "eDP-1"), + ), + "rec": ( + "Start Recording (eDP-1)", + "Record the main display with audio", + screenrecord, + lambda: self._run_script("record", "eDP-1"), + ), + "record-region": ( + "Start Region Recording", + "Record a selected region with audio", + screenrecord, + lambda: self._run_script("record", "selection"), + ), + "rec-region": ( + "Start Region Recording", + "Record a selected region with audio", + screenrecord, + lambda: self._run_script("record", "selection"), + ), + "record-hdmi": ( + "Start Recording (HDMI-A-1)", + "Record HDMI display with audio", + screenrecord, + lambda: self._run_script("record", "HDMI-A-1"), + ), + "rec-hdmi": ( + "Start Recording (HDMI-A-1)", + "Record HDMI display with audio", + screenrecord, + lambda: self._run_script("record", "HDMI-A-1"), + ), + # Recording commands (no audio) + "record-noaudio": ( + "Start Recording No Audio (eDP-1)", + "Record the main display without audio", + screenrecord, + lambda: self._run_script("record-noaudio", "eDP-1"), + ), + "rec-noaudio": ( + "Start Recording No Audio (eDP-1)", + "Record the main display without audio", + screenrecord, + lambda: self._run_script("record-noaudio", "eDP-1"), + ), + "record-noaudio-region": ( + "Start Region Recording No Audio", + "Record a selected region without audio", + screenrecord, + lambda: self._run_script("record-noaudio", "selection"), + ), + "rec-noaudio-region": ( + "Start Region Recording No Audio", + "Record a selected region without audio", + screenrecord, + lambda: self._run_script("record-noaudio", "selection"), + ), + "record-noaudio-hdmi": ( + "Start Recording No Audio (HDMI-A-1)", + "Record HDMI display without audio", + screenrecord, + lambda: self._run_script("record-noaudio", "HDMI-A-1"), + ), + "rec-noaudio-hdmi": ( + "Start Recording No Audio (HDMI-A-1)", + "Record HDMI display without audio", + screenrecord, + lambda: self._run_script("record-noaudio", "HDMI-A-1"), + ), + # High-quality recording commands + "record-hq": ( + "Start HQ Recording (eDP-1)", + "High-quality recording for YouTube", + screenrecord, + lambda: self._run_script("record-hq", "eDP-1"), + ), + "rec-hq": ( + "Start HQ Recording (eDP-1)", + "High-quality recording for YouTube", + screenrecord, + lambda: self._run_script("record-hq", "eDP-1"), + ), + "record-hq-region": ( + "Start HQ Region Recording", + "High-quality region recording", + screenrecord, + lambda: self._run_script("record-hq", "selection"), + ), + "rec-hq-region": ( + "Start HQ Region Recording", + "High-quality region recording", + screenrecord, + lambda: self._run_script("record-hq", "selection"), + ), + "record-hq-hdmi": ( + "Start HQ Recording (HDMI-A-1)", + "High-quality HDMI recording", + screenrecord, + lambda: self._run_script("record-hq", "HDMI-A-1"), + ), + "rec-hq-hdmi": ( + "Start HQ Recording (HDMI-A-1)", + "High-quality HDMI recording", + screenrecord, + lambda: self._run_script("record-hq", "HDMI-A-1"), + ), + # GIF recording commands + "record-gif": ( + "Start GIF Recording (eDP-1)", + "Record as optimized GIF", + screenrecord, + lambda: self._run_script("record-gif", "eDP-1"), + ), + "rec-gif": ( + "Start GIF Recording (eDP-1)", + "Record as optimized GIF", + screenrecord, + lambda: self._run_script("record-gif", "eDP-1"), + ), + "record-gif-region": ( + "Start GIF Region Recording", + "Record selected region as GIF", + screenrecord, + lambda: self._run_script("record-gif", "selection"), + ), + "rec-gif-region": ( + "Start GIF Region Recording", + "Record selected region as GIF", + screenrecord, + lambda: self._run_script("record-gif", "selection"), + ), + # Control commands + "stop": ( + "Stop Recording", + "Stop the current screen recording", + stop, + lambda: self._run_script("record", "stop"), + ), + # Conversion commands + "convert-webm": ( + "Convert Latest to WebM", + "Convert latest MKV recording to WebM format", + screenrecord, + lambda: self._run_script("convert", "webm"), + ), + "conv-webm": ( + "Convert Latest to WebM", + "Convert latest MKV recording to WebM format", + screenrecord, + lambda: self._run_script("convert", "webm"), + ), + "convert-iphone": ( + "Convert Latest for iPhone", + "Convert latest MKV recording for iPhone compatibility", + screenrecord, + lambda: self._run_script("convert", "iphone"), + ), + "conv-iphone": ( + "Convert Latest for iPhone", + "Convert latest MKV recording for iPhone compatibility", + screenrecord, + lambda: self._run_script("convert", "iphone"), + ), + "convert-youtube": ( + "Convert Latest for YouTube", + "Convert latest recording for YouTube upload", + screenrecord, + lambda: self._run_script("convert", "youtube"), + ), + "conv-youtube": ( + "Convert Latest for YouTube", + "Convert latest recording for YouTube upload", + screenrecord, + lambda: self._run_script("convert", "youtube"), + ), + "convert-gif": ( + "Convert Latest to GIF", + "Convert latest recording to GIF format", + screenrecord, + lambda: self._run_script("convert", "gif"), + ), + "conv-gif": ( + "Convert Latest to GIF", + "Convert latest recording to GIF format", + screenrecord, + lambda: self._run_script("convert", "gif"), + ), + # File-based conversion commands (these will be handled specially) + "convert-webm-file": ( + "Convert File to WebM", + "Type filename after command (e.g., convert-webm-file video.mkv)", + screenrecord, + None, # Will be handled in query method + ), + "conv-webm-file": ( + "Convert File to WebM", + "Type filename after command (e.g., conv-webm-file video.mkv)", + screenrecord, + None, # Will be handled in query method + ), + "convert-iphone-file": ( + "Convert File for iPhone", + "Type filename after command (e.g., convert-iphone-file video.mkv)", + screenrecord, + None, # Will be handled in query method + ), + "conv-iphone-file": ( + "Convert File for iPhone", + "Type filename after command (e.g., conv-iphone-file video.mkv)", + screenrecord, + None, # Will be handled in query method + ), + "convert-youtube-file": ( + "Convert File for YouTube", + "Type filename after command (e.g., convert-youtube-file video.mkv)", + screenrecord, + None, # Will be handled in query method + ), + "conv-youtube-file": ( + "Convert File for YouTube", + "Type filename after command (e.g., conv-youtube-file video.mkv)", + screenrecord, + None, # Will be handled in query method + ), + "convert-gif-file": ( + "Convert File to GIF", + "Type filename after command (e.g., convert-gif-file video.mkv)", + screenrecord, + None, # Will be handled in query method + ), + "conv-gif-file": ( + "Convert File to GIF", + "Type filename after command (e.g., conv-gif-file video.mkv)", + screenrecord, + None, # Will be handled in query method + ), + } + + if command in command_info: + title, subtitle, icon, action = command_info[command] + if action is not None: # Regular command + return Result( + title=title, + subtitle=subtitle, + icon_markup=icon, + action=action, + relevance=1.0, + plugin_name=self.display_name, + ) + else: # File-based command, show instruction + return Result( + title=title, + subtitle=subtitle, + icon_markup=icon, + action=lambda: None, # No action for instruction + relevance=1.0, + plugin_name=self.display_name, + ) + + return None + + def query(self, query_string: str) -> List[Result]: + """Search for screencapture actions based on query.""" + # Import here to avoid circular imports + + # Clean the query string + query = query_string.strip().lower() + + results = [] + + # Check for file-based conversion commands with parameters + file_conversion_commands = { + "convert-webm-file": "webm", + "conv-webm-file": "webm", + "convert-iphone-file": "iphone", + "conv-iphone-file": "iphone", + "convert-youtube-file": "youtube", + "conv-youtube-file": "youtube", + "convert-gif-file": "gif", + "conv-gif-file": "gif", + } + + # Parse query for file-based commands + query_parts = query.split() + if len(query_parts) >= 2: + command = query_parts[0] + file_param = " ".join(query_parts[1:]) + + if command in file_conversion_commands: + format_type = file_conversion_commands[command] + return [ + Result( + title=f"Convert {file_param} to {format_type.upper()}", + subtitle=f"Convert specified file to {format_type} format", + icon_markup=screenrecord, + action=lambda fp=file_param, + ft=format_type: self._run_script_with_file(ft, fp), + relevance=1.0, + plugin_name=self.display_name, + ) + ] + + # Check if query matches a command and return it as a result + command_result = self._get_command_result(query) + if command_result: + return [command_result] + + # Check recording status + is_recording = self._is_recording() + + # If recording is active, show stop button first with highest relevance + if is_recording: + results.append( + Result( + title="Stop Recording", + subtitle="Stop the current screen recording", + icon_markup=stop, + action=lambda: self._run_script("record", "stop"), + relevance=2.0, # Highest relevance to appear at top + plugin_name=self.display_name, + ) + ) + + # Screenshot actions + results.extend( + [ + Result( + title="Take Screenshot", + subtitle="Capture the entire screen (eDP-1)", + icon_markup=ssfull, + action=lambda: self._run_script("screenshot", "eDP-1"), + relevance=1.0, + plugin_name=self.display_name, + ), + Result( + title="Take Region Screenshot", + subtitle="Capture a selected region", + icon_markup=ssregion, + action=lambda: self._run_script("screenshot", "selection"), + relevance=0.9, + plugin_name=self.display_name, + ), + Result( + title="Take Screenshot (Both Displays)", + subtitle="Capture both displays combined", + icon_markup=screenshots, + action=lambda: self._run_script("screenshot", "both"), + relevance=0.8, + plugin_name=self.display_name, + ), + Result( + title="Take Screenshot (HDMI-A-1)", + subtitle="Capture HDMI display", + icon_markup=ssfull, + action=lambda: self._run_script("screenshot", "HDMI-A-1"), + relevance=0.7, + plugin_name=self.display_name, + ), + ] + ) + + # Standard recording actions + results.extend( + [ + Result( + title="Start Recording (eDP-1)", + subtitle="Record the main display with audio", + icon_markup=screenrecord, + action=lambda: self._run_script("record", "eDP-1"), + relevance=0.7, + plugin_name=self.display_name, + ), + Result( + title="Start Region Recording", + subtitle="Record a selected region", + icon_markup=screenrecord, + action=lambda: self._run_script("record", "selection"), + relevance=0.6, + plugin_name=self.display_name, + ), + Result( + title="Start Recording (HDMI-A-1)", + subtitle="Record HDMI display with audio", + icon_markup=screenrecord, + action=lambda: self._run_script("record", "HDMI-A-1"), + relevance=0.5, + plugin_name=self.display_name, + ), + ] + ) + + # No-audio recording actions + results.extend( + [ + Result( + title="Start Recording No Audio (eDP-1)", + subtitle="Record the main display without audio", + icon_markup=screenrecord, + action=lambda: self._run_script("record-noaudio", "eDP-1"), + relevance=0.65, + plugin_name=self.display_name, + ), + Result( + title="Start Region Recording No Audio", + subtitle="Record a selected region without audio", + icon_markup=screenrecord, + action=lambda: self._run_script("record-noaudio", "selection"), + relevance=0.55, + plugin_name=self.display_name, + ), + Result( + title="Start Recording No Audio (HDMI-A-1)", + subtitle="Record HDMI display without audio", + icon_markup=screenrecord, + action=lambda: self._run_script("record-noaudio", "HDMI-A-1"), + relevance=0.45, + plugin_name=self.display_name, + ), + ] + ) + + # High-quality recording actions + results.extend( + [ + Result( + title="Start HQ Recording (eDP-1)", + subtitle="High-quality recording for YouTube", + icon_markup=screenrecord, + action=lambda: self._run_script("record-hq", "eDP-1"), + relevance=0.4, + plugin_name=self.display_name, + ), + Result( + title="Start HQ Region Recording", + subtitle="High-quality region recording", + icon_markup=screenrecord, + action=lambda: self._run_script("record-hq", "selection"), + relevance=0.3, + plugin_name=self.display_name, + ), + Result( + title="Start HQ Recording (HDMI-A-1)", + subtitle="High-quality HDMI recording", + icon_markup=screenrecord, + action=lambda: self._run_script("record-hq", "HDMI-A-1"), + relevance=0.2, + plugin_name=self.display_name, + ), + ] + ) + + # GIF recording actions + results.extend( + [ + Result( + title="Start GIF Recording (eDP-1)", + subtitle="Record as optimized GIF", + icon_markup=screenrecord, + action=lambda: self._run_script("record-gif", "eDP-1"), + relevance=0.1, + plugin_name=self.display_name, + ), + Result( + title="Start GIF Region Recording", + subtitle="Record selected region as GIF", + icon_markup=screenrecord, + action=lambda: self._run_script("record-gif", "selection"), + relevance=0.05, + plugin_name=self.display_name, + ), + ] + ) + + # Conversion actions + results.extend( + [ + Result( + title="Convert Latest to WebM", + subtitle="Convert latest MKV recording to WebM format", + icon_markup=screenrecord, + action=lambda: self._run_script("convert", "webm"), + relevance=0.01, + plugin_name=self.display_name, + ), + Result( + title="Convert Latest for iPhone", + subtitle="Convert latest MKV recording for iPhone compatibility", + icon_markup=screenrecord, + action=lambda: self._run_script("convert", "iphone"), + relevance=0.01, + plugin_name=self.display_name, + ), + Result( + title="Convert Latest for YouTube", + subtitle="Convert latest recording for YouTube upload", + icon_markup=screenrecord, + action=lambda: self._run_script("convert", "youtube"), + relevance=0.01, + plugin_name=self.display_name, + ), + Result( + title="Convert Latest to GIF", + subtitle="Convert latest recording to GIF format", + icon_markup=screenrecord, + action=lambda: self._run_script("convert", "gif"), + relevance=0.01, + plugin_name=self.display_name, + ), + ] + ) + + return results diff --git a/modules/launcher/plugins/system.py b/modules/launcher/plugins/system.py new file mode 100644 index 0000000..65142f1 --- /dev/null +++ b/modules/launcher/plugins/system.py @@ -0,0 +1,234 @@ +import json +import os +import shlex +import threading +import time +from typing import List, Set, Union + +from fabric.utils import exec_shell_command_async + +import config.data as data +import utils.icons as icons +from modules.launcher.plugin_base import PluginBase +from modules.launcher.result import Result + + +class SystemPlugin(PluginBase): + """ + Plugin for system commands and actions. + """ + + def __init__(self): + super().__init__() + self.display_name = "System" + self.description = "System commands and actions" + + # JSON cache file for system binaries + self.bin_cache_file = os.path.join(data.CACHE_DIR, "system_binaries.json") + + # In-memory cache for system binaries + self._bin_cache: Set[str] = set() + self._last_bin_update = 0 + self._bin_update_interval = 300 # 5 minutes + + # Background cache building + self._cache_building = False + self._cache_thread = None + + def initialize(self): + """Initialize the system plugin.""" + self.set_triggers(["bin"]) + self._load_bin_cache() + self._start_background_cache_update() + + def cleanup(self): + """Cleanup the system plugin.""" + self._bin_cache.clear() + if self._cache_thread and self._cache_thread.is_alive(): + # Note: We don't join the thread to avoid blocking cleanup + pass + + def _load_bin_cache(self): + """Load binary cache from JSON file.""" + try: + if os.path.exists(self.bin_cache_file): + with open(self.bin_cache_file, "r", encoding="utf-8") as f: + cache_data = json.load(f) + self._bin_cache = set(cache_data.get("binaries", [])) + self._last_bin_update = cache_data.get("last_update", 0) + else: + print("SystemPlugin: No cache file found, will build cache in background") + except Exception as e: + print(f"SystemPlugin: Error loading binary cache: {e}") + self._bin_cache = set() + self._last_bin_update = 0 + + def _save_bin_cache(self): + """Save binary cache to JSON file.""" + try: + # Ensure the cache directory exists + os.makedirs(data.CACHE_DIR, exist_ok=True) + + cache_data = { + "binaries": sorted(list(self._bin_cache)), + "last_update": self._last_bin_update, + "cache_version": "1.0" + } + + with open(self.bin_cache_file, "w", encoding="utf-8") as f: + json.dump(cache_data, f, indent=2) + except Exception as e: + print(f"SystemPlugin: Error saving binary cache: {e}") + + def _start_background_cache_update(self): + """Start background thread to update binary cache.""" + current_time = time.time() + + # Check if cache needs updating + if (current_time - self._last_bin_update > self._bin_update_interval or + not self._bin_cache): + + if not self._cache_building: + self._cache_building = True + self._cache_thread = threading.Thread( + target=self._build_bin_cache_background, + daemon=True + ) + self._cache_thread.start() + + def _build_bin_cache_background(self): + """Build binary cache in background thread.""" + try: + new_cache = set() + processed_paths = set() # Avoid duplicate paths + + for path in os.environ["PATH"].split(":"): + # Skip empty paths and duplicates + if not path or path in processed_paths: + continue + processed_paths.add(path) + + if os.path.exists(path) and os.path.isdir(path): + try: + # Use os.scandir for better performance than os.listdir + with os.scandir(path) as entries: + for entry in entries: + if (entry.is_file(follow_symlinks=False) and + os.access(entry.path, os.X_OK)): + new_cache.add(entry.name) + except (PermissionError, FileNotFoundError, OSError): + continue + + # Update cache atomically + self._bin_cache = new_cache + self._last_bin_update = time.time() + + # Save to disk + self._save_bin_cache() + + + except Exception as e: + print(f"SystemPlugin: Error building binary cache: {e}") + finally: + self._cache_building = False + + def query(self, query_string: str) -> List[Result]: + """Search for system commands matching the query.""" + query = query_string.strip() + + if not query: + return [] + + results = [] + + # Parse the query to extract binary name and arguments + query_parts = query.split() + if not query_parts: + return [] + + binary_query = query_parts[0].lower() + full_command = query # Keep the original case and spacing + + # Check system binaries + # Start background update if needed (non-blocking) - but only if cache is empty or very old + if not self._bin_cache or (time.time() - self._last_bin_update > self._bin_update_interval): + self._start_background_cache_update() + + # Optimize search with early termination and result limiting + exact_matches = [] + prefix_matches = [] + partial_matches = [] + max_results = 20 # Limit total results for performance + + for binary in self._bin_cache: + # Pre-compute lowercase once + binary_lower = binary.lower() + + # Skip if no match at all + if binary_query not in binary_lower: + continue + + # Categorize matches for better sorting + if binary_lower == binary_query: + # Exact match - highest priority + display_command = full_command + command_to_execute = full_command + relevance = 1.0 + exact_matches.append((binary, display_command, command_to_execute, relevance)) + elif binary_lower.startswith(binary_query): + # Prefix match - high priority + display_command = binary + command_to_execute = binary + relevance = 0.9 + prefix_matches.append((binary, display_command, command_to_execute, relevance)) + else: + # Partial match - lower priority + display_command = binary + command_to_execute = binary + relevance = 0.7 + partial_matches.append((binary, display_command, command_to_execute, relevance)) + + # Early termination if we have enough good matches + if len(exact_matches) + len(prefix_matches) >= max_results: + break + + # Combine results in priority order + all_matches = exact_matches + prefix_matches + partial_matches + + # Convert to Result objects (limit to max_results) + for binary, display_command, command_to_execute, relevance in all_matches[:max_results]: + result = Result( + title=display_command, + subtitle=f"Execute: {display_command}", + icon_markup=icons.terminal, + action=self._create_action(command_to_execute), + relevance=relevance, + plugin_name=self.display_name, + data={"command": command_to_execute, "id": binary}, + ) + results.append(result) + + return results # Already sorted by priority + + + + def _create_action(self, command: Union[str, List[str]]): + """Create an action function for the given command.""" + + def action(): + self._execute_command(command) + + return action + + def _execute_command(self, command: Union[str, List[str]]): + """Execute a system command.""" + try: + if isinstance(command, str): + # Handle string commands with arguments - split into list for proper execution + command_list = shlex.split(command) + exec_shell_command_async(command_list) + else: + # Handle list commands (backward compatibility) + exec_shell_command_async(command) + except Exception as e: + print(f"SystemPlugin: Error executing command '{command}': {e}") diff --git a/modules/launcher/plugins/tmux.py b/modules/launcher/plugins/tmux.py new file mode 100644 index 0000000..23e1267 --- /dev/null +++ b/modules/launcher/plugins/tmux.py @@ -0,0 +1,292 @@ +import subprocess +import threading +import time +from typing import List + +from fabric.utils import exec_shell_command_async + +import config.data as data +import utils.icons as icons +from modules.launcher.plugin_base import PluginBase +from modules.launcher.result import Result + + +class TmuxPlugin(PluginBase): + """ + Plugin for managing tmux sessions through the launcher. + """ + + def __init__(self): + super().__init__() + self.display_name = "Tmux Manager" + self.description = "Manage tmux sessions - create, attach, rename, and kill" + + # Cache for sessions to avoid repeated subprocess calls + self._sessions_cache = [] + self._last_cache_update = 0 + self._cache_ttl = 2 # Cache sessions for 2 seconds + + # Threading for auto-refresh + self.refresh_thread = None + self.stop_refresh = threading.Event() + + def initialize(self): + """Initialize the tmux plugin.""" + self.set_triggers(["tmux"]) + self._start_refresh_thread() + + def cleanup(self): + """Cleanup the tmux plugin.""" + self.stop_refresh.set() + if self.refresh_thread: + self.refresh_thread.join(timeout=1) + self._sessions_cache.clear() + + def _start_refresh_thread(self): + """Start background thread to refresh session cache.""" + if not self.refresh_thread or not self.refresh_thread.is_alive(): + self.refresh_thread = threading.Thread( + target=self._refresh_sessions_background, + daemon=True + ) + self.refresh_thread.start() + + def _refresh_sessions_background(self): + """Background thread to refresh sessions cache.""" + while not self.stop_refresh.is_set(): + try: + current_time = time.time() + if current_time - self._last_cache_update > self._cache_ttl: + self._sessions_cache = self._get_tmux_sessions() + self._last_cache_update = current_time + + # Sleep for a short interval + self.stop_refresh.wait(1) + except Exception as e: + print(f"TmuxPlugin: Error in refresh thread: {e}") + self.stop_refresh.wait(5) # Wait longer on error + + def _get_tmux_sessions(self): + """Get list of tmux sessions.""" + try: + result = subprocess.run( + ["tmux", "list-sessions", "-F", "#{session_name}"], + capture_output=True, + text=True, + timeout=5 + ) + if result.returncode == 0: + return [s.strip() for s in result.stdout.strip().split('\n') if s.strip()] + return [] + except (subprocess.TimeoutExpired, subprocess.CalledProcessError, FileNotFoundError) as e: + print(f"TmuxPlugin: Error getting tmux sessions: {e}") + return [] + + def query(self, query_string: str) -> List[Result]: + """Process tmux queries.""" + query = query_string.strip().lower() + results = [] + + # Get current sessions (use cache if recent) + current_time = time.time() + if current_time - self._last_cache_update > self._cache_ttl: + self._sessions_cache = self._get_tmux_sessions() + self._last_cache_update = current_time + + sessions = self._sessions_cache + + # Handle specific commands + if query.startswith("new ") or query.startswith("create "): + session_name = query.split(" ", 1)[1].strip() if " " in query else "" + results.append(self._create_new_session_result(session_name)) + + elif query.startswith("kill ") or query.startswith("delete "): + session_name = query.split(" ", 1)[1].strip() if " " in query else "" + if session_name: + matching_sessions = [s for s in sessions if session_name.lower() in s.lower()] + for session in matching_sessions: + results.append(self._create_kill_session_result(session)) + + elif query.startswith("rename "): + parts = query.split(" ", 2) + if len(parts) >= 3: + old_name, new_name = parts[1], parts[2] + if old_name in sessions: + results.append(self._create_rename_session_result(old_name, new_name)) + + else: + # Show existing sessions for attachment + if sessions: + # Filter sessions based on query + if query: + filtered_sessions = [s for s in sessions if query in s.lower()] + else: + filtered_sessions = sessions + + for session in filtered_sessions: + results.append(self._create_attach_session_result(session)) + + # Always show option to create new session + if not query or "new" in query or "create" in query: + results.append(self._create_new_session_result(query if query and not any(cmd in query for cmd in ["new", "create"]) else "")) + + return results + + def _create_attach_session_result(self, session_name: str) -> Result: + """Create result for attaching to a session.""" + return Result( + title=f"Attach to '{session_name}'", + subtitle=f"Connect to tmux session: {session_name}", + icon_markup=icons.terminal, + action=lambda: self._attach_to_session(session_name), + relevance=0.9, + data={"type": "attach", "session": session_name} + ) + + def _create_new_session_result(self, session_name: str = "") -> Result: + """Create result for creating a new session.""" + display_name = session_name if session_name else "new session" + return Result( + title=f"Create '{display_name}'", + subtitle=f"Create new tmux session{f': {session_name}' if session_name else ''}", + icon_markup=icons.plus, + action=lambda: self._create_session(session_name), + relevance=0.8, + data={"type": "create", "session": session_name} + ) + + def _create_kill_session_result(self, session_name: str) -> Result: + """Create result for killing a session.""" + return Result( + title=f"Kill '{session_name}'", + subtitle=f"Terminate tmux session: {session_name}", + icon_markup=icons.trash, + action=lambda: self._kill_session(session_name), + relevance=0.7, + data={"type": "kill", "session": session_name} + ) + + def _create_rename_session_result(self, old_name: str, new_name: str) -> Result: + """Create result for renaming a session.""" + return Result( + title=f"Rename '{old_name}' to '{new_name}'", + subtitle=f"Rename tmux session from {old_name} to {new_name}", + icon_markup=icons.config, + action=lambda: self._rename_session(old_name, new_name), + relevance=0.6, + data={"type": "rename", "old_session": old_name, "new_session": new_name} + ) + + + def _attach_to_session(self, session_name: str): + """Attach to an existing tmux session.""" + try: + terminal_cmd = self._get_terminal_command(f"tmux attach-session -t '{session_name}'") + exec_shell_command_async(terminal_cmd) + print(f"TmuxPlugin: Attaching to session '{session_name}'") + except Exception as e: + print(f"TmuxPlugin: Error attaching to session '{session_name}': {e}") + + def _create_session(self, session_name: str = ""): + """Create a new tmux session.""" + try: + if not session_name: + # Generate a default name + existing_sessions = self._get_tmux_sessions() + counter = 0 + while str(counter) in existing_sessions: + counter += 1 + session_name = str(counter) + + # Clean the session name + clean_name = session_name.strip().replace(" ", "_") + + # Create session + subprocess.run( + ["tmux", "new-session", "-d", "-s", clean_name], + check=True, + timeout=10 + ) + + # Launch terminal and attach + terminal_cmd = self._get_terminal_command(f"tmux attach-session -t '{clean_name}'") + exec_shell_command_async(terminal_cmd) + + # Refresh cache + self._sessions_cache = self._get_tmux_sessions() + self._last_cache_update = time.time() + + print(f"TmuxPlugin: Created and attached to session '{clean_name}'") + except Exception as e: + print(f"TmuxPlugin: Error creating session '{session_name}': {e}") + + def _kill_session(self, session_name: str): + """Kill a tmux session.""" + try: + subprocess.run( + ["tmux", "kill-session", "-t", session_name], + check=True, + timeout=10 + ) + + # Refresh cache + self._sessions_cache = self._get_tmux_sessions() + self._last_cache_update = time.time() + + print(f"TmuxPlugin: Killed session '{session_name}'") + except Exception as e: + print(f"TmuxPlugin: Error killing session '{session_name}': {e}") + + def _rename_session(self, old_name: str, new_name: str): + """Rename a tmux session.""" + try: + clean_name = new_name.strip().replace(" ", "_") + subprocess.run( + ["tmux", "rename-session", "-t", old_name, clean_name], + check=True, + timeout=10 + ) + + # Refresh cache + self._sessions_cache = self._get_tmux_sessions() + self._last_cache_update = time.time() + + print(f"TmuxPlugin: Renamed session '{old_name}' to '{clean_name}'") + except Exception as e: + print(f"TmuxPlugin: Error renaming session '{old_name}' to '{new_name}': {e}") + + def _get_terminal_command(self, cmd: str) -> str: + """Get terminal command based on configured terminal or available terminals.""" + # First try to use the configured terminal command + if hasattr(data, 'TERMINAL_COMMAND') and data.TERMINAL_COMMAND: + parts = data.TERMINAL_COMMAND.split() + terminal = parts[0] + + try: + # Check if the configured terminal is available + subprocess.run(["which", terminal], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + return f"{data.TERMINAL_COMMAND} {cmd}" + except subprocess.CalledProcessError: + # If configured terminal is not available, fall back to defaults + pass + + # Fallback to checking available terminals + terminals = [ + ("kitty", f"kitty -e {cmd}"), + ("alacritty", f"alacritty -e {cmd}"), + ("foot", f"foot {cmd}"), + ("gnome-terminal", f"gnome-terminal -- {cmd}"), + ("konsole", f"konsole -e {cmd}"), + ("xfce4-terminal", f"xfce4-terminal -e '{cmd}'"), + ] + + for term, term_cmd in terminals: + try: + # Check if terminal is available + subprocess.run(["which", term], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + return term_cmd + except subprocess.CalledProcessError: + continue + + # Default fallback + return f"kitty -e {cmd}" diff --git a/modules/launcher/plugins/wallpaper.py b/modules/launcher/plugins/wallpaper.py new file mode 100644 index 0000000..e349a80 --- /dev/null +++ b/modules/launcher/plugins/wallpaper.py @@ -0,0 +1,996 @@ +import colorsys +import hashlib +import json +import os +import random +import re +import threading +import time +from typing import Dict, List, Optional + +from fabric.utils.helpers import exec_shell_command_async +from gi.repository import GdkPixbuf +from PIL import Image + +import config.data as data +import utils.icons as icons +from modules.launcher.plugin_base import PluginBase +from modules.launcher.result import Result + + +class WallpaperPlugin(PluginBase): + """ + Plugin for wallpaper management with search, random selection, and matugen integration. + """ + + def __init__(self): + super().__init__() + self.display_name = "Wallpaper" + self.wallpapers = [] + self.cache_dir = f"{data.CACHE_DIR}/thumbs" + self.thumbnail_cache: Dict[str, Optional[GdkPixbuf.Pixbuf]] = {} + self.thumbnail_loading = set() # Track which thumbnails are being loaded + self.schemes = { + "scheme-tonal-spot": "Tonal Spot", + "scheme-content": "Content", + "scheme-expressive": "Expressive", + "scheme-fidelity": "Fidelity", + "scheme-fruit-salad": "Fruit Salad", + "scheme-monochrome": "Monochrome", + "scheme-neutral": "Neutral", + "scheme-rainbow": "Rainbow", + } + + def initialize(self): + """Initialize the wallpaper plugin.""" + self.set_triggers(["wall"]) + self._load_wallpapers() + os.makedirs(self.cache_dir, exist_ok=True) + # Start background thumbnail creation + self._start_background_thumbnail_creation() + + def cleanup(self): + """Cleanup the wallpaper plugin.""" + pass + + def _load_wallpapers(self): + """Load available wallpapers from the wallpapers directory.""" + try: + if os.path.exists(data.WALLPAPERS_DIR): + self.wallpapers = sorted( + [f for f in os.listdir(data.WALLPAPERS_DIR) if self._is_image(f)] + ) + else: + print(f"Wallpapers directory not found: {data.WALLPAPERS_DIR}") + except Exception as e: + print(f"Error loading wallpapers: {e}") + + def _is_image(self, filename: str) -> bool: + """Check if file is a supported image format.""" + return filename.lower().endswith( + (".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp") + ) + + def _get_matugen_state(self) -> bool: + """Get current matugen state from config.json.""" + self.matugen_enabled = True # Default to True + try: + with open(data.CONFIG_FILE, "r") as f: + config = json.load(f) + self.matugen_enabled = config.get("matugen_enabled", True) + except FileNotFoundError: + # File doesn't exist, keep default True + pass + except Exception as e: + print(f"Error reading config file: {e}") + # Keep default True on error + + return self.matugen_enabled + + def _set_matugen_state(self, enabled: bool): + """Set matugen state and save to config.json.""" + self.matugen_enabled = enabled + + # Save the state to config.json + try: + # Read current config + config = {} + if os.path.exists(data.CONFIG_FILE): + with open(data.CONFIG_FILE, "r") as f: + config = json.load(f) + + # Update matugen state + config["matugen_enabled"] = enabled + + # Write back to config file + with open(data.CONFIG_FILE, "w") as f: + json.dump(config, f, indent=4) + + except Exception as e: + print(f"Error writing matugen state to config: {e}") + + # Clear the search query after toggling matugen + self._clear_launcher_query() + + # # Send notification + # status = "enabled" if enabled else "disabled" + # exec_shell_command_async( + # f"notify-send '🎨 Matugen' 'Dynamic colors {status}' -a '{ + # data.APP_NAME_CAP + # }' -e" + # ) + + def _clear_launcher_query(self): + """Clear the launcher search query and reset to trigger.""" + try: + # Try to access the launcher through the fabric Application + from fabric import Application + from gi.repository import GLib + + app = Application.get_default() + + if app and hasattr(app, "launcher"): + launcher = app.launcher + if launcher and hasattr(launcher, "search_entry"): + def clear_and_refresh(): + # Clear the search entry to just the trigger + launcher.search_entry.set_text("wall ") + # Position cursor at the end + launcher.search_entry.set_position(-1) + # Trigger the search to show default wallpaper view + if hasattr(launcher, "_perform_search"): + launcher._perform_search("wall ") + return False + + # Use a small delay to ensure the action completes first + GLib.timeout_add(50, clear_and_refresh) + return + + # Fallback: try to find launcher instance through other means + import gc + + for obj in gc.get_objects(): + if ( + hasattr(obj, "__class__") + and obj.__class__.__name__ == "Launcher" + ): + if hasattr(obj, "search_entry") and hasattr(obj, "_perform_search"): + def clear_and_refresh(): + obj.search_entry.set_text("wall ") + obj.search_entry.set_position(-1) + obj._perform_search("wall ") + return False + + GLib.timeout_add(50, clear_and_refresh) + return + + except Exception as e: + print(f"Could not clear launcher query: {e}") + + def _get_cache_path(self, filename: str) -> str: + """Get cache path for wallpaper thumbnail.""" + file_hash = hashlib.md5(filename.encode("utf-8")).hexdigest() + return os.path.join(self.cache_dir, f"{file_hash}.png") + + def _create_thumbnail(self, filename: str) -> str: + """Create thumbnail for wallpaper if it doesn't exist.""" + full_path = os.path.join(data.WALLPAPERS_DIR, filename) + cache_path = self._get_cache_path(filename) + + if not os.path.exists(cache_path): + try: + with Image.open(full_path) as img: + # Use faster thumbnail creation with smaller size for better performance + img.thumbnail((32, 32), Image.Resampling.LANCZOS) + img.save(cache_path, "PNG", optimize=True) + except Exception as e: + print(f"Error creating thumbnail for {filename}: {e}") + return None + + return cache_path + + def _start_background_thumbnail_creation(self): + """Start background thread to create thumbnails for all wallpapers.""" + + def create_thumbnails(): + for wallpaper in self.wallpapers: + if wallpaper not in self.thumbnail_loading: + self.thumbnail_loading.add(wallpaper) + try: + cache_path = self._create_thumbnail(wallpaper) + if cache_path and os.path.exists(cache_path): + # Load thumbnail into memory cache + try: + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( + cache_path, 32, 32, True + ) + self.thumbnail_cache[wallpaper] = pixbuf + except Exception as e: + print(f"Error loading thumbnail for {wallpaper}: {e}") + self.thumbnail_cache[wallpaper] = None + else: + self.thumbnail_cache[wallpaper] = None + except Exception as e: + print(f"Error processing thumbnail for {wallpaper}: {e}") + self.thumbnail_cache[wallpaper] = None + finally: + self.thumbnail_loading.discard(wallpaper) + + # Small delay to prevent overwhelming the system + time.sleep(0.01) + + # Start background thread + thread = threading.Thread(target=create_thumbnails, daemon=True) + thread.start() + + def _get_thumbnail_fast(self, filename: str) -> Optional[GdkPixbuf.Pixbuf]: + """Get thumbnail quickly from cache or return None if not ready.""" + # Return cached thumbnail if available + if filename in self.thumbnail_cache: + return self.thumbnail_cache[filename] + + # Check if thumbnail file exists and load it immediately + cache_path = self._get_cache_path(filename) + if os.path.exists(cache_path): + try: + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( + cache_path, 32, 32, True + ) + self.thumbnail_cache[filename] = pixbuf + return pixbuf + except Exception as e: + print(f"Error loading thumbnail for {filename}: {e}") + self.thumbnail_cache[filename] = None + return None + + # If not in cache and file doesn't exist, trigger background creation + if filename not in self.thumbnail_loading: + self.thumbnail_loading.add(filename) + + def create_async(): + try: + cache_path = self._create_thumbnail(filename) + if cache_path and os.path.exists(cache_path): + try: + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( + cache_path, 32, 32, True + ) + self.thumbnail_cache[filename] = pixbuf + except Exception as e: + print(f"Error loading thumbnail for {filename}: {e}") + self.thumbnail_cache[filename] = None + else: + self.thumbnail_cache[filename] = None + except Exception as e: + print(f"Error creating thumbnail for {filename}: {e}") + self.thumbnail_cache[filename] = None + finally: + self.thumbnail_loading.discard(filename) + + thread = threading.Thread(target=create_async, daemon=True) + thread.start() + + return None + + def _set_wallpaper(self, filename: str, scheme: str = None): + """Set wallpaper with optional matugen integration.""" + full_path = os.path.join(data.WALLPAPERS_DIR, filename) + current_wall = os.path.expanduser("~/.current.wall") + + if scheme is None: + scheme = self._get_current_scheme() + + # Update current wallpaper symlink + if os.path.isfile(current_wall) or os.path.islink(current_wall): + os.remove(current_wall) + os.symlink(full_path, current_wall) + + # Apply wallpaper with or without matugen + matugen_enabled = self._get_matugen_state() + if matugen_enabled: + exec_shell_command_async(f'matugen image "{full_path}" -t {scheme}') + else: + exec_shell_command_async( + f'swww img "{ + full_path + }" -t outer --transition-duration 1.5 --transition-step 255 --transition-fps 60 -f Nearest' + ) + + def _set_random_wallpaper(self): + """Set a random wallpaper.""" + if not self.wallpapers: + return + + filename = random.choice(self.wallpapers) + self._set_wallpaper(filename) + # Show notification for immediate feedback + exec_shell_command_async( + f"notify-send '🎲 Random Wallpaper' 'Applied: {filename}' -a '{ + data.APP_NAME_CAP + }' -e" + ) + return filename + + def _hsl_to_rgb_hex(self, h: float, s: float = 1.0, l: float = 0.5) -> str: + """Convert HSL color value to RGB HEX string.""" + # colorsys uses HLS, not HSL, and expects values between 0.0 and 1.0 + hue = h / 360.0 + r, g, b = colorsys.hls_to_rgb(hue, l, s) # Note the order: H, L, S + r_int, g_int, b_int = int(r * 255), int(g * 255), int(b * 255) + return f"#{r_int:02X}{g_int:02X}{b_int:02X}" + + def _is_valid_hex_color(self, hex_color: str) -> bool: + """Check if string is a valid hex color.""" + if not hex_color.startswith("#"): + hex_color = "#" + hex_color + return bool(re.match(r"^#[0-9A-Fa-f]{6}$", hex_color)) + + def _get_current_scheme(self) -> str: + """Get current color scheme from config (default to tonal-spot).""" + try: + with open(data.CONFIG_FILE, "r") as f: + config = json.load(f) + return config.get("current_scheme", "scheme-tonal-spot") + except FileNotFoundError: + # File doesn't exist, return default + return "scheme-tonal-spot" + except Exception as e: + print(f"Error reading current scheme from config: {e}") + return "scheme-tonal-spot" + + def _set_current_scheme(self, scheme: str): + """Set current color scheme and apply it.""" + scheme_name = self.schemes.get(scheme, scheme) + matugen_enabled = self._get_matugen_state() + + # Save the scheme to config + try: + # Read current config + config = {} + if os.path.exists(data.CONFIG_FILE): + with open(data.CONFIG_FILE, "r") as f: + config = json.load(f) + + # Update current scheme + config["current_scheme"] = scheme + + # Write back to config file + with open(data.CONFIG_FILE, "w") as f: + json.dump(config, f, indent=4) + + except Exception as e: + print(f"Error saving current scheme to config: {e}") + return + + # Apply the scheme to current wallpaper if matugen is enabled + if matugen_enabled: + current_wall = os.path.expanduser("~/.current.wall") + if os.path.exists(current_wall) and os.path.islink(current_wall): + # Get the current wallpaper path + wallpaper_path = os.readlink(current_wall) + if os.path.exists(wallpaper_path): + # Apply the new scheme to current wallpaper + exec_shell_command_async( + f'matugen image "{wallpaper_path}" -t {scheme}' + ) + + # Send notification + exec_shell_command_async( + f"notify-send '🎨 Color Scheme' 'Applied { + scheme_name + } scheme' -a '{data.APP_NAME_CAP}' -e" + ) + else: + # No current wallpaper, just show scheme change notification + exec_shell_command_async( + f"notify-send '🎨 Color Scheme' 'Set to { + scheme_name + } (will apply to next wallpaper)' -a '{data.APP_NAME_CAP}' -e" + ) + else: + # No current wallpaper, just show scheme change notification + exec_shell_command_async( + f"notify-send '🎨 Color Scheme' 'Set to { + scheme_name + } (will apply to next wallpaper)' -a '{data.APP_NAME_CAP}' -e" + ) + else: + # Matugen is disabled, just save the setting + exec_shell_command_async( + f"notify-send '🎨 Color Scheme' 'Set to { + scheme_name + } (matugen disabled)' -a '{data.APP_NAME_CAP}' -e" + ) + + def _apply_hex_color(self, hex_color: str, scheme: str = None): + """Apply hex color using matugen. Assumes matugen is enabled.""" + if not hex_color.startswith("#"): + hex_color = "#" + hex_color + + if scheme is None: + scheme = self._get_current_scheme() + + exec_shell_command_async(f'matugen color hex "{hex_color}" -t {scheme}') + + def _apply_hex_color_direct(self, hex_color: str, scheme: str = None): + """Apply hex color using matugen (following example_wallpapers.py pattern).""" + if not hex_color.startswith("#"): + hex_color = "#" + hex_color + + if scheme is None: + scheme = self._get_current_scheme() + + exec_shell_command_async(f'matugen color hex "{hex_color}" -t {scheme}') + + # Send notification + exec_shell_command_async( + f"notify-send '🎨 Hex Color Applied' 'Applied color: {hex_color}' -a '{ + data.APP_NAME_CAP + }' -e" + ) + + def _generate_random_hex_color(self) -> str: + """Generate a random hex color.""" + hue = random.randint(0, 360) + return self._hsl_to_rgb_hex(hue) + + def _get_status_indicators(self) -> tuple: + """Get current status indicators for display.""" + current_scheme = self._get_current_scheme() + matugen_enabled = self._get_matugen_state() + scheme_name = self.schemes.get(current_scheme, current_scheme) + + indicators = [] + if not matugen_enabled: + indicators.append("Matugen Off") + + indicator_text = " • " + " • ".join(indicators) if indicators else "" + status_text = f"Matugen: { + 'Enabled' if matugen_enabled else 'Disabled' + } • Scheme: {scheme_name}" + + return indicator_text, status_text, current_scheme, matugen_enabled + + def query(self, query_string: str) -> List[Result]: + """Search wallpapers and provide management options.""" + results = [] + query = query_string.lower().strip() + + # Get status indicators for consistent display + indicator_text, status_text, current_scheme, matugen_enabled = ( + self._get_status_indicators() + ) + + # Special commands + if query.strip() == "random": + # Show result for random wallpaper (execute on Enter) + results.append( + Result( + title=f"Random Wallpaper{indicator_text}", + subtitle=f"Set a random wallpaper • {status_text}", + icon_markup=random.choice( + [ + icons.dice_1, + icons.dice_2, + icons.dice_3, + icons.dice_4, + icons.dice_5, + icons.dice_6, + ] + ), + action=lambda: self._set_random_wallpaper(), + relevance=1.0, + plugin_name=self.display_name, + data={ + "action": "random", + "bypass_max_results": True, + "keep_launcher_open": True, + }, + ) + ) + elif query.startswith("random") and query.strip() != "random": + # Show suggestion for random wallpaper (partial match) + results.append( + Result( + title=f"Random Wallpaper{indicator_text}", + subtitle=f"Set a random wallpaper • {status_text}", + icon_markup=random.choice( + [ + icons.dice_1, + icons.dice_2, + icons.dice_3, + icons.dice_4, + icons.dice_5, + icons.dice_6, + ] + ), + action=lambda: self._set_random_wallpaper(), + relevance=0.9, + plugin_name=self.display_name, + data={ + "action": "random_suggestion", + "bypass_max_results": True, + "keep_launcher_open": True, + }, + ) + ) + + # Hex color commands + if "color" in query or "hex" in query or query.startswith("#"): + # Check for scheme specification in the query + scheme = self._get_current_scheme() + for scheme_id, scheme_name in self.schemes.items(): + if ( + scheme_name.lower() in query.lower() + or scheme_id.lower() in query.lower() + ): + scheme = scheme_id + break + + # Check for hex color patterns + hex_match = re.search(r"#?([0-9A-Fa-f]{6})", query) + if hex_match: + hex_color = "#" + hex_match.group(1) + scheme_name = self.schemes.get(scheme, scheme) + + # Check if this is a complete hex color input (6 digits) + # Execute immediately when we have a complete 6-digit hex color + if len(hex_match.group(1)) == 6: + # Check if there's additional text after the hex color + hex_end_pos = hex_match.end() + remaining_text = query[hex_end_pos:].strip() + + # Only show result if no additional text after hex color (exact match) + if not remaining_text: + # Hex colors work when matugen is OFF (following example_wallpapers.py pattern) + if not matugen_enabled: + # Show result for hex color (execute on Enter) + results.append( + Result( + title=f"Apply Hex Color: {hex_color}{ + indicator_text + }", + subtitle=f"Apply with {scheme_name} scheme • { + status_text + }", + icon_markup=icons.palette, + action=lambda c=hex_color, + s=scheme: self._apply_hex_color_direct(c, s), + relevance=1.0, + plugin_name=self.display_name, + data={ + "action": "hex_color", + "color": hex_color, + "scheme": scheme, + "bypass_max_results": True, + "keep_launcher_open": True, + }, + ) + ) + else: + # Show error result when matugen is enabled + results.append( + Result( + title=f"Cannot Apply Hex Color: {hex_color}{ + indicator_text + }", + subtitle="Matugen is enabled • Disable matugen to use hex colors", + icon_markup=icons.palette, + action=lambda: None, + relevance=1.0, + plugin_name=self.display_name, + data={ + "action": "hex_color_failed", + "color": hex_color, + "bypass_max_results": True, + }, + ) + ) + else: + # Show suggestion for hex color with additional text (partial match) + results.append( + Result( + title=f"Apply Hex Color: {hex_color}{indicator_text}", + subtitle=f"Apply with {scheme_name} scheme • { + status_text + }", + icon_markup=icons.palette, + action=lambda c=hex_color, + s=scheme: self._apply_hex_color_direct(c, s) + if not matugen_enabled + else None, + relevance=0.9, + plugin_name=self.display_name, + data={ + "action": "hex_color_suggestion", + "color": hex_color, + "scheme": scheme, + "bypass_max_results": True, + "keep_launcher_open": True, + }, + ) + ) + else: + # Incomplete hex color, show as suggestion + results.append( + Result( + title=f"Hex Color (incomplete): {hex_color}", + subtitle="Complete the 6-digit hex color to apply", + icon_markup=icons.palette, + action=lambda: None, + relevance=0.7, + plugin_name=self.display_name, + data={ + "action": "hex_color_incomplete", + "color": hex_color, + "bypass_max_results": True, + }, + ) + ) + elif "random" in query and ("color" in query or "hex" in query): + # Check for exact matches for random color commands + if ( + query.strip() == "color random" + or query.strip() == "hex random" + or query.strip() == "random color" + or query.strip() == "random hex" + ): + # Random hex color - show result (execute on Enter) + scheme_name = self.schemes.get(scheme, scheme) + + # Check if matugen is disabled (hex colors work when matugen is OFF) + if not matugen_enabled: + results.append( + Result( + title=f"Random Hex Color{indicator_text}", + subtitle=f"Generate and apply random color with { + scheme_name + } scheme • {status_text}", + icon_markup=icons.palette, + action=lambda s=scheme: self._apply_hex_color_direct( + self._generate_random_hex_color(), s + ), + relevance=1.0, + plugin_name=self.display_name, + data={ + "action": "random_hex", + "scheme": scheme, + "bypass_max_results": True, + "keep_launcher_open": True, + }, + ) + ) + else: + # Show error result when matugen is enabled + results.append( + Result( + title=f"Cannot Apply Random Color{indicator_text}", + subtitle="Matugen is enabled • Disable matugen to use hex colors", + icon_markup=icons.palette, + action=lambda: None, + relevance=1.0, + plugin_name=self.display_name, + data={ + "action": "random_hex_failed", + "bypass_max_results": True, + }, + ) + ) + else: + # Show suggestion for random hex color (partial match) + results.append( + Result( + title=f"Random Hex Color{indicator_text}", + subtitle=f"Generate and apply random color • {status_text}", + icon_markup=icons.palette, + action=lambda s=scheme: self._apply_hex_color_direct( + self._generate_random_hex_color(), s + ) + if not matugen_enabled + else None, + relevance=0.8, + plugin_name=self.display_name, + data={ + "action": "random_hex_suggestion", + "scheme": scheme, + "bypass_max_results": True, + "keep_launcher_open": True, + }, + ) + ) + else: + # Show hex color help + results.append( + Result( + title="Hex Color Commands", + subtitle="Use: color #FF5733, hex #00FF00, color random", + icon_markup=icons.palette, + action=lambda: None, + relevance=0.8, + plugin_name=self.display_name, + data={"action": "hex_help", "bypass_max_results": True}, + ) + ) + + # Color scheme commands + if "scheme" in query: + current_scheme = self._get_current_scheme() + matugen_enabled = self._get_matugen_state() + + # Show all available schemes + for scheme_id, scheme_name in self.schemes.items(): + # Check if this scheme matches the query (for filtering) + if query.strip() == "scheme": + # Show all schemes when just "scheme" is typed + scheme_matches = True + else: + # Extract search terms from query (remove "scheme" keyword) + search_terms = query.replace("scheme", "").strip().split() + scheme_matches = False + + # Check if any search term matches the scheme name or ID + for term in search_terms: + if ( + term.lower() in scheme_name.lower() + or term.lower() in scheme_id.lower() + ): + scheme_matches = True + break + + if scheme_matches: + # Create indicators + indicators = [] + if scheme_id == current_scheme: + indicators.append("Current") + if not matugen_enabled: + indicators.append("Matugen Off") + + indicator_text = ( + " • " + " • ".join(indicators) if indicators else "" + ) + + results.append( + Result( + title=f"{scheme_name}{indicator_text}", + subtitle=f"Set color scheme to {scheme_name}" + + ( + " (requires matugen enabled)" + if not matugen_enabled + else "" + ), + icon_markup=icons.palette, + action=lambda s=scheme_id: self._set_current_scheme(s), + relevance=1.0 if scheme_id == current_scheme else 0.8, + plugin_name=self.display_name, + data={ + "action": "scheme_select", + "scheme": scheme_id, + "bypass_max_results": True, + "keep_launcher_open": True, + }, + ) + ) + + # Matugen controls + if "matugen" in query: + current_state = self._get_matugen_state() + + # Check for exact command matches + if query.strip() == "matugen on" or query.strip() == "matugen enable": + # Show result for enabling matugen (execute on Enter) + results.append( + Result( + title=f"Enable Matugen{indicator_text}", + subtitle=f"Turn on dynamic color generation • {status_text}", + icon_markup=icons.palette, + action=lambda: self._set_matugen_state(True), + relevance=0.9, + plugin_name=self.display_name, + data={ + "action": "matugen_on", + "bypass_max_results": True, + "keep_launcher_open": True, + }, + ) + ) + elif query.strip() == "matugen off" or query.strip() == "matugen disable": + # Show result for disabling matugen (execute on Enter) + results.append( + Result( + title=f"Disable Matugen{indicator_text}", + subtitle=f"Turn off dynamic color generation • {status_text}", + icon_markup=icons.palette, + action=lambda: self._set_matugen_state(False), + relevance=0.9, + plugin_name=self.display_name, + data={ + "action": "matugen_off", + "bypass_max_results": True, + "keep_launcher_open": True, + }, + ) + ) + elif query.strip() == "matugen toggle": + # Show result for toggling matugen (execute on Enter) + new_state = not current_state + results.append( + Result( + title=f"Toggle Matugen to { + 'Enabled' if new_state else 'Disabled' + }{indicator_text}", + subtitle=f"Switch matugen to { + 'enabled' if new_state else 'disabled' + } • {status_text}", + icon_markup=icons.palette, + action=lambda: self._set_matugen_state(new_state), + relevance=0.9, + plugin_name=self.display_name, + data={ + "action": "matugen_toggle", + "bypass_max_results": True, + "keep_launcher_open": True, + }, + ) + ) + elif ("on" in query or "enable" in query) and not query.strip().endswith( + ("on", "enable") + ): + # Show suggestion for enabling (partial match) + results.append( + Result( + title=f"Enable Matugen{indicator_text}", + subtitle=f"Turn on dynamic color generation • {status_text}", + icon_markup=icons.palette, + action=lambda: self._set_matugen_state(True), + relevance=0.8, + plugin_name=self.display_name, + data={ + "action": "matugen_on_suggestion", + "bypass_max_results": True, + "keep_launcher_open": True, + }, + ) + ) + elif ("off" in query or "disable" in query) and not query.strip().endswith( + ("off", "disable") + ): + # Show suggestion for disabling (partial match) + results.append( + Result( + title=f"Disable Matugen{indicator_text}", + subtitle=f"Turn off dynamic color generation • {status_text}", + icon_markup=icons.palette, + action=lambda: self._set_matugen_state(False), + relevance=0.8, + plugin_name=self.display_name, + data={ + "action": "matugen_off_suggestion", + "bypass_max_results": True, + "keep_launcher_open": True, + }, + ) + ) + elif "toggle" in query and not query.strip().endswith("toggle"): + # Show suggestion for toggling (partial match) + results.append( + Result( + title=f"Toggle Matugen{indicator_text}", + subtitle=f"Switch matugen state • {status_text}", + icon_markup=icons.palette, + action=lambda: self._set_matugen_state(not current_state), + relevance=0.8, + plugin_name=self.display_name, + data={ + "action": "matugen_toggle_suggestion", + "bypass_max_results": True, + "keep_launcher_open": True, + }, + ) + ) + else: + # Show current state with scheme info + results.append( + Result( + title=f"Matugen: {'Enabled' if current_state else 'Disabled'}{ + indicator_text + }", + subtitle=f"Dynamic colors • {status_text}", + icon_markup=icons.palette, + action=lambda: None, + relevance=0.8, + plugin_name=self.display_name, + data={"action": "matugen_status", "bypass_max_results": True}, + ) + ) + + # Status command + if query == "status" or query == "info": + results.append( + Result( + title=f"Wallpaper System Status{indicator_text}", + subtitle=status_text, + icon_markup=icons.palette, + action=lambda: None, + relevance=1.0, + plugin_name=self.display_name, + data={"action": "status", "bypass_max_results": True}, + ) + ) + + # Show quick actions + results.append( + Result( + title=f"Random Wallpaper{indicator_text}", + subtitle=f"Set a random wallpaper • {status_text}", + icon_markup=random.choice( + [ + icons.dice_1, + icons.dice_2, + icons.dice_3, + icons.dice_4, + icons.dice_5, + icons.dice_6, + ] + ), + action=lambda: self._set_random_wallpaper(), + relevance=0.9, + plugin_name=self.display_name, + data={ + "action": "random_quick", + "bypass_max_results": True, + "keep_launcher_open": True, + }, + ) + ) + + # Search wallpapers by filename - Show ALL wallpapers like example_wallpapers.py + if not query or ( + query + and "matugen" not in query + and "random" not in query + and "scheme" not in query + and "status" not in query + and "info" not in query + and "color" not in query + and "hex" not in query + ): + matching_wallpapers = [] + for wallpaper in self.wallpapers: + if not query or query in wallpaper.lower(): + # Calculate relevance + relevance = 1.0 if query == wallpaper.lower() else 0.7 + if query and query in wallpaper.lower(): + relevance = 0.8 + matching_wallpapers.append((wallpaper, relevance)) + + # Sort by relevance and show ALL wallpapers (like example_wallpapers.py) + matching_wallpapers.sort(key=lambda x: x[1], reverse=True) + + # Show ALL wallpapers instead of limiting (following example_wallpapers.py pattern) + for wallpaper, relevance in matching_wallpapers: + # Use fast thumbnail loading + icon = self._get_thumbnail_fast(wallpaper) + + results.append( + Result( + title=f"{wallpaper}{indicator_text if not query else ''}", + subtitle=f"Set as wallpaper{ + ' • ' + status_text if not query else '' + }", + icon=icon, + icon_markup=icons.wallpapers if not icon else None, + action=lambda w=wallpaper: self._set_wallpaper(w), + relevance=relevance, + plugin_name=self.display_name, + data={ + "wallpaper": wallpaper, + "action": "set", + "bypass_max_results": True, + "keep_launcher_open": True, + }, + ) + ) + + # Sort by relevance - no limit since we use bypass_max_results + results.sort(key=lambda x: x.relevance, reverse=True) + return results diff --git a/modules/launcher/plugins/websearch.py b/modules/launcher/plugins/websearch.py new file mode 100644 index 0000000..575492a --- /dev/null +++ b/modules/launcher/plugins/websearch.py @@ -0,0 +1,260 @@ +import subprocess +import urllib.parse +from typing import List + +import utils.icons as icons +from modules.launcher.plugin_base import PluginBase +from modules.launcher.result import Result + + +class WebSearchPlugin(PluginBase): + """ + Web search plugin that supports multiple search engines. + """ + + def __init__(self): + super().__init__() + self.display_name = "Web Search" + self.description = "Search the web using various search engines" + self.current_trigger = "" # Track the current active trigger + + # Define search engines with their URLs and icons + self.search_engines = { + "google": { + "name": "Google", + "url": "https://www.google.com/search?q={}", + "icon": icons.google, + "description": "Search with Google", + }, + "duckduckgo": { + "name": "DuckDuckGo", + "url": "https://duckduckgo.com/?q={}", + "icon": icons.duckduckgo, + "description": "Search with DuckDuckGo (privacy-focused)", + }, + "youtube": { + "name": "YouTube", + "url": "https://www.youtube.com/results?search_query={}", + "icon": icons.youtube, + "description": "Search videos on YouTube", + }, + "github": { + "name": "GitHub", + "url": "https://github.com/search?q={}", + "icon": icons.github, + "description": "Search repositories on GitHub", + }, + "stackoverflow": { + "name": "Stack Overflow", + "url": "https://stackoverflow.com/search?q={}", + "icon": icons.stackoverflow, + "description": "Search programming questions on Stack Overflow", + }, + "wikipedia": { + "name": "Wikipedia", + "url": "https://en.wikipedia.org/wiki/Special:Search?search={}", + "icon": icons.wikipedia, + "description": "Search articles on Wikipedia", + }, + "reddit": { + "name": "Reddit", + "url": "https://www.reddit.com/search/?q={}", + "icon": icons.reddit, + "description": "Search discussions on Reddit", + }, + "linkedin": { + "name": "LinkedIn", + "url": "https://www.linkedin.com/search/results/all/?keywords={}", + "icon": icons.linkedin, + "description": "Search professionals and jobs on LinkedIn", + }, + } + + # Default search engine + self.default_engine = "google" + + def initialize(self): + """Initialize the web search plugin.""" + # Set up triggers for web search - only main triggers, not individual search engines + triggers = ["?"] + + # Don't add individual search engine triggers to avoid cluttering trigger keywords + # Search engines can still be used within web search context (e.g., "web google cats") + + self.set_triggers(triggers) + + def get_active_trigger(self, query_string: str) -> str: + """Override to track which trigger was activated.""" + trigger = super().get_active_trigger(query_string) + if trigger: + self.current_trigger = trigger.strip() + return trigger + + def cleanup(self): + """Cleanup the web search plugin.""" + pass + + def query(self, query_string: str) -> List[Result]: + """Process web search queries.""" + results = [] + + if not query_string.strip(): + # Show available search engines when no query + return self._get_search_engine_list() + + # Check if the query is a URL + if self._is_url(query_string): + results.append(self._create_url_result(query_string)) + return results + + # Parse query to check if it starts with a search engine name + engine_name, search_query = self._parse_engine_query(query_string) + + if engine_name and engine_name in self.search_engines: + # Specific search engine specified in query (e.g., "google cats") + if search_query: + # Search with specific engine + results.append(self._create_search_result(engine_name, search_query)) + else: + # Show specific engine info + results.append(self._create_engine_info_result(engine_name)) + else: + # General search - offer multiple engines + search_query = query_string.strip() + + # Add default search engine first + results.append( + self._create_search_result(self.default_engine, search_query) + ) + + # Add other popular search engines + popular_engines = ["duckduckgo", "youtube", "github"] + for engine in popular_engines: + if engine != self.default_engine: + results.append(self._create_search_result(engine, search_query)) + + return results + + def _parse_engine_query(self, query: str) -> tuple[str, str]: + """Parse query to extract search engine and search terms.""" + query = query.strip().lower() + + for engine in self.search_engines.keys(): + if query.startswith(f"{engine} "): + return engine, query[len(engine) :].strip() + elif query == engine: + return engine, "" + + return "", query + + def _is_url(self, text: str) -> bool: + """Check if the text is a URL.""" + text = text.strip().lower() + return text.startswith(("http://", "https://", "www.")) or ( + "." in text and " " not in text and len(text) > 3 + ) + + def _create_url_result(self, url: str) -> Result: + """Create a result for opening a URL directly.""" + # Add protocol if missing + if not url.startswith(("http://", "https://")): + if url.startswith("www."): + url = "https://" + url + else: + url = "https://" + url + + return Result( + title=f"Open {url}", + subtitle="Open this URL in your default browser", + icon_markup=icons.world, + action=lambda u=url: self._open_url(u), + relevance=1.0, + plugin_name=self.display_name, + data={"type": "url", "url": url}, + ) + + def _open_url(self, url: str): + """Open a URL directly in the default browser.""" + try: + subprocess.Popen( + ["xdg-open", url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL + ) + except Exception as e: + print(f"Failed to open URL: {e}") + + def _get_search_engine_list(self) -> List[Result]: + """Get list of available search engines.""" + results = [] + + for engine_id, engine_info in self.search_engines.items(): + result = Result( + title=engine_info["name"], + subtitle=engine_info["description"], + icon_markup=engine_info["icon"], + action=lambda e=engine_id: self._show_engine_help(e), + relevance=1.0 if engine_id == self.default_engine else 0.8, + plugin_name=self.display_name, + data={"type": "engine_info", "engine": engine_id}, + ) + results.append(result) + + return results + + def _create_search_result(self, engine_id: str, query: str) -> Result: + """Create a search result for a specific engine and query.""" + engine_info = self.search_engines[engine_id] + + return Result( + title=f"Search '{query}' on {engine_info['name']}", + subtitle=f"{engine_info['description']} - {query}", + icon_markup=engine_info["icon"], + action=lambda e=engine_id, q=query: self._perform_search(e, q), + relevance=1.0 if engine_id == self.default_engine else 0.9, + plugin_name=self.display_name, + data={"type": "search", "engine": engine_id, "query": query}, + ) + + def _create_engine_info_result(self, engine_id: str) -> Result: + """Create an info result for a specific search engine.""" + engine_info = self.search_engines[engine_id] + + return Result( + title=f"{engine_info['name']} Search", + subtitle=f"{engine_info['description']} - Type your search query", + icon_markup=engine_info["icon"], + action=lambda: None, # No action for info result + relevance=1.0, + plugin_name=self.display_name, + data={"type": "engine_ready", "engine": engine_id}, + ) + + def _perform_search(self, engine_id: str, query: str): + """Perform a web search using the specified engine.""" + if not query.strip(): + return + + engine_info = self.search_engines.get(engine_id) + if not engine_info: + return + + # URL encode the search query + encoded_query = urllib.parse.quote_plus(query) + search_url = engine_info["url"].format(encoded_query) + + try: + # Open the search URL in the default browser + subprocess.Popen( + ["xdg-open", search_url], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except Exception as e: + print(f"Failed to open search URL: {e}") + + def _show_engine_help(self, engine_id: str): + """Show help for a specific search engine.""" + engine_info = self.search_engines.get(engine_id) + if engine_info: + print( + f"Search engine: {engine_info['name']} - {engine_info['description']}" + ) diff --git a/modules/launcher/result.py b/modules/launcher/result.py new file mode 100644 index 0000000..fce3550 --- /dev/null +++ b/modules/launcher/result.py @@ -0,0 +1,52 @@ +from dataclasses import dataclass +from typing import Any, Callable, Optional + +from gi.repository import GdkPixbuf, Gtk + + +@dataclass +class Result: + """ + Represents a search result that can be displayed and activated. + """ + + # Display information + title: str + subtitle: str = "" + subtitle_markup: Optional[str] = None # Pango markup for subtitle + description: str = "" + icon: Optional[GdkPixbuf.Pixbuf] = None + icon_name: Optional[str] = None + icon_markup: Optional[str] = None + # Behavior + action: Optional[Callable[[], Any]] = None + relevance: float = 0.0 + + # Custom widget support + custom_widget: Optional[Gtk.Widget] = None + + # Metadata + plugin_name: str = "" + data: Optional[dict] = None + + def activate(self): + """Activate this result (execute its action).""" + if self.action: + return self.action() + else: + raise NotImplementedError("No action defined for this result") + + def __post_init__(self): + """Post-initialization processing.""" + # Ensure relevance is within valid range + self.relevance = max(0.0, min(1.0, self.relevance)) + + # Set default data if None + if self.data is None: + self.data = {} + + def __str__(self): + return f"Result(title='{self.title}', relevance={self.relevance})" + + def __repr__(self): + return self.__str__() diff --git a/modules/launcher/result_item.py b/modules/launcher/result_item.py new file mode 100644 index 0000000..27dcf82 --- /dev/null +++ b/modules/launcher/result_item.py @@ -0,0 +1,161 @@ +import gi +from fabric.core.service import Signal +from fabric.widgets.box import Box +from fabric.widgets.eventbox import EventBox +from fabric.widgets.image import Image +from fabric.widgets.label import Label +from modules.launcher.result import Result + +gi.require_version("Gtk", "3.0") + + +class ResultItem(EventBox): + """ + Widget for displaying a single search result. + """ + + # Signals + @Signal + def clicked(self, index: int) -> None: + """Emitted when result is clicked.""" + pass + + @Signal + def hovered(self, index: int) -> None: + """Emitted when result is hovered.""" + pass + + def __init__(self, result: Result, selected: bool = False, index: int = 0, **kwargs): + super().__init__(name="launcher-result-item", **kwargs) + + self.result = result + self._selected = selected + self.index = index + + # Setup UI + self._setup_ui() + + # Connect signals + self.connect("button-press-event", self._on_button_press) + self.connect("enter-notify-event", self._on_enter) + self.connect("leave-notify-event", self._on_leave) + + # Set initial selection state + self.set_selected(selected) + + def _setup_ui(self): + """Setup the result item UI.""" + # Main container + main_box = Box( + name="result-item-main", + orientation="h", + spacing=12, + h_align="fill", + v_align="center", + ) + self.add(main_box) + + # Icon + if self.result.icon: + icon_widget = Image(pixbuf=self.result.icon, name="result-item-icon") + elif self.result.icon_name: + icon_widget = Image( + icon_name=self.result.icon_name, icon_size=32, name="result-item-icon" + ) + elif self.result.icon_markup: + icon_widget = Label( + markup=self.result.icon_markup, name="launcher-icon-label" + ) + else: + # Default icon + icon_widget = Image( + icon_name="application-x-executable", + icon_size=32, + name="result-item-icon", + ) + + main_box.add(icon_widget) + + # Text container + text_box = Box( + name="result-item-text", + orientation="v", + spacing=2, + h_expand=True, + v_align="center", + ) + main_box.add(text_box) + + # Title + title_label = Label( + label=self.result.title, + name="result-item-title", + h_align="start", + v_align="center", + ellipsize="end", + ) + text_box.add(title_label) + + # Subtitle (if present) + if self.result.subtitle or self.result.subtitle_markup: + if self.result.subtitle_markup: + # Use markup for subtitle (supports Pango markup) + subtitle_label = Label( + markup=self.result.subtitle_markup, + name="result-item-subtitle", + h_align="start", + v_align="center", + ellipsize="end", + ) + else: + # Use plain text for subtitle + subtitle_label = Label( + label=self.result.subtitle, + name="result-item-subtitle", + h_align="start", + v_align="center", + ellipsize="end", + ) + text_box.add(subtitle_label) + + # Plugin name (small text) + if self.result.plugin_name: + plugin_label = Label( + label=f"via {self.result.plugin_name}", + name="result-item-plugin", + h_align="start", + v_align="center", + ellipsize="end", + ) + text_box.add(plugin_label) + + def set_selected(self, selected: bool): + """Set the selection state of this result item.""" + self._selected = selected + + if selected: + self.add_style_class("selected") + else: + self.remove_style_class("selected") + + def get_selected(self) -> bool: + """Get the selection state of this result item.""" + return self._selected + + def _on_button_press(self, widget, event): + """Handle button press events.""" + if event.button == 1: # Left click + self.clicked.emit(self.index) + return True + return False + + def _on_enter(self, widget, event): + """Handle mouse enter events.""" + # Emit hover signal to update selection + self.hovered.emit(self.index) + return False + + def _on_leave(self, widget, event): + """Handle mouse leave events.""" + # Could be used for hover effects cleanup + return False diff --git a/modules/launcher/trigger_config.py b/modules/launcher/trigger_config.py new file mode 100644 index 0000000..cb67687 --- /dev/null +++ b/modules/launcher/trigger_config.py @@ -0,0 +1,59 @@ +import json +import os +from typing import Any, Dict, List + +from fabric.utils import get_relative_path + +import utils.icons as icons + + +class TriggerConfig: + def __init__(self, config_path: str = None): + if config_path is None: + config_path = get_relative_path("../../config/assets/launcher.json") + + self.config_path = config_path + + # Load configuration from JSON file + config = {"launcher_config": {}, "settings": {}} + if os.path.exists(config_path): + try: + with open(config_path, "r", encoding="utf-8") as f: + config = json.load(f) + except Exception as e: + print(f"Error loading trigger config: {e}") + + self.config = config + self.launcher_config = self.config.get("launcher_config", {}) + + # Initialize settings with defaults + default_settings = { + "max_examples_shown": 2, + "default_icon": "apps", + "fallback_example_template": "{trigger} <search>", + "config_version": "1.0", + } + self.settings = {**default_settings, **self.config.get("settings", {})} + + def get_trigger_examples(self, trigger: str) -> List[str]: + examples = self.launcher_config.get(trigger, {}).get("examples", []) + if not examples: + template = self.settings.get( + "fallback_example_template", "{trigger} <search>" + ) + examples = [template.format(trigger=trigger)] + return examples + + def get_trigger_icon(self, trigger: str) -> str: + icon_name = self.launcher_config.get(trigger, {}).get( + "icon", self.settings.get("default_icon", "apps") + ) + return getattr(icons, icon_name, icons.apps) + + def get_trigger_description(self, trigger: str) -> str: + return self.launcher_config.get(trigger, {}).get( + "description", f"{trigger} - No description available" + ) + + def get_all_triggers(self) -> Dict[str, Dict[str, Any]]: + return self.launcher_config.copy() diff --git a/modules/notification_popup.py b/modules/notification_popup.py index 82dcb5d..ab4e1bc 100644 --- a/modules/notification_popup.py +++ b/modules/notification_popup.py @@ -1,21 +1,36 @@ -import os -from gi.repository import GdkPixbuf, GLib, Gdk -from fabric.notifications import ( - Notification, - NotificationAction, - NotificationCloseReason, -) -from loguru import logger +import uuid + +from fabric.notifications.service import Notification, NotificationAction, Notifications from fabric.widgets.box import Box from fabric.widgets.button import Button -from fabric.widgets.image import Image from fabric.widgets.centerbox import CenterBox +from fabric.widgets.image import Image from fabric.widgets.label import Label from fabric.widgets.revealer import Revealer -from fabric.widgets.wayland import WaylandWindow as Window -from utils import CustomImage +from gi.repository import GLib, Gtk +from loguru import logger + +import config.data as data import utils.icons as icons -from services import notification_service +from utils.custom_image import CustomImage +from utils.wayland import WaylandWindow as Window +from utils.notification_utils import ( + cache_notification_pixbuf, + load_scaled_pixbuf, + get_shared_notification_history, + NotificationHistory, + delete_cached_image, +) + + +def get_limited_apps_history(): + config = data.load_config() + return config.get("limited_apps_history", ["Spotify"]) + + +def get_history_ignored_apps(): + config = data.load_config() + return config.get("history_ignored_apps", ["Modus"]) class ActionButton(Button): @@ -26,7 +41,14 @@ class ActionButton(Button): name="action-button", h_expand=True, on_clicked=self.on_clicked, - child=Label(name="button-label", label=action.label), + child=Label( + name="button-label", + h_expand=True, + h_align="fill", + ellipsization="end", + max_chars_width=1, + label=action.label, + ), ) self.action = action self.notification_box = notification_box @@ -50,25 +72,78 @@ class ActionButton(Button): self.action.parent.close("dismissed-by-user") -class NotificationWidget(Box): +class NotificationBox(Box): def __init__(self, notification: Notification, timeout_ms=5000, **kwargs): super().__init__( name="notification-box", orientation="v", h_align="fill", h_expand=True, - children=[ - self.create_content(notification), - self.create_action_buttons(notification), - ], + children=[], ) self.notification = notification - self.timeout_ms = timeout_ms - self._timeout_id = None - self.start_timeout() + self.uuid = str(uuid.uuid4()) - def create_header(self, notification): - app_icon = ( + if timeout_ms == 0: + self.timeout_ms = 0 + else: + live_timeout = getattr(self.notification, "timeout", -1) + self.timeout_ms = live_timeout if live_timeout != -1 else timeout_ms + self._timeout_id = None + self._container = None + self.cached_image_path = None + + if self.timeout_ms > 0: + self.start_timeout() + + # Try to access image_pixbuf with error handling + try: + if self.notification.image_pixbuf: + cache_path = cache_notification_pixbuf(self) + if cache_path: + self.cached_image_path = cache_path + else: + logger.warning( + f"NotificationBox { + self.uuid + }: Caching failed, cached_image_path not set." + ) + else: + logger.debug(f"NotificationBox {self.uuid}: No image to cache.") + except Exception as e: + logger.debug(f"NotificationBox {self.uuid}: Error accessing image_pixbuf: {e}") + # Continue without image - this is not a critical error + + content = self.create_content() + action_buttons = self.create_action_buttons() + self.add(content) + if action_buttons: + self.add(action_buttons) + + self.connect("enter-notify-event", self.on_hover_enter) + self.connect("leave-notify-event", self.on_hover_leave) + + self._destroyed = False + self._is_history = False + + def set_is_history(self, is_history): + # Ensure the object is properly initialized + if not hasattr(self, "_is_history"): + self._is_history = False + self._is_history = is_history + + def set_container(self, container): + # Ensure the object is properly initialized + if not hasattr(self, "_container"): + self._container = None + self._container = container + + def get_container(self): + return getattr(self, "_container", None) + + def create_header(self): + notification = self.notification + self.app_icon_image = ( Image( name="notification-icon", image_file=notification.app_icon[7:], @@ -81,6 +156,10 @@ class NotificationWidget(Box): icon_size=24, ) ) + self.app_name_label_header = Label( + notification.app_name, name="notification-app-name", h_align="start" + ) + self.header_close_button = self.create_close_button() return CenterBox( name="notification-title", @@ -88,215 +167,550 @@ class NotificationWidget(Box): Box( spacing=4, children=[ - app_icon, - Label( - notification.app_name, - name="notification-app-name", - h_align="start", - ), + self.app_icon_image, + self.app_name_label_header, ], ) ], - end_children=[self.create_close_button()], + end_children=[self.header_close_button], + ) + + def create_content(self): + notification = self.notification + pixbuf = load_scaled_pixbuf(self, 48, 48) + self.notification_image_box = Box( + name="notification-image", + orientation="v", + children=[CustomImage(pixbuf=pixbuf), Box(v_expand=True)], + ) + self.notification_summary_label = Label( + name="notification-summary", + markup=notification.summary, + h_align="start", + max_chars_width=16, + ellipsization="end", + ) + self.notification_app_name_label_content = Label( + name="notification-app-name", + markup=notification.app_name, + h_align="start", + max_chars_width=16, + ellipsization="end", + ) + self.notification_body_label = ( + Label( + markup=notification.body, + h_align="start", + max_chars_width=34, + ellipsization="end", + ) + if notification.body + else Box() + ) + self.notification_body_label.set_single_line_mode( + True + ) if notification.body else None + self.notification_text_box = Box( + name="notification-text", + orientation="v", + v_align="center", + h_expand=True, + h_align="start", + children=[ + Box( + name="notification-summary-box", + orientation="h", + children=[ + self.notification_summary_label, + Box( + name="notif-sep", + h_expand=False, + v_expand=False, + h_align="center", + v_align="center", + ), + self.notification_app_name_label_content, + ], + ), + self.notification_body_label, + ], + ) + self.content_close_button = self.create_close_button() + self.content_close_button_box = Box( + orientation="v", + children=[ + self.content_close_button, + ], ) - def create_content(self, notification): return Box( name="notification-content", spacing=8, children=[ - Box( - name="notification-image", - children=CustomImage( - pixbuf=notification.image_pixbuf.scale_simple( - 48, 48, GdkPixbuf.InterpType.BILINEAR - ) - if notification.image_pixbuf - else self.get_pixbuf(notification.app_icon, 48, 48) - ), - ), - Box( - name="notification-text", - orientation="v", - v_align="center", - children=[ - Box( - name="notification-summary-box", - orientation="h", - children=[ - Label( - name="notification-summary", - markup=notification.summary.replace("\n", " "), - h_align="start", - ellipsization="end", - ), - Label( - name="notification-app-name", - markup=" | " + notification.app_name, - h_align="start", - ellipsization="end", - ), - ], - ), - Label( - markup=notification.body.replace("\n", " "), - h_align="start", - ellipsization="end", - ) - if notification.body - else Box(), - ], - ), - Box(h_expand=True), - Box( - orientation="v", - children=[ - self.create_close_button(), - Box(v_expand=True), - ], - ), + self.notification_image_box, + self.notification_text_box, + self.content_close_button_box, ], ) - def get_pixbuf(self, icon_path, width, height): - if icon_path.startswith("file://"): - icon_path = icon_path[7:] - - if not os.path.exists(icon_path): - logger.warning(f"Icon path does not exist: {icon_path}") + def create_action_buttons(self): + notification = self.notification + if not notification.actions: return None - try: - pixbuf = GdkPixbuf.Pixbuf.new_from_file(icon_path) - return pixbuf.scale_simple(width, height, GdkPixbuf.InterpType.BILINEAR) - except Exception as e: - logger.error(f"Failed to load or scale icon: {e}") - return None - - def create_action_buttons(self, notification): - return Box( - name="notification-action-buttons", - spacing=4, - h_expand=True, - children=[ - ActionButton(action, i, len(notification.actions), self) - for i, action in enumerate(notification.actions) - ], - ) + grid = Gtk.Grid() + grid.set_column_homogeneous(True) + grid.set_column_spacing(4) + for i, action in enumerate(notification.actions): + action_button = ActionButton(action, i, len(notification.actions), self) + grid.attach(action_button, i, 0, 1, 1) + return grid def create_close_button(self): - close_button = Button( + self.close_button = Button( name="notif-close-button", child=Label(name="notif-close-label", markup=icons.cancel), on_clicked=lambda *_: self.notification.close("dismissed-by-user"), ) - close_button.connect( - "enter-notify-event", lambda *_: self.hover_button(close_button) + self.close_button.connect( + "enter-notify-event", lambda *_: self.hover_button(self.close_button) ) - close_button.connect( - "leave-notify-event", lambda *_: self.unhover_button(close_button) + self.close_button.connect( + "leave-notify-event", lambda *_: self.unhover_button(self.close_button) ) - return close_button + return self.close_button + + def on_hover_enter(self, *args): + container = getattr(self, "_container", None) + if container: + container.pause_and_reset_all_timeouts() + + def on_hover_leave(self, *args): + container = getattr(self, "_container", None) + if container: + container.resume_all_timeouts() def start_timeout(self): self.stop_timeout() self._timeout_id = GLib.timeout_add(self.timeout_ms, self.close_notification) def stop_timeout(self): - if self._timeout_id is not None: + if hasattr(self, "_timeout_id") and self._timeout_id is not None: GLib.source_remove(self._timeout_id) self._timeout_id = None def close_notification(self): - self.notification.close("expired") - self.stop_timeout() + # Safety check: ensure _destroyed attribute exists + if not hasattr(self, "_destroyed"): + logger.warning( + f"NotificationBox { + getattr(self, 'uuid', 'unknown') + } missing _destroyed attribute, skipping close" + ) + return False + + if not self._destroyed: + try: + logger.debug( + f"Notification { + self.notification.id + } timeout expired, closing notification." + ) + self.notification.close("expired") + self.stop_timeout() + except Exception as e: + logger.error( + f"Error in close_notification for notification { + self.notification.id + }: {e}" + ) return False - def pause_timeout(self): - self.stop_timeout() + def destroy(self, from_history_delete=False): + if ( + hasattr(self, "cached_image_path") + and self.cached_image_path + and (not getattr(self, "_is_history", False) or from_history_delete) + ): + delete_cached_image(self.cached_image_path) - def resume_timeout(self): - self.start_timeout() + # Ensure _destroyed attribute exists before setting it + if hasattr(self, "_destroyed"): + self._destroyed = True + else: + # If _destroyed doesn't exist, create it + self._destroyed = True - def destroy(self): self.stop_timeout() super().destroy() - # @staticmethod - def set_pointer_cursor(self, widget, cursor_name): - window = widget.get_window() - if window: - cursor = Gdk.Cursor.new_from_name(widget.get_display(), cursor_name) - window.set_cursor(cursor) - def hover_button(self, button): - self.pause_timeout() - self.set_pointer_cursor(button, "hand2") + container = getattr(self, "_container", None) + if container: + container.pause_and_reset_all_timeouts() def unhover_button(self, button): - self.resume_timeout() - self.set_pointer_cursor(button, "arrow") + container = getattr(self, "_container", None) + if container: + container.resume_all_timeouts() -class NotificationRevealer(Revealer): - def __init__(self, notification: Notification, **kwargs): - self.notif_box = NotificationWidget(notification) - self._notification = notification - super().__init__( - child=Box( - children=[self.notif_box], - ), - transition_duration=250, - transition_type="slide-down", - ) - - self.connect( - "notify::child-revealed", - lambda *_: self.destroy() if not self.get_child_revealed() else None, - ) - - self._notification.connect("closed", self.on_resolved) - - def on_resolved( +class NotificationContainer(Box): + def __init__( self, - notification: Notification, - reason: NotificationCloseReason, + notification_history_instance: NotificationHistory, + revealer_transition_type: str = "slide-down", ): - self.set_reveal_child(False) + super().__init__(name="notification-container-main", orientation="v", spacing=4) + self.notification_history = notification_history_instance + + self._server = Notifications() + self._server.connect("notification-added", self.on_new_notification) + self._pending_removal = False + self._is_destroying = False + + self.stack = Gtk.Stack( + name="notification-stack", + transition_type=Gtk.StackTransitionType.SLIDE_LEFT_RIGHT, + transition_duration=200, + visible=True, + ) + self.navigation = Box( + name="notification-navigation", spacing=4, h_align="center" + ) + self.stack_box = Box( + name="notification-stack-box", + h_align="center", + h_expand=False, + children=[self.stack], + ) + self.prev_button = Button( + name="nav-button", + child=Label(name="nav-button-label", markup=icons.chevron_left), + on_clicked=self.show_previous, + ) + self.close_all_button = Button( + name="nav-button", + child=Label(name="nav-button-label", markup=icons.cancel), + on_clicked=self.close_all_notifications, + ) + self.close_all_button_label = self.close_all_button.get_child() + self.close_all_button_label.add_style_class("close") + self.next_button = Button( + name="nav-button", + child=Label(name="nav-button-label", markup=icons.chevron_right), + on_clicked=self.show_next, + ) + for button in [self.prev_button, self.close_all_button, self.next_button]: + button.connect( + "enter-notify-event", lambda *_: self.pause_and_reset_all_timeouts() + ) + button.connect("leave-notify-event", lambda *_: self.resume_all_timeouts()) + self.navigation.add(self.prev_button) + self.navigation.add(self.close_all_button) + self.navigation.add(self.next_button) + + self.navigation_revealer = Revealer( + transition_type="slide-down", + transition_duration=200, + child=self.navigation, + reveal_child=False, + ) + + self.notification_box_container = Box( + name="notification-box-internal-container", + orientation="v", + children=[self.stack_box, self.navigation_revealer], + ) + + self.main_revealer = Revealer( + name="notification-main-revealer", + transition_type=revealer_transition_type, + transition_duration=250, + child_revealed=False, + child=self.notification_box_container, + ) + + self.add(self.main_revealer) + + self.notifications = [] + self.current_index = 0 + self.update_navigation_buttons() + self._destroyed_notifications = set() + + def on_new_notification(self, fabric_notif, id): + notification_history_instance = self.notification_history + if notification_history_instance.do_not_disturb_enabled: + notification = fabric_notif.get_notification_from_id(id) + new_box = NotificationBox(notification) + try: + if notification.image_pixbuf: + cache_notification_pixbuf(new_box) + except Exception as e: + logger.debug(f"Error accessing image_pixbuf for notification {id}: {e}") + new_box.set_is_history(True) + notification_history_instance.add_notification(new_box) + return + + notification = fabric_notif.get_notification_from_id(id) + new_box = NotificationBox(notification) + new_box.set_container(self) + notification.connect("closed", self.on_notification_closed) + + app_name = notification.app_name + if app_name in get_history_ignored_apps(): + logger.info( + f"Ignoring notification from {app_name} as it is in the ignored list." + ) + # Don't add to history, just show the notification and let it timeout normally + new_box.set_is_history(False) + # Continue with normal notification display but skip history logic + self.stack.add_named(new_box, str(id)) + self.notifications.append(new_box) + self.current_index = len(self.notifications) - 1 + self.stack.set_visible_child(new_box) + + for notification_box in self.notifications: + notification_box.start_timeout() + self.main_revealer.show_all() + self.main_revealer.set_reveal_child(True) + self.update_navigation_buttons() + return + if app_name in get_limited_apps_history(): + notification_history_instance.clear_history_for_app(app_name) + + existing_notification_index = -1 + for index, existing_box in enumerate(self.notifications): + if existing_box.notification.app_name == app_name: + existing_notification_index = index + break + + if existing_notification_index != -1: + old_notification_box = self.notifications.pop( + existing_notification_index + ) + self.stack.remove(old_notification_box) + old_notification_box.set_is_history(True) + notification_history_instance.add_notification(old_notification_box) + old_notification_box.destroy() + + self.stack.add_named(new_box, str(id)) + self.notifications.append(new_box) + self.current_index = len(self.notifications) - 1 + self.stack.set_visible_child(new_box) + else: + while len(self.notifications) >= 5: + oldest_notification = self.notifications[0] + oldest_notification.set_is_history(True) + notification_history_instance.add_notification(oldest_notification) + self.stack.remove(oldest_notification) + self.notifications.pop(0) + if self.current_index > 0: + self.current_index -= 1 + self.stack.add_named(new_box, str(id)) + self.notifications.append(new_box) + self.current_index = len(self.notifications) - 1 + self.stack.set_visible_child(new_box) + else: + while len(self.notifications) >= 5: + oldest_notification = self.notifications[0] + oldest_notification.set_is_history(True) + notification_history_instance.add_notification(oldest_notification) + self.stack.remove(oldest_notification) + self.notifications.pop(0) + if self.current_index > 0: + self.current_index -= 1 + self.stack.add_named(new_box, str(id)) + self.notifications.append(new_box) + self.current_index = len(self.notifications) - 1 + self.stack.set_visible_child(new_box) + + for notification_box in self.notifications: + notification_box.start_timeout() + self.main_revealer.show_all() + self.main_revealer.set_reveal_child(True) + self.update_navigation_buttons() + + def show_previous(self, *args): + if self.current_index > 0: + self.current_index -= 1 + self.stack.set_visible_child(self.notifications[self.current_index]) + self.update_navigation_buttons() + + def show_next(self, *args): + if self.current_index < len(self.notifications) - 1: + self.current_index += 1 + self.stack.set_visible_child(self.notifications[self.current_index]) + self.update_navigation_buttons() + + def update_navigation_buttons(self): + self.prev_button.set_sensitive(self.current_index > 0) + self.next_button.set_sensitive(self.current_index < len(self.notifications) - 1) + should_reveal = len(self.notifications) > 1 + self.navigation_revealer.set_reveal_child(should_reveal) + + def on_notification_closed(self, notification, reason): + if self._is_destroying: + return + if notification.id in self._destroyed_notifications: + return + self._destroyed_notifications.add(notification.id) + try: + notif_to_remove = None + for i, notif_box in enumerate(self.notifications): + if notif_box.notification.id == notification.id: + notif_to_remove = (i, notif_box) + break + if not notif_to_remove: + return + i, notif_box = notif_to_remove + reason_str = str(reason) + + notification_history_instance = self.notification_history + + # Check if this notification should be ignored from history + app_name = notification.app_name + if app_name not in get_history_ignored_apps(): + notif_box.set_is_history(True) + notification_history_instance.add_notification(notif_box) + + if reason_str == "NotificationCloseReason.DISMISSED_BY_USER": + logger.info( + f"User dismissed notification { + notification.id + }, added to history" + ) + elif ( + reason_str == "NotificationCloseReason.EXPIRED" + or reason_str == "NotificationCloseReason.CLOSED" + or reason_str == "NotificationCloseReason.UNDEFINED" + ): + logger.info( + f"Notification { + notification.id + } closed automatically, added to history" + ) + else: + logger.warning( + f"Unknown close reason: {reason_str} for notification { + notification.id + }. Still added to history." + ) + else: + logger.info( + f"Notification from {app_name} closed but not added to history (ignored app)" + ) + + notif_box.stop_timeout() + + if len(self.notifications) == 1: + self._is_destroying = True + self.main_revealer.set_reveal_child(False) + GLib.timeout_add( + self.main_revealer.get_transition_duration(), + self._destroy_container, + ) + return + + new_index = i + if i == self.current_index: + new_index = max(0, i - 1) + elif i < self.current_index: + new_index = self.current_index - 1 + + if notif_box.get_parent() == self.stack: + self.stack.remove(notif_box) + self.notifications.pop(i) + + if new_index >= len(self.notifications) and len(self.notifications) > 0: + new_index = len(self.notifications) - 1 + + self.current_index = new_index + + if len(self.notifications) > 0: + self.stack.set_visible_child(self.notifications[self.current_index]) + + self.update_navigation_buttons() + except Exception as e: + logger.error(f"Error closing notification: {e}") + + def _destroy_container(self): + try: + self.notifications.clear() + self._destroyed_notifications.clear() + for child in self.stack.get_children(): + self.stack.remove(child) + child.destroy() + self.current_index = 0 + except Exception as e: + logger.error(f"Error cleaning up the container: {e}") + finally: + self._is_destroying = False + return False + + def pause_and_reset_all_timeouts(self): + if self._is_destroying: + return + for notification in self.notifications[:]: + try: + if hasattr(notification, "pause_timeout"): + notification.pause_timeout() + except Exception as e: + logger.error(f"Error pausing timeout: {e}") + + def resume_all_timeouts(self): + if self._is_destroying: + return + for notification in self.notifications[:]: + try: + if hasattr(notification, "resume_timeout"): + notification.resume_timeout() + except Exception as e: + logger.error(f"Error resuming timeout: {e}") + + def close_all_notifications(self, *args): + notifications_to_close = self.notifications.copy() + for notification_box in notifications_to_close: + notification_box.notification.close("dismissed-by-user") class NotificationPopup(Window): - def __init__(self): - self._server = notification_service - self.cache_notification_service = notification_service - self.notifications = Box( - v_expand=True, - h_expand=True, - style="margin: 1px 0px 1px 1px;", - orientation="v", - spacing=5, - ) - self._server.connect("notification-added", self.on_new_notification) + def __init__(self, **kwargs): + y_pos = data.NOTIF_POS.lower() if hasattr(data, "NOTIF_POS") else "top" + x_pos = "right" + + if hasattr(data, "DOCK_POSITION") and data.DOCK_POSITION in ["Top", "Bottom"]: + x_pos = "right" super().__init__( - anchor="top right", - child=self.notifications, - layer="overlay", - all_visible=True, + name="notification-popup", + anchor=f"{x_pos} {y_pos}", + layer="top", + keyboard_mode="none", + exclusivity="none", visible=True, - exclusive=False, + all_visible=True, ) - def on_new_notification(self, fabric_notif, id): - notification: Notification = fabric_notif.get_notification_from_id(id) - # Always cache the notification regardless of DND status - self.cache_notification_service.cache_notification(notification) - - # Only show popup if DND is not enabled - if not self.cache_notification_service.dont_disturb: - new_box = NotificationRevealer(fabric_notif.get_notification_from_id(id)) - self.notifications.add(new_box) - new_box.set_reveal_child(True) - logger.info(f"[Notification] New notification from {notification.app_name}") - else: - logger.info(f"[Notification] DND enabled, notification from {notification.app_name} not shown") + self.widgets = kwargs.get("widgets", None) + + self.notification_history = ( + self.widgets.notification_history + if self.widgets + else get_shared_notification_history() + ) + self.notification_container = NotificationContainer( + notification_history_instance=self.notification_history, + revealer_transition_type="slide-down" if y_pos == "top" else "slide-up", + ) + + self.show_box = Box() + self.show_box.set_size_request(1, 1) + + self.add( + Box( + name="notification-popup-box", + orientation="v", + children=[self.notification_container, self.show_box], + ) + ) diff --git a/modules/osd.py b/modules/osd.py index 6d79a60..3667080 100644 --- a/modules/osd.py +++ b/modules/osd.py @@ -1,21 +1,23 @@ import time -from typing import ClassVar +from typing import ClassVar, Literal from fabric.audio import Audio from fabric.widgets.box import Box -from fabric.widgets.scale import Scale, ScaleMark -from fabric.widgets.wayland import WaylandWindow as Window -from fabric.utils import invoke_repeater -from gi.repository import GObject -from services.brightness import Brightness from fabric.widgets.label import Label -from utils import Animator +from fabric.widgets.revealer import Revealer +from fabric.widgets.scale import Scale, ScaleMark +from gi.repository import GLib, GObject + +import utils.icons as icons +from services.brightness import Brightness +from utils.animator import Animator +from utils.wayland import WaylandWindow as Window class AnimatedScale(Scale): def __init__(self, **kwargs): super().__init__(**kwargs) - self.animator = None # Lazily initialized + self.animator = None def animate_value(self, value: float): if not self.animator: @@ -23,30 +25,23 @@ class AnimatedScale(Scale): bezier_curve=(0.34, 1.56, 0.64, 1.0), duration=0.8, min_value=self.min_value, - max_value=self.max_value, + max_value=self.value, tick_widget=self, notify_value=lambda p, *_: self.set_value(p.value), ) self.animator.pause() self.animator.min_value = self.value - self.animator.max_value = min(max(value, self.min_value), self.max_value) + self.animator.max_value = value self.animator.play() -class BaseOSDContainer(Box): - def __init__(self, label_text, **kwargs): - super().__init__(**kwargs, orientation="v", spacing=8, name="osd") - - self.header = Box(orientation="h") - self.label = Label( - label=label_text, name="osd-label", h_align="start", h_expand=True - ) - self.value_label = Label(name="osd-label", h_align="end") - self.header.add(self.label) - self.header.add(self.value_label) - +class BrightnessOSDContainer(Box): + def __init__(self, **kwargs): + super().__init__(**kwargs, orientation="h", spacing=12, name="osd") + self.brightness_service = Brightness.get_initial() + self.icon = Label(markup=icons.brightness, name="brighntess-icons") self.scale = AnimatedScale( - marks=(ScaleMark(value=i) for i in range(0, 100, 10)), + marks=(ScaleMark(value=i) for i in range(0, 101, 10)), value=70, min_value=0, max_value=100, @@ -54,17 +49,8 @@ class BaseOSDContainer(Box): orientation="h", ) - self.add(self.header) + self.add(self.icon) self.add(self.scale) - - def update_value_label(self, value): - self.value_label.set_label(str(round(value))) - - -class BrightnessOSDContainer(BaseOSDContainer): - def __init__(self, **kwargs): - super().__init__(label_text="Brightness", **kwargs) - self.brightness_service = Brightness() self.update_brightness() self.scale.connect("value-changed", lambda *_: self.update_brightness()) @@ -72,85 +58,339 @@ class BrightnessOSDContainer(BaseOSDContainer): def update_brightness(self) -> None: current_brightness = self.brightness_service.screen_brightness + normalized_brightness = self._normalize_brightness(current_brightness) if current_brightness != 0: - normalized_brightness = self._normalize_brightness(current_brightness) - self.scale.set_value(normalized_brightness) - self.update_value_label(normalized_brightness) + self.scale.animate_value(normalized_brightness) + self.update_icon(normalized_brightness) + + def update_icon(self, brightness_percentage: float) -> None: + icon_name = self.get_brightness_icon(brightness_percentage) + self.icon.set_label(icon_name) def on_brightness_changed(self, _sender: any, value: float, *_args) -> None: normalized_brightness = self._normalize_brightness(value) self.scale.animate_value(normalized_brightness) - self.update_value_label(normalized_brightness) + self.update_icon(normalized_brightness) def _normalize_brightness(self, brightness: float) -> float: - normalized = (brightness / self.brightness_service.max_screen) * 100 - return max(0, min(100, normalized)) # Ensure value stays within bounds + return (brightness / self.brightness_service.max_screen) * 100 + + def get_brightness_icon(self, brightness_percentage: float) -> str: + if brightness_percentage >= 67: + return icons.brightness_high + elif brightness_percentage >= 34: + return icons.brightness_medium + else: + return icons.brightness_low -class AudioOSDContainer(BaseOSDContainer): +class AudioOSDContainer(Box): __gsignals__: ClassVar[dict] = { "volume-changed": (GObject.SIGNAL_RUN_FIRST, GObject.TYPE_NONE, ()), } def __init__(self, **kwargs): - super().__init__(label_text="Volume", **kwargs) + super().__init__(**kwargs, orientation="h", spacing=13, name="osd") self.audio = Audio() + self.icon = Label(markup=icons.vol_high, name="vol-icon") + self.scale = AnimatedScale( + marks=(ScaleMark(value=i) for i in range(1, 100, 10)), + value=70, + min_value=0, + max_value=100, + increments=(1, 1), + orientation="h", + ) + + self.previous_volume = None + self.previous_muted = None + + self.add(self.icon) + self.add(self.scale) self.sync_with_audio() self.scale.connect("value-changed", self.on_volume_changed) self.audio.connect("notify::speaker", self.on_audio_speaker_changed) + + # Connect to speaker-changed signal directly self.audio.connect("speaker-changed", self.on_speaker_changed) + # Connect to current speaker if available + if self.audio.speaker: + self.audio.speaker.connect( + "notify::volume", self.on_volume_property_changed + ) + self.audio.speaker.connect("notify::muted", self.on_mute_changed) + def sync_with_audio(self): if self.audio.speaker: volume = round(self.audio.speaker.volume) - self.scale.set_value( - max(0, min(100, volume)) - ) # Ensure value stays within bounds - self.update_value_label(volume) + self.scale.set_value(volume) + self.update_icon(volume) + self.previous_volume = volume + self.previous_muted = self.audio.speaker.muted def on_volume_changed(self, *_): if self.audio.speaker: volume = self.scale.value if 0 <= volume <= 100: self.audio.speaker.set_volume(volume) - self.update_value_label(volume) + self.update_icon(volume) + + if volume == 0 or (self.audio.speaker and self.audio.speaker.muted): + self.scale.add_style_class("muted") + self.icon.add_style_class("muted") + else: + self.scale.remove_style_class("muted") + self.icon.remove_style_class("muted") + self.emit("volume-changed") def on_audio_speaker_changed(self, *_): if self.audio.speaker: - self.audio.speaker.connect("changed", self.update_volume) + self.audio.speaker.connect( + "notify::volume", self.on_volume_property_changed + ) + self.audio.speaker.connect("notify::muted", self.on_mute_changed) self.update_volume() def on_speaker_changed(self, *_): self.update_volume() + self.update_icon(round(self.audio.speaker.volume)) + + def on_volume_property_changed(self, *_): + if self.audio.speaker: + current_volume = round(self.audio.speaker.volume) + if self.previous_volume is None or current_volume != self.previous_volume: + self.previous_volume = current_volume + self.update_volume() + self.update_icon(current_volume) + self.emit("volume-changed") def update_volume(self, *_): if self.audio.speaker and not self.is_hovered(): volume = round(self.audio.speaker.volume) - self.scale.set_value( - max(0, min(100, volume)) - ) # Ensure value stays within bounds - self.update_value_label(volume) + self.scale.set_value(volume) + self.update_icon(volume) + + if volume == 0 or (self.audio.speaker and self.audio.speaker.muted): + self.scale.add_style_class("muted") + self.icon.add_style_class("muted") + else: + self.scale.remove_style_class("muted") + self.icon.remove_style_class("muted") + + def update_icon(self, volume): + if not self.audio.speaker: + return + + vol_high_icon = icons.vol_high + vol_medium_icon = icons.vol_medium + vol_mute_icon = icons.vol_off + vol_off_icon = icons.vol_mute + + if self.audio.speaker and "bluetooth" in self.audio.speaker.icon_name: + vol_high_icon = icons.bluetooth_connected + vol_medium_icon = icons.bluetooth + vol_mute_icon = icons.bluetooth_off + vol_off_icon = icons.bluetooth_disconnected + + if self.audio.speaker.muted: + self.icon.set_label(vol_mute_icon) + self.scale.add_style_class("muted") + self.icon.add_style_class("muted") + else: + if volume == 0: + self.scale.add_style_class("muted") + self.icon.add_style_class("muted") + else: + self.scale.remove_style_class("muted") + self.icon.remove_style_class("muted") + + if volume >= 67: + self.icon.set_label(vol_high_icon) + elif volume >= 34: + self.icon.set_label(vol_medium_icon) + elif volume >= 1: + # Use medium for low volumes too + self.icon.set_label(vol_medium_icon) + else: + self.icon.set_label(vol_off_icon) + + def _get_audio_icon_name(self, volume: int) -> str: + if volume >= 67: + return icons.vol_high + elif volume >= 34: + return icons.vol_medium + elif volume >= 1: + return icons.vol_mute + else: + return icons.vol_off + + def on_mute_changed(self, *_): + if self.audio.speaker: + current_muted = self.audio.speaker.muted + if self.previous_muted is None or current_muted != self.previous_muted: + self.previous_muted = current_muted + self.update_icon(round(self.audio.speaker.volume)) + GLib.idle_add(lambda: self.emit("volume-changed")) + + +class MicrophoneOSDContainer(Box): + __gsignals__: ClassVar[dict] = { + "mic-changed": (GObject.SIGNAL_RUN_FIRST, GObject.TYPE_NONE, ()), + } + + def __init__(self, **kwargs): + super().__init__(**kwargs, orientation="h", spacing=13, name="osd") + self.audio = Audio() + self.icon = Label(markup=icons.mic, name="mic-icon") + self.scale = AnimatedScale( + marks=(ScaleMark(value=i) for i in range(1, 100, 10)), + value=70, + min_value=0, + max_value=100, + increments=(1, 1), + orientation="h", + ) + + self.previous_volume = None + self.previous_muted = None + + self.add(self.icon) + self.add(self.scale) + self.sync_with_audio() + + self.scale.connect("value-changed", self.on_volume_changed) + self.audio.connect("notify::microphone", self.on_audio_microphone_changed) + + # Connect to microphone-changed signal directly + self.audio.connect("microphone-changed", self.on_microphone_changed) + + if self.audio.microphone: + self.audio.microphone.connect( + "notify::volume", self.on_volume_property_changed + ) + self.audio.microphone.connect("notify::muted", self.on_mute_changed) + + def sync_with_audio(self): + if self.audio.microphone: + volume = round(self.audio.microphone.volume) + self.scale.set_value(volume) + self.update_icon(volume) + self.previous_volume = volume + self.previous_muted = self.audio.microphone.muted + + def on_volume_changed(self, *_): + if self.audio.microphone: + volume = self.scale.value + if 0 <= volume <= 100: + self.audio.microphone.set_volume(volume) + self.update_icon(volume) + + if volume == 0 or ( + self.audio.microphone and self.audio.microphone.muted + ): + self.scale.add_style_class("muted") + self.icon.add_style_class("muted") + else: + self.scale.remove_style_class("muted") + self.icon.remove_style_class("muted") + + self.emit("mic-changed") + + def on_audio_microphone_changed(self, *_): + if self.audio.microphone: + self.audio.microphone.connect( + "notify::volume", self.on_volume_property_changed + ) + self.audio.microphone.connect("notify::muted", self.on_mute_changed) + self.update_volume() + + def on_microphone_changed(self, *_): + self.update_volume() + self.update_icon(round(self.audio.microphone.volume)) + + def on_volume_property_changed(self, *_): + if self.audio.microphone: + current_volume = round(self.audio.microphone.volume) + if self.previous_volume is None or current_volume != self.previous_volume: + self.previous_volume = current_volume + self.update_volume() + self.update_icon(current_volume) + self.emit("mic-changed") + + def update_volume(self, *_): + if self.audio.microphone and not self.is_hovered(): + volume = round(self.audio.microphone.volume) + self.scale.set_value(volume) + self.update_icon(volume) + + if volume == 0 or (self.audio.microphone and self.audio.microphone.muted): + self.scale.add_style_class("muted") + self.icon.add_style_class("muted") + else: + self.scale.remove_style_class("muted") + self.icon.remove_style_class("muted") + + def update_icon(self, volume): + if not self.audio.microphone: + return + + if self.audio.microphone.muted: + self.icon.set_label(icons.mic_mute) + self.scale.add_style_class("muted") + self.icon.add_style_class("muted") + else: + if volume == 0: + self.scale.add_style_class("muted") + self.icon.add_style_class("muted") + else: + self.scale.remove_style_class("muted") + self.icon.remove_style_class("muted") + + if volume >= 67: + self.icon.set_label(icons.mic) + elif volume >= 34: + self.icon.set_label(icons.mic) + elif volume >= 1: + self.icon.set_label(icons.mic) + else: + self.icon.set_label(icons.mic_mute) + + def on_mute_changed(self, *_): + if self.audio.microphone: + current_muted = self.audio.microphone.muted + if self.previous_muted is None or current_muted != self.previous_muted: + self.previous_muted = current_muted + self.update_icon(round(self.audio.microphone.volume)) + GLib.idle_add(lambda: self.emit("mic-changed")) class OSD(Window): def __init__(self, **kwargs): self.audio_container = AudioOSDContainer() self.brightness_container = BrightnessOSDContainer() + self.microphone_container = MicrophoneOSDContainer() self.timeout = 1000 - self.controls_box = Box( - orientation="h", - spacing=20, - children=[self.brightness_container, self.audio_container], + self.revealer = Revealer( + transition_type="slide-up", + transition_duration=100, + child_revealed=False, + ) + + self.main_box = Box( + orientation="v", + spacing=13, + children=[self.revealer], ) super().__init__( layer="overlay", - anchor="top", - child=self.controls_box, + anchor="bottom", + child=self.main_box, visible=False, pass_through=True, keyboard_mode="on-demand", @@ -159,16 +399,35 @@ class OSD(Window): self.last_activity_time = time.time() - self.audio_container.audio.connect("notify::speaker", self.show_controls) + self.audio_container.connect("volume-changed", self.show_audio) self.brightness_container.brightness_service.connect( - "screen", self.show_controls + "screen", self.show_brightness ) - self.audio_container.connect("volume-changed", self.show_controls) + self.microphone_container.connect("mic-changed", self.show_microphone) - invoke_repeater(100, self.check_inactivity, initial_call=True) + GLib.timeout_add(100, self.check_inactivity) - def show_controls(self, *_): + def show_audio(self, *_): + self.show_box(box_to_show="audio") + self.reset_inactivity_timer() + + def show_brightness(self, *_): + self.show_box(box_to_show="brightness") + self.reset_inactivity_timer() + + def show_microphone(self, *_): + self.show_box(box_to_show="microphone") + self.reset_inactivity_timer() + + def show_box(self, box_to_show: Literal["audio", "brightness", "microphone"]): self.set_visible(True) + if box_to_show == "audio": + self.revealer.children = self.audio_container + elif box_to_show == "brightness": + self.revealer.children = self.brightness_container + elif box_to_show == "microphone": + self.revealer.children = self.microphone_container + self.revealer.set_reveal_child(True) self.reset_inactivity_timer() def start_hide_timer(self): diff --git a/modules/switcher.py b/modules/switcher.py new file mode 100644 index 0000000..784e92b --- /dev/null +++ b/modules/switcher.py @@ -0,0 +1,293 @@ +import json +import subprocess + +from fabric.hyprland.widgets import get_hyprland_connection +from fabric.widgets.box import Box +from fabric.widgets.eventbox import EventBox +from fabric.widgets.image import Image +from fabric.widgets.label import Label +from gi.repository import Gdk + +import config.data as data +from utils.icon_resolver import IconResolver +from utils.wayland import WaylandWindow as Window + + +class ApplicationSwitcher(Window): + def __init__(self, **kwargs): + super().__init__( + name="application-switcher", + layer="top", + anchor="center", + exclusivity="auto", + keyboard_mode="exclusive", + **kwargs, + ) + + self.conn = get_hyprland_connection() + self.icon_resolver = IconResolver() + self.windows = [] + self.current_index = 0 + self.tab_pressed = False + self.items_per_row = data.WINDOW_SWITCHER_ITEMS_PER_ROW + self.icon_size = 64 + + container = Box( + name="application-switcher-container", + orientation="v", + h_align="center", + v_align="center", + expand=True, + ) + self.add(container) + + self.view = Box( + name="application-switcher-view", + orientation="v", + spacing=0, + h_align="center", + v_align="center", + ) + container.add(self.view) + self.connect("key-press-event", self.on_key_press) + self.connect("key-release-event", self.on_key_release) + self.show_all() + self.hide() + + def show_switcher(self) -> None: + self.update_windows() + if not self.windows: + return + + self.show() + self.grab_keyboard() + self.tab_pressed = False + + def hide_switcher(self) -> None: + self.hide() + self.ungrab_keyboard() + + def _is_special_workspace(self, client): + """Check if a client is in a special workspace""" + if "workspace" not in client: + return False + + workspace = client["workspace"] + if "name" in workspace: + workspace_name = str(workspace["name"]) + # Special workspaces typically start with "special:" or have negative IDs + if workspace_name.startswith("special:"): + return True + + if "id" in workspace: + workspace_id = workspace["id"] + # Special workspaces have negative IDs + if workspace_id < 0: + return True + + return False + + def update_windows(self) -> None: + for child in self.view.get_children(): + self.view.remove(child) + + try: + clients_data = self.conn.send_command("j/clients").reply + if not clients_data: + return + clients = json.loads(clients_data.decode("utf-8")) + + # Filter out hidden windows and optionally special workspace windows + filtered_windows = [] + for c in clients: + if c.get("hidden", False): + continue + # Skip clients in special workspaces if the setting is enabled + if data.DOCK_HIDE_SPECIAL_WORKSPACE_APPS and self._is_special_workspace(c): + continue + filtered_windows.append(c) + + self.windows = filtered_windows + + active_data = self.conn.send_command("j/activewindow").reply + active_window = ( + json.loads(active_data.decode("utf-8")) if active_data else None + ) + + self.current_index = 0 + if active_window: + for i, window in enumerate(self.windows): + if window.get("address") == active_window.get("address"): + self.current_index = i + break + + current_row = Box( + name="window-row", + orientation="h", + spacing=12, + h_align="center", + v_align="center", + ) + self.view.add(current_row) + + for i, window in enumerate(self.windows): + class_name = window.get("class", "").lower() + title = window.get("title", "") + + icon_img = self.icon_resolver.get_icon_pixbuf( + class_name, self.icon_size + ) + if not icon_img: + icon_img = self.icon_resolver.get_icon_pixbuf( + "application-x-executable-symbolic", self.icon_size + ) + + button_content = Box( + name="switcher-button", + orientation="v", + spacing=4, + h_align="center", + v_align="center", + children=[ + Image(pixbuf=icon_img), + Label( + label=title[:15] + "..." if len(title) > 15 else title, + h_align="center", + v_align="center", + max_width_chars=15, + ellipsize="end", + ), + ], + ) + + event_box = EventBox( + name="window-button", + style_classes=["active"] if i == self.current_index else None, + child=button_content, + ) + current_row.add(event_box) + + if (i + 1) % self.items_per_row == 0 and i + 1 < len(self.windows): + current_row = Box( + name="window-row", + orientation="h", + spacing=12, + h_align="center", + v_align="center", + ) + self.view.add(current_row) + + self.view.show_all() + self.update_selection() + except Exception as e: + print(f"Failed to update windows: {e}") + + def on_key_press(self, widget, event): + keyval = event.keyval + state = event.state + alt_pressed = bool(state & Gdk.ModifierType.MOD1_MASK) + + if not self.windows: + return False + + if keyval == Gdk.KEY_Escape: + self.hide_switcher() + return True + + if keyval == Gdk.KEY_Tab: + if not self.tab_pressed or alt_pressed: + self.current_index = (self.current_index + 1) % len(self.windows) + self.update_selection() + self.tab_pressed = True + return True + + if keyval == Gdk.KEY_ISO_Left_Tab or ( + keyval == Gdk.KEY_Tab and (state & Gdk.ModifierType.SHIFT_MASK) + ): + self.current_index = (self.current_index - 1) % len(self.windows) + self.update_selection() + return True + + if keyval == Gdk.KEY_Return: + self.activate_selected() + self.hide_switcher() + return True + + if keyval == Gdk.KEY_Right or keyval == Gdk.KEY_l: + self.current_index = (self.current_index + 1) % len(self.windows) + self.update_selection() + return True + + if keyval == Gdk.KEY_Left or keyval == Gdk.KEY_h: + self.current_index = (self.current_index - 1) % len(self.windows) + self.update_selection() + return True + + if keyval == Gdk.KEY_Down: + next_index = self.current_index + self.items_per_row + if next_index < len(self.windows): + self.current_index = next_index + self.update_selection() + return True + + if keyval == Gdk.KEY_Up: + next_index = self.current_index - self.items_per_row + if next_index >= 0: + self.current_index = next_index + self.update_selection() + return True + + return False + + def on_key_release(self, widget, event): + keyval = event.keyval + + if keyval in (Gdk.KEY_Alt_L, Gdk.KEY_Alt_R): + self.activate_selected() + self.hide_switcher() + return True + + if keyval == Gdk.KEY_Tab: + self.tab_pressed = False + return True + + return False + + def update_selection(self): + for row in self.view.get_children(): + for i, child in enumerate(row.get_children()): + index = self.view.get_children().index(row) * self.items_per_row + i + if index == self.current_index: + child.add_style_class("active") + else: + child.remove_style_class("active") + + def activate_selected(self): + if not self.windows or self.current_index >= len(self.windows): + return + + window = self.windows[self.current_index] + address = window.get("address") + if address: + try: + cmd = ["hyprctl", "dispatch", "focuswindow", f"address:{address}"] + subprocess.run(cmd, capture_output=True, text=True, timeout=2) + except Exception as e: + print(f"Failed to focus window: {e}") + + def grab_keyboard(self): + try: + display = Gdk.Display.get_default() + seat = display.get_default_seat() + window = self.get_window() + seat.grab(window, Gdk.SeatCapabilities.KEYBOARD, False, None, None, None) + except Exception as e: + print(f"Failed to grab keyboard: {e}") + + def ungrab_keyboard(self): + try: + display = Gdk.Display.get_default() + seat = display.get_default_seat() + seat.ungrab() + except Exception as e: + print(f"Failed to ungrab keyboard: {e}") diff --git a/config/scripts/gamemode.sh b/scripts/gamemode.sh similarity index 100% rename from config/scripts/gamemode.sh rename to scripts/gamemode.sh diff --git a/config/scripts/hyprpicker.sh b/scripts/hyprpicker.sh similarity index 100% rename from config/scripts/hyprpicker.sh rename to scripts/hyprpicker.sh diff --git a/scripts/screen-capture.sh b/scripts/screen-capture.sh new file mode 100755 index 0000000..a1fd899 --- /dev/null +++ b/scripts/screen-capture.sh @@ -0,0 +1,704 @@ +#!/bin/env bash + +# Script name +SCRIPT_NAME=$(basename "$0") + +# Function to display usage +usage() { + cat <<EOF +Usage: $SCRIPT_NAME <command> [options] + +Commands: + screenshot <target> Take a screenshot + Targets: + selection - Screenshot selected area + eDP-1 - Screenshot eDP-1 display + HDMI-A-1 - Screenshot HDMI-A-1 display + both - Screenshot both displays + + record <target> Start/stop recording (with audio) + Targets: + selection - Record selected area + eDP-1 - Record eDP-1 display + HDMI-A-1 - Record HDMI-A-1 display + stop - Stop current recording + + record-noaudio <target> Start/stop recording (no audio) + Targets: + selection - Record selected area without audio + eDP-1 - Record eDP-1 display without audio + HDMI-A-1 - Record HDMI-A-1 display without audio + stop - Stop current recording + + record-hq <target> Start/stop high-quality recording (for YouTube) + Targets: + selection - Record selected area in high quality + eDP-1 - Record eDP-1 display in high quality + HDMI-A-1 - Record HDMI-A-1 display in high quality + stop - Stop current recording + + record-gif <target> Start/stop GIF recording + Targets: + selection - Record selected area as GIF + eDP-1 - Record eDP-1 display as GIF + HDMI-A-1 - Record HDMI-A-1 display as GIF + stop - Stop current recording + + status Check if recording is active (exit 0 if recording, 1 if not) + + convert <format> [file] Convert recordings + Formats: + webm - Convert latest MKV to WebM (or specify file) + iphone - Convert latest MKV to iPhone format (or specify file) + youtube - Convert latest video to YouTube format (or specify file) + gif - Convert latest video to GIF (or specify file) + + Optional [file] parameter: + - If not provided, converts the latest recorded video + - If provided, converts the specified file (full path or filename in Recordings folder) + +Examples: + $SCRIPT_NAME screenshot selection + $SCRIPT_NAME record eDP-1 + $SCRIPT_NAME record-noaudio selection + $SCRIPT_NAME record-hq eDP-1 + $SCRIPT_NAME record-gif selection + $SCRIPT_NAME record stop + $SCRIPT_NAME convert gif # Convert latest video to GIF + $SCRIPT_NAME convert youtube # Convert latest video for YouTube + $SCRIPT_NAME convert webm /path/to/video.mkv # Convert specific file to WebM + +EOF + exit 1 +} + +# Check if no arguments provided +if [ $# -eq 0 ]; then + usage +fi + +# Function to send screenshot notification with action buttons +send_screenshot_notification() { + local full_path="$1" + local save_dir=$(dirname "$full_path") + + ACTION=$(notify-send -a "Modus" -i "$full_path" "Screenshot saved" "in $full_path" \ + -A "view=View" -A "edit=Edit" -A "open=Open Folder") + + case "$ACTION" in + view) xdg-open "$full_path" ;; + edit) swappy -f "$full_path" ;; + open) xdg-open "$save_dir" ;; + esac +} + +# Function to send recording notification with action buttons +send_recording_notification() { + local full_path="$1" + local save_dir=$(dirname "$full_path") + + ACTION=$(notify-send -a "Modus" -i "camera-video-symbolic" "Recording saved" "in $full_path" \ + -A "view=View" -A "open=Open Folder") + + case "$ACTION" in + view) xdg-open "$full_path" ;; + open) xdg-open "$save_dir" ;; + esac +} + +wf-recorder_check() { + if pgrep -x "wf-recorder" >/dev/null; then + pkill -INT -x wf-recorder + # Get the recording file path and send notification with actions + if [ -f /tmp/recording.txt ]; then + recording_file=$(cat /tmp/recording.txt) + wl-copy <"$recording_file" + send_recording_notification "$recording_file" + else + notify-send "Recording stopped" "wf-recorder process terminated" + fi + # Clean up recording start time file + rm -f /tmp/recording_start_time.txt + exit 0 + fi +} + +# Function to record with standard settings +record_video() { + local output_file="$1" + shift + + wf-recorder "$@" -f "$output_file" -c libvpx-vp9 --pixel-format yuv420p -F "eq=brightness=0.12:contrast=1.1" +} + +# Function to record without audio +record_video_noaudio() { + local output_file="$1" + shift + + wf-recorder "$@" -f "$output_file" -c libvpx-vp9 --pixel-format yuv420p -F "eq=brightness=0.12:contrast=1.1" --no-audio +} + +record_high_quality() { + local output_file="$1" + shift + + # High quality settings for YouTube uploads + # - h264_vaapi for hardware encoding (if available) or libx264 for software + # - yuv420p pixel format for maximum compatibility + # - High bitrate (8000k) for quality + # - GOP size of 30 for better seeking + # - Preset 'slow' for better compression efficiency + # - CRF 18 for high quality (lower = better quality, 0-51 scale) + # - Audio at 192k bitrate + # - 60 FPS for smooth motion + # - No color filters to maintain original colors + + # Check if VAAPI hardware encoding is available + if vainfo &>/dev/null && wf-recorder --help | grep -q "h264_vaapi"; then + # Use hardware encoding for better performance + wf-recorder "$@" -f "$output_file" \ + -c h264_vaapi \ + -p "preset=slow" \ + -p "crf=18" \ + -r 60 \ + -b 8000000 \ + -B 192000 \ + --pixel-format yuv420p \ + -g 30 + else + # Fallback to software encoding + wf-recorder "$@" -f "$output_file" \ + -c libx264 \ + -p "preset=slow" \ + -p "crf=18" \ + -r 60 \ + -b 8000000 \ + -B 192000 \ + --pixel-format yuv420p \ + -g 30 + fi +} + +record_gif() { + local output_file="$1" + shift + + # Record temporary video first (MKV format for better quality) + local temp_video="/tmp/gif_recording_$(date +%s).mkv" + echo "$temp_video" >/tmp/gif_temp_video.txt + + # GIF-optimized recording settings: + # - Lower framerate (15 fps) for smaller file size + # - No audio recording + # - Standard codec for compatibility + wf-recorder "$@" -f "$temp_video" \ + -c libvpx-vp9 \ + -r 15 \ + --pixel-format yuv420p \ + --no-audio + + # After recording stops, convert to GIF + if [ -f "$temp_video" ]; then + notify-send "Converting to GIF" "Processing recording..." + + # Create high-quality GIF with optimized palette + # Using ffmpeg with palette generation for better colors + ffmpeg -i "$temp_video" \ + -vf "fps=15,scale=iw:-1:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=128:stats_mode=diff[p];[s1][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle" \ + -loop 0 \ + "$output_file" 2>/tmp/gif_conversion.log + + if [ $? -eq 0 ]; then + # Clean up temp file + rm -f "$temp_video" + rm -f /tmp/gif_temp_video.txt + + # Copy to clipboard and send notification with actions + wl-copy <"$output_file" + send_recording_notification "$output_file" + else + error=$(cat /tmp/gif_conversion.log | tail -n 5) + notify-send "GIF Conversion Failed" "Error: $error" + rm -f "$temp_video" + rm -f /tmp/gif_temp_video.txt + fi + fi +} + +# Function to find the latest video file for conversion +find_latest_video() { + local format="$1" + local recording_dir="${HOME}/Videos/Recordings" + + case "$format" in + "webm"|"iphone") + # For webm and iphone, only look for MKV files + find "$recording_dir" -name "*.mkv" -type f -printf '%T@ %p\n' 2>/dev/null | sort -n | tail -1 | cut -d' ' -f2- + ;; + "youtube"|"gif") + # For youtube and gif, look for both MKV and MP4 files + find "$recording_dir" \( -name "*.mkv" -o -name "*.mp4" \) -type f -printf '%T@ %p\n' 2>/dev/null | sort -n | tail -1 | cut -d' ' -f2- + ;; + *) + echo "" + ;; + esac +} + +# Function to resolve video file path +resolve_video_file() { + local format="$1" + local file_param="$2" + local recording_dir="${HOME}/Videos/Recordings" + + if [ -n "$file_param" ]; then + # File parameter provided + if [ -f "$file_param" ]; then + # Full path provided and exists + echo "$file_param" + elif [ -f "$recording_dir/$file_param" ]; then + # Filename provided, exists in recordings folder + echo "$recording_dir/$file_param" + else + echo "" + fi + else + # No file parameter, find latest + find_latest_video "$format" + fi +} + +# Parse command +COMMAND="$1" +TARGET="$2" +FILE_PARAM="$3" # Optional file parameter for convert command + +# Set up file paths +IMG="${HOME}/Pictures/Screenshots/$(date +%Y-%m-%d_%H-%m-%s).png" +VID="${HOME}/Videos/Recordings/$(date +%Y-%m-%d_%H-%m-%s).mkv" + +case "$COMMAND" in +"screenshot") + case "$TARGET" in + "selection") + grim -g "$(slurp)" "$IMG" + wl-copy <"$IMG" + send_screenshot_notification "$IMG" + ;; + "eDP-1") + grim -c -o eDP-1 "$IMG" + wl-copy <"$IMG" + send_screenshot_notification "$IMG" + ;; + "HDMI-A-1") + grim -c -o HDMI-A-1 "$IMG" + wl-copy <"$IMG" + send_screenshot_notification "$IMG" + ;; + "both") + grim -c -o eDP-1 "${IMG//.png/-eDP-1.png}" + grim -c -o HDMI-A-1 "${IMG//.png/-HDMI-A-1.png}" + montage "${IMG//.png/-eDP-1.png}" "${IMG//.png/-HDMI-A-1.png}" -tile 2x1 -geometry +0+0 "$IMG" + wl-copy <"$IMG" + rm "${IMG//.png/-eDP-1.png}" "${IMG//.png/-HDMI-A-1.png}" + send_screenshot_notification "$IMG" + ;; + *) + echo "Error: Invalid screenshot target '$TARGET'" + usage + ;; + esac + ;; + +"record") + case "$TARGET" in + "stop") + wf-recorder_check + ;; + "selection") + wf-recorder_check + echo "$VID" >/tmp/recording.txt + date +%s >/tmp/recording_start_time.txt + record_video "$VID" -g "$(slurp)" + ;; + "eDP-1") + wf-recorder_check + echo "$VID" >/tmp/recording.txt + date +%s >/tmp/recording_start_time.txt + record_video "$VID" -a -o eDP-1 + ;; + "HDMI-A-1") + wf-recorder_check + echo "$VID" >/tmp/recording.txt + date +%s >/tmp/recording_start_time.txt + record_video "$VID" -a -o HDMI-A-1 + ;; + *) + echo "Error: Invalid record target '$TARGET'" + usage + ;; + esac + ;; + +"record-noaudio") + case "$TARGET" in + "stop") + wf-recorder_check + ;; + "selection") + wf-recorder_check + echo "$VID" >/tmp/recording.txt + date +%s >/tmp/recording_start_time.txt + record_video_noaudio "$VID" -g "$(slurp)" + ;; + "eDP-1") + wf-recorder_check + echo "$VID" >/tmp/recording.txt + date +%s >/tmp/recording_start_time.txt + record_video_noaudio "$VID" -o eDP-1 + ;; + "HDMI-A-1") + wf-recorder_check + echo "$VID" >/tmp/recording.txt + date +%s >/tmp/recording_start_time.txt + record_video_noaudio "$VID" -o HDMI-A-1 + ;; + *) + echo "Error: Invalid record-noaudio target '$TARGET'" + usage + ;; + esac + ;; + +"record-hq") + # Change file extension to mp4 for high quality recordings + VID_HQ="${HOME}/Videos/Recordings/$(date +%Y-%m-%d_%H-%m-%s)-hq.mp4" + + case "$TARGET" in + "stop") + wf-recorder_check + ;; + "selection") + wf-recorder_check + echo "$VID_HQ" >/tmp/recording.txt + date +%s >/tmp/recording_start_time.txt + notify-send "High Quality Recording" "Starting YouTube-quality recording..." + record_high_quality "$VID_HQ" -g "$(slurp)" + ;; + "eDP-1") + wf-recorder_check + echo "$VID_HQ" >/tmp/recording.txt + date +%s >/tmp/recording_start_time.txt + notify-send "High Quality Recording" "Starting YouTube-quality recording on eDP-1..." + record_high_quality "$VID_HQ" -a -o eDP-1 + ;; + "HDMI-A-1") + wf-recorder_check + echo "$VID_HQ" >/tmp/recording.txt + date +%s >/tmp/recording_start_time.txt + notify-send "High Quality Recording" "Starting YouTube-quality recording on HDMI-A-1..." + record_high_quality "$VID_HQ" -a -o HDMI-A-1 + ;; + *) + echo "Error: Invalid record-hq target '$TARGET'" + usage + ;; + esac + ;; + +"record-gif") + # GIF files go to a specific location + GIF="${HOME}/Videos/Recordings/$(date +%Y-%m-%d_%H-%m-%s).gif" + + case "$TARGET" in + "stop") + wf-recorder_check + ;; + "selection") + wf-recorder_check + echo "$GIF" >/tmp/recording.txt + date +%s >/tmp/recording_start_time.txt + notify-send "GIF Recording" "Starting GIF recording (15 FPS)..." + record_gif "$GIF" -g "$(slurp)" + ;; + "eDP-1") + wf-recorder_check + echo "$GIF" >/tmp/recording.txt + date +%s >/tmp/recording_start_time.txt + notify-send "GIF Recording" "Starting GIF recording on eDP-1..." + record_gif "$GIF" -o eDP-1 + ;; + "HDMI-A-1") + wf-recorder_check + echo "$GIF" >/tmp/recording.txt + date +%s >/tmp/recording_start_time.txt + notify-send "GIF Recording" "Starting GIF recording on HDMI-A-1..." + record_gif "$GIF" -o HDMI-A-1 + ;; + *) + echo "Error: Invalid record-gif target '$TARGET'" + usage + ;; + esac + ;; + +"status") + # Check if wf-recorder is running + if pgrep -x "wf-recorder" >/dev/null; then + echo "true" + exit 0 + else + echo "false" + exit 0 + fi + ;; + +"convert") + case "$TARGET" in + "webm") + # Check if ffmpeg is installed + if ! command -v ffmpeg >/dev/null 2>&1; then + notify-send "Error" "ffmpeg is not installed. Please install it to use this feature." + exit 1 + fi + + # Resolve the video file to convert + video_file=$(resolve_video_file "webm" "$FILE_PARAM") + + if [ -z "$video_file" ] || [ ! -f "$video_file" ]; then + if [ -n "$FILE_PARAM" ]; then + notify-send "WebM Conversion Error" "File not found: $FILE_PARAM" + else + notify-send "WebM Conversion Error" "No MKV files found in Recordings folder" + fi + exit 1 + fi + + # Ensure it's an MKV file + if [[ "$video_file" != *.mkv ]]; then + notify-send "WebM Conversion Error" "Only MKV files can be converted to WebM. Found: $(basename "$video_file")" + exit 1 + fi + + webm_file="${video_file%.mkv}.webm" + + # Check if webm version doesn't already exist + if [ -f "$webm_file" ]; then + notify-send "WebM Conversion Skipped" "WebM version already exists: $(basename "$webm_file")" + exit 0 + fi + + notify-send "Converting to WebM" "Processing: $(basename "$video_file")" + + # Convert the file + ffmpeg -y -i "$video_file" -c:v libvpx -b:v 1M -c:a libvorbis "$webm_file" 2>/tmp/ffmpeg_error.log + + if [ $? -eq 0 ]; then + file_size=$(du -h "$webm_file" | cut -f1) + notify-send "WebM Conversion Success" "$(basename "$video_file") → $(basename "$webm_file") ($file_size)" + else + error=$(cat /tmp/ffmpeg_error.log | tail -n 5) + notify-send "WebM Conversion Failed" "Error: $error" + fi + ;; + + "iphone") + # Check if ffmpeg is installed + if ! command -v ffmpeg >/dev/null 2>&1; then + notify-send "Error" "ffmpeg is not installed. Please install it to use this feature." + exit 1 + fi + + # Resolve the video file to convert + video_file=$(resolve_video_file "iphone" "$FILE_PARAM") + + if [ -z "$video_file" ] || [ ! -f "$video_file" ]; then + if [ -n "$FILE_PARAM" ]; then + notify-send "iPhone Conversion Error" "File not found: $FILE_PARAM" + else + notify-send "iPhone Conversion Error" "No MKV files found in Recordings folder" + fi + exit 1 + fi + + # Ensure it's an MKV file + if [[ "$video_file" != *.mkv ]]; then + notify-send "iPhone Conversion Error" "Only MKV files can be converted for iPhone. Found: $(basename "$video_file")" + exit 1 + fi + + base_filename=$(basename "$video_file") + + # Skip files with "iphone" in the filename + if [[ $base_filename == *"iphone"* ]]; then + notify-send "iPhone Conversion Skipped" "File already appears to be iPhone format: $(basename "$video_file")" + exit 0 + fi + + iphone_file="${video_file%.mkv}-iphone.mp4" + + # Check if iPhone version doesn't already exist + if [ -f "$iphone_file" ]; then + notify-send "iPhone Conversion Skipped" "iPhone version already exists: $(basename "$iphone_file")" + exit 0 + fi + + notify-send "Converting for iPhone" "Processing: $(basename "$video_file")" + + # Convert the file + ffmpeg -y -i "$video_file" -vcodec h264 -acodec aac "$iphone_file" 2>/tmp/ffmpeg_error.log + + if [ $? -eq 0 ]; then + file_size=$(du -h "$iphone_file" | cut -f1) + notify-send "iPhone Conversion Success" "$(basename "$video_file") → $(basename "$iphone_file") ($file_size)" + else + error=$(cat /tmp/ffmpeg_error.log | tail -n 5) + notify-send "iPhone Conversion Failed" "Error: $error" + fi + ;; + + "youtube") + # Check if ffmpeg is installed + if ! command -v ffmpeg >/dev/null 2>&1; then + notify-send "Error" "ffmpeg is not installed. Please install it to use this feature." + exit 1 + fi + + # Resolve the video file to convert + video_file=$(resolve_video_file "youtube" "$FILE_PARAM") + + if [ -z "$video_file" ] || [ ! -f "$video_file" ]; then + if [ -n "$FILE_PARAM" ]; then + notify-send "YouTube Conversion Error" "File not found: $FILE_PARAM" + else + notify-send "YouTube Conversion Error" "No video files found in Recordings folder" + fi + exit 1 + fi + + base_filename=$(basename "$video_file") + + # Skip files already marked as YouTube uploads + if [[ $base_filename == *"youtube"* ]]; then + notify-send "YouTube Conversion Skipped" "File already appears to be YouTube format: $(basename "$video_file")" + exit 0 + fi + + # Create YouTube optimized filename + youtube_file="${video_file%.*}-youtube.mp4" + + # Check if YouTube version doesn't already exist + if [ -f "$youtube_file" ]; then + notify-send "YouTube Conversion Skipped" "YouTube version already exists: $(basename "$youtube_file")" + exit 0 + fi + + notify-send "Converting for YouTube" "Processing: $(basename "$video_file")" + + # YouTube recommended settings: + # - H.264 codec with High profile + # - 1080p or source resolution + # - 60fps or source framerate + # - High bitrate for quality (8-12 Mbps for 1080p60) + # - AAC audio at 384kbps + # - yuv420p pixel format for compatibility + # - Keyframe interval of 2 seconds (GOP) + # - No filters to preserve original colors + + ffmpeg -y -i "$video_file" \ + -c:v libx264 \ + -profile:v high \ + -preset slow \ + -crf 18 \ + -pix_fmt yuv420p \ + -c:a aac \ + -b:a 384k \ + -movflags +faststart \ + "$youtube_file" 2>/tmp/ffmpeg_error.log + + if [ $? -eq 0 ]; then + file_size=$(du -h "$youtube_file" | cut -f1) + notify-send "YouTube Conversion Success" "$(basename "$video_file") → $(basename "$youtube_file") ($file_size)" + else + error=$(cat /tmp/ffmpeg_error.log | tail -n 5) + notify-send "YouTube Conversion Failed" "Error converting $(basename "$video_file"): $error" + fi + ;; + + "gif") + # Check if ffmpeg is installed + if ! command -v ffmpeg >/dev/null 2>&1; then + notify-send "Error" "ffmpeg is not installed. Please install it to use this feature." + exit 1 + fi + + # Resolve the video file to convert + video_file=$(resolve_video_file "gif" "$FILE_PARAM") + + if [ -z "$video_file" ] || [ ! -f "$video_file" ]; then + if [ -n "$FILE_PARAM" ]; then + notify-send "GIF Conversion Error" "File not found: $FILE_PARAM" + else + notify-send "GIF Conversion Error" "No video files found in Recordings folder" + fi + exit 1 + fi + + base_filename=$(basename "$video_file") + + # Skip files already GIFs + if [[ $base_filename == *.gif ]]; then + notify-send "GIF Conversion Skipped" "File is already a GIF: $(basename "$video_file")" + exit 0 + fi + + # Create GIF filename + gif_file="${video_file%.*}.gif" + + # Check if GIF version doesn't already exist + if [ -f "$gif_file" ]; then + notify-send "GIF Conversion Skipped" "GIF version already exists: $(basename "$gif_file")" + exit 0 + fi + + notify-send "Converting to GIF" "Processing: $(basename "$video_file")" + + # Get video dimensions for scaling + width=$(ffprobe -v error -select_streams v:0 -show_entries stream=width -of csv=s=x:p=0 "$video_file") + + # Scale down if wider than 800px to keep file size reasonable + if [ "$width" -gt 800 ]; then + scale_filter="scale=800:-1:flags=lanczos," + else + scale_filter="" + fi + + # Create high-quality GIF with optimized palette + ffmpeg -i "$video_file" \ + -vf "${scale_filter}fps=15,split[s0][s1];[s0]palettegen=max_colors=256:stats_mode=diff[p];[s1][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle" \ + -loop 0 \ + "$gif_file" 2>/tmp/ffmpeg_error.log + + if [ $? -eq 0 ]; then + file_size=$(du -h "$gif_file" | cut -f1) + notify-send "GIF Conversion Success" "$(basename "$video_file") → $(basename "$gif_file") ($file_size)" + send_recording_notification "$gif_file" + else + error=$(cat /tmp/ffmpeg_error.log | tail -n 5) + notify-send "GIF Conversion Failed" "Error converting $(basename "$video_file"): $error" + fi + ;; + + *) + echo "Error: Invalid convert format '$TARGET'" + usage + ;; + esac + ;; + +*) + echo "Error: Invalid command '$COMMAND'" + usage + ;; +esac diff --git a/services/__init__.py b/services/__init__.py index cdba666..3f03f12 100644 --- a/services/__init__.py +++ b/services/__init__.py @@ -1,10 +1,4 @@ -from fabric.audio import Audio -from .brightness import Brightness -from .network import NetworkClient -from .notification import * -from .powerprofile import * - -network_client = NetworkClient() -brightness = Brightness() -audio = Audio() -notification_service = CustomNotifications() +""" +Modus services package. +Contains background services and utilities for the shell. +""" diff --git a/services/auth.py b/services/auth.py new file mode 100644 index 0000000..3a7b070 --- /dev/null +++ b/services/auth.py @@ -0,0 +1,348 @@ +import json +import os +import subprocess +import time +from datetime import datetime +from pathlib import Path +from urllib.parse import parse_qs, urlparse + +import pyotp +from PIL import Image +from pyzbar.pyzbar import decode + +import config.data as data + + +def get_otp_file_path(): + """Returns the path to the OTP file inside the cache directory.""" + cache_dir = Path(data.CACHE_DIR) / "otp" + # Create the directory if it doesn't exist + cache_dir.mkdir(parents=True, exist_ok=True) + + file_path = cache_dir / "otp_codes.json" + + # Create the file if it doesn't exist + if not file_path.exists(): + with open(file_path, "w") as f: + json.dump([], f) + print(f"File {file_path} created successfully!") + + return file_path + + +def capture_selected_area(filename="/tmp/screenshot.png"): + """ + Uses slurp to let the user select an area of the screen, + then captures that area using grim. + """ + try: + # slurp returns coordinates in format: x,y widthxheight (e.g. 100,200 300x400) + result = subprocess.run(["slurp"], check=True, capture_output=True, text=True) + geometry = result.stdout.strip() + if not geometry: + print("No area selected with slurp.") + return None + except subprocess.CalledProcessError as e: + print("Error selecting area with slurp:", e) + return None + + try: + # grim uses -g to capture a specific region + subprocess.run(["grim", "-g", geometry, filename], check=True) + except subprocess.CalledProcessError as e: + print("Error capturing screenshot with grim:", e) + return None + + return filename + + +def read_and_save_to_json(): + # Get the full path to the JSON file + json_file = get_otp_file_path() + + screenshot = capture_selected_area() + if screenshot is None: + print("Failed to capture the selected area.") + return False + + # Short delay to ensure the file is written + time.sleep(1) + + # Open the captured image + try: + img = Image.open(screenshot) + except Exception as e: + print("Error opening the image:", e) + return False + + # Decode QR Code(s) from the image + decoded_objects = decode(img) + if not decoded_objects: + print("No QR Code detected in the selected area.") + return False + + results = [] + + for obj in decoded_objects: + data = obj.data.decode("utf-8") + print("QR Code detected:", data) + + result_entry = {"timestamp": datetime.now().isoformat(), "qr_data": data} + + if data.startswith("otpauth://"): + parsed = urlparse(data) + query = parse_qs(parsed.query) + + # Extract the secret properly + secret = query.get("secret", [None])[0] + issuer_from_query = query.get("issuer", [None])[0] + + # Extract the label (path), which may contain issuer and account + label = parsed.path.lstrip("/") if parsed.path else "" + account_name = label + issuer_from_path = None + + if ":" in label: + parts = label.split(":", 1) + issuer_from_path = parts[0] + account_name = parts[1] + + # Prefer issuer from path if available, otherwise use from query + issuer = issuer_from_path or issuer_from_query + + # Extract the period (default is 30 seconds) + period = int(query.get("period", ["30"])[0]) + + # Create TOTP object with the correct interval + totp = pyotp.TOTP(secret, interval=period) + current_otp = totp.now() + print(f"Generated OTP: {current_otp} (valid for {period} seconds)") + + result_entry.update( + { + "type": "otp", + "secret": secret, + "issuer": issuer, + "account_name": account_name, + } + ) + else: + result_entry["type"] = "unknown" + print("Unrecognized format. Expected a URI like otpauth://") + return False + + results.append(result_entry) + + # Load existing data if the file exists + existing_data = [] + if os.path.exists(json_file): + try: + with open(json_file, "r") as f: + existing_data = json.load(f) + except json.JSONDecodeError: + print(f"Error reading the file {json_file}. Creating a new one.") + + # Append new results + existing_data.extend(results) + + # Save to JSON file + with open(json_file, "w") as f: + json.dump(existing_data, f, indent=4) + + print(f"OTP data saved to {json_file}") + return True + + +def CodeOTP(uri): + parsed = urlparse(uri) + query = parse_qs(parsed.query) + secret = query.get("secret", [None])[0] + + if secret is None: + return None + else: + totp = pyotp.TOTP(secret) + return totp.now() + + +# TOTP/OTP utility functions +def generate_totp(secret: str) -> str: + """Generate TOTP code from secret.""" + try: + return pyotp.TOTP(secret).now() + except Exception as e: + print(f"Error generating TOTP: {e}") + return None + + +def get_time_remaining() -> int: + """Get seconds remaining until next token refresh.""" + return 30 - (int(time.time()) % 30) + + +def get_time_remaining_with_blink() -> str: + """Get time remaining with blinking effect.""" + time_remaining = get_time_remaining() + current_second = int(time.time()) + should_blink = current_second % 2 == 0 + + if should_blink: + return f"<span alpha='30%'>{time_remaining}s</span>" + else: + return f"{time_remaining}s" + + +def validate_base32_secret(secret: str) -> dict: + """Validate and clean Base32 secret.""" + import base64 + import re + + try: + # Clean up the secret - remove spaces, dashes, and convert to uppercase + clean_secret = secret.replace(" ", "").replace("-", "").replace("_", "").upper() + + # Remove any non-base32 characters + clean_secret = re.sub(r"[^A-Z2-7]", "", clean_secret) + + # Add padding if needed (Base32 requires padding to multiple of 8) + while len(clean_secret) % 8 != 0: + clean_secret += "=" + + # Validate Base32 format + try: + base64.b32decode(clean_secret) + except Exception as e: + return {"success": False, "error": f"Invalid Base32 secret: {str(e)}"} + + # Test if the secret can generate a valid TOTP + try: + test_totp = pyotp.TOTP(clean_secret) + test_code = test_totp.now() + if not test_code or len(test_code) != 6: + raise ValueError("Generated invalid TOTP code") + except Exception as e: + return {"success": False, "error": f"Cannot generate TOTP: {str(e)}"} + + return {"success": True, "secret": clean_secret} + except Exception as e: + return {"success": False, "error": f"Unexpected error: {str(e)}"} + + +def parse_otpauth_uri(uri: str, account_name: str = "") -> dict: + """Parse otpauth URI and extract account information.""" + try: + parsed = urlparse(uri) + if parsed.scheme != "otpauth" or parsed.netloc != "totp": + return { + "success": False, + "error": "Only otpauth://totp/ URIs are supported", + } + + if not account_name: + account_path = parsed.path.lstrip("/") + if ":" in account_path: + issuer, extracted_name = account_path.split(":", 1) + account_name = extracted_name + else: + account_name = account_path + + params = parse_qs(parsed.query) + secret = params.get("secret", [""])[0] + issuer = params.get("issuer", [""])[0] + algorithm = params.get("algorithm", ["SHA1"])[0] + digits = int(params.get("digits", ["6"])[0]) + period = int(params.get("period", ["30"])[0]) + + if not secret: + return {"success": False, "error": "No secret found in URI"} + + return { + "success": True, + "account_name": account_name, + "secret": secret, + "issuer": issuer, + "algorithm": algorithm, + "digits": digits, + "period": period, + } + except Exception as e: + return {"success": False, "error": f"Error parsing otpauth URI: {str(e)}"} + + +def scan_qr_and_add_account(account_name: str, secrets_file_path: str) -> dict: + """Scan QR code and add OTP account to secrets file.""" + try: + # Capture QR code from screen + screenshot_path = capture_selected_area() + if not screenshot_path: + return {"success": False, "error": "QR scan cancelled or failed"} + + # Decode QR code + try: + img = Image.open(screenshot_path) + decoded_objects = decode(img) + + if not decoded_objects: + return { + "success": False, + "error": "No QR code detected in selected area", + } + + # Process the first QR code found + qr_data = decoded_objects[0].data.decode("utf-8") + print(f"QR Code detected: {qr_data}") + + if qr_data.startswith("otpauth://"): + # Parse otpauth URI + result = parse_otpauth_uri(qr_data, account_name) + if not result["success"]: + return result + + # Load existing secrets + secrets = {} + if os.path.exists(secrets_file_path): + try: + with open(secrets_file_path, "r", encoding="utf-8") as f: + secrets = json.load(f) + except Exception as e: + print(f"Error loading secrets: {e}") + + # Add new account + secrets[result["account_name"]] = { + "secret": result["secret"], + "issuer": result["issuer"], + "algorithm": result["algorithm"], + "digits": result["digits"], + "period": result["period"], + } + + # Save secrets + try: + os.makedirs(os.path.dirname(secrets_file_path), exist_ok=True) + with open(secrets_file_path, "w", encoding="utf-8") as f: + json.dump(secrets, f, indent=2) + except Exception as e: + return { + "success": False, + "error": f"Error saving secrets: {str(e)}", + } + + display_name = ( + f"{result['issuer']} - {result['account_name']}" + if result["issuer"] + else result["account_name"] + ) + return { + "success": True, + "account_name": result["account_name"], + "display_name": display_name, + "message": f"Successfully added OTP account: {display_name}", + } + else: + return {"success": False, "error": "QR code is not an otpauth URI"} + + except Exception as e: + return {"success": False, "error": f"Error processing QR code: {str(e)}"} + + except Exception as e: + return {"success": False, "error": f"Error during QR scan: {str(e)}"} diff --git a/services/battery.py b/services/battery.py new file mode 100644 index 0000000..b6b8e3c --- /dev/null +++ b/services/battery.py @@ -0,0 +1,169 @@ +from fabric.core import Property, Service, Signal +from pydbus import SystemBus +import psutil +from gi.repository import GLib + +DeviceState = { + 0: "UNKNOWN", + 1: "CHARGING", + 2: "DISCHARGING", + 3: "EMPTY", + 4: "FULLY_CHARGED", + 5: "PENDING_CHARGE", + 6: "PENDING_DISCHARGE", +} + + +class Battery(Service): + @staticmethod + def seconds_to_hours_minutes(seconds): + hours = seconds // 3600 + minutes = (seconds % 3600) // 60 + return f"{hours}h {minutes}m" if hours else f"{minutes}m" + + @Signal + def changed(self) -> None: ... + + @Signal + def profile_changed(self, value: str) -> None: ... + + @Property(int, "readable") + def percentage(self): + if self._use_psutil_fallback: + if self._psutil_battery: + return int(self._psutil_battery.percent) + return 0 + return int(self._battery.Percentage) + + @Property(str, "readable") + def temperature(self): + if self._use_psutil_fallback: + return "N/A" # psutil doesn't provide temperature + return ( + f"{self._battery.Temperature}°C" + if hasattr(self._battery, "Temperature") + else "N/A" + ) + + @Property(str, "readable") + def time_to_empty(self): + if self._use_psutil_fallback: + if self._psutil_battery and hasattr(self._psutil_battery, 'secsleft'): + return self.seconds_to_hours_minutes(self._psutil_battery.secsleft) + return "N/A" + return self.seconds_to_hours_minutes(getattr(self._battery, "TimeToEmpty", 0)) + + @Property(str, "readable") + def time_to_full(self): + if self._use_psutil_fallback: + return "N/A" # psutil doesn't provide time to full + return self.seconds_to_hours_minutes(getattr(self._battery, "TimeToFull", 0)) + + @Property(str, "readable") + def icon_name(self): + if self._use_psutil_fallback: + return "battery" # Generic icon name for psutil fallback + return self._battery.IconName + + @Property(str, "readable") + def state(self): + if self._use_psutil_fallback: + if self._psutil_battery: + # psutil returns power_plugged boolean, convert to state + if self._psutil_battery.power_plugged: + if self._psutil_battery.percent >= 100: + return "FULLY_CHARGED" + else: + return "CHARGING" + else: + return "DISCHARGING" + return "UNKNOWN" + return DeviceState.get(self._battery.State, "UNKNOWN") + + @Property(str, "readable") + def capacity(self): + if self._use_psutil_fallback: + return "N/A" # psutil doesn't provide capacity info + return f"{int(self._battery.Capacity)}%" + + @Property(bool, "readable", default_value=False) + def is_present(self): + if self._use_psutil_fallback: + return self._psutil_battery is not None + return self._battery.IsPresent + + @Property(str, "readable") + def power_profile(self): + if self._profile_proxy: + return self._profile_proxy.ActiveProfile + return None + + def set_power_profile(self, profile: str) -> bool: + if not self._profile_proxy: + return False + if profile not in [p.Profile for p in self._profile_proxy.Profiles]: + return False + try: + self._profile_proxy.ActiveProfile = profile + self.profile_changed.emit(profile) + self.changed.emit() + return True + except Exception: + return False + + def __init__(self): + super().__init__() + self._bus = SystemBus() + self._use_psutil_fallback = False + self._psutil_battery = None + + # Battery device + try: + self._battery = self._bus.get( + "org.freedesktop.UPower", "/org/freedesktop/UPower/devices/battery_BAT0" + ) + self._battery.onPropertiesChanged = self.handle_battery_change + except Exception: + # Fallback to psutil if UPower is not available + self._use_psutil_fallback = True + try: + self._psutil_battery = psutil.sensors_battery() + if self._psutil_battery is None: + return # No battery found + # Start periodic updates for psutil fallback + GLib.timeout_add_seconds(5, self._update_psutil_battery) + except Exception: + return # psutil battery not available either + + # PowerProfiles + try: + self._profile_proxy = self._bus.get( + "net.hadess.PowerProfiles", "/net/hadess/PowerProfiles" + ) + # Use onPropertiesChanged for consistency with battery device + self._profile_proxy.onPropertiesChanged = ( + lambda _, changed, __: self._handle_profile_props_changed(changed) + ) + except Exception: + self._profile_proxy = None + + self.changed.emit() + + def _update_psutil_battery(self): + """Update psutil battery data periodically""" + try: + self._psutil_battery = psutil.sensors_battery() + self.changed.emit() + except Exception: + pass # Continue trying + return True # Keep the timeout active + + def _handle_profile_props_changed(self, changed): + """Internal handler for property changes that processes only the changed properties""" + if "ActiveProfile" in changed: + new_profile = changed["ActiveProfile"] + self.profile_changed.emit(new_profile) + self.changed.emit() + + def handle_battery_change(self, iface, changed, invalidated): + self.changed.emit() diff --git a/services/brightness.py b/services/brightness.py index 49e7040..88d0d2e 100644 --- a/services/brightness.py +++ b/services/brightness.py @@ -1,92 +1,73 @@ import os -import subprocess from fabric.core.service import Property, Service, Signal -from fabric.utils import exec_shell_command, monitor_file +from fabric.utils import exec_shell_command_async, monitor_file from gi.repository import GLib -from loguru import logger # Import loguru logger +from loguru import logger -# Helper function to execute brightnessctl asynchronously def exec_brightnessctl_async(args: str): - # Executes brightnessctl command asynchronously, ensuring no resource leaks. - try: - # Use subprocess.Popen to run the command without blocking - process = subprocess.Popen( - f"brightnessctl {args}", - shell=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - - # Wait for the process to complete - stdout, stderr = process.communicate() - - if process.returncode != 0: - logger.error(f"Error executing brightnessctl: {stderr.decode().strip()}") - else: - logger.debug( - f"brightnessctl output: {stdout.decode().strip()}" - ) # Optional: Log the output - except Exception as e: - logger.exception(f"Exception in exec_brightnessctl_async: {e}") + exec_shell_command_async(f"brightnessctl {args}", lambda _: None) # Discover screen backlight device try: - screen_device = str(exec_shell_command("ls -w1 /sys/class/backlight")).split("\n")[ - 0 - ] -except IndexError: - screen_device = None - - -class NoBrightnessError(ImportError): - # Raised when brightness-related dependencies are missing. - def __init__(self, *args): - super().__init__( - "Brightness control not available or missing dependencies", *args - ) + screen_device = os.listdir("/sys/class/backlight") + screen_device = screen_device[0] if screen_device else "" +except FileNotFoundError: + logger.error("No backlight devices found, brightness control disabled") + screen_device = "" class Brightness(Service): - # Service to manage screen brightness levels. + """Service to manage screen brightness levels.""" + + instance = None + + @staticmethod + def get_initial(): + if Brightness.instance is None: + Brightness.instance = Brightness() + + return Brightness.instance @Signal def screen(self, value: int) -> None: """Signal emitted when screen brightness changes.""" - pass # Implement as needed for your application + # Implement as needed for your application def __init__(self, **kwargs): super().__init__(**kwargs) - if not screen_device: - raise NoBrightnessError() - # Path for screen backlight control self.screen_backlight_path = f"/sys/class/backlight/{screen_device}" # Initialize maximum brightness level - self.max_screen = self._read_max_brightness(self.screen_backlight_path) + self.max_screen = self.do_read_max_brightness(self.screen_backlight_path) + + if screen_device == "": + return # Monitor screen brightness file self.screen_monitor = monitor_file(f"{self.screen_backlight_path}/brightness") + self.screen_monitor.connect( "changed", lambda _, file, *args: self.emit( - "screen", round(int(file.load_bytes()[0].get_data())) + "screen", + round(int(file.load_bytes()[0].get_data())), ), ) # Log the initialization of the service logger.info(f"Brightness service initialized for device: {screen_device}") - def _read_max_brightness(self, path: str) -> int: + def do_read_max_brightness(self, path: str) -> int: # Reads the maximum brightness value from the specified path. max_brightness_path = os.path.join(path, "max_brightness") if os.path.exists(max_brightness_path): with open(max_brightness_path) as f: - return int(f.read().strip()) + return int(f.readline()) return -1 # Return -1 if file doesn't exist, indicating an error. @Property(int, "read-write") @@ -95,7 +76,7 @@ class Brightness(Service): brightness_path = os.path.join(self.screen_backlight_path, "brightness") if os.path.exists(brightness_path): with open(brightness_path) as f: - return int(f.read().strip()) + return int(f.readline()) logger.warning(f"Brightness file does not exist: {brightness_path}") return -1 # Return -1 if file doesn't exist, indicating error. @@ -108,7 +89,6 @@ class Brightness(Service): try: exec_brightnessctl_async(f"--device '{screen_device}' set {value}") self.emit("screen", int((value / self.max_screen) * 100)) - logger.info(f"Set screen brightness to {value} (out of {self.max_screen})") except GLib.Error as e: logger.error(f"Error setting screen brightness: {e.message}") except Exception as e: diff --git a/services/mpris.py b/services/mpris.py new file mode 100644 index 0000000..99dd76a --- /dev/null +++ b/services/mpris.py @@ -0,0 +1,244 @@ +from gi.repository import Playerctl, GLib +from fabric.utils import bulk_connect +from fabric.core.service import Service, Signal +from fabric import Fabricator + +from config.data import ALLOWED_PLAYERS + +import gi + +gi.require_version("Playerctl", "2.0") + + +class PlayerService(Service): + @Signal + def shuffle_toggle(self, player: Playerctl.Player, status: bool) -> None: ... + + @Signal + def meta_change(self, metadata: GLib.Variant, player: Playerctl.Player) -> None: ... + + @Signal + def pause(self) -> None: ... + + @Signal + def play(self) -> None: ... + + @Signal + def track_position(self, pos: float, dur: float) -> None: ... + + def __init__(self, player: Playerctl.Player, **kwargs): + self._player: Playerctl.Player = player + super().__init__(**kwargs) + if player.props.player_name in ALLOWED_PLAYERS: + self._player.connect("playback-status::playing", self.on_play) + self._player.connect("playback-status::paused", self.on_pause) + self._player.connect("shuffle", self.on_shuffle) + self._player.connect("metadata", self.on_metadata) + self._player.connect("seeked", self.on_seeked) + + self.status = self._player.props.playback_status + self.pos_fabricator = Fabricator( + interval=1000, # 1s + poll_from=lambda f, *_: self._safe_get_position(), + on_changed=lambda f, *_: self.fabricating(), + ) + # Add state tracking to prevent repeated operations + self._fabricator_running = False + self._cleanup_done = False + self.poll_progress() + + def _safe_get_position(self): + """Safely get player position with error handling""" + try: + if not self._player or self._cleanup_done: + return 0 + return self._player.get_position() + except Exception: + # Player is no longer available, stop the fabricator + if hasattr(self, 'pos_fabricator') and self.pos_fabricator and self._fabricator_running: + try: + self.pos_fabricator.stop() + self._fabricator_running = False + except Exception: + pass + return 0 + + def cleanup(self): + """Clean up resources when service is no longer needed""" + if self._cleanup_done: + return # Already cleaned up + + try: + if hasattr(self, 'pos_fabricator') and self.pos_fabricator: + if self._fabricator_running: + self.pos_fabricator.stop() + self._fabricator_running = False + self.pos_fabricator = None + self._cleanup_done = True + except Exception as e: + print(f"[DEBUG] Error during PlayerService cleanup: {e}") + + def on_seeked(self, player, position): + if (not self._cleanup_done and + self.status.value_name == "PLAYERCTL_PLAYBACK_STATUS_PLAYING" and + not self._fabricator_running): + try: + self.pos_fabricator.start() + self._fabricator_running = True + except Exception: + pass # Ignore if already started + + def set_position(self, pos: float): + if not self._cleanup_done and self._fabricator_running: + try: + self.pos_fabricator.stop() + self._fabricator_running = False + except Exception: + pass # Ignore if already stopped + + micro_pos = int(pos * 1_000_000) + try: + self._player.set_position(micro_pos) + except GLib.Error as e: + print(f"Failed to seek: {e}") + + def poll_progress(self): + if self._cleanup_done: + return # Don't operate on cleaned up fabricator + + if self.status.value_name == "PLAYERCTL_PLAYBACK_STATUS_PLAYING": + if not self._fabricator_running: + try: + self.pos_fabricator.start() + self._fabricator_running = True + except Exception: + pass # Ignore if already started + else: + if self._fabricator_running: + try: + self.pos_fabricator.stop() + self._fabricator_running = False + except Exception: + pass # Ignore if already stopped + + def fabricating(self): + try: + # Check if cleanup has been done + if self._cleanup_done: + return + + # Validate player object + if not self._player or not hasattr(self._player, 'get_position'): + return + + # Get position safely + try: + pos = self._player.get_position() / 1_000_000 # seconds + except Exception: + # Player is no longer available, stop fabricator + if self._fabricator_running: + try: + self.pos_fabricator.stop() + self._fabricator_running = False + except Exception: + pass + return + + # Get duration safely from multiple sources + dur = 0.0 # Default to 0 + + if (hasattr(self._player, 'props') and + hasattr(self._player.props, 'metadata') and + self._player.props.metadata): + + metadata = self._player.props.metadata + + # Try mpris:length first (most common) + try: + raw_dur = metadata["mpris:length"] + if raw_dur and raw_dur > 0: + dur = float(raw_dur) / 1_000_000 # seconds + except (KeyError, Exception): + pass + + # Fallback to xesam:duration (alternative field) + if dur <= 0: + try: + raw_dur = metadata["xesam:duration"] + if raw_dur and raw_dur > 0: + dur = float(raw_dur) / 1_000_000 # seconds + except (KeyError, Exception): + pass + + # Try to get duration from player directly (some players support this) + if dur <= 0: + try: + if hasattr(self._player, 'get_metadata'): + player_metadata = self._player.get_metadata() + if player_metadata: + raw_dur = player_metadata["mpris:length"] + if raw_dur and raw_dur > 0: + dur = float(raw_dur) / 1_000_000 + except (KeyError, Exception): + pass + + # Validate values before emitting signal + # Allow dur=0 for position-only tracking + if pos is not None and dur is not None and pos >= 0 and dur >= 0: + self.track_position(pos, dur) + + except Exception: + pass + + def on_play(self, player, status): + self.status = player.props.playback_status + self.poll_progress() + self.play() + + def on_pause(self, player, status): + self.poll_progress() + self.pause() + + def on_shuffle(self, player, status): + self.shuffle_toggle(player, status) + + def on_metadata(self, player, metadata): + keys = metadata.keys() + self.meta_change(metadata, player) + + +class PlayerManager(Service): + @Signal + def new_player(self, player: Playerctl.Player) -> Playerctl.Player: ... + + @Signal + def player_vanish(self, player: Playerctl.Player) -> None: ... + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._manager = Playerctl.PlayerManager() + self._manager.connect("name-appeared", self._on_name_appeared, self._manager) + self._manager.connect( + "player-vanished", self._on_player_vanished, self._manager + ) + + self._players = {} + + def init_all_players(self): + # invoked in the UI + for player_obj in self._manager.props.player_names: + name_str = player_obj.name + if name_str in ALLOWED_PLAYERS: + player = Playerctl.Player.new_from_name(player_obj) + self._manager.manage_player(player) + self.new_player(player) + + def _on_name_appeared(self, sender, name, manager): + name_str = name.name + if name_str in ALLOWED_PLAYERS: + player = Playerctl.Player.new_from_name(name) + self._manager.manage_player(player) + self.new_player(player) + + def _on_player_vanished(self, sender, player, manager): + self.player_vanish(player) \ No newline at end of file diff --git a/services/network.py b/services/network.py index 04ebcae..1124b4a 100644 --- a/services/network.py +++ b/services/network.py @@ -1,16 +1,301 @@ -from typing import Any, List, Literal +from typing import Optional import gi from fabric.core.service import Property, Service, Signal -from fabric.utils import bulk_connect, exec_shell_command_async -from gi.repository import Gio +from fabric.utils import bulk_connect, get_enum_member_name, snake_case_to_kebab_case +from gi.repository import NM, GLib from loguru import logger -try: - gi.require_version("NM", "1.0") - from gi.repository import NM -except ValueError: - logger.error("Failed to start network manager") +gi.require_version("NM", "1.0") # Ensure the correct version is loaded + + +class NetworkClient(Service): + """A service to manage network devices""" + + @Signal + def wifi_device_added(self) -> None: ... + + @Signal + def ethernet_device_added(self) -> None: ... + + @Signal + def wifi_device_removed(self) -> None: ... + + @Signal + def ethernet_device_removed(self) -> None: ... + + @Signal + def changed(self) -> None: ... + + @Signal + def ready(self) -> None: ... + + @Property(bool, "readable", default_value=False) + def is_ready(self) -> bool: + """Returns whether the client is ready to use.""" + return self._client is not None + + @Property(list, "readable") + def connections(self) -> Optional[list]: + """Returns the active connections, if available.""" + return self._client.get_property("connections") if self._client else None + + @Property(object, "readable") + def wifi_device(self) -> Optional[object]: + """Returns the WiFi device if available.""" + return self._wifi_device + + @Property(object, "readable") + def ethernet_device(self) -> Optional[object]: + """Returns the Ethernet device if available.""" + return self._ethernet_device + + @Property(str, "readable") + def primary_connection(self) -> Optional[str]: + """Returns the primary connection if available.""" + return self._client.get_property("primary_connection") if self._client else None + + @Property(str, "readable") + def active_connection(self) -> Optional[str]: + """Returns the active connection if available.""" + return self._client.get_property("active_connection") if self._client else None + + @Property(str, "readable") + def state(self) -> str: + """Returns the current network state.""" + if not self._client: + return "disconnected" + return snake_case_to_kebab_case( + get_enum_member_name( + self._client.get_property("state"), default="disconnected" + ) + ) + + @Property(str, "readable") + def connectivity(self) -> str: + """Returns the connectivity state.""" + if not self._client: + return "disconnected" + return snake_case_to_kebab_case( + get_enum_member_name( + self._client.get_property("connectivity"), default="disconnected" + ) + ) + + @Property(list, "readable") + def devices(self) -> Optional[list]: + """Returns the list of network devices if available.""" + return self._client.get_property("devices") if self._client else None + + @Property(str, "readable") + def hostname(self) -> Optional[str]: + """Returns the hostname if available.""" + return self._client.get_property("hostname") if self._client else None + + @Property(bool, "read-write", default_value=False) + def networking_enabled(self) -> bool: + """Checks if networking is enabled.""" + return ( + self._client.get_property("networking_enabled") if self._client else False + ) + + @networking_enabled.setter + def networking_enabled(self, value: bool): + """Sets the networking state if the client is available.""" + if self._client: + self._client.set_property("networking_enabled", value) + + @Property(bool, "read-write", default_value=False) + def wireless_enabled(self) -> bool: + """Checks if wireless networking is enabled.""" + return self._client.get_property("wireless_enabled") if self._client else False + + @wireless_enabled.setter + def wireless_enabled(self, value: bool): + """Sets the wireless networking state if the client is available.""" + if self._client: + self._client.set_property("wireless_enabled", value) + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + self._client: NM.Client = None + self._wifi_device: Wifi | None = None + self._ethernet_device: Ethernet | None = None + + logger.info("[Network] Initializing client asynchronously...") + + # Start async NM client initialization + NM.Client.new_async(None, self.on_client_ready) + + def on_client_ready(self, source, result): + """Callback when NM.Client is ready.""" + try: + self._client = NM.Client.new_finish(result) # Retrieve client instance + logger.info("[Network] NM.Client initialized successfully!") + + # Connect signals + bulk_connect( + self._client, + { + "device-added": lambda _, device: self.on_device_added( + device=device + ), + "device-removed": lambda _, device: self.on_device_removed( + device=device + ), + "notify::state": lambda *args: self.notifier("state"), + "notify::networking-enabled": lambda *args: self.notifier( + "networking-enabled" + ), + "notify::wireless-enabled": lambda *args: self.notifier( + "wireless-enabled" + ), + # "notify::primary-connection": lambda *args: self.notifier('primary-connection'), + # "notify::active-connection": lambda *args: self.notifier('active-connection'), + # "active-connection-added": lambda *args: self.emit("changed"), + # "active-connection-removed": lambda *args: self.emit("changed") + }, + ) + + # Process devices AFTER client is ready + for device in self.do_get_raw_devices(): + self.on_device_added(device=device) + + self.notify("state") + self.notify("networking-enabled") + self.notify("wireless-enabled") + self.notify("is-ready") + self.ready.emit() + + except Exception as e: + logger.error(f"[Network] Error initializing NM.Client: {e}") + + def do_get_raw_devices(self) -> list[NM.Device]: + return [ + dev + for dev in self.devices + if dev.get_device_type() in (NM.DeviceType.WIFI, NM.DeviceType.ETHERNET) + ] + + def on_device_added(self, device): + device_type = device.get_device_type() + if device_type == NM.DeviceType.WIFI and not self._wifi_device: + logger.info("[Network] WiFi device detected, initializing...") + self._wifi_device = Wifi(client=self, device=device) + self.wifi_device_added.emit() + + elif device_type == NM.DeviceType.ETHERNET and not self._ethernet_device: + logger.info("[Network] Ethernet device detected, initializing...") + self._ethernet_device = Ethernet(client=self, device=device) + self.ethernet_device_added.emit() + + def on_device_removed(self, device): + if device == self._wifi_device: + logger.info("[Network] WiFi device removed.") + self._wifi_device = None + self.wifi_device_removed.emit() + + elif device == self._ethernet_device: + logger.info("[Network] Ethernet device removed.") + self._ethernet_device = None + self.ethernet_device_removed.emit() + + def toggle_network(self): + """Enable or disable Network""" + self.networking_enabled = not self.networking_enabled + + def deactivate_connection(self, connection): + """Disconnect""" + self._client.deactivate_connection_async(connection, None, None) + + def notifier(self, name): + self.notify(name) + self.emit("changed") + + +class AccessPoint(Service): + """A service to manage access points""" + + @Signal + def changed(self) -> None: ... + + @Property(object, "readable") + def device(self) -> object: + return self._device + + @Property(int, "readable") + def strength(self) -> int: + return self._ap.get_property("strength") + + @Property(int, "readable") + def frequency(self) -> int: + return self._ap.get_property("frequency") + + @Property(str, "readable") + def bssid(self) -> str: + return self._ap.get_property("bssid") if self._ap else None + + @Property(str, "readable") + def hw_address(self) -> str: + return self._ap.get_property("hw_address") + + @Property(str, "readable") + def ssid(self) -> str: + ssid = self._ap.get_ssid() + return NM.utils_ssid_to_utf8(ssid.get_data()) if ssid else "Unknown" + + @Property(str, "readable") + def icon(self) -> str: + return { + 80: "network-wireless-signal-excellent-symbolic", + 60: "network-wireless-signal-good-symbolic", + 40: "network-wireless-signal-ok-symbolic", + 20: "network-wireless-signal-weak-symbolic", + 00: "network-wireless-signal-none-symbolic", + }.get( + min(80, 20 * round(self.strength / 20)), + "network-wireless-no-route-symbolic", + ) + + @Property(bool, "readable", default_value=False) + def requires_password(self) -> bool: + ssid = self.ssid + settings = self._client.connections + connection = None + for setting in settings: + wifi_setting = setting.get_setting_wireless() + if ( + wifi_setting + and NM.utils_ssid_to_utf8(wifi_setting.get_ssid().get_data()) == ssid + ): + connection = setting + break + if not connection: + return bool(self._ap.get_wpa_flags() or self._ap.get_rsn_flags()) + return False + + @Property(bool, "readable", default_value=False) + def is_active(self) -> bool: + if self._device.active_access_point: + return self.bssid == self._device.active_access_point.get_bssid() + return False + + def __init__(self, device: "Wifi", ap: NM.AccessPoint, **kwargs): + super().__init__(**kwargs) + self._client: NetworkClient = device.client + self._device: Wifi = device + self._ap: NM.AccessPoint = ap + + self._ap.connect("notify::strength", lambda *args: self.notifier("strength")) + self._device.connect( + "notify::active-access-point", lambda *args: self.notifier("is-active") + ) + + def notifier(self, name: str, *args): + self.notify(name) + self.emit("changed") + return class Wifi(Service): @@ -20,186 +305,242 @@ class Wifi(Service): def changed(self) -> None: ... @Signal - def enabled(self) -> bool: ... + def ap_added(self, ap: AccessPoint) -> None: ... @Signal - def scanning(self, is_scanning: bool) -> None: ... + def ap_removed(self, ap: AccessPoint) -> None: ... - def __init__(self, client: NM.Client, device: NM.DeviceWifi, **kwargs): - self._client: NM.Client = client - self._device: NM.DeviceWifi = device - self._ap: NM.AccessPoint | None = None - self._ap_signal: int | None = None + @Property(NetworkClient, "readable") + def client(self) -> NetworkClient: + """Returns the client""" + return self._client + + @Property(bool, "read-write", default_value=False) + def wireless_enabled(self) -> bool: + """Returns if the wifi is enabled""" + return self._client.get_property("wireless_enabled") + + @wireless_enabled.setter + def wireless_enabled(self, value: bool): + return self._client.set_property("wireless_enabled", value) + + @Property(list[AccessPoint], "readable") + def access_points(self) -> list[AccessPoint]: + return sorted( + self._access_points.values(), key=lambda x: x.is_active, reverse=True + ) + + @Property(AccessPoint, "readable") + def active_access_point(self) -> Optional[AccessPoint]: + return self._active_access_point + + def __init__(self, client: NetworkClient, device: NM.DeviceWifi, **kwargs): super().__init__(**kwargs) + self._client: NetworkClient = client + self._device: NM.DeviceWifi = device + self._active_access_point: NM.AccessPoint | None = None + self._access_points: dict[str, AccessPoint] = {} + + bulk_connect( + self._device, + { + "notify::active-access-point": lambda *args: self.on_access_point_activated(), + "access-point-added": lambda _, ap: self.on_access_point_added(ap=ap), + "access-point-removed": lambda _, ap: self.on_access_point_removed( + ap=ap + ), + # "state-changed": lambda device, new, old, reason: self.on_state_changed(new), + }, + ) self._client.connect( - "notify::wireless-enabled", - lambda *args: self.notifier("enabled", args), + "notify::wireless-enabled", lambda *args: self.notifier("wireless-enabled") ) - if self._device: - bulk_connect( - self._device, - { - "notify::active-access-point": lambda *args: self._activate_ap(), - "access-point-added": lambda *args: self.emit("changed"), - "access-point-removed": lambda *args: self.emit("changed"), - "state-changed": lambda *args: self.ap_update(), - }, - ) - self._activate_ap() - def ap_update(self): + 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") - for sn in [ - "enabled", - "internet", - "strength", - "frequency", - "access-points", - "ssid", - "state", - "icon-name", - ]: - self.notify(sn) - def _activate_ap(self): - if self._ap: - self._ap.disconnect(self._ap_signal) - self._ap = self._device.get_active_access_point() - if not self._ap: - return + def do_get_access_points(self): + return self._device.get_access_points() - self._ap_signal = self._ap.connect( - "notify::strength", lambda *args: self.ap_update() - ) # type: ignore + def on_access_point_added(self, ap): + ssid = ap.get_ssid() + ssid = NM.utils_ssid_to_utf8(ssid.get_data()) if ssid else "Unknown" - def toggle_wifi(self): - self._client.wireless_set_enabled(not self._client.wireless_get_enabled()) + access_point: AccessPoint = AccessPoint(ap=ap, device=self) - # def set_active_ap(self, ap): - # self._device.access + 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): - """Start scanning for WiFi networks and emit scanning signal""" - if self._device: - self.emit("scanning", True) # Emit signal that scanning has started - self._device.request_scan_async( + self._device.request_scan_async( + None, + lambda device, result: [ + device.request_scan_finish(result), + self.notifier("access-points"), + ], + ) + logger.info("[Wifi] Scan started") + + def toggle_wifi(self): + """Enable or disable WiFi""" + self.wireless_enabled = not self.wireless_enabled + + def connect_to_wifi(self, ap: AccessPoint, password: str = None): + """Connect to a WiFi network.""" + + ssid = ap.ssid + + if ssid == "Unknown": + logger.error("Invalid access point data") + return False + + logger.info(f"Connecting to WiFi SSID: {ssid}") + + # Check for existing connections + settings = self._client.connections + connection = None + + for setting in settings: + wifi_setting = setting.get_setting_wireless() + if ( + wifi_setting + and NM.utils_ssid_to_utf8(wifi_setting.get_ssid().get_data()) == ssid + ): + connection = setting + break + + if not connection: + # Create a new connection profile + logger.info(f"Creating new WiFi connection for SSID '{ssid}'") + connection = NM.SimpleConnection.new() + + # Required connection settings + s_con = NM.SettingConnection.new() + s_con.set_property(NM.SETTING_CONNECTION_ID, ssid) + s_con.set_property(NM.SETTING_CONNECTION_TYPE, "802-11-wireless") + s_con.set_property( + NM.SETTING_CONNECTION_INTERFACE_NAME, self._device.get_iface() + ) # Set interface name + connection.add_setting(s_con) + + # Wireless settings + s_wifi = NM.SettingWireless.new() + s_wifi.set_property(NM.SETTING_WIRELESS_SSID, GLib.Bytes.new(ssid.encode())) + s_wifi.set_property( + NM.SETTING_WIRELESS_MODE, "infrastructure" + ) # Ensure mode is correct + connection.add_setting(s_wifi) + + # Security settings (only if password is required and provided) + if ap.requires_password: + if not password: + logger.error("Password required but not provided") + return False + + s_sec = NM.SettingWirelessSecurity.new() + s_sec.set_property(NM.SETTING_WIRELESS_SECURITY_KEY_MGMT, "wpa-psk") + s_sec.set_property(NM.SETTING_WIRELESS_SECURITY_PSK, password) + connection.add_setting(s_sec) + + # IPv4 settings + s_ipv4 = NM.SettingIP4Config.new() + s_ipv4.set_property("method", "auto") + connection.add_setting(s_ipv4) + + # IPv6 settings + s_ipv6 = NM.SettingIP6Config.new() + s_ipv6.set_property("method", "auto") + connection.add_setting(s_ipv6) + + # Callback for async connection + def on_connection_added(client, result, ssid, device): + try: + new_connection = client.add_connection_finish(result) + if not new_connection: + logger.error(f"Failed to create connection for '{ssid}'") + return + + logger.info(f"Connection for '{ssid}' added successfully") + + client.activate_connection_async( + new_connection, + device, + None, + None, + lambda c, r: logger.info(f"Connected to '{ssid}'") + if c + else logger.error(f"Failed to connect to '{ssid}'"), + ) + except Exception as e: + logger.error(f"Failed to add connection: {e}") + + # Save the new connection + self._client._client.add_connection_async( + connection, + True, None, - lambda device, result: [ - device.request_scan_finish(result), - self.emit( - "scanning", False - ), # Emit signal that scanning has stopped - ], + lambda c, r: on_connection_added(c, r, ssid, self._device), ) + # Activate the connection + self._client._client.activate_connection_async( + connection, + self._device, + None, + None, + lambda client, result: logger.info(f"Connected to '{ssid}'"), + ) + + return True + def notifier(self, name: str, *args): self.notify(name) self.emit("changed") return - @Property(bool, "read-write", default_value=False) - def enabled(self) -> bool: # type: ignore - return bool(self._client.wireless_get_enabled()) - - @enabled.setter - def enabled(self, value: bool): - self._client.wireless_set_enabled(value) - - @Property(int, "readable") - def strength(self): - return self._ap.get_strength() if self._ap else -1 - - @Property(str, "readable") - def icon_name(self): - if not self._ap: - return "network-wireless-disabled-symbolic" - - if self.internet == "activated": - 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._ap.get_strength() / 20)), - "network-wireless-no-route-symbolic", - ) - if self.internet == "activating": - return "network-wireless-acquiring-symbolic" - - return "network-wireless-offline-symbolic" - - @Property(int, "readable") - def frequency(self): - return self._ap.get_frequency() if self._ap else -1 - - @Property(int, "readable") - def internet(self): - return { - NM.ActiveConnectionState.ACTIVATED: "activated", - NM.ActiveConnectionState.ACTIVATING: "activating", - NM.ActiveConnectionState.DEACTIVATING: "deactivating", - NM.ActiveConnectionState.DEACTIVATED: "deactivated", - }.get( - self._device.get_active_connection().get_state(), - "unknown", - ) - - @Property(object, "readable") - def access_points(self) -> List[object]: - points: list[NM.AccessPoint] = self._device.get_access_points() - - def make_ap_dict(ap: NM.AccessPoint): - return { - "bssid": ap.get_bssid(), - # "address": ap.get_ - "last_seen": ap.get_last_seen(), - "ssid": NM.utils_ssid_to_utf8(ap.get_ssid().get_data()) - if ap.get_ssid() - else "Unknown", - "active-ap": self._ap, - "strength": ap.get_strength(), - "frequency": ap.get_frequency(), - "icon-name": { - 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(ap.get_strength() / 20)), - "network-wireless-no-route-symbolic", - ), - } - - return list(map(make_ap_dict, points)) - - @Property(str, "readable") - def ssid(self): - if not self._ap: - return "Disconnected" - ssid = self._ap.get_ssid().get_data() - return NM.utils_ssid_to_utf8(ssid) if ssid else "Unknown" - - @Property(int, "readable") - def state(self): - return { - NM.DeviceState.UNMANAGED: "unmanaged", - NM.DeviceState.UNAVAILABLE: "unavailable", - NM.DeviceState.DISCONNECTED: "disconnected", - NM.DeviceState.PREPARE: "prepare", - NM.DeviceState.CONFIG: "config", - NM.DeviceState.NEED_AUTH: "need_auth", - NM.DeviceState.IP_CONFIG: "ip_config", - NM.DeviceState.IP_CHECK: "ip_check", - NM.DeviceState.SECONDARIES: "secondaries", - NM.DeviceState.ACTIVATED: "activated", - NM.DeviceState.DEACTIVATING: "deactivating", - NM.DeviceState.FAILED: "failed", - }.get(self._device.get_state(), "unknown") - class Ethernet(Service): """A service to manage ethernet devices""" @@ -211,124 +552,80 @@ class Ethernet(Service): def enabled(self) -> bool: ... @Property(int, "readable") - def speed(self) -> int: - return self._device.get_speed() + 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: - return { - NM.ActiveConnectionState.ACTIVATED: "activated", - NM.ActiveConnectionState.ACTIVATING: "activating", - NM.ActiveConnectionState.DEACTIVATING: "deactivating", - NM.ActiveConnectionState.DEACTIVATED: "deactivated", - }.get( - self._device.get_active_connection().get_state(), - "disconnected", - ) + 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" - - elif self._device.get_connectivity != NM.ConnectivityState.FULL: - return "network-wired-no-route-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 - for names in ( - "active-connection", - "icon-name", - "internet", - "speed", - "state", - ): - self._device.connect(f"notify::{names}", lambda *_: self.notifier(names)) - - self._device.connect("notify::speed", lambda *_: print(_)) - - def notifier(self, names): - self.notify(names) - self.emit("changed") - - -class NetworkClient(Service): - """A service to manage network devices""" - - @Signal - def device_ready(self) -> None: ... - - def __init__(self, **kwargs): - self._client: NM.Client | None = None - self.wifi_device: Wifi | None = None - self.ethernet_device: Ethernet | None = None - super().__init__(**kwargs) - NM.Client.new_async( - cancellable=None, - callback=self._init_network_client, - **kwargs, + self._device.connect( + "state-changed", lambda *args: self.on_network_state_changed() ) - def _init_network_client(self, client: NM.Client, task: Gio.Task, **kwargs): - self._client = client - wifi_device: NM.DeviceWifi | None = self._get_device(NM.DeviceType.WIFI) # type: ignore - ethernet_device: NM.DeviceEthernet | None = self._get_device( - NM.DeviceType.ETHERNET - ) + self.update_active_connection() - if wifi_device: - self.wifi_device = Wifi(self._client, wifi_device) - self.emit("device-ready") + 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 - if ethernet_device: - self.ethernet_device = Ethernet(client=self._client, device=ethernet_device) - self.emit("device-ready") + 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") + ) - self.notify("primary-device") - - def _get_device(self, device_type) -> Any: - devices: List[NM.Device] = self._client.get_devices() # type: ignore - return next( - ( - x - for x in devices - if x.get_device_type() == device_type - and x.get_active_connection() is not None - ), - None, - ) - - def _get_primary_device(self) -> Literal["wifi", "wired"] | None: - if not self._client: - return None - - if self._client.get_primary_connection() is None: - return "wifi" - return ( - "wifi" - if "wireless" - in str(self._client.get_primary_connection().get_connection_type()) - else "wired" - if "ethernet" - in str(self._client.get_primary_connection().get_connection_type()) - else None - ) - - def connect_wifi_bssid(self, bssid): - # We are using nmcli here, idk im lazy - exec_shell_command_async( - f"nmcli device wifi connect {bssid}", lambda *args: print(args) - ) - - @Property(str, "readable") - def primary_device(self) -> Literal["wifi", "wired"] | None: - return self._get_primary_device() + def get_network_stats(self): + """Fetch received and transmitted bytes from the system files""" + try: + # Read data from /sys/class/net for accurate speed + with open( + f"/sys/class/net/{self.iface}/statistics/rx_bytes", "r" + ) as rx_file: + rx_bytes = int(rx_file.read().strip()) + with open( + f"/sys/class/net/{self.iface}/statistics/tx_bytes", "r" + ) as tx_file: + tx_bytes = int(tx_file.read().strip()) + return rx_bytes, tx_bytes + except FileNotFoundError: + return None, None diff --git a/services/notification.py b/services/notification.py deleted file mode 100644 index 4da918b..0000000 --- a/services/notification.py +++ /dev/null @@ -1,226 +0,0 @@ -import json -import os -from typing import List - -from fabric import Signal -from fabric.notifications import Notification, Notifications -from loguru import logger -import gi -gi.require_version('GLib', '2.0') -from gi.repository import GLib - -import config.data as data - -NOTIFICATION_CACHE_FILE = f"{data.CACHE_DIR}/notifications.json" - - -class CustomNotifications(Notifications): - """A service to manage the notifications.""" - - @Signal - def clear_all(self, value: bool) -> None: - """Signal emitted when notifications are emptied.""" - # Implement as needed for your application - - @Signal - def notification_count(self, value: int) -> None: - """Signal emitted when a new notification is added.""" - # Implement as needed for your application - - @Signal - def dnd(self, value: bool) -> None: - """Signal emitted when dnd is toggled.""" - # Implement as needed for your application - - @property - def count(self) -> int: - """Return the count of notifications.""" - return len(self.all_notifications) - - @property - def dont_disturb(self) -> bool: - """Return the pause status.""" - return self._dont_disturb - - @dont_disturb.setter - def dont_disturb(self, value: bool): - """Set the pause status.""" - self._dont_disturb = value - self.emit("dnd", value) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.all_notifications = [] - self._count = 0 # Will be updated to highest ID when loading - self.deserialized_notifications = [] - self._dont_disturb = False - self._load_notifications() - - def _load_notifications(self): - """Read notifications from the cache file.""" - if os.path.exists(NOTIFICATION_CACHE_FILE): - try: - with open(NOTIFICATION_CACHE_FILE, "r") as file: - notifications = json.load(file) - - def validate_with_id(notif): - """Helper to validate and return ID if valid.""" - try: - self._deserialize_notification(notif) - return (True, notif, notif.get("id", 0)) - except Exception as e: - msg = f"[Notification] Invalid: {str(e)[:50]}" - logger.error(msg) - return (False, None, 0) - - # Validate all notifications at once - results = [validate_with_id(n) for n in notifications] - - # Process results and find highest ID - valid_notifications = [] - highest_id = self._count # Start with current count - for is_valid, notif, notif_id in results: - if is_valid: - valid_notifications.append(notif) - highest_id = max(highest_id, notif_id) - - self.all_notifications = valid_notifications - self._count = highest_id # Update to highest ID seen - self._write_notifications(self.all_notifications) - - except (json.JSONDecodeError, KeyError, ValueError, IndexError) as e: - logger.error(f"[Notification] {e}") - self.all_notifications = [] - self._count = 0 - - def remove_notification(self, id: int): - """Remove the notification of given id.""" - item = next((p for p in self.all_notifications if p["id"] == id), None) - if item: - self.all_notifications.remove(item) - self._write_notifications(self.all_notifications) - self.emit("notification_count", len(self.all_notifications)) - - # Emit clear_all signal if there are no notifications left - if len(self.all_notifications) == 0: - self.emit("clear_all", True) - - def cache_notification(self, data: Notification): - """Cache the notification without widget_config and max_count.""" - - # First clean up any invalid notifications - self._cleanup_invalid_notifications() - - # Create the new notification - self._count += 1 - new_id = self._count - timestamp = GLib.get_real_time() / 1000000 # Convert microseconds to seconds - serialized_data = data.serialize() - serialized_data.update({ - "id": new_id, - "app_name": data.app_name, - "timestamp": timestamp - }) - - # Append the new notification - self.all_notifications.append(serialized_data) - - self._write_notifications(self.all_notifications) - self.emit("notification_count", len(self.all_notifications)) - - def _cleanup_invalid_notifications(self): - """Remove any invalid notifications.""" - - def validate_with_id(notif): - """Helper to validate and return result with ID.""" - try: - self._deserialize_notification(notif) - return (True, notif, None) - except Exception as e: - msg = f"[Notification] Removing invalid: {str(e)[:50]}" - logger.debug(msg) - return (False, None, notif.get("id", 0)) - - # Validate all notifications at once - results = [validate_with_id(n) for n in self.all_notifications] - - # Process results - valid_notifications = [] - invalid_count = 0 - for is_valid, notif, invalid_id in results: - if is_valid: - valid_notifications.append(notif) - else: - invalid_count += 1 - self.emit("notification-closed", invalid_id, "dismissed-by-limit") - - if invalid_count > 0: - self.all_notifications = valid_notifications - self._write_notifications(self.all_notifications) - self.emit("notification_count", len(self.all_notifications)) - - def _deserialize_notification(self, notification): - """Deserialize a notification.""" - try: - notif = Notification.deserialize(notification) - # Store the timestamp in the notification object - notif.timestamp = notification.get("timestamp", GLib.get_real_time() / 1000000) - return notif - except Exception as e: - logger.error( - f"[Notification] Deserialize failed for: {notification}, Error: {e}" - ) - raise - - def clear_all_notifications(self): - """Empty the notifications.""" - logger.info("[Notification] Clearing all notifications") - # Clear notifications but preserve the highest ID we've seen - highest_id = self._count - self.all_notifications = [] - self._write_notifications(self.all_notifications) - self.emit("notification_count", 0) - self.emit("clear_all", True) - # Restore the ID counter so new notifications get unique IDs - self._count = highest_id - - def _write_notifications(self, data): - """Write the notifications to the cache file.""" - try: - with open(NOTIFICATION_CACHE_FILE, "w") as f: - json.dump(data, f, indent=4, ensure_ascii=False) - logger.info("[Notification] Notifications written successfully.") - except Exception as e: - logger.error(f"[Notification] Failed to write notifications: {e}") - - def get_deserialized(self) -> List[Notification]: - """Return the notifications.""" - - def deserialize_with_id(notif): - """Helper to deserialize and return result with ID.""" - try: - return (self._deserialize_notification(notif), None) - except Exception as e: - msg = f"[Notification] Deserialize failed: {str(e)[:50]}" - logger.error(msg) - return (None, notif.get("id")) - - # Process all notifications at once - results = [ - deserialize_with_id(notification) for notification in self.all_notifications - ] - - # Split into successful and failed - deserialized = [] - invalid_ids = [] - for result, error_id in results: - if result is not None: - deserialized.append(result) - elif error_id is not None: - invalid_ids.append(error_id) - - # Clean up invalid notifications - for invalid_id in invalid_ids: - self.remove_notification(invalid_id) - - return deserialized diff --git a/services/powerprofile.py b/services/powerprofile.py deleted file mode 100644 index cccf03e..0000000 --- a/services/powerprofile.py +++ /dev/null @@ -1,88 +0,0 @@ -import dbus -from dbus.mainloop.glib import DBusGMainLoop -from fabric import Service, Signal -from loguru import logger - - -class PowerProfiles(Service): - """Service to interact with the PowerProfiles service.""" - - @Signal - def profile(self, value: str) -> None: - """Signal emitted when profile changes.""" - - instance = None - - @staticmethod - def get_default(): - if PowerProfiles.instance is None: - PowerProfiles.instance = PowerProfiles() - - return PowerProfiles.instance - - def __init__( - self, - **kwargs, - ): - super().__init__( - **kwargs, - ) - - self.bus_name = "net.hadess.PowerProfiles" - self.object_path = "/net/hadess/PowerProfiles" - - self.power_profiles = { - "power-saver": { - "name": "Power Saver", - "icon_name": "power-profile-power-saver-symbolic", - }, - "balanced": { - "name": "Balanced", - "icon_name": "power-profile-balanced-symbolic", - }, - "performance": { - "name": "Performance", - "icon_name": "power-profile-performance-symbolic", - }, - } - - # Set up the dbus main loop - DBusGMainLoop(set_as_default=True) - - self.bus = dbus.SystemBus() - - self.power_profiles_obj = self.bus.get_object(self.bus_name, self.object_path) - - self.iface = dbus.Interface( - self.power_profiles_obj, "org.freedesktop.DBus.Properties" - ) - - # Connect the 'g-properties-changed' signal to the handler - self.iface.connect_to_signal("PropertiesChanged", self.handle_property_change) - - def get_current_profile(self): - try: - return self.iface.Get("net.hadess.PowerProfiles", "ActiveProfile").strip() - - except dbus.DBusException as e: - logger.error(f"[PowerProfile] Error retrieving current power profile: {e}") - return "balanced" - - def set_power_profile(self, profile): - try: - self.iface.Set(self.bus_name, "ActiveProfile", dbus.String(profile)) - logger.info(f"[PowerProfile] Power profile set to {profile}") - except dbus.DBusException as e: - logger.error( - f"[PowerProfile] Could not change power level to {profile}: {e}" - ) - - # Function to handle properties change signals - def handle_property_change(self, proxy, changed, invalidated): - # Print the changed ActiveProfile - if "ActiveProfile" in changed: - logger.info(f"Profile changed: {changed['ActiveProfile']}") - self.emit("profile", changed["ActiveProfile"]) - - def get_profile_icon(self, profile): - return self.power_profiles.get(profile, "balanced").get("icon_name") diff --git a/services/screen_record.py b/services/screen_record.py deleted file mode 100644 index 7fbf6d5..0000000 --- a/services/screen_record.py +++ /dev/null @@ -1,142 +0,0 @@ -import datetime -import subprocess - -from fabric.core.service import Property, Service, Signal -from fabric.utils import exec_shell_command, exec_shell_command_async -from gi.repository import Gio, GLib -from loguru import logger - - -class ScreenRecorder(Service): - @Signal - def recording(self, value: bool) -> None: ... - - def __init__(self, **kwargs): - self.screenshot_path = GLib.get_home_dir() + "/Pictures/Screenshots/" - self.screenrecord_path = GLib.get_home_dir() + "/Videos/Screencasting/" - - super().__init__(**kwargs) - - def screenshot(self, fullscreen=False, save_copy=True): - time = datetime.datetime.today().strftime("%Y-%m-%d_%H-%M-%S") - file_path = self.screenshot_path + str(time) + ".png" - command = ( - ["grimblast", "copysave", "screen", file_path] - if save_copy - else ["grimblast", "copyscreen"] - ) - if not fullscreen: - command[2] = "area" - try: - subprocess.run(command, check=True) - self.send_screenshot_notification( - file_path=file_path if file_path else None, - ) - except Exception as e: - logger.error(f"[SCREENSHOT] Failed to run command: {command}") - - def screencast_start(self, fullscreen=False): - if self.is_recording: - logger.error( - "[SCREENRECORD] Another instance of wf-recorder is already running" - ) - return - time = datetime.datetime.today().strftime("%Y-%m-%d_%H-%M-%S") - file_path = self.screenrecord_path + str(time) + ".mp4" - self._current_screencast_path = file_path - area = "" if fullscreen else f"-g '{exec_shell_command('slurp')}'" - command = f"wf-recorder --file={file_path} --pixel-format yuv420p {area}" - exec_shell_command_async(command) - self.emit("recording", True) - - def screencast_stop(self): - exec_shell_command_async("killall -INT wf-recorder") - self.emit("recording", False) - self.send_screencast_notification(self._current_screencast_path) - - def send_screencast_notification(self, file_path): - # TODO: Generate a thumbnail - # exec_shell_command_async(f"totem-video-thumbnailer {file_path} {FABRIC_CACHE}") - - 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.screenrecord_path}") - case "view": - exec_shell_command_async(f"xdg-open {file_path}") - - proc.communicate_utf8_async(None, None, do_callback) - - 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.screenshot_path}") - case "view": - exec_shell_command_async(f"xdg-open {file_path}") - case "edit": - exec_shell_command_async(f"swappy -f {file_path}") - - proc.communicate_utf8_async(None, None, do_callback) - - @Property(bool, "readable", default_value=False) - def is_recording(self): - return False if len(exec_shell_command("pidof wf-recorder")) == 0 else True diff --git a/styles/bar.css b/styles/bar.css deleted file mode 100644 index 6f4993c..0000000 --- a/styles/bar.css +++ /dev/null @@ -1,214 +0,0 @@ -#bar { - padding: 0px 5px 0px 5px; - margin: 1px; - transition: all 0.5s cubic-bezier(0.5, 0.25, 0, 1); - background-color: var(--surface); - /* Bar background */ -} - -#start-container, -#center-container, -#end-container { - padding-top: 2px; - padding-bottom: 2px; -} - -#bar-corner * { - background-color: var(--surface); -} - -/** - * ── Workspace ───────────────────────────────────────────────────────── - */ -#workspaces { - font-style: normal; - background-color: var(--surface-container); - padding: 4px; - margin-top: 1px; - margin-bottom: 1px; - border-radius: 8px; -} - -/** - * ── Update ──────────────────────────────────────────────────────────── - */ -#update { - background-color: var(--surface-container); - margin-top: 1px; - margin-bottom: 1px; - border-radius: 8px; - padding: 1px; - min-width: 24px; - min-height: 24px; - transition: background-color 0.1s ease; -} - -#update:hover { - background-color: var(--surface-bright); -} - -#update:active { - color: var(--shadow); - background-color: var(--primary); -} - -#update-icon { - color: var(--primary); - font-size: 20px; - padding: 1px; - border-radius: 11px; - transition: all 0.1s ease; -} - -#updated-icon { - color: var(--primary); - font-size: 20px; - padding: 4px; - border-radius: 8px; - transition: all 0.1s ease; -} - -#update-label { - color: var(--primary); - font-size: 10px; - font-weight: bold; - padding: 0px; - margin-right: 4px; - border-radius: 8px; - transition: all 0.1s ease; -} - -/** - * ── Clock ───────────────────────────────────────────────────────────── - */ -#datetime { - background-color: var(--surface-container); - margin-top: 1px; - margin-bottom: 1px; - padding: 4px; - border-radius: 8px; -} - -#datetime:hover { - background-color: var(--surface-bright); -} - -/** - * ── Tray ────────────────────────────────────────────────────────────── - */ -#tray { - background-color: var(--surface-container); - margin-top: 1px; - margin-bottom: 1px; - padding: 4px; - border-radius: 8px; -} - -#tray > button { - margin: 0px; - padding: 0px; -} - -menu { - border: solid 1px var(--outline-variant); - border-radius: 16px; - background-color: var(--surface); - padding: 6px; -} - -menu > menuitem { - border-radius: 10px; - padding: 6px 10px; -} - -menu > menuitem:hover { - background-color: var(--surface-bright); -} - -menu > menuitem:hover > label { - color: var(--on-surface); -} - -/** - * ── Logo ────────────────────────────────────────────────────────────── - */ -#button-bar { - background-color: var(--surface-container); - margin-top: 1px; - padding: 4px; - border-radius: 8px; - margin-bottom: 1px; - transition: all 0.1s ease; -} - -#button-bar-label { - color: var(--primary); - font-size: 24px; - border-radius: 8px; - transition: all 0.1s ease; -} - -#button-bar:hover #button-bar-label { - background-color: var(--surface_bright); -} - -#button-bar:active #button-bar-label { - color: var(--shadow); - background-color: var(--primary); -} - -/** - * ── System Indicator ───────────────────────────────────────────────── - */ -#system-indicator { - background-color: var(--surface-container); - margin-top: 1px; - padding: 4px; - margin-bottom: 1px; - border-radius: 8px; -} - -#system-indicator-icon { - font-size: 20px; -} - -#system-notif-label { - font-size: 18px; -} - -/** - * ── Metric ──────────────────────────────────────────────────────────── - */ -#metrics { - background-color: var(--surface-container); - border-radius: 8px; -} - -#metrics-circle { - color: var(--surface-bright); - border: 3px solid var(--primary); -} - -#metrics-circle.bat { - border: 3px solid var(--green); -} - -#metrics-circle.alert { - border: 3px solid var(--red-dim); -} - -#metrics-icon { - font-size: 15px; -} - -#metrics-icon.alert { - color: var(--red-dim); -} - -#metrics-level { - font-weight: bold; -} - -#metrics-sep { - min-width: 6px; -} diff --git a/styles/calendar.css b/styles/calendar.css new file mode 100644 index 0000000..f67a694 --- /dev/null +++ b/styles/calendar.css @@ -0,0 +1,79 @@ +/* Main calendar widget container */ +#calendar-widget { + border: 4px solid var(--surface); + padding: 4px; +} + +#calendar-header-box { + border-radius: 16px; + border: 2px solid var(--surface); + padding: 4px; +} + +/* Navigation buttons */ +#calendar-nav-button { + background-color: var(--surface); + border-radius: 8px; + padding: 4px; +} + +#calendar-nav-button:hover { + background-color: var(--surface-bright); +} + +#calendar-nav-button:active { + background-color: var(--primary); + +} + +/* Month/Year header */ +#calendar-month-year { + color: var(--foreground); + font-weight: 700; + font-size: 12px; + padding: 4px 0; + margin-bottom: 2px; +} + +/* Calendar grid */ +#calendar-grid { + border-radius: 16px; + padding: 4px; +} + +/* Day headers (Mon, Tue, Wed, etc.) */ +#calendar-day-header { + color: var(--primary); + border-radius: 8px; + padding: 4px; +} + + +#calendar-day-label, +#calendar-day-empty { + padding: 2px; + min-width: 23px; + min-height: 23px; +} + + +#calendar-day-label.today { + background-color: var(--foreground); + color: var(--shadow); + border-radius: 20px; +} + +#calendar-day-label.weekend { + color: var(--red-dim); + font-weight: 600; +} + + + + + + + + + + diff --git a/styles/colors.css b/styles/colors.css deleted file mode 100644 index f30081e..0000000 --- a/styles/colors.css +++ /dev/null @@ -1,44 +0,0 @@ -:vars { - --foreground: #f1dfdb; - --background: #1a1110; - --cursor: #f1dfdb; - --primary: #ffb4a5; - --primary-container: #733428; - --on-primary: #561e14; - --on-primary-container: #ffdad3; - --secondary: #e7bdb5; - --on-secondary: #442a24; - --secondary-container: #5d3f39; - --tertiary: #dcc48c; - --tertiary-container: #554519; - --on-tertiary: #3d2f04; - --on-tertiary-container: #f9e0a6; - --surface: #1a1110; - --surface-container: #271d1b; - --surface-bright: #423734; - --surface-variant: #534340; - --surface-container-low: #231918; - --on-surface: #f1dfdb; - --error: #ffb4ab; - --error-dim: #ff8678; - --on-error: #690005; - --error-container: #93000a; - --on-error-container: #ffdad6; - --outline: #a08c88; - --outline-variant: #534340; - --shadow: #000000; - --red: #ffb4a7; - --red-dim: #ff8974; - --green: #b7d085; - --green-dim: #a1c260; - --yellow: #dec56e; - --yellow-dim: #d5b444; - --blue: #cebdfe; - --blue-dim: #a98bfd; - --magenta: #fcb0d5; - --magenta-dim: #fa7fbb; - --cyan: #84d5c4; - --cyan-dim: #5ec8b2; - --white: #82d3e0; - -} diff --git a/styles/components.css b/styles/components.css new file mode 100644 index 0000000..486030d --- /dev/null +++ b/styles/components.css @@ -0,0 +1,270 @@ +#music-player { + background-color: var(--surface); + padding: 4px; +} + +#music-player.invert { + border-radius: 12px; +} + +#player-circle { + padding: 4px; + color: var(--surface-bright); + border: 3px solid var(--primary); +} +#music-label { + font-size: 16px; +} + +/* + * ── language indicator ──────────────────────────────────────────────── +*/ + +#language { + background-color: var(--surface); + padding: 8px; +} + +#language.invert { + border-radius: 12px; +} + +#lang-label { + font-weight: bold; +} + +#lang-label.icon { + color: var(--primary); + font-size: 20px; +} + +/* + * ── battery ─────────────────────────────────────────────────────────── +*/ + +#battery { + background-color: var(--surface); + padding: 4px; +} + +#battery.invert { + border-radius: 12px; +} + +#battery-circle { + color: var(--surface-bright); + border: 3px solid var(--primary); +} + +#battery-circle.alert { + border: 2px solid var(--red-dim); +} + +#battery-icon { + font-size: 16px; +} + +#battery-icon.alert { + color: var(--red-dim); +} + +#battery-level { + color: var(--primary); + font-weight: bold; + margin: 0 4px; +} + +#battery-circle.power-saver { + border: 3px solid var(--green); +} + +#battery-circle.performance { + border: 3px solid var(--red); +} + +#battery-circle.discharging { + border: 3px solid var(--yellow); +} + +#battery-circle.discharging-low { + border: 3px solid var(--orange); +} + +#battery-circle.discharging-critical { + border: 3px solid var(--red-dim); +} + +#battery-icon.discharging { + color: var(--yellow); +} + +#battery-icon.discharging-low { + color: var(--orange); +} + +#battery-icon.discharging-critical { + color: var(--red-dim); +} + +/* + * ── battery popup ───────────────────────────────────────────────────── +*/ + +#battery-popup-content { + background-color: var(--shadow); + border: 1px solid var(--surface); + border-radius: 12px; + padding: 16px; + margin: 8px; +} + +.power-profiles { + padding-top: 12px; + margin-top: 8px; +} + +#bat-icon { + font-size: 18px; +} + +.power-profile-btn { + background-color: var(--surface); + border-radius: 8px; + padding: 10px 20px; + transition: all 0.2s ease; +} +.power-profile-btn:hover, +.power-profile-btn:focus { + background-color: var(--surface-bright); +} + +.power-profile-btn.active { + background-color: var(--primary); +} + +.power-profile-btn.active #bat-icon { + color: var(--shadow); +} + +/* Battery info styles */ +.battery-percentage { + font-size: 24px; + font-weight: bold; + color: var(--primary); +} + +.battery-state { + font-size: 14px; + color: var(--text-dim); +} + +.battery-label { + font-size: 12px; + color: var(--text-dim); + min-width: 80px; +} + +.battery-value { + font-size: 12px; + color: var(--text); + font-weight: 500; +} + +#percentage-state-box { + margin-bottom: 8px; +} + +#capacity-box, +#time-box, +#profile-label-box { + margin-bottom: 4px; +} + +/* + * ── metrics ─────────────────────────────────────────────────────────── +*/ + +#metrics { + background-color: var(--surface); + padding: 4px; +} + +#metrics.invert { + background-color: var(--surface); + border-radius: 12px; +} + +#metrics-circle { + padding: 4px; + color: var(--surface-bright); + border: 3px solid var(--primary); +} + +#metrics-icon { + font-size: 16px; +} + +#metrics-icon.alert { + color: var(--red-dim); +} + +#metrics-level { + color: var(--primary); + font-weight: bold; + margin: 0 4px; +} + +#metrics-sep { + min-width: 4px; +} + +/* + * ── datetime ────────────────────────────────────────────────────────── +*/ + +#date-time { + background-color: var(--surface); + padding: 8px; + font-weight: bold; +} +#date-time.vertical { + padding: 8px 0; +} +#date-time.invert { + background-color: var(--surface); + border-radius: 12px; +} + +/* + * ── tray ────────────────────────────────────────────────────────────── +*/ + +#tray { + background-color: var(--surface); + padding: 8px; +} + +#tray.invert { + border-radius: 12px; +} + +menu { + border: solid 1px; + border-radius: 16px; + border-color: var(--surface); + background-color: var(--shadow); + padding: 6px; +} + +menu > menuitem { + border-radius: 10px; + padding: 6px 10px; +} + +menu > menuitem:hover { + background-color: var(--primary); +} + +menu > menuitem:hover > label { + color: var(--shadow); +} diff --git a/styles/controls.css b/styles/controls.css new file mode 100644 index 0000000..c42022b --- /dev/null +++ b/styles/controls.css @@ -0,0 +1,33 @@ +#control { + padding: 4px; + background-color: var(--surface); +} + +#control.invert { + border-radius: 12px; + background-color: var(--surface); +} + +#button-volume, +#button-mic, +#button-brightness { + color: var(--surface-bright); + border: 3px solid var(--primary); +} + +#button-volume.muted, +#button-mic.muted { + color: var(--surface-bright); + border: 3px solid var(--outline); +} + +#vol-label, +#mic-label, +#brightness-label { + font-size: 16px; +} + +#vol-label.muted, +#mic-label.muted { + color: var(--outline); +} diff --git a/styles/dashboard.css b/styles/dashboard.css index 6d99fc2..745ccd9 100644 --- a/styles/dashboard.css +++ b/styles/dashboard.css @@ -1,163 +1,193 @@ -#buttons-grid { - min-height: 52px; +#dashboard-box { + transition: margin 0.25s cubic-bezier(0.5, 0.25, 0, 1); + background-color: var(--shadow); + border-radius: 25px; + padding: 6px; + margin-bottom: 65px; } -#network-button, -#bluetooth-button, -#night-mode-button, -#power-profile-button, -#dark-mode-button, -#caffeine-button { +#inner { + margin: 3px; +} + +.tile { + margin: 0 6px; + min-height: 60px; + min-width: 140px; border-radius: 20px; } - -#network-button, -#bluetooth-button { - background-color: unset; - padding: 0; +.tile.mini { + min-height: 46px; + min-width: 46px; + border-radius: 100%; } - -#night-mode-button, -#dark-mode-button, -#power-profile-button, -#caffeine-button { +.tile.on { background-color: var(--primary); - padding: 0 10px; +} +.tile.off { + background-color: var(--surface-bright); +} +.tile.on .desc-label, +.tile.on .tile-label, +.tile.on .tile-icon { + color: var(--shadow); + transition: all; +} +.tile.off .desc-label, +.tile.off .tile-label, +.tile.off .tile-icon { + color: var(--foreground); + transition: all; } -#night-mode-button.disabled, -#dark-mode-button.disabled, -#power-profile-button.disabled, -#caffeine-button.disabled { +.tile-icon { + font-size: 18px; + margin: 10px; +} +.tile-icon.mini { + font-size: 20px; + transition: font-size 0.25s cubic-bezier(0.5, 0.25, 0, 1); +} +.tile-icon.maxi { + font-size: 16px; + transition: font-size 0.25s cubic-bezier(0.5, 0.25, 0, 1); +} + +.tile-menu { + margin: -999px; +} +.tile-menu.expand { + margin: 0px; + transition: margin 0.15s cubic-bezier(0.5, 0.25, 0, 1); +} +.tile-menu.contract { + margin: -200px; + transition: margin 0.25s cubic-bezier(0.5, 0.25, 0, 1); +} + +#bluetooth-header, +#network-header { + border: 2px solid var(--surface); + padding: 4px; + border-radius: 12px; +} + +#bluetooth-device, +#wifi-ap-slot { + border: 2px solid var(--surface); + border-radius: 12px; + padding: 4px; +} + +#bluetooth-scan, +#bluetooth-back, +#network-refresh, +#network-back { background-color: var(--surface); + border-radius: 8px; + padding: 4px; } -#network-status-button, -#network-menu-button, -#bluetooth-status-button, -#bluetooth-menu-button { - background-color: var(--primary); - padding: 0 8px; +#bluetooth-back-label, +#network-back-label { + font-size: 20px; } -#network-menu-button, -#bluetooth-menu-button { - border-left: 1px solid var(--outline); - min-width: 24px; -} - -#network-status-button:hover, -#network-menu-button:hover, -#bluetooth-status-button:hover, -#bluetooth-menu-button:hover, -#bluetooth-button:hover, -#night-mode-button:hover, -#power-profile-button:hover, -#dark-mode-button:hover, -#caffeine-button:hover { - background-color: var(--foreground); -} - -/* Estados Disabled */ -#network-status-button.disabled, -#network-menu-button.disabled, -#bluetooth-status-button.disabled, -#bluetooth-menu-button.disabled { - background-color: var(--surface); -} - -#network-menu-button.disabled, -#bluetooth-menu-button.disabled { - border-left: 1px solid var(--surface-bright); -} - -#network-status-button.disabled:hover, -#network-menu-button.disabled:hover, -#bluetooth-status-button.disabled:hover, -#bluetooth-menu-button.disabled:hover, -#night-mode-button.disabled:hover, -#power-profile-button.disabled:hover, -#dark-mode-button.disabled:hover, -#caffeine-button.disabled:hover { +#bluetooth-scan:hover, +#bluetooth-back:hover, +#network-refresh:hover, +#network-back:hover { background-color: var(--surface-bright); } -#network-status-button, -#bluetooth-status-button { - border-radius: 20px 0 0 20px; -} - -#network-menu-button, -#bluetooth-menu-button { - border-radius: 0 20px 20px 0; -} - -#network-icon, -#bluetooth-icon, -#night-mode-icon, -#dark-mode-icon, -#power-profile-icon, -#caffeine-icon { - color: var(--shadow); - font-size: 24px; -} - -#network-icon.disabled, -#bluetooth-icon.disabled, -#night-mode-icon.disabled, -#power-profile-icon.disabled, -#dark-mode-icon.disabled, -#caffeine-icon.disabled { - color: var(--outline); -} - -#network-menu-label, -#bluetooth-menu-label { - color: var(--shadow); - font-size: 16px; -} - -#network-menu-label.disabled, -#bluetooth-menu-label.disabled { - color: var(--outline); -} - -#network-label, -#bluetooth-label, -#night-mode-label, -#power-profile-label, -#caffeine-label, -#dark-mode-label { - color: var(--shadow); - font-size: 14px; +#bluetooth-scan label, +#bluetooth-toggle label, +#wifi-connect-button label { font-weight: bold; - margin-bottom: -4px; } -#network-label.disabled, -#bluetooth-label.disabled, -#night-mode-label.disabled, -#power-profile-label.disabled, -#dark-mode-label.disabled, -#caffeine-label.disabled { - color: var(--outline); +#bluetooth-text, +#bluetooth-section { + font-weight: bold; } -#network-ssid, -#bluetooth-status, -#night-mode-status, -#power-profile-status, -#dark-mode-status, -#caffeine-status { - color: var(--surface-bright); - font-size: 12px; +#bluetooth-section { + background-color: var(--surface); + border-radius: 12px; + padding: 8px; } -#network-ssid.disabled, -#bluetooth-status.disabled, -#night-mode-status.disabled, -#power-profile-status.disabled, -#dark-mode-status.disabled, -#caffeine-status.disabled { - color: var(--outline); +#bluetooth-connection { + font-size: 20px; +} + +#bluetooth-connect, +#wifi-connect-button { + font-weight: bold; + background-color: var(--surface); + border-radius: 8px; + padding: 8px; +} + +#wifi-connect-button.connected { + background-color: var(--green); +} + +#wifi-connect-button.connected label { + color: var(--shadow); +} + +#bluetooth-connect.connected { + background-color: var(--blue); +} + +#bluetooth-connect.connected label { + color: var(--shadow); +} + +#bluetooth-connect:hover { + background-color: var(--surface-bright); +} + +#bluetooth-paired, +#bluetooth-available { + background-color: var(--surface); + border-radius: 20px; + padding: 4px; +} + +#bt-sep { + /*padding: 1px;*/ + border-radius: 16px; + /*background-color: var(--surface);*/ + /*margin: 0 16px;*/ +} + +#bluetooth-scan-label, +#network-refresh-label { + font-size: 20px; + color: var(--primary); +} + +#bluetooth-scan.scanning { + animation: blink 0.5s ease infinite; +} + +#bluetooth-scan-label.scanning { + animation: blink 0.5s ease infinite; +} + +@keyframes blink { + 0% { + background-color: var(--blue); + color: var(--shadow); + } + 50% { + background-color: var(--shadow); + color: var(--blue); + } + 100% { + background-color: var(--blue); + color: var(--shadow); + } } diff --git a/styles/dock.css b/styles/dock.css index c0a9860..7c0abdc 100644 --- a/styles/dock.css +++ b/styles/dock.css @@ -1,52 +1,99 @@ #dock { - background-color: var(--surface); + background-color: var(--shadow); padding: 8px; - margin: 8px 8px 7px 8px; + margin: 8px 8px 0 8px; border-radius: 20px 20px 0 0; - transition: all 0.25s cubic-bezier(0.5, 0.25, 0, 1); + transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +#dock.top { + margin: 0 8px 8px 8px; + border-radius: 0 0 20px 20px; } #dock.vertical { - margin: 8px 79px 8px 8px; + margin: 8px 0 8px 8px; border-radius: 20px 0 0 20px; } -#dock.vertical-left { - margin: 8px 39px 8px 0px; +#dock.vertical.left { + margin: 8px 8px 8px 0; border-radius: 0 20px 20px 0; } -#dock.vertical-right{ - margin: 8px 79px 8px 8px; +#dock.dense { + margin: 8px 8px 4px 8px; + border-radius: 20px; + border: 2px solid var(--surface); +} + +#dock.dense.top { + margin: 4px 8px 8px 8px; + border-radius: 20px; + border: 2px solid var(--surface); +} + +#dock.edge { + border-radius: 20px 20px 0 0; + border: 2px solid var(--surface); + border-bottom: none; +} + +#dock.edge.top { + border-radius: 0 0 20px 20px; + border: 2px solid var(--surface); + border-top: none; +} + +#dock.dense.vertical { + margin: 8px 4px 8px 8px; + border-radius: 20px; + border: 2px solid var(--surface); +} + +#dock.edge.vertical { border-radius: 20px 0 0 20px; + border: 2px solid var(--surface); + border-right: none; +} + +#applications-dock { + background-color: var(--surface); + padding: 5px; + margin: 0; + border-radius: 16px; + border: none; + transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +#applications-dock.invert { + border-radius: 12px; } #dock-full { - transition: all 0.25s cubic-bezier(0.5, 0.25, 0, 1); + transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); opacity: 1; } -#dock-full.occluded { - @apply dock_nmargin(); -} - #dock-separator { - padding: 1px; - @apply dock_sep(); + padding: 2px; border-radius: 16px; background-color: var(--surface-bright); } #dock-app-button { padding: 4px; - border-radius: 12px; + border-radius: 24px; + box-shadow: 0 0 4px alpha(var(--shadow), 0.5); + transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); } -#dock-app-button:hover, -#dock-app-button:hover.instance { +#dock-app-button:hover { background-color: var(--surface-bright); - border-top: 1px solid var(--primary); - padding-top: 3px; +} + +#dock-app-button:hover.instance { + background-color: var(--outline); } #dock-app-button:active, @@ -55,24 +102,22 @@ } #dock-app-button.instance { - background: linear-gradient(to top, transparent, var(--surface-bright)); - border-bottom: 1px solid var(--surface-container); - border-top: 1px solid var(--outline); - padding: 3px 4px; + background: var(--surface-bright); + border-radius: 12px; } #dock-corner-left { - margin: 0 -8px 7px 0; + margin: 0 -8px 0 0; } #dock-corner-right { - margin: 0 0 7px -8px; + margin: 0 0 0 -8px; } #dock-corner-top { - margin: 0 79px -8px 0; + margin: 0 0 -8px 0; } #dock-corner-bottom { - margin: -8px 79px 0 0; + margin: -8px 0 0 0; } diff --git a/styles/indicators.css b/styles/indicators.css new file mode 100644 index 0000000..12a6ec6 --- /dev/null +++ b/styles/indicators.css @@ -0,0 +1,53 @@ +#indicator { + padding: 4px; + background-color: var(--surface); +} + +#indicator.invert { + background-color: var(--surface); + border-radius: 12px; +} + +#button-network, +#button-bluetooth { + color: var(--surface-bright); + border: 3px solid var(--primary); +} + +#button-bar-recording { + border-radius: 12px; + padding: 4px; + animation: recording-blink 1.5s ease-in-out infinite; +} + +#button-bar-recording:hover { + background-color: var(--red); +} + +@keyframes recording-blink { + 0% { + background-color: var(--red-dim); + } + 50% { + background-color: var(--red-dim); + } + 100% { + background-color: var(--red-dim); + } +} + +#network-label, +#bluetooth-label, +#recording-label, +#notification-icon { + font-size: 20px; +} + +#button-bar-notifications { + border-radius: 12px; + padding: 4px; +} + +#button-bar-notifications:hover { + background-color: var(--surface-bright); +} diff --git a/styles/kanban.css b/styles/kanban.css new file mode 100644 index 0000000..6c889ba --- /dev/null +++ b/styles/kanban.css @@ -0,0 +1,75 @@ +#kanban { + /* background-color: var(--shadow); */ + border-radius: 20px; + border: 4px solid var(--surface); + padding: 4px; +} + +#kanban-header { + border: 2px solid var(--surface); + border-radius: 12px; + padding: 4px; +} + +#column-header { + padding: 4px; + font-weight: bold; + color: var(--primary); +} + +#kanban-note { + background-color: var(--surface); + border-radius: 4px; + padding: 8px; + margin-bottom: 4px; +} + +#kanban-row:first-child > * > * { + border-radius: 16px 16px 4px 4px; +} + +#kanban-row:last-child > * > * { + border-radius: 4px 4px 16px 16px; +} + +#inline-editor { + border: 2px solid var(--surface-bright); + border-radius: 12px; + padding: 8px; +} + +#kanban-btn, +#kanban-btn-add { + background-color: var(--surface); + border-radius: 8px; + padding: 4px; +} + +#kanban-btn { + background-color: var(--shadow); +} + +#kanban-btn:hover, +#kanban-btn-add:hover { + background-color: var(--surface-bright); +} + +#kanban-btn:active, +#kanban-btn-add:active { + background-color: var(--primary); +} + +#kanban-btn-label, +#kanban-btn-neg { + font-size: 20px; + color: var(--primary); +} + +#kanban-btn-neg { + color: var(--red-dim); +} + +#kanban-btn:active #kanban-btn-label, +#kanban-btn-add:active #kanban-btn-label { + color: var(--shadow); +} diff --git a/styles/launcher.css b/styles/launcher.css index 8a58328..f115f2b 100644 --- a/styles/launcher.css +++ b/styles/launcher.css @@ -1,145 +1,117 @@ -/* - * ── launcher ────────────────────────────────────────────────────────── -*/ -#dashboard { - min-height: 100px; -} - #launcher { - margin: 18px; - border: 1px solid var(--outline-variant); + background: var(--shadow); + padding: 12px; + border-radius: 34px; + min-width: 550px; + transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +#close-button, +#config-button { background-color: var(--surface); - border-radius: 24px; - padding: 18px; - transition: all 0.5s cubic-bezier(0.5, 0.25, 0, 1); + border-radius: 40px; + padding: 8px; + transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); } -#launcher entry { - transition: all 0.1s ease; - border-radius: 11px; +#close-button:hover, +#close-button:focus, +#config-button:hover, +#config-button:focus { + background-color: var(--surface-bright); + border-radius: 16px; +} + +#close-button.focused, +#config-button.focused { + background-color: var(--primary); + border-radius: 16px; + box-shadow: 0 0 0 2px var(--primary-bright); +} + +#close-button.focused #close-label { + color: var(--background); +} + +#config-button.focused #config-label { + color: var(--background); +} + +#close-button:active { + background-color: var(--red-dim); + border-radius: 40px; +} + +#close-label { + color: var(--red-dim); + font-size: 24px; +} + +#close-button:active #close-label { + color: var(--shadow); +} + +#config-button:active { + background-color: var(--primary); + border-radius: 40px; +} + +#config-label { + color: var(--primary); + font-size: 24px; +} + +#config-button:active #config-label { + color: var(--shadow); +} + +#launcher-icon-label { + font-size: 32px; + padding: 8px; +} + +#launcher-search { + font-weight: bold; + background-color: var(--surface); + color: var(--foreground); + border-radius: 16px; padding: 10px; - margin: 12px; - box-shadow: inset 0 0 0 1px var(--outline-variant); } -#launcher-content.wallpapers { - padding: 16px; - min-height: 420px; - min-width: 620px; +#launcher-search selection { + color: var(--background); + background-color: var(--primary); } -.applauncher-scrolled-window { - transition: min-height 0.2s ease; +#launcher-results-scroll { + margin-top: 10px; + border-radius: 16px; } -#launcher-content.bluetooth { - min-height: 464px; -} - -#launcher-content.wifi { - min-height: 464px; -} - -#launcher-content.tools { - min-width: 308px; -} - -#launcher-content.calendar { - min-height: 420px; - min-width: 420px; -} - -#launcher-content { - min-width: 485px; -} - -#launcher-content.power { - min-width: 200px; - min-height: 52px; -} - -#launcher-content.kanban { - min-width: 1000px; - min-height: 478px; -} - -#launcher-content.tmux { - min-height: 220px; - min-width: 480px; -} - -#scrolled-window scrollbar { +#launcher-results-scroll scrollbar { border-radius: 10px; background-color: var(--surface); padding: 4px; margin-left: 6px; } -#scrolled-window slider { +#launcher-results-scroll slider { border-radius: 8px; min-width: 10px; - min-height: 48px; + min-height: 38px; background-color: var(--primary); } -#config-button:active, -#new-session-button:active { - background-color: var(--primary); +#launcher-results { + background: transparent; } -#config-label, -#clear-label, -#new-session-label { - color: var(--primary); - font-size: 24px; -} - -#config-button:active #config-label, -#clear-button:active - #clear-label - #new-session-button:active - #new-session-label { - color: var(--shadow); -} - -#config-button, -#clear-button, -#new-session-button { - background-color: var(--surface-variant); - border-radius: 16px; - padding: 8px; - margin-bottom: 12px; - margin-top: 12px; -} - -#config-button:hover, -#config-button:focus, -#clear-button:hover, -#clear-button:focus, -#new-session-button:hover, -#new-session-button:focus { - background-color: var(--surface-bright); -} - -#clear-button:active { - background-color: var(--red-dim); -} - -/* - * ── applauncher ─────────────────────────────────────────────────────── -*/ -#app-desc { - color: var(--outline); - font-size: 12px; - font-style: italic; -} - -#slot-button { - border-radius: 16px; - padding: 16px; - min-height: 0; - transition: padding 0.25s cubic-bezier(0.5, 0.25, 0, 1); +#launcher-result-item { + border-radius: 40px; + padding: 0 16px; animation: loadSlot 0.5s ease; + min-height: 52px; + transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); } @keyframes loadSlot { @@ -151,735 +123,54 @@ opacity: 1; } } - -#slot-button:hover, -#slot-button:focus, -#slot-button:selected, -#slot-button.selected { - background-color: var(--surface-bright); - padding-left: 24px; -} - -#slot-button:active { - background-color: var(--primary); -} - -#app-icon { - margin-right: 8px; -} - -#app-label { - color: var(--foreground); - font-weight: bold; -} - -#slot-button:hover #app-label, -#slot-button:focus #app-label, -#slot-button:selected #app-label, -#slot-button.selected #app-label { - color: var(--primary); -} - -#slot-button:active #app-label { - color: var(--shadow); -} - -/* - * ── wallpaper ───────────────────────────────────────────────────────── -*/ -#search-entry-walls { - min-width: 512px; -} - -#wallpaper-icons:selected { - background-color: var(--outline); - border-radius: 0px; -} - -#scheme-dropdown { - background-color: var(--surface); - padding: 8px; -} - -#scheme-dropdown > * > * { - font-weight: bold; - color: var(--shadow); -} - -#scheme-dropdown.box button.box.cellview { - background-color: var(--primary); -} - -#scheme-dropdown.box button.box.cellview:selected { - background-color: var(--surface-bright); -} - -#search-entry-walls { - min-width: 512px; -} - -/* Customize the overall switch appearance */ -#matugen-switcher, -#dnd-switch { - min-width: 40px; - min-height: 20px; - background-color: var(--surface); - border-radius: 15px; - padding: 2px; - transition: background-color 0.3s ease; -} - -/* Style the switch's slider */ -#matugen-switcher slider, -#dnd-switch slider { - background-color: var(--primary); +#launcher-result-item:focus, +#launcher-result-item:selected, +#launcher-result-item:hover, +#launcher-result-item.selected { border-radius: 16px; - min-width: 20px; - min-height: 20px; - transition: - background-color 0.1s cubic-bezier(0.5, 0.25, 0, 1.25), - transform 0.25s cubic-bezier(0.5, 0.25, 0, 1.25); -} - -/* When the switch is active (checked) */ -#matugen-switcher:checked, -#dnd-switch:checked { - background-color: var(--primary); -} - -/* Optional: additional styling for the slider when active */ -#matugen-switcher:checked slider, -#dnd-switch:checked slider { - background-color: var(--shadow); -} - -#matugen-switcher:checked image, -#dnd-switch:checked image { - color: var(--shadow); -} - -#mat-label { - font-size: 24px; - margin-left: 4px; -} - -#custom-color-selector-box { background-color: var(--surface); - border-radius: 20px; + padding-left: 20px; +} + +#result-item-main { + min-height: 48px; +} + +#launcher-result-item { + min-height: 48px; +} + +#result-item-icon { + min-width: 48px; + min-height: 48px; + margin-right: 12px; +} + +#result-item-title { + font-size: 14px; + font-weight: 600; + color: var(--foreground); + margin-top: 8px; + margin-bottom: 2px; +} + +#result-item-subtitle { + font-size: 12px; + color: var(--outline); + margin-bottom: 8px; +} + +#result-item-plugin { + font-size: 10px; + color: var(--outline); + font-style: italic; + margin-bottom: 8px; + opacity: 0.7; +} + +#network-password-entry { + border: 1px solid var(--outline); padding: 8px; -} - -#hue-slider { - margin: 0 24px; -} - -#hue-slider trough { - background-image: linear-gradient( - to right, - #ff0000, - #ffff00, - #00ff00, - #00ffff, - #0000ff, - #ff00ff, - #ff0000 - ); border-radius: 8px; - min-height: 8px; -} - -#hue-slider slider { - min-height: 16px; - min-width: 8px; - margin: -10px 0; - background-color: var(--primary); - box-shadow: 0 0 4px alpha(var(--shadow), 0.5); -} - -#apply-color-button { - background-color: var(--primary); - color: var(--shadow); - border-radius: 20px; - padding: 8px; - box-shadow: 0 0 4px alpha(var(--shadow), 0.5); -} - -#apply-color-button:hover { - background-color: var(--foreground); -} - -#apply-color-button:active { - background-color: var(--shadow); -} - -#apply-color-label { - font-size: 16px; - color: var(--shadow); -} - -#apply-color-button:active #apply-color-label { - color: var(--foreground); -} -/* - * ── emoji ───────────────────────────────────────────────────────────── -*/ - -#emoji #search-entry { - font-weight: bold; - background-color: var(--surface); - color: var(--foreground); - border-radius: 16px; - padding: 10px; -} - -#emoji #search-entry selection { - color: var(--background); - background-color: var(--primary); -} - -#emoji #close-button { - background-color: var(--surface); - border-radius: 16px; - padding: 8px; -} - -#emoji #close-button:hover, -#emoji #close-button:focus { - background-color: var(--surface-bright); -} - -#emoji #close-button:active { - background-color: var(--red-dim); -} - -#emoji #close-label { - color: var(--red-dim); - font-size: 24px; -} - -#emoji #close-button:active #close-label { - color: var(--shadow); -} - -#emoji #emoji-slot-button { - border-radius: 16px; - padding: 16px; - transition: padding 0.1s ease; -} - -@keyframes loadEmojiSlot { - 0% { - opacity: 0; - } - - 100% { - opacity: 1; - } -} - -#emoji #emoji-slot-button:hover, -#emoji #emoji-slot-button:focus, -#emoji #emoji-slot-button:selected, -#emoji #emoji-slot-button.selected { - background-color: var(--surface-bright); - text-shadow: 0 0 4px var(--shadow); - animation: highlightEmojiSlot 0.25s cubic-bezier(0, 0, 0.5, 1); -} - -@keyframes highlightEmojiSlot { - 0% { - padding: 16px; - text-shadow: 0 4px 4px var(--shadow); - } - - 50% { - padding: 8px 16px 24px 16px; - text-shadow: 0 12px 4px var(--shadow); - } - - 100% { - padding: 16px; - text-shadow: 0 4px 4px var(--shadow); - } -} - -#emoji #emoji-slot-button:active { - background-color: var(--primary); -} - -#emoji #emoji-name-label { - color: var(--foreground); - font-weight: bold; -} - -#emoji #emoji-slot-button:hover #emoji-name-label, -#emoji #emoji-slot-button:focus #emoji-name-label, -#emoji #emoji-slot-button:selected #emoji-name-label, -#emoji #emoji-slot-button.selected #emoji-name-label { - color: var(--primary); -} - -#emoji #emoji-slot-button:active #emoji-name-label { - color: var(--shadow); -} - -#emoji #emoji-char-label { - font-size: 24px; -} - -/* - * ── powermenu ───────────────────────────────────────────────────────── -*/ - -#power-menu-button { - border-radius: 20px; - min-width: 52px; - min-height: 52px; - padding: 5px; -} - -#power-menu-button label { - font-size: 35px; - color: var(--foreground); -} - -#power-menu-button:hover, -#power-menu-button:focus { - background-color: var(--surface-bright); -} - -#power-menu-button:hover label, -#power-menu-button:focus label { - color: var(--primary); -} - -#power-menu-button:active { - background-color: var(--primary); -} - -#power-menu-button:active label { - color: var(--shadow); -} - -/* - * ── sh runner ───────────────────────────────────────────────────────── -*/ - -#sh-item { - border-radius: 11px; - background-color: transparent; - background-image: none; - box-shadow: none; - padding: 0px; -} - -#sh-item { - font-size: 24px; -} - -#sh-item:hover, -#sh-item:focus { - text-shadow: 2px 2px 2px var(--primary); -} - -/* - * ── bluetooth ───────────────────────────────────────────────────────── -*/ - -#bluetooth-header { - border: 2px solid var(--shadow); - padding: 4px; - background-color: var(--surface-container); - border-radius: 12px; -} - -#bluetooth-device { - border: 2px solid var(--shadow); - background-color: var(--surface-container); - border-radius: 12px; - padding: 4px; -} - -#bluetooth-scan, -#bluetooth-back { - background-color: var(--surface); - border-radius: 8px; - padding: 4px; -} - -#bluetooth-back-label { - font-size: 20px; -} - -#bluetooth-scan:hover, -#bluetooth-back:hover { - background-color: var(--surface-bright); -} - -#bluetooth-scan label, -#bluetooth-toggle label { - font-weight: bold; -} - -#bluetooth-text, -#bluetooth-section { - font-weight: bold; -} - -#bluetooth-section { - background-color: var(--surface-container); - border-radius: 12px; - padding: 8px; -} - -#bluetooth-connection { - font-size: 20px; -} - -#bluetooth-connect { - background-color: var(--surface); - border-radius: 8px; - padding: 8px; -} - -#bluetooth-connect:hover { - background-color: var(--surface-bright); -} - -#bluetooth-paired, -#bluetooth-available { - background-color: var(--surface-container); - border-radius: 20px; - padding: 4px; -} - -#bt-sep { - border-radius: 16px; -} - -#bluetooth-scan-label { - font-size: 20px; - color: var(--primary); -} - -#bluetooth-scan.scanning { - animation: blink 0.5s ease infinite; -} - -#bluetooth-scan-label.scanning { - animation: blink 0.5s ease infinite; -} - -@keyframes blink { - 0% { - background-color: var(--blue); - color: var(--shadow); - } - - 50% { - background-color: var(--shadow); - color: var(--blue); - } - - 100% { - background-color: var(--blue); - color: var(--shadow); - } -} - -/* - * ── wifi ────────────────────────────────────────────────────────────── -*/ - -#wifi-header { - background-color: var(--surface-container); - padding: 4px; - border-radius: 20px; -} - -#wifi-device { - background-color: var(--secondary-container); - border-radius: 16px; - padding: 4px; margin-bottom: 4px; } - -#wifi-toggle-icon { - font-size: 24px; -} - -#wifi-scan, -#wifi-toggle { - background-color: var(--surface-container); - margin-left: 0px; - min-width: 44px; - min-height: 44px; - border-radius: 100%; -} - -#wifi-scan:hover, -#wifi-toggle:hover { - background-color: var(--surface-bright); -} - -#wifi-scan label, -#wifi-toggle label { - font-weight: bold; -} - -#wifi-text, -#wifi-section { - font-weight: bold; -} - -#wifi-text { - color: var(--primary); -} - -#wifi-connection { - font-size: 20px; -} - -#wifi-connect { - background-color: var(--surface); - border-radius: 16px; - padding: 8px 16px; -} - -#wifi-connect:hover { - background-color: var(--surface-bright); -} - -#wifi-available { - /*background-color: var(--surface-container);*/ - padding: 4px; - border-radius: 20px; -} - -/* - * ── calendar ────────────────────────────────────────────────────────── -*/ - -#header { - border-radius: 12px; - padding: 4px; -} - -#weekday-row { - background-color: var(--surface); - border-radius: 8px; - padding: 4px; -} - -#month-label, -#weekday-label, -#day-label { - font-weight: bold; -} - -#day-label, -#day-empty { - min-width: 38px; - min-height: 22px; -} - -#day-empty { - color: var(--surface-bright); -} - -#weekday-label { - color: var(--primary); -} - -#calendar-grid { - border-radius: 16px; - padding: 4px; -} - -#day-label.current-day { - background-color: var(--primary); - color: var(--shadow); - border-radius: 20px; -} - -#prev-month-button, -#next-month-button { - background-color: var(--surface); - border-radius: 8px; - padding: 4px; -} - -#prev-month-button:hover, -#next-month-button:hover { - background-color: var(--surface-bright); -} - -#prev-month-button:active, -#next-month-button:active { - background-color: var(--primary); -} - -#month-button-label { - color: var(--primary); - font-size: 20px; -} - -#prev-month-button:active #month-button-label, -#next-month-button:active #month-button-label { - color: var(--shadow); -} - -#prev-month-day, -#next-month-day { - color: mix(var(--on-surface), transparent, 0.6); -} - -/* - * ── tools ───────────────────────────────────────────────────────────── -*/ - -#toolbox-button { - border-radius: 20px; - min-width: 52px; - min-height: 52px; - padding: 5px; -} - -#toolbox-button label { - font-size: 35px; - color: var(--foreground); -} - -#toolbox-button.recording label { - color: var(--red-dim); -} - -#toolbox-button:hover, -#toolbox-button:focus { - background-color: var(--surface-bright); -} - -#toolbox-button:hover label, -#toolbox-button:focus label { - color: var(--primary); -} - -#toolbox-button.recording:hover label, -#toolbox-button.recording:focus label { - color: var(--red-dim); -} - -#toolbox-button:active { - background-color: var(--primary); -} - -#toolbox-button:active label { - color: var(--shadow); -} - -#toolbox-button.recording:active { - background-color: var(--red-dim); -} - -#toolbox-button.recording:active label { - color: var(--shadow); -} - -#tool-sep { - min-width: 4px; - min-height: 4px; - margin: 0px 4px; - border-radius: 16px; - background-color: var(--surface-bright); -} - -/* - * ── kanban ──────────────────────────────────────────────────────────── -*/ - -#kanban-header { - border: 2px solid var(--outline); - border-radius: 12px; - padding: 4px; -} - -#column-header { - padding: 4px; - font-weight: bold; - color: var(--primary); -} - -#kanban-note { - background-color: var(--surface-container); - border-radius: 4px; - padding: 8px; - margin-bottom: 4px; -} - -#kanban-row:first-child > * > * { - border-radius: 16px 16px 4px 4px; -} - -#kanban-row:last-child > * > * { - border-radius: 4px 4px 16px 16px; -} - -#inline-editor { - border: 2px solid var(--surface-bright); - border-radius: 12px; - padding: 8px; -} - -#kanban-btn, -#kanban-btn-add { - background-color: var(--surface-container); - border-radius: 8px; - padding: 4px; -} - -#kanban-btn { - background-color: var(--surface); -} - -#kanban-btn:hover, -#kanban-btn-add:hover { - background-color: var(--surface-bright); -} - -#kanban-btn:active, -#kanban-btn-add:active { - background-color: var(--primary); -} - -#kanban-btn-label, -#kanban-btn-neg { - font-size: 20px; - color: var(--primary); -} - -#kanban-btn-neg { - color: var(--red-dim); -} - -#kanban-btn:active #kanban-btn-label, -#kanban-btn-add:active #kanban-btn-label { - color: var(--surface); -} - -/* tmux */ -#no-notif, -#no-tmux, -#no-clip { - font-size: 96px; - color: var(--surface-container); -} - -#clip-label { - font-weight: bold; -} - -#tmux-icon, -#clip-icon { - font-size: 20px; - color: var(--primary); -} diff --git a/styles/main.css b/styles/main.css deleted file mode 100644 index 79e35a8..0000000 --- a/styles/main.css +++ /dev/null @@ -1,51 +0,0 @@ -@import "./colors.css"; -@import "./bar.css"; -@import "./notification.css"; -@import "./osd.css"; -@import "./launcher.css"; -@import "./dashboard.css"; -@import "./dock.css"; -@import "./notif_center.css"; - -* { - all: unset; - color: var(--foreground); - font-size: 15px; - font-family: "Monaspace Radon"; -} - -/* - * ── tooltip ─────────────────────────────────────────────────────────── -*/ -tooltip { - border: solid 1px; - border-radius: 16px; - border-color: var(--outline); - background-color: var(--surface); - animation: tooltipShow 0.25s cubic-bezier(0.5, 0.25, 0, 1); -} - -@keyframes tooltipShow { - from { - opacity: 0; - } - - to { - opacity: 1; - } -} - -tooltip > * { - padding: 6px 10px; - border-radius: 10px; -} - -#corner { - background-color: var(--surface); - border-radius: 0; -} - -#corner-container { - min-width: 20px; - min-height: 20px; -} diff --git a/styles/notif_center.css b/styles/notif_center.css deleted file mode 100644 index 85f0f1d..0000000 --- a/styles/notif_center.css +++ /dev/null @@ -1,28 +0,0 @@ -#nhh { - font-weight: bold; -} - -#nhh-button { - border-radius: 8px; - background-color: var(--surface); - padding: 4px; -} - -#nhh-button:hover { - background-color: var(--surface-bright); -} - -#nhh-button-label { - color: var(--red-dim); - font-size: 20px; -} - -#dnd-label { - font-size: 20px; - margin-left: 8px; -} - -#no-notifications-label { - font-size: 96px; - color: var(--surface-container); -} diff --git a/styles/notification.css b/styles/notification.css index dddf9bf..465421b 100644 --- a/styles/notification.css +++ b/styles/notification.css @@ -1,29 +1,23 @@ -#notification-action-buttons button.start-action { - border-radius: 20px; -} - -#notification-action-buttons button { +#action-button { + border-radius: 16px; background-color: var(--surface); padding: 8px; - border-radius: 20px; - transition: background-color 0.1s ease; } -#notification-action-buttons button:hover { +#action-button:hover { background-color: var(--surface-bright); } -#notification-action-buttons button:active { +#action-button:active { background-color: var(--primary); } -#notification-action-buttons button:active #button-label { +#action-button:active #button-label { color: var(--shadow); } #notification-image image { border-radius: 16px; - color: var(--on-surface); } #notification-summary, @@ -44,24 +38,42 @@ margin-top: 8px; } -#notification-box { - border-radius: 20px; - padding: 16px; - background-color: var(--surface-container-low); - margin: 4px; - min-width: 336px; - transition: background-color 0.1s ease; +#no-notif { + font-size: 96px; + color: var(--surface); } -#boxed-notification-revealer { - margin-top: -14px; +#notification-history { + border-radius: 20px; + padding: 16px; + border: 2px solid var(--surface); + background-color: var(--shadow); + margin: 4px; + min-width: 430px; +} + +#notification-history.vertical { + margin: 10px; +} + +#notification-stack-box { + border-radius: 32px; + padding: 16px; + border: 2px solid var(--surface); + background-color: var(--shadow); + margin: 8px; + margin-bottom: 4px; + min-width: 330px; +} + +#notification-navigation { + padding: 16px; } #notif-close-button { background-color: var(--surface); border-radius: 16px; - padding: 4px; - transition: background-color 0.1s ease; + padding: 8px; } #notif-close-button:hover, @@ -75,9 +87,150 @@ #notif-close-label { color: var(--red-dim); - font-size: 20px; + font-size: 16px; } #notif-close-button:active #notif-close-label { color: var(--shadow); } + +#nav-button { + padding: 8px; + border-radius: 16px; + background: var(--shadow); + border: 2px solid var(--surface); + margin-top: -12px; +} + +#nav-button:hover { + background: var(--surface-bright); +} + +#nav-button:disabled #nav-button-label { + color: var(--surface-bright); +} + +#nav-button-label { + font-size: 16px; +} + +#nav-button-label.close { + color: var(--red-dim); +} + +#nav-button:hover #nav-button-label { + color: var(--primary); +} + +#nav-button:hover #nav-button-label.close { + color: var(--red-dim); +} + +#notification-history scrollbar { + background: transparent; +} + +#notification-history scrollbar.vertical slider { + background: var(--primary); + border-radius: 8px; + min-width: 16px; + min-height: 48px; + margin: 4px; +} + +#notification-history scrollbar.vertical trough { + background: var(--surface); + border: none; + border-radius: 12px; + margin: 4px; +} + +#notification-box-hist { + padding: 8px; + border-radius: 12px; + border: 2px solid var(--surface); +} + +#notification-timestamp { + color: var(--surface-bright); +} + +#notification-history-header { + border-radius: 12px; + border: 2px solid var(--surface); + padding: 4px; + margin-bottom: 4px; +} + +#nhh { + font-weight: bold; +} + +#nhh-button { + border-radius: 8px; + background-color: var(--surface); + padding: 4px; +} + +#nhh-button:hover { + background-color: var(--surface-bright); +} + +#nhh-button-label { + color: var(--red-dim); + font-size: 20px; +} + +#dnd-label { + font-size: 20px; + margin-left: 4px; +} + +#dnd-switch { + min-width: 40px; + min-height: 20px; + background-color: var(--surface); + border-radius: 15px; + padding: 2px; + transition: background-color 0.3s ease; +} + +#dnd-switch slider { + background-color: var(--primary); + border-radius: 16px; + min-width: 16px; + min-height: 8px; + transition: + background-color 0.1s cubic-bezier(0.5, 0.25, 0, 1.25), + transform 0.25s cubic-bezier(0.5, 0.25, 0, 1.25); +} + +#dnd-switch:checked { + background-color: var(--primary); +} + +#dnd-switch:checked slider { + background-color: var(--shadow); +} + +#dnd-switch:checked image { + color: var(--shadow); +} + +#notif-sep { + padding: 2px; + border-radius: 16px; + background-color: var(--surface-bright); + margin: 0 8px; +} + +#notif-date-sep { + padding: 4px; + border-radius: 12px; + background-color: var(--surface); +} + +#notif-date-sep-label { + color: var(--outline); + font-weight: bold; +} diff --git a/styles/osd.css b/styles/osd.css index 017865d..90f015a 100644 --- a/styles/osd.css +++ b/styles/osd.css @@ -1,8 +1,7 @@ #osd { - background-color: var(--surface); - padding: 10px; - border-radius: 6px; - margin-top: 6px; + background: var(--shadow); + padding: 12px 20px; + margin: 70px; } #osd scale { @@ -10,38 +9,41 @@ } #osd trough { - background: var(--surface-container); + background: var(--surface); min-height: 10px; margin-right: 4px; - border-radius: 99px; + transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); } -#osd highlight { - border-radius: 99px 0px 0px 99px; +#osd trough highlight { + border-radius: 100px 0px 0px 100px; background: var(--primary); - margin-right: 6px; + transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +#osd.muted trough highlight, +#osd.muted slider, +#osd scale.muted trough highlight, +#osd scale.muted slider { + background-color: var(--surface-bright); } #osd slider { - color: var(--on-primary); background-color: var(--primary); - background-size: 20px; - min-width: 5px; - min-height: 10px; - border-radius: 12px; + background-size: 20px 20px; + min-width: 4px; + min-height: 8px; + border-radius: 2px; padding: 0px; - background-repeat: no-repeat; - background-position: center; - margin-left: 0px; - margin-right: 0px; - margin-top: -10px; - margin-bottom: -10px; + margin: -14px 0; box-shadow: - -1px 0px 0px 1px var(--surface-container), - 1px 0px 0px 1px var(--surface-container); + -4px 0 0px 2px var(--shadow), + 4px 0 0px 2px var(--shadow); + transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); } -#osd-label { - font-weight: bold; - font-size: 17px; +#brighntess-icons.muted, +#vol-icon.muted, +#mic-icon.muted { + color: var(--outline); } diff --git a/styles/player.css b/styles/player.css new file mode 100644 index 0000000..3e22dd1 --- /dev/null +++ b/styles/player.css @@ -0,0 +1,203 @@ +.player-mini { + min-height: 60px; + min-width: 200px; + padding-right: 20px; + + box-shadow: + inset 0 0 100px rgba(148, 148, 148, 0.3), + inset 70px 0 20px rgba(255, 255, 255, 0.3), + inset 0px 0px 20px 5px rgba(255, 255, 255, 0.3); + border-top-right-radius: 15px; + border-bottom-right-radius: 15px; + border-top-left-radius: 20px; + border-bottom-left-radius: 20px; + background-position: center; + background-size: cover; +} + +#player-container.mini { + min-height: 60px; + border-radius: 15px; + background-position: center; + background-size: cover; +} + +.album-image { + min-width: 65px; + background-position: center; + background-size: cover; + border-radius: 15px; +} + +#artist { + color: var(--primary); +} + +#song { + color: whitesmoke; + font-size: 16px; + margin-top: 5px; +} +#song.mini { + color: var(--surface); + font-size: 15px; + margin-top: 5px; +} + +#source { + background-color: antiquewhite; + margin-top: -10px; +} +#source.mini { + margin-left: -20px; +} + +#controls { + margin-bottom: -10px; +} + +#play-next.mini, +#play-previous.mini { + text-shadow: none; + font-size: 30px; + color: var(--surface); +} +#play-pause-label { + color: var(--surface); + text-shadow: none; +} + +#pause-button { + background-color: whitesmoke; + border-radius: 16px; + padding: 10px; +} + +.vertical #pause-button { + margin-bottom: 5px; +} + +#pause-button.pause-track { + border-radius: 100%; +} + +#pause-label, +#play-label { + color: var(--shadow); + /* background-color: var(--error-dim); */ + font-size: 20px; + padding: 2px; + border-radius: 999px; + min-width: 25px; +} + +#pause-label.mini, +#play-label.mini { + color: white; + /* background-color: var(--error-dim); */ + font-size: 20px; + padding: 2px; + border-radius: 999px; + min-width: 25px; +} + +#shuffle-button, +#next-button, +#prev-button, +#play-pause-button { + border: none; + padding: 0px; + padding-left: 11px; + padding-right: 11px; +} + +#shuffle-button:hover, +#next-button:hover, +#prev-button:hover, +#play-pause-button:hover { + border-radius: 100%; + /* background-color: white; */ + /* opacity: .3; */ + box-shadow: inset rgba(255, 255, 255, 0.5) 0 0 10px; +} + +.player-button { + border-radius: 5px; + /* background-color: var(--secondary); */ + /* background-color: white; */ + opacity: 0.5; + min-width: 10px; + min-height: 10px; + padding: 0px; + margin: 0px; + border: none; + margin-left: 5px; +} + +.player-button.mini { + border-radius: 5px; + background-color: var(--shadow); + /* background-color: white; */ + opacity: 0.5; + min-width: 10px; + min-height: 10px; + padding: 0px; + margin: 0px; + border: none; + margin-top: 5px; +} + +.player-button.active { + opacity: 1; + /* min-width: 40px; */ + /* border-radius: 5px; */ +} + +#player-switch-container.vertical-player { + margin-top: -23px; + padding-bottom: 13px; +} + +#player-switch-container { + margin-top: -17px; + padding-bottom: 7px; +} + +#player-switch-container.mini { + margin-top: 0px; + margin-left: -18px; + padding-right: 10px; +} + +#controls.vertical { + padding-bottom: 10px; + margin-bottom: 0px; +} + +.player-icon.mini { + font-size: 20px; + color: black; + text-shadow: 0 0 5px white; +} + +#shuffle { + font-size: 17px; +} + +#disable-shuffle { + font-size: 27px; +} + +#progress-slider { + background-color: white; + margin-left: -3px; + margin-right: -2px; + margin-top: 8px; + margin-bottom: 8px; + border-radius: 2px; +} + +#progress-other-end { + min-width: 50px; + background-color: white; +} diff --git a/styles/switcher.css b/styles/switcher.css new file mode 100644 index 0000000..8a33033 --- /dev/null +++ b/styles/switcher.css @@ -0,0 +1,36 @@ +#application-switcher-container { + background: var(--shadow); +} + +#application-switcher-view { + min-height: 120px; + padding: 4px; +} + +#switcher-button { + padding: 4px; + min-width: 100px; +} + +#window-button { + background-color: var(--surface); +} + +#switcher-button:hover { + background-color: var(--surface); +} + +#window-button.active { + background-color: var(--surface-bright); + border: 3px solid var(--surface-bright); +} + +#switcher-button label { + color: var(--foreground); + font-size: 10px; + margin-top: 4px; +} + +#window-row { + padding: 18px; +} diff --git a/styles/workspaces.css b/styles/workspaces.css new file mode 100644 index 0000000..b728803 --- /dev/null +++ b/styles/workspaces.css @@ -0,0 +1,113 @@ +#workspaces { + padding: 14px; +} + +#workspace-single { + min-width: 34px; + background-color: var(--surface); + transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +#workspace-single.invert { + min-height: 34px; + border-radius: 12px; +} + +#workspaces-num { + padding: 4px; +} + +#workspaces-container { + background-color: var(--surface); +} + +#workspaces-container.invert { + background-color: var(--surface); + border-radius: 12px; +} + +#workspaces > button { + min-width: 8px; + min-height: 8px; + border-radius: 16px; + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); + background-color: var(--foreground); +} + +#workspaces > button > label { + font-size: 0px; +} + +#workspaces > button.empty:hover { + background-color: var(--foreground); +} + +#workspaces > button.urgent { + background-color: var(--error-dim); +} + +#workspaces > button.active { + min-width: 48px; + min-height: 8px; + background-color: var(--primary); +} + +#workspaces > button.active.vertical { + min-width: 8px; + min-height: 48px; + background-color: var(--primary); +} + +#workspaces > button.empty { + background-color: var(--surface-bright); +} + +#workspaces-num > button > label { + color: var(--foreground); +} + +#workspaces-num { + padding: 8px; +} + +#workspaces-num > button { + border-radius: 20px; + transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +#workspaces-num > button > label { + color: var(--foreground); + font-weight: bold; + min-width: 20px; + min-height: 20px; + font-size: 10pt; +} + +#workspaces-num > button:hover { + background-color: var(--surface-bright); +} + +#workspaces-num > button.active { + background-color: var(--primary); + border-radius: 8px; +} + +#workspaces-num > button.active > label { + color: var(--shadow); +} + +#workspaces-num > button.empty > label { + color: var(--surface-bright); +} + +#workspaces-num > button.empty:hover > label { + color: var(--foreground); +} + +#workspaces-num > button.empty:hover { + background-color: transparent; +} + +#workspaces-num > button.urgent > label { + color: var(--error-dim); +} diff --git a/utils/__init__.py b/utils/__init__.py index 43abad3..3f03f12 100644 --- a/utils/__init__.py +++ b/utils/__init__.py @@ -1,4 +1,4 @@ -from .custom_image import * -from .icon_resolver import * -from .occlusion import * -from .animator import * +""" +Modus services package. +Contains background services and utilities for the shell. +""" diff --git a/utils/circle_image.py b/utils/circle_image.py new file mode 100644 index 0000000..acfdc6d --- /dev/null +++ b/utils/circle_image.py @@ -0,0 +1,118 @@ +import math +from typing import Literal + +import cairo +import gi +from fabric.core.service import Property +from fabric.widgets.widget import Widget + +gi.require_version("Gtk", "3.0") +from gi.repository import Gdk, GdkPixbuf, Gtk # noqa: E402 + + +class CircleImage(Gtk.DrawingArea, Widget): + """A widget that displays an image in a circular shape with a 1:1 aspect ratio.""" + + @Property(int, "read-write") + def angle(self) -> int: + return self._angle + + @angle.setter + def angle(self, value: int): + self._angle = value % 360 + self.queue_draw() + + def __init__( + self, + image_file: str | None = None, + pixbuf: GdkPixbuf.Pixbuf | None = None, + name: str | None = None, + visible: bool = True, + all_visible: bool = False, + style: str | None = None, + tooltip_text: str | None = None, + tooltip_markup: str | None = None, + h_align: Literal["fill", "start", "end", "center", "baseline"] | Gtk.Align | None = None, + v_align: Literal["fill", "start", "end", "center", "baseline"] | Gtk.Align | None = None, + h_expand: bool = False, + v_expand: bool = False, + size: int | None = None, + **kwargs, + ): + Gtk.DrawingArea.__init__(self) + Widget.__init__( + self, + name=name, + visible=visible, + all_visible=all_visible, + style=style, + tooltip_text=tooltip_text, + tooltip_markup=tooltip_markup, + h_align=h_align, + v_align=v_align, + h_expand=h_expand, + v_expand=v_expand, + size=size, + **kwargs, + ) + self.size = size if size is not None else 100 # Default size if not provided + self._angle = 0 + self._orig_image: GdkPixbuf.Pixbuf | None = None # Original image for reprocessing + self._image: GdkPixbuf.Pixbuf | None = None + if image_file: + pix = GdkPixbuf.Pixbuf.new_from_file(image_file) + self._orig_image = pix + self._image = self._process_image(pix) + elif pixbuf: + self._orig_image = pixbuf + self._image = self._process_image(pixbuf) + self.connect("draw", self.on_draw) + + def _process_image(self, pixbuf: GdkPixbuf.Pixbuf) -> GdkPixbuf.Pixbuf: + """Crop the image to a centered square and scale it to the widget’s size.""" + width, height = pixbuf.get_width(), pixbuf.get_height() + if width != height: + square_size = min(width, height) + x_offset = (width - square_size) // 2 + y_offset = (height - square_size) // 2 + pixbuf = pixbuf.new_subpixbuf(x_offset, y_offset, square_size, square_size) + else: + square_size = width + if square_size != self.size: + pixbuf = pixbuf.scale_simple(self.size, self.size, GdkPixbuf.InterpType.BILINEAR) + return pixbuf + + def on_draw(self, widget: "CircleImage", ctx: cairo.Context): + if self._image: + ctx.save() + # Create a circular clipping path + ctx.arc(self.size / 2, self.size / 2, self.size / 2, 0, 2 * math.pi) + ctx.clip() + # Rotate around the center of the square image + ctx.translate(self.size / 2, self.size / 2) + ctx.rotate(self._angle * math.pi / 180.0) + ctx.translate(-self.size / 2, -self.size / 2) + Gdk.cairo_set_source_pixbuf(ctx, self._image, 0, 0) + ctx.paint() + ctx.restore() + + def set_image_from_file(self, new_image_file: str): + if not new_image_file: + return + pixbuf = GdkPixbuf.Pixbuf.new_from_file(new_image_file) + self._orig_image = pixbuf + self._image = self._process_image(pixbuf) + self.queue_draw() + + def set_image_from_pixbuf(self, pixbuf: GdkPixbuf.Pixbuf): + if not pixbuf: + return + self._orig_image = pixbuf + self._image = self._process_image(pixbuf) + self.queue_draw() + + def set_image_size(self, size: int): + self.size = size + if self._orig_image: + self._image = self._process_image(self._orig_image) + self.queue_draw() diff --git a/utils/conversion.py b/utils/conversion.py new file mode 100644 index 0000000..0f15f9a --- /dev/null +++ b/utils/conversion.py @@ -0,0 +1,619 @@ +import threading +import time +from concurrent.futures import ThreadPoolExecutor +from typing import Dict, Optional, Tuple + +import requests + + +class CurrencyCache: + """Thread-safe currency exchange rate cache with background updates.""" + + def __init__(self): + # {base_currency: {rates_data, timestamp}} + self._cache: Dict[str, Dict] = {} + self._cache_lock = threading.Lock() + self._cache_ttl = 3600 # 1 hour in seconds + self._request_timeout = 2 # 2 seconds for faster response + self._executor = ThreadPoolExecutor( + max_workers=2, thread_name_prefix="currency" + ) + self._pending_requests: Dict[str, threading.Event] = {} + + def get_rate(self, from_code: str, to_code: str) -> Optional[Tuple[float, float]]: + """ + Get exchange rate from cache or fetch if needed. + Returns (rate, cache_age_seconds) or None if unavailable. + """ + from_lower = from_code.lower() + to_lower = to_code.lower() + + if from_lower == to_lower: + return 1.0, 0.0 + + current_time = time.time() + + with self._cache_lock: + # Check if we have fresh cached data + if from_lower in self._cache: + cache_entry = self._cache[from_lower] + cache_age = current_time - cache_entry["timestamp"] + + if cache_age < self._cache_ttl and to_lower in cache_entry["rates"]: + rate = cache_entry["rates"][to_lower]["rate"] + return rate, cache_age + + # Check if we're already fetching this currency + if from_lower in self._pending_requests: + # Don't wait, return cached data if available (even if stale) + if ( + from_lower in self._cache + and to_lower in self._cache[from_lower]["rates"] + ): + cache_entry = self._cache[from_lower] + cache_age = current_time - cache_entry["timestamp"] + rate = cache_entry["rates"][to_lower]["rate"] + return rate, cache_age + return None + + # Start background fetch + self._pending_requests[from_lower] = threading.Event() + + # Submit background task to fetch rates + self._executor.submit(self._fetch_rates_background, from_lower) + + # Return stale cached data if available + with self._cache_lock: + if ( + from_lower in self._cache + and to_lower in self._cache[from_lower]["rates"] + ): + cache_entry = self._cache[from_lower] + cache_age = current_time - cache_entry["timestamp"] + rate = cache_entry["rates"][to_lower]["rate"] + return rate, cache_age + + return None + + def _fetch_rates_background(self, from_code: str): + """Fetch exchange rates in background thread.""" + try: + url = f"https://www.floatrates.com/daily/{from_code}.json" + response = requests.get(url, timeout=self._request_timeout) + + if response.status_code == 200: + rates_data = response.json() + current_time = time.time() + + with self._cache_lock: + self._cache[from_code] = { + "rates": rates_data, + "timestamp": current_time, + } + + except Exception as e: + print(f"Background currency fetch failed for {from_code}: {e}") + finally: + # Mark request as complete + with self._cache_lock: + if from_code in self._pending_requests: + self._pending_requests[from_code].set() + del self._pending_requests[from_code] + + def cleanup(self): + """Cleanup resources.""" + self._executor.shutdown(wait=False) + with self._cache_lock: + self._cache.clear() + self._pending_requests.clear() + + +# Global currency cache instance +_currency_cache = CurrencyCache() + + +class Units: + def __init__(self): + self.WEIGHT_CHART: dict[str, tuple[float, float]] = { + "kilogram": (1, 1), + "kg": (1, 1), + "tonne": (1000, 0.001), + "ton": (1000, 0.001), + "gram": (1e-3, 1e3), + "g": (1e-3, 1e3), + "milligram": (1e-6, 1e6), + "mg": (1e-6, 1e6), + "metric-ton": (1000, 0.001), + "metric-tonne": (1000, 0.001), + "long-ton": (1016.04608, 0.0009842073), + "short-ton": (907.184, 0.0011023122), + "pound": (0.453592, 2.2046244202), + "lb": (0.453592, 2.2046244202), + "stone": (6.35029, 0.1574731728), + "st": (6.35029, 0.1574731728), + "ounce": (0.0283495, 35.273990723), + "oz": (0.0283495, 35.273990723), + "carrat": (0.0002, 5000), + "ct": (0.0002, 5000), + "atomic-mass-unit": (1.660540199e-27, 6.022136652e26), + } + + self.LENGTH_CHART: dict[str, float] = { + # meter + "m": 1, + "M": 1, + "meter": 1, + # kilometer + "km": 1e3, + "KM": 1e3, + "kilometer": 1e3, + # centimeter + "cm": 1e-2, + "CM": 1e-2, + "centimeter": 1e-2, + # millimeter + "mm": 1e-3, + "MM": 1e-3, + "millimeter": 1e-3, + # micrometer + "um": 1e-6, + "UM": 1e-6, + "micrometer": 1e-6, + # nanometer + "nm": 1e-9, + "NM": 1e-9, + "nanometer": 1e-9, + # mile + "mi": 1609.344, + "MI": 1609.344, + "mile": 1609.344, + # yard + "yd": 0.9144, + "YD": 0.9144, + "yard": 0.9144, + # foot + "ft": 0.3048, + "FT": 0.3048, + "foot": 0.3048, + "feet": 0.3048, + # inch + "in": 0.0254, + "IN": 0.0254, + "inch": 0.0254, + "inches": 0.0254, + # nautical mile + "nmi": 1852, + "NMI": 1852, + "nautical-mile": 1852, + } + + self.STORAGE_TYPE_CHART: dict[str, float] = { + "bit": 1, + "byte": 8, + "B": 8, + "kilobyte": 8192, + "KB": 8192, + "megabyte": 8388608, + "MB": 8388608, + "gigabyte": 8589934592, + "GB": 8589934592, + "terabyte": 8796093022208, + "TB": 8796093022208, + "petabyte": 9007199254740992, + "PB": 9007199254740992, + "exabyte": 9223372036854775808, + "EB": 9223372036854775808, + } + + self.TEMPERATURE_CHART = { + "celsius": (lambda v: v + 273.15, lambda v: v - 273.15), + "c": (lambda v: v + 273.15, lambda v: v - 273.15), + "fahrenheit": ( + lambda v: (v - 32) * 5 / 9 + 273.15, + lambda v: (v - 273.15) * 9 / 5 + 32, + ), + "f": ( + lambda v: (v - 32) * 5 / 9 + 273.15, + lambda v: (v - 273.15) * 9 / 5 + 32, + ), + "kelvin": (lambda v: v, lambda v: v), + "k": (lambda v: v, lambda v: v), + "rankine": (lambda v: v * 5 / 9, lambda v: v * 9 / 5), + "reaumur": (lambda v: v * 5 / 4 + 273.15, lambda v: (v - 273.15) * 4 / 5), + } + + self.TIME_CHART: dict[str, float] = { + "second": 1, + "s": 1, + "minute": 60, + "min": 60, + "m": 60, + "hour": 3600, + "h": 3600, + "milisecond": 1e-3, + "ms": 1e-3, + "day": 86400, + "d": 86400, + "week": 604800, + "w": 604800, + "fortnight": 1209600, + "month": 2628000, # Approximation (30.44 days) + "mo": 2628000, # Approximation (30.44 days) + "year": 31536000, # Approximation (365 days) + "yr": 31536000, # Approximation (365 days) + "decade": 315360000, # Approximation (10 years) + "dec": 315360000, # Approximation (10 years) + "century": 3153600000, # Approximation (100 years) + "cent": 3153600000, # Approximation (100 years) + "millennium": 31536000000, # Approximation (1000 years) + "millenia": 31536000000, # Approximation (1000 years) + } + + self.LIQUID_VOLUME_CHART: dict[str, float] = { + "liter": 1, + "l": 1, + "milliliter": 1e-3, + "ml": 1e-3, + "gallon": 3.78541, + "quart": 0.946353, + "pint": 0.473176, + "fluid-ounce": 0.0295735, + "fl-oz": 0.0295735, + "oz": 0.0295735, + "ounce": 0.0295735, + "cup": 0.236588, + "tablespoon": 0.0147868, + "tbsp": 0.0147868, + "teaspoon": 0.00492892, + "tsp": 0.00492892, + } + + self.ANGLE_CHART: dict[str, float] = { + "degree": 1, + "deg": 1, + "radian": 57.2958, + "rad": 57.2958, + "gradian": 0.9, + "gon": 0.9, + } + + self.ENERGY_CHART: dict[str, float] = { + "joule": 1, + "j": 1, + "kilojoule": 1000, + "kj": 1000, + "calorie": 4.184, + "cal": 4.184, + "kilocalorie": 4184, + "kcal": 4184, + "watt-hour": 3600, + "wh": 3600, + "kilowatt-hour": 3.6e6, + "kwh": 3.6e6, + } + + self.SPEED_CHART: dict[str, float] = { + "mps": 1, + "kmph": 0.277778, + "mph": 0.44704, + "fps": 0.3048, + "knot": 0.514444, + } + + self.PRESSURE_CHART: dict[str, float] = { + "pascal": 1, + "Pa": 1, + "bar": 100000, + "atm": 101325, + "torr": 133.322, + "mmHg": 133.322, + "psi": 6894.76, + } + + self.FORCE_CHART: dict[str, float] = { + "newton": 1, + "N": 1, + "kilonewton": 1000, + "kN": 1000, + "pound-force": 4.44822, + "lbf": 4.44822, + "dyne": 1e-5, + } + + self.POWER_CHART: dict[str, float] = { + "watt": 1, + "W": 1, + "kilowatt": 1000, + "kW": 1000, + "horsepower": 745.7, + "hp": 745.7, + "megawatt": 1e6, + "MW": 1e6, + } + + self.VOLTAGE_CHART: dict[str, float] = { + "volt": 1, + "V": 1, + "millivolt": 1e-3, + "mV": 1e-3, + "kilovolt": 1000, + "kV": 1000, + "megavolt": 1e6, + "MV": 1e6, + } + + self.CURRENT_CHART: dict[str, float] = { + "ampere": 1, + "A": 1, + "milliampere": 1e-3, + "mA": 1e-3, + "microampere": 1e-6, + "μA": 1e-6, + } + + self.RESISTANCE_CHART: dict[str, float] = { + "ohm": 1, + "Ω": 1, + "kilohm": 1000, + "kΩ": 1000, + "megohm": 1e6, + "MΩ": 1e6, + } + + self.CAPACITANCE_CHART: dict[str, float] = { + "farad": 1, + "F": 1, + "millifarad": 1e-3, + "mF": 1e-3, + "microfarad": 1e-6, + "μF": 1e-6, + "nanofarad": 1e-9, + "nF": 1e-9, + } + + self.INDUCTANCE_CHART: dict[str, float] = { + "henry": 1, + "H": 1, + "millihenry": 1e-3, + "mH": 1e-3, + "microhenry": 1e-6, + "μH": 1e-6, + "nanohenry": 1e-9, + "nH": 1e-9, + } + + self.FREQUENCY_CHART: dict[str, float] = { + "hertz": 1, + "Hz": 1, + "kilohertz": 1e3, + "kHz": 1e3, + "megahertz": 1e6, + "MHz": 1e6, + "gigahertz": 1e9, + "GHz": 1e9, + } + + self.LUMINANCE_CHART: dict[str, float] = { + "candela": 1, + "cd": 1, + "lumen": 1, + "lm": 1, + "lux": 1, + "lx": 1, + } + + self.AREA_CHART: dict[str, float] = { + "square-meter": 1, + "m2": 1, + "square-kilometer": 1e6, + "km2": 1e6, + "hectare": 1e4, + "ha": 1e4, + "are": 1e2, + "a": 1e2, + "square-centimeter": 1e-4, + "cm2": 1e-4, + "square-millimeter": 1e-6, + "mm2": 1e-6, + } + + # We no longer use currency_converter here. + + +class Conversion: + def __init__(self): + self.units = Units() + self.currency_cache = _currency_cache + + def convert(self, value: float, from_type: str, to_type: str): + """ + Generalized conversion function that works with all categories, + including currency via floatrates.com. + """ + # Collection of all non-currency charts + charts = { + "WEIGHT_CHART": self.units.WEIGHT_CHART, + "LENGTH_CHART": self.units.LENGTH_CHART, + "TEMPERATURE_CHART": self.units.TEMPERATURE_CHART, + "TIME_CHART": self.units.TIME_CHART, + "LIQUID_VOLUME_CHART": self.units.LIQUID_VOLUME_CHART, + "STORAGE_TYPE_CHART": self.units.STORAGE_TYPE_CHART, + "ANGLE_CHART": self.units.ANGLE_CHART, + "ENERGY_CHART": self.units.ENERGY_CHART, + "SPEED_CHART": self.units.SPEED_CHART, + "PRESSURE_CHART": self.units.PRESSURE_CHART, + "FORCE_CHART": self.units.FORCE_CHART, + "POWER_CHART": self.units.POWER_CHART, + "VOLTAGE_CHART": self.units.VOLTAGE_CHART, + "CURRENT_CHART": self.units.CURRENT_CHART, + "RESISTANCE_CHART": self.units.RESISTANCE_CHART, + "CAPACITANCE_CHART": self.units.CAPACITANCE_CHART, + "INDUCTANCE_CHART": self.units.INDUCTANCE_CHART, + "FREQUENCY_CHART": self.units.FREQUENCY_CHART, + "LUMINANCE_CHART": self.units.LUMINANCE_CHART, + "AREA_CHART": self.units.AREA_CHART, + } + + # 1) Check if it's in any of the charts (non-currency) + for chart_name, chart in charts.items(): + if from_type in chart and to_type in chart: + # Temperatures use lambdas + if chart_name == "TEMPERATURE_CHART": + if from_type == to_type: + return value + to_kelvin = chart[from_type][0] + from_kelvin = chart[to_type][1] + return from_kelvin(to_kelvin(value)) + + # Handle WEIGHT_CHART separately (tuple values) + if chart_name == "WEIGHT_CHART": + if from_type == to_type: + return value + to_kg = chart[from_type][0] + from_kg = chart[to_type][1] + return value * to_kg * from_kg + + # Any other numeric chart + if from_type == to_type: + return value + return value * (chart[from_type] / chart[to_type]) + + # 2) If both are currency codes (e.g. “USD”, “ARS”) + # we assume they are uppercase and have 3 letters. + if ( + len(from_type) == 3 + and len(to_type) == 3 + and from_type.isalpha() + and to_type.isalpha() + ): + result = self._convert_currency_fast(value, from_type, to_type) + if result is not None: + return result + # Fallback to slow method if fast method fails + return self._convert_currency_via_floatrates(value, from_type, to_type) + + # 3) If it doesn't fall into any case, error. + raise ValueError(f"Unsupported conversion: {from_type} to {to_type}") + + def _convert_currency_fast( + self, value: float, from_code: str, to_code: str + ) -> Optional[float]: + """ + Fast currency conversion using cached exchange rates. + Returns None if rate is not available in cache. + """ + rate_info = self.currency_cache.get_rate(from_code, to_code) + if rate_info is not None: + rate, _ = rate_info # cache_age not needed here + return value * rate + return None + + def _convert_currency_via_floatrates( + self, value: float, from_code: str, to_code: str + ) -> float: + """ + Converts using the JSON from floatrates.com: + - Makes GET to https://www.floatrates.com/daily/{from_lower}.json + - Takes the rate from the to_lower key and multiplies. + """ + from_lower = from_code.lower() + to_lower = to_code.lower() + + if from_lower == to_lower: + return value + + url = f"https://www.floatrates.com/daily/{from_lower}.json" + resp = requests.get(url, timeout=5) + if resp.status_code != 200: + raise ValueError(f"Error getting data from floatrates for {from_code}") + + data = resp.json() + if to_lower not in data: + raise ValueError( + f"Target currency '{to_code}' not found in floatrates response for '{ + from_code + }'" + ) + + rate = data[to_lower]["rate"] + return value * rate + + def parse_input_and_convert(self, input: str): + parts = input.split() + addition = "s" if parts[-1].endswith("s") else "" + + if "and" in parts: # value unit1 and value2 unit2 _ to target_unit + parts.remove("and") + if len(parts) != 6: + raise ValueError( + "Invalid format. Expected: 'value from_type and value2 from_type2 _ to_type'" + ) + + value1, from_type1, value2, from_type2, _, to_type = parts + value1, value2 = float(value1), float(value2) + from_type1 = self.clean_type(from_type1) + from_type2 = self.clean_type(from_type2) + to_type = self.clean_type(to_type) + + if from_type1 == from_type2: + return self.convert( + value1 + value2, from_type1, to_type + ), to_type + addition + else: + res = 0 + res += self.convert(value1, from_type1, to_type) + res += self.convert(value2, from_type2, to_type) + return res, to_type + addition + else: + if len(parts) != 4: + raise ValueError( + "Invalid format. Expected: 'value from_type _ to_type'" + ) + value, from_type, _, to_type = parts + value = float(value) + from_type = self.clean_type(from_type) + to_type = self.clean_type(to_type) + return self.convert(value, from_type, to_type), to_type + addition + + def clean_type(self, type: str) -> str: + """ + If it's currency (3 letters), convert to uppercase. + If it ends in 's' (and is not 'celsius'), remove the 's' for + other units.""" + if len(type) == 3 and type.isalpha(): + return type.upper() + if type.endswith("s") and type.lower() != "celsius": + # For tables that have singular/plural + singular = type[:-1].lower() + # If it exists in STORAGE_TYPE_CHART, we use it; + # if not, we return singular in lowercase for other charts. + if singular in self.units.STORAGE_TYPE_CHART: + return singular + return singular.lower() + return type + + def cleanup(self): + """Cleanup resources.""" + self.currency_cache.cleanup() + + def get_currency_cache_info( + self, from_code: str, to_code: str + ) -> Optional[Tuple[bool, float]]: + """ + Get currency cache information for UI display. + Returns (is_fresh, cache_age_seconds) or None if not cached. + """ + rate_info = self.currency_cache.get_rate(from_code, to_code) + if rate_info is not None: + _, cache_age = rate_info # rate not needed here + is_fresh = cache_age < 300 # Consider fresh if less than 5 minutes old + return is_fresh, cache_age + return None + + +# Quick usage example: +if __name__ == "__main__": + conv = Conversion() + # Convert 10 USD to ARS: + result, suffix = conv.parse_input_and_convert("10 USD _ ARS") + print(f"{result:.2f} {suffix}") # Ex: "10 USD _ ARS" -> "38754.23 ARS" diff --git a/utils/custom_image.py b/utils/custom_image.py index 1aae7f6..8096a6a 100644 --- a/utils/custom_image.py +++ b/utils/custom_image.py @@ -2,9 +2,8 @@ import math from typing import cast import cairo -from gi.repository import Gtk - from fabric.widgets.image import Image +from gi.repository import Gtk class CustomImage(Image): diff --git a/utils/icon_resolver.py b/utils/icon_resolver.py index b8bedeb..a80c8f6 100644 --- a/utils/icon_resolver.py +++ b/utils/icon_resolver.py @@ -99,4 +99,4 @@ class IconResolver: self._get_icon_from_desktop_file(desktop_file) if desktop_file else self.default_applicaiton_icon - ) \ No newline at end of file + ) diff --git a/utils/icons.py b/utils/icons.py index ed61594..2a1059f 100644 --- a/utils/icons.py +++ b/utils/icons.py @@ -5,53 +5,47 @@ font_weight: str = "normal" span: str = f"<span font-family='{font_family}' font-weight='{font_weight}'>" # Panels -apps: str = "" +apps: str = "" dashboard: str = "" chat: str = "" -wallpapers: str = "" windows: str = "" # Bar colorpicker: str = "" media: str = "" +# Toolbox + +toolbox: str = "" +ssfull: str = "" +ssregion: str = "" +sswindow: str = "" +screenshots: str = "" +screenrecord: str = "" +recordings: str = "" +ocr: str = "ﳃ" +gamemode: str = "" +gamemode_off: str = "" +close: str = "" + # Circles temp: str = "" disk: str = "" battery: str = "" memory: str = "流" cpu: str = "" +gpu: str = "" swap: str = "" -# Toolbox -toolbox: str = "" -ssfull: str = "" -ssregion: str = "" -screenshots: str = "" -screenrecord: str = "" -recordings: str = "" -ocr: str = "ﳃ" -gamemode: str = "" -gamemode_off: str = "" -close: str = "" -bell: str = "" -bell_off: str = "" -notifications_on: str = "" -notifications_off: str = "" -notifications_clear: str = "" -notifications: str = "" + +# AIchat +reload: str = "" +detach: str = "" # Wallpapers add: str = "" sort: str = "" circle: str = "" -check_box: str = "" -box: str = "" -scan: str = "" -scan_stop: str = "" -record: str = "" -update: str = "見" -updated: str = "" # Chevrons chevron_up: str = "" @@ -79,37 +73,35 @@ bat_low: str = "=" bat_full: str = "" -moderate_signal: str = "" -good_signal: str = "" -no_signal: str = "" -weak_signal: str = "" # Applets -lan: str = "" + +# Applets wifi_0: str = "" wifi_1: str = "" wifi_2: str = "" wifi_3: str = "" -wifi: str = "" world: str = "" world_off: str = "" bluetooth: str = "" night: str = "" coffee: str = "" -dnd: str = "" -darkmode: str = "﹖" - +notifications: str = "" +notifications_dots: str = "" wifi_off: str = "" bluetooth_off: str = "" night_off: str = "" -dnd_off: str = "" +notifications_off: str = "" +notifications_clear: str = "" + +download: str = "" +upload: str = "" # Bluetooth bluetooth_connected: str = "" bluetooth_disconnected: str = "" - # Player pause: str = "" play: str = "" @@ -124,22 +116,27 @@ music: str = "" rewind_backward_5: str = "謹" rewind_forward_5: str = "難" +next_fill: str = chr(0xF04AD) +previous_fill: str = chr(0xF04AE) + # Volume vol_off: str = "" vol_mute: str = "" vol_medium: str = "" vol_high: str = "" - mic: str = "" -mic_muted: str = "" +mic_mute: str = "" +speaker: str = "𐁅" +headphones: str = "屮" +mic_filled: str = "️" # Overview circle_plus: str = "" # Pins -copy_plus: str = "ﶮ" +paperclip: str = "" # Clipboard Manager clipboard: str = "" @@ -149,7 +146,6 @@ clip_text: str = "" accept: str = "" cancel: str = "" trash: str = "" -clear: str = "" # Config config: str = "" @@ -161,6 +157,20 @@ spotify: str = "ﺆ" disc: str = "𐀾" disc_off: str = "" +# Brightness +brightness_low: str = "" +brightness_medium: str = "" +brightness_high: str = "" + +brightness: str = "" + +# Dashboard +widgets: str = "" +pins: str = "" +kanban: str = "" +wallpapers: str = "" +sparkles: str = "" + # Misc dot: str = "" palette: str = "" @@ -170,8 +180,47 @@ radar: str = "" emoji: str = "" keyboard: str = "" terminal: str = "" +timer_off: str = "" +timer_on: str = "" +spy: str = "" +calculator: str = "" +file: str = "" +calendar: str = "" +connect: str = "" +disconnect: str = "ﴖ" +auth: str = "" +bookmark: str = "" +star: str = "" +process: str = "" +music: str = "" +# otp +check: str = "" +bulb: str = "" +key: str = "" +magnifier: str = "" +plus: str = "" +info: str = "" + +# Dice +dice_1: str = "" +dice_2: str = "" +dice_3: str = "" +dice_4: str = "" +dice_5: str = "" +dice_6: str = "" + +# search engine +reddit: str = "" +youtube: str = "" +duckduckgo: str = "" +github: str = "" +stackoverflow: str = "" +google: str = "" +linkedin: str = "" +wikipedia: str = "啕" + exceptions: list[str] = ["font_family", "font_weight", "span"] diff --git a/utils/inhibit.py b/utils/inhibit.py new file mode 100644 index 0000000..6b5a106 --- /dev/null +++ b/utils/inhibit.py @@ -0,0 +1,186 @@ +# From https://github.com/stwa/wayland-idle-inhibitor +# License: WTFPL Version 2 + +import argparse +import os +import subprocess +import sys +from dataclasses import dataclass +from signal import SIGINT, SIGTERM, signal +from threading import Event, Timer + +import setproctitle +from pywayland.client.display import Display +from pywayland.protocol.idle_inhibit_unstable_v1.zwp_idle_inhibit_manager_v1 import ( + ZwpIdleInhibitManagerV1, +) +from pywayland.protocol.wayland.wl_compositor import WlCompositor +from pywayland.protocol.wayland.wl_registry import WlRegistryProxy +from pywayland.protocol.wayland.wl_surface import WlSurface + + +@dataclass +class GlobalRegistry: + surface: WlSurface | None = None + inhibit_manager: ZwpIdleInhibitManagerV1 | None = None + + +def parse_duration(duration_str: str) -> int: + """Parse duration string into seconds. + Examples: '1h', '30m', '45s', '1.5h', '1.5m', '30.5s', 'on', 'off' + """ + try: + if duration_str.lower() in ["on", "off"]: + return 0 + elif duration_str.endswith("h"): + return int(float(duration_str[:-1]) * 3600) + elif duration_str.endswith("m"): + return int(float(duration_str[:-1]) * 60) + elif duration_str.endswith("s"): + return int(float(duration_str[:-1])) + else: + return int(duration_str) + except ValueError: + raise ValueError( + "Invalid duration format. Use '1h', '30m', '45s', 'on', 'off', etc." + ) + + +def kill_existing_inhibit_processes(): + """Kill any existing modus-inhibit processes.""" + try: + # Find all modus-inhibit processes except the current one + output = subprocess.check_output(["pgrep", "-f", "modus-inhibit"], text=True) + pids = output.strip().split("\n") + current_pid = str(os.getpid()) + + killed_count = 0 + for pid in pids: + pid = pid.strip() + if pid and pid != current_pid: + try: + subprocess.run(["kill", pid], check=True) + killed_count += 1 + except subprocess.CalledProcessError: + pass # Process might have already exited + + if killed_count > 0: + print(f"Stopped {killed_count} existing inhibit process(es)") + else: + print("No existing inhibit processes were running") + return killed_count + + except subprocess.CalledProcessError: + # No processes found + print("No existing inhibit processes were running") + return 0 + except Exception as e: + print(f"Error stopping existing processes: {e}") + return 0 + + +def handle_registry_global( + wl_registry: WlRegistryProxy, id_num: int, iface_name: str, version: int +) -> None: + global_registry: GlobalRegistry = wl_registry.user_data or GlobalRegistry() + + if iface_name == "wl_compositor": + compositor = wl_registry.bind(id_num, WlCompositor, version) + global_registry.surface = compositor.create_surface() # type: ignore + elif iface_name == "zwp_idle_inhibit_manager_v1": + global_registry.inhibit_manager = wl_registry.bind( + id_num, ZwpIdleInhibitManagerV1, version + ) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Inhibit system idle for a specified duration" + ) + parser.add_argument( + "duration", + nargs="?", + default="0", + help='Duration to inhibit (e.g., "1h", "30m", "45s", "on", "off"). Use "on" for indefinite, "off" to stop.', + ) + args = parser.parse_args() + + # Handle "off" argument - kill existing processes and exit + if args.duration.lower() == "off": + kill_existing_inhibit_processes() + sys.exit(0) + + done = Event() + signal(SIGINT, lambda _, __: done.set()) + signal(SIGTERM, lambda _, __: done.set()) + + global_registry = GlobalRegistry() + + try: + display = Display() + display.connect() + + registry = display.get_registry() # type: ignore + registry.user_data = global_registry + registry.dispatcher["global"] = handle_registry_global + + def shutdown() -> None: + display.dispatch() + display.roundtrip() + display.disconnect() + + display.dispatch() + display.roundtrip() + + if global_registry.surface is None: + print("Error: Failed to create Wayland surface.") + shutdown() + sys.exit(1) + + if global_registry.inhibit_manager is None: + print("Error: Your Wayland compositor does not support idle inhibition.") + print("This usually means either:") + print( + "1. Your compositor (like Hyprland) doesn't support the idle-inhibit protocol" + ) + print("2. The protocol is not enabled in your compositor") + print("\nFor Hyprland, you can enable it by adding to your config:") + print("misc:disable_autoreload = true") + print("misc:enable_wayland_protocols = true") + shutdown() + sys.exit(1) + + inhibitor = global_registry.inhibit_manager.create_inhibitor( # type: ignore + global_registry.surface + ) + + display.dispatch() + display.roundtrip() + + duration = parse_duration(args.duration) + if duration > 0: + print(f"Inhibiting idle for {args.duration}...") + # Set up timer to release inhibition + timer = Timer(duration, lambda: done.set()) + timer.start() + else: + print("Inhibiting idle indefinitely...") + + done.wait() + print("Shutting down...") + + inhibitor.destroy() + if duration > 0: + timer.cancel() + + shutdown() + + except Exception as e: + print(f"Error: {str(e)}") + print("Make sure you're running this under a Wayland session.") + sys.exit(1) + + +if __name__ == "__main__": + setproctitle.setproctitle("modus-inhibit") + main() diff --git a/utils/notification_utils.py b/utils/notification_utils.py new file mode 100644 index 0000000..46b8f6f --- /dev/null +++ b/utils/notification_utils.py @@ -0,0 +1,429 @@ +import json +import locale +import os +from datetime import datetime, timedelta + +from fabric.utils.helpers import get_relative_path +from fabric.widgets.box import Box +from gi.repository import GdkPixbuf, GLib, GObject +from loguru import logger + +import config.data as data + +# Constants +PERSISTENT_DIR = f"/tmp/{data.APP_NAME}/notifications" +PERSISTENT_HISTORY_FILE = os.path.join(PERSISTENT_DIR, "notification_history.json") +CONFIG_FILE = get_relative_path("../config/assets/config.json") +NOTIFICATION_IMAGE_PREFIX = "notification_" +NOTIFICATION_IMAGE_SUFFIX = ".png" +DEFAULT_NOTIFICATION_IMAGE_SIZE = 48 +MAX_PERSISTENT_NOTIFICATIONS = 100 + +# Global shared notification history instance +_shared_notification_history = None + + +class HistoricalNotification: + """Data structure for historical notification data""" + + def __init__( + self, id, app_icon, summary, body, app_name, timestamp, cached_image_path=None + ): + self.id = id + self.app_icon = app_icon + self.summary = summary + self.body = body + self.app_name = app_name + self.timestamp = timestamp + self.cached_image_path = cached_image_path + self.image_pixbuf = None + self.actions = [] + self.cached_scaled_pixbuf = None + + +# Image handling functions +def cache_notification_pixbuf(notification_box): + """Cache notification image to disk and return cache file path""" + notification = notification_box.notification + try: + if notification.image_pixbuf: + os.makedirs(PERSISTENT_DIR, exist_ok=True) + cache_file = os.path.join( + PERSISTENT_DIR, + f"{NOTIFICATION_IMAGE_PREFIX}{notification_box.uuid}{ + NOTIFICATION_IMAGE_SUFFIX + }", + ) + + try: + scaled = notification.image_pixbuf.scale_simple( + DEFAULT_NOTIFICATION_IMAGE_SIZE, + DEFAULT_NOTIFICATION_IMAGE_SIZE, + GdkPixbuf.InterpType.BILINEAR, + ) + scaled.savev(cache_file, "png", [], []) + return cache_file + except Exception as e: + logger.error(f"Error caching image for notification {notification.id}: {e}") + return None + else: + logger.debug(f"Notification {notification.id} has no image_pixbuf to cache.") + return None + except Exception as e: + logger.debug(f"Error accessing image_pixbuf for notification {notification.id}: {e}") + return None + + +def load_scaled_pixbuf(notification_box, width, height): + """Load and scale pixbuf from notification box""" + notification = notification_box.notification + if not hasattr(notification_box, "notification") or notification is None: + return None + + pixbuf = None + + # Try to load from cached image first + if ( + hasattr(notification_box, "cached_image_path") + and notification_box.cached_image_path + and os.path.exists(notification_box.cached_image_path) + ): + try: + logger.debug( + f"Attempting to load cached image from: { + notification_box.cached_image_path + } " + f"for notification {notification.id}" + ) + pixbuf = GdkPixbuf.Pixbuf.new_from_file(notification_box.cached_image_path) + if pixbuf: + pixbuf = pixbuf.scale_simple( + width, height, GdkPixbuf.InterpType.BILINEAR + ) + return pixbuf + except Exception as e: + logger.error( + f"Error loading cached image from {notification_box.cached_image_path} " + f"for notification {notification.id}: {e}" + ) + logger.warning( + f"Falling back to notification.image_pixbuf for notification { + notification.id + }" + ) + + # Fall back to notification image_pixbuf + try: + if notification.image_pixbuf: + pixbuf = notification.image_pixbuf.scale_simple( + width, height, GdkPixbuf.InterpType.BILINEAR + ) + return pixbuf + except Exception as e: + logger.debug(f"Error accessing image_pixbuf for notification {notification.id}: {e}") + + # Fall back to app icon + return get_app_icon_pixbuf(notification.app_icon, width, height) + + +def get_app_icon_pixbuf(icon_path, width, height): + """Load and scale app icon pixbuf""" + if not icon_path: + return None + if icon_path.startswith("file://"): + icon_path = icon_path[7:] + if not os.path.exists(icon_path): + logger.warning(f"Icon path does not exist: {icon_path}") + return None + try: + pixbuf = GdkPixbuf.Pixbuf.new_from_file(icon_path) + return pixbuf.scale_simple(width, height, GdkPixbuf.InterpType.BILINEAR) + except Exception as e: + logger.error(f"Failed to load or scale icon: {e}") + return None + + +def cleanup_orphan_cached_images(persistent_notifications): + """Clean up cached images that no longer have corresponding notifications""" + if not os.path.exists(PERSISTENT_DIR): + return + + cached_files = [ + f + for f in os.listdir(PERSISTENT_DIR) + if f.startswith(NOTIFICATION_IMAGE_PREFIX) + and f.endswith(NOTIFICATION_IMAGE_SUFFIX) + ] + if not cached_files: + return + + history_uuids = { + note.get("id") for note in persistent_notifications if note.get("id") + } + for cached_file in cached_files: + try: + uuid_from_filename = cached_file[ + len(NOTIFICATION_IMAGE_PREFIX) : -len(NOTIFICATION_IMAGE_SUFFIX) + ] + if uuid_from_filename not in history_uuids: + cache_file_path = os.path.join(PERSISTENT_DIR, cached_file) + os.remove(cache_file_path) + except Exception as e: + print(f"Error processing cached file {cached_file} during cleanup: {e}") + + +def delete_cached_image(cached_image_path): + """Delete a specific cached image file""" + if cached_image_path and os.path.exists(cached_image_path): + try: + os.remove(cached_image_path) + logger.info(f"Deleted cached image: {cached_image_path}") + except Exception as e: + logger.error(f"Error deleting cached image {cached_image_path}: {e}") + + +# Time and date utilities +def get_ordinal(n): + """Get ordinal suffix for a number (1st, 2nd, 3rd, etc.)""" + if 11 <= (n % 100) <= 13: + return "th" + else: + return {1: "st", 2: "nd", 3: "rd"}.get(n % 10, "th") + + +def get_date_header(dt): + """Get human-readable date header for a datetime""" + now = datetime.now() + today = now.date() + date = dt.date() + + if date == today: + return "Today" + elif date == today - timedelta(days=1): + return "Yesterday" + else: + original_locale = locale.getlocale(locale.LC_TIME) + try: + locale.setlocale(locale.LC_TIME, ("en_US", "UTF-8")) + except locale.Error: + locale.setlocale(locale.LC_TIME, "C") + try: + day = dt.day + ordinal = get_ordinal(day) + month = dt.strftime("%B") + if dt.year == now.year: + result = f"{month} {day}{ordinal}" + else: + result = f"{month} {day}{ordinal}, {dt.year}" + finally: + locale.setlocale(locale.LC_TIME, original_locale) + return result + + +def schedule_midnight_update(callback): + """Schedule a callback to run at midnight""" + now = datetime.now() + next_midnight = datetime.combine( + now.date() + timedelta(days=1), datetime.min.time() + ) + delta_seconds = (next_midnight - now).total_seconds() + return GLib.timeout_add_seconds(int(delta_seconds), callback) + + +def compute_time_label(arrival_time): + """Compute time label for notification timestamp""" + return arrival_time.strftime("%H:%M") + + +# Data creation utilities +def create_notification_data(notification_box): + """Create notification data dictionary from notification box""" + notification = notification_box.notification + + return { + "id": notification_box.uuid, + "app_icon": getattr(notification, "app_icon", "dialog-information-symbolic"), + "summary": getattr(notification, "summary", "No summary"), + "body": getattr(notification, "body", ""), + "app_name": getattr(notification, "app_name", "Unknown"), + "timestamp": datetime.now().isoformat(), + "cached_image_path": getattr(notification_box, "cached_image_path", None), + } + + +def create_historical_notification_from_data(note_data): + """Create HistoricalNotification object from data dictionary""" + return HistoricalNotification( + id=note_data.get("id"), + app_icon=note_data.get("app_icon"), + summary=note_data.get("summary"), + body=note_data.get("body"), + app_name=note_data.get("app_name"), + timestamp=note_data.get("timestamp"), + cached_image_path=note_data.get("cached_image_path"), + ) + + +# Storage utilities +def ensure_persistent_dir(): + """Ensure the persistent directory exists""" + if not os.path.exists(PERSISTENT_DIR): + os.makedirs(PERSISTENT_DIR, exist_ok=True) + + +def load_persistent_history(): + """Load notification history from persistent storage""" + ensure_persistent_dir() + + if os.path.exists(PERSISTENT_HISTORY_FILE): + try: + with open(PERSISTENT_HISTORY_FILE, "r") as f: + notifications = json.load(f) + logger.info( + f"Loaded {len(notifications)} notifications from persistent history" + ) + return notifications + except Exception as e: + logger.error(f"Error loading persistent history: {e}") + return [] + else: + return [] + + +def save_persistent_history(notifications): + """Save notification history to persistent storage""" + try: + ensure_persistent_dir() + with open(PERSISTENT_HISTORY_FILE, "w") as f: + json.dump(notifications, f, indent=2) + logger.info(f"Saved {len(notifications)} notifications to persistent history") + except Exception as e: + logger.error(f"Error saving persistent history: {e}") + + +def load_dnd_state(): + """Load Do Not Disturb state from config""" + try: + if os.path.exists(CONFIG_FILE): + with open(CONFIG_FILE, "r") as f: + config_data = json.load(f) + dnd_enabled = config_data.get("dnd_enabled", False) + logger.info(f"Loaded DND state: {dnd_enabled}") + return dnd_enabled + except Exception as e: + logger.error(f"Error loading DND state: {e}") + + return False + + +def save_dnd_state(dnd_enabled): + """Save Do Not Disturb state to config""" + try: + config_data = {} + if os.path.exists(CONFIG_FILE): + with open(CONFIG_FILE, "r") as f: + config_data = json.load(f) + + config_data["dnd_enabled"] = dnd_enabled + + with open(CONFIG_FILE, "w") as f: + json.dump(config_data, f, indent=4) + logger.debug(f"Saved DND state: {dnd_enabled}") + except Exception as e: + logger.error(f"Error saving DND state: {e}") + + +# Shared notification history class +class NotificationHistory(Box): + """Shared notification history with DND state management""" + + __gsignals__ = { + "dnd-state-changed": (GObject.SignalFlags.RUN_FIRST, None, (bool,)), + "notification-added": (GObject.SignalFlags.RUN_FIRST, None, ()), + } + + def __init__(self, **kwargs): + super().__init__(name="notification-history", orientation="v", **kwargs) + self.do_not_disturb_enabled = False + self.persistent_notifications = [] + self._load_persistent_history() + self._load_dnd_state() + + def set_do_not_disturb_enabled(self, enabled): + """Set DND state and emit signal if changed""" + if self.do_not_disturb_enabled != enabled: + self.do_not_disturb_enabled = enabled + save_dnd_state(enabled) + self.emit("dnd-state-changed", enabled) + + def _load_persistent_history(self): + """Load persistent notification history""" + self.persistent_notifications = load_persistent_history() + + def _load_dnd_state(self): + """Load DND state from config""" + self.do_not_disturb_enabled = load_dnd_state() + + def _save_persistent_history(self): + """Save persistent notification history""" + save_persistent_history(self.persistent_notifications) + + def add_notification(self, notification_box): + """Add notification to history""" + try: + hist_data = create_notification_data(notification_box) + self.persistent_notifications.append(hist_data) + + # Remove old notifications if we exceed the limit + if len(self.persistent_notifications) > MAX_PERSISTENT_NOTIFICATIONS: + self.persistent_notifications.pop(0) + + self._save_persistent_history() + self.emit("notification-added") + except Exception as e: + print(f"Error adding notification to history: {e}") + + def clear_history_for_app(self, app_name): + """Clear history for specific app""" + try: + original_count = len(self.persistent_notifications) + self.persistent_notifications = [ + notif + for notif in self.persistent_notifications + if notif.get("app_name") != app_name + ] + removed_count = original_count - len(self.persistent_notifications) + if removed_count > 0: + self._save_persistent_history() + logger.info( + f"Cleared {removed_count} notifications for app: {app_name}" + ) + except Exception as e: + print(f"Error clearing history for app {app_name}: {e}") + + def clear_history(self): + """Clear all notification history""" + try: + # Clear cached images for all notifications + for notif in self.persistent_notifications: + cached_image_path = notif.get("cached_image_path") + if cached_image_path: + delete_cached_image(cached_image_path) + + # Clear the persistent notifications list + self.persistent_notifications.clear() + + # Save the empty history + self._save_persistent_history() + + logger.info("Cleared all notification history") + except Exception as e: + print(f"Error clearing notification history: {e}") + + +def get_shared_notification_history(): + """Get or create the shared notification history instance""" + global _shared_notification_history + if _shared_notification_history is None: + _shared_notification_history = NotificationHistory() + return _shared_notification_history diff --git a/utils/occlusion.py b/utils/occlusion.py index bf06350..b81ee75 100644 --- a/utils/occlusion.py +++ b/utils/occlusion.py @@ -1,5 +1,5 @@ -import subprocess import json +import subprocess import config.data as data @@ -138,4 +138,3 @@ def check_occlusion(occlusion_region, workspace=None): return True # Occlusion region is occupied return False # No window overlaps the occlusion region - diff --git a/utils/wayland.py b/utils/wayland.py new file mode 100644 index 0000000..472dc40 --- /dev/null +++ b/utils/wayland.py @@ -0,0 +1,359 @@ +import re +from collections.abc import Iterable +from enum import Enum +from typing import Literal, cast + +import cairo +import gi +from fabric.core.service import Property +from fabric.utils.helpers import extract_css_values, get_enum_member +from fabric.widgets.window import Window +from gi.repository import Gdk, GObject, Gtk +from loguru import logger + +gi.require_version("Gtk", "3.0") + +try: + gi.require_version("GtkLayerShell", "0.1") + from gi.repository import GtkLayerShell +except: + raise ImportError( + "looks like we don't have gtk-layer-shell installed, make sure to install it first (as well as using wayland)" + ) + + +class WaylandWindowExclusivity(Enum): + NONE = 1 + NORMAL = 2 + AUTO = 3 + + +class Layer(GObject.GEnum): + BACKGROUND = 0 + BOTTOM = 1 + TOP = 2 + OVERLAY = 3 + ENTRY_NUMBER = 4 + + +class KeyboardMode(GObject.GEnum): + NONE = 0 + EXCLUSIVE = 1 + ON_DEMAND = 2 + ENTRY_NUMBER = 3 + + +class Edge(GObject.GEnum): + LEFT = 0 + RIGHT = 1 + TOP = 2 + BOTTOM = 3 + ENTRY_NUMBER = 4 + + +class WaylandWindow(Window): + @Property( + Layer, + flags="read-write", + default_value=Layer.TOP, + ) + def layer(self) -> Layer: # type: ignore + return self._layer # type: ignore + + @layer.setter + def layer( + self, + value: Literal["background", "bottom", "top", "overlay"] | Layer, + ) -> None: + self._layer = get_enum_member(Layer, value, default=Layer.TOP) + return GtkLayerShell.set_layer(self, self._layer) + + @Property(int, "read-write") + def monitor(self) -> int: + if not (monitor := cast(Gdk.Monitor, GtkLayerShell.get_monitor(self))): + return -1 + display = monitor.get_display() or Gdk.Display.get_default() + for i in range(0, display.get_n_monitors()): + if display.get_monitor(i) is monitor: + return i + return -1 + + @monitor.setter + def monitor(self, monitor: int | Gdk.Monitor) -> bool: + if isinstance(monitor, int): + display = Gdk.Display().get_default() + monitor = display.get_monitor(monitor) + return ( + (GtkLayerShell.set_monitor(self, monitor), True)[1] + if monitor is not None + else False + ) + + @Property(WaylandWindowExclusivity, "read-write") + def exclusivity(self) -> WaylandWindowExclusivity: + return self._exclusivity + + @exclusivity.setter + def exclusivity( + self, value: Literal["none", "normal", "auto"] | WaylandWindowExclusivity + ) -> None: + value = get_enum_member( + WaylandWindowExclusivity, value, default=WaylandWindowExclusivity.NONE + ) + self._exclusivity = value + match value: + case WaylandWindowExclusivity.NORMAL: + return GtkLayerShell.set_exclusive_zone(self, True) + case WaylandWindowExclusivity.AUTO: + return GtkLayerShell.auto_exclusive_zone_enable(self) + case _: + return GtkLayerShell.set_exclusive_zone(self, False) + + @Property(bool, "read-write", default_value=False) + def pass_through(self) -> bool: + return self._pass_through + + @pass_through.setter + def pass_through(self, pass_through: bool = False): + self._pass_through = pass_through + region = cairo.Region() if pass_through is True else None + self.input_shape_combine_region(region) + del region + return + + @Property( + KeyboardMode, + "read-write", + default_value=KeyboardMode.NONE, + ) + def keyboard_mode(self) -> KeyboardMode: + return self._keyboard_mode + + @keyboard_mode.setter + def keyboard_mode( + self, + value: Literal[ + "none", + "exclusive", + "on-demand", + "entry-number", + ] + | KeyboardMode, + ): + self._keyboard_mode = get_enum_member( + KeyboardMode, value, default=KeyboardMode.NONE + ) + return GtkLayerShell.set_keyboard_mode(self, self._keyboard_mode) + + @Property(tuple[Edge, ...], "read-write") + def anchor(self): + return tuple( + x + for x in [ + Edge.TOP, + Edge.RIGHT, + Edge.BOTTOM, + Edge.LEFT, + ] + if GtkLayerShell.get_anchor(self, x) + ) + + @anchor.setter + def anchor(self, value: str | Iterable[Edge]) -> None: + self._anchor = value + if isinstance(value, (list, tuple)) and all( + isinstance(edge, Edge) for edge in value + ): + for edge in [ + Edge.TOP, + Edge.RIGHT, + Edge.BOTTOM, + Edge.LEFT, + ]: + if edge not in value: + GtkLayerShell.set_anchor(self, edge, False) + GtkLayerShell.set_anchor(self, edge, True) + return + elif isinstance(value, str): + for edge, anchored in WaylandWindow.extract_edges_from_string( + value + ).items(): + GtkLayerShell.set_anchor(self, edge, anchored) + + return + + @Property(tuple[int, ...], flags="read-write") + def margin(self) -> tuple[int, ...]: + return tuple( + GtkLayerShell.get_margin(self, x) + for x in [ + Edge.TOP, + Edge.RIGHT, + Edge.BOTTOM, + Edge.LEFT, + ] + ) + + @margin.setter + def margin(self, value: str | Iterable[int]) -> None: + for edge, mrgv in WaylandWindow.extract_margin(value).items(): + GtkLayerShell.set_margin(self, edge, mrgv) + return + + @Property(object, "read-write") + def keyboard_mode(self): + kb_mode = GtkLayerShell.get_keyboard_mode(self) + if GtkLayerShell.get_keyboard_interactivity(self): + kb_mode = KeyboardMode.EXCLUSIVE + return kb_mode + + @keyboard_mode.setter + def keyboard_mode( + self, + value: Literal["none", "exclusive", "on-demand"] | KeyboardMode, + ): + return GtkLayerShell.set_keyboard_mode( + self, + get_enum_member( + KeyboardMode, + value, + default=KeyboardMode.NONE, + ), + ) + + def __init__( + self, + layer: Literal["background", "bottom", "top", "overlay"] | Layer = Layer.TOP, + anchor: str = "", + margin: str | Iterable[int] = "0px 0px 0px 0px", + exclusivity: Literal["auto", "normal", "none"] + | WaylandWindowExclusivity = WaylandWindowExclusivity.NONE, + keyboard_mode: Literal["none", "exclusive", "on-demand"] + | KeyboardMode = KeyboardMode.NONE, + pass_through: bool = False, + monitor: int | Gdk.Monitor | None = None, + title: str = "fabric", + type: Literal["top-level", "popup"] | Gtk.WindowType = Gtk.WindowType.TOPLEVEL, + child: Gtk.Widget | None = None, + name: str | None = None, + visible: bool = True, + all_visible: bool = False, + style: str | None = None, + style_classes: Iterable[str] | str | None = None, + tooltip_text: str | None = None, + tooltip_markup: str | None = None, + h_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + v_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + h_expand: bool = False, + v_expand: bool = False, + size: Iterable[int] | int | None = None, + **kwargs, + ): + Window.__init__( + self, + title=title, + type=type, + child=child, + name=name, + visible=False, + all_visible=False, + style=style, + style_classes=style_classes, + tooltip_text=tooltip_text, + tooltip_markup=tooltip_markup, + h_align=h_align, + v_align=v_align, + h_expand=h_expand, + v_expand=v_expand, + size=size, + **kwargs, + ) + self._layer = Layer.ENTRY_NUMBER + self._keyboard_mode = KeyboardMode.NONE + self._anchor = anchor + self._exclusivity = WaylandWindowExclusivity.NONE + self._pass_through = pass_through + + GtkLayerShell.init_for_window(self) + GtkLayerShell.set_namespace(self, title) + self.connect( + "notify::title", + lambda *_: GtkLayerShell.set_namespace(self, self.get_title()), + ) + if monitor is not None: + self.monitor = monitor + self.layer = layer + self.anchor = anchor + self.margin = margin + self.keyboard_mode = keyboard_mode + self.exclusivity = exclusivity + self.pass_through = pass_through + self.show_all() if all_visible is True else self.show() if visible is True else None + + def steal_input(self) -> None: + return GtkLayerShell.set_keyboard_interactivity(self, True) + + def return_input(self) -> None: + return GtkLayerShell.set_keyboard_interactivity(self, False) + + # custom overrides + def show(self) -> None: + super().show() + return self.do_handle_post_show_request() + + def show_all(self) -> None: + super().show_all() + return self.do_handle_post_show_request() + + def do_handle_post_show_request(self) -> None: + if not self.get_children(): + logger.warning( + "[WaylandWindow] showing an empty window is not recommended, some compositors might freak out." + ) + self.pass_through = self._pass_through + return + + @staticmethod + def extract_anchor_values(string: str) -> tuple[str, ...]: + """ + extracts the geometry values from a given geometry string. + + :param string: the string containing the geometry values. + :type string: str + :return: a list of unique directions extracted from the geometry string. + :rtype: list + """ + direction_map = {"l": "left", "t": "top", "r": "right", "b": "bottom"} + pattern = re.compile(r"\b(left|right|top|bottom)\b", re.IGNORECASE) + matches = pattern.findall(string) + return tuple(set(tuple(direction_map[match.lower()[0]] for match in matches))) + + @staticmethod + def extract_edges_from_string(string: str) -> dict["Edge", bool]: + anchor_values = WaylandWindow.extract_anchor_values(string.lower()) + return { + Edge.TOP: "top" in anchor_values, + Edge.RIGHT: "right" in anchor_values, + Edge.BOTTOM: "bottom" in anchor_values, + Edge.LEFT: "left" in anchor_values, + } + + @staticmethod + def extract_margin(input: str | Iterable[int]) -> dict["Edge", int]: + margins = ( + extract_css_values(input.lower()) + if isinstance(input, str) + else input + if isinstance(input, (tuple, list)) and len(input) == 4 + else (0, 0, 0, 0) + ) + return { + Edge.TOP: margins[0], + Edge.RIGHT: margins[1], + Edge.BOTTOM: margins[2], + Edge.LEFT: margins[3], + } diff --git a/utils/wiggle_bar.py b/utils/wiggle_bar.py new file mode 100644 index 0000000..69298c2 --- /dev/null +++ b/utils/wiggle_bar.py @@ -0,0 +1,174 @@ +import math +from fabric.core.service import Service, Signal +from gi.repository import Gtk, Gdk, GLib +import gi + +gi.require_version("Gtk", "3.0") + + +class WigglyWidget(Gtk.DrawingArea, Service): + @Signal + def on_seek(self, ratio: float) -> None: ... + + def __init__(self): + super().__init__(name="wiggle-bar") + self.phase = 0 + self.set_size_request(-1, 20) + self.connect("draw", self.on_draw) + + # mouse events + self.add_events( + Gdk.EventMask.BUTTON_PRESS_MASK + | Gdk.EventMask.BUTTON_RELEASE_MASK + | Gdk.EventMask.POINTER_MOTION_MASK + ) + + self.connect("button-press-event", self.on_button_press) + self.connect("motion-notify-event", self.on_motion) + self.connect("button-release-event", self.on_button_release) + + GLib.timeout_add(16, self.update) # 60 FPS + self.value = 0.0 + self.amplitude = 2 + self.dragging = False + self.pause = False + + self.show_all() + + def animate_amplitude_to(self): + if abs(self.amplitude - self.amplitude_target) < 0.01: + self.amplitude = self.amplitude_target + self.queue_draw() + return False + else: + self.amplitude += self.amplitude_step + self.queue_draw() + return True + + def update_amplitude(self, decrease): + if decrease: + self.amplitude_target = 0 + self.amplitude_step = (0 - self.amplitude) / 10.0 + else: + self.amplitude_target = 2 + self.amplitude_step = (2 - self.amplitude) / 10.0 + + # animation stops when False is returned btw + GLib.timeout_add(16, self.animate_amplitude_to) + + def update_value_from_x(self, x): + width = self.get_allocated_width() + self.value = max(0.0, min(1.0, x / width)) + self.on_seek(self.value) + self.queue_draw() + + def on_button_press(self, widget, event): + self.dragging = True + self.update_amplitude(True) + self.update_value_from_x(event.x) + return True + + # def animate_progress_to(self): + # print("frame") + # if self.value < self.value_target: + # self.value = self.value_target + # print(self.value) + # self.queue_draw() + # return False + # else: + # self.value += self.value_step + # self.queue_draw() + # return True + + # def update_value_from_signal(self, new_value): + # self.value_target = min(1.0, new_value) + # steps = 60 # 1s / 16ms ≈ 60 frames + # self.value_step = (self.value_target - self.value) / steps + # GLib.timeout_add(16, self.animate_progress_to) + + def update_value_from_signal(self, new_value): + self.value = min(1.0, new_value) + + def on_motion(self, widget, event): + if self.dragging and not self.pause: + self.update_value_from_x(event.x) + + cursor = Gdk.Cursor.new(Gdk.CursorType.HAND1) + widget.get_window().set_cursor(cursor) + + return True + + def on_button_release(self, widget, event): + if self.pause == False: + self.dragging = False + self.update_amplitude(False) + return True + + def update(self): + if self.dragging == False: + self.phase += 0.1 + # if (self.value<1): + # self.value += 0.001 + self.queue_draw() + return True + + def on_draw(self, widget, cr): + alloc_width = self.get_allocated_width() + height = self.get_allocated_height() + center_y = height / 2 + # self.amplitude = 2 + frequency = 0.3 + slider_diameter = 6 + + width = int(alloc_width * self.value) - slider_diameter + 1 + + cr.set_source_rgb(1, 1, 1) # color here btw + cr.set_line_width(2) + + last_x = 0 # fallback values in case the slider gets dragged out of range + last_y = 0 + + cr.move_to(0, center_y) + for x in range(width): + y = center_y + self.amplitude * math.sin((x * frequency) + self.phase) + cr.line_to(x, y) + last_x, last_y = x, y + + cr.stroke() + + # Set the color using CSS - the widget should have CSS styling applied + # that sets the color to var(--primary). For now, use a fallback approach. + style_context = self.get_style_context() + + try: + # Get the foreground color which should be styled with CSS + color = style_context.get_color(Gtk.StateFlags.NORMAL) + cr.set_source_rgba(color.red, color.green, color.blue, color.alpha) + except: + # Fallback to a nice blue color that matches the primary theme + cr.set_source_rgb(0.2, 0.6, 1.0) + + rect_width = 6 + arc_radius = slider_diameter / 2 + self.draw_rounded_rect(cr, last_x, height, rect_width, rect_width, arc_radius) + cr.fill() + + cr.stroke() + + cr.set_source_rgb(1, 1, 1) + cr.set_line_width(1) + + # This is for anti aliasing. When line width becomes 1px and the position is not an integer, two lines of opacity 50% is drawn on two pixel rows. + # This moves the line to make it pixel perfect hence not needing anti aliasing + cr.translate(0, 0.5) + + cr.move_to(last_x + 2 * arc_radius, height / 2) + cr.line_to(alloc_width, height / 2) + + cr.stroke() + + def draw_rounded_rect(self, cr, x, y, width, _height, radius): + cr.new_sub_path() + cr.arc(x + width - radius, radius, radius, -math.pi, 0) + cr.arc(x + width - radius, y - radius, radius, 0, -math.pi) + cr.close_path()