diff --git a/README.md b/README.md index 53835a6..ed42b17 100644 --- a/README.md +++ b/README.md @@ -26,17 +26,17 @@

Home Screen:

- fabric + fabric

Lock Screen:

- fabric + fabric

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

Rocket Todo

-## Manual Installation (WIP) +## Manual Installation ```bash -paru -S glace-git gtk-session-lock uv apple-fonts cinnamon-desktop --needed +paru -S fabric-cli-git gtk-session-lock uv apple-fonts cinnamon-desktop hyprshot hypridle hyprpicker grim slurp gnome-bluetooth-3.0 cliphist matugen-bin awww swappy wl-clipboard webp-pixbuf-loader acpi wf-recorder brightnessctl power-profile-daemon uwsm libnotify playerctl ffmpeg --needed git clone https://github.com/S4NKALP/Modus ~/.config/Modus cd ~/.config/Modus uv sync -uv run main.py +uv run start ``` - [x] Launcher diff --git a/config.json b/config.json deleted file mode 100644 index 1f35478..0000000 --- a/config.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "wallpapers_dir": "~/Pictures/Wallpapers/", - "dock_position": "Bottom", - "terminal_command": "kitty -e", - "dock_enabled": true, - "dock_auto_hide": true, - "dock_always_occluded": false, - "dock_icon_size": 52, - "window_switcher_items_per_row": 10, - "hide_special_workspace": true, - "dock_hide_special_workspace_apps": true, - "notification_timeout": "5s", - "notification_ignored_apps": [ - "Hyprshot" - ], - "notification_limited_apps_history": [ - "Spotify" - ], - "imac_button": true, - "systray": true, - "control_center": true, - "search": true, - "global_menu": true, - "network": true, - "battery": true, - "notification_center": true, - "workspace_indicator": true, - "bluetooth": true, - "date_time": true, - "keyboard_layouts": [ - "us", - "np" - ], - "dock": true, - "window_switcher": true, - "osd": true, - "systray_ignore": ["blueman", "network"] -} diff --git a/debug_memory.py b/debug_memory.py deleted file mode 100644 index 2e175e7..0000000 --- a/debug_memory.py +++ /dev/null @@ -1,165 +0,0 @@ -#!/usr/bin/env python3 -""" -Real-time memory monitor for debugging expanded player memory leaks. -This module provides functions to track memory usage in real-time. -""" - -import psutil -import os -import gc -import threading -import time -from loguru import logger - - -class MemoryMonitor: - """Real-time memory monitoring for debugging memory leaks.""" - - def __init__(self): - self.process = psutil.Process(os.getpid()) - self.baseline_memory = None - self.last_memory = None - self.monitoring = False - self.monitor_thread = None - - def get_memory_usage(self): - """Get current memory usage in MB.""" - return self.process.memory_info().rss / 1024 / 1024 - - def get_memory_details(self): - """Get detailed memory information.""" - memory_info = self.process.memory_info() - memory_percent = self.process.memory_percent() - - return { - "rss_mb": memory_info.rss / 1024 / 1024, - "vms_mb": memory_info.vms / 1024 / 1024, - "percent": memory_percent, - "num_threads": self.process.num_threads(), - } - - def set_baseline(self, label="Baseline"): - """Set the baseline memory usage.""" - self.baseline_memory = self.get_memory_usage() - logger.info(f"🎯 {label} memory: {self.baseline_memory:.1f} MB") - return self.baseline_memory - - def log_memory_change(self, label="Memory Check", force_gc=True): - """Log current memory usage and change from baseline.""" - if force_gc: - gc.collect() - - current_memory = self.get_memory_usage() - details = self.get_memory_details() - - if self.baseline_memory: - delta = current_memory - self.baseline_memory - logger.info( - f"📊 {label}: {current_memory:.1f} MB (Δ: {delta:+.1f} MB) | Threads: { - details['num_threads'] - }" - ) - else: - logger.info( - f"📊 {label}: {current_memory:.1f} MB | Threads: { - details['num_threads'] - }" - ) - - self.last_memory = current_memory - return current_memory - - def log_memory_spike(self, threshold_mb=10): - """Log if there's a significant memory increase.""" - if self.last_memory: - current = self.get_memory_usage() - increase = current - self.last_memory - if increase > threshold_mb: - logger.warning( - f"🚨 MEMORY SPIKE: +{increase:.1f} MB (from {self.last_memory:.1f} to {current:.1f} MB)" - ) - return True - return False - - def start_continuous_monitoring(self, interval_seconds=2): - """Start continuous background monitoring.""" - if self.monitoring: - return - - self.monitoring = True - - def monitor_loop(): - while self.monitoring: - try: - self.log_memory_change("Continuous Monitor", force_gc=False) - time.sleep(interval_seconds) - except Exception as e: - logger.error(f"Memory monitor error: {e}") - break - - self.monitor_thread = threading.Thread(target=monitor_loop, daemon=True) - self.monitor_thread.start() - logger.info( - f"🔍 Started continuous memory monitoring (every {interval_seconds}s)" - ) - - def stop_continuous_monitoring(self): - """Stop continuous monitoring.""" - if self.monitoring: - self.monitoring = False - logger.info("🛑 Stopped continuous memory monitoring") - - -# Global memory monitor instance -memory_monitor = MemoryMonitor() - -# Convenience functions for easy use - - -def set_memory_baseline(label="Baseline"): - """Set memory baseline.""" - return memory_monitor.set_baseline(label) - - -def log_memory(label="Memory Check"): - """Log current memory usage.""" - return memory_monitor.log_memory_change(label) - - -def start_memory_monitoring(): - """Start continuous memory monitoring.""" - memory_monitor.start_continuous_monitoring() - - -def stop_memory_monitoring(): - """Stop continuous memory monitoring.""" - memory_monitor.stop_continuous_monitoring() - - -def check_memory_spike(): - """Check for memory spike.""" - return memory_monitor.log_memory_spike() - - -# Test function - - -def test_memory_monitor(): - """Test the memory monitor.""" - print("Testing Memory Monitor...") - monitor = MemoryMonitor() - - monitor.set_baseline("Test Start") - - # Simulate some memory usage - big_list = [i for i in range(100000)] - monitor.log_memory_change("After creating big list") - - del big_list - monitor.log_memory_change("After deleting big list") - - print("Memory monitor test complete!") - - -if __name__ == "__main__": - test_memory_monitor() diff --git a/install.sh b/install.sh index 599cd4e..49ac6ab 100755 --- a/install.sh +++ b/install.sh @@ -30,6 +30,8 @@ PACKAGES=( hypridle hyprsunset hyprpicker + hyprshot + grim libnotify matugen-bin playerctl @@ -283,20 +285,6 @@ else success "All packages are up-to-date" fi -# Configuration -# progress "Running configuration" -# if [ -f "$INSTALL_DIR/config/config.py" ]; then -# step "Initializing Modus configuration..." -# if python "$INSTALL_DIR/config/config.py" 2>/dev/null; then -# success "Configuration completed" -# else -# warn "Configuration step failed or was skipped" -# fi -# else -# info "No configuration file found, skipping" -# fi - -# Hyprland configuration progress "Configuring Hyprland" HYPR_CONFIG="$HOME/.config/hypr/hyprland.conf" @@ -330,10 +318,10 @@ else fi step "Starting Modus..." -if uwsm app -- uv run "$INSTALL_DIR/main.py" >/dev/null 2>&1 & then +if uwsm app -- uv run --project "$INSTALL_DIR" start >/dev/null 2>&1 & then disown - sleep 1 - if pgrep -f "python.*main.py" >/dev/null; then + sleep 2 + if pgrep -x "modus" >/dev/null; then success "Modus is now running" else warn "Modus may not have started correctly" @@ -347,7 +335,7 @@ fi echo "" echo -e "${GREEN}${BOLD}╔════════════════════════════════════════╗${RESET}" echo -e "${GREEN}${BOLD}║ ║${RESET}" -echo -e "${GREEN}${BOLD}║ Installation completed! 🎉 ║${RESET}" +echo -e "${GREEN}${BOLD}║ Installation completed! ║${RESET}" echo -e "${GREEN}${BOLD}║ ║${RESET}" echo -e "${GREEN}${BOLD}╚════════════════════════════════════════╝${RESET}" echo "" diff --git a/main.py b/main.py deleted file mode 100644 index 0ccbee3..0000000 --- a/main.py +++ /dev/null @@ -1,63 +0,0 @@ -from fabric import Application -from fabric.utils import get_relative_path, logger, monitor_file - -from config.data import APP_NAME -from modules.desktop.widget import Deskwidgets -from modules.launcher.main import Launcher -from modules.notification.notification import ModusNoti -from modules.osd.main import OSDWindow -from modules.panel.main import Panel -from modules.switcher import ApplicationSwitcher -from modules.dock import Dock -from utils.functions import set_process_name - -for log in [ - "fabric.hyprland.widgets", - "fabric.audio.service", - "fabric.bluetooth.service", - "services.network", - "utils.wayland", -]: - logger.disable(log) - -if __name__ == "__main__": - set_process_name(APP_NAME) - - # Load configuration - from config.data import load_config - - # About().toggle(None) - config = load_config() - switcher = ApplicationSwitcher() - panel = Panel() - modusnoti = ModusNoti() - launcher = Launcher() - deskwidget = Deskwidgets() - dock = Dock() - osd = OSDWindow() - # Monitor CSS files for changes - css_file = monitor_file(get_relative_path("styles")) - _ = css_file.connect("changed", lambda *_: set_css()) - - app = Application( - f"{APP_NAME}", - panel, - modusnoti, - deskwidget.top_left, - deskwidget.bottom_left, - osd, - launcher, - switcher, - dock, - ) - - def set_css(): - app.set_stylesheet_from_file( - get_relative_path("styles/main.css"), - ) - - app.set_css = set_css - - app.set_css() - - app.run() diff --git a/pyproject.toml b/pyproject.toml index d5d3a53..59ebccb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,3 +23,17 @@ dev = [ [tool.uv.sources] fabric = { git = "https://github.com/Fabric-Development/fabric.git" } + +[project.scripts] +start = "main:main" +lock = "lock:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/shared", "src/utils", "src/window", "src/services", "src/main.py"] + +[tool.hatch.build.targets.wheel.sources] +"src" = "" diff --git a/styles/todo.css b/src/__init__.py similarity index 100% rename from styles/todo.css rename to src/__init__.py diff --git a/src/assets/icons/applets/bluetooth-clear.svg b/src/assets/icons/applets/bluetooth-clear.svg new file mode 100644 index 0000000..6348bc5 --- /dev/null +++ b/src/assets/icons/applets/bluetooth-clear.svg @@ -0,0 +1,8 @@ + + + + + + diff --git a/src/assets/icons/applets/bluetooth-off-clear.svg b/src/assets/icons/applets/bluetooth-off-clear.svg new file mode 100644 index 0000000..cea2aa5 --- /dev/null +++ b/src/assets/icons/applets/bluetooth-off-clear.svg @@ -0,0 +1,25 @@ + + + + + diff --git a/src/assets/icons/applets/bluetooth-off.svg b/src/assets/icons/applets/bluetooth-off.svg new file mode 100644 index 0000000..308fba6 --- /dev/null +++ b/src/assets/icons/applets/bluetooth-off.svg @@ -0,0 +1,76 @@ + + + + + + + + + + + + diff --git a/src/assets/icons/applets/bluetooth-paired.svg b/src/assets/icons/applets/bluetooth-paired.svg new file mode 100644 index 0000000..c400923 --- /dev/null +++ b/src/assets/icons/applets/bluetooth-paired.svg @@ -0,0 +1,9 @@ + + + + + + + diff --git a/src/assets/icons/applets/bluetooth.svg b/src/assets/icons/applets/bluetooth.svg new file mode 100644 index 0000000..d3a0b61 --- /dev/null +++ b/src/assets/icons/applets/bluetooth.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + diff --git a/src/assets/icons/applets/brightness.svg b/src/assets/icons/applets/brightness.svg new file mode 100755 index 0000000..bcc61ef --- /dev/null +++ b/src/assets/icons/applets/brightness.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/icons/applets/caffeine-off.svg b/src/assets/icons/applets/caffeine-off.svg new file mode 100644 index 0000000..b59dfe0 --- /dev/null +++ b/src/assets/icons/applets/caffeine-off.svg @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/icons/applets/caffeine-on.svg b/src/assets/icons/applets/caffeine-on.svg new file mode 100644 index 0000000..2432b4a --- /dev/null +++ b/src/assets/icons/applets/caffeine-on.svg @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/icons/applets/dnd-clear.svg b/src/assets/icons/applets/dnd-clear.svg new file mode 100644 index 0000000..19b053f --- /dev/null +++ b/src/assets/icons/applets/dnd-clear.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/icons/applets/dnd-off.svg b/src/assets/icons/applets/dnd-off.svg new file mode 100644 index 0000000..fe6a37c --- /dev/null +++ b/src/assets/icons/applets/dnd-off.svg @@ -0,0 +1,53 @@ + + + + + + + diff --git a/src/assets/icons/applets/dnd.svg b/src/assets/icons/applets/dnd.svg new file mode 100644 index 0000000..4ecdd99 --- /dev/null +++ b/src/assets/icons/applets/dnd.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/assets/icons/applets/flight-off.svg b/src/assets/icons/applets/flight-off.svg new file mode 100644 index 0000000..d59785c --- /dev/null +++ b/src/assets/icons/applets/flight-off.svg @@ -0,0 +1,82 @@ + + + + + + + + + + + + + diff --git a/src/assets/icons/applets/flight-on.svg b/src/assets/icons/applets/flight-on.svg new file mode 100644 index 0000000..5a492f1 --- /dev/null +++ b/src/assets/icons/applets/flight-on.svg @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + diff --git a/src/assets/icons/applets/network-wired-offline.svg b/src/assets/icons/applets/network-wired-offline.svg new file mode 100644 index 0000000..c1734ca --- /dev/null +++ b/src/assets/icons/applets/network-wired-offline.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/assets/icons/applets/network-wired.svg b/src/assets/icons/applets/network-wired.svg new file mode 100644 index 0000000..7b6881e --- /dev/null +++ b/src/assets/icons/applets/network-wired.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/icons/applets/redshift-status-off.svg b/src/assets/icons/applets/redshift-status-off.svg new file mode 100644 index 0000000..e81d366 --- /dev/null +++ b/src/assets/icons/applets/redshift-status-off.svg @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + diff --git a/src/assets/icons/applets/redshift-status-on.svg b/src/assets/icons/applets/redshift-status-on.svg new file mode 100644 index 0000000..aaa2959 --- /dev/null +++ b/src/assets/icons/applets/redshift-status-on.svg @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + diff --git a/src/assets/icons/applets/wifi-clear.svg b/src/assets/icons/applets/wifi-clear.svg new file mode 100644 index 0000000..9276ebe --- /dev/null +++ b/src/assets/icons/applets/wifi-clear.svg @@ -0,0 +1,16 @@ + + + + + + + + diff --git a/src/assets/icons/applets/wifi-off-clear.svg b/src/assets/icons/applets/wifi-off-clear.svg new file mode 100644 index 0000000..d19de29 --- /dev/null +++ b/src/assets/icons/applets/wifi-off-clear.svg @@ -0,0 +1,44 @@ + + + + + + + + + diff --git a/src/assets/icons/applets/wifi-off.svg b/src/assets/icons/applets/wifi-off.svg new file mode 100644 index 0000000..ba5c100 --- /dev/null +++ b/src/assets/icons/applets/wifi-off.svg @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + diff --git a/src/assets/icons/applets/wifi.svg b/src/assets/icons/applets/wifi.svg new file mode 100644 index 0000000..6a0dd08 --- /dev/null +++ b/src/assets/icons/applets/wifi.svg @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/icons/battery/battery-000-charging.svg b/src/assets/icons/battery/battery-000-charging.svg new file mode 100644 index 0000000..ac8c28f --- /dev/null +++ b/src/assets/icons/battery/battery-000-charging.svg @@ -0,0 +1,10 @@ + + + + + diff --git a/src/assets/icons/battery/battery-000.svg b/src/assets/icons/battery/battery-000.svg new file mode 100644 index 0000000..c49fb45 --- /dev/null +++ b/src/assets/icons/battery/battery-000.svg @@ -0,0 +1,6 @@ + + + + diff --git a/src/assets/icons/battery/battery-010-charging.svg b/src/assets/icons/battery/battery-010-charging.svg new file mode 100644 index 0000000..c9ea308 --- /dev/null +++ b/src/assets/icons/battery/battery-010-charging.svg @@ -0,0 +1,11 @@ + + + + + + diff --git a/src/assets/icons/battery/battery-010.svg b/src/assets/icons/battery/battery-010.svg new file mode 100644 index 0000000..b88e8fb --- /dev/null +++ b/src/assets/icons/battery/battery-010.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/src/assets/icons/battery/battery-020-charging.svg b/src/assets/icons/battery/battery-020-charging.svg new file mode 100644 index 0000000..5824ad4 --- /dev/null +++ b/src/assets/icons/battery/battery-020-charging.svg @@ -0,0 +1,11 @@ + + + + + + diff --git a/src/assets/icons/battery/battery-020.svg b/src/assets/icons/battery/battery-020.svg new file mode 100644 index 0000000..87375ae --- /dev/null +++ b/src/assets/icons/battery/battery-020.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/src/assets/icons/battery/battery-030-charging.svg b/src/assets/icons/battery/battery-030-charging.svg new file mode 100644 index 0000000..c0088ce --- /dev/null +++ b/src/assets/icons/battery/battery-030-charging.svg @@ -0,0 +1,11 @@ + + + + + + diff --git a/src/assets/icons/battery/battery-030.svg b/src/assets/icons/battery/battery-030.svg new file mode 100644 index 0000000..312ab1b --- /dev/null +++ b/src/assets/icons/battery/battery-030.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/src/assets/icons/battery/battery-040-charging.svg b/src/assets/icons/battery/battery-040-charging.svg new file mode 100644 index 0000000..1327b10 --- /dev/null +++ b/src/assets/icons/battery/battery-040-charging.svg @@ -0,0 +1,11 @@ + + + + + + diff --git a/src/assets/icons/battery/battery-040.svg b/src/assets/icons/battery/battery-040.svg new file mode 100644 index 0000000..ff8a28a --- /dev/null +++ b/src/assets/icons/battery/battery-040.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/src/assets/icons/battery/battery-050-charging.svg b/src/assets/icons/battery/battery-050-charging.svg new file mode 100644 index 0000000..016985f --- /dev/null +++ b/src/assets/icons/battery/battery-050-charging.svg @@ -0,0 +1,11 @@ + + + + + + diff --git a/src/assets/icons/battery/battery-050.svg b/src/assets/icons/battery/battery-050.svg new file mode 100644 index 0000000..0e6af06 --- /dev/null +++ b/src/assets/icons/battery/battery-050.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/src/assets/icons/battery/battery-060-charging.svg b/src/assets/icons/battery/battery-060-charging.svg new file mode 100644 index 0000000..e23c733 --- /dev/null +++ b/src/assets/icons/battery/battery-060-charging.svg @@ -0,0 +1,11 @@ + + + + + + diff --git a/src/assets/icons/battery/battery-060.svg b/src/assets/icons/battery/battery-060.svg new file mode 100644 index 0000000..f9c44b9 --- /dev/null +++ b/src/assets/icons/battery/battery-060.svg @@ -0,0 +1,8 @@ + + + + + + diff --git a/src/assets/icons/battery/battery-070-charging.svg b/src/assets/icons/battery/battery-070-charging.svg new file mode 100644 index 0000000..cf18c55 --- /dev/null +++ b/src/assets/icons/battery/battery-070-charging.svg @@ -0,0 +1,11 @@ + + + + + + diff --git a/src/assets/icons/battery/battery-070.svg b/src/assets/icons/battery/battery-070.svg new file mode 100644 index 0000000..abd1a8e --- /dev/null +++ b/src/assets/icons/battery/battery-070.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/src/assets/icons/battery/battery-080-charging.svg b/src/assets/icons/battery/battery-080-charging.svg new file mode 100644 index 0000000..672c08f --- /dev/null +++ b/src/assets/icons/battery/battery-080-charging.svg @@ -0,0 +1,11 @@ + + + + + + diff --git a/src/assets/icons/battery/battery-080.svg b/src/assets/icons/battery/battery-080.svg new file mode 100644 index 0000000..0a78582 --- /dev/null +++ b/src/assets/icons/battery/battery-080.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/src/assets/icons/battery/battery-090-charging.svg b/src/assets/icons/battery/battery-090-charging.svg new file mode 100644 index 0000000..2e81ab9 --- /dev/null +++ b/src/assets/icons/battery/battery-090-charging.svg @@ -0,0 +1,11 @@ + + + + + + diff --git a/src/assets/icons/battery/battery-090.svg b/src/assets/icons/battery/battery-090.svg new file mode 100644 index 0000000..8cc1219 --- /dev/null +++ b/src/assets/icons/battery/battery-090.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/src/assets/icons/battery/battery-100-charging.svg b/src/assets/icons/battery/battery-100-charging.svg new file mode 100644 index 0000000..22471ae --- /dev/null +++ b/src/assets/icons/battery/battery-100-charging.svg @@ -0,0 +1,11 @@ + + + + + + diff --git a/src/assets/icons/battery/battery-100.svg b/src/assets/icons/battery/battery-100.svg new file mode 100644 index 0000000..9d9104b --- /dev/null +++ b/src/assets/icons/battery/battery-100.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/src/assets/icons/brightness/brightness-0.svg b/src/assets/icons/brightness/brightness-0.svg new file mode 100755 index 0000000..d6065c7 --- /dev/null +++ b/src/assets/icons/brightness/brightness-0.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/icons/brightness/brightness-1.svg b/src/assets/icons/brightness/brightness-1.svg new file mode 100755 index 0000000..e5b4101 --- /dev/null +++ b/src/assets/icons/brightness/brightness-1.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/icons/brightness/brightness-2.svg b/src/assets/icons/brightness/brightness-2.svg new file mode 100755 index 0000000..df7cfba --- /dev/null +++ b/src/assets/icons/brightness/brightness-2.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/icons/brightness/brightness-3.svg b/src/assets/icons/brightness/brightness-3.svg new file mode 100755 index 0000000..c3b390b --- /dev/null +++ b/src/assets/icons/brightness/brightness-3.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/icons/brightness/brightness.svg b/src/assets/icons/brightness/brightness.svg new file mode 100755 index 0000000..bcc61ef --- /dev/null +++ b/src/assets/icons/brightness/brightness.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/icons/mic/microphone-0.svg b/src/assets/icons/mic/microphone-0.svg new file mode 100755 index 0000000..ef1f97a --- /dev/null +++ b/src/assets/icons/mic/microphone-0.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/icons/mic/microphone-1.svg b/src/assets/icons/mic/microphone-1.svg new file mode 100755 index 0000000..dfa3008 --- /dev/null +++ b/src/assets/icons/mic/microphone-1.svg @@ -0,0 +1,9 @@ + + + + + + + diff --git a/src/assets/icons/mic/microphone-2.svg b/src/assets/icons/mic/microphone-2.svg new file mode 100755 index 0000000..0b47426 --- /dev/null +++ b/src/assets/icons/mic/microphone-2.svg @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/src/assets/icons/mic/microphone-3.svg b/src/assets/icons/mic/microphone-3.svg new file mode 100755 index 0000000..f48bd31 --- /dev/null +++ b/src/assets/icons/mic/microphone-3.svg @@ -0,0 +1,9 @@ + + + + + + + diff --git a/src/assets/icons/mic/microphone.svg b/src/assets/icons/mic/microphone.svg new file mode 100755 index 0000000..dfa3008 --- /dev/null +++ b/src/assets/icons/mic/microphone.svg @@ -0,0 +1,9 @@ + + + + + + + diff --git a/src/assets/icons/misc/caps-lock-off.svg b/src/assets/icons/misc/caps-lock-off.svg new file mode 100644 index 0000000..8dd8dbf --- /dev/null +++ b/src/assets/icons/misc/caps-lock-off.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/assets/icons/misc/caps-lock.svg b/src/assets/icons/misc/caps-lock.svg new file mode 100644 index 0000000..41c18aa --- /dev/null +++ b/src/assets/icons/misc/caps-lock.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/src/assets/icons/misc/chevron-left.svg b/src/assets/icons/misc/chevron-left.svg new file mode 100644 index 0000000..8cade29 --- /dev/null +++ b/src/assets/icons/misc/chevron-left.svg @@ -0,0 +1,13 @@ + + + + + + diff --git a/src/assets/icons/misc/chevron-right.svg b/src/assets/icons/misc/chevron-right.svg new file mode 100644 index 0000000..f81b1e6 --- /dev/null +++ b/src/assets/icons/misc/chevron-right.svg @@ -0,0 +1,13 @@ + + + + + + diff --git a/src/assets/icons/misc/control-center.svg b/src/assets/icons/misc/control-center.svg new file mode 100644 index 0000000..24f2195 --- /dev/null +++ b/src/assets/icons/misc/control-center.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/icons/misc/control.svg b/src/assets/icons/misc/control.svg new file mode 100644 index 0000000..ed4e9d7 --- /dev/null +++ b/src/assets/icons/misc/control.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/misc/imac.svg b/src/assets/icons/misc/imac.svg new file mode 100644 index 0000000..47719db --- /dev/null +++ b/src/assets/icons/misc/imac.svg @@ -0,0 +1,133 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/icons/misc/keyboard-layout.svg b/src/assets/icons/misc/keyboard-layout.svg new file mode 100644 index 0000000..b20cca7 --- /dev/null +++ b/src/assets/icons/misc/keyboard-layout.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/src/assets/icons/misc/logo.svg b/src/assets/icons/misc/logo.svg new file mode 100644 index 0000000..9fa578d --- /dev/null +++ b/src/assets/icons/misc/logo.svg @@ -0,0 +1,46 @@ + + + + + + + + + + diff --git a/src/assets/icons/misc/media-record.svg b/src/assets/icons/misc/media-record.svg new file mode 100644 index 0000000..8f71f09 --- /dev/null +++ b/src/assets/icons/misc/media-record.svg @@ -0,0 +1,8 @@ + + + + + + diff --git a/src/assets/icons/misc/search.svg b/src/assets/icons/misc/search.svg new file mode 100644 index 0000000..ef84365 --- /dev/null +++ b/src/assets/icons/misc/search.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/icons/music.svg b/src/assets/icons/music.svg new file mode 100644 index 0000000..a95e0cb --- /dev/null +++ b/src/assets/icons/music.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/icons/notification.png b/src/assets/icons/notification.png new file mode 100644 index 0000000..41d66d6 Binary files /dev/null and b/src/assets/icons/notification.png differ diff --git a/src/assets/icons/notifications/notification-active.svg b/src/assets/icons/notifications/notification-active.svg new file mode 100644 index 0000000..63452f8 --- /dev/null +++ b/src/assets/icons/notifications/notification-active.svg @@ -0,0 +1,13 @@ + + + + + diff --git a/src/assets/icons/notifications/notification-disabled.svg b/src/assets/icons/notifications/notification-disabled.svg new file mode 100644 index 0000000..d4cb163 --- /dev/null +++ b/src/assets/icons/notifications/notification-disabled.svg @@ -0,0 +1,13 @@ + + + + + diff --git a/src/assets/icons/notifications/notification-inactive.svg b/src/assets/icons/notifications/notification-inactive.svg new file mode 100644 index 0000000..108e755 --- /dev/null +++ b/src/assets/icons/notifications/notification-inactive.svg @@ -0,0 +1,12 @@ + + + + diff --git a/src/assets/icons/player/Pause.svg b/src/assets/icons/player/Pause.svg new file mode 100644 index 0000000..f95a5b8 --- /dev/null +++ b/src/assets/icons/player/Pause.svg @@ -0,0 +1,7 @@ + + + + Layer 1 + + + \ No newline at end of file diff --git a/src/assets/icons/player/Rewind.svg b/src/assets/icons/player/Rewind.svg new file mode 100644 index 0000000..e52bc1d --- /dev/null +++ b/src/assets/icons/player/Rewind.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/player/audio-switcher.svg b/src/assets/icons/player/audio-switcher.svg new file mode 100644 index 0000000..d50264f --- /dev/null +++ b/src/assets/icons/player/audio-switcher.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/icons/player/fwd.svg b/src/assets/icons/player/fwd.svg new file mode 100644 index 0000000..57f16c3 --- /dev/null +++ b/src/assets/icons/player/fwd.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/player/play.svg b/src/assets/icons/player/play.svg new file mode 100644 index 0000000..d3356bf --- /dev/null +++ b/src/assets/icons/player/play.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/assets/icons/power_modes/battery-balanced.svg b/src/assets/icons/power_modes/battery-balanced.svg new file mode 100644 index 0000000..9d9104b --- /dev/null +++ b/src/assets/icons/power_modes/battery-balanced.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/src/assets/icons/power_modes/battery-performance.svg b/src/assets/icons/power_modes/battery-performance.svg new file mode 100644 index 0000000..3bd7b42 --- /dev/null +++ b/src/assets/icons/power_modes/battery-performance.svg @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + diff --git a/src/assets/icons/power_modes/battery-power.svg b/src/assets/icons/power_modes/battery-power.svg new file mode 100644 index 0000000..87375ae --- /dev/null +++ b/src/assets/icons/power_modes/battery-power.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/src/assets/icons/todo/checkbox-check.svg b/src/assets/icons/todo/checkbox-check.svg new file mode 100644 index 0000000..198f1dd --- /dev/null +++ b/src/assets/icons/todo/checkbox-check.svg @@ -0,0 +1,58 @@ + + + + + + + + + + + + diff --git a/src/assets/icons/todo/checkbox-uncheck.svg b/src/assets/icons/todo/checkbox-uncheck.svg new file mode 100644 index 0000000..a0ee941 --- /dev/null +++ b/src/assets/icons/todo/checkbox-uncheck.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + diff --git a/src/assets/icons/todo/delete-symbolic.svg b/src/assets/icons/todo/delete-symbolic.svg new file mode 100644 index 0000000..180f034 --- /dev/null +++ b/src/assets/icons/todo/delete-symbolic.svg @@ -0,0 +1,43 @@ + + + + + + + + diff --git a/src/assets/icons/todo/edit.svg b/src/assets/icons/todo/edit.svg new file mode 100644 index 0000000..42b5282 --- /dev/null +++ b/src/assets/icons/todo/edit.svg @@ -0,0 +1,59 @@ + + + + + + + + image/svg+xml + + + + + + + + diff --git a/src/assets/icons/todo/plus-symbolic.svg b/src/assets/icons/todo/plus-symbolic.svg new file mode 100644 index 0000000..af065bd --- /dev/null +++ b/src/assets/icons/todo/plus-symbolic.svg @@ -0,0 +1,51 @@ + + + + + + + diff --git a/src/assets/icons/todo/to-do-app-symbolic.svg b/src/assets/icons/todo/to-do-app-symbolic.svg new file mode 100644 index 0000000..17cbcdf --- /dev/null +++ b/src/assets/icons/todo/to-do-app-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/volume/audio-volume-0.svg b/src/assets/icons/volume/audio-volume-0.svg new file mode 100644 index 0000000..2728d98 --- /dev/null +++ b/src/assets/icons/volume/audio-volume-0.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/assets/icons/volume/audio-volume-1.svg b/src/assets/icons/volume/audio-volume-1.svg new file mode 100644 index 0000000..8378bed --- /dev/null +++ b/src/assets/icons/volume/audio-volume-1.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/assets/icons/volume/audio-volume-2.svg b/src/assets/icons/volume/audio-volume-2.svg new file mode 100644 index 0000000..927462a --- /dev/null +++ b/src/assets/icons/volume/audio-volume-2.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/assets/icons/volume/audio-volume-3.svg b/src/assets/icons/volume/audio-volume-3.svg new file mode 100644 index 0000000..abd00cb --- /dev/null +++ b/src/assets/icons/volume/audio-volume-3.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/assets/icons/volume/audio-volume.svg b/src/assets/icons/volume/audio-volume.svg new file mode 100644 index 0000000..abd00cb --- /dev/null +++ b/src/assets/icons/volume/audio-volume.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/assets/icons/weather/weather-clear-night.svg b/src/assets/icons/weather/weather-clear-night.svg new file mode 100644 index 0000000..1c56d8c --- /dev/null +++ b/src/assets/icons/weather/weather-clear-night.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/weather/weather-clear.svg b/src/assets/icons/weather/weather-clear.svg new file mode 100644 index 0000000..7da66b9 --- /dev/null +++ b/src/assets/icons/weather/weather-clear.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/weather/weather-clouds-night.svg b/src/assets/icons/weather/weather-clouds-night.svg new file mode 100644 index 0000000..c6cf30c --- /dev/null +++ b/src/assets/icons/weather/weather-clouds-night.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/weather/weather-clouds.svg b/src/assets/icons/weather/weather-clouds.svg new file mode 100644 index 0000000..cc2e2b5 --- /dev/null +++ b/src/assets/icons/weather/weather-clouds.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/weather/weather-few-clouds-night.svg b/src/assets/icons/weather/weather-few-clouds-night.svg new file mode 100644 index 0000000..7448ada --- /dev/null +++ b/src/assets/icons/weather/weather-few-clouds-night.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/weather/weather-few-clouds.svg b/src/assets/icons/weather/weather-few-clouds.svg new file mode 100644 index 0000000..23f386a --- /dev/null +++ b/src/assets/icons/weather/weather-few-clouds.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/weather/weather-fog.svg b/src/assets/icons/weather/weather-fog.svg new file mode 100644 index 0000000..0838f14 --- /dev/null +++ b/src/assets/icons/weather/weather-fog.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/icons/weather/weather-freezing-rain.svg b/src/assets/icons/weather/weather-freezing-rain.svg new file mode 100644 index 0000000..ac8a18b --- /dev/null +++ b/src/assets/icons/weather/weather-freezing-rain.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/weather/weather-hail.svg b/src/assets/icons/weather/weather-hail.svg new file mode 100644 index 0000000..13f4cdf --- /dev/null +++ b/src/assets/icons/weather/weather-hail.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/weather/weather-many-clouds.svg b/src/assets/icons/weather/weather-many-clouds.svg new file mode 100644 index 0000000..adf556d --- /dev/null +++ b/src/assets/icons/weather/weather-many-clouds.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/src/assets/icons/weather/weather-none-available.svg b/src/assets/icons/weather/weather-none-available.svg new file mode 100644 index 0000000..cc31231 --- /dev/null +++ b/src/assets/icons/weather/weather-none-available.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/assets/icons/weather/weather-overcast-night.svg b/src/assets/icons/weather/weather-overcast-night.svg new file mode 100644 index 0000000..e830d22 --- /dev/null +++ b/src/assets/icons/weather/weather-overcast-night.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/assets/icons/weather/weather-overcast.svg b/src/assets/icons/weather/weather-overcast.svg new file mode 100644 index 0000000..27d0593 --- /dev/null +++ b/src/assets/icons/weather/weather-overcast.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/icons/weather/weather-severe-alert.svg b/src/assets/icons/weather/weather-severe-alert.svg new file mode 100644 index 0000000..9812e23 --- /dev/null +++ b/src/assets/icons/weather/weather-severe-alert.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/src/assets/icons/weather/weather-showers-day.svg b/src/assets/icons/weather/weather-showers-day.svg new file mode 100644 index 0000000..de8527e --- /dev/null +++ b/src/assets/icons/weather/weather-showers-day.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/weather/weather-showers-night.svg b/src/assets/icons/weather/weather-showers-night.svg new file mode 100644 index 0000000..85b1dd9 --- /dev/null +++ b/src/assets/icons/weather/weather-showers-night.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/weather/weather-showers-scattered-day.svg b/src/assets/icons/weather/weather-showers-scattered-day.svg new file mode 100644 index 0000000..07e7c05 --- /dev/null +++ b/src/assets/icons/weather/weather-showers-scattered-day.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/weather/weather-showers-scattered-night.svg b/src/assets/icons/weather/weather-showers-scattered-night.svg new file mode 100644 index 0000000..9a03c61 --- /dev/null +++ b/src/assets/icons/weather/weather-showers-scattered-night.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/weather/weather-showers-scattered.svg b/src/assets/icons/weather/weather-showers-scattered.svg new file mode 100644 index 0000000..66e03d9 --- /dev/null +++ b/src/assets/icons/weather/weather-showers-scattered.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/weather/weather-showers.svg b/src/assets/icons/weather/weather-showers.svg new file mode 100644 index 0000000..b7cb07a --- /dev/null +++ b/src/assets/icons/weather/weather-showers.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/weather/weather-snow-night.svg b/src/assets/icons/weather/weather-snow-night.svg new file mode 100644 index 0000000..d577ca2 --- /dev/null +++ b/src/assets/icons/weather/weather-snow-night.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/src/assets/icons/weather/weather-snow-rain.svg b/src/assets/icons/weather/weather-snow-rain.svg new file mode 100644 index 0000000..518c9cd --- /dev/null +++ b/src/assets/icons/weather/weather-snow-rain.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/weather/weather-snow-scattered-day.svg b/src/assets/icons/weather/weather-snow-scattered-day.svg new file mode 100644 index 0000000..fba0379 --- /dev/null +++ b/src/assets/icons/weather/weather-snow-scattered-day.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/weather/weather-snow-scattered-night.svg b/src/assets/icons/weather/weather-snow-scattered-night.svg new file mode 100644 index 0000000..52c7945 --- /dev/null +++ b/src/assets/icons/weather/weather-snow-scattered-night.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/weather/weather-snow-scattered.svg b/src/assets/icons/weather/weather-snow-scattered.svg new file mode 100644 index 0000000..e88f712 --- /dev/null +++ b/src/assets/icons/weather/weather-snow-scattered.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/weather/weather-snow.svg b/src/assets/icons/weather/weather-snow.svg new file mode 100644 index 0000000..a27de54 --- /dev/null +++ b/src/assets/icons/weather/weather-snow.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/weather/weather-storm-night.svg b/src/assets/icons/weather/weather-storm-night.svg new file mode 100644 index 0000000..2231272 --- /dev/null +++ b/src/assets/icons/weather/weather-storm-night.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/icons/weather/weather-storm-tornado.svg b/src/assets/icons/weather/weather-storm-tornado.svg new file mode 100644 index 0000000..41b7c6b --- /dev/null +++ b/src/assets/icons/weather/weather-storm-tornado.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/icons/weather/weather-storm.svg b/src/assets/icons/weather/weather-storm.svg new file mode 100644 index 0000000..b752ca5 --- /dev/null +++ b/src/assets/icons/weather/weather-storm.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/weather/weather-windy.svg b/src/assets/icons/weather/weather-windy.svg new file mode 100644 index 0000000..e76d9da --- /dev/null +++ b/src/assets/icons/weather/weather-windy.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/icons/wifi/network-wireless-0.svg b/src/assets/icons/wifi/network-wireless-0.svg new file mode 100644 index 0000000..79a7d67 --- /dev/null +++ b/src/assets/icons/wifi/network-wireless-0.svg @@ -0,0 +1,16 @@ + + + + + + + + diff --git a/src/assets/icons/wifi/network-wireless-100.svg b/src/assets/icons/wifi/network-wireless-100.svg new file mode 100644 index 0000000..9276ebe --- /dev/null +++ b/src/assets/icons/wifi/network-wireless-100.svg @@ -0,0 +1,16 @@ + + + + + + + + diff --git a/src/assets/icons/wifi/network-wireless-20.svg b/src/assets/icons/wifi/network-wireless-20.svg new file mode 100644 index 0000000..5dd4259 --- /dev/null +++ b/src/assets/icons/wifi/network-wireless-20.svg @@ -0,0 +1,16 @@ + + + + + + + + diff --git a/src/assets/icons/wifi/network-wireless-40.svg b/src/assets/icons/wifi/network-wireless-40.svg new file mode 100644 index 0000000..d323c5c --- /dev/null +++ b/src/assets/icons/wifi/network-wireless-40.svg @@ -0,0 +1,16 @@ + + + + + + + + diff --git a/src/assets/icons/wifi/network-wireless-60.svg b/src/assets/icons/wifi/network-wireless-60.svg new file mode 100644 index 0000000..47c2a37 --- /dev/null +++ b/src/assets/icons/wifi/network-wireless-60.svg @@ -0,0 +1,16 @@ + + + + + + + + diff --git a/src/assets/icons/wifi/network-wireless-80.svg b/src/assets/icons/wifi/network-wireless-80.svg new file mode 100644 index 0000000..6e65071 --- /dev/null +++ b/src/assets/icons/wifi/network-wireless-80.svg @@ -0,0 +1,16 @@ + + + + + + + + diff --git a/src/assets/icons/wifi/wifi-connecting.svg b/src/assets/icons/wifi/wifi-connecting.svg new file mode 100644 index 0000000..6d95e92 --- /dev/null +++ b/src/assets/icons/wifi/wifi-connecting.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/modus.png b/src/assets/modus.png new file mode 100644 index 0000000..eb680cc Binary files /dev/null and b/src/assets/modus.png differ diff --git a/src/assets/screenshots/home.png b/src/assets/screenshots/home.png new file mode 100644 index 0000000..a98ab7c Binary files /dev/null and b/src/assets/screenshots/home.png differ diff --git a/src/assets/screenshots/lock.png b/src/assets/screenshots/lock.png new file mode 100644 index 0000000..123f456 Binary files /dev/null and b/src/assets/screenshots/lock.png differ diff --git a/src/assets/wallpaper_example/example-1.png b/src/assets/wallpaper_example/example-1.png new file mode 100644 index 0000000..0dcf6f3 Binary files /dev/null and b/src/assets/wallpaper_example/example-1.png differ diff --git a/src/assets/wallpaper_example/example-2.png b/src/assets/wallpaper_example/example-2.png new file mode 100644 index 0000000..470534f Binary files /dev/null and b/src/assets/wallpaper_example/example-2.png differ diff --git a/src/assets/wallpaper_example/example-3.jpg b/src/assets/wallpaper_example/example-3.jpg new file mode 100644 index 0000000..a9084d5 Binary files /dev/null and b/src/assets/wallpaper_example/example-3.jpg differ diff --git a/src/assets/wallpaper_example/example-4.png b/src/assets/wallpaper_example/example-4.png new file mode 100644 index 0000000..61eaafa Binary files /dev/null and b/src/assets/wallpaper_example/example-4.png differ diff --git a/src/assets/wallpaper_example/example-5.png b/src/assets/wallpaper_example/example-5.png new file mode 100644 index 0000000..af9e956 Binary files /dev/null and b/src/assets/wallpaper_example/example-5.png differ diff --git a/lock.py b/src/lock.py similarity index 92% rename from lock.py rename to src/lock.py index 5909132..f548990 100644 --- a/lock.py +++ b/src/lock.py @@ -1,38 +1,32 @@ import getpass -import os -import gi import pam from fabric import Application -from fabric.utils import get_relative_path +from fabric.utils import Gdk, GLib, get_relative_path, logger, os from fabric.widgets.box import Box from fabric.widgets.centerbox import CenterBox from fabric.widgets.datetime import DateTime from fabric.widgets.entry import Entry from fabric.widgets.label import Label from fabric.widgets.window import Window -from gi.repository import ( - Gdk, # pyright: ignore[reportMissingModuleSource] - GLib, - GtkSessionLock, # pyright: ignore[reportAttributeAccessIssue] -) +from gi.repository import GtkSessionLock # pyright: ignore[reportAttributeAccessIssue] -from modules.panel.components.indicators import ( +from shared.widgets.circle_image import CircleImage as Image +from utils.functions import set_process_name +from window.panel.components.indicators import ( BatteryIndicator, BluetoothIndicator, NetworkIndicator, ) -from utils.functions import set_process_name -from widgets.circle_image import CircleImage as Image -gi.require_version("Gdk", "3.0") -gi.require_version("Gtk", "3.0") - - -gi.require_version("GtkSessionLock", "0.1") -# from fabric.widgets.image import Image - -# from widgets.wayland import WaylandWindow as Window +for log in [ + "fabric.audio.service", + "fabric.core.application", + "fabric.bluetooth.service", + "fabric.notifications.service", + "services.network", +]: + logger.disable(log) class IndicatorBox(Box): @@ -218,19 +212,24 @@ def initialize(): lockscreen.show() -if __name__ == "__main__": +def main(): set_process_name("lockscreen") initialize() lockscreen = LockScreen(GtkSessionLock.Lock()) + global app app = Application("lock", lockscreen) def set_css(): app.set_stylesheet_from_file( - get_relative_path("styles/main.css"), + get_relative_path("shared/styles/main.css"), ) app.set_css = set_css # pyright: ignore[reportAttributeAccessIssue] app.set_css() # pyright: ignore[reportAttributeAccessIssue] app.run() + + +if __name__ == "__main__": + main() diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..16586da --- /dev/null +++ b/src/main.py @@ -0,0 +1,71 @@ +import os +from fabric import Application +from fabric.utils import get_relative_path, logger, monitor_file + +from shared.data import APP_NAME, load_config +from utils.functions import set_process_name +from window.desktop.widget import Deskwidgets +from window.dock import Dock +from window.launcher.main import Launcher +from window.notification.notification import ModusNoti +from window.osd.main import OSDWindow +from window.panel.main import Panel +from window.switcher import ApplicationSwitcher + +for log in [ + "fabric", + "services", + "window", + "utils", +]: + logger.disable(log) + + +def main(): + # Generate colors.css if it doesn't exist + colors_css_path = get_relative_path("shared/styles/colors.css") + if not os.path.exists(colors_css_path): + from utils.utils import generate_colors_from_wallpaper + + default_wallpaper = get_relative_path("assets/wallpaper_example/example-1.png") + if os.path.exists(default_wallpaper): + generate_colors_from_wallpaper(default_wallpaper) + + set_process_name(APP_NAME) + + load_config() + switcher = ApplicationSwitcher() + panel = Panel() + modusnoti = ModusNoti() + launcher = Launcher() + deskwidget = Deskwidgets() + dock = Dock() + osd = OSDWindow() + # Monitor CSS files for changes + css_file = monitor_file(get_relative_path("shared/styles")) + _ = css_file.connect("changed", lambda *_: set_css()) + + app = Application( + f"{APP_NAME}", + panel, + modusnoti, + deskwidget.top_left, + deskwidget.bottom_left, + osd, + launcher, + switcher, + dock, + ) + + def set_css(): + app.set_stylesheet_from_file( + get_relative_path("shared/styles/main.css"), + ) + + app.set_css = set_css + app.set_css() + app.run() + + +if __name__ == "__main__": + main() diff --git a/services/__init__.py b/src/services/__init__.py similarity index 100% rename from services/__init__.py rename to src/services/__init__.py diff --git a/services/auth.py b/src/services/auth.py similarity index 98% rename from services/auth.py rename to src/services/auth.py index c205987..50af801 100644 --- a/services/auth.py +++ b/src/services/auth.py @@ -1,5 +1,4 @@ import json -import os import subprocess import time from datetime import datetime @@ -7,21 +6,19 @@ from pathlib import Path from urllib.parse import parse_qs, urlparse import pyotp -from fabric.utils import GdkPixbuf +from fabric.utils import GdkPixbuf, os from pyzbar.pyzbar import decode -import config.data as data +import shared.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) diff --git a/services/battery.py b/src/services/battery.py similarity index 100% rename from services/battery.py rename to src/services/battery.py diff --git a/services/brightness.py b/src/services/brightness.py similarity index 100% rename from services/brightness.py rename to src/services/brightness.py diff --git a/services/capslock.py b/src/services/capslock.py similarity index 100% rename from services/capslock.py rename to src/services/capslock.py diff --git a/services/config.py b/src/services/config.py similarity index 87% rename from services/config.py rename to src/services/config.py index fa57c9b..653b189 100644 --- a/services/config.py +++ b/src/services/config.py @@ -34,7 +34,7 @@ class ConfigService: self._reload_callbacks: List[ Callable[[Dict[str, Any], Dict[str, Any]], None] ] = [] - self._config_file: str = get_relative_path("../config.json") + self._config_file: str = get_relative_path("../../config/config.json") self._monitors: List[Gio.FileMonitor] = [] self._reload_pending: bool = False self.RELOAD_DELAY_MS: int = 100 @@ -77,7 +77,21 @@ class ConfigService: with open(self._config_file, "r") as f: self._config = json.load(f) else: - self._config = {} + try: + from utils.constants import DEFAULT + + # Ensure the config directory exists + os.makedirs(os.path.dirname(self._config_file), exist_ok=True) + with open(self._config_file, "w") as f: + json.dump(DEFAULT, f, indent=4) + + self._config = DEFAULT.copy() + logger.info( + f"[ConfigService] Generated default config at {self._config_file}" + ) + except ImportError as e: + logger.error(f"[ConfigService] Failed to import defaults: {e}") + self._config = {} except Exception as e: logger.error(f"[ConfigService] Failed to load config: {e}") self._config = {} diff --git a/services/custom_notification.py b/src/services/custom_notification.py similarity index 97% rename from services/custom_notification.py rename to src/services/custom_notification.py index 1558924..c75e89c 100644 --- a/services/custom_notification.py +++ b/src/services/custom_notification.py @@ -1,6 +1,4 @@ -# Standard library imports import json -import time from typing import List from fabric.core.service import Property, Service, Signal @@ -10,11 +8,9 @@ from fabric.notifications import ( NotificationImagePixmap, Notifications, ) +from fabric.utils import GdkPixbuf, logger, os, time -# Fabric imports -from fabric.utils import GdkPixbuf, os, logger - -import config.data as data +import shared.data as data NOTIFICATION_CACHE_FILE = f"{data.CACHE_DIR}/notification_history.json" @@ -101,7 +97,7 @@ class CachedNotification(Service): @Property(dict, "readable") def serialized(self) -> dict: """Enhanced serialization with cache metadata - stores only cache keys""" - from modules.notification.notification import ( + from window.notification.notification import ( get_cache_key, get_notification_image_cache_key, ) @@ -273,7 +269,7 @@ class CachedNotifications(Notifications): def clear_all_cached_notifications(self): """Empty the notifications with enhanced cache cleanup""" # Clean up all cached files before clearing notifications - from modules.notification.notification import cleanup_all_notification_caches + from window.notification.notification import cleanup_all_notification_caches for cached_notification in self._cached_notifications.values(): handler_id = self._signal_handlers.pop(cached_notification.cache_id, None) @@ -304,8 +300,7 @@ class CachedNotifications(Notifications): return # Import here to avoid circular imports - from config import data - from modules.notification.notification import ( + from window.notification.notification import ( cache_notification_icon, cache_notification_image, get_cache_key, @@ -467,7 +462,7 @@ class CachedNotifications(Notifications): cache_metadata = cached_notification.cache_metadata # Clean up specific cached files using stored keys - from modules.notification.notification import ( + from window.notification.notification import ( cleanup_notification_specific_caches, ) diff --git a/utils/gamemode.py b/src/services/gamemode.py similarity index 100% rename from utils/gamemode.py rename to src/services/gamemode.py diff --git a/services/keyboard_layout.py b/src/services/keyboard_layout.py similarity index 99% rename from services/keyboard_layout.py rename to src/services/keyboard_layout.py index 70aa140..717672a 100644 --- a/services/keyboard_layout.py +++ b/src/services/keyboard_layout.py @@ -6,7 +6,7 @@ from pathlib import Path from fabric.core.service import Property, Service, Signal from fabric.utils import GLib, logger, os -import config.data as data +import shared.data as data from services.config import on_config_change from utils.functions import read_json_file, run_command, write_json_file diff --git a/services/modus.py b/src/services/modus.py similarity index 100% rename from services/modus.py rename to src/services/modus.py diff --git a/services/mpris.py b/src/services/mpris.py similarity index 100% rename from services/mpris.py rename to src/services/mpris.py diff --git a/services/network.py b/src/services/network.py similarity index 100% rename from services/network.py rename to src/services/network.py diff --git a/src/services/screencapture.py b/src/services/screencapture.py new file mode 100644 index 0000000..8f6cc58 --- /dev/null +++ b/src/services/screencapture.py @@ -0,0 +1,409 @@ +import json +from datetime import datetime +from pathlib import Path + +from fabric import Service, Signal +from fabric.core.service import Property +from fabric.utils import ( + Gio, + exec_shell_command, + exec_shell_command_async, + logger, + time, +) + +from utils.functions import is_app_running, run_command, kill_process + + +class ScreenCapture(Service): + """Service for screen capture and recording functionality""" + + @Signal + def screenshot_taken(self, path: str) -> None: + """Signal emitted when screenshot is taken.""" + + @Signal + def recording_started(self, path: str) -> None: + """Signal emitted when recording starts.""" + + @Signal + def recording_stopped(self, path: str) -> None: + """Signal emitted when recording stops.""" + + _instance = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.home = Path.home() + self.screenshots_dir = self.home / "Pictures" / "Screenshots" + self.recordings_dir = self.home / "Videos" / "Recordings" + self.recording_file = Path("/tmp/recording.txt") + self.recording_start_time_file = Path("/tmp/recording_start_time.txt") + + self.screenshots_dir.mkdir(parents=True, exist_ok=True) + self.recordings_dir.mkdir(parents=True, exist_ok=True) + + def notify_send(self, title, message, icon=None, actions=None): + cmd = ["notify-send", "-a", "Modus"] + + if icon: + cmd.extend(["-i", str(icon)]) + + if actions: + for action in actions: + cmd.extend(["-A", f"{action}={action}"]) + + cmd.extend([title, message]) + return run_command(cmd) + + def send_screenshot_notification(self, file_path=None): + cmd = ["notify-send"] + cmd.extend( + [ + "-A", + "files=Show in Files", + "-A", + "view=View", + "-A", + "edit=Edit", + "-i", + "camera-photo-symbolic", + "-a", + "Fabric Screenshot Utility", + "-h", + f"STRING:image-path:{file_path}", + "Screenshot Saved", + f"Saved Screenshot at {file_path}", + ] + if file_path + else ["Screenshot Sent to Clipboard"] + ) + + proc: Gio.Subprocess = Gio.Subprocess.new(cmd, Gio.SubprocessFlags.STDOUT_PIPE) + + def do_callback(process: Gio.Subprocess, task: Gio.Task): + try: + _, stdout, stderr = process.communicate_utf8_finish(task) + except Exception: + logger.error( + f"[SCREENSHOT] Failed read notification action with error {stderr}" + ) + return + + match stdout.strip("\n"): + case "files": + exec_shell_command_async(f"xdg-open {self.screenshots_dir}") + case "view": + exec_shell_command_async(f"xdg-open {file_path}") + case "edit": + exec_shell_command_async(f"satty -f {file_path}") + + proc.communicate_utf8_async(None, None, do_callback) + self.screenshot_taken(file_path) + + def send_recording_notification(self, file_path): + cmd = ["notify-send"] + cmd.extend( + [ + "-A", + "files=Show in Files", + "-A", + "view=View", + "-i", + "camera-video-symbolic", + "-a", + "Fabric Screenshot Utility", + "Screencast Saved", + f"Saved Screencast at {file_path}", + ] + ) + + proc: Gio.Subprocess = Gio.Subprocess.new(cmd, Gio.SubprocessFlags.STDOUT_PIPE) + + def do_callback(process: Gio.Subprocess, task: Gio.Task): + try: + _, stdout, stderr = process.communicate_utf8_finish(task) + except Exception: + logger.error( + f"[SCREENCAST] Failed read notification action with error {stderr}" + ) + return + + match stdout.strip("\n"): + case "files": + exec_shell_command_async(f"xdg-open {self.recordings_dir}") + case "view": + exec_shell_command_async(f"xdg-open {file_path}") + + proc.communicate_utf8_async(None, None, do_callback) + self.recording_stopped(file_path) + + def check_wf_recorder(self): + if not is_app_running("wf-recorder"): + return False + + kill_process("wf-recorder") + + if self.recording_file.exists(): + recording_file = self.recording_file.read_text().strip() + self.send_recording_notification(recording_file) + + if self.recording_start_time_file.exists(): + self.recording_start_time_file.unlink() + + return True + + def record_video(self, output_file, *args): + cmd = [ + "wf-recorder", + *args, + "-f", + str(output_file), + "-c", + "libvpx-vp9", + "--pixel-format", + "yuv420p", + "-F", + "eq=brightness=0.12:contrast=1.1", + ] + return run_command(cmd) + + def record_video_noaudio(self, output_file, *args): + cmd = [ + "wf-recorder", + *args, + "-f", + str(output_file), + "-c", + "libvpx-vp9", + "--pixel-format", + "yuv420p", + "-F", + "eq=brightness=0.12:contrast=1.1", + "--no-audio", + ] + return run_command(cmd) + + def _get_active_monitor(self): + """Get the name of the currently focused monitor.""" + try: + monitors_json = exec_shell_command("hyprctl monitors -j") + if monitors_json: + monitors = json.loads(monitors_json) + for monitor in monitors: + if monitor.get("focused"): + return monitor.get("name") + except Exception as e: + logger.error(f"[SCREENSHOT] Failed to get active monitor: {e}") + return "eDP-1" # Fallback + + def screenshot(self, target="region"): + """ + Take a screenshot. + :param target: 'region', 'active', 'output', 'both', or a specific display name like 'eDP-1' + """ + timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + screenshot_path = self.screenshots_dir / f"{timestamp}.png" + + if target == "both": + # Special case for multiple displays, similar to the shell script + temp_edp = self.screenshots_dir / f"{timestamp}_eDP-1.png" + temp_hdmi = self.screenshots_dir / f"{timestamp}_HDMI-A-1.png" + + cmd = f"grim -c -o eDP-1 {temp_edp} && grim -c -o HDMI-A-1 {temp_hdmi} && montage {temp_edp} {temp_hdmi} -tile 2x1 -geometry +0+0 {screenshot_path} && rm {temp_edp} {temp_hdmi}" + exec_shell_command_async( + cmd, lambda *_: self.send_screenshot_notification(str(screenshot_path)) + ) + return True + + command = [ + "hyprshot", + "-s", + "-o", + str(self.screenshots_dir), + "-f", + f"{timestamp}.png", + ] + + if target == "region": + command.extend(["-m", "region"]) + elif target == "active": + active_monitor = self._get_active_monitor() + command.extend(["-m", "output", "-m", active_monitor]) + elif target == "output": + command.extend(["-m", "output"]) + else: + # Specific display name + command.extend(["-m", "output", "-m", target]) + + command.append("-- ls") + + try: + exec_shell_command_async( + " ".join(command), + self._after_screenshot, + ) + except Exception as e: + logger.error(f"Screenshot failed: {e}") + return False + + return True + + def _after_screenshot(self, *_): + try: + screenshot_files = list(self.screenshots_dir.glob("*.png")) + if screenshot_files: + latest_file = max(screenshot_files, key=lambda f: f.stat().st_mtime) + self.send_screenshot_notification(file_path=str(latest_file)) + time.sleep(0.2) + else: + # No file found after command: likely user cancelled the selection + self.notify_send( + "Screenshot cancelled", + "Selection was cancelled", + icon="camera-photo-symbolic", + ) + except Exception as e: + logger.error(f"Screenshot notification failed: {e}") + + def record(self, target="selection", no_audio=False, mode="standard"): + """ + Start recording. + :param target: 'selection', 'eDP-1', 'HDMI-A-1', etc. + :param no_audio: bool + :param mode: 'standard', 'hq', 'gif' + """ + if self.is_recording: + logger.error( + "[SCREENRECORD] Another instance of wf-recorder is already running" + ) + return False + + timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + ext = "mp4" if mode == "hq" else "gif" if mode == "gif" else "mkv" + output_file = self.recordings_dir / f"{timestamp}.{ext}" + + self._current_recording_path = str(output_file) + self.recording_file.write_text(str(output_file)) + self.recording_start_time_file.write_text(str(int(time.time()))) + + area = "" + if target == "selection": + geometry = exec_shell_command("slurp") + if not geometry or not str(geometry).strip(): + self.notify_send( + "Recording cancelled", + "Selection was cancelled", + icon="camera-video-symbolic", + ) + return False + area = f"-g '{geometry}'" + elif target == "active": + active_monitor = self._get_active_monitor() + area = f"-o {active_monitor}" + else: + area = f"-o {target}" + + if mode == "gif": + # GIF optimized recording (lower fps, temp mkv then convert) + temp_video = f"/tmp/gif_recording_{int(time.time())}.mkv" + self.recording_file.write_text(temp_video) # Update tracking to temp file + command = f"wf-recorder -f {temp_video} -c libvpx-vp9 -r 15 --pixel-format yuv420p --no-audio {area}" + + def after_gif_recording(*_): + if Path(temp_video).exists(): + self.notify_send("Converting to GIF", "Processing recording...") + conv_cmd = f"ffmpeg -i {temp_video} -vf 'fps=15,scale=iw:-1:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=128:stats_mode=diff[p];[s1][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle' -loop 0 {output_file}" + exec_shell_command_async( + conv_cmd, + lambda *_: ( + Path(temp_video).unlink(), + self.send_recording_notification(str(output_file)), + ), + ) + + exec_shell_command_async(command, after_gif_recording) + elif mode == "hq": + # High quality preset + preset_flags = "-c h264_vaapi -p 'preset=slow' -p 'crf=18' -r 60 -b 8000000 -B 192000 -g 30" + if no_audio: + preset_flags += " --no-audio" + command = f"wf-recorder --file={output_file} --pixel-format yuv420p {preset_flags} {area}" + exec_shell_command_async(command) + else: + # Standard mode + audio_flag = "--no-audio" if no_audio else "" + command = f"wf-recorder --file={output_file} --pixel-format yuv420p {audio_flag} {area}" + exec_shell_command_async(command) + + self.recording_started(str(output_file)) + return True + + def stop_recording(self): + kill_process("wf-recorder") + if hasattr(self, "_current_recording_path"): + self.recording_stopped(self._current_recording_path) + return True + + def convert(self, format_type, file_path=None): + """ + Convert video to specified format. + :param format_type: 'webm', 'iphone', 'youtube', 'gif' + :param file_path: Optional path to specific file + """ + if not file_path: + # Find latest recording + files = list(self.recordings_dir.glob("*.[mkv|mp4]*")) + if not files: + self.notify_send("Conversion Error", "No recordings found to convert") + return False + file_path = str(max(files, key=lambda f: f.stat().st_mtime)) + + file_path_obj = Path(file_path) + if not file_path_obj.exists(): + self.notify_send("Conversion Error", f"File not found: {file_path}") + return False + + output_path = file_path_obj.with_suffix(f".{format_type}") + if format_type == "iphone": + output_path = file_path_obj.with_name(f"{file_path_obj.stem}-iphone.mp4") + elif format_type == "youtube": + output_path = file_path_obj.with_name(f"{file_path_obj.stem}-youtube.mp4") + + self.notify_send( + f"Converting to {format_type.upper()}", f"Processing: {file_path_obj.name}" + ) + + if format_type == "webm": + cmd = f"ffmpeg -y -i {file_path} -c:v libvpx -b:v 1M -c:a libvorbis {output_path}" + elif format_type == "iphone": + cmd = f"ffmpeg -y -i {file_path} -vcodec h264 -acodec aac {output_path}" + elif format_type == "youtube": + cmd = f"ffmpeg -y -i {file_path} -c:v libx264 -profile:v high -preset slow -crf 18 -pix_fmt yuv420p -c:a aac -b:a 384k -movflags +faststart {output_path}" + elif format_type == "gif": + cmd = f"ffmpeg -i {file_path} -vf 'fps=15,scale=800:-1:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=256:stats_mode=diff[p];[s1][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle' -loop 0 {output_path}" + else: + return False + + def on_done(*_): + self.notify_send( + f"{format_type.upper()} Conversion Success", + f"Saved to {output_path.name}", + ) + if format_type == "gif": + exec_shell_command_async(f"wl-copy < {output_path}") + + exec_shell_command_async(cmd, on_done) + return True + + @Property(bool, "readable", default_value=False) + def is_recording(self): + return is_app_running("wf-recorder") or is_app_running("gpu-screen-recorder") + + +screen_capture_service = ScreenCapture() diff --git a/services/todo.py b/src/services/todo.py similarity index 99% rename from services/todo.py rename to src/services/todo.py index c440381..97365f8 100644 --- a/services/todo.py +++ b/src/services/todo.py @@ -8,7 +8,7 @@ from pathlib import Path from fabric.core.service import Property, Service # Local imports -import config.data as data +import shared.data as data class TodoService(Service): diff --git a/src/shared/__init__.py b/src/shared/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/shared/data.py b/src/shared/data.py new file mode 100644 index 0000000..43f9241 --- /dev/null +++ b/src/shared/data.py @@ -0,0 +1,116 @@ +import json + +from fabric.utils import Gdk, GLib, get_relative_path, os + +from utils.functions import parse_timeout_string + +APP_NAME = "modus1" +APP_NAME_CAP = "Modus" + + +CACHE_DIR = str(GLib.get_user_cache_dir()) + f"/{APP_NAME}" +USERNAME = os.getlogin() +HOSTNAME = os.uname().nodename +HOME_DIR = GLib.get_home_dir() + +CONFIG_DIR = os.path.expanduser(f"~/.config/{APP_NAME}") + +screen = Gdk.Screen.get_default() +CURRENT_WIDTH = screen.get_width() +CURRENT_HEIGHT = screen.get_height() + + +WALLPAPERS_DIR_DEFAULT = get_relative_path("../assets/wallpapers_example/") +CONFIG_FILE = get_relative_path("../../config/config.json") +MATUGEN_STATE_FILE = os.path.join(CONFIG_DIR, "matugen") + + +def load_config(): + """Load the configuration from config.json""" + try: + from services.config import start_config_service + + service = start_config_service() + return service.get_all() + except ImportError: + # Fallback to direct file loading + config = {} + + if os.path.exists(CONFIG_FILE): + try: + with open(CONFIG_FILE, "r") as f: + config = json.load(f) + except Exception as e: + print(f"Error loading config: {e}") + + return config + + +if os.path.exists(CONFIG_FILE): + with open(CONFIG_FILE, "r") as f: + config = json.load(f) + wallpapers_dir_from_config = config.get("wallpapers_dir", WALLPAPERS_DIR_DEFAULT) + WALLPAPERS_DIR = os.path.expanduser(wallpapers_dir_from_config) + DOCK_POSITION = config.get("dock_position", "Bottom") + DOCK_ENABLED = config.get("dock_enabled", True) + DOCK_AUTO_HIDE = config.get("dock_auto_hide", True) + DOCK_ALWAYS_OCCLUDED = config.get("dock_always_occluded", False) + DOCK_ICON_SIZE = config.get("dock_icon_size", 60) + WINDOW_SWITCHER_ITEMS_PER_ROW = config.get("window_switcher_items_per_row", 10) + HIDE_SPECIAL_WORKSPACE = config.get("hide_special_workspace", True) + DOCK_HIDE_SPECIAL_WORKSPACE_APPS = config.get( + "dock_hide_special_workspace_apps", True + ) + + NOTIFICATION_TIMEOUT_STR = config.get("notification_timeout", "5s") + NOTIFICATION_TIMEOUT = parse_timeout_string(NOTIFICATION_TIMEOUT_STR) + NOTIFICATION_IGNORED_APPS_HISTORY = config.get( + "notification_ignored_apps_history", ["Hyprshot"] + ) + NOTIFICATION_LIMITED_APPS_HISTORY = config.get( + "notification_limited_apps_history", ["Spotify"] + ) + + PANEL_COMPONENTS_VISIBILITY = { + "imac_button": config.get("imac_button_visible", True), + "systray": config.get("systray_visible", True), + "control_center": config.get("control_center_visible", True), + "search": config.get("search_visible", True), + "global_menu": config.get("global_menu_visible", True), + "network": config.get("network_visible", True), + "battery": config.get("battery_visible", True), + "notification_center": config.get("notification_center_visible", True), + "workspace_indicator": config.get("workspace_indicator_visible", True), + "bluetooth": config.get("bluetooth_visible", True), + "date_time": config.get("date_time_visible", True), + } + +else: + WALLPAPERS_DIR = WALLPAPERS_DIR_DEFAULT + DOCK_POSITION = "Bottom" + DOCK_ENABLED = True + DOCK_ALWAYS_OCCLUDED = False + DOCK_AUTO_HIDE = True + DOCK_ICON_SIZE = 52 + WINDOW_SWITCHER_ITEMS_PER_ROW = 10 + HIDE_SPECIAL_WORKSPACE = True + DOCK_HIDE_SPECIAL_WORKSPACE_APPS = True + + NOTIFICATION_TIMEOUT_STR = "5s" + NOTIFICATION_TIMEOUT = parse_timeout_string(NOTIFICATION_TIMEOUT_STR) + NOTIFICATION_IGNORED_APPS_HISTORY = ["Hyprshot"] + NOTIFICATION_LIMITED_APPS_HISTORY = ["Spotify"] + + PANEL_COMPONENTS_VISIBILITY = { + "imac_button": True, + "systray": True, + "control_center": True, + "search": True, + "global_menu": True, + "network": True, + "battery": True, + "notification_center": True, + "workspace_indicator": True, + "bluetooth": True, + "date_time": True, + } diff --git a/widgets/about.py b/src/shared/dialogs/about.py similarity index 99% rename from widgets/about.py rename to src/shared/dialogs/about.py index e48f11f..e203369 100644 --- a/widgets/about.py +++ b/src/shared/dialogs/about.py @@ -1,7 +1,6 @@ -import re import subprocess -from fabric.utils import GdkPixbuf, Gtk, get_relative_path, os +from fabric.utils import GdkPixbuf, Gtk, get_relative_path, os, re from utils.functions import escape_markup_text from utils.icon_resolver import IconResolver diff --git a/widgets/wifi_password_dialog.py b/src/shared/dialogs/wifi_password_dialog.py similarity index 99% rename from widgets/wifi_password_dialog.py rename to src/shared/dialogs/wifi_password_dialog.py index 2397f9a..566b3de 100644 --- a/widgets/wifi_password_dialog.py +++ b/src/shared/dialogs/wifi_password_dialog.py @@ -1,13 +1,9 @@ from fabric.utils import Gdk, GLib - from fabric.widgets.box import Box from fabric.widgets.button import Button from fabric.widgets.entry import Entry from fabric.widgets.image import Image from fabric.widgets.label import Label - -# from fabric.widgets.wayland import WaylandWindow as Window - from fabric.widgets.window import Window diff --git a/styles/about.css b/src/shared/styles/about.css similarity index 100% rename from styles/about.css rename to src/shared/styles/about.css diff --git a/styles/battery-widget.css b/src/shared/styles/battery-widget.css similarity index 100% rename from styles/battery-widget.css rename to src/shared/styles/battery-widget.css diff --git a/src/shared/styles/colors.css b/src/shared/styles/colors.css new file mode 100644 index 0000000..fa722c3 --- /dev/null +++ b/src/shared/styles/colors.css @@ -0,0 +1,32 @@ +:vars { + --foreground: #e2e2e9; + --background: #121318; + --cursor: #e2e2e9; + --primary: #b0c6ff; + --on-primary: #152e60; + --secondary: #c0c6dc; + --on-secondary: #293042; + --tertiary: #e0bbdd; + --on-tertiary: #412742; + --surface: #121318; + --surface-bright: #38393f; + --error: #ffb4ab; + --error-dim: #ff8677; + --on-error: #690005; + --error-container: #93000a; + --outline: #8f9099; + --shadow: #000000; + --red: #ffb2b9; + --red-dim: #ff7f8a; + --green: #95d5a7; + --green-dim: #70c688; + --yellow: #b8cf84; + --yellow-dim: #a2c05f; + --blue: #b7c4ff; + --blue-dim: #849afe; + --magenta: #e4b7f3; + --magenta-dim: #d38beb; + --cyan: #82d3e2; + --cyan-dim: #58c4d8; + --white: #82d3e0; +} diff --git a/styles/constant.css b/src/shared/styles/constant.css similarity index 100% rename from styles/constant.css rename to src/shared/styles/constant.css diff --git a/styles/controlcenter.css b/src/shared/styles/controlcenter.css similarity index 100% rename from styles/controlcenter.css rename to src/shared/styles/controlcenter.css diff --git a/styles/dock.css b/src/shared/styles/dock.css similarity index 100% rename from styles/dock.css rename to src/shared/styles/dock.css diff --git a/styles/dropdown.css b/src/shared/styles/dropdown.css similarity index 100% rename from styles/dropdown.css rename to src/shared/styles/dropdown.css diff --git a/styles/launcher.css b/src/shared/styles/launcher.css similarity index 100% rename from styles/launcher.css rename to src/shared/styles/launcher.css diff --git a/styles/lock.css b/src/shared/styles/lock.css similarity index 100% rename from styles/lock.css rename to src/shared/styles/lock.css diff --git a/styles/main.css b/src/shared/styles/main.css similarity index 100% rename from styles/main.css rename to src/shared/styles/main.css diff --git a/styles/notification-center.css b/src/shared/styles/notification-center.css similarity index 100% rename from styles/notification-center.css rename to src/shared/styles/notification-center.css diff --git a/styles/notification.css b/src/shared/styles/notification.css similarity index 100% rename from styles/notification.css rename to src/shared/styles/notification.css diff --git a/styles/osd.css b/src/shared/styles/osd.css similarity index 100% rename from styles/osd.css rename to src/shared/styles/osd.css diff --git a/styles/panel.css b/src/shared/styles/panel.css similarity index 100% rename from styles/panel.css rename to src/shared/styles/panel.css diff --git a/styles/password-dialog.css b/src/shared/styles/password-dialog.css similarity index 100% rename from styles/password-dialog.css rename to src/shared/styles/password-dialog.css diff --git a/styles/player.css b/src/shared/styles/player.css similarity index 100% rename from styles/player.css rename to src/shared/styles/player.css diff --git a/styles/switcher.css b/src/shared/styles/switcher.css similarity index 100% rename from styles/switcher.css rename to src/shared/styles/switcher.css diff --git a/src/shared/styles/todo.css b/src/shared/styles/todo.css new file mode 100644 index 0000000..e69de29 diff --git a/styles/tray.css b/src/shared/styles/tray.css similarity index 100% rename from styles/tray.css rename to src/shared/styles/tray.css diff --git a/styles/widgets.css b/src/shared/styles/widgets.css similarity index 100% rename from styles/widgets.css rename to src/shared/styles/widgets.css diff --git a/utils/animator.py b/src/shared/widgets/animator.py similarity index 100% rename from utils/animator.py rename to src/shared/widgets/animator.py diff --git a/widgets/circle_image.py b/src/shared/widgets/circle_image.py similarity index 97% rename from widgets/circle_image.py rename to src/shared/widgets/circle_image.py index fb68587..8488219 100644 --- a/widgets/circle_image.py +++ b/src/shared/widgets/circle_image.py @@ -1,11 +1,7 @@ -import math from typing import Literal - -import cairo - from fabric.core.service import Property +from fabric.utils import Gdk, GdkPixbuf, Gtk, cairo, math # noqa: E402 from fabric.widgets.widget import Widget -from fabric.utils import Gdk, GdkPixbuf, Gtk # noqa: E402 class CircleImage(Gtk.DrawingArea, Widget): diff --git a/widgets/clipping_box.py b/src/shared/widgets/clipping_box.py similarity index 100% rename from widgets/clipping_box.py rename to src/shared/widgets/clipping_box.py diff --git a/widgets/custom_image.py b/src/shared/widgets/custom_image.py similarity index 100% rename from widgets/custom_image.py rename to src/shared/widgets/custom_image.py diff --git a/widgets/customrevealer.py b/src/shared/widgets/customrevealer.py similarity index 98% rename from widgets/customrevealer.py rename to src/shared/widgets/customrevealer.py index 0f26f86..2ca10a3 100644 --- a/widgets/customrevealer.py +++ b/src/shared/widgets/customrevealer.py @@ -1,6 +1,6 @@ -from fabric.utils import Gtk -import math -from utils.animator import Animator +from fabric.utils import Gtk, math + +from shared.widgets.animator import Animator # macOS-style easing functions for natural motion diff --git a/widgets/battery_widget.py b/src/shared/window/battery_widget.py similarity index 99% rename from widgets/battery_widget.py rename to src/shared/window/battery_widget.py index 8fe05fc..beba25a 100644 --- a/widgets/battery_widget.py +++ b/src/shared/window/battery_widget.py @@ -5,7 +5,7 @@ from fabric.widgets.centerbox import CenterBox from fabric.widgets.image import Image from fabric.widgets.label import Label from fabric.widgets.separator import Separator -from utils.gamemode import check_gamemode, toggle_gamemode +from services.gamemode import check_gamemode, toggle_gamemode from services.battery import BatteryService, DeviceState from utils.functions import clear_children, format_duration diff --git a/widgets/dropdown.py b/src/shared/window/dropdown.py similarity index 98% rename from widgets/dropdown.py rename to src/shared/window/dropdown.py index e4e97cb..5a9a595 100644 --- a/widgets/dropdown.py +++ b/src/shared/window/dropdown.py @@ -2,7 +2,7 @@ from fabric.widgets.box import Box from fabric.widgets.centerbox import CenterBox from fabric.widgets.eventbox import EventBox from utils.roam import modus_service -from widgets.popup_window import PopupWindow +from shared.window.popup_window import PopupWindow dropdowns = [] diff --git a/widgets/mousecapture.py b/src/shared/window/mousecapture.py similarity index 99% rename from widgets/mousecapture.py rename to src/shared/window/mousecapture.py index b7373c8..1de5ff9 100644 --- a/widgets/mousecapture.py +++ b/src/shared/window/mousecapture.py @@ -1,7 +1,4 @@ -from typing import Any - -import cairo -from fabric.utils import Gdk +from fabric.utils import Any, Gdk, cairo from fabric.widgets.eventbox import EventBox from fabric.widgets.wayland import WaylandWindow as Window from fabric.widgets.widget import Widget diff --git a/widgets/popup_window.py b/src/shared/window/popup_window.py similarity index 100% rename from widgets/popup_window.py rename to src/shared/window/popup_window.py diff --git a/utils/__init__.py b/src/utils/__init__.py similarity index 100% rename from utils/__init__.py rename to src/utils/__init__.py diff --git a/utils/app_name_resolver.py b/src/utils/app_name_resolver.py similarity index 100% rename from utils/app_name_resolver.py rename to src/utils/app_name_resolver.py diff --git a/src/utils/constants.py b/src/utils/constants.py new file mode 100644 index 0000000..fe159be --- /dev/null +++ b/src/utils/constants.py @@ -0,0 +1,31 @@ +DEFAULT = { + "wallpapers_dir": "~/Pictures/Wallpapers/", + "dock_position": "Bottom", + "terminal_command": "kitty -e", + "dock_enabled": True, + "dock_auto_hide": True, + "dock_always_occluded": False, + "dock_icon_size": 52, + "window_switcher_items_per_row": 10, + "hide_special_workspace": True, + "dock_hide_special_workspace_apps": True, + "notification_timeout": "5s", + "notification_ignored_apps": ["Hyprshot"], + "notification_limited_apps_history": ["Spotify"], + "imac_button": True, + "systray": True, + "control_center": True, + "search": True, + "global_menu": True, + "network": True, + "battery": True, + "notification_center": True, + "workspace_indicator": True, + "bluetooth": True, + "date_time": True, + "keyboard_layouts": ["us", "np"], + "dock": True, + "window_switcher": True, + "osd": True, + "systray_ignore": ["blueman", "network"], +} diff --git a/utils/conversion.py b/src/utils/conversion.py similarity index 100% rename from utils/conversion.py rename to src/utils/conversion.py diff --git a/utils/dbus_helper.py b/src/utils/dbus_helper.py similarity index 100% rename from utils/dbus_helper.py rename to src/utils/dbus_helper.py diff --git a/utils/debounce.py b/src/utils/debounce.py similarity index 100% rename from utils/debounce.py rename to src/utils/debounce.py diff --git a/utils/functions.py b/src/utils/functions.py similarity index 100% rename from utils/functions.py rename to src/utils/functions.py diff --git a/utils/icon_resolver.py b/src/utils/icon_resolver.py similarity index 98% rename from utils/icon_resolver.py rename to src/utils/icon_resolver.py index 011059c..ae3a0c8 100644 --- a/utils/icon_resolver.py +++ b/src/utils/icon_resolver.py @@ -1,7 +1,8 @@ import json -from fabric.utils import GLib, Gtk, logger, os # noqa: E402 -import config.data as data # noqa: E402 +from fabric.utils import GLib, Gtk, logger, os + +import shared.data as data ICON_CACHE_FILE = data.CACHE_DIR + "/icons.json" if not os.path.exists(data.CACHE_DIR): diff --git a/utils/inhibit.py b/src/utils/inhibit.py similarity index 99% rename from utils/inhibit.py rename to src/utils/inhibit.py index 0c88bfe..9899483 100644 --- a/utils/inhibit.py +++ b/src/utils/inhibit.py @@ -14,7 +14,6 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from signal import SIGINT, SIGTERM, signal from threading import Event, Timer -from utils.functions import set_process_name from pywayland.client.display import Display from pywayland.protocol.idle_inhibit_unstable_v1.zwp_idle_inhibit_manager_v1 import ( ZwpIdleInhibitManagerV1, @@ -23,6 +22,8 @@ from pywayland.protocol.wayland.wl_compositor import WlCompositor from pywayland.protocol.wayland.wl_registry import WlRegistryProxy from pywayland.protocol.wayland.wl_surface import WlSurface +from utils.functions import set_process_name + @dataclass class GlobalRegistry: diff --git a/utils/monitors.py b/src/utils/monitors.py similarity index 100% rename from utils/monitors.py rename to src/utils/monitors.py diff --git a/utils/occlusion.py b/src/utils/occlusion.py similarity index 99% rename from utils/occlusion.py rename to src/utils/occlusion.py index b81ee75..a7b8602 100644 --- a/utils/occlusion.py +++ b/src/utils/occlusion.py @@ -1,7 +1,7 @@ import json import subprocess -import config.data as data +import shared.data as data def get_current_workspace(): diff --git a/utils/roam.py b/src/utils/roam.py similarity index 97% rename from utils/roam.py rename to src/utils/roam.py index 6fe0e1e..20a791e 100644 --- a/utils/roam.py +++ b/src/utils/roam.py @@ -1,10 +1,9 @@ from fabric.audio import Audio from fabric.utils import logger +# ruff: noqa: I001 from services.modus import ( ModusService, -) -from services.modus import ( notification_service as notification_service_instance, ) diff --git a/utils/utils.py b/src/utils/utils.py similarity index 89% rename from utils/utils.py rename to src/utils/utils.py index bc33a1b..44baf23 100644 --- a/utils/utils.py +++ b/src/utils/utils.py @@ -1,11 +1,11 @@ from pathlib import Path from typing import Literal -from fabric.utils import Gdk, bulk_connect +from fabric.utils import Gdk, bulk_connect, exec_shell_command, exec_shell_command_async from fabric.widgets.scale import Scale, ScaleMark from fabric.widgets.svg import Svg -from utils.animator import Animator +from shared.widgets.animator import Animator class AnimatedScale(Scale): @@ -29,6 +29,10 @@ class AnimatedScale(Scale): self.animator.play() +def is_app_running(app_name: str) -> bool: + return len(exec_shell_command(f"pidof {app_name}")) != 0 + + # Function to set up cursor hover def setup_cursor_hover( widget, cursor_name: Literal["pointer", "crosshair", "grab"] = "pointer" @@ -103,7 +107,7 @@ def svg_file(relative_path: str, **kwargs) -> Svg: def _resolve_path(path_like): s = str(path_like) - marker = "/config/assets/icons/" + marker = "/src/assets/icons/" if marker in s: rel = s.split(marker, 1)[1] return (BASE_SVG_PATH / rel).resolve() @@ -145,3 +149,8 @@ def svg_file(relative_path: str, **kwargs) -> Svg: svg.dynamic_style = _dynamic_style # type: ignore[attr-defined] return svg + + +def generate_colors_from_wallpaper(image_path: str) -> bool: + cmd = f'matugen image "{image_path}" --source-color-index 0' + return bool(exec_shell_command_async(cmd)) diff --git a/src/window/__init__.py b/src/window/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/window/controlcenter/__init__.py b/src/window/controlcenter/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/window/controlcenter/battery.py b/src/window/controlcenter/battery.py new file mode 100644 index 0000000..e8c32ec --- /dev/null +++ b/src/window/controlcenter/battery.py @@ -0,0 +1,615 @@ +import subprocess + +from fabric.bluetooth import BluetoothClient, BluetoothDevice +from fabric.utils import Gdk, GLib, Gtk, exec_shell_command, logger +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.centerbox import CenterBox +from fabric.widgets.image import Image +from fabric.widgets.label import Label +from fabric.widgets.revealer import Revealer +from fabric.widgets.scrolledwindow import ScrolledWindow +from fabric.widgets.separator import Separator + +from utils.utils import svg_file + + +def get_battery_icon_file( + percentage: int, is_charging: bool, icon_path: str = "" +) -> str: + """ + Get the battery icon file path based on percentage and charging status. + + Args: + percentage: Battery percentage (0-100) + is_charging: Whether the device is charging + icon_path: Base path for icons (unused, kept for compatibility) + + Returns: + Relative path to the battery icon file + """ + clamped = max(0, min(100, percentage)) + step = (clamped // 10) * 10 + filename = f"battery-{step:03d}{'-charging' if is_charging else ''}.svg" + return f"battery/{filename}" + + +def set_bluetooth_enabled_with_fallback(client, enabled: bool): + try: + # Try fabric bluetooth first + client.set_enabled(enabled) + except Exception as e: + logger.warning(f"Fabric bluetooth set_enabled({enabled}) failed: {e}") + + # Fallback to rfkill to unblock/block bluetooth + if enabled: + command = "rfkill unblock bluetooth" + else: + command = "rfkill block bluetooth" + + result = exec_shell_command(command) + if result is False: + logger.error(f"rfkill fallback failed: command '{command}' returned False") + elif isinstance(result, str) and result.strip(): + # If result is a non-empty string, it might be an error message + logger.warning(f"rfkill command output: {result.strip()}") + else: + logger.info(f"rfkill fallback succeeded: {command}") + + +class BluetoothDeviceSlot(CenterBox): + def __init__(self, device: BluetoothDevice, **kwargs): + super().__init__(h_expand=True, name="device-button", **kwargs) + self.device = device + self.device.connect("changed", self.on_changed) + self.device.connect( + "notify::closed", lambda *_: self.device.closed and self.destroy() + ) + + self.styles = [ + "connected" if self.device.connected else "", + "paired" if self.device.paired else "", + ] + + self.dimage = Image( + icon_name=device.icon_name + "-symbolic", # type: ignore + size=5, + name="device-icon", + style_classes=" ".join(self.styles), + ) + + self.device_button = Button( + on_clicked=lambda *_: self.toggle_connecting(), + child=Box( + orientation="h", + h_expand=True, + children=[self.dimage, Label(label=device.name)], + ), # type: ignore + ) + self.start_children = [self.device_button] + + # # Add battery info if available + if hasattr(device, "battery_percentage") and device.battery_percentage > 0: + battery_box = Box(orientation="h", spacing=4) + + # Create battery icon + battery_icon = svg_file( + get_battery_icon_file( + device.battery_percentage, + False, # Not charging for bluetooth devices + ) + ) + + # Create battery percentage label + battery_label = Label( + label=f"{device.battery_percentage:.0f}%", name="battery-label" + ) + + battery_box.children = [battery_icon, battery_label] + self.end_children = [battery_box] + + self.device_button.connect("enter-notify-event", self.on_button_enter) + self.device_button.connect("leave-notify-event", self.on_button_leave) + self.device.emit("changed") # to update display status + + def on_button_enter(self, widget, event): + self.add_style_class("button-hovered") + + def on_button_leave(self, widget, event): + self.remove_style_class("button-hovered") + + def toggle_connecting(self): + self.device.emit("changed") + self.device.set_connecting(not self.device.connected) + + def on_changed(self, *_): + try: + # Update connection and pairing status + new_styles = [ + "connected" if self.device.connected else "", + "paired" if self.device.paired else "", + ] + + self.styles = new_styles + self.dimage.set_property("style-classes", " ".join(self.styles)) + except Exception: + return + + # Update battery info if available + if ( + hasattr(self.device, "battery_percentage") + and self.device.battery_percentage > 0 + ): + if not self.end_children: # Add battery display if not already present + battery_box = Box(orientation="h", spacing=4) + + # Create battery icon + battery_icon = svg_file( + get_battery_icon_file( + self.device.battery_percentage, + False, # Not charging for bluetooth devices + ) + ) + + # Create battery percentage label + battery_label = Label( + label=f"{self.device.battery_percentage:.0f}%", name="battery-label" + ) + + battery_box.children = [battery_icon, battery_label] + self.end_children = [battery_box] + else: # Update existing battery display + battery_box = self.end_children[0] + if hasattr(battery_box, "children") and len(battery_box.children) >= 2: + battery_icon = battery_box.children[0] + battery_label = battery_box.children[1] + + # Update battery icon + battery_icon.dynamic_file( + get_battery_icon_file( + self.device.battery_percentage, + False, # Not charging for bluetooth devices + ) + ) + + # Update battery percentage + battery_label.set_label(f"{self.device.battery_percentage:.0f}%") + elif self.end_children: # Remove battery display if no longer available + self.end_children = [] + + return + + +class BluetoothConnections(Box): + def __init__( + self, parent, show_hidden_devices: bool = False, show_back_button=True, **kwargs + ): + super().__init__( + spacing=8, + orientation="vertical", + name="bluetooth-connections", + **kwargs, + ) + + self.parent = parent + self.show_hidden_devices = show_hidden_devices + self.is_scanning = False # Track scanning state + self.refresh_timer = None # Timer for periodic device refresh + self._update_in_progress = False # Prevent concurrent updates + self._destroyed = False # Track if widget is destroyed + + self.client = BluetoothClient(on_device_added=self.on_device_added) + + # Create pull-to-refresh indicator + self.refresh_indicator = Label( + name="bluetooth-refresh-indicator", + label="↓ Pull to scan for devices", + h_align="center", + visible=False, + style="color: #fff; font-size: 12px; padding: 5px;", + ) + + # Create title with optional back button + title_children = [] + if show_back_button: + title_children.append( + Button( + image=Image(icon_name="back", size=10), + on_clicked=lambda *_: self.parent.close_bluetooth(), + ) + ) + title_children.append(Label("Bluetooth", name="bluetooth-title")) + + self.title = Box( + orientation="h", + children=title_children, + ) + + self.toggle_button = Gtk.Switch(visible=True, name="toggle-button") + + # Safely set initial state + self.toggle_button.set_active(self.client.enabled) + self.toggle_button.connect( + "notify::active", + lambda *_: set_bluetooth_enabled_with_fallback( + self.client, self.toggle_button.get_active() + ), + ) + + # Connect client signals + self.client.connect( + "notify::enabled", + lambda *_: self.toggle_button.set_active(self.client.enabled), + ) + self.client.connect("notify::scanning", lambda *_: self.update_scan_label()) + + # Connect to device changes + self.client.connect("device-added", self.update_devices) + self.client.connect("device-removed", self.update_devices) + + # Connect to additional signals for better real-time monitoring + self.client.connect("changed", self.on_client_changed) + + # Create Devices section + self.paired_devices_label = Label( + label="Devices", h_align="start", name="networks-title" + ) + self.paired_devices = Box( + spacing=4, orientation="vertical", name="known-networks" + ) + + # Create "No devices available" message + self.no_devices_label = Label( + label="No devices available", + h_align="center", + name="no-networks-label", + visible=False, + ) + + # Create Other Devices section with clickable title + self.other_devices_button = Button( + child=Label("Other Devices", h_align="start"), + name="wifi-other-button", + on_clicked=self.toggle_other_devices, + ) + self.other_devices = Box(spacing=4, orientation="vertical") + + # Create scrolled window for other devices + self.other_devices_scrolled = ScrolledWindow( + min_content_size=(303, 150), + child=self.other_devices, + overlay_scroll=True, + ) + + # Add pull-to-refresh functionality to scrolled window + self.setup_pull_to_refresh() + + # Create revealer for Other Devices section + self.other_devices_revealer = Revealer( + child=self.other_devices_scrolled, + transition_type="slide-down", + transition_duration=100, + child_revealed=False, + ) + + # Create More Settings button (same style as Other Devices button) + self.more_settings_button = Button( + child=Label("More Settings", h_align="start"), + name="wifi-other-button", + on_clicked=self.open_bluetooth_settings, + ) + + self.children = [ + CenterBox( + start_children=self.title, + end_children=self.toggle_button, + name="bluetooth-widget-top", + ), + self.refresh_indicator, + Separator(orientation="h", name="separator"), + self.paired_devices_label, + self.paired_devices, + self.no_devices_label, + Separator(orientation="h", name="separator"), + self.other_devices_button, + self.other_devices_revealer, + Separator(orientation="h", name="separator"), + self.more_settings_button, + ] + + # Connect cleanup on destroy + self.connect("destroy", self.on_destroy) + + self.client.notify("scanning") + self.client.notify("enabled") + + # Initial device update + self.update_devices() + + # Start periodic device monitoring for real-time updates + self.start_device_monitoring() + + def toggle_other_devices(self, *_): + """Toggle the visibility of other devices section""" + current_state = self.other_devices_revealer.child_revealed + self.other_devices_revealer.child_revealed = not current_state + + # Handle scanning based on section visibility + if self.client: + if self.other_devices_revealer.child_revealed: + # Start scanning when revealing other devices section + if not self.client.scanning: + self.client.toggle_scan() + # Also force an immediate device refresh to catch any missed connections + self.force_device_refresh() + else: + # Stop scanning when hiding other devices section + if self.client.scanning: + self.client.toggle_scan() + + def open_bluetooth_settings(self, *_): + """Open Blueman bluetooth manager""" + try: + subprocess.Popen(["blueman-manager"], start_new_session=True) + if self.parent and hasattr(self.parent, "hide_controlcenter"): + self.parent.hide_controlcenter() + except FileNotFoundError: + pass + except Exception: + pass + + def update_scan_label(self): + """Update scanning state appearance""" + if self.client.scanning: + # Show scanning feedback in refresh indicator + self.refresh_indicator.set_label("Scanning for devices...") + self.refresh_indicator.set_visible(True) + self.refresh_indicator.add_style_class("scanning") + else: + # Hide scanning feedback + self.refresh_indicator.set_visible(False) + self.refresh_indicator.remove_style_class("scanning") + + def update_devices(self, *_): + """Update the list of available devices""" + # Prevent concurrent updates and check if destroyed + if self._update_in_progress or self._destroyed or not self.client: + return + + self._update_in_progress = True + + try: + # Store current device addresses to detect changes + current_paired_addresses = { + child.device.address + for child in self.paired_devices.get_children() + if hasattr(child, "device") + } + current_other_addresses = { + child.device.address + for child in self.other_devices.get_children() + if hasattr(child, "device") + } + + # Get current devices safely + devices = self.client.devices + paired_devices = [] + other_devices = [] + new_paired_addresses = set() + new_other_addresses = set() + + for device in devices: + try: + if device.name and device.name != "Unknown": + # Categorize devices: paired devices go to "Devices (Paired)" + # All others go to "Other Devices" + if device.paired: + paired_devices.append(device) + new_paired_addresses.add(device.address) + else: + other_devices.append(device) + new_other_addresses.add(device.address) + except Exception: + continue + + # Check if we need to update (devices added/removed) + paired_changed = current_paired_addresses != new_paired_addresses + other_changed = current_other_addresses != new_other_addresses + + # Only rebuild if something actually changed + if paired_changed or other_changed: + # Clear existing devices safely + for child in list(self.paired_devices.get_children()): + if not self._destroyed: + child.destroy() + for child in list(self.other_devices.get_children()): + if not self._destroyed: + child.destroy() + + # Add paired devices + for device in paired_devices: + if not self._destroyed: + device_slot = BluetoothDeviceSlot(device) + self.paired_devices.add(device_slot) + + # Add other devices + for device in other_devices: + if not self._destroyed: + device_slot = BluetoothDeviceSlot(device) + self.other_devices.add(device_slot) + + # Show/hide sections based on available devices + if not self._destroyed: + has_paired_devices = len(paired_devices) > 0 + has_other_devices = len(other_devices) > 0 + has_any_devices = has_paired_devices or has_other_devices + + # Show paired devices section only if there are paired devices + self.paired_devices_label.set_visible(has_paired_devices) + self.paired_devices.set_visible(has_paired_devices) + + # Show "No devices available" message if no devices at all + self.no_devices_label.set_visible(not has_any_devices) + + # Always show the other devices button, regardless of available devices + self.other_devices_button.set_visible(True) # Always visible + + except Exception: + pass + finally: + self._update_in_progress = False + + def start_device_monitoring(self): + """Start periodic monitoring for device changes""" + # Monitor for device changes every 5 seconds (less aggressive) + # This helps catch devices that connect from external sources + self.refresh_timer = GLib.timeout_add_seconds(5, self.periodic_device_refresh) + + def stop_device_monitoring(self): + """Stop periodic monitoring""" + if self.refresh_timer: + GLib.source_remove(self.refresh_timer) + self.refresh_timer = None + + def periodic_device_refresh(self): + """Periodically refresh device list to catch external connections""" + # Skip if update in progress, destroyed, or client not available + if ( + self._update_in_progress + or self._destroyed + or not self.client + or not self.client.enabled + ): + return True # Continue monitoring + + try: + # Simple check - just trigger update_devices which has its own safety checks + # Don't force signal emissions as that can cause race conditions + self.update_devices() + + except Exception: + pass + + return True # Continue monitoring + + def force_device_refresh(self): + """Force an immediate refresh of the device list""" + if self._update_in_progress or self._destroyed: + return + + try: + # Simply trigger update_devices which has its own safety checks + # Avoid forcing signal emissions to prevent race conditions + self.update_devices() + except Exception: + pass + + def on_client_changed(self, *_): + """Handle when the bluetooth client state changes""" + # Update devices when client state changes + self.update_devices() + + def on_destroy(self, widget): + """Cleanup when widget is destroyed""" + # Mark as destroyed to prevent further updates + self._destroyed = True + # Stop monitoring + self.stop_device_monitoring() + # Make sure other devices revealer is collapsed when closing + try: + self.other_devices_revealer.child_revealed = False + except Exception: + pass # Widget might already be destroyed + + def close_bluetooth(self): + """Called when Bluetooth panel is being closed""" + # Collapse the other devices section when closing + self.other_devices_revealer.child_revealed = False + + def setup_pull_to_refresh(self): + """Setup pull-to-refresh gesture for the scrolled window""" + # Get the scrolled window's vertical adjustment + self.vadjustment = self.other_devices_scrolled.get_vadjustment() + + # Track gesture state + self.pull_start_y = 0 + self.is_pulling = False + self.pull_threshold = 50 # pixels to trigger refresh + + # Connect to scroll events + self.other_devices_scrolled.connect("scroll-event", self.on_scroll_event) + self.other_devices_scrolled.connect("button-press-event", self.on_button_press) + self.other_devices_scrolled.connect( + "button-release-event", self.on_button_release + ) + self.other_devices_scrolled.connect( + "motion-notify-event", self.on_motion_notify + ) + + # Enable events + self.other_devices_scrolled.set_events( + Gdk.EventMask.SCROLL_MASK + | Gdk.EventMask.BUTTON_PRESS_MASK + | Gdk.EventMask.BUTTON_RELEASE_MASK + | Gdk.EventMask.POINTER_MOTION_MASK + ) + + def on_scroll_event(self, widget, event): + """Handle scroll events for pull-to-refresh""" + # Only handle pull-to-refresh when at the top + if self.vadjustment.get_value() <= 0: + if event.direction == Gdk.ScrollDirection.UP: + # Scrolling up at the top - toggle scan and force refresh + self.client.toggle_scan() + self.force_device_refresh() + return True # Consume the event + return False # Let normal scrolling continue + + def on_button_press(self, widget, event): + """Handle button press for touch/drag gestures""" + if self.vadjustment.get_value() <= 0: + self.pull_start_y = event.y + self.is_pulling = True + return False + + def on_button_release(self, widget, event): + """Handle button release for touch/drag gestures""" + if self.is_pulling: + pull_distance = event.y - self.pull_start_y + if pull_distance > self.pull_threshold: + # Toggle scan and force refresh + self.client.toggle_scan() + self.force_device_refresh() + # Hide refresh indicator + self.refresh_indicator.set_visible(False) + self.refresh_indicator.remove_style_class("ready-to-refresh") + self.is_pulling = False + return False + + def on_motion_notify(self, widget, event): + """Handle motion events for visual feedback during pull""" + if self.is_pulling and self.vadjustment.get_value() <= 0: + pull_distance = event.y - self.pull_start_y + if pull_distance > 0: + # Show refresh indicator when pulling down + self.refresh_indicator.set_visible(True) + if pull_distance >= self.pull_threshold: + if self.client.scanning: + self.refresh_indicator.set_label("↑ Release to stop scanning") + else: + self.refresh_indicator.set_label("↑ Release to scan") + self.refresh_indicator.add_style_class("ready-to-refresh") + else: + if self.client.scanning: + self.refresh_indicator.set_label("↓ Pull to stop scanning") + else: + self.refresh_indicator.set_label("↓ Pull to scan for devices") + self.refresh_indicator.remove_style_class("ready-to-refresh") + else: + self.refresh_indicator.set_visible(False) + return False + + def on_device_added(self, client: BluetoothClient, address: str): + """Handle when a new device is added""" + # Update the device list when devices are added + self.update_devices() diff --git a/src/window/controlcenter/bluetooth.py b/src/window/controlcenter/bluetooth.py new file mode 100644 index 0000000..10fc050 --- /dev/null +++ b/src/window/controlcenter/bluetooth.py @@ -0,0 +1,615 @@ +import subprocess + +from fabric.bluetooth import BluetoothClient, BluetoothDevice +from fabric.utils import Gdk, GLib, Gtk, exec_shell_command, logger +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.centerbox import CenterBox +from fabric.widgets.image import Image +from fabric.widgets.label import Label +from fabric.widgets.revealer import Revealer +from fabric.widgets.scrolledwindow import ScrolledWindow +from fabric.widgets.separator import Separator + +from utils.utils import svg_file + + +def get_battery_icon_file( + percentage: int, is_charging: bool, icon_path: str = "" +) -> str: + """ + Get the battery icon file path based on percentage and charging status. + + Args: + percentage: Battery percentage (0-100) + is_charging: Whether the device is charging + icon_path: Base path for icons (unused, kept for compatibility) + + Returns: + Relative path to the battery icon file + """ + clamped = max(0, min(100, percentage)) + step = int((clamped // 10) * 10) + filename = f"battery-{step:03d}{'-charging' if is_charging else ''}.svg" + return f"battery/{filename}" + + +def set_bluetooth_enabled_with_fallback(client, enabled: bool): + try: + # Try fabric bluetooth first + client.set_enabled(enabled) + except Exception as e: + logger.warning(f"Fabric bluetooth set_enabled({enabled}) failed: {e}") + + # Fallback to rfkill to unblock/block bluetooth + if enabled: + command = "rfkill unblock bluetooth" + else: + command = "rfkill block bluetooth" + + result = exec_shell_command(command) + if result is False: + logger.error(f"rfkill fallback failed: command '{command}' returned False") + elif isinstance(result, str) and result.strip(): + # If result is a non-empty string, it might be an error message + logger.warning(f"rfkill command output: {result.strip()}") + else: + logger.info(f"rfkill fallback succeeded: {command}") + + +class BluetoothDeviceSlot(CenterBox): + def __init__(self, device: BluetoothDevice, **kwargs): + super().__init__(h_expand=True, name="device-button", **kwargs) + self.device = device + self.device.connect("changed", self.on_changed) + self.device.connect( + "notify::closed", lambda *_: self.device.closed and self.destroy() + ) + + self.styles = [ + "connected" if self.device.connected else "", + "paired" if self.device.paired else "", + ] + + self.dimage = Image( + icon_name=device.icon_name + "-symbolic", # type: ignore + size=5, + name="device-icon", + style_classes=" ".join(self.styles), + ) + + self.device_button = Button( + on_clicked=lambda *_: self.toggle_connecting(), + child=Box( + orientation="h", + h_expand=True, + children=[self.dimage, Label(label=device.name)], + ), # type: ignore + ) + self.start_children = [self.device_button] + + # # Add battery info if available + if hasattr(device, "battery_percentage") and device.battery_percentage > 0: + battery_box = Box(orientation="h", spacing=4) + + # Create battery icon + battery_icon = svg_file( + get_battery_icon_file( + device.battery_percentage, + False, # Not charging for bluetooth devices + ) + ) + + # Create battery percentage label + battery_label = Label( + label=f"{device.battery_percentage:.0f}%", name="battery-label" + ) + + battery_box.children = [battery_icon, battery_label] + self.end_children = [battery_box] + + self.device_button.connect("enter-notify-event", self.on_button_enter) + self.device_button.connect("leave-notify-event", self.on_button_leave) + self.device.emit("changed") # to update display status + + def on_button_enter(self, widget, event): + self.add_style_class("button-hovered") + + def on_button_leave(self, widget, event): + self.remove_style_class("button-hovered") + + def toggle_connecting(self): + self.device.emit("changed") + self.device.set_connecting(not self.device.connected) + + def on_changed(self, *_): + try: + # Update connection and pairing status + new_styles = [ + "connected" if getattr(self.device, "connected", False) else "", + "paired" if getattr(self.device, "paired", False) else "", + ] + + self.styles = new_styles + self.dimage.set_property("style-classes", " ".join(self.styles)) + except Exception: + return + + # Update battery info if available + if ( + hasattr(self.device, "battery_percentage") + and self.device.battery_percentage > 0 + ): + if not self.end_children: # Add battery display if not already present + battery_box = Box(orientation="h", spacing=4) + + # Create battery icon + battery_icon = svg_file( + get_battery_icon_file( + self.device.battery_percentage, + False, # Not charging for bluetooth devices + ) + ) + + # Create battery percentage label + battery_label = Label( + label=f"{self.device.battery_percentage:.0f}%", name="battery-label" + ) + + battery_box.children = [battery_icon, battery_label] + self.end_children = [battery_box] + else: # Update existing battery display + battery_box = self.end_children[0] + if hasattr(battery_box, "children") and len(battery_box.children) >= 2: + battery_icon = battery_box.children[0] + battery_label = battery_box.children[1] + + # Update battery icon + battery_icon.dynamic_file( + get_battery_icon_file( + self.device.battery_percentage, + False, # Not charging for bluetooth devices + ) + ) + + # Update battery percentage + battery_label.set_label(f"{self.device.battery_percentage:.0f}%") + elif self.end_children: # Remove battery display if no longer available + self.end_children = [] + + return + + +class BluetoothConnections(Box): + def __init__( + self, parent, show_hidden_devices: bool = False, show_back_button=True, **kwargs + ): + super().__init__( + spacing=8, + orientation="vertical", + name="bluetooth-connections", + **kwargs, + ) + + self.parent = parent + self.show_hidden_devices = show_hidden_devices + self.is_scanning = False # Track scanning state + self.refresh_timer = None # Timer for periodic device refresh + self._update_in_progress = False # Prevent concurrent updates + self._destroyed = False # Track if widget is destroyed + + self.client = BluetoothClient(on_device_added=self.on_device_added) + + # Create pull-to-refresh indicator + self.refresh_indicator = Label( + name="bluetooth-refresh-indicator", + label="↓ Pull to scan for devices", + h_align="center", + visible=False, + style="color: #fff; font-size: 12px; padding: 5px;", + ) + + # Create title with optional back button + title_children = [] + if show_back_button: + title_children.append( + Button( + image=Image(icon_name="back", size=10), + on_clicked=lambda *_: self.parent.close_bluetooth(), + ) + ) + title_children.append(Label("Bluetooth", name="bluetooth-title")) + + self.title = Box( + orientation="h", + children=title_children, + ) + + self.toggle_button = Gtk.Switch(visible=True, name="toggle-button") + + # Safely set initial state + self.toggle_button.set_active(self.client.enabled) + self.toggle_button.connect( + "notify::active", + lambda *_: set_bluetooth_enabled_with_fallback( + self.client, self.toggle_button.get_active() + ), + ) + + # Connect client signals + self.client.connect( + "notify::enabled", + lambda *_: self.toggle_button.set_active(self.client.enabled), + ) + self.client.connect("notify::scanning", lambda *_: self.update_scan_label()) + + # Connect to device changes + self.client.connect("device-added", self.update_devices) + self.client.connect("device-removed", self.update_devices) + + # Connect to additional signals for better real-time monitoring + self.client.connect("changed", self.on_client_changed) + + # Create Devices section + self.paired_devices_label = Label( + label="Devices", h_align="start", name="networks-title" + ) + self.paired_devices = Box( + spacing=4, orientation="vertical", name="known-networks" + ) + + # Create "No devices available" message + self.no_devices_label = Label( + label="No devices available", + h_align="center", + name="no-networks-label", + visible=False, + ) + + # Create Other Devices section with clickable title + self.other_devices_button = Button( + child=Label("Other Devices", h_align="start"), + name="wifi-other-button", + on_clicked=self.toggle_other_devices, + ) + self.other_devices = Box(spacing=4, orientation="vertical") + + # Create scrolled window for other devices + self.other_devices_scrolled = ScrolledWindow( + min_content_size=(303, 150), + child=self.other_devices, + overlay_scroll=True, + ) + + # Add pull-to-refresh functionality to scrolled window + self.setup_pull_to_refresh() + + # Create revealer for Other Devices section + self.other_devices_revealer = Revealer( + child=self.other_devices_scrolled, + transition_type="slide-down", + transition_duration=100, + child_revealed=False, + ) + + # Create More Settings button (same style as Other Devices button) + self.more_settings_button = Button( + child=Label("More Settings", h_align="start"), + name="wifi-other-button", + on_clicked=self.open_bluetooth_settings, + ) + + self.children = [ + CenterBox( + start_children=self.title, + end_children=self.toggle_button, + name="bluetooth-widget-top", + ), + self.refresh_indicator, + Separator(orientation="h", name="separator"), + self.paired_devices_label, + self.paired_devices, + self.no_devices_label, + Separator(orientation="h", name="separator"), + self.other_devices_button, + self.other_devices_revealer, + Separator(orientation="h", name="separator"), + self.more_settings_button, + ] + + # Connect cleanup on destroy + self.connect("destroy", self.on_destroy) + + self.client.notify("scanning") + self.client.notify("enabled") + + # Initial device update + self.update_devices() + + # Start periodic device monitoring for real-time updates + self.start_device_monitoring() + + def toggle_other_devices(self, *_): + """Toggle the visibility of other devices section""" + current_state = self.other_devices_revealer.child_revealed + self.other_devices_revealer.child_revealed = not current_state + + # Handle scanning based on section visibility + if self.client: + if self.other_devices_revealer.child_revealed: + # Start scanning when revealing other devices section + if not self.client.scanning: + self.client.toggle_scan() + # Also force an immediate device refresh to catch any missed connections + self.force_device_refresh() + else: + # Stop scanning when hiding other devices section + if self.client.scanning: + self.client.toggle_scan() + + def open_bluetooth_settings(self, *_): + """Open Blueman bluetooth manager""" + try: + subprocess.Popen(["blueman-manager"], start_new_session=True) + if self.parent and hasattr(self.parent, "hide_controlcenter"): + self.parent.hide_controlcenter() + except FileNotFoundError: + pass + except Exception: + pass + + def update_scan_label(self): + """Update scanning state appearance""" + if self.client.scanning: + # Show scanning feedback in refresh indicator + self.refresh_indicator.set_label("Scanning for devices...") + self.refresh_indicator.set_visible(True) + self.refresh_indicator.add_style_class("scanning") + else: + # Hide scanning feedback + self.refresh_indicator.set_visible(False) + self.refresh_indicator.remove_style_class("scanning") + + def update_devices(self, *_): + """Update the list of available devices""" + # Prevent concurrent updates and check if destroyed + if self._update_in_progress or self._destroyed or not self.client: + return + + self._update_in_progress = True + + try: + # Store current device addresses to detect changes + current_paired_addresses = { + child.device.address + for child in self.paired_devices.get_children() + if hasattr(child, "device") + } + current_other_addresses = { + child.device.address + for child in self.other_devices.get_children() + if hasattr(child, "device") + } + + # Get current devices safely + devices = self.client.devices + paired_devices = [] + other_devices = [] + new_paired_addresses = set() + new_other_addresses = set() + + for device in devices: + try: + if device.name and device.name != "Unknown": + # Categorize devices: paired devices go to "Devices (Paired)" + # All others go to "Other Devices" + if device.paired: + paired_devices.append(device) + new_paired_addresses.add(device.address) + else: + other_devices.append(device) + new_other_addresses.add(device.address) + except Exception: + continue + + # Check if we need to update (devices added/removed) + paired_changed = current_paired_addresses != new_paired_addresses + other_changed = current_other_addresses != new_other_addresses + + # Only rebuild if something actually changed + if paired_changed or other_changed: + # Clear existing devices safely + for child in list(self.paired_devices.get_children()): + if not self._destroyed: + child.destroy() + for child in list(self.other_devices.get_children()): + if not self._destroyed: + child.destroy() + + # Add paired devices + for device in paired_devices: + if not self._destroyed: + device_slot = BluetoothDeviceSlot(device) + self.paired_devices.add(device_slot) + + # Add other devices + for device in other_devices: + if not self._destroyed: + device_slot = BluetoothDeviceSlot(device) + self.other_devices.add(device_slot) + + # Show/hide sections based on available devices + if not self._destroyed: + has_paired_devices = len(paired_devices) > 0 + has_other_devices = len(other_devices) > 0 + has_any_devices = has_paired_devices or has_other_devices + + # Show paired devices section only if there are paired devices + self.paired_devices_label.set_visible(has_paired_devices) + self.paired_devices.set_visible(has_paired_devices) + + # Show "No devices available" message if no devices at all + self.no_devices_label.set_visible(not has_any_devices) + + # Always show the other devices button, regardless of available devices + self.other_devices_button.set_visible(True) # Always visible + + except Exception: + pass + finally: + self._update_in_progress = False + + def start_device_monitoring(self): + """Start periodic monitoring for device changes""" + # Monitor for device changes every 5 seconds (less aggressive) + # This helps catch devices that connect from external sources + self.refresh_timer = GLib.timeout_add_seconds(5, self.periodic_device_refresh) + + def stop_device_monitoring(self): + """Stop periodic monitoring""" + if self.refresh_timer: + GLib.source_remove(self.refresh_timer) + self.refresh_timer = None + + def periodic_device_refresh(self): + """Periodically refresh device list to catch external connections""" + # Skip if update in progress, destroyed, or client not available + if ( + self._update_in_progress + or self._destroyed + or not self.client + or not self.client.enabled + ): + return True # Continue monitoring + + try: + # Simple check - just trigger update_devices which has its own safety checks + # Don't force signal emissions as that can cause race conditions + self.update_devices() + + except Exception: + pass + + return True # Continue monitoring + + def force_device_refresh(self): + """Force an immediate refresh of the device list""" + if self._update_in_progress or self._destroyed: + return + + try: + # Simply trigger update_devices which has its own safety checks + # Avoid forcing signal emissions to prevent race conditions + self.update_devices() + except Exception: + pass + + def on_client_changed(self, *_): + """Handle when the bluetooth client state changes""" + # Update devices when client state changes + self.update_devices() + + def on_destroy(self, widget): + """Cleanup when widget is destroyed""" + # Mark as destroyed to prevent further updates + self._destroyed = True + # Stop monitoring + self.stop_device_monitoring() + # Make sure other devices revealer is collapsed when closing + try: + self.other_devices_revealer.child_revealed = False + except Exception: + pass # Widget might already be destroyed + + def close_bluetooth(self): + """Called when Bluetooth panel is being closed""" + # Collapse the other devices section when closing + self.other_devices_revealer.child_revealed = False + + def setup_pull_to_refresh(self): + """Setup pull-to-refresh gesture for the scrolled window""" + # Get the scrolled window's vertical adjustment + self.vadjustment = self.other_devices_scrolled.get_vadjustment() + + # Track gesture state + self.pull_start_y = 0 + self.is_pulling = False + self.pull_threshold = 50 # pixels to trigger refresh + + # Connect to scroll events + self.other_devices_scrolled.connect("scroll-event", self.on_scroll_event) + self.other_devices_scrolled.connect("button-press-event", self.on_button_press) + self.other_devices_scrolled.connect( + "button-release-event", self.on_button_release + ) + self.other_devices_scrolled.connect( + "motion-notify-event", self.on_motion_notify + ) + + # Enable events + self.other_devices_scrolled.set_events( + Gdk.EventMask.SCROLL_MASK + | Gdk.EventMask.BUTTON_PRESS_MASK + | Gdk.EventMask.BUTTON_RELEASE_MASK + | Gdk.EventMask.POINTER_MOTION_MASK + ) + + def on_scroll_event(self, widget, event): + """Handle scroll events for pull-to-refresh""" + # Only handle pull-to-refresh when at the top + if self.vadjustment.get_value() <= 0: + if event.direction == Gdk.ScrollDirection.UP: + # Scrolling up at the top - toggle scan and force refresh + self.client.toggle_scan() + self.force_device_refresh() + return True # Consume the event + return False # Let normal scrolling continue + + def on_button_press(self, widget, event): + """Handle button press for touch/drag gestures""" + if self.vadjustment.get_value() <= 0: + self.pull_start_y = event.y + self.is_pulling = True + return False + + def on_button_release(self, widget, event): + """Handle button release for touch/drag gestures""" + if self.is_pulling: + pull_distance = event.y - self.pull_start_y + if pull_distance > self.pull_threshold: + # Toggle scan and force refresh + self.client.toggle_scan() + self.force_device_refresh() + # Hide refresh indicator + self.refresh_indicator.set_visible(False) + self.refresh_indicator.remove_style_class("ready-to-refresh") + self.is_pulling = False + return False + + def on_motion_notify(self, widget, event): + """Handle motion events for visual feedback during pull""" + if self.is_pulling and self.vadjustment.get_value() <= 0: + pull_distance = event.y - self.pull_start_y + if pull_distance > 0: + # Show refresh indicator when pulling down + self.refresh_indicator.set_visible(True) + if pull_distance >= self.pull_threshold: + if self.client.scanning: + self.refresh_indicator.set_label("↑ Release to stop scanning") + else: + self.refresh_indicator.set_label("↑ Release to scan") + self.refresh_indicator.add_style_class("ready-to-refresh") + else: + if self.client.scanning: + self.refresh_indicator.set_label("↓ Pull to stop scanning") + else: + self.refresh_indicator.set_label("↓ Pull to scan for devices") + self.refresh_indicator.remove_style_class("ready-to-refresh") + else: + self.refresh_indicator.set_visible(False) + return False + + def on_device_added(self, client: BluetoothClient, address: str): + """Handle when a new device is added""" + # Update the device list when devices are added + self.update_devices() diff --git a/src/window/controlcenter/expanded_player.py b/src/window/controlcenter/expanded_player.py new file mode 100644 index 0000000..f3c7014 --- /dev/null +++ b/src/window/controlcenter/expanded_player.py @@ -0,0 +1,1337 @@ +# Standard library imports +import gc +import re +import tempfile +import threading +import urllib.parse +import urllib.request +import weakref +from typing import Dict, List, Optional + +from fabric.utils import ( + bulk_connect, + cooldown, + invoke_repeater, + GLib, + GObject, + logger, + os, +) +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.image import Image +from fabric.widgets.label import Label +from fabric.widgets.overlay import Overlay + +# Fabric imports +from fabric.widgets.scale import Scale +from fabric.widgets.stack import Stack +from fabric.widgets.wayland import WaylandWindow as Window + +import shared.data as data + +# Local imports +from services.mpris import MprisPlayer, MprisPlayerManager +from utils.utils import svg_file + +CACHE_DIR = f"{data.CACHE_DIR}/media" + +# Global memory management +_shared_mpris_manager = None +_widget_cache = weakref.WeakValueDictionary() +_artwork_cache = {} +_max_artwork_cache_size = 10 # Limit artwork cache to prevent memory bloat + + +def get_shared_mpris_manager(): + """Get shared MPRIS manager instance to reduce memory usage.""" + global _shared_mpris_manager + if _shared_mpris_manager is None: + _shared_mpris_manager = MprisPlayerManager() + return _shared_mpris_manager + + +def cleanup_artwork_cache(): + """Clean up artwork cache immediately when size limit is reached.""" + global _artwork_cache + if len(_artwork_cache) > _max_artwork_cache_size: + # Keep only the most recent items immediately + items = list(_artwork_cache.items()) + _artwork_cache = dict(items[-_max_artwork_cache_size:]) + # Immediate garbage collection + gc.collect() + + +def cleanup_old_cache_files(): + """Clean up old artwork cache files immediately (no lazy loading).""" + try: + if not os.path.exists(CACHE_DIR): + return + + import time + + current_time = time.time() + # Clean files older than 2 hours (more aggressive) + two_hours_ago = current_time - (2 * 60 * 60) + + for filename in os.listdir(CACHE_DIR): + filepath = os.path.join(CACHE_DIR, filename) + try: + if os.path.isfile(filepath): + file_mod_time = os.path.getmtime(filepath) + if file_mod_time < two_hours_ago: + os.remove(filepath) + logger.debug(f"Cleaned up old cache file: {filename}") + except Exception as e: + logger.warning(f"Failed to clean up cache file {filename}: {e}") + + except Exception as e: + logger.error(f"Error during cache cleanup: {e}") + + +def get_artwork_cached(url: str) -> Optional[str]: + """Get artwork with memory-efficient caching.""" + if not url: + return None + + # Check memory cache first + if url in _artwork_cache: + return _artwork_cache[url] + + # Clean cache immediately if too large (no lazy loading) + cleanup_artwork_cache() + + try: + # Create cache directory if it doesn't exist + os.makedirs(CACHE_DIR, exist_ok=True) + + # Generate cache filename + safe_filename = re.sub( + r"[^\w\-_.]", "_", urllib.parse.urlparse(url).path.split("/")[-1] + ) + if not safe_filename: + safe_filename = f"artwork_{hash(url)}" + + cache_file = os.path.join(CACHE_DIR, safe_filename) + + # Check if cached file exists and is recent (within 2 hours) + if os.path.exists(cache_file): + import time + + if time.time() - os.path.getmtime(cache_file) < 7200: # 2 hours + _artwork_cache[url] = cache_file + return cache_file + + # Download and cache + urllib.request.urlretrieve(url, cache_file) + _artwork_cache[url] = cache_file + return cache_file + + except Exception as e: + logger.warning(f"Failed to cache artwork from {url}: {e}") + return None + + +class EmbeddedExpandedPlayer(Box): + """Embedded expanded player widget for use inside control center.""" + + def __init__(self, control_center, **kwargs): + super().__init__( + orientation="vertical", + h_expand=True, + name="embedded-expanded-player", + **kwargs, + ) + + self.control_center = control_center + self.mpris_manager = get_shared_mpris_manager() + + # Create back button (hidden in header) + self.back_button = Button( + name="back-button", + child=Label(label="← Back"), + on_clicked=self._on_back_clicked, + ) + + # Create expanded player content + self.player_content = PlayerBoxStack(self.mpris_manager) + + self.children = [ + Box( + orientation="horizontal", + h_expand=True, + style_classes="menu", + visible=False, # Hide header to remove title and back button + children=[ + self.back_button, + Box(h_expand=True), # Spacer + Label(label="Now Playing", style_classes="title"), + Box(h_expand=True), # Spacer + ], + ), + self.player_content, + ] + + def _on_back_clicked(self, *_): + """Handle back button click""" + if self.control_center and hasattr( + self.control_center, "close_expanded_player" + ): + self.control_center.close_expanded_player() + + def refresh(self): + """Refresh the player content""" + # This will automatically update as MPRIS players change + pass + + def destroy(self): + """Clean up resources and prevent memory leaks""" + logger.debug("🗑️ EmbeddedExpandedPlayer cleanup starting") + + try: + # Destroy player content (PlayerBoxStack) + if hasattr(self, "player_content") and hasattr( + self.player_content, "destroy" + ): + self.player_content.destroy() + + # Clean up back button + if hasattr(self, "back_button") and hasattr(self.back_button, "destroy"): + self.back_button.destroy() + + # Clean up any other widgets we might have + for child in list(self.get_children()): + try: + child.destroy() + except Exception as e: + logger.warning(f"Failed to destroy child widget: {e}") + + # Aggressively clean global caches + global _widget_cache, _artwork_cache + _widget_cache.clear() + _artwork_cache.clear() + logger.debug("🗑️ Cleared global widget and artwork caches") + + # Clear references + if hasattr(self, "control_center"): + self.control_center = None + if hasattr(self, "mpris_manager"): + self.mpris_manager = None + + # Force garbage collection + gc.collect() + logger.debug("🗑️ EmbeddedExpandedPlayer cleanup completed") + + except Exception as e: + logger.error(f"Error during EmbeddedExpandedPlayer cleanup: {e}") + finally: + super().destroy() + + +class PlayerBoxStack(Box): + """Memory-optimized widget that displays current player information.""" + + def __init__(self, mpris_manager: MprisPlayerManager, **kwargs): + # Clean up old cache files immediately (no lazy loading) + cleanup_old_cache_files() + + # The player stack with memory-efficient settings + self.player_stack = Stack( + name="player-stack", + # Disable transitions to reduce memory usage + transition_type="none", + ) + self.current_stack_pos = 0 + + # Store player buttons - cleaned up via explicit removal + self.player_buttons: list[Button] = [] + self._player_widgets: Dict[str, Box] = weakref.WeakValueDictionary() + + # Track signal connections for cleanup using weak references + self._signal_connections = [] + + # Create a lightweight "No media playing" placeholder + self.no_media_box = self._create_no_media_box() + + super().__init__(orientation="v", name="media", children=[self.player_stack]) + + # Show the no media box initially + self.player_stack.children = [self.no_media_box] + self.set_visible(True) + + self.mpris_manager = mpris_manager + + # Track connections for cleanup + connections = bulk_connect( + self.mpris_manager, + { + "player-appeared": self.on_new_player, + "player-vanished": self.on_lost_player, + }, + ) + for handler_id in connections: + self._signal_connections.append((self.mpris_manager, handler_id)) + + # Process existing players + for player in self.mpris_manager.players.values(): # type: ignore + logger.info(f"[PLAYER MANAGER] player found: {player.player_name}") + self.on_new_player(self.mpris_manager, player) + + # No periodic cleanup - immediate cleanup when needed + + def destroy(self): + """Clean up resources when the widget is destroyed.""" + try: + # No periodic cleanup timer to cancel (removed lazy loading) + + # Disconnect all signal connections + for obj, handler_id in self._signal_connections: + try: + if obj and hasattr(obj, "disconnect"): + obj.disconnect(handler_id) + except Exception as e: + logger.warning(f"Failed to disconnect signal: {e}") + self._signal_connections.clear() + + # Clean up player widgets + for widget in list(self._player_widgets.values()): + try: + if widget and hasattr(widget, "destroy"): + widget.destroy() + except Exception: + pass + self._player_widgets.clear() + + # Clean up player buttons explicitly + for button in list(self.player_buttons): + try: + if button and hasattr(button, "destroy"): + button.destroy() + except Exception: + pass + + # Clean up stack children + for child in self.player_stack.get_children(): + if hasattr(child, "destroy") and child != self.no_media_box: + try: + child.destroy() + except Exception: + pass + + # Force final garbage collection + gc.collect() + + except Exception as e: + logger.error(f"Error during PlayerBoxStack cleanup: {e}") + finally: + super().destroy() + + def _create_no_media_box(self): + """Create a placeholder box for when no media is playing.""" + fallback_cover_path = f"{data.HOME_DIR}/.current.wall" + + # Album cover with fallback image using Image widget + + album_cover = Box( + name="macos-album-image-no", + ) + album_cover.set_style(f"background-image:url('{fallback_cover_path}')") + + image_stack = Box(h_align="start", v_align="center", name="player-image-stack") + image_stack.children = [album_cover] + + # Track info showing "No media playing" + track_title = Label( + label="No media playing", + name="player-title-no", + justification="left", + max_chars_width=30, + ellipsization="end", + h_align="start", + ) + + track_artist = Label( + label="", + name="player-artist", + justification="left", + max_chars_width=12, + ellipsization="end", + h_align="start", + visible=False, # Hide artist and album when no media + ) + + track_album = Label( + label="", + name="player-album", + justification="left", + max_chars_width=12, + ellipsization="end", + h_align="start", + visible=False, # Hide artist and album when no media + ) + + track_info = Box( + name="track-info", + spacing=5, + orientation="v", + v_align="start", + h_align="start", + children=[track_title, track_artist, track_album], + ) + + # No control buttons for no media state - just an empty box + controls_box = Box( + name="player-controls", + visible=False, # Hide controls when no media + ) + + player_info_box = Box( + name="player-info-box", + v_align="center", + h_align="start", + orientation="v", + h_expand=True, + children=[track_info, controls_box], + ) + + inner_box = Box( + name="inner-player-box", + v_align="center", + h_align="start", + ) + + outer_box = Box( + name="outer-player-box", + h_align="start", + ) + + overlay_box = Overlay( + child=outer_box, + overlays=[ + inner_box, + player_info_box, + image_stack, + ], + ) + + no_media_box = Box( + h_align="center", + name="player-box", + h_expand=True, + children=[overlay_box], + ) + + return no_media_box + + def on_player_clicked(self, type): + # unset active from prev active button + if self.player_buttons and self.current_stack_pos < len(self.player_buttons): + self.player_buttons[self.current_stack_pos].remove_style_class("active") + + if type == "next": + self.current_stack_pos = ( + self.current_stack_pos + 1 + if self.current_stack_pos != len(self.player_stack.get_children()) - 1 + else 0 + ) + elif type == "prev": + self.current_stack_pos = ( + self.current_stack_pos - 1 + if self.current_stack_pos != 0 + else len(self.player_stack.get_children()) - 1 + ) + + # set new active button + if self.player_buttons and self.current_stack_pos < len(self.player_buttons): + self.player_buttons[self.current_stack_pos].add_style_class("active") + self.player_stack.set_visible_child( + self.player_stack.get_children()[self.current_stack_pos], + ) + + def on_player_clicked_by_index(self, index): + """Switch to player at given index""" + if 0 <= index < len(self.player_buttons): + # unset active from prev active button + if self.player_buttons and self.current_stack_pos < len( + self.player_buttons + ): + self.player_buttons[self.current_stack_pos].remove_style_class("active") + # set new position + self.current_stack_pos = index + # set new active button + if self.player_buttons and self.current_stack_pos < len( + self.player_buttons + ): + self.player_buttons[self.current_stack_pos].add_style_class("active") + self.player_stack.set_visible_child( + self.player_stack.get_children()[self.current_stack_pos], + ) + # Update all player boxes with new button state + self._update_all_player_buttons() + + def on_new_player(self, mpris_manager, player): + # if player_name in self.config.get("ignore", []): + # return + + # Remove the no media box if it's the only child + if ( + len(self.player_stack.get_children()) == 1 + and self.player_stack.get_children()[0] == self.no_media_box + ): + self.player_stack.children = [] + self.current_stack_pos = 0 + + self.set_visible(True) + + new_player_box = PlayerBox(player=player, player_stack=self) + self.player_stack.children = [ + *self.player_stack.children, + new_player_box, + ] + + self.make_new_player_button(self.player_stack.get_children()[-1]) + logger.info( + f"[PLAYER MANAGER] adding new player: {player.get_property('player-name')}", + ) + if self.player_buttons and self.current_stack_pos < len(self.player_buttons): + self.player_buttons[self.current_stack_pos].set_style_classes(["active"]) + + # Update all player boxes with current button state + self._update_all_player_buttons() + + def on_lost_player(self, mpris_manager, player_name): + # the playerBox is automatically removed from mprisbox children on being removed + logger.info(f"[PLAYER_MANAGER] Player Removed {player_name}") + players: List[PlayerBox] = self.player_stack.get_children() + + # Find and properly destroy the player box + player_box_to_remove = None + for player_box in players: + if ( + hasattr(player_box, "player") + and player_box.player.bus_name == player_name + ): + player_box_to_remove = player_box + break + + if player_box_to_remove: + try: + player_box_to_remove.destroy() + except Exception as e: + logger.warning(f"Failed to destroy player box: {e}") + + # Check if this was the last player + remaining_players = [ + p for p in self.player_stack.get_children() if p != player_box_to_remove + ] + if len(remaining_players) == 0: + # Show the no media box instead of hiding + self.player_stack.children = [self.no_media_box] + self.current_stack_pos = 0 + self.player_buttons = [] # Clear player buttons + return + + # Adjust current position if needed + if self.current_stack_pos >= len(self.player_stack.get_children()): + self.current_stack_pos = max(0, len(self.player_stack.get_children()) - 1) + + # Set active button if we have buttons and a valid position + if self.player_buttons and self.current_stack_pos < len(self.player_buttons): + self.player_buttons[self.current_stack_pos].set_style_classes(["active"]) + if self.player_stack.get_children(): + self.player_stack.set_visible_child( + self.player_stack.get_children()[self.current_stack_pos], + ) + + # Update all player boxes with current button state + self._update_all_player_buttons() + + def make_new_player_button(self, player_box): + new_button = Button(name="player-stack-button") + + def on_player_button_click(button: Button): + if self.player_buttons and self.current_stack_pos < len( + self.player_buttons + ): + self.player_buttons[self.current_stack_pos].remove_style_class("active") + if button in self.player_buttons: + self.current_stack_pos = self.player_buttons.index(button) + button.add_style_class("active") + self.player_stack.set_visible_child(player_box) + + new_button.connect( + "clicked", + on_player_button_click, + ) + self.player_buttons.append(new_button) + + # This will automatically destroy our used button + def cleanup_button(*_): + try: + if new_button in self.player_buttons: + self.player_buttons.remove(new_button) + new_button.destroy() + except Exception as e: + logger.warning(f"Failed to cleanup button: {e}") + + player_box.connect("destroy", cleanup_button) + + def _update_all_player_buttons(self): + """Update all player boxes with the current button state""" + players: List[PlayerBox] = self.player_stack.get_children() + logger.info( + f"[PlayerBoxStack] Updating buttons for {len(players)} players, { + len(self.player_buttons) + } buttons" + ) + for player_box in players: + if hasattr(player_box, "update_buttons"): + player_box.update_buttons(self.player_buttons, len(players) > 1) + else: + logger.warning( + "[PlayerBoxStack] PlayerBox missing update_buttons method" + ) + + +class PlayerBox(Box): + """A widget that displays the current player information.""" + + def __init__(self, player: MprisPlayer, player_stack=None, **kwargs): + super().__init__( + h_align="center", + name="player-box", + **kwargs, + h_expand=True, + ) + # Setup + self.player: MprisPlayer = player + self.player_stack = player_stack + self.fallback_cover_path = f"{data.HOME_DIR}/.current.wall" + + self.icon_size = 15 + + # State + self.exit = False + self.skipped = False + self._user_seeking = False # Flag to prevent choppy seeking + self._seekbar_timer_id = None # Track timer ID for cleanup + + # Memory management + self.temp_artwork_files = [] # Track temp files for cleanup + self.current_download_thread = None # Track current download thread + self._download_cancelled = False # Flag to cancel downloads + self._signal_connections = [] # Track signal connections + + # Use same CSS background approach as small player for consistency + self.album_cover = Box( + name="macos-album-image", + ) + self.album_cover.set_style( + f"background-image:url('{self.fallback_cover_path}')" + ) + self.album_cover.set_size_request(70, 70) + + self.image_stack = Box( + h_align="start", v_align="center", name="player-image-stack" + ) + self.image_stack.children = [*self.image_stack.children, self.album_cover] + + # Track Info + self.track_title = Label( + label="No Title", + name="macos-player-title", + justification="left", + max_chars_width=30, + ellipsization="end", + h_align="start", + h_expand=True, + ) + + self.track_artist = Label( + label="No Artist", + name="macos-player-artist", + justification="left", + max_chars_width=25, + ellipsization="end", + h_align="start", + h_expand=True, + visible=True, + ) + self.track_album = Label( + label="No Album", + name="macos-player-album", + justification="left", + max_chars_width=25, + ellipsization="end", + h_align="start", + visible=True, # Hide artist and album when no media + ) + + self.app_icon = Box( + children=Image( + icon_name=self.player.player_name, name="player-app-icon", icon_size=20 + ), + h_align="end", + v_align="end", + tooltip_text=self.player.player_name, # type: ignore + ) + self.image = Overlay( + child=self.image_stack, + overlays=[ + self.app_icon, + ], + ) + # Seek bar should not update automatically during user interaction + self._user_seeking = False + + self.seek_bar = Scale( + value=0, + min_value=0, + max_value=100, + increments=(1, 1), + name="expanded-seek-bar", + size=1, + h_expand=True, + ) + self.seek_bar.connect("value-changed", self._on_scale_value_changed) + self.seek_bar.connect("button-press-event", self._on_seek_start) + self.seek_bar.connect("button-release-event", self._on_seek_end) + self.player.bind_property("can_seek", self.seek_bar, "sensitive") + + # Position and length labels for seek bar + self.position_label = Label( + label="0:00", + name="macos-position-label", + justification="left", + h_align="start", + ) + + self.length_label = Label( + label="0:00", + name="macos-length-label", + justification="right", + h_align="end", + ) + + # Labels box for position and length below seek bar + self.labels_box = Box( + name="macos-labels-box", + orientation="h", + children=[ + self.position_label, + Box(h_expand=True), # Spacer to push labels to ends + self.length_label, + ], + ) + + # Seek bar with position labels below + self.seek_box = Box( + name="macos-seek-box", + orientation="v", + spacing=2, + children=[ + self.seek_bar, + self.labels_box, + ], + ) + + # Define buttons first + self.button_box = Box( + name="macos-button-box", + h_align="center", + h_expand=True, + spacing=10, # Reduced spacing for macOS look + ) + + # Define controls_box BEFORE using it in track_info + self.controls_box = Box( + name="macos-player-controls", + orientation="v", + h_expand=True, + spacing=6, + h_align="center", + children=[self.button_box], + ) + + # Track info with inline controls - expands to fill available space + self.track_info = Box( + name="macos-track-info", + spacing=4, # Reduced spacing + orientation="v", + v_align="start", + h_align="fill", # Fill all available horizontal space + h_expand=True, # Expand horizontally to take maximum space + v_expand=True, # Also expand vertically + children=[ + self.track_title, + self.track_artist, + self.track_album, + ], + ) + + # Bind player properties + self.player.bind_property( + "title", + self.track_title, + "label", + GObject.BindingFlags.DEFAULT, + lambda _, x: ( + re.sub(r"\r?\n", " ", x) if x != "" and x is not None else "No Title" + ), # type: ignore + ) + self.player.bind_property( + "artist", + self.track_artist, + "label", + GObject.BindingFlags.DEFAULT, + lambda _, x: ( + re.sub(r"\r?\n", " ", ", ".join(x)) + if isinstance(x, list) and len(x) > 0 + else "No Artist" + ), # type: ignore + ) + self.player.bind_property( + "album", + self.track_album, + "label", + GObject.BindingFlags.DEFAULT, + lambda _, x: ( + re.sub(r"\r?\n", " ", x) if x != "" and x is not None else "No Album" + ), # type: ignore + ) + + # Player switcher buttons box (compact, minimal space) + self.stack_buttons_box = Box( + h_expand=False, # Fixed width, don't expand + v_expand=True, + name="macos-stack-buttons-box", + spacing=4, # Reduced spacing + orientation="h", # Vertical layout for compactness + h_align="center", + v_align="end", + ) + self.stack_buttons_box.hide() # Initially hidden + + # Create SVG icons from player directory + self.skip_next_icon = svg_file("player/fwd.svg", size=22) + self.skip_prev_icon = svg_file("player/Rewind.svg", size=22) + self.play_pause_icon = svg_file("player/Pause.svg", size=22) + + self.play_pause_button = Button( + style_classes=["control-buttons"], + name="macos-play-button", + child=self.play_pause_icon, + on_clicked=self.player.play_pause, + ) + + self.player.bind_property("can_pause", self.play_pause_button, "sensitive") + + self.next_button = Button( + style_classes=["control-buttons"], + name="macos-control-button", + child=self.skip_next_icon, + on_clicked=self._on_player_next, + ) + self.player.bind_property("can_go_next", self.next_button, "sensitive") + + self.prev_button = Button( + name="macos-control-button", + child=self.skip_prev_icon, + style_classes=["control-buttons"], + on_clicked=self._on_player_prev, + ) + self.player.bind_property("can_go_previous", self.prev_button, "sensitive") + self.button_box.children = ( + self.prev_button, + self.play_pause_button, + self.next_button, + ) + + self.box = Box( + orientation="horizontal", + children=[ + self.image, # Album art on left (fixed width) + # Contains title, artist, seek bar AND controls (expands) + self.track_info, + ], + ) + self.inner_box = Box( + orientation="v", + h_expand=True, + h_align="fill", # Fill available space + children=[ + self.box, # Track info and album art + self.seek_box, # Seek bar with position labels + self.controls_box, # Controls now inline with track info + ], + ) + # Compact macOS layout: album art on left, expanded track info+controls, minimal switcher + self.outer_box = Box( + name="macos-outer-player-box", + orientation="v", + spacing=10, # Reduced spacing between elements + h_expand=True, + v_expand=True, + v_align="center", + h_align="fill", # Fill available space + children=[ + self.inner_box, # Track info and controls + # Compact switcher in corner (fixed width) + self.stack_buttons_box, + ], + ) + + self.children = [*self.children, self.outer_box] + + # Track signal connections for cleanup - store (object, handler_id) tuples + connections = bulk_connect( + self.player, + { + "closed": self._on_player_exit, + "notify::playback-status": self._on_playback_change, + "notify::metadata": self._on_metadata, + }, + ) + # Store as (object, handler_id) tuples + for handler_id in connections: + self._signal_connections.append((self.player, handler_id)) + + # Start seek bar timer immediately for live updates (regardless of playback status) + self._seekbar_timer_id = invoke_repeater(1000, self._move_seekbar) + + # Connect to realize signal to initialize seek bar properly + self.seek_bar.connect("realize", self._on_seek_bar_realized) + + def destroy(self): + """Clean up all resources when the widget is destroyed.""" + # Set exit flag FIRST to stop any running timers + self.exit = True + + # Cancel any ongoing downloads immediately + self._download_cancelled = True + + # Cancel seek bar timer + if self._seekbar_timer_id: + try: + GLib.source_remove(self._seekbar_timer_id) + except Exception: + pass + self._seekbar_timer_id = None + + # Wait for download thread to finish (with timeout) + if self.current_download_thread and self.current_download_thread.is_alive(): + try: + self.current_download_thread.join(timeout=1.0) # 1 second timeout + except Exception: + pass + + # Disconnect all signal connections + for obj, handler_id in self._signal_connections: + try: + obj.disconnect(handler_id) + except Exception as e: + logger.warning(f"Failed to disconnect signal: {e}") + self._signal_connections.clear() + + # Clean up temp files aggressively + self._cleanup_temp_files() + + # Clear image references + if hasattr(self, "album_cover_image"): + try: + self.album_cover_image.set_from_pixbuf(None) + except Exception: + pass + + super().destroy() + + def __del__(self): + """Ensure cleanup happens even if player exits unexpectedly.""" + try: + self._cleanup_temp_files() + except Exception: + pass # Ignore errors during cleanup in destructor + + def update_buttons(self, player_buttons, show_buttons): + """Update the stack switcher buttons in this player box""" + logger.info( + f"[PlayerBox] update_buttons called: show_buttons={ + show_buttons + }, num_buttons={len(player_buttons)}" + ) + + # Clear existing buttons + for child in self.stack_buttons_box.get_children(): + try: + child.destroy() + except Exception: + pass + + if show_buttons and len(player_buttons) > 1: + logger.info(f"[PlayerBox] Creating {len(player_buttons)} stack buttons") + # Create macOS-style dot indicators for each player + for i, button in enumerate(player_buttons): + # Create a macOS-style dot button + dot_button = Button( + name="macos-player-switcher-dot", + style_classes=["macos-switcher-dot"], + ) + + # Set active state based on original button + if button.get_style_context().has_class("active"): + dot_button.add_style_class("active") + + # Connect click handler to switch to this player + def make_click_handler(index): + return lambda *_: self.player_stack.on_player_clicked_by_index( + index + ) + + dot_button.connect("clicked", make_click_handler(i)) + self.stack_buttons_box.children = [ + *self.stack_buttons_box.children, + dot_button, + ] + logger.info(f"[PlayerBox] Added dot button {i}") + + self.stack_buttons_box.show_all() + logger.info("[PlayerBox] Stack buttons box shown") + else: + self.stack_buttons_box.hide() + logger.info("[PlayerBox] Stack buttons box hidden") + + def length_str(self, length): + """Convert length in microseconds to MM:SS or H:MM:SS format like real media players.""" + if length is None or length <= 0: + return "0:00" + + # Convert microseconds to seconds + length_seconds = length / 1000000 + + hours = int(length_seconds // 3600) + minutes = int((length_seconds % 3600) // 60) + seconds = int(length_seconds % 60) + + if hours > 0: + return f"{hours}:{minutes:02d}:{seconds:02d}" + else: + return f"{minutes}:{seconds:02d}" + + def _on_metadata(self, *_): + self._set_image() + duration = self.player.length + + if duration is None: + duration = 0 + + if duration and duration > 0: + self.length_label.set_label(self.length_str(duration)) + # Clamp duration to avoid 32-bit integer overflow in the scale widget + max_int32 = 2147483647 # 2^31 - 1 + safe_duration = min(max_int32, duration) + + # Only set range if seek bar is ready + if self.seek_bar.get_realized(): + self.seek_bar.set_range(0, safe_duration) + else: + self.length_label.set_label("0:00") + if self.seek_bar.get_realized(): + self.seek_bar.set_range(0, 100) + + # Restart timer to ensure it's running with updated metadata + if self._seekbar_timer_id: + try: + GLib.source_remove(self._seekbar_timer_id) + except Exception: + pass + self._seekbar_timer_id = None + + # Start new timer and store its ID + self._seekbar_timer_id = invoke_repeater(1000, self._move_seekbar) + + def _cleanup_temp_files(self): + """Clean up temporary artwork files.""" + for temp_file in self.temp_artwork_files: + try: + if os.path.exists(temp_file): + os.unlink(temp_file) + except Exception as e: + logger.warning(f"Failed to cleanup temp file {temp_file}: {e}") + self.temp_artwork_files.clear() + + def _on_player_exit(self, _, value): + self.exit = value + self._cleanup_temp_files() # Clean up temp files before destroying + self.destroy() + + def _on_player_next(self, *_): + self.player.next() + + def _on_player_prev(self, *_): + self.player.previous() + + def _on_playback_change(self, player, status): + status = player.playback_status + + if status == "Paused": + self.play_pause_icon.dynamic_file("player/play.svg") + # Keep timer running for live updates even when paused + + if status == "Playing": + self.play_pause_icon.dynamic_file("player/Pause.svg") + # Ensure timer is running for live updates + if not self._seekbar_timer_id: + self._seekbar_timer_id = invoke_repeater(1000, self._move_seekbar) + + def _update_image(self, image_path): + if image_path and os.path.isfile(image_path): + self.album_cover.set_style(f"background-image:url('{image_path}')") + else: + self.album_cover.set_style( + f"background-image:url('{self.fallback_cover_path}')" + ) + + def _set_image(self, *_): + art_url = self.player.arturl + + # If no art URL or empty/None, use fallback + if not art_url: + self._update_image(None) + return + + parsed = urllib.parse.urlparse(art_url) + if parsed.scheme == "file": + local_arturl = urllib.parse.unquote(parsed.path) + self._update_image(local_arturl) + elif parsed.scheme in ("http", "https"): + # Cancel any existing download to prevent memory buildup + self._download_cancelled = True + + # Use threading.Thread instead of GLib.Thread for better control + if self.current_download_thread and self.current_download_thread.is_alive(): + # Thread will check _download_cancelled flag and exit early + pass + + self._download_cancelled = False + self.current_download_thread = threading.Thread( + target=self._download_and_set_artwork, + args=(art_url,), + daemon=True, # Dies with main thread + ) + self.current_download_thread.start() + else: + print(art_url) + self._update_image(art_url) + + def _download_and_set_artwork(self, arturl): + """ + Download the artwork from the given URL asynchronously and update the cover + using GLib.idle_add to ensure UI updates occur on the main thread. + """ + local_arturl = self.fallback_cover_path + temp_file_path = None + + try: + # Check if download was cancelled + if self._download_cancelled: + return + + # Clean up old temp files first (keep only last 1 to reduce memory) + if len(self.temp_artwork_files) > 1: + old_files = self.temp_artwork_files[:-1] + for old_file in old_files: + try: + if os.path.exists(old_file): + os.unlink(old_file) + except Exception: + pass + self.temp_artwork_files = self.temp_artwork_files[-1:] + + # Check again if cancelled + if self._download_cancelled: + return + + # Download artwork + parsed = urllib.parse.urlparse(arturl) + suffix = os.path.splitext(parsed.path)[1] or ".png" + + with urllib.request.urlopen(arturl, timeout=10) as response: # Add timeout + if self._download_cancelled: + return + data = response.read() + + # Check one more time if cancelled + if self._download_cancelled: + return + + # Create temp file in cache directory instead of system temp + os.makedirs(CACHE_DIR, exist_ok=True) + with tempfile.NamedTemporaryFile( + delete=False, suffix=suffix, dir=CACHE_DIR + ) as temp_file: + temp_file.write(data) + temp_file_path = temp_file.name + local_arturl = temp_file_path + + # Track temp file for cleanup + if temp_file_path and not self._download_cancelled: + self.temp_artwork_files.append(temp_file_path) + + except Exception as e: + if not self._download_cancelled: + logger.warning(f"Failed to download artwork from {arturl}: {e}") + # Clean up failed temp file + if temp_file_path and os.path.exists(temp_file_path): + try: + os.unlink(temp_file_path) + except Exception: + pass + return + + # Only update UI if not cancelled + if not self._download_cancelled: + GLib.idle_add(self._update_image, local_arturl) + return None + + def _move_seekbar(self, *_): + if self.player is None or self.exit or self._user_seeking: + return True # Continue the timer but don't update while user is seeking + + # Additional safety checks to prevent GTK errors + if not hasattr(self, "seek_bar") or self.seek_bar is None: + return False # Stop the timer + + try: + # Check if seek bar is realized and has a valid adjustment + if not self.seek_bar.get_realized(): + return True # Continue timer, widget not ready yet + + position = self.player.position + if position is None: + position = 0 + + self.position_label.set_label(self.length_str(position)) + + # Only update seek bar if user is not currently seeking + if not self._user_seeking: + # Clamp position to avoid 32-bit integer overflow + max_int32 = 2147483647 # 2^31 - 1 + safe_position = min(max_int32, position) if position else 0 + + # Only set value if seek bar has a valid range + if ( + self.seek_bar.get_adjustment() + and self.seek_bar.get_adjustment().get_upper() > 0 + ): + self.seek_bar.set_value(safe_position) + + except Exception as e: + # If any error occurs (widget destroyed, etc), stop the timer + logger.warning(f"Seek bar update failed, stopping timer: {e}") + return False + + return True + + def _on_seek_start(self, widget, event): + """User started seeking - disable automatic updates""" + self._user_seeking = True + return False + + def _on_seek_end(self, widget, event): + """User finished seeking - re-enable automatic updates""" + self._user_seeking = False + return False + + def _on_seek_bar_realized(self, widget): + """Initialize seek bar when it's realized""" + try: + duration = self.player.length + if duration is None: + duration = 0 + + if duration and duration > 0: + max_int32 = 2147483647 # 2^31 - 1 + safe_duration = min(max_int32, duration) + self.seek_bar.set_range(0, safe_duration) + else: + self.seek_bar.set_range(0, 100) + except Exception as e: + logger.warning(f"Failed to initialize seek bar: {e}") + + def _on_scale_value_changed(self, scale: Scale): + """Handle seek bar value changes - only when user is seeking""" + if self.player and not self.exit and self._user_seeking: + try: + new_position = int(scale.get_value()) + # Clamp to 32-bit signed integer range to avoid overflow + max_int32 = 2147483647 # 2^31 - 1 + min_int32 = -2147483648 # -2^31 + new_position = max(min_int32, min(max_int32, new_position)) + self.player.position = new_position + self.position_label.set_label(self.length_str(new_position)) + except Exception as e: + # If setting position fails, just update the label + try: + self.position_label.set_label(self.length_str(new_position)) + except Exception: + logger.warning(f"Failed to update position label: {e}") + + @cooldown(0.1) + def _on_scale_move(self, scale: Scale, event, pos: int): + try: + if not self.exit and self.player: + self.player.position = pos + self.position_label.set_label(self.length_str(pos)) + self.seek_bar.set_value(pos) + except Exception as e: + logger.warning(f"Failed to update seek position: {e}") + + +class Thing(Box): + def __init__(self, **kwargs): + super().__init__( + name="thing", + size=(480, 160), + orientation="vertical", + spacing=0, + children=[ + Label( + name="thing-label", + label="This is a thing", + style="font-size: 16px; padding: 10px;", + ), + ], + **kwargs, + ) + + +class ExpandedPlayer(Window): + def __init__(self, **kwargs): + super().__init__( + name="expanded-player", + title="modus", + anchor="top right", + layer="top", + exclusivity="auto", + child=PlayerBoxStack(get_shared_mpris_manager()), + visible=False, + ) + self.add_keybinding("Escape", self.set_child_visible(False)) + + def destroy(self): + """Clean up resources when the window is destroyed.""" + # Clean up the child PlayerBoxStack + if hasattr(self, "child") and hasattr(self.child, "destroy"): + try: + self.child.destroy() + except Exception as e: + logger.warning(f"Failed to destroy child PlayerBoxStack: {e}") + + super().destroy() + + def _init_mousecapture(self, mousecapture): + self._mousecapture_parent = mousecapture + + def hide_controlcenter(self, *_): + # self._mousecapture_parent.toggle_mousecapture() + self.set_visible(False) diff --git a/src/window/controlcenter/main.py b/src/window/controlcenter/main.py new file mode 100644 index 0000000..fe8646a --- /dev/null +++ b/src/window/controlcenter/main.py @@ -0,0 +1,1197 @@ +import subprocess + +from fabric.utils import Gdk, GLib, get_relative_path, idle_add, logger +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.centerbox import CenterBox +from fabric.widgets.label import Label +from fabric.widgets.scale import Scale +from fabric.widgets.wayland import WaylandWindow as Window + +from window.controlcenter.bluetooth import ( + BluetoothConnections, + set_bluetooth_enabled_with_fallback, +) +from window.controlcenter.expanded_player import EmbeddedExpandedPlayer +from window.controlcenter.nightlight import create_night_light_widget +from window.controlcenter.per_app_volume import PerAppVolumeControl +from window.controlcenter.player import PlayerBoxStack, get_shared_mpris_manager +from window.controlcenter.wifi import WifiConnections +from services.brightness import Brightness +from services.network import NetworkClient +from utils.roam import audio_service, modus_service +from utils.utils import svg_file + +brightness_service = Brightness.get_initial() + + +class ModusControlCenter(Window): + def __init__(self, **kwargs): + super().__init__( + layer="top", + title="modus", + anchor="top right", + margin="2px 10px 0px 0px", + exclusivity="auto", + keyboard_mode="on-demand", + name="control-center-menu", + visible=False, + **kwargs, + ) + self.focus_mode = modus_service.dont_disturb + self._updating_brightness = False + self._updating_volume = False + + # Flight mode and caffeine states + self.flight_mode = False + self.caffeine_mode = False + self._caffeine_process = None + + # Loading flags - music and expanded player are no longer lazy loaded + self._per_app_volume_initialized = False + self._signals_connected = False # Track if signals are connected + self._resources_initialized = False # Track if resources are initialized + + # Store references for cleanup - initialize all as None + self._signal_connections = [] + self._music_widget_content = None + self._per_app_volume_widget = None + self._expanded_player_widget = None + self._mpris_manager = None # Shared MPRIS manager instance + + # Initialize network service for WiFi toggle - lazy load + self.network_service = None + self.wifi_service = None + + # Initialize flight mode and caffeine states - lazy load + self.caffeine_mode = False + self.flight_mode = False + + # Add keybinding immediately (this is fast) + self.add_keybinding("Escape", self.hide_controlcenter) + + volume = 100 + # Get initial values and connect signals - store connection IDs for cleanup + wlan = modus_service.wlan if modus_service.wlan else "No Connection" + bluetooth = modus_service.bluetooth if modus_service.bluetooth else "Off" + + self.wlan_label = Label( + label=wlan, + name="wifi-widget-label", + max_chars_width=15, + h_align="start", + ellipsization="end", + ) + if bluetooth != "disabled": + if bluetooth.startswith("connected:"): + parts = bluetooth.split(":") + bluetooth_display = parts[1] if len(parts) >= 2 else "Connected" + else: + bluetooth_display = "On" + else: + bluetooth_display = "Off" + + self.bluetooth_label = Label( + label=bluetooth_display, + name="bluetooth-widget-label", + max_chars_width=15, + ellipsization="end", + h_align="start", + ) + self.volume_scale = Scale( + value=volume, + min_value=0, + max_value=100, + increments=(5, 5), + name="volume-widget-slider", + size=30, + h_expand=True, + ) + + current_brightness = brightness_service.screen_brightness + brightness_percentage = ( + int((current_brightness / brightness_service.max_screen) * 100) + if brightness_service.max_screen > 0 + else 50 + ) + + self.brightness_scale = Scale( + value=brightness_percentage, + min_value=0, + max_value=100, + increments=(5, 5), + name="brightness-widget-slider", + size=30, + h_expand=True, + ) + + # Only connect brightness controls if brightness service is available + if brightness_service.max_screen > 0: + pass + else: + # Disable brightness scale if no backlight device available + self.brightness_scale.set_sensitive(False) + + self._mpris_manager = get_shared_mpris_manager() + self.music_widget = PlayerBoxStack(self._mpris_manager, control_center=self) + + self.has_bluetooth_open = False + self.has_wifi_open = False + self.has_per_app_volume_open = False + self.has_expanded_player_open = False + + self.bluetooth_svg = svg_file( + ( + "applets/bluetooth.svg" + if bluetooth != "disabled" + else "applets/bluetooth-off.svg" + ), + size=42, + ) + self.wifi_svg = svg_file( + "applets/wifi.svg" if wlan != "No Connection" else "applets/wifi-off.svg", + size=42, + ) + + self.bluetooth_widget = Box( + name="bluetooth-widget", + orientation="h", + children=[ + Button( + name="bluetooth-icon-button", + child=self.bluetooth_svg, + on_clicked=self.toggle_bluetooth, + ), + Button( + name="bluetooth-info-button", + child=Box( + name="bluetooth-widget-info", + orientation="vertical", + children=[ + Label( + name="bluetooth-widget-name", + label="Bluetooth", + style_classes="ct", + h_align="start", + ), + self.bluetooth_label, + ], + ), + on_clicked=self.open_bluetooth, + ), + ], + ) + + self.wlan_widget = Box( + name="wifi-widget", + orientation="h", + children=[ + Button( + name="wifi-icon-button", + child=self.wifi_svg, + on_clicked=self.toggle_wifi, + ), + Button( + name="wifi-info-button", + child=Box( + name="wifi-widget-info", + orientation="vertical", + children=[ + Label( + name="wifi-widget-name", + label="Wi-Fi", + style_classes="ct", + h_align="start", + ), + self.wlan_label, + ], + ), + on_clicked=self.open_wifi, + ), + ], + ) + + self.focus_icon = svg_file( + "applets/dnd.svg" if self.focus_mode else "applets/dnd-off.svg", + size=42, + ) + + self.focus_status_label = Label( + label="On" if self.focus_mode else "Off", + style_classes="status-label", + h_align="start", + ) + + self.focus_widget = Button( + name="focus-widget", + child=Box( + spacing=4, + orientation="h", + children=[ + self.focus_icon, + Box( + v_expand=True, + h_expand=True, + v_align="center", + orientation="v", + h_align="start", + children=[ + Label( + label="Focus", + style_classes="title-widget", + h_align="start", + ), + self.focus_status_label, + ], + ), + ], + ), + on_clicked=self.set_dont_disturb, + ) + + self.flight_icon = svg_file( + "applets/flight-on.svg" if self.flight_mode else "applets/flight-off.svg", + size=42, + ) + + self.flight_widget = Button( + name="flight-widget", + child=Box( + orientation="v", + h_expand=True, + v_expand=True, + spacing=8, + h_align="center", + v_align="center", + children=[ + self.flight_icon, + Label( + label="Flight", + style_classes="title-widget", + h_align="center", + ), + ], + ), + on_clicked=self.toggle_flight_mode, + ) + + self.caffeine_icon = svg_file( + ( + "applets/caffeine-on.svg" + if self.caffeine_mode + else "applets/caffeine-off.svg" + ), + size=42, + ) + + self.caffeine_status_label = Label( + label="On" if self.caffeine_mode else "Off", + style_classes="status-label", + h_align="start", + ) + + self.caffeine_widget = Button( + name="caffeine-widget", + child=Box( + orientation="v", + spacing=8, + h_expand=True, + v_expand=True, + h_align="center", + v_align="center", + children=[ + self.caffeine_icon, + Label( + label="Caffeine", + style_classes="title-widget", + h_align="center", + ), + ], + ), + on_clicked=self.toggle_caffeine, + ) + + # Create night light widget + self.night_light_widget = create_night_light_widget(self) + + # Create main widgets directly without XML - defer heavy operations + self.widgets = Box( + orientation="vertical", + h_expand=True, + name="control-center-widgets", + children=[ + # Top section: Wi-Fi + Bluetooth on left, DND + Flight Mode + Caffeine on right + Box( + orientation="horizontal", + h_expand=True, + name="top-widget", + children=[ + # Left side: Wi-Fi and Bluetooth + Box( + orientation="vertical", + name="wb-widget", + style_classes="menu", + spacing=5, + children=[ + self.wlan_widget, + self.bluetooth_widget, + self.night_light_widget, + ], + ), + # Right side: DND, Flight Mode, and Caffeine + Box( + orientation="vertical", + h_expand=True, + name="right-side-widget", + children=[ + # DND widget + Box( + orientation="vertical", + name="dnd-widget", + style_classes="menu", + children=[ + self.focus_widget, + ], + ), + # Flight Mode and Caffeine row + Box( + orientation="horizontal", + name="flight-caffeine-row", + children=[ + Box( + orientation="vertical", + name="flight-widget-container", + style_classes="menu", + h_expand=True, + v_expand=True, + children=[ + self.flight_widget, + ], + ), + Box( + orientation="vertical", + name="caffeine-widget-container", + h_expand=True, + v_expand=True, + style_classes="menu", + children=[ + self.caffeine_widget, + ], + ), + ], + ), + ], + ), + ], + ), + Box( + orientation="vertical", + name="brightness-widget", + style_classes="menu", + h_expand=True, + children=[ + Label(label="Display", style_classes="title", h_align="start"), + self.brightness_scale, + Label( + label="󰖨 ", name="brightness-widget-icon", h_align="start" + ), + ], + ), + Box( + orientation="vertical", + name="volume-widget", + style_classes="menu", + h_expand=True, + children=[ + Label(label="Sound", style_classes="title", h_align="start"), + Box( + name="vol-box", + orientation="horizontal", + spacing=12, + v_expand=True, + children=[ + Box( + name="vol-slider-box", + h_expand=True, + v_align="center", + v_expand=False, + children=[ + self.volume_scale, + ], + ), + Button( + name="per-app-volume-button", + size=(36, 36), + child=svg_file( + "player/audio-switcher.svg", + name="per-app-volume-icon", + size=32, + ), + on_clicked=self.open_per_app_volume, + ), + ], + ), + Label(label=" ", name="volume-widget-icon", h_align="start"), + ], + ), + self.music_widget, + ], + ) + + # Initialize managers directly like working version + self.wifi_man = WifiConnections(self) + self.bluetooth_man = BluetoothConnections(self) + + self.has_bluetooth_open = False + self.has_wifi_open = False + + # Create expanded player widgets immediately (no lazy loading) + self._expanded_player_widget = EmbeddedExpandedPlayer(self) + self.expanded_player_widgets = Box( + orientation="vertical", + h_expand=True, + name="control-center-widgets", + children=[ + self._expanded_player_widget, + ], + ) + + # Lazy-loaded widgets - create placeholders for others + self.bluetooth_widgets = None + self.wifi_widgets = None + self.per_app_volume_widgets = None + + # Create main content boxes + self.center_box = CenterBox(start_children=[self.widgets]) + self.bluetooth_center_box = None + self.wifi_center_box = None + self.per_app_volume_center_box = None + # Create expanded player center box immediately + self.expanded_player_center_box = CenterBox( + start_children=[self.expanded_player_widgets] + ) + self.expanded_player_center_box.set_size_request(300, -1) + + # Create revealers for crossfade transitions + + self.widgets.set_size_request(300, -1) + + self.children = self.center_box + + # Track current state for smooth transitions + self.current_view = "main" # main, expanded_player + + # Connect to visibility changes for cleanup + self.connect("notify::visible", self._on_visibility_changed) + + def _on_visibility_changed(self, widget, param): + """Handle visibility changes for resource management""" + if not self.get_visible(): + # Just disconnect signals and reset state flags - don't destroy widgets + self._disconnect_signals_when_hidden() + else: + self._initialize_resources() + + def _initialize_resources(self): + """Initialize resources and connect signals when the control center becomes visible.""" + if self._resources_initialized: + return + + try: + logger.debug("Initializing control center resources...") + + # Initialize network service lazily + if self.network_service is None: + self.network_service = NetworkClient() + # Connect network signal + self.network_service.connect("wifi-device-added", self.on_network_ready) + + # Check initial states lazily (only when needed) + self._check_initial_states() + + # Store signal connections for cleanup + self._signal_connections.extend( + [ + audio_service.connect("changed", self.audio_changed), + audio_service.connect("changed", self.volume_changed), + modus_service.connect("wlan-changed", self.wlan_changed), + modus_service.connect("bluetooth-changed", self.bluetooth_changed), + modus_service.connect("dont-disturb-changed", self.dnd_changed), + ] + ) + + # Connect brightness controls if brightness service is available + if brightness_service.max_screen > 0: + self.brightness_scale.connect("change-value", self.set_brightness) + self.brightness_scale.connect("scroll-event", self.on_brightness_scroll) + self._signal_connections.append( + brightness_service.connect("screen", self.brightness_changed) + ) + + # Connect volume scale signals + self.volume_scale.connect("change-value", self.set_volume) + self.volume_scale.connect("scroll-event", self.on_volume_scroll) + + # Mark signals as connected + self._signals_connected = True + self._resources_initialized = True + + logger.debug("Control center resources initialized successfully") + + except Exception as e: + logger.error(f"Failed to initialize control center resources: {e}") + # Reset flags on failure + self._signals_connected = False + self._resources_initialized = False + + def _disconnect_signals_when_hidden(self): + """Disconnect signals when hidden to reduce resource usage, but keep widgets intact""" + try: + # Just disconnect signals and reset flags - don't destroy widgets + self._signals_connected = False + self._resources_initialized = False + + # Reset state flags + self.has_bluetooth_open = False + self.has_wifi_open = False + self.has_per_app_volume_open = False + self.has_expanded_player_open = False + + # Reset current view + self.current_view = "main" + + except Exception as e: + logger.warning(f"Control center signal disconnection failed: {e}") + + def _ensure_bluetooth_widgets(self): + """Lazy load bluetooth widgets""" + if self.bluetooth_widgets is None: + self.bluetooth_widgets = Box( + orientation="vertical", + h_expand=True, + v_expand=True, + children=[self.bluetooth_man], + ) + self.bluetooth_center_box = Box( + h_expand=True, v_expand=True, children=[self.bluetooth_widgets] + ) + self.bluetooth_center_box.set_size_request(350, -1) + + def _ensure_wifi_widgets(self): + """Lazy load wifi widgets""" + if self.wifi_widgets is None: + self.wifi_widgets = Box( + orientation="vertical", + h_expand=True, + v_expand=True, + children=[ + self.wifi_man, + ], + ) + self.wifi_center_box = Box( + h_expand=True, v_expand=True, children=[self.wifi_widgets] + ) + self.wifi_center_box.set_size_request(350, -1) + + def _ensure_per_app_volume_widgets(self): + """Lazy load per-app volume widgets""" + if self.per_app_volume_widgets is None: + if self._per_app_volume_widget is None: + self._per_app_volume_widget = PerAppVolumeControl(self) + + self.per_app_volume_widgets = Box( + orientation="vertical", + h_expand=True, + name="control-center-widgets", + children=[ + self._per_app_volume_widget, + ], + ) + self.per_app_volume_center_box = CenterBox( + start_children=[self.per_app_volume_widgets] + ) + self.per_app_volume_center_box.set_size_request(300, -1) + + def _check_initial_states(self): + # Check if caffeine is already running - use faster method + try: + # Use faster check with timeout + result = subprocess.run( + ["pgrep", "-f", "modus-inhibit"], + capture_output=True, + text=True, + timeout=0.5, # Add timeout to prevent hanging + ) + self.caffeine_mode = bool(result.stdout.strip()) + except (subprocess.TimeoutExpired, Exception): + self.caffeine_mode = False + + # Flight mode starts as False (normal mode) + self.flight_mode = False + + # Update initial labels (will be set after widgets are created) + GLib.timeout_add(100, self._update_initial_labels) + + def _update_initial_labels(self): + return False + + def set_dont_disturb(self, *_): + self.focus_mode = not self.focus_mode + modus_service.dont_disturb = self.focus_mode + self.focus_icon.dynamic_file( + "applets/dnd.svg" if self.focus_mode else "applets/dnd-off.svg" + ) + self.focus_status_label.set_label("On" if self.focus_mode else "Off") + + def toggle_flight_mode(self, *_): + try: + self.flight_mode = not self.flight_mode + + if self.flight_mode: + # Turn off WiFi and Bluetooth + if self.wifi_service: + self.wifi_service.wireless_enabled = False + if hasattr(self, "bluetooth_man") and hasattr( + self.bluetooth_man, "client" + ): + self.bluetooth_man.client.set_enabled(False) + else: + # Turn on WiFi and Bluetooth + if self.wifi_service: + self.wifi_service.wireless_enabled = True + if hasattr(self, "bluetooth_man") and hasattr( + self.bluetooth_man, "client" + ): + self.bluetooth_man.client.set_enabled(True) + + # Update icon + self.flight_icon.dynamic_file( + "applets/flight-on.svg" + if self.flight_mode + else "applets/flight-off.svg" + ) + + except Exception as e: + logger.warning(f"Failed to toggle flight mode: {e}") + + def toggle_caffeine(self, *_): + try: + if self.caffeine_mode: + inhibit_script = get_relative_path("../../utils/inhibit.py") + subprocess.run(["python3", inhibit_script, "off"], check=False) + self.caffeine_mode = False + if self._caffeine_process: + try: + self._caffeine_process.terminate() + except Exception: + pass + self._caffeine_process = None + else: + inhibit_script = get_relative_path("../../utils/inhibit.py") + self._caffeine_process = subprocess.Popen( + ["python3", inhibit_script, "on"], start_new_session=True + ) + self.caffeine_mode = True + + self.caffeine_icon.dynamic_file( + "applets/caffeine-on.svg" + if self.caffeine_mode + else "applets/caffeine-off.svg" + ) + self.caffeine_status_label.set_label("On" if self.caffeine_mode else "Off") + + except Exception as e: + logger.warning(f"Failed to toggle caffeine: {e}") + + def set_volume(self, _, __, volume): + if not self._signals_connected: + return + self._updating_volume = True + audio_service.speaker.volume = round(volume) + self._updating_volume = False + + def set_brightness(self, _, __, brightness): + if not self._signals_connected: + return + self._updating_brightness = True + brightness_value = int((brightness / 100) * brightness_service.max_screen) + brightness_service.screen_brightness = brightness_value + self._updating_brightness = False + + def brightness_changed(self, _, brightness_value): + if not self._signals_connected or self._updating_brightness: + return + + if brightness_service.max_screen > 0: + brightness_percentage = int( + (brightness_value / brightness_service.max_screen) * 100 + ) + + GLib.idle_add( + lambda: self.brightness_scale.set_value(brightness_percentage) + ) + + def on_volume_scroll(self, widget, event): + if not self._signals_connected: + return False + current_value = self.volume_scale.get_value() + scroll_step = 5 + if event.direction == Gdk.ScrollDirection.UP: + new_value = min(100, current_value + scroll_step) + elif event.direction == Gdk.ScrollDirection.DOWN: + new_value = max(0, current_value - scroll_step) + else: + return False + + self.volume_scale.set_value(new_value) + return True + + def on_brightness_scroll(self, widget, event): + if not self._signals_connected: + return False + current_value = self.brightness_scale.get_value() + scroll_step = 5 + if event.direction == Gdk.ScrollDirection.UP: + new_value = min(100, current_value + scroll_step) + elif event.direction == Gdk.ScrollDirection.DOWN: + new_value = max(0, current_value - scroll_step) + else: + return False + + self.brightness_scale.set_value(new_value) + return True + + def toggle_bluetooth(self, *_): + if not self._resources_initialized: + return + try: + if hasattr(self, "bluetooth_man") and hasattr(self.bluetooth_man, "client"): + current_state = self.bluetooth_man.client.enabled + set_bluetooth_enabled_with_fallback( + self.bluetooth_man.client, not current_state + ) + else: + logger.warning("Bluetooth client not available for toggling") + except Exception as e: + logger.warning(f"Failed to toggle bluetooth: {e}") + + def on_network_ready(self, *_): + self.wifi_service = self.network_service.wifi_device + if self.wifi_service: + self.wifi_service.connect("notify::wireless-enabled", self.update_wifi_icon) + + def update_wifi_icon(self, *_): + try: + if self.wifi_service and hasattr(self, "wifi_svg"): + is_enabled = self.wifi_service.wireless_enabled + self.wifi_svg.dynamic_file( + "applets/wifi.svg" if is_enabled else "applets/wifi-off.svg" + ) + except Exception as e: + logger.warning(f"Failed to update WiFi icon: {e}") + + def toggle_wifi(self, *_): + if not self._resources_initialized: + return + try: + if self.wifi_service: + self.wifi_service.toggle_wifi() + GLib.timeout_add(100, self.update_wifi_icon) + else: + logger.warning("WiFi device not available for toggling") + except Exception as e: + logger.warning(f"Failed to toggle wifi: {e}") + + def set_children(self, children): + self.children = children + + def open_bluetooth(self, *_): + if not self._resources_initialized: + return + self._ensure_bluetooth_widgets() + idle_add(lambda *_: self.set_children(self.bluetooth_center_box)) + self.has_bluetooth_open = True + + def open_wifi(self, *_): + if not self._resources_initialized: + return + self._ensure_wifi_widgets() + idle_add(lambda *_: self.set_children(self.wifi_center_box)) + self.has_wifi_open = True + + def close_bluetooth(self, *_): + if self.current_view == "expanded_player": + self._crossfade_to_view("main") + else: + idle_add(lambda *_: self.set_children(self.center_box)) + self.has_bluetooth_open = False + + def close_wifi(self, *_): + if self.current_view == "expanded_player": + self._crossfade_to_view("main") + else: + idle_add(lambda *_: self.set_children(self.center_box)) + self.has_wifi_open = False + + def open_per_app_volume(self, *_): + if not self._resources_initialized: + return + self._ensure_per_app_volume_widgets() + if self.current_view == "expanded_player": + # If coming from expanded player, use crossfade + self._crossfade_to_view("main") + GLib.timeout_add( + 250, + lambda: idle_add( + lambda *_: self.set_children(self.per_app_volume_center_box) + ), + ) + else: + idle_add(lambda *_: self.set_children(self.per_app_volume_center_box)) + self.has_per_app_volume_open = True + # Refresh the app list when opening + if self._per_app_volume_widget: + self._per_app_volume_widget.refresh() + + def close_per_app_volume(self, *_): + if self.current_view == "expanded_player": + self._crossfade_to_view("main") + else: + idle_add(lambda *_: self.set_children(self.center_box)) + self.has_per_app_volume_open = False + + def open_expanded_player(self, *_): + if not self._resources_initialized: + return + self._crossfade_to_view("expanded_player") + self.has_expanded_player_open = True + if self._expanded_player_widget: + self._expanded_player_widget.refresh() + + def close_expanded_player(self, *_): + # Just hide the expanded player widget, don't destroy it + self._crossfade_to_view("main") + self.has_expanded_player_open = False + + def _crossfade_to_view(self, view_name): + """Handle transitions between views""" + if view_name == "expanded_player": + idle_add(lambda *_: self.set_children(self.expanded_player_center_box)) + self.current_view = "expanded_player" + elif view_name == "main": + idle_add(lambda *_: self.set_children(self.center_box)) + self.current_view = "main" + + def _set_mousecapture(self, visible: bool): + if visible: + GLib.idle_add(self._initialize_resources) + + self.set_visible(visible) + if not visible: + self.close_bluetooth() + self.close_wifi() + self.close_per_app_volume() + self.close_expanded_player() + + def volume_changed( + self, + _, + ): + if not self._signals_connected or self._updating_volume: + return + + GLib.idle_add( + lambda: self.volume_scale.set_value(int(audio_service.speaker.volume)) + ) + + def wlan_changed(self, _, wlan): + if not self._signals_connected: + return + self.wifi_svg.dynamic_file( + "applets/wifi.svg" if wlan != "No Connection" else "applets/wifi-off.svg" + ) + if wlan != "No Connection": + if wlan.startswith("connected:"): + parts = wlan.split(":") + if len(parts) >= 2: + wifi_name = parts[1] + GLib.idle_add( + lambda: self.wlan_label.set_property("label", wifi_name) + ) + else: + GLib.idle_add( + lambda: self.wlan_label.set_property("label", "Connected") + ) + else: + GLib.idle_add(lambda: self.wlan_label.set_property("label", wlan)) + else: + GLib.idle_add(lambda: self.wlan_label.set_property("label", wlan)) + + def bluetooth_changed(self, _, bluetooth): + if not self._signals_connected: + return + self.bluetooth_svg.dynamic_file( + "applets/bluetooth.svg" + if bluetooth != "disabled" + else "applets/bluetooth-off.svg" + ) + if bluetooth != "disabled": + if bluetooth.startswith("connected:"): + parts = bluetooth.split(":") + if len(parts) >= 2: + device_name = parts[1] + GLib.idle_add(lambda: self.bluetooth_label.set_label(device_name)) + else: + GLib.idle_add(lambda: self.bluetooth_label.set_label("Connected")) + elif bluetooth == "enabled": + GLib.idle_add(lambda: self.bluetooth_label.set_label("On")) + else: + GLib.idle_add(lambda: self.bluetooth_label.set_label("On")) + else: + GLib.idle_add(lambda: self.bluetooth_label.set_label("Off")) + + def audio_changed(self, *_): + if not self._signals_connected: + return + pass + + def dnd_changed(self, _, dnd_state): + if not self._signals_connected: + return + self.focus_mode = dnd_state + self.focus_icon.dynamic_file( + "applets/dnd.svg" if self.focus_mode else "applets/dnd-off.svg" + ) + self.focus_status_label.set_label("On" if self.focus_mode else "Off") + + def _init_mousecapture(self, mousecapture): + self._mousecapture_parent = mousecapture + + def _disconnect_all_signals(self): + """Disconnect all signal connections to prevent memory leaks""" + try: + for connection in self._signal_connections: + try: + if connection and hasattr(connection, "disconnect"): + connection.disconnect() + except Exception as e: + logger.warning(f"Failed to disconnect signal: {e}") + + self._signal_connections.clear() + + try: + self.disconnect_by_func(self._on_visibility_changed) + except Exception as e: + logger.warning(f"Failed to disconnect visibility signal: {e}") + + try: + self.disconnect_by_func(self.hide_controlcenter) + except Exception as e: + logger.warning(f"Failed to disconnect keybinding: {e}") + + if hasattr(self, "network_service") and self.network_service: + try: + self.network_service.disconnect_by_func(self.on_network_ready) + except Exception as e: + logger.warning(f"Failed to disconnect network signal: {e}") + + if hasattr(self, "brightness_service") and self.brightness_service: + try: + self.brightness_service.disconnect_by_func(self.brightness_changed) + except Exception as e: + logger.warning(f"Failed to disconnect brightness signal: {e}") + + if hasattr(self, "audio_service") and audio_service: + try: + audio_service.disconnect_by_func(self.audio_changed) + audio_service.disconnect_by_func(self.volume_changed) + except Exception as e: + logger.warning(f"Failed to disconnect audio signals: {e}") + + if hasattr(self, "modus_service") and modus_service: + try: + modus_service.disconnect_by_func(self.wlan_changed) + modus_service.disconnect_by_func(self.bluetooth_changed) + modus_service.disconnect_by_func(self.dnd_changed) + except Exception as e: + logger.warning(f"Failed to disconnect modus signals: {e}") + + if hasattr(self, "volume_scale") and self.volume_scale: + try: + self.volume_scale.disconnect_by_func(self.set_volume) + self.volume_scale.disconnect_by_func(self.on_volume_scroll) + except Exception as e: + logger.warning(f"Failed to disconnect volume scale signals: {e}") + + if hasattr(self, "brightness_scale") and self.brightness_scale: + try: + self.brightness_scale.disconnect_by_func(self.set_brightness) + self.brightness_scale.disconnect_by_func(self.on_brightness_scroll) + except Exception as e: + logger.warning( + f"Failed to disconnect brightness scale signals: {e}" + ) + + self._signals_connected = False + + logger.debug("All signals disconnected successfully") + + except Exception as e: + logger.warning(f"Signal disconnection failed: {e}") + + def _cleanup_managers(self): + """Clean up all manager instances""" + try: + if hasattr(self, "wifi_man") and self.wifi_man: + try: + self.wifi_man.destroy() + except Exception as e: + logger.warning(f"Failed to destroy WiFi manager: {e}") + self.wifi_man = None + + if hasattr(self, "bluetooth_man") and self.bluetooth_man: + try: + self.bluetooth_man.destroy() + except Exception as e: + logger.warning(f"Failed to destroy Bluetooth manager: {e}") + self.bluetooth_man = None + + if hasattr(self, "network_service") and self.network_service: + try: + self.network_service.destroy() + except Exception as e: + logger.warning(f"Failed to destroy network service: {e}") + self.network_service = None + + if hasattr(self, "wifi_service") and self.wifi_service: + try: + self.wifi_service.disconnect_by_func(self.update_wifi_icon) + except Exception as e: + logger.warning(f"Failed to disconnect WiFi service: {e}") + self.wifi_service = None + + self._resources_initialized = False + + logger.debug("All managers cleaned up successfully") + + except Exception as e: + logger.warning(f"Manager cleanup failed: {e}") + + def _cleanup_widgets(self): + try: + # Clean up main widgets + if hasattr(self, "widgets") and self.widgets: + try: + self.widgets.destroy() + except Exception as e: + logger.warning(f"Failed to destroy main widgets: {e}") + self.widgets = None + + # Clean up center box + if hasattr(self, "center_box") and self.center_box: + try: + self.center_box.destroy() + except Exception as e: + logger.warning(f"Failed to destroy center box: {e}") + self.center_box = None + + # Clean up individual widgets + widget_attrs = [ + "wlan_widget", + "bluetooth_widget", + "focus_widget", + "flight_widget", + "caffeine_widget", + "night_light_widget", + "volume_scale", + "brightness_scale", + "wlan_label", + "bluetooth_label", + "focus_status_label", + "caffeine_status_label", + "wlan_svg", + "bluetooth_svg", + "focus_icon", + "flight_icon", + "caffeine_icon", + ] + + for attr in widget_attrs: + if hasattr(self, attr) and getattr(self, attr): + try: + widget = getattr(self, attr) + if hasattr(widget, "destroy"): + widget.destroy() + except Exception as e: + logger.warning(f"Failed to destroy {attr}: {e}") + setattr(self, attr, None) + + logger.debug("All widgets cleaned up successfully") + + except Exception as e: + logger.warning(f"Widget cleanup failed: {e}") + + def _cleanup_processes(self): + """Clean up any running processes""" + try: + # Clean up caffeine process + if self._caffeine_process: + try: + self._caffeine_process.terminate() + self._caffeine_process.wait(timeout=1) + except Exception as e: + logger.warning(f"Failed to terminate caffeine process: {e}") + finally: + self._caffeine_process = None + + logger.debug("All processes cleaned up successfully") + + except Exception as e: + logger.warning(f"Process cleanup failed: {e}") + + def _complete_cleanup(self): + """Perform complete cleanup of all resources""" + try: + logger.debug("Starting complete cleanup...") + self._disconnect_all_signals() + self._cleanup_managers() + self._cleanup_widgets() + self._cleanup_processes() + self._disconnect_signals_when_hidden() + + # Force garbage collection + import gc + + gc.collect() + + logger.debug("Complete cleanup finished successfully") + + except Exception as e: + logger.error(f"Complete cleanup failed: {e}") + + def hide_controlcenter(self, *_): + try: + # Just disconnect signals when hiding, don't destroy widgets + self._disconnect_signals_when_hidden() + + # Hide the control center + if hasattr(self, "_mousecapture_parent"): + self._mousecapture_parent.toggle_mousecapture() + self.set_visible(False) + + except Exception as e: + logger.error(f"Failed to hide control center: {e}") + # Still try to hide even if cleanup fails + self.set_visible(False) + + def destroy(self): + try: + self._complete_cleanup() + super().destroy() + logger.debug("Control center destroyed successfully") + except Exception as e: + logger.error(f"Failed to destroy control center: {e}") + try: + super().destroy() + except Exception: + pass diff --git a/src/window/controlcenter/nightlight.py b/src/window/controlcenter/nightlight.py new file mode 100644 index 0000000..d1ab0d4 --- /dev/null +++ b/src/window/controlcenter/nightlight.py @@ -0,0 +1,138 @@ +import subprocess + +from fabric.utils import logger +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.label import Label + +from utils.utils import svg_file + + +class NightLightControl: + """Control night light using hyprsunset""" + + def __init__(self): + self.is_active = False + self._check_initial_state() + + def _check_initial_state(self): + """Check if hyprsunset is currently running""" + try: + result = subprocess.run( + ["pgrep", "-f", "hyprsunset"], capture_output=True, text=True + ) + self.is_active = bool(result.stdout.strip()) + except Exception as e: + logger.warning(f"Failed to check hyprsunset status: {e}") + self.is_active = False + + def toggle(self): + """Toggle night light on/off""" + try: + if self.is_active: + # Turn off night light by killing hyprsunset + subprocess.run(["pkill", "hyprsunset"], check=False) + self.is_active = False + logger.debug("Night light turned off") + else: + # Turn on night light with default temperature (3000K) + subprocess.Popen(["hyprsunset", "-t", "4500"], start_new_session=True) + self.is_active = True + logger.debug("Night light turned on") + return True + except Exception as e: + logger.warning(f"Failed to toggle night light: {e}") + return False + + def set_temperature(self, temperature: int): + """Set night light temperature (1000-6500K)""" + try: + # Kill existing process + subprocess.run(["pkill", "hyprsunset"], check=False) + + # Start with new temperature + subprocess.Popen( + ["hyprsunset", "-t", str(temperature)], start_new_session=True + ) + self.is_active = True + logger.debug(f"Night light temperature set to {temperature}K") + return True + except Exception as e: + logger.warning(f"Failed to set night light temperature: {e}") + return False + + +def create_night_light_widget(control_center): + """Create night light widget for control center""" + + # Initialize night light control + night_light = NightLightControl() + + # Create icon + night_light_icon = svg_file( + ( + "applets/redshift-status-on.svg" + if night_light.is_active + else "applets/redshift-status-off.svg" + ), + size=42, + ) + + # Create status label + night_light_status_label = Label( + label="On" if night_light.is_active else "Off", + name="nightlight-widget-label", + style_classes="status-label", + max_chars_width=15, + ellipsization="end", + h_align="start", + ) + + def toggle_night_light(*_): + """Toggle night light and update UI""" + if night_light.toggle(): + # Update icon + night_light_icon.dynamic_file( + "applets/redshift-status-on.svg" + if night_light.is_active + else "applets/redshift-status-off.svg" + ) + # Update status label + night_light_status_label.set_label("On" if night_light.is_active else "Off") + + # Create widget box (similar to bluetooth_widget structure) + night_light_widget = Box( + name="nightlight-widget", + orientation="h", + h_expand=True, + children=[ + Button( + name="nightlight-icon-button", + child=night_light_icon, + on_clicked=toggle_night_light, + ), + Button( + name="nightlight-info-button", + child=Box( + name="nightlight-widget-info", + h_expand=True, + v_expand=True, + v_align="center", + h_align="start", + orientation="vertical", + children=[ + Label( + name="nightlight-widget-name", + label="Night Light", + style_classes="ct", + h_align="start", + ), + night_light_status_label, + ], + ), + on_clicked=toggle_night_light, + ), + ], + ) + + return night_light_widget diff --git a/src/window/controlcenter/per_app_volume.py b/src/window/controlcenter/per_app_volume.py new file mode 100644 index 0000000..bf7223b --- /dev/null +++ b/src/window/controlcenter/per_app_volume.py @@ -0,0 +1,332 @@ +from fabric.utils import GLib +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.image import Image +from fabric.widgets.label import Label +from fabric.widgets.scale import Scale +from fabric.widgets.scrolledwindow import ScrolledWindow +from fabric.widgets.separator import Separator + +# Local imports +from utils.roam import audio_service + + +class PerAppVolumeControl(Box): + """Per-application volume control widget""" + + def __init__(self, control_center, **kwargs): + super().__init__( + orientation="vertical", + name="per-app-volume-control", + spacing=5, + **kwargs, + ) + + self.control_center = control_center + self._updating_volumes = set() + self._app_widgets = {} + self._signal_connections = [] + + # Header with back button + self.header = Box( + orientation="horizontal", + name="per-app-volume-header", + style_classes="menu-header", + children=[ + Button( + image=Image(icon_name="back", size=10), + on_clicked=lambda *_: self.control_center.close_per_app_volume(), + ), + Label("App Volume", name="app-volume-header"), + ], + ) + + # Scrollable container for app volume controls + self.apps_container = Box( + orientation="vertical", + name="apps-scrolled-container", + spacing=2, + ) + + self.scrolled_window = ScrolledWindow( + name="apps-scrolled-window", + child=self.apps_container, + size=(300, 500), + ) + + # Add escape key binding for navigation back + try: + if hasattr(self.control_center, "add_keybinding"): + self.control_center.add_keybinding("Escape", self._go_back) + except Exception: + pass # Ignore if keybinding fails + + self.children = [self.header, self.scrolled_window] + + # Connect to audio service changes + if audio_service: + self._signal_connections.append( + audio_service.connect("stream-added", self._on_stream_changed) + ) + self._signal_connections.append( + audio_service.connect("stream-removed", self._on_stream_changed) + ) + + # Initial population + self._populate_apps() + + # Set up auto-refresh timer for audio streams + self._refresh_timer = GLib.timeout_add_seconds(2, self._auto_refresh) + + def _auto_refresh(self): + """Auto-refresh the application list every 2 seconds""" + self._populate_apps() + return True # Continue the timer + + def _go_back(self, *_): + """Return to main control center view""" + self.control_center.close_per_app_volume() + + def _get_app_icon(self, app): + """Get icon for application""" + # Don't use fabric's generic audio icon, use description/name for better detection + fabric_icon = getattr(app, "icon_name", "") + if fabric_icon and fabric_icon != "audio": + return fabric_icon + + # Use description first (more accurate), then name + app_description = getattr(app, "description", "").lower() + app_name = app.name.lower() + + # Check description first as it's more reliable + search_text = app_description if app_description else app_name + + icon_mapping = { + "spotify": "spotify", + "firefox": "firefox", + "chromium": "chromium-browser", + "chrome": "google-chrome", + "vlc": "vlc", + "discord": "discord", + "steam": "steam", + "zen": "zen-browser", + "code": "vscode", + "visual studio code": "vscode", + "telegram": "telegram-desktop", + "pulse": "audio-card", + "pipewire": "audio-card", + "alsa": "audio-card", + "sink": "audio-speakers", + "source": "audio-input-microphone", + "youtube": "youtube", + "music": "rhythmbox", + "media": "multimedia-player", + } + + # Check if any key in mapping is contained in search text + for key, icon in icon_mapping.items(): + if key in search_text: + return icon + + # Also check app name as fallback + if search_text != app_name: + for key, icon in icon_mapping.items(): + if key in app_name: + return icon + + # Default audio icon for unknown apps + return "audio-volume-high" + + def _format_app_name(self, name): + """Format application name with proper capitalization""" + if not name: + return "Unknown" + + # Handle common app names specially + special_names = { + "spotify": "Spotify", + "firefox": "Firefox", + "chromium": "Chromium", + "chrome": "Chrome", + "vlc": "VLC", + "discord": "Discord", + "steam": "Steam", + "zen": "Zen Browser", + "code": "VS Code", + "telegram": "Telegram", + } + + name_lower = name.lower() + if name_lower in special_names: + return special_names[name_lower] + + # Default: capitalize first letter + return name.capitalize() + + def _populate_apps(self): + """Populate the widget with current audio applications""" + # Clear existing widgets + self.apps_container.children = [] + self._app_widgets.clear() + + # Use fabric audio service for applications + if not audio_service: + self._show_no_apps_message() + return + + applications = getattr(audio_service, "applications", []) + + if applications: + for i, app in enumerate(applications): + if hasattr(app, "name") and hasattr(app, "volume"): + app_widget = self._create_app_control(app) + self.apps_container.children = list( + self.apps_container.children + ) + [app_widget] + + # Add separator between apps (except for last one) + if i < len(applications) - 1: + separator = Separator( + orientation="horizontal", + style_classes="app-volume-separator", + ) + self.apps_container.children = list( + self.apps_container.children + ) + [separator] + + self._app_widgets[app.name] = (app_widget, app) + else: + self._show_no_apps_message() + + def _show_no_apps_message(self): + """Show message when no apps are playing audio""" + message = Label( + label="No applications currently using audio", + style_classes="subtitle", + h_align="center", + v_align="center", + ) + self.apps_container.children = [message] + + def _create_app_control(self, app): + """Create compact volume control for a single application""" + # Format app name (shorter for compact layout) + app_name = self._format_app_name(app.name) + # Get current volume from fabric audio service + current_volume = getattr(app, "volume", 0.0) + max_vol = getattr(audio_service, "max_volume", 100) if audio_service else 100 + + # Ensure volume is in valid range + volume_percent = max(0, min(current_volume, max_vol)) + + # App icon - use the same approach as expanded player + icon_name = self._get_app_icon(app) + app_icon = Image( + icon_name=icon_name, + name="app-icon", + icon_size=16, + style_classes="app-icon", + ) + + # App name label + name_label = Label( + label=app_name, + style_classes="app-name-compact", + justification="left", + h_align="start", + max_chars_width=10, + ellipsization="end", + ) + + # Volume scale (smaller and horizontal) + volume_scale = Scale( + value=volume_percent, + min_value=0, + max_value=max_vol, + increments=(1, 5), + name="compact-volume-slider", + size=20, + h_expand=True, + ) + + # Connect volume change handler + volume_scale.connect( + "change-value", + lambda scale, scroll_type, value, app=app: self._set_app_volume(app, value), + ) + + # Create horizontal compact layout + app_control = Box( + orientation="horizontal", + spacing=5, + h_expand=True, + style_classes="compact-app-volume-item", + children=[ + Box( + orientation="h", + spacing=3, + v_expand=True, + v_align="center", + name="app-control-box", + children=[ + app_icon, + name_label, + ], + ), + volume_scale, + ], + ) + # Add separator after the control + + return app_control + + def _set_app_volume(self, app, volume_value): + """Set volume for a specific application using fabric audio service""" + if app.name in self._updating_volumes: + return + + self._updating_volumes.add(app.name) + + try: + # Get max volume from audio service + max_vol = ( + getattr(audio_service, "max_volume", 100) if audio_service else 100 + ) + + # Ensure volume is within bounds + volume_value = max(0, min(volume_value, max_vol)) + + # Set volume directly - fabric expects the actual volume value, not percentage + app.volume = volume_value + + except Exception as e: + print(f"Error setting volume for {app.name}: {e}") + finally: + GLib.timeout_add(100, lambda: self._updating_volumes.discard(app.name)) + + def _on_stream_changed(self, *_): + """Handle when audio streams are added or removed""" + GLib.idle_add(self._populate_apps) + + def refresh(self): + """Manually refresh the application list""" + self._populate_apps() + + def destroy(self): + """Clean up resources""" + if hasattr(self, "_refresh_timer"): + GLib.source_remove(self._refresh_timer) + + # Disconnect fabric audio service signals + if audio_service: + for connection in self._signal_connections: + try: + audio_service.disconnect(connection) + except Exception: + pass + + self._signal_connections.clear() + self._app_widgets.clear() + self._updating_volumes.clear() + + super().destroy() diff --git a/src/window/controlcenter/player.py b/src/window/controlcenter/player.py new file mode 100644 index 0000000..0943b7b --- /dev/null +++ b/src/window/controlcenter/player.py @@ -0,0 +1,1037 @@ +import re +import time +from typing import List + +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.centerbox import CenterBox +from fabric.widgets.image import Image +from fabric.widgets.label import Label +from fabric.widgets.overlay import Overlay +from fabric.widgets.stack import Stack + +from fabric.utils import GLib, GObject, Gio, logger, bulk_connect, get_relative_path, os + +import shared.data as data +from services.mpris import MprisPlayer, MprisPlayerManager +from utils.utils import svg_file + +CACHE_DIR = f"{data.CACHE_DIR}/media" +MEDIA_CACHE = CACHE_DIR +if not os.path.exists(CACHE_DIR): + os.makedirs(CACHE_DIR) +if not os.path.exists(MEDIA_CACHE): + os.makedirs(MEDIA_CACHE) + +# Global shared MPRIS manager +_shared_mpris_manager = None + + +def get_shared_mpris_manager(): + """Get shared MPRIS manager instance to reduce memory usage.""" + global _shared_mpris_manager + if _shared_mpris_manager is None: + # Create MPRIS manager immediately but don't wait for DBus discovery + _shared_mpris_manager = MprisPlayerManager() + return _shared_mpris_manager + + +def cleanup_shared_mpris_manager(): + """Clean up the shared MPRIS manager to free memory.""" + global _shared_mpris_manager + if _shared_mpris_manager is not None: + try: + _shared_mpris_manager.destroy() + except Exception as e: + logger.warning(f"Failed to destroy shared MPRIS manager: {e}") + _shared_mpris_manager = None + + +def cleanup_old_cache_files(): + """Clean up old artwork cache files (older than 1 day) and limit total cache size.""" + try: + if not os.path.exists(CACHE_DIR): + return + + current_time = time.time() + one_day_ago = current_time - (24 * 60 * 60) # 24 hours + cache_files = [] + + # Collect all cache files with their modification times + for filename in os.listdir(CACHE_DIR): + filepath = os.path.join(CACHE_DIR, filename) + try: + if os.path.isfile(filepath): + file_mtime = os.path.getmtime(filepath) + file_size = os.path.getsize(filepath) + cache_files.append((filepath, file_mtime, file_size)) + except Exception: + pass # Ignore individual file errors + + # Remove files older than 1 day + total_size = 0 + recent_files = [] + for filepath, file_mtime, file_size in cache_files: + if file_mtime < one_day_ago: + try: + os.unlink(filepath) + except Exception: + pass + else: + recent_files.append((filepath, file_mtime, file_size)) + total_size += file_size + + # If cache is still too large (>50MB), remove oldest files + MAX_CACHE_SIZE = 50 * 1024 * 1024 # 50MB + if total_size > MAX_CACHE_SIZE: + # Sort by modification time (oldest first) + recent_files.sort(key=lambda x: x[1]) + for filepath, _, file_size in recent_files: + if total_size <= MAX_CACHE_SIZE: + break + try: + os.unlink(filepath) + total_size -= file_size + except Exception: + pass + + except Exception: + pass # Ignore all errors in cleanup + + +class PlayerBoxStack(Box): + """A widget that displays the current player information.""" + + def __init__( + self, mpris_manager: MprisPlayerManager = None, control_center=None, **kwargs + ): + # Defer cache cleanup to avoid blocking startup + GLib.idle_add(cleanup_old_cache_files) + + # The player stack + self.player_stack = Stack( + # transition_type="slide-left-right", + # transition_duration=500, + name="player-stack", + ) + self.current_stack_pos = 0 + self.control_center = control_center + + # List to store player buttons + self.player_buttons: list[Button] = [] + + # Track signal connections for cleanup + self._signal_connections = [] + + # Create a "No media playing" placeholder + self.no_media_box = self._create_no_media_box() + + super().__init__(orientation="v", name="media", children=[self.player_stack]) + + # Show the no media box initially + self.player_stack.children = [self.no_media_box] + self.set_visible(True) + + # Use shared MPRIS manager if none provided + self.mpris_manager = mpris_manager + + # Initialize MPRIS manager if provided, otherwise defer + if self.mpris_manager is not None: + self._init_mpris_manager() + else: + # Will be initialized later via _init_mpris_manager() + pass + + # Connect to visibility changes for cleanup + self.connect("notify::visible", self._on_visibility_changed) + + def _on_visibility_changed(self, widget, param): + """Monitor visibility changes to debug hiding issues.""" + is_visible = self.get_visible() + logger.debug(f"[PlayerBoxStack] Visibility changed to: {is_visible}") + + if not is_visible: + # Check if we should be visible + current_children = self.player_stack.get_children() + if len(current_children) > 0: + logger.warning( + f"[PlayerBoxStack] Widget was hidden but has {len(current_children)} children - forcing visibility" + ) + GLib.idle_add(self.set_visible, True) + + def set_visible(self, visible: bool) -> None: + """Override set_visible to ensure the widget is never hidden when it should show content.""" + # Always ensure the widget is visible if we have content to show + if not visible: + current_children = self.player_stack.get_children() + if len(current_children) > 0: + # Don't allow hiding if we have content + logger.debug( + "[PlayerBoxStack] Preventing widget from being hidden - has content" + ) + return + + super().set_visible(visible) + + def get_visible(self) -> bool: + """Override get_visible to ensure the widget always reports as visible when it has content.""" + # Check if we have content to show + current_children = self.player_stack.get_children() + if len(current_children) > 0: + # If we have content, we should always be visible + return True + + # Otherwise, use the parent's visibility state + return super().get_visible() + + def _init_mpris_manager(self): + """Initialize MPRIS manager and connect signals""" + if self.mpris_manager is None: + return + + try: + # Track connections for cleanup - store (object, handler_id) tuples + connections = bulk_connect( + self.mpris_manager, + { + "player-appeared": self.on_new_player, + "player-vanished": self.on_lost_player, + }, + ) + # Store as (object, handler_id) tuples + for handler_id in connections: + self._signal_connections.append((self.mpris_manager, handler_id)) + + # Process existing players asynchronously to avoid blocking + def process_existing_players(): + try: + for player in self.mpris_manager.players.values(): # type: ignore + self.on_new_player(self.mpris_manager, player) + logger.debug("PlayerBoxStack initialized successfully") + except Exception as e: + logger.error(f"Failed to process existing players: {e}") + + # Use idle_add to process players asynchronously + GLib.idle_add(process_existing_players) + + # Also check the playing state after a short delay to ensure consistency + GLib.timeout_add(1000, self._check_and_update_playing_state) + + # Ensure the widget is always visible + GLib.timeout_add(500, self._ensure_visibility) + + except Exception as e: + logger.error(f"Failed to initialize PlayerBoxStack signals: {e}") + + def destroy(self): + """Clean up resources when the widget is destroyed.""" + # Disconnect all signal connections + for obj, handler_id in self._signal_connections: + try: + obj.disconnect(handler_id) + except Exception as e: + logger.warning(f"Failed to disconnect signal: {e}") + self._signal_connections.clear() + + # Clean up player buttons + for button in self.player_buttons: + try: + button.destroy() + except Exception: + pass + self.player_buttons.clear() + + # Clean up player boxes + for child in self.player_stack.get_children(): + if hasattr(child, "destroy") and child != self.no_media_box: + try: + child.destroy() + except Exception: + pass + + # Clean up shared MPRIS manager if this is the last instance + if ( + hasattr(self, "mpris_manager") + and self.mpris_manager == _shared_mpris_manager + ): + # Only cleanup if no other widgets are using it + cleanup_shared_mpris_manager() + + super().destroy() + + def _periodic_cleanup(self): + """Enhanced cleanup for reuse - clean internal state and free memory""" + try: + # Destroy all player boxes properly to free their resources + current_children = list(self.player_stack.get_children()) + for child in current_children: + if hasattr(child, "destroy") and child != self.no_media_box: + try: + child.destroy() + except Exception as e: + logger.warning(f"Failed to destroy player child: {e}") + + # Reset to no media state + self.player_stack.children = [self.no_media_box] + + # Clear player buttons + for button in self.player_buttons: + try: + button.destroy() + except Exception: + pass + self.player_buttons.clear() + + # Reset stack position + self.current_stack_pos = 0 + + # Clean up old cache files more aggressively + cleanup_old_cache_files() + + except Exception as e: + logger.warning(f"PlayerBoxStack enhanced cleanup failed: {e}") + + def _create_no_media_box(self): + """Create a placeholder box for when no media is playing.""" + fallback_cover_path = get_relative_path("../../assets/icons/music.svg") + + # Album cover with fallback image + album_cover = Box(style_classes="album-image-c") + album_cover.set_style(f"background-image:url('{fallback_cover_path}')") + + image_stack = Box(h_align="start", v_align="center", name="player-image-stack") + image_stack.children = [album_cover] + + # Track info showing "No media playing" + track_title = Label( + label="No media playing", + name="player-title-c", + justification="left", + max_chars_width=25, + ellipsization="end", + h_align="start", + ) + + track_artist = Label( + label="", + name="player-artist-c", + justification="left", + max_chars_width=15, + ellipsization="end", + h_align="start", + visible=False, # Hide artist and album when no media + ) + + track_info = Box( + name="track-info", + # spacing=5, + h_expand=True, + orientation="v", + v_align="start", + h_align="start", + children=[track_title, track_artist], + ) + + # No control buttons for no media state - just an empty box + controls_box = Box( + name="player-controls", + visible=False, # Hide controls when no media + ) + + player_info_box = Box( + name="player-info-box-c", + h_expand=True, + v_align="center", + h_align="center", + orientation="v", + children=[track_info, controls_box], + ) + + inner_box = CenterBox( + name="inner-player-box", + start_children=[ + image_stack, + ], + center_children=[ + player_info_box, + ], + ) + # resize the inner box + outer_box = Box( + spacing=5, + name="outer-no-player-box-c", + h_expand=True, + h_align="fill", + # children=[ + v_expand=True, + children=inner_box, + # inner_box, + # player_info_box, + # image, + # ], + ) + + box = Box( + name="box-c", + orientation="h", + v_expand=True, + h_align="fill", + h_expand=True, + children=[ + outer_box, + ], + ) + no_media_box = Box( + h_align="center", + name="player-box", + h_expand=True, + children=[box], + ) + + return no_media_box + + def _find_playing_player_index(self): + """Find the index of the currently playing player.""" + players: List[PlayerBox] = self.player_stack.get_children() + for i, player_box in enumerate(players): + if ( + hasattr(player_box, "player") + and str(player_box.player.playback_status).lower() == "playing" + ): + return i + return None + + def _switch_to_playing_player(self): + """Switch to the currently playing player if one exists.""" + playing_index = self._find_playing_player_index() + if playing_index is not None and playing_index != self.current_stack_pos: + self.on_player_clicked_by_index(playing_index) + + def _check_and_update_playing_state(self): + """Check if any players are playing and update the display accordingly.""" + players: List[PlayerBox] = self.player_stack.get_children() + has_playing_player = False + + # Check if any player is currently playing + for player_box in players: + if ( + hasattr(player_box, "player") + and str(player_box.player.playback_status).lower() == "playing" + ): + has_playing_player = True + break + + # If no players are playing, show the no media box + if not has_playing_player: + # Check if we already have the no media box visible + current_children = self.player_stack.get_children() + if len(current_children) == 1 and current_children[0] == self.no_media_box: + # Already showing no media, no need to change + return + + # Hide all player boxes and show no media + for player_box in players: + if hasattr(player_box, "destroy"): + try: + player_box.destroy() + except Exception as e: + logger.warning(f"Failed to destroy player box: {e}") + + # Clear player buttons + self.player_buttons.clear() + + # Show the no media box + self.player_stack.children = [self.no_media_box] + self.current_stack_pos = 0 + + # Ensure the widget is visible + self.set_visible(True) + self.player_stack.set_visible_child(self.no_media_box) + + logger.debug( + "[PlayerBoxStack] No players playing, showing 'No media playing'" + ) + + def _ensure_visibility(self): + """Ensure the widget is always visible and showing appropriate content.""" + try: + # Always ensure the widget is visible + self.set_visible(True) + + # Check current state + current_children = self.player_stack.get_children() + + # If no children, show no media box + if len(current_children) == 0: + self.player_stack.children = [self.no_media_box] + self.current_stack_pos = 0 + logger.debug("[PlayerBoxStack] No children found, showing no media box") + + # If only no_media_box is visible, ensure it's the active child + elif ( + len(current_children) == 1 and current_children[0] == self.no_media_box + ): + self.player_stack.set_visible_child(self.no_media_box) + logger.debug("[PlayerBoxStack] Ensuring no media box is visible") + + # Force the widget to be visible and show content + self.set_visible(True) + self.player_stack.set_visible(True) + + # If we have the no media box, make sure it's visible + if self.no_media_box in current_children: + self.no_media_box.set_visible(True) + + logger.debug( + f"[PlayerBoxStack] Current state: {len(current_children)} children, visible: {self.get_visible()}, stack_visible: {self.player_stack.get_visible()}" + ) + + except Exception as e: + logger.error(f"[PlayerBoxStack] Error in _ensure_visibility: {e}") + + def on_player_playback_changed(self, player_box, status): + """Called when a player's playback status changes.""" + status = str(status).lower() if isinstance(status, str) else status + if status == "playing": + # Find this player's index and switch to it + players: List[PlayerBox] = self.player_stack.get_children() + for i, pb in enumerate(players): + if pb == player_box: + if i != self.current_stack_pos: + self.on_player_clicked_by_index(i) + break + elif status in ["paused", "stopped"]: + # Check if any other players are playing + self._check_and_update_playing_state() + + def on_player_clicked(self, type): + # unset active from prev active button + if self.player_buttons and self.current_stack_pos < len(self.player_buttons): + self.player_buttons[self.current_stack_pos].remove_style_class("active") + + if type == "next": + self.current_stack_pos = ( + self.current_stack_pos + 1 + if self.current_stack_pos != len(self.player_stack.get_children()) - 1 + else 0 + ) + elif type == "prev": + self.current_stack_pos = ( + self.current_stack_pos - 1 + if self.current_stack_pos != 0 + else len(self.player_stack.get_children()) - 1 + ) + + # set new active button + if self.player_buttons and self.current_stack_pos < len(self.player_buttons): + self.player_buttons[self.current_stack_pos].add_style_class("active") + self.player_stack.set_visible_child( + self.player_stack.get_children()[self.current_stack_pos], + ) + + def on_player_clicked_by_index(self, index): + """Switch to player at given index""" + if 0 <= index < len(self.player_buttons): + # unset active from prev active button + if self.player_buttons and self.current_stack_pos < len( + self.player_buttons + ): + self.player_buttons[self.current_stack_pos].remove_style_class("active") + # set new position + self.current_stack_pos = index + # set new active button + if self.player_buttons and self.current_stack_pos < len( + self.player_buttons + ): + self.player_buttons[self.current_stack_pos].add_style_class("active") + self.player_stack.set_visible_child( + self.player_stack.get_children()[self.current_stack_pos], + ) + # Update all player boxes with new button state + self._update_all_player_buttons() + + def on_new_player(self, mpris_manager, player): + + # if player_name in self.config.get("ignore", []): + # return + + # Show all players, but we'll handle their playback status in the UI + # if player.playback_status != "playing": + # logger.debug(f"[PlayerBoxStack] Player {player_name} is not playing (status: {player.playback_status}), skipping display") + # return + + # Remove the no media box if it's the only child + if ( + len(self.player_stack.get_children()) == 1 + and self.player_stack.get_children()[0] == self.no_media_box + ): + self.player_stack.children = [] + self.current_stack_pos = 0 + + self.set_visible(True) + + new_player_box = PlayerBox( + player=player, + player_stack=self, + control_center=self.control_center, + ) + self.player_stack.children = [ + *self.player_stack.children, + new_player_box, + ] + + self.make_new_player_button(self.player_stack.get_children()[-1]) + if self.player_buttons and self.current_stack_pos < len(self.player_buttons): + self.player_buttons[self.current_stack_pos].set_style_classes(["active"]) + + # Update all player boxes with current button state + self._update_all_player_buttons() + + # Check if this new player is playing and switch to it + self._switch_to_playing_player() + + def on_lost_player(self, mpris_manager, bus_name): + # the playerBox is automatically removed from mprisbox children on being removed + players: List[PlayerBox] = self.player_stack.get_children() + + # Find and properly destroy the player box + player_box_to_remove = None + for player_box in players: + if ( + hasattr(player_box, "player") + and getattr(player_box.player, "bus_name", None) == bus_name + ): + player_box_to_remove = player_box + break + + if player_box_to_remove: + try: + player_box_to_remove.destroy() + except Exception as e: + logger.warning(f"Failed to destroy player box: {e}") + + # Check if this was the last player + remaining_players = [ + p for p in self.player_stack.get_children() if p != player_box_to_remove + ] + if len(remaining_players) == 0: + # Show the no media box instead of hiding + self.player_stack.children = [self.no_media_box] + self.current_stack_pos = 0 + self.player_buttons = [] # Clear player buttons + return + + # Adjust current position if needed + if self.current_stack_pos >= len(self.player_stack.get_children()): + self.current_stack_pos = max(0, len(self.player_stack.get_children()) - 1) + + # Set active button if we have buttons and a valid position + if self.player_buttons and self.current_stack_pos < len(self.player_buttons): + self.player_buttons[self.current_stack_pos].set_style_classes(["active"]) + if self.player_stack.get_children(): + self.player_stack.set_visible_child( + self.player_stack.get_children()[self.current_stack_pos], + ) + + # Update all player boxes with current button state + self._update_all_player_buttons() + + # After a player is removed, check if we should switch to a playing player + self._switch_to_playing_player() + + def make_new_player_button(self, player_box): + new_button = Button(name="player-stack-button") + + def on_player_button_click(button: Button): + if self.player_buttons and self.current_stack_pos < len( + self.player_buttons + ): + self.player_buttons[self.current_stack_pos].remove_style_class("active") + if button in self.player_buttons: + self.current_stack_pos = self.player_buttons.index(button) + button.add_style_class("active") + self.player_stack.set_visible_child(player_box) + + new_button.connect( + "clicked", + on_player_button_click, + ) + self.player_buttons.append(new_button) + + # This will automatically destroy our used button + def cleanup_button(*_): + try: + if new_button in self.player_buttons: + self.player_buttons.remove(new_button) + new_button.destroy() + except Exception as e: + logger.warning(f"Failed to cleanup button: {e}") + + player_box.connect("destroy", cleanup_button) + + def _update_all_player_buttons(self): + """Update all player boxes with the current button state""" + players: List[PlayerBox] = self.player_stack.get_children() + + for player_box in players: + if hasattr(player_box, "update_buttons"): + player_box.update_buttons(self.player_buttons, len(players) > 1) + else: + logger.warning( + "[PlayerBoxStack] PlayerBox missing update_buttons method" + ) + + +class PlayerBox(Box): + """A widget that displays the current player information.""" + + def __init__( + self, player: MprisPlayer, player_stack=None, control_center=None, **kwargs + ): + super().__init__( + h_align="center", + name="player-box", + **kwargs, + h_expand=True, + ) + # Setup + self.player: MprisPlayer = player + self.player_stack = player_stack + self.control_center = control_center + self.cover_path = get_relative_path("../../assets/icons/music.svg") + + # Add controls_box attribute early for compatibility + # Temporary placeholder + self.controls_box = Box(name="temp-controls-box") + + self.image_size = 50 + self.icon_size = 15 + + # State + self.exit = False + self.skipped = False + self._signal_connections = [] # Track signal connections + + # Memory management + self.temp_artwork_files = [] # Track temp files for cleanup + self._download_cancelled = False # Flag to cancel downloads + + # Album art with existing Box widget + self.album_cover = Box(style_classes="album-image-c") + self.album_cover.set_style(f"background-image:url('{self.cover_path}')") + + self.image_stack = Box( + h_align="start", + v_align="center", + name="player-image-stack", + ) + self.image_stack.children = [self.album_cover] + + # Connect to arturl changes + self.player.connect("notify::arturl", self.set_image) + + self.app_icon = Box( + children=Image( + icon_name=self.player.player_name, name="player-app-icon", icon_size=20 + ), + h_align="end", + v_align="end", + tooltip_text=self.player.player_name, # type: ignore + ) + self.image = Overlay( + child=self.image_stack, + overlays=[ + self.app_icon, + ], + ) + + # Track Info + + self.track_title = Label( + label="No Title", + name="player-title-c", + justification="left", + max_chars_width=25, + ellipsization="end", + h_align="start", + ) + + self.track_artist = Label( + label="No Artist", + name="player-artist-c", + justification="left", + max_chars_width=23, + ellipsization="end", + h_align="start", + visible=True, + ) + + self.player.bind_property( + "title", + self.track_title, + "label", + GObject.BindingFlags.DEFAULT, + lambda _, x: ( + re.sub(r"\r?\n", " ", x) if x != "" and x is not None else "No Title" + ), # type: ignore + ) + self.player.bind_property( + "artist", + self.track_artist, + "label", + GObject.BindingFlags.DEFAULT, + lambda _, x: ", ".join(x) if x and isinstance(x, list) else "No Artist", # type: ignore + ) + + self.track_info = Box( + name="track-info", + spacing=5, + orientation="v", + v_align="start", + h_align="start", + children=[ + self.track_title, + self.track_artist, + ], + ) + + # Buttons with fixed sizing for layout stability + self.button_box = Box( + name="button-box-c", + h_expand=False, + spacing=2, + ) + + # Create SVG icons with consistent sizing + self.skip_next_icon = svg_file("player/fwd.svg", size=22) + self.play_pause_icon = svg_file("player/Pause.svg", size=22) + + # Fixed size buttons to prevent layout shifts + self.play_pause_button = Button( + name="player-button", + child=self.play_pause_icon, + on_clicked=self.player.play_pause, + ) + # Set consistent button size + + self.player.bind_property("can_pause", self.play_pause_button, "sensitive") + + self.next_button = Button( + name="player-button", + child=self.skip_next_icon, + on_clicked=self._on_player_next, + ) + # Set consistent button size + # self.next_button.set_size_request(32, 32) + self.player.bind_property("can_go_next", self.next_button, "sensitive") + + self.button_box.children = ( + self.play_pause_button, + self.next_button, + ) + + # Assign button_box to controls_box for compatibility + self.controls_box = self.button_box + + self.player_info_box = Box( + name="player-info-box-c", + v_align="center", + h_expand=True, + h_align="start", + orientation="v", + children=[ + self.track_info, + ], + ) + + self.inner_box = Box( + name="inner-player-box", + h_expand=True, + v_align="center", + h_align="start", + children=[ + self.image, + self.player_info_box, + ], + ) + # resize the inner box + self.outer_box = Button( + spacing=5, + name="outer-player-box-c", + h_expand=True, + on_clicked=self._on_outer_box_clicked, + h_align="start", + child=self.inner_box, + ) + + self.box = CenterBox( + name="box-c", + orientation="h", + h_align="center", + start_children=[ + self.outer_box, + ], + end_children=[ + self.button_box, + ], + ) + self.box.set_size_request(352, -1) + + self.children = [ + *self.children, + self.box, + ] + + # Track signal connections for cleanup - store (object, handler_id) tuples + connections = bulk_connect( + self.player, + { + "closed": self._on_player_exit, + "notify::playback-status": self._on_playback_change, + "notify::metadata": self._on_metadata, + }, + ) + # Store as (object, handler_id) tuples + for handler_id in connections: + self._signal_connections.append((self.player, handler_id)) + + def destroy(self): + """Clean up all resources when the widget is destroyed.""" + # Cancel any ongoing downloads + self._download_cancelled = True + + # Disconnect all signal connections + for obj, handler_id in self._signal_connections: + try: + obj.disconnect(handler_id) + except Exception as e: + logger.warning(f"Failed to disconnect signal: {e}") + self._signal_connections.clear() + + # Clean up temp files + self._cleanup_temp_files() + + super().destroy() + + def __del__(self): + """Ensure cleanup happens even if player exits unexpectedly.""" + try: + self._cleanup_temp_files() + except Exception: + pass # Ignore errors during cleanup in destructor + + def _on_prev_button_click(self, *_): + """Handle prev button click: open expanded player in control center""" + try: + # Open expanded player in control center instead of new window + if self.control_center and hasattr( + self.control_center, "open_expanded_player" + ): + self.control_center.open_expanded_player() + except Exception as e: + logger.warning(f"Failed to handle prev button click: {e}") + + def _on_outer_box_clicked(self, *_): + """Handle outer box click with proper error handling.""" + try: + # Open expanded player in control center instead of new window + if self.control_center and hasattr( + self.control_center, "open_expanded_player" + ): + self.control_center.open_expanded_player() + except Exception as e: + logger.warning(f"Failed to handle outer box click: {e}") + + def update_buttons(self, player_buttons, show_buttons): + # """Update the stack switcher buttons in this player box""" + pass + + def _on_metadata(self, *_): + self.set_image() + + def _cleanup_temp_files(self): + """Clean up temporary artwork files.""" + for temp_file in self.temp_artwork_files: + try: + if os.path.exists(temp_file): + os.unlink(temp_file) + except Exception as e: + logger.warning(f"Failed to cleanup temp file {temp_file}: {e}") + self.temp_artwork_files.clear() + + def _on_player_exit(self, _, value): + self.exit = value + self._cleanup_temp_files() # Clean up temp files before destroying + self.destroy() + + def _on_player_next(self, *_): + self.player.next() + + def _on_player_prev(self, *_): + self.player.previous() + + def _on_playback_change(self, player, status): + status = player.get_property("playback-status") + status_l = str(status).lower() if isinstance(status, str) else status + + if status_l == "paused": + self.play_pause_icon.dynamic_file("player/play.svg") + + if status_l == "playing": + self.play_pause_icon.dynamic_file("player/Pause.svg") + + # Always notify the player stack about playback status changes + if self.player_stack and hasattr( + self.player_stack, "on_player_playback_changed" + ): + self.player_stack.on_player_playback_changed(self, status_l) + + def img_callback(self, source: Gio.File, result: Gio.AsyncResult): + try: + if os.path.isfile(self.cover_path): + self.update_image() + except ValueError: + logger.error("[PLAYER] Failed to grab artUrl") + + def update_image(self): + self.album_cover.set_style(f"background-image:url('{self.cover_path}')") + + def set_image(self, *args): + url = self.player.arturl + + if url is None or url == "": + return + + new_cover_path = ( + ( + MEDIA_CACHE + + "/" + # type: ignore + + GLib.compute_checksum_for_string(GLib.ChecksumType.SHA1, url, -1) + ) + if "file://" != url[0:7] + else url[7:] + ) + + if new_cover_path == self.cover_path: + self.update_image() + return + + self.cover_path = new_cover_path + + if os.path.exists(self.cover_path): + self.update_image() + return + + Gio.File.new_for_uri(uri=url).copy_async( + Gio.File.new_for_path(self.cover_path), + Gio.FileCopyFlags.OVERWRITE, + GLib.PRIORITY_DEFAULT, + None, + None, + self.img_callback, + ) + + def close_bluetooth(self, *args): + """Placeholder method for compatibility""" + pass diff --git a/src/window/controlcenter/wifi.py b/src/window/controlcenter/wifi.py new file mode 100644 index 0000000..441d23f --- /dev/null +++ b/src/window/controlcenter/wifi.py @@ -0,0 +1,706 @@ +import subprocess + +from fabric.utils import Gdk, GLib, Gtk +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.centerbox import CenterBox +from fabric.widgets.image import Image +from fabric.widgets.label import Label +from fabric.widgets.revealer import Revealer +from fabric.widgets.scrolledwindow import ScrolledWindow +from fabric.widgets.separator import Separator + +from services.network import NetworkClient +from utils.functions import get_wifi_connecting_icon, get_wifi_icon_for_strength +from utils.utils import svg_file +from shared.dialogs.wifi_password_dialog import WiFiPasswordDialog +from gi.repository import NM + + +class WifiNetworkSlot(Box): + def __init__(self, access_point, wifi_service, parent=None, **kwargs): + super().__init__(name="wifi-network-slot", **kwargs) + self.access_point = access_point + self.wifi_service = wifi_service + self.parent = parent # Reference to control center + + # Get network info from AccessPoint object + self.ssid = access_point.ssid + self.bssid = access_point.bssid + self.strength = access_point.strength + self.icon_name = access_point.icon + + # Check if this network is currently connected + self.is_connected = access_point.is_active + + # Initialize styles based on connection state + self.styles = [ + "connected" if self.is_connected else "", + ] + + # Create connection status indicator using dynamic WiFi icon based on signal strength + wifi_icon_path = get_wifi_icon_for_strength(self.strength) + self.dimage = svg_file(wifi_icon_path, size=28) + self.wifi_icon_box = Box( + children=[self.dimage], + style_classes=["wifi-icon-box"], + ) + # if self.is_connected: + # self.wifi_icon_box.remove_style_class("wifi-icon-box") + # self.wifi_icon_box.add_style_class("wifi-icon-box-connected") + # + # Set initial style classes + if self.is_connected: + self.dimage.add_style_class("connected") + + self.network_label = Label( + label=self.ssid, name="wifi-network-name", h_align="start", h_expand=True + ) + + # Create lock icon for secured networks + self.lock_icon = None + if self.access_point.requires_password: + self.lock_icon = Image( + icon_name="changes-prevent-symbolic", + size=12, + name="wifi-lock-icon", + ) + + # Initialize password dialog + self.password_dialog = None + + # Create the start section with WiFi icon and network name + start_box = Box( + orientation="h", + spacing=8, + ) + start_box.children = [self.wifi_icon_box, self.network_label] + if self.lock_icon: + start_box.children.append(self.lock_icon) + # Create end section with lock icon if needed + + self.children = [ + Button( + child=start_box, + h_expand=True, + name="wifi-network-button", + on_clicked=lambda *_: self.toggle_connecting(), + ) + ] + + # Emit initial change to update display + self.on_changed() + + def toggle_connecting(self): + # Check if this network is currently connected + is_currently_connected = self.access_point.is_active + + if is_currently_connected: + # Show disconnecting state using connecting icon + connecting_icon = get_wifi_connecting_icon() + self.dimage.set_from_file(connecting_icon) + self.dimage.add_style_class("disconnecting") + + # Disconnect from network + self.wifi_service.disconnect_wifi() + self.is_connected = False + # Remove disconnecting state after a short delay to show feedback + GLib.timeout_add(500, lambda: self._reset_disconnect_state()) + else: + # Try to connect - check if password is required + if self.access_point.requires_password: + # Show password dialog immediately + self._show_password_dialog() + else: + # Try to connect without password (for open networks) + connecting_icon = get_wifi_connecting_icon() + self.dimage.set_from_file(connecting_icon) + self.dimage.add_style_class("connecting") + + def on_open_connection_result(success, message): + """Handle the connection result for open networks""" + if success: + self.is_connected = True + # Remove connecting state after a short delay + GLib.timeout_add(500, lambda: self._reset_connect_state()) + else: + # Connection failed + self._reset_connect_state() + + # Update display after connection attempt + self.on_changed() + + try: + self.wifi_service.connect_to_wifi( + self.access_point, callback=on_open_connection_result + ) + except Exception: + # Handle any connection errors gracefully + self._reset_connect_state() + self.on_changed() + + # Update display after connection attempt + self.on_changed() + + def _reset_disconnect_state(self): + """Reset visual state after disconnect operation""" + self.dimage.remove_style_class("disconnecting") + wifi_icon_path = get_wifi_icon_for_strength(self.strength) + self.dimage.set_from_file(wifi_icon_path) + self.on_changed() + return False # Remove timeout + + def _reset_connect_state(self): + """Reset visual state after connect operation""" + self.dimage.remove_style_class("connecting") + wifi_icon_path = get_wifi_icon_for_strength(self.strength) + self.dimage.set_from_file(wifi_icon_path) + self.on_changed() + return False # Remove timeout + + def on_changed(self, *_): + # Check if this network is currently connected using the access point's is_active property + self.is_connected = self.access_point.is_active + self.styles = [ + "connected" if self.is_connected else "", + ] + # Update style classes for SVG widget + if self.is_connected: + self.wifi_icon_box.add_style_class("wifi-icon-box-connected") + else: + self.wifi_icon_box.remove_style_class("wifi-icon-box-connected") + return + + def _show_password_dialog(self): + """Show the WiFi password dialog""" + # Close the control center first + if self.parent and hasattr(self.parent, "hide_controlcenter"): + self.parent.hide_controlcenter() + + # Create a new dialog each time to ensure clean state + if self.password_dialog: + self.password_dialog.destroy_dialog() + + self.password_dialog = WiFiPasswordDialog( + ssid=self.ssid, + on_connect_callback=self._on_password_connect, + on_cancel_callback=self._on_password_cancel, + ) + + self.password_dialog.show_dialog() + + def _on_password_connect(self, ssid, password): + """Handle password dialog connect action""" + if password.strip(): + # Show connecting state using connecting icon + connecting_icon = get_wifi_connecting_icon() + self.dimage.set_from_file(connecting_icon) + self.dimage.add_style_class("connecting") + + # Try to connect with password using callback + def on_connection_result(success, message): + """Handle the connection result""" + if success: + self.is_connected = True + # Remove connecting state after a short delay + + GLib.timeout_add(500, lambda: self._reset_connect_state()) + + # Clear any timeout in the password dialog + if ( + self.password_dialog + and self.password_dialog.connection_timeout_id + ): + GLib.source_remove(self.password_dialog.connection_timeout_id) + self.password_dialog.connection_timeout_id = None + self.password_dialog.is_connecting = False + else: + # Connection failed - show error in dialog + self._reset_connect_state() + if self.password_dialog: + self._show_connection_error(message) + + # Update display after connection attempt + self.on_changed() + + try: + self.wifi_service.connect_to_wifi( + self.access_point, password, callback=on_connection_result + ) + except Exception: + # Handle any connection errors gracefully + self._reset_connect_state() + if self.password_dialog: + self._show_connection_error("Connection failed. Please try again.") + self.on_changed() + + def _show_connection_error(self, message="Incorrect password. Please try again."): + """Show connection error in a separate thread to prevent UI blocking""" + if self.password_dialog: + self.password_dialog.show_error(message) + return False # Don't repeat if called from GLib.timeout_add + + def _on_password_cancel(self): + """Handle password dialog cancel action""" + # Reset any connecting state + self._reset_connect_state() + + +class WifiConnections(Box): + def __init__(self, parent, show_back_button=True, **kwargs): + super().__init__( + spacing=8, + orientation="vertical", + name="wifi-connections", + **kwargs, + ) + + self.parent = parent + self.network_service = NetworkClient() + self.wifi_service = None + self.is_scanning = False # Track scanning state + self.refresh_timer = None # Timer for periodic network refresh + self._update_in_progress = False # Prevent concurrent updates + self._destroyed = False # Track if widget is destroyed + + # Wait for network service to be ready + self.network_service.connect("wifi-device-added", self.on_network_ready) + + # Create pull-to-refresh indicator + self.refresh_indicator = Label( + name="wifi-refresh-indicator", + label="↓ Pull to scan for networks", + h_align="center", + visible=False, + style="color: #fff; font-size: 12px; padding: 5px;", + ) + + # Create title with optional back button + title_children = [] + if show_back_button: + title_children.append( + Button( + image=Image(icon_name="back", size=10), + on_clicked=lambda *_: self.parent.close_wifi(), + ) + ) + title_children.append(Label("Wi-Fi", name="wifi-title")) + + self.title = Box( + orientation="h", + children=title_children, + ) + + self.toggle_button = Gtk.Switch(visible=True, name="toggle-button") + + # Create Known Network section + self.known_networks_label = Label( + label="Known Network", h_align="start", name="networks-title" + ) + self.known_networks = Box( + spacing=4, orientation="vertical", name="known-networks" + ) + + # Create "No networks available" message + self.no_networks_label = Label( + label="No networks available", + h_align="center", + name="no-networks-label", + visible=False, + ) + + # Create Other Networks section with clickable title + self.other_networks_button = Button( + child=Label("Other Networks", h_align="start"), + name="wifi-other-button", + on_clicked=self.toggle_other_networks, + ) + self.other_networks = Box(spacing=4, orientation="vertical") + + # Create scrolled window for other networks + self.other_networks_scrolled = ScrolledWindow( + min_content_size=(303, 150), + child=self.other_networks, + overlay_scroll=True, + ) + + # Add pull-to-refresh functionality to scrolled window + self.setup_pull_to_refresh() + + # Create revealer for Other Networks section + self.other_networks_revealer = Revealer( + child=self.other_networks_scrolled, + transition_type="slide-down", + transition_duration=100, + child_revealed=False, + ) + + # Create More Settings button (same style as Other Networks button) + self.more_settings_button = Button( + child=Label("More Settings", h_align="start"), + name="wifi-other-button", + on_clicked=self.open_network_settings, + ) + + self.children = [ + CenterBox( + start_children=self.title, + end_children=self.toggle_button, + name="wifi-widget-top", + ), + self.refresh_indicator, + Separator(orientation="h", name="separator"), + self.known_networks_label, + self.known_networks, + self.no_networks_label, + Separator(orientation="h", name="separator"), + self.other_networks_button, + self.other_networks_revealer, + Separator(orientation="h", name="separator"), + self.more_settings_button, + ] + + # Connect cleanup on destroy + self.connect("destroy", self.on_destroy) + + # Start periodic network monitoring for real-time updates + self.start_network_monitoring() + + def toggle_other_networks(self, *_): + """Toggle the visibility of other networks section""" + current_state = self.other_networks_revealer.child_revealed + self.other_networks_revealer.child_revealed = not current_state + + # Update button text based on state + if self.other_networks_revealer.child_revealed: + # Trigger a scan when revealing other networks and force refresh + if self.wifi_service: + self.wifi_service.scan() + # Also force an immediate network refresh to catch any missed connections + self.force_network_refresh() + + def on_network_ready(self, *_): + """Called when network service is ready""" + self.wifi_service = self.network_service.wifi_device + if self.wifi_service: + # Set up WiFi toggle + self.toggle_button.set_active(self.wifi_service.wireless_enabled) + self.toggle_button.connect("notify::active", self.on_toggle_changed) + + # Connect to WiFi service signals + self.wifi_service.connect( + "notify::wireless-enabled", self.on_wifi_enabled_changed + ) + self.wifi_service.connect("changed", self.update_networks) + self.wifi_service.connect("ap-added", self.update_networks) + self.wifi_service.connect("ap-removed", self.update_networks) + + # Initial network update + self.update_networks() + + def on_toggle_changed(self, toggle_button, *_): + """Handle WiFi toggle button changes""" + if self.wifi_service: + new_state = toggle_button.get_active() + self.wifi_service.wireless_enabled = new_state + + def on_wifi_enabled_changed(self, *_): + """Handle WiFi enabled state changes""" + if self.wifi_service: + self.toggle_button.set_active(self.wifi_service.wireless_enabled) + + def open_network_settings(self, *_): + """Open NetworkManager connection editor""" + try: + subprocess.Popen(["nm-connection-editor"], start_new_session=True) + if self.parent and hasattr(self.parent, "hide_controlcenter"): + self.parent.hide_controlcenter() + except FileNotFoundError: + pass + except Exception: + pass + + def update_networks(self, *_): + """Update the list of available networks""" + # Prevent concurrent updates and check if destroyed + if self._update_in_progress or self._destroyed or not self.wifi_service: + return + + self._update_in_progress = True + + try: + # Store current network SSIDs to detect changes + current_known_ssids = { + child.ssid + for child in self.known_networks.get_children() + if hasattr(child, "ssid") + } + current_other_ssids = { + child.ssid + for child in self.other_networks.get_children() + if hasattr(child, "ssid") + } + + # Get current networks + access_points = self.wifi_service.access_points + known_networks = [] + other_networks = [] + new_known_ssids = set() + new_other_ssids = set() + + for access_point in access_points: + try: + if access_point.ssid and access_point.ssid != "Unknown": + # Categorize networks: connected or saved networks go to "Known Network" + # All others go to "Other Networks" + if access_point.is_active or self._is_saved_network( + access_point + ): + known_networks.append(access_point) + new_known_ssids.add(access_point.ssid) + else: + other_networks.append(access_point) + new_other_ssids.add(access_point.ssid) + except Exception: + continue + + # Check if we need to update (networks added/removed) + known_changed = current_known_ssids != new_known_ssids + other_changed = current_other_ssids != new_other_ssids + + # Only rebuild if something actually changed + if known_changed or other_changed: + # Clear existing networks safely + for child in list(self.known_networks.get_children()): + if not self._destroyed: + child.destroy() + for child in list(self.other_networks.get_children()): + if not self._destroyed: + child.destroy() + + # Add known networks + for access_point in known_networks: + if not self._destroyed: + network_slot = WifiNetworkSlot( + access_point, self.wifi_service, parent=self.parent + ) + self.known_networks.add(network_slot) + + # Add other networks + for access_point in other_networks: + if not self._destroyed: + network_slot = WifiNetworkSlot( + access_point, self.wifi_service, parent=self.parent + ) + self.other_networks.add(network_slot) + + # Show/hide sections based on available networks + if not self._destroyed: + has_known_networks = len(known_networks) > 0 + has_other_networks = len(other_networks) > 0 + has_any_networks = has_known_networks or has_other_networks + + # Show known networks section only if there are known networks + self.known_networks_label.set_visible(has_known_networks) + self.known_networks.set_visible(has_known_networks) + + # Show "No networks available" message if no networks at all + self.no_networks_label.set_visible(not has_any_networks) + + # Always show the other networks button, regardless of available networks + self.other_networks_button.set_visible(True) # Always visible + + # Update all network connection states + self.refresh_network_states() + + except Exception: + pass + finally: + self._update_in_progress = False + + def _is_saved_network(self, access_point): + """Check if a network is saved/known using NetworkManager connections""" + if not self.network_service or not self.network_service._client: + return False + + try: + ssid = access_point.ssid + if not ssid or ssid == "Unknown": + return False + + # Get all saved connections from NetworkManager + connections = self.network_service._client.get_connections() + + for connection in connections: + # Check if this is a WiFi connection + if connection.get_connection_type() != "802-11-wireless": + continue + + # Get the wireless setting + wifi_setting = connection.get_setting_wireless() + if not wifi_setting: + continue + + # Compare SSIDs + connection_ssid_bytes = wifi_setting.get_ssid() + if connection_ssid_bytes: + connection_ssid = NM.utils_ssid_to_utf8( + connection_ssid_bytes.get_data() + ) + if connection_ssid == ssid: + return True + + except Exception: + pass + + return False + + def refresh_network_states(self, *_): + """Refresh connection states for all network slots""" + # Refresh known networks + for child in self.known_networks.get_children(): + if hasattr(child, "on_changed"): + child.on_changed() + + # Refresh other networks + for child in self.other_networks.get_children(): + if hasattr(child, "on_changed"): + child.on_changed() + + def start_network_monitoring(self): + """Start periodic monitoring for network changes""" + # Monitor for network changes every 5 seconds + # This helps catch networks that connect from external sources + self.refresh_timer = GLib.timeout_add_seconds(5, self.periodic_network_refresh) + + def stop_network_monitoring(self): + """Stop periodic monitoring""" + if self.refresh_timer: + GLib.source_remove(self.refresh_timer) + self.refresh_timer = None + + def periodic_network_refresh(self): + """Periodically refresh network list to catch external connections""" + # Skip if update in progress, destroyed, or wifi service not available + if ( + self._update_in_progress + or self._destroyed + or not self.wifi_service + or not self.wifi_service.wireless_enabled + ): + return True # Continue monitoring + + try: + # Simple check - just trigger update_networks which has its own safety checks + self.update_networks() + except Exception: + pass + + return True # Continue monitoring + + def force_network_refresh(self): + """Force an immediate refresh of the network list""" + if self._update_in_progress or self._destroyed: + return + + try: + # Simply trigger update_networks which has its own safety checks + self.update_networks() + except Exception: + pass + + def setup_pull_to_refresh(self): + """Setup pull-to-refresh gesture for the scrolled window""" + # Get the scrolled window's vertical adjustment + self.vadjustment = self.other_networks_scrolled.get_vadjustment() + + # Track gesture state + self.pull_start_y = 0 + self.is_pulling = False + self.pull_threshold = 50 # pixels to trigger refresh + + # Connect to scroll events + self.other_networks_scrolled.connect("scroll-event", self.on_scroll_event) + self.other_networks_scrolled.connect("button-press-event", self.on_button_press) + self.other_networks_scrolled.connect( + "button-release-event", self.on_button_release + ) + self.other_networks_scrolled.connect( + "motion-notify-event", self.on_motion_notify + ) + + # Enable events + self.other_networks_scrolled.set_events( + Gdk.EventMask.SCROLL_MASK + | Gdk.EventMask.BUTTON_PRESS_MASK + | Gdk.EventMask.BUTTON_RELEASE_MASK + | Gdk.EventMask.POINTER_MOTION_MASK + ) + + def on_scroll_event(self, widget, event): + """Handle scroll events for pull-to-refresh""" + # Only handle pull-to-refresh when at the top + if self.vadjustment.get_value() <= 0: + if event.direction == Gdk.ScrollDirection.UP: + # Scrolling up at the top - trigger scan and force refresh + if self.wifi_service: + self.wifi_service.scan() + self.force_network_refresh() + return True # Consume the event + return False # Let normal scrolling continue + + def on_button_press(self, widget, event): + """Handle button press for touch/drag gestures""" + if self.vadjustment.get_value() <= 0: + self.pull_start_y = event.y + self.is_pulling = True + return False + + def on_button_release(self, widget, event): + """Handle button release for touch/drag gestures""" + if self.is_pulling: + pull_distance = event.y - self.pull_start_y + if pull_distance > self.pull_threshold: + # Trigger scan and force refresh + if self.wifi_service: + self.wifi_service.scan() + self.force_network_refresh() + # Hide refresh indicator + self.refresh_indicator.set_visible(False) + self.refresh_indicator.remove_style_class("ready-to-refresh") + self.is_pulling = False + return False + + def on_motion_notify(self, widget, event): + """Handle motion events for visual feedback during pull""" + if self.is_pulling and self.vadjustment.get_value() <= 0: + pull_distance = event.y - self.pull_start_y + if pull_distance > 0: + # Show refresh indicator when pulling down + self.refresh_indicator.set_visible(True) + if pull_distance >= self.pull_threshold: + self.refresh_indicator.set_label("↑ Release to scan") + self.refresh_indicator.add_style_class("ready-to-refresh") + else: + self.refresh_indicator.set_label("↓ Pull to scan for networks") + self.refresh_indicator.remove_style_class("ready-to-refresh") + else: + self.refresh_indicator.set_visible(False) + return False + + def on_destroy(self, widget): + """Cleanup when widget is destroyed""" + # Mark as destroyed to prevent further updates + self._destroyed = True + # Stop monitoring + self.stop_network_monitoring() + # Make sure other networks revealer is collapsed when closing + try: + self.other_networks_revealer.child_revealed = False + except Exception: + pass # Widget might already be destroyed + + def close_wifi(self): + """Called when WiFi panel is being closed""" + # Collapse the other networks section when closing + self.other_networks_revealer.child_revealed = False diff --git a/src/window/desktop/__init__.py b/src/window/desktop/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/window/desktop/constants.py b/src/window/desktop/constants.py new file mode 100644 index 0000000..38545c2 --- /dev/null +++ b/src/window/desktop/constants.py @@ -0,0 +1,159 @@ +import datetime + +WEATHER_UPDATE_INTERVAL = 600 # 10 minutes +WEATHER_CACHE_TIMEOUT = 1800 # 30 minutes +SYSTEM_UPDATE_INTERVAL = 60000 # 1 minute instead of 1 second +CALENDAR_UPDATE_INTERVAL = int( + ( + ( + datetime.datetime.combine( + datetime.date.today() + datetime.timedelta(days=1), datetime.time.min + ) + - datetime.datetime.now() + ).total_seconds() + ) + * 1000 +) # Calculate time till midnight +LOCATION_CACHE_TIMEOUT = 604800 # 7 days (extended from 24h) + + +WEATHER_GRADIENT_MAP = { + # Clear/Sunny conditions - bright blue to lighter blue + 0: "weather-clear", # Clear sky + 1: "weather-mostly-clear", # Mainly clear + # Cloudy conditions - grey gradients + 2: "weather-partly-cloudy", # Partly cloudy + 3: "weather-overcast", # Overcast + # Fog conditions - muted grey/blue + 45: "weather-fog", # Fog + 48: "weather-fog", # Depositing rime fog + # Light rain/drizzle - blue-grey gradients + 51: "weather-light-rain", # Light drizzle + 53: "weather-rain", # Moderate drizzle + 55: "weather-rain", # Dense drizzle + 61: "weather-light-rain", # Slight rain + 80: "weather-light-rain", # Slight rain showers + # Heavy rain - darker blue-grey + 63: "weather-heavy-rain", # Moderate rain + 65: "weather-heavy-rain", # Heavy rain + 81: "weather-heavy-rain", # Moderate rain showers + 82: "weather-storm", # Violent rain showers + # Snow conditions - blue-white gradients + 56: "weather-snow", # Light freezing drizzle + 57: "weather-snow", # Dense freezing drizzle + 66: "weather-snow", # Light freezing rain + 67: "weather-snow", # Heavy freezing rain + 71: "weather-snow", # Slight snow fall + 73: "weather-heavy-snow", # Moderate snow fall + 75: "weather-heavy-snow", # Heavy snow fall + 77: "weather-snow", # Snow grains + 85: "weather-snow", # Slight snow showers + 86: "weather-heavy-snow", # Heavy snow showers + # Storm conditions - dark dramatic gradients + 95: "weather-storm", # Thunderstorm + 96: "weather-storm", # Thunderstorm with slight hail + 99: "weather-storm", # Thunderstorm with heavy hail +} + +# Weather condition to emoji mapping +WEATHER_EMOJI_MAP = { + 0: "☀️", # Clear sky + 1: "🌤️", # Mainly clear + 2: "⛅", # Partly cloudy + 3: "☁️", # Overcast + 45: "🌫️", # Fog + 48: "🌫️", # Depositing rime fog + 51: "🌦️", # Light drizzle + 53: "🌧️", # Moderate drizzle + 55: "🌧️", # Dense drizzle + 56: "🌨️", # Light freezing drizzle + 57: "🌨️", # Dense freezing drizzle + 61: "🌦️", # Slight rain + 63: "🌧️", # Moderate rain + 65: "🌧️", # Heavy rain + 66: "🌨️", # Light freezing rain + 67: "🌨️", # Heavy freezing rain + 71: "🌨️", # Slight snow fall + 73: "❄️", # Moderate snow fall + 75: "❄️", # Heavy snow fall + 77: "🌨️", # Snow grains + 80: "🌦️", # Slight rain showers + 81: "🌧️", # Moderate rain showers + 82: "⛈️", # Violent rain showers + 85: "🌨️", # Slight snow showers + 86: "❄️", # Heavy snow showers + 95: "⛈️", # Thunderstorm + 96: "⛈️", # Thunderstorm with slight hail + 99: "⛈️", # Thunderstorm with heavy hail +} + +# Weather condition to SVG icon mapping +WEATHER_ICON_MAP = { + 0: "weather-clear", + 1: "weather-few-clouds", + 2: "weather-clouds", + 3: "weather-overcast", + 45: "weather-fog", + 48: "weather-fog", + 51: "weather-showers-scattered", + 53: "weather-showers", + 55: "weather-showers", + 61: "weather-showers-scattered", + 80: "weather-showers-scattered", + 63: "weather-showers", + 65: "weather-showers", + 81: "weather-showers", + 82: "weather-storm", + 56: "weather-freezing-rain", + 57: "weather-freezing-rain", + 66: "weather-freezing-rain", + 67: "weather-freezing-rain", + 71: "weather-snow", + 73: "weather-snow", + 75: "weather-snow", + 77: "weather-hail", + 85: "weather-snow-scattered", + 86: "weather-snow-scattered", + 95: "weather-storm", + 96: "weather-storm", + 99: "weather-storm", +} + +# Weather condition descriptions +WEATHER_DESC_MAP = { + 0: "Clear sky", + 1: "Mainly clear", + 2: "Partly cloudy", + 3: "Overcast", + 45: "Fog", + 48: "Depositing rime fog", + 51: "Light drizzle", + 53: "Moderate drizzle", + 55: "Dense drizzle", + 56: "Light freezing drizzle", + 57: "Dense freezing drizzle", + 61: "Slight rain", + 63: "Moderate rain", + 65: "Heavy rain", + 66: "Light freezing rain", + 67: "Heavy freezing rain", + 71: "Slight snow", + 73: "Moderate snow", + 75: "Heavy snow", + 77: "Snow grains", + 80: "Light rain showers", + 81: "Moderate rain showers", + 82: "Violent rain showers", + 85: "Slight snow showers", + 86: "Heavy snow showers", + 95: "Thunderstorm", + 96: "Thunderstorm with hail", + 99: "Thunderstorm with heavy hail", +} + +# Location APIs in order of preference (fastest first) +LOCATION_APIS = [ + "https://ipapi.co/json/", # Fastest, 200ms average + "http://ip-api.com/json/", # Fast fallback, 150ms average + "https://ipinfo.io/json", # Original fallback +] diff --git a/src/window/desktop/widget.py b/src/window/desktop/widget.py new file mode 100644 index 0000000..2b665dd --- /dev/null +++ b/src/window/desktop/widget.py @@ -0,0 +1,653 @@ +import calendar +import datetime +import json +import urllib.parse +import urllib.request +from concurrent.futures import ThreadPoolExecutor +from typing import Any, Dict, List, Optional, Tuple + +import psutil +from fabric.utils import GLib, invoke_repeater, time +from fabric.widgets.box import Box +from fabric.widgets.circularprogressbar import CircularProgressBar +from fabric.widgets.datetime import DateTime +from fabric.widgets.label import Label +from fabric.widgets.overlay import Overlay +from fabric.widgets.wayland import WaylandWindow as Window + +from shared.data import load_config +from utils.debounce import sync_debounce +from utils.utils import svg_file +from window.desktop.constants import ( + CALENDAR_UPDATE_INTERVAL, + LOCATION_APIS, + LOCATION_CACHE_TIMEOUT, + SYSTEM_UPDATE_INTERVAL, + WEATHER_CACHE_TIMEOUT, + WEATHER_DESC_MAP, + WEATHER_GRADIENT_MAP, + WEATHER_ICON_MAP, + WEATHER_UPDATE_INTERVAL, +) + +# Thread pool for async operations +executor = ThreadPoolExecutor(max_workers=4) + +# Global cache for weather data +_weather_cache: Dict[str, Tuple[Any, float]] = {} +_location_cache: Dict[str, Tuple[float, float, float]] = {} + + +def http_get_json( + url: str, timeout: int = 3, headers: Optional[Dict] = None +) -> Optional[Dict]: + """Helper to perform GET requests and return JSON using built-in urllib.""" + try: + req = urllib.request.Request(url, headers=headers or {}) + with urllib.request.urlopen(req, timeout=timeout) as response: + if response.status == 200: + return json.loads(response.read().decode("utf-8")) + except Exception as e: + print(f"HTTP Request to {url} failed: {e}") + return None + + +def get_location() -> str: + """Get current location using multiple IP geolocation APIs with fallback.""" + for api_url in LOCATION_APIS: + data = http_get_json(api_url, timeout=2) + if data: + city = data.get("city", "") + if city: + return city.replace(" ", "") + + print("All location APIs failed") + return "" + + +def get_coordinates(city: str) -> Optional[Tuple[float, float]]: + """Get coordinates for a city using Nominatim geocoding API.""" + cache_key = city.lower() + current_time = time.time() + + if cache_key in _location_cache: + lat, lon, timestamp = _location_cache[cache_key] + if current_time - timestamp < LOCATION_CACHE_TIMEOUT: + return lat, lon + + encoded_city = urllib.parse.quote(city) + url = f"https://nominatim.openstreetmap.org/search?q={encoded_city}&format=json&limit=1" + + data = http_get_json(url, timeout=3, headers={"User-Agent": "Modus-Desktop/1.0"}) + + if data and isinstance(data, list) and len(data) > 0: + try: + lat = float(data[0]["lat"]) + lon = float(data[0]["lon"]) + _location_cache[cache_key] = (lat, lon, current_time) + return lat, lon + except (ValueError, KeyError): + pass + + return None + + +def get_weather_data(lat: float, lon: float) -> Optional[Dict[str, Any]]: + """Fetch weather data from Open-Meteo API.""" + url = ( + f"https://api.open-meteo.com/v1/forecast?" + f"latitude={lat}&longitude={lon}" + f"¤t_weather=true" + f"&daily=temperature_2m_max,temperature_2m_min" + f"&timezone=auto" + f"&forecast_days=1" + ) + return http_get_json(url, timeout=3) + + +def format_weather_data(weather_data: Dict[str, Any], city: str) -> List[str]: + """Format weather data into the expected format.""" + try: + current = weather_data["current_weather"] + daily = weather_data["daily"] + + weather_code = current["weathercode"] + is_day = current.get("is_day", 1) # Default to day if missing + + base_icon = WEATHER_ICON_MAP.get(weather_code, "weather-none-available") + + # Determine day/night variant if applicable + icon_name = base_icon + if not is_day: + night_variant = f"{base_icon}-night" + + # Fast check if night variant exists using predefined list or checking the path + # Since we know the variants from earlier, let's just optimistically build it + # then logic in svg_file will handle resolution seamlessly (fallbacks can be tricky, but we assume it's correct) + if base_icon in [ + "weather-clear", + "weather-clouds", + "weather-few-clouds", + "weather-overcast", + "weather-showers", + "weather-showers-scattered", + "weather-snow", + "weather-snow-scattered", + "weather-storm", + ]: + icon_name = night_variant + + condition = WEATHER_DESC_MAP.get(weather_code, "Unknown") + gradient_class = WEATHER_GRADIENT_MAP.get(weather_code, "weather-clear") + + temp = f"{round(current['temperature'])}°" + max_temp = f"{round(daily['temperature_2m_max'][0])}°" + min_temp = f"{round(daily['temperature_2m_min'][0])}°" + + return [icon_name, temp, condition, city, max_temp, min_temp, gradient_class] + except (KeyError, IndexError, TypeError) as e: + print(f"Error formatting weather data: {e}") + return None + + +def get_weather(callback): + """Fetch weather data asynchronously.""" + + def fetch_weather(): + location = get_location() + if not location: + return GLib.idle_add(callback, None) + + cache_key = location.lower() + current_time = time.time() + + if cache_key in _weather_cache: + cached_data, timestamp = _weather_cache[cache_key] + if current_time - timestamp < WEATHER_CACHE_TIMEOUT: + return GLib.idle_add(callback, cached_data) + + coords = get_coordinates(location) + if not coords: + return GLib.idle_add(callback, None) + + lat, lon = coords + weather_data = get_weather_data(lat, lon) + if not weather_data: + return GLib.idle_add(callback, None) + + formatted_data = format_weather_data(weather_data, location) + if formatted_data: + _weather_cache[cache_key] = (formatted_data, current_time) + GLib.idle_add(callback, formatted_data) + else: + GLib.idle_add(callback, None) + + executor.submit(fetch_weather) + + +def update_weather(widget): + """Update weather widget with new data.""" + + def perform_fetch(): + get_weather(lambda weather_info: update_widget(widget, weather_info)) + + debounced_perform_fetch = sync_debounce(1000, immediate=True)(perform_fetch) + + def fetch_and_update(): + debounced_perform_fetch() + return True + + GLib.timeout_add_seconds(WEATHER_UPDATE_INTERVAL, fetch_and_update) + fetch_and_update() + + +def update_widget(widget, weather_info): + """Update widget labels with weather information.""" + if weather_info: + widget.weatherinfo = weather_info + widget.update_labels(weather_info) + + +class Weather(Box): + def __init__(self, parent, **kwargs): + super().__init__( + name="weather-widget", + h_expand=True, + v_expand=True, + justification="right", + orientation="v", + all_visible=False, + **kwargs, + ) + self.parent = parent + self.weatherinfo = None + self._create_labels() + self._layout_labels() + update_weather(self) + + def _create_labels(self): + self.header = Box(orientation="h", h_expand=True) + self.body = Box(orientation="v", v_expand=True, valign="end") + + self.city = Label( + name="city", + label="Loading...", + justification="left", + h_align="start", + max_chars_width=15, + ellipsization="end", + ) + self.temperature = Label(name="temperature", label="--°", h_align="start") + self.condition_em = svg_file( + "weather/weather-none-available.svg", size=(35, 35), name="condition-emoji" + ) + self.condition = Label( + name="condition", + label="Loading...", + max_chars_width=18, + ellipsization="end", + h_align="start", + ) + self.feels_like = Label(name="feels-like", label="L:-- H:--", h_align="start") + + def _layout_labels(self): + # Header: City (left) and Icon (right) + self.header.add(self.city) + self.header.pack_end(self.condition_em, False, False, 0) + + # Body: Temp, Condition, High/Low + self.body.add(self.temperature) + self.body.add(self.condition) + self.body.add(self.feels_like) + + self.add(self.header) + self.add(self.body) + + def update_labels(self, weather_info: List[str]): + if not weather_info or len(weather_info) != 7: + return + icon_name, temp, condition, location, maxtemp, mintemp, gradient_class = ( + weather_info + ) + maxmin = f"L:{mintemp} H:{maxtemp}" + + self.city.set_label(location) + self.temperature.set_label(temp) + + # update SVG File + self.condition_em.dynamic_file(f"weather/{icon_name}.svg") + + self.condition.set_label(condition) + self.feels_like.set_label(maxmin) + + # Apply gradient class to container + if hasattr(self, "parent") and self.parent: + # Remove old weather classes + for cls in self.parent.get_style_context().list_classes(): + if cls.startswith("weather-"): + self.parent.remove_style_class(cls) + # Add new one + self.parent.add_style_class(gradient_class) + + self.parent.set_visible(True) + + +class WeatherContainer(Box): + def __init__(self, **kwargs): + super().__init__( + orientation="v", + name="weather-container", + v_expand=True, + v_align="center", + size=(170, 170), + visible=True, + h_align="center", + children=[Weather(self)], + **kwargs, + ) + + +class Date(Box): + def __init__(self, **kwargs): + super().__init__( + name="date-widget", + h_expand=True, + v_expand=True, + justification="center", + h_align="center", + v_align="start", + orientation="v", + **kwargs, + ) + self.top = Box(orientation="h", name="date-top", h_expand=True) + date_interval = 10000 + self.dateone = DateTime(formatters=["%a"], interval=date_interval, name="day") + self.datetwo = DateTime(formatters=["%b"], interval=date_interval, name="month") + self.datethree = DateTime( + formatters=["%-d"], interval=date_interval, name="date" + ) + self.top.add(self.dateone) + self.top.add(self.datetwo) + self.add(self.top) + self.add(self.datethree) + + +class DateContainer(Box): + def __init__(self, **kwargs): + super().__init__( + orientation="v", + name="date-container", + v_expand=True, + size=(170, 170), + v_align="center", + h_align="center", + children=[Date()], + **kwargs, + ) + + +class Calendar(Box): + def __init__(self, **kwargs): + calendar.setfirstweekday(6) + super().__init__( + name="calendar-widget", + h_expand=True, + v_expand=True, + orientation="v", + **kwargs, + ) + self._update_current_date() + self._create_header() + self._create_days_header() + self._create_calendar_grid() + self.add(self.month_label) + self.add(self.days_header) + self.add(self.calendar_grid) + invoke_repeater(CALENDAR_UPDATE_INTERVAL, self.update_calendar_if_needed) + + def _update_current_date(self): + now = datetime.datetime.now() + self.current_month, self.current_year, self.current_day = ( + now.month, + now.year, + now.day, + ) + + def _create_header(self): + self.month_label = Label( + name="calendar-month", + label=calendar.month_name[self.current_month], + h_align="start", + justification="left", + ) + + def _create_days_header(self): + self.days_header = Box( + name="calendar-days-header", orientation="h", h_expand=True, spacing=2 + ) + for i, day_name in enumerate(["S", "M", "T", "W", "T", "F", "S"]): + self.days_header.add( + Label( + name="calendar-day-header-weekend" + if i in (0, 6) + else "calendar-day-header", + label=day_name, + h_align="center", + h_expand=True, + ) + ) + + def _create_calendar_grid(self): + self.calendar_grid = Box(name="calendar-grid", orientation="v", spacing=1) + self.update_calendar() + + def update_calendar_if_needed(self) -> bool: + now = datetime.datetime.now() + if (now.month, now.year, now.day) != ( + self.current_month, + self.current_year, + self.current_day, + ): + self._update_current_date() + self.update_calendar() + return True + + def update_calendar(self): + for child in self.calendar_grid.get_children(): + self.calendar_grid.remove(child) + self.month_label.set_label(calendar.month_name[self.current_month]) + cal = calendar.monthcalendar(self.current_year, self.current_month) + for week in cal: + week_box = Box(orientation="h", spacing=2, h_expand=True) + for i, day in enumerate(week): + if day == 0: + label = Label( + name="calendar-day-empty", + label="", + h_align="center", + h_expand=True, + ) + else: + is_today = ( + day == self.current_day + and self.current_month == datetime.datetime.now().month + ) + name = ( + "calendar-day-today" + if is_today + else ("calendar-day-weekend" if i in (0, 6) else "calendar-day") + ) + label = Label( + name=name, label=str(day), h_align="center", h_expand=True + ) + week_box.add(label) + self.calendar_grid.add(week_box) + + +class CalendarContainer(Box): + def __init__(self, **kwargs): + super().__init__( + orientation="v", + name="calendar-box-widget", + v_expand=True, + size=(170, 170), + v_align="center", + h_align="center", + children=[Calendar()], + **kwargs, + ) + + +class SystemInfoBase(Box): + @staticmethod + def create_progress_bar(name: str = "progress-bar", size: int = 80, **kwargs): + return CircularProgressBar( + name=name, + start_angle=270, + end_angle=630, + min_value=0, + max_value=100, + size=size, + **kwargs, + ) + + def __init__(self, name: str, **kwargs): + super().__init__( + layer="bottom", + title="sysinfo", + name=name, + visible=True, + size=(170, 170), + h_expand=True, + v_expand=True, + all_visible=True, + **kwargs, + ) + self.progress = self.create_progress_bar(name="progress") + self.main_label = Label( + label="0%\nLoading", justification="center", name="progress-label" + ) + self.info_container = Box( + name="info-container", orientation="v", spacing=2, h_align="center" + ) + self.add( + Box( + name="progress-bar-container", + h_expand=True, + v_expand=True, + orientation="v", + spacing=12, + h_align="center", + v_align="center", + children=[ + Box( + children=[ + Overlay( + child=self.progress, + tooltip_text="", + overlays=self.main_label, + ) + ] + ), + Box( + h_align="center", + justification="centre", + orientation="v", + children=[self.info_container], + ), + ], + ) + ) + + def start_updates(self): + invoke_repeater(SYSTEM_UPDATE_INTERVAL, self.update) + + def create_info_line( + self, indicator_name: str, info_text: str, value_text: str + ) -> Box: + line = Box( + orientation="h", + spacing=4, + h_align="start", + children=[ + Label(label="■", name=indicator_name), + Label(label=info_text, name="info-text"), + Label(label=value_text, name="info-value"), + ], + ) + line.value_label = line.get_children()[2] + return line + + def update(self) -> bool: + raise NotImplementedError + + +class RamInfo(SystemInfoBase): + def __init__(self, **kwargs): + super().__init__("info-box-widget", **kwargs) + self.used_line = self.create_info_line("used-color-indicator", "Used", "0.0GB") + self.free_line = self.create_info_line("free-color-indicator", "Free", "0.0GB") + self.info_container.add(self.used_line) + self.info_container.add(self.free_line) + self.start_updates() + + def update(self) -> bool: + try: + mem = psutil.virtual_memory() + self.main_label.set_label(f" {round(mem.percent):<2} %\nRAM") + self.used_line.value_label.set_label(f"{round(mem.used / (1024**3), 1)}GB") + self.free_line.value_label.set_label( + f"{round(mem.available / (1024**3), 1)}GB" + ) + GLib.idle_add(self.progress.set_value, mem.percent) + except Exception as e: + print(f"Error: {e}") + return True + + +class CpuInfo(SystemInfoBase): + def __init__(self, **kwargs): + super().__init__("info-box-widget", **kwargs) + self.temp_value = Label(label="0°C", name="info-value") + self.info_container.add( + Box( + orientation="h", + spacing=4, + h_align="start", + children=[Label(label="Temp", name="info-text"), self.temp_value], + ) + ) + self.start_updates() + + def get_cpu_temp(self) -> Optional[float]: + try: + temps = psutil.sensors_temperatures() + for name, entries in temps.items(): + if any(s in name.lower() for s in ["coretemp", "k10temp", "cpu"]): + for entry in entries: + if any( + p in (entry.label or "").lower() + for p in ["package id 0", "core 0", ""] + ): + return round(entry.current, 1) + except Exception: + pass + return None + + def update(self) -> bool: + try: + cpu = psutil.cpu_percent() + self.main_label.set_label(f" {round(cpu):<2} %\nCPU") + temp = self.get_cpu_temp() + self.temp_value.set_label(f"{temp}°C" if temp else "N/A") + GLib.idle_add(self.progress.set_value, cpu) + except Exception as e: + print(f"Error: {e}") + return True + + +class Deskwidgets: + """Desktop widgets manager - handles all desktop widgets.""" + + config = load_config() + + def __init__(self): + # This class now just manages other windows instead of being one itself + + # Create separate independent windows as attributes + self.top_left = Window( + anchor="top left", + title="modus-widgets-topleft", + exclusivity="none", + orientation="h", + layer="bottom", + visible=False, # Start hidden until content ready + child=Box( + name="desktop-widgets-container", + children=[ + DateContainer(), + WeatherContainer(), + CalendarContainer(), + ], + ), + ) + + self.bottom_left = Window( + anchor="bottom right", + title="modus-widgets-bottomright", + orientation="h", + layer="bottom", + exclusivity="none", + visible=False, # Start hidden until content ready + child=Box( + name="desktop-widgets-container", + children=[ + CpuInfo(), + RamInfo(), + ], + ), + ) + + # Show widgets after initialization is complete + self.top_left.set_visible(True) + self.bottom_left.set_visible(True) diff --git a/src/window/dock.py b/src/window/dock.py new file mode 100644 index 0000000..1c63f42 --- /dev/null +++ b/src/window/dock.py @@ -0,0 +1,854 @@ +import json +import subprocess + +from fabric.utils import ( + GLib, + Gtk, + get_desktop_applications, + get_relative_path, + logger, + os, + random, + re, +) +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.eventbox import EventBox +from fabric.widgets.image import Image +from fabric.widgets.label import Label +from fabric.widgets.overlay import Overlay +from fabric.widgets.revealer import Revealer +from fabric.widgets.wayland import WaylandWindow as Window + +import shared.data as data +from services.modus import modus_service +from utils.functions import ( + clear_children, + is_special_workspace_id, + read_json_file, + write_json_file, +) +from utils.icon_resolver import IconResolver +from utils.occlusion import check_occlusion + +# Pinned apps file +PINNED_APPS_FILE = get_relative_path("../../config/dock.json") + + +class AppBar(Box): + def __init__(self, parent: Window): + self.client_buttons = {} # For running app instances + self.pinned_buttons = {} # For pinned apps + # Position tracking for hover effects + self.running_items_pos = [] + self.pinned_items_pos = [] + self._parent = parent + + # Set orientation based on dock position + orientation = ( + "vertical" if data.DOCK_POSITION in ["Left", "Right"] else "horizontal" + ) + + super().__init__( + spacing=0, + name="dock", + orientation=orientation, + children=[], + ) + self.icon_resolver = IconResolver() + self._hyprland_connection = modus_service._hyprland_connection + + # Initialize GTK menu + self.menu = Gtk.Menu() + + self.pinned_apps = read_json_file(PINNED_APPS_FILE) or [] + self.pinned_apps_container = Box() + self.add(self.pinned_apps_container) + + self.separator = Box( + v_align="center", style_classes=["hidden", "dock_separator"] + ) + self.add(self.separator) + + self.running_apps_container = Box(name="dock_container") + self.add(self.running_apps_container) + + self._populate_pinned_apps() + self.setup_app_monitoring() + + def setup_app_monitoring(self): + def update_running_apps(): + try: + self.update_dock_apps() + except Exception as e: + logger.error(f"[AppBar] Error updating apps: {e}") + return True + + GLib.timeout_add(250, update_running_apps) + GLib.idle_add(self.update_dock_apps) + + def _populate_pinned_apps(self): + clear_children(self.pinned_apps_container) + + self.pinned_buttons = {} + self.pinned_items_pos = [] + + try: + desktop_apps = get_desktop_applications(include_hidden=False) + except Exception: + desktop_apps = [] + + for app_data in self.pinned_apps: + self._create_pinned_button(app_data, desktop_apps) + + # Add trash icon at the end of pinned apps + self._create_trash_button() + + def _create_pinned_button(self, app_data, desktop_apps): + app = self._find_desktop_app(app_data, desktop_apps) + app_identifier = self._get_app_identifier(app_data) + display_name = ( + app.display_name + if app + else ( + app_data.get("display_name", app_identifier) + if isinstance(app_data, dict) + else app_identifier + ) + ) + + icon_pixbuf = self._get_app_icon(app_data, app) + + main_container = Box( + name="dock_item_main_container", + orientation="v", + children=[Image(name="dock_item_icon", pixbuf=icon_pixbuf)], + ) + + pinned_button = Button( + name="dock_item", + child=main_container, + tooltip_text=display_name, + on_button_press_event=lambda _, event: self._handle_pinned_app_click( + event, app_data + ), + on_enter_notify_event=lambda *_: self._set_item_hover_state( + pinned_button, True, pinned=True + ), + on_leave_notify_event=lambda *_: self._set_item_hover_state( + pinned_button, False, pinned=True + ), + ) + + pinned_button.add_style_class("shown") + + self.pinned_buttons[app_identifier] = pinned_button + self.pinned_apps_container.add(pinned_button) + self.pinned_items_pos.append(pinned_button) + + def _get_app_icon(self, app_data, app=None): + if app: + return app.get_icon_pixbuf(data.DOCK_ICON_SIZE) + + icon_name = "" + if isinstance(app_data, dict): + icon_name = app_data.get("window_class") or app_data.get("name") + elif isinstance(app_data, str): + icon_name = app_data + + return self.icon_resolver.get_icon_pixbuf( + icon_name or "application-x-executable", data.DOCK_ICON_SIZE + ) + + def _create_trash_button(self): + """Create a trash button that opens the trash in file manager""" + trash_icon_pixbuf = self.icon_resolver.get_icon_pixbuf( + "user-trash", data.DOCK_ICON_SIZE + ) + + main_container = Box( + name="dock_item_main_container", + orientation="v", + children=[Image(name="dock_item_icon", pixbuf=trash_icon_pixbuf)], + ) + + trash_button = Button( + name="dock_item", + child=main_container, + tooltip_text="Trash", + on_button_press_event=lambda _, event: self._handle_trash_click(event), + on_enter_notify_event=lambda *_: self._set_item_hover_state( + trash_button, True, pinned=True + ), + on_leave_notify_event=lambda *_: self._set_item_hover_state( + trash_button, False, pinned=True + ), + ) + + trash_button.add_style_class("shown") + trash_button.is_trash = True + + self.pinned_buttons["trash"] = trash_button + self.pinned_apps_container.add(trash_button) + self.pinned_items_pos.append(trash_button) + + def _find_desktop_app(self, app_info, desktop_apps): + """Find a desktop app from either a string ID or a data dictionary""" + if not app_info: + return None + + # Extract normalized search terms + search_terms = [] + if isinstance(app_info, dict): + search_terms = [ + app_info.get("window_class"), + app_info.get("name"), + app_info.get("executable"), + ] + else: + search_terms = [app_info] + + search_terms = [s.lower() for s in search_terms if s and isinstance(s, str)] + if not search_terms: + return None + + # Handle Reverse DNS or decorated names (e.g., org.gnome.Nautilus, float_kitty) + expanded_terms = list(search_terms) + for term in search_terms: + # Handle dots (Reverse DNS) + if "." in term: + parts = term.split(".") + if parts[-1] and parts[-1] not in expanded_terms: + expanded_terms.append(parts[-1]) + + # Handle underscores and hyphens (decorated names) + for sep in ["_", "-"]: + if sep in term: + for part in term.split(sep): + if len(part) > 2 and part not in expanded_terms: + expanded_terms.append(part) + + # Priority 1: Exact matches for window_class + for app in desktop_apps: + app_class = getattr(app, "window_class", None) + if app_class and app_class.lower() in search_terms: + return app + + # Priority 2: Exact matches for app name or display name + for app in desktop_apps: + app_names = [ + getattr(app, "name", None), + getattr(app, "display_name", None), + ] + app_names = [n.lower() for n in app_names if n] + if any(name in expanded_terms for name in app_names): + return app + + # Priority 3: Exact matches for executable basename + for app in desktop_apps: + if app.executable: + exe_base = os.path.basename(app.executable).lower() + if exe_base in expanded_terms: + # If this is a common terminal or shell, only match if the name also matches + # This prevents Neovim (which uses 'kitty' as its executable wrapper) from overriding Kitty + common_wrappers = [ + "kitty", + "bash", + "sh", + "zsh", + "python", + "python3", + ] + if exe_base in common_wrappers: + app_names = [ + getattr(app, "name", ""), + getattr(app, "display_name", ""), + ] + if not any(exe_base in n.lower() for n in app_names if n): + continue + return app + + # Priority 4: Fuzzy name matching (containment) + for app in desktop_apps: + app_terms = [ + getattr(app, "name", ""), + getattr(app, "display_name", ""), + getattr(app, "window_class", ""), + ] + app_terms = [t.lower() for t in app_terms if t] + + for term in expanded_terms: + if len(term) < 3: + continue + if any(term in app_term for app_term in app_terms): + return app + + return None + + def show_menu(self, app_id: str, client=None, instance_address=None): + for item in self.menu.get_children(): + self.menu.remove(item) + item.destroy() + + if client or instance_address: + close_item = Gtk.MenuItem(label="Close") + if instance_address: + close_item.connect( + "activate", lambda *_: self._close_running_app(instance_address) + ) + self.menu.add(close_item) + + if app_id: + separator = Gtk.SeparatorMenuItem() + self.menu.add(separator) + + if app_id: + is_pinned = self._is_app_pinned(app_id) + pin_item = Gtk.MenuItem(label="Unpin" if is_pinned else "Pin") + + if is_pinned: + pin_item.connect("activate", lambda *_: self._unpin_app(app_id)) + else: + pin_item.connect("activate", lambda *_: self._pin_app(app_id)) + + self.menu.add(pin_item) + + self.menu.show_all() + + def _close_running_app(self, instance_address): + try: + self._hyprland_connection.send_command( + f"dispatch closewindow address:{instance_address}" + ) + except Exception as e: + logger.error(f"[AppBar] Error closing window: {e}") + + def _handle_pinned_app_click(self, event, app_data): + app_identifier = self._get_app_identifier(app_data) + if event.button == 1: # Left click - launch app + self._launch_app(app_data) + elif event.button == 2: # Middle click - unpin app + self._unpin_app(app_identifier) + elif event.button == 3: # Right click - show context menu + self.show_menu(app_identifier) + self.menu.popup_at_pointer(event) + + def _handle_trash_click(self, event): + """Handle trash button click to open trash in file manager""" + if event.button == 1: # Left click + try: + trash_path = os.path.expanduser("~/.local/share/Trash/files") + file_managers = [ + "nautilus", + "dolphin", + "thunar", + "nemo", + "caja", + "pcmanfm", + ] + + for fm in file_managers: + try: + result = subprocess.run( + ["which", fm], capture_output=True, text=True + ) + if result.returncode == 0: + subprocess.Popen([fm, trash_path]) + return + except Exception: + continue + except Exception as e: + logger.error(f"[AppBar] Error opening trash: {e}") + + def _set_item_hover_state(self, item, is_hovered, pinned=False): + if is_hovered: + self._parent.on_hover_enter() + + items_list = self.pinned_items_pos if pinned else self.running_items_pos + try: + index = items_list.index(item) + style_class = "semi_hovered" + action = "add" if is_hovered else "remove" + + if index > 0: + getattr(items_list[index - 1], f"{action}_style_class")(style_class) + if index < len(items_list) - 1: + getattr(items_list[index + 1], f"{action}_style_class")(style_class) + except (ValueError, IndexError): + pass + + def _launch_app(self, app_info): + """Launch an app using its desktop app instance or a data dictionary""" + try: + command_line = "" + if hasattr(app_info, "command_line"): + command_line = app_info.command_line + elif isinstance(app_info, dict): + command_line = app_info.get("command_line") or app_info.get( + "executable" + ) + elif isinstance(app_info, str): + command_line = app_info + + if not command_line: + # Fallback to desktop app search if it's just a string class + desktop_apps = get_desktop_applications(include_hidden=False) + app = self._find_desktop_app(app_info, desktop_apps) + if app: + command_line = app.command_line + + if command_line: + cleaned_command = re.sub(r"%\w+", "", command_line).strip() + final_command = f"hyprctl dispatch exec 'uwsm app -- {cleaned_command}'" + subprocess.Popen(final_command, shell=True) + elif hasattr(app_info, "launch"): + app_info.launch() + else: + logger.error( + f"[AppBar] Could not determine launch command for: {app_info}" + ) + except Exception as e: + logger.error(f"[AppBar] Failed to launch app: {e}") + + def _get_app_identifier(self, app_data): + if isinstance(app_data, dict): + return app_data.get("name", "") or app_data.get("window_class", "") + return app_data + + def _pin_app(self, app_class: str): + if self._is_app_pinned(app_class): + return False + + try: + desktop_apps = get_desktop_applications(include_hidden=False) + app = self._find_desktop_app(app_class, desktop_apps) + + app_data = { + "name": app.name if app else app_class, + "display_name": (app.display_name or app.name) if app else app_class, + "window_class": (getattr(app, "window_class", None) or app_class) + if app + else app_class, + "executable": app.executable if app else app_class, + "command_line": app.command_line if app else app_class, + } + self.pinned_apps.append(app_data) + except Exception: + self.pinned_apps.append(app_class) + + write_json_file(self.pinned_apps, PINNED_APPS_FILE) + self._populate_pinned_apps() + return True + + def _unpin_app(self, app_identifier: str): + apps_to_remove = [] + + for i, pinned_app in enumerate(self.pinned_apps): + if self._matches_app_identifier(pinned_app, app_identifier): + apps_to_remove.append(i) + + for i in reversed(apps_to_remove): + self.pinned_apps.pop(i) + + if apps_to_remove: + write_json_file(self.pinned_apps, PINNED_APPS_FILE) + self._populate_pinned_apps() + return True + return False + + def _matches_app_identifier(self, pinned_app, app_identifier): + if not app_identifier: + return False + + if isinstance(pinned_app, dict): + window_class = pinned_app.get("window_class") or "" + name = pinned_app.get("name") or "" + return ( + window_class.lower() == app_identifier.lower() + or name.lower() == app_identifier.lower() + ) + return ( + isinstance(pinned_app, str) and pinned_app.lower() == app_identifier.lower() + ) + + def get_clients(self): + try: + clients_data = self._hyprland_connection.send_command("j/clients").reply + if not clients_data: + return [] + return json.loads(clients_data.decode("utf-8")) + except Exception as e: + logger.error(f"[AppBar] Error getting clients: {e}") + return [] + + def get_focused_window(self): + try: + active_data = self._hyprland_connection.send_command("j/activewindow").reply + if not active_data: + return None + return json.loads(active_data.decode("utf-8")) + except Exception as e: + logger.error(f"[AppBar] Error getting focused window: {e}") + return None + + def update_dock_apps(self): + try: + clients = self.get_clients() + focused_window = self.get_focused_window() + focused_address = ( + focused_window.get("address", "") if focused_window else "" + ) + + current_instance_ids = set() + + for client in clients: + if client.get("hidden", False) or not self._should_show_app_instance( + client + ): + continue + + instance_address = client.get("address", "") + app_class = client.get("class", "") or client.get("title", "") + if not instance_address or not app_class: + continue + + current_instance_ids.add(instance_address) + + if instance_address not in self.client_buttons: + self.create_instance_button(instance_address, client, app_class) + else: + self.update_instance_button(instance_address, client, app_class) + + button = self.client_buttons[instance_address] + if instance_address == focused_address: + button.add_style_class("activated") + else: + button.remove_style_class("activated") + + self._update_pinned_apps_state(clients) + self._update_separator_visibility() + + self._cleanup_removed_instances(current_instance_ids) + + except Exception as e: + logger.error(f"[AppBar] Error in update_dock_apps: {e}") + + def _update_pinned_apps_state(self, clients): + running_app_classes = { + client.get("class", "").lower() or client.get("title", "").lower() + for client in clients + if not client.get("hidden", False) + and (client.get("class") or client.get("title")) + and self._should_show_app_instance(client) + } + + for app_identifier, button in self.pinned_buttons.items(): + # Skip trash button as it's not a regular app + if app_identifier == "trash" or hasattr(button, "is_trash"): + continue + + if app_identifier.lower() in running_app_classes: + button.add_style_class("instance") + else: + button.remove_style_class("instance") + + def _cleanup_removed_instances(self, current_instance_ids): + buttons_to_remove = [ + instance_id + for instance_id in self.client_buttons.keys() + if instance_id not in current_instance_ids + ] + + # Clean up removed and orphaned buttons + for instance_id in buttons_to_remove + [ + k + for k, v in self.client_buttons.items() + if not hasattr(v, "instance_address") or not v.get_parent() + ]: + if instance_id in self.client_buttons: + button = self.client_buttons.pop(instance_id) + try: + if button in self.running_items_pos: + self.running_items_pos.remove(button) + button.remove_style_class("shown") + button.remove_style_class("activated") + if button.get_parent(): + button.get_parent().remove(button) + button.destroy() + except Exception as e: + logger.warning(f"[AppBar] Error during cleanup: {e}") + + def create_instance_button(self, instance_address, client, app_class): + try: + client_image = Image(name="dock_item_icon") + + try: + desktop_apps = get_desktop_applications(include_hidden=False) + app = self._find_desktop_app(app_class, desktop_apps) + pixbuf = self._get_app_icon(app_class, app) + client_image.set_from_pixbuf(pixbuf) + except Exception as e: + logger.warning(f"[AppBar] Could not load icon for {app_class}: {e}") + + workspace_id = self._get_workspace_id(client) + workspace_label = None + if workspace_id is not None: + workspace_label = Label( + label=str(workspace_id), + name="workspace-indicator", + h_align="end", + v_align="end", + ) + + image_overlay = Overlay(name="dock-image-overlay", child=client_image) + if workspace_label: + image_overlay.add_overlay(workspace_label) + + indicator = Box(name="dock_item_indicator", h_align="center") + main_container = Box( + name="dock_item_main_container", + orientation="v", + children=[image_overlay, indicator], + ) + + tooltip_text = client.get("title", app_class) + if tooltip_text != app_class: + tooltip_text = f"{app_class}: {tooltip_text}" + + client_button = Button( + name="dock_item", + child=main_container, + tooltip_text=tooltip_text, + on_button_press_event=lambda widget, event: self.handle_instance_click( + widget, event + ), + on_enter_notify_event=lambda *_: self._set_item_hover_state( + client_button, True, pinned=False + ), + on_leave_notify_event=lambda *_: self._set_item_hover_state( + client_button, False, pinned=False + ), + ) + + client_button.instance_address = instance_address + client_button.client_data = client + client_button.app_class = app_class + client_button.workspace_label = workspace_label + client_button.add_style_class("shown") + + self.client_buttons[instance_address] = client_button + self.running_apps_container.add(client_button) + self.running_items_pos.append(client_button) + + except Exception as e: + logger.error( + f"[AppBar] Error creating instance button for {app_class}: {e}" + ) + + def _get_workspace_id(self, client): + workspace_data = client.get("workspace", {}) + if isinstance(workspace_data, dict): + return workspace_data.get("id") + elif isinstance(workspace_data, (int, str)): + return workspace_data + return None + + def _should_show_app_instance(self, client): + if not data.DOCK_HIDE_SPECIAL_WORKSPACE_APPS: + return True + + workspace_id = self._get_workspace_id(client) + return ( + False if workspace_id is None else not is_special_workspace_id(workspace_id) + ) + + def update_instance_button(self, instance_address, client, app_class): + if instance_address not in self.client_buttons: + return + + button = self.client_buttons[instance_address] + button.client_data = client + button.app_class = app_class + + tooltip_text = client.get("title", app_class) + if tooltip_text != app_class: + tooltip_text = f"{app_class}: {tooltip_text}" + button.set_tooltip_text(tooltip_text) + + workspace_id = self._get_workspace_id(client) + existing_label = getattr(button, "workspace_label", None) + + container = button.get_child() + if not hasattr(container, "get_children"): + return + + children = container.get_children() + if not children or not isinstance(children[0], Overlay): + return + + image_overlay = children[0] + # Remove existing workspace label + if existing_label and existing_label.get_parent(): + image_overlay.remove_overlay(existing_label) + + # Add new workspace label if needed + if workspace_id is not None: + new_label = Label( + label=str(workspace_id), + name="workspace-indicator", + h_align="end", + v_align="end", + ) + image_overlay.add_overlay(new_label) + button.workspace_label = new_label + else: + button.workspace_label = None + + def handle_instance_click(self, button_widget, event): + instance_address = getattr(button_widget, "instance_address", None) + app_class = getattr(button_widget, "app_class", None) + + if event.button == 1: # Left click - focus window + if instance_address: + try: + self._hyprland_connection.send_command( + f"dispatch focuswindow address:{instance_address}" + ) + except Exception as e: + logger.error(f"[AppBar] Error focusing window: {e}") + + elif event.button == 2: # Middle click - pin/unpin app + if app_class and not self._is_app_pinned(app_class): + self._pin_app(app_class) + + elif event.button == 3: # Right click - context menu + if app_class: + self.show_menu(app_class, instance_address=instance_address) + self.menu.popup_at_pointer(event) + + def _is_app_pinned(self, app_class: str) -> bool: + return any( + self._matches_app_identifier(pinned_app, app_class) + for pinned_app in self.pinned_apps + ) + + def _update_separator_visibility(self): + has_pinned_apps = len(self.pinned_items_pos) > 0 + has_running_apps = len(self.running_items_pos) > 0 + if has_pinned_apps and has_running_apps: + self.separator.remove_style_class("hidden") + else: + self.separator.add_style_class("hidden") + + +class Dock(Window): + def __init__(self): + dock_config = self._get_dock_config() + if not data.DOCK_ENABLED: + super().__init__(layer="top", title="dock", anchor=dock_config["anchor"]) + self.children = Box() # Empty dock if disabled + return + + super().__init__(layer="top", anchor=dock_config["anchor"]) + + self.app_bar = AppBar(self) + self.revealer = Revealer( + child=Box(children=[self.app_bar], style="padding: 20px 50px 5px 50px;"), + transition_duration=200, + transition_type=dock_config["transition"], + ) + + self.children = EventBox( + events=["enter-notify", "leave-notify"], + child=Box(style="min-height: 1px", children=self.revealer), + on_enter_notify_event=lambda *_: self.on_hover_enter(), + on_leave_notify_event=lambda *_: self.on_hover_leave(), + ) + + self.revealer.set_reveal_child(True) + self.app_bar.add_style_class("shown") + + self.dock_height = 100 + self.is_hovered = False + self.hide_ticket = 0 + + # Only setup occlusion monitoring if auto-hide is enabled + if data.DOCK_AUTO_HIDE: + self.setup_occlusion_monitoring() + + def on_hover_enter(self): + self.is_hovered = True + self.hide_ticket = random.getrandbits(32) + self.revealer.set_reveal_child(True) + self.app_bar.add_style_class("shown") + + def on_hover_leave(self): + self.is_hovered = False + self.hide_ticket = random.getrandbits(32) + + def delayed_hide(ticket): + if ticket == self.hide_ticket and not self.is_hovered: + # Only hide if we aren't occluded (or if occlusion logic says so) + # For now, just trigger the basic hide if auto-hide is on + if data.DOCK_AUTO_HIDE: + # Check if actually occluded before hiding if DOCK_ALWAYS_OCCLUDED is false + occlusion_pos = self._get_dock_config()["occlusion"] + is_occluded = data.DOCK_ALWAYS_OCCLUDED or check_occlusion( + (occlusion_pos, self.dock_height) + ) + if is_occluded: + self.revealer.set_reveal_child(False) + self.app_bar.remove_style_class("shown") + return False + + GLib.timeout_add(500, delayed_hide, self.hide_ticket) + + def _get_dock_config(self): + config_map = { + "Left": { + "anchor": "left center", + "transition": "slide-right", + "occlusion": "left", + }, + "Right": { + "anchor": "right center", + "transition": "slide-left", + "occlusion": "right", + }, + "Bottom": { + "anchor": "bottom center", + "transition": "slide-up", + "occlusion": "bottom", + }, + } + return config_map.get(data.DOCK_POSITION, config_map["Bottom"]) + + def setup_occlusion_monitoring(self): + def check_dock_occlusion(): + try: + if data.DOCK_ALWAYS_OCCLUDED: + is_occluded = True + else: + occlusion_pos = self._get_dock_config()["occlusion"] + is_occluded = check_occlusion((occlusion_pos, self.dock_height)) + + if ( + is_occluded + and not self.is_hovered + and self.revealer.get_reveal_child() + ): + self.revealer.set_reveal_child(False) + self.app_bar.remove_style_class("shown") + elif not is_occluded and not self.revealer.get_reveal_child(): + self.revealer.set_reveal_child(True) + self.app_bar.add_style_class("shown") + elif is_occluded and self.is_hovered: + if not self.revealer.get_reveal_child(): + self.revealer.set_reveal_child(True) + self.app_bar.add_style_class("shown") + except Exception as e: + logger.error(f"[Dock] Occlusion check error: {e}") + + return True + + GLib.timeout_add(300, check_dock_occlusion) diff --git a/src/window/launcher/__init__.py b/src/window/launcher/__init__.py new file mode 100644 index 0000000..547fa73 --- /dev/null +++ b/src/window/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/src/window/launcher/main.py b/src/window/launcher/main.py new file mode 100644 index 0000000..918d0ea --- /dev/null +++ b/src/window/launcher/main.py @@ -0,0 +1,1380 @@ +from typing import List, Optional, Tuple + +from fabric.core.service import Property +from fabric.utils import Gdk, GLib +from fabric.widgets.box import Box +from fabric.widgets.entry import Entry +from fabric.widgets.scrolledwindow import ScrolledWindow +from fabric.widgets.wayland import WaylandWindow as Window + +from window.launcher.plugin_manager import PluginManager +from window.launcher.result import Result +from window.launcher.result_item import ResultItem +from window.launcher.trigger_config import TriggerConfig + +# Constants +SEARCH_DEBOUNCE_MS = 50 +TRIGGER_SEARCH_DEBOUNCE_MS = 50 +CURSOR_POSITION_DELAY_MS = 10 +SCROLL_PADDING = 10 +DEFAULT_ITEM_HEIGHT = 68 +PAGE_NAVIGATION_STEP = 5 +LAUNCHER_WIDTH = 640 +LAUNCHER_HEIGHT = 400 + + +class Launcher(Window): + """ + Main launcher window with search functionality and plugin system. + Spotlight-style interface. + """ + + # Properties + query = Property(str, flags="read-write", default_value="") + visible = Property(bool, flags="read-write", default_value=False) + active_trigger = Property(str, flags="read-write", default_value="") + + def __init__(self, **kwargs): + super().__init__( + name="launcher-window", + title="modus-launcher", + layer="top", + anchor="center", + exclusivity="none", + keyboard_mode="exclusive", + visible=False, # Start hidden until explicitly shown + **kwargs, + ) + self._initializing = True + self._auto_adding_space = False + self._processing_backspace = False + self.plugin_manager = PluginManager() + self.trigger_config = TriggerConfig() + + self.results: List[Result] = [] + self.selected_index = 0 + self.max_results = 5 # Show 4 applications by default instead of list + + # Trigger system + self.triggered_plugin = None # Currently active triggered plugin + self.active_trigger = "" # Currently active trigger keyword + self.query = "" # Current search query + self.visible = False # Launcher visibility state + self.opened_with_trigger = False # Whether launcher was opened with a trigger + + # Focus management + self.focus_mode = "search" # "search", "results" + + # Mouse activity tracking + self._mouse_active = False + self._last_mouse_activity = 0 + self._mouse_activity_timeout = 1000 # 1 second timeout + self._keyboard_used_recently = False + self._last_keyboard_activity = 0 + self._keyboard_activity_timeout = 2000 # 2 second timeout + self._launcher_just_opened = False + self._mouse_interaction_delay = 500 # 500ms delay after opening + + # Setup UI + main_box = Box( + name="launcher", + orientation="v", + spacing=0, + h_align="center", + v_align="center", + ) + self.add(main_box) + + # Search entry with Spotlight-style large text + self.search_entry = Entry( + name="launcher-search", + placeholder="Spotlight Search", + h_expand=True, + h_align="fill", + notify_text=lambda entry, *_: self._on_search_changed(entry), + ) + self.search_entry.connect("changed", self._on_search_changed) + self.search_entry.connect("activate", self._on_entry_activate) + + self.header_box = Box( + name="header_box", + spacing=10, + orientation="h", + children=[ + self.search_entry, + ], + ) + + main_box.add(self.header_box) + + self.results_scroll = ScrolledWindow( + name="launcher-results-scroll", + h_scrollbar_policy="never", + min_content_size=(LAUNCHER_WIDTH, LAUNCHER_HEIGHT), + max_content_size=(LAUNCHER_WIDTH, LAUNCHER_HEIGHT), + propagate_width=False, + propagate_height=False, + ) + + self.results_box = Box( + name="launcher-results", + orientation="v", + spacing=0, + ) + self.results_scroll.add(self.results_box) + main_box.add(self.results_scroll) + + # Keep the results container visible initially + + self.connect("key-press-event", self._on_key_press) + + # Connect mouse events to track activity + self.results_scroll.connect("button-press-event", self._on_mouse_activity) + self.results_scroll.connect("motion-notify-event", self._on_mouse_activity) + self.results_scroll.connect("scroll-event", self._on_mouse_activity) + + # Also track mouse activity on the main window for better coverage + self.connect("button-press-event", self._on_mouse_activity) + self.connect("motion-notify-event", self._on_mouse_activity) + + self.hide() + + # Mark initialization as complete + self._initializing = False + + # Hide trigger suggestions at startup + self._clear_results() + + def show_launcher(self, trigger_keyword: str = None, external: bool = False): + """Show the launcher and focus the search entry, or execute command externally. + + Args: + trigger_keyword: Optional trigger keyword to activate immediately (e.g., "google", "calc", "app") + external: If True, execute the command without showing the launcher UI + """ + # Reset mouse activity tracking when launcher is shown + self._mouse_active = False + self._last_mouse_activity = 0 + self._keyboard_used_recently = False + self._last_keyboard_activity = 0 + self._launcher_just_opened = True + + # Schedule to allow mouse interactions after a delay + def enable_mouse_interactions(): + self._launcher_just_opened = False + return False + + GLib.timeout_add(self._mouse_interaction_delay, enable_mouse_interactions) + + if external and trigger_keyword: + # Execute command externally without showing launcher + return self._execute_external_command(trigger_keyword) + + self.show_all() + + if trigger_keyword: + # Set flag to track that launcher was opened with a trigger keyword + self.opened_with_trigger = True + + # Set the trigger keyword with a space and activate trigger mode + trigger_text = f"{trigger_keyword} " + self.search_entry.set_text(trigger_text) + + # Detect and activate the trigger + triggered_plugin, detected_trigger = self._detect_trigger(trigger_text) + if triggered_plugin: + self.triggered_plugin = triggered_plugin + self.active_trigger = detected_trigger + + # Query the plugin with empty string to show default options + try: + results = triggered_plugin.query("") + self.results = results + self.selected_index = 0 + self._update_results_display() + except Exception as e: + print( + f"Error querying triggered plugin {triggered_plugin.name}: {e}" + ) + self._clear_results() + else: + # Trigger not found, clear and show error or fallback + self.search_entry.set_text("") + self._clear_results() + else: + # Normal launcher opening - show applications + self.opened_with_trigger = False + self.search_entry.set_text("") + # Trigger initial search to show applications immediately + self._perform_search("") + + # Reset focus mode to search + self.focus_mode = "search" + + # Focus search entry without selecting text + if trigger_keyword: + # For trigger keywords, we want the cursor at the end + self.search_entry.grab_focus() + + def position_cursor(): + if hasattr(self.search_entry, "set_position"): + self.search_entry.set_position(-1) # Move caret to end + return False # Only run once + + GLib.idle_add(position_cursor) + else: + # For normal opening, use our method that prevents text selection + self._focus_search_entry_without_selection() + + self.visible = True + + def _position_cursor_at_end(self, text_length: Optional[int] = None) -> None: + """Position cursor at the end of search entry text.""" + if text_length is None: + text_length = len(self.search_entry.get_text()) + + def position_cursor(): + if hasattr(self.search_entry, "set_position"): + self.search_entry.set_position(-1) # Move caret to end + if hasattr(self.search_entry, "select_region"): + self.search_entry.select_region( + text_length, text_length + ) # No selection + return False # Only run once + + GLib.idle_add(position_cursor) + + def _add_space_to_trigger(self, trigger_word: str) -> None: + """Add space after trigger keyword and position cursor.""" + trigger_text_with_space = f"{trigger_word} " + + # Temporarily disable search change handling to prevent recursion + self._auto_adding_space = True + self.search_entry.set_text(trigger_text_with_space) + + # Position cursor at the end + def position_cursor(): + if hasattr(self.search_entry, "set_position"): + self.search_entry.set_position(-1) # Move caret to end + if hasattr(self.search_entry, "select_region"): + self.search_entry.select_region( + len(trigger_text_with_space), len(trigger_text_with_space) + ) # No selection + self._auto_adding_space = False + return False # Only run once + + GLib.idle_add(position_cursor) + + # Update query + self.query = trigger_text_with_space + return trigger_text_with_space + + def close_launcher(self): + """Hide the launcher and clear search.""" + self.hide() + self.search_entry.set_text("") + self._clear_results() + self.triggered_plugin = None + self.active_trigger = "" + self.visible = False + self.opened_with_trigger = False + + def _on_search_changed(self, entry): + """Handle search text changes.""" + # Skip if still initializing + if getattr(self, "_initializing", True): + return + + # Skip if we're automatically adding a space to prevent recursion + if getattr(self, "_auto_adding_space", False): + return + + # Skip if we're processing a backspace to prevent interference + if getattr(self, "_processing_backspace", False): + return + + # Reset focus to search when user types + if self.focus_mode != "search": + self.focus_mode = "search" + + query = entry.get_text().strip() + self.query = query + + # If query is exactly ':', show all triggers + if query == ":": + self._show_available_triggers() + # If query matches a trigger exactly (with or without space), handle trigger activation + elif any( + query == trig or query == f"{trig} " + for trig in [ + t.strip() + for p in self.plugin_manager.get_active_plugins() + for t in p.get_triggers() + ] + ): + # Check if we need to add space immediately for exact trigger matches + if not query.endswith(" "): + # This is an exact trigger match without space - add space immediately + trigger_text_with_space = self._add_space_to_trigger(query) + GLib.timeout_add( + TRIGGER_SEARCH_DEBOUNCE_MS, + self._perform_search, + trigger_text_with_space, + ) + else: + # Already has space, proceed with normal search + GLib.timeout_add(SEARCH_DEBOUNCE_MS, self._perform_search, query) + elif query: + # Debounce search to avoid too many queries + GLib.timeout_add(SEARCH_DEBOUNCE_MS, self._perform_search, query) + else: + # Show applications when query is empty + self._perform_search("") + + def _perform_search(self, query: str) -> bool: + """Perform search across all plugins.""" + # Only search if query hasn't changed + if query != self.query: + return False + + if not query: + # Empty query - show popular applications + self.triggered_plugin = None + self.active_trigger = "" + + # Get applications plugin and show popular apps + applications_plugin = self._get_applications_plugin() + if applications_plugin: + try: + # Query with empty string to get popular/frequently used applications + all_results = applications_plugin.query("") + # Limit to max_results for empty query + self.results = all_results[: self.max_results] + self.selected_index = 0 + self._update_results_display() + except Exception as e: + print(f"Error getting applications: {e}") + self._clear_results() + else: + self._clear_results() + return False + + # Check if we're already in trigger mode + if self.triggered_plugin and self.active_trigger: + # We're in trigger mode - search within the triggered plugin + try: + # Extract the search query after the trigger + remaining_query = self._extract_query_after_trigger( + query, self.active_trigger + ) + all_results = self.triggered_plugin.query(remaining_query) + except Exception as e: + print(f"Error in triggered plugin {self.triggered_plugin.name}: {e}") + all_results = [] + else: + # Check for trigger activation + triggered_plugin, trigger = self._detect_trigger(query) + + if triggered_plugin: + # New trigger detected - check if we need to add space automatically + trigger_word = trigger.strip() + current_text = self.search_entry.get_text() + + # If the current text is exactly the trigger word (no space), add space automatically + if ( + current_text.strip() == trigger_word + and not current_text.endswith(" ") + and not getattr(self, "_auto_adding_space", False) + ): + query = self._add_space_to_trigger(trigger_word) + + # Enter trigger mode + self.triggered_plugin = triggered_plugin + self.active_trigger = trigger + + # Extract search query after trigger + remaining_query = self._extract_query_after_trigger(query, trigger) + + # Always call the plugin's query method, even with empty remaining query + # This allows plugins to show default options when just the trigger is typed + try: + all_results = triggered_plugin.query(remaining_query) + except Exception as e: + print(f"Error in triggered plugin {triggered_plugin.name}: {e}") + all_results = [] + else: + # No trigger detected - search applications and show trigger suggestions + self.triggered_plugin = None + self.active_trigger = "" + + all_results = [] + + # Search applications directly without trigger + applications_plugin = self._get_applications_plugin() + if applications_plugin: + try: + app_results = applications_plugin.query(query) + all_results.extend(app_results) + except Exception as e: + print(f"Error searching applications: {e}") + + # Also show trigger suggestions if query matches trigger prefixes + trigger_suggestions = self._get_trigger_suggestions(query) + all_results.extend(trigger_suggestions) + + # Sort results by relevance score + all_results.sort(key=lambda r: r.relevance, reverse=True) + + # Check if any results have bypass_max_results flag + has_bypass = any( + hasattr(r, "data") and r.data and r.data.get("bypass_max_results") + for r in all_results + ) + + # Don't limit results for triggered plugin queries, only for global searches and trigger suggestions + if self.triggered_plugin and self.active_trigger: + # In trigger mode - show all results from the triggered plugin + self.results = all_results + elif not has_bypass: + # Global search - check if it's an application search + applications_plugin = self._get_applications_plugin() + is_application_search = applications_plugin and any( + hasattr(r, "plugin_name") and r.plugin_name == "Applications" + for r in all_results + ) + + if is_application_search: + # Don't limit application search results + self.results = all_results + else: + # Apply max_results limit for other global searches and trigger suggestions + self.results = all_results[: self.max_results] + else: + # Has bypass flag - show all results + self.results = all_results + self.selected_index = 0 + + # Update UI + self._update_results_display() + + return False # Don't repeat timeout + + def _extract_query_after_trigger(self, query: str, trigger: str) -> str: + """ + Extract the search query after removing the trigger. + + Args: + query: The full query string + trigger: The trigger keyword + + Returns: + The remaining query after the trigger + """ + if not query or not trigger: + return "" + + query_lower = query.lower() + trigger_lower = trigger.lower() + + # Handle trigger with space (e.g., "app ") + if trigger_lower.endswith(" ") and query_lower.startswith(trigger_lower): + return query[len(trigger) :].strip() + + # Handle trigger without space (e.g., "app") + trigger_word = trigger.strip().lower() + if query_lower.startswith(trigger_word): + # Check if it's followed by space or end of string + if len(query) == len(trigger_word): + return "" + elif len(query) > len(trigger_word) and query[len(trigger_word)] == " ": + return query[len(trigger_word) + 1 :].strip() + elif len(query) > len(trigger_word): + # No space after trigger word, extract rest + return query[len(trigger_word) :].strip() + + return "" + + def _show_available_triggers(self): + """Show available triggers when launcher is first opened.""" + trigger_suggestions = self._get_trigger_suggestions("") + self.results = trigger_suggestions # Show all available triggers without limit + self.selected_index = 0 + self._update_results_display() + + def _detect_trigger(self, query: str) -> Tuple[Optional[object], str]: + """ + Detect if query starts with a trigger keyword. + + Args: + query: The search query + + Returns: + Tuple of (plugin, trigger) if triggered, (None, "") otherwise + """ + if not query.strip(): + return None, "" + + # Check all plugins for triggers + for plugin in self.plugin_manager.get_active_plugins(): + trigger = plugin.get_active_trigger(query) + if trigger: + return plugin, trigger + + return None, "" + + def _get_applications_plugin(self): + """Get the applications plugin instance.""" + for plugin in self.plugin_manager.get_active_plugins(): + if ( + hasattr(plugin, "display_name") + and plugin.display_name == "Applications" + ): + return plugin + return None + + def _get_trigger_suggestions(self, query: str) -> List[Result]: + """ + Get trigger suggestions based on the current query. + + Args: + query: The search query + + Returns: + List of Result objects showing available triggers + """ + suggestions = [] + query_lower = query.lower().strip() + + # Get all available triggers from plugins + all_triggers = {} + for plugin in self.plugin_manager.get_active_plugins(): + triggers = plugin.get_triggers() + for trigger in triggers: + trigger_clean = trigger.strip() + if trigger_clean not in all_triggers: + all_triggers[trigger_clean] = { + "plugin": plugin, + "trigger": trigger, + "examples": [], + } + + # Get max examples to show from configuration + max_examples = self.trigger_config.settings.get("max_examples_shown", 2) + + # Show trigger suggestions based on query + if query_lower: + # Show triggers that match the query + for trigger_clean, _ in all_triggers.items(): + if trigger_clean.lower().startswith(query_lower): + result = self._create_trigger_result(trigger_clean, max_examples) + suggestions.append(result) + else: + # Empty query - show all available triggers + for trigger_clean, _ in all_triggers.items(): + result = self._create_trigger_result(trigger_clean, max_examples) + suggestions.append(result) + + return suggestions # Return all trigger suggestions without limit + + def _create_trigger_result(self, trigger_clean: str, max_examples: int) -> Result: + """Create a Result object for a trigger suggestion.""" + examples = self.trigger_config.get_trigger_examples(trigger_clean) + icon_name = self.trigger_config.get_trigger_icon(trigger_clean) + description = self.trigger_config.get_trigger_description(trigger_clean) + + return Result( + title=f"{trigger_clean}", + subtitle=f"{description} - {', '.join(examples[:max_examples])}", + icon_name=icon_name, + action=lambda t=trigger_clean: self._activate_trigger(t), + # Shorter triggers get higher relevance + relevance=100 - len(trigger_clean), + data={"type": "trigger_suggestion", "trigger": trigger_clean}, + ) + + def _activate_trigger(self, trigger: str): + """ + Activate a trigger by setting it in the search entry. + + Args: + trigger: The trigger keyword to activate + """ + # Set the trigger text in the search entry + trigger_text = f"{trigger} " + self.search_entry.set_text(trigger_text) + self.search_entry.grab_focus() + + def clear_selection(): + if hasattr(self.search_entry, "set_position"): + self.search_entry.set_position(-1) # Move caret to end + if hasattr(self.search_entry, "select_region"): + self.search_entry.select_region( + len(trigger_text), len(trigger_text) + ) # No selection + return False # Only run once + + GLib.idle_add(clear_selection) + + # Manually set the trigger mode to avoid search processing issues + triggered_plugin, detected_trigger = self._detect_trigger(trigger_text) + if triggered_plugin: + self.triggered_plugin = triggered_plugin + self.active_trigger = detected_trigger + + # Clear results and show trigger ready state + self.results = [] + self.selected_index = 0 + self._update_results_display() + + # Focus back to the search entry for immediate typing + self.search_entry.grab_focus() + + # Don't hide the launcher - user should be able to continue typing + + def _execute_external_command(self, command_string: str): + """Execute a command externally without showing the launcher UI. + + Args: + command_string: Full command string (e.g., "wall random", "calc 2+2") + + Returns: + Result of the command execution or None if failed + """ + try: + # Parse the command to extract trigger and query + parts = command_string.strip().split(" ", 1) + if not parts: + return None + + trigger_part = parts[0] + query_part = parts[1] if len(parts) > 1 else "" + + # Find the plugin that handles this trigger + triggered_plugin = None + + for plugin in self.plugin_manager.get_active_plugins(): + trigger = plugin.get_active_trigger(f"{trigger_part} ") + if trigger: + triggered_plugin = plugin + break + + if not triggered_plugin: + print(f"No plugin found for trigger: {trigger_part}") + return None + + # Query the plugin with the remaining query + try: + results = triggered_plugin.query(query_part) + if not results: + print(f"No results found for query: {query_part}") + return None + + # Find the first result that matches the query exactly or has highest relevance + best_result = None + for result in results: + # For exact matches like "random", execute immediately + if ( + hasattr(result, "data") + and result.data + and result.data.get("action") == query_part.strip() + ): + best_result = result + break + # For partial matches, take the first high-relevance result + elif not best_result and result.relevance >= 0.9: + best_result = result + + # If no exact match, take the first result + if not best_result and results: + best_result = results[0] + + if best_result: + # Execute the result action + try: + result_value = best_result.activate() + print(f"External command executed: {command_string}") + return result_value + except Exception as e: + print(f"Error executing result action: {e}") + return None + else: + print(f"No suitable result found for: {command_string}") + return None + + except Exception as e: + print(f"Error querying plugin {triggered_plugin.name}: {e}") + return None + + except Exception as e: + print(f"Error executing external command '{command_string}': {e}") + return None + + def _update_results_display(self): + """Update the results display.""" + # Skip if still initializing or results_box not ready + if getattr(self, "_initializing", True) or not hasattr(self, "results_box"): + return + + # Update input field with trigger indication (Spotlight-style) + self._update_input_action_text() + + # Clear existing results + for child in self.results_box.get_children(): + self.results_box.remove(child) + + # Add new results + for i, result in enumerate(self.results): + # Check if this result has a custom widget + if result.custom_widget: + # Ensure the widget is not already parented + parent = result.custom_widget.get_parent() + if parent: + parent.remove(result.custom_widget) + + result.custom_widget.show_all() # Ensure widget is visible + self.results_box.add(result.custom_widget) + else: + # Create normal result item + result_item = ResultItem( + result=result, selected=(i == self.selected_index), index=i + ) + result_item.clicked.connect( + lambda _, idx=i: self._on_result_clicked(result_item, idx) + ) + result_item.hovered.connect( + lambda _, idx=i: self._on_result_hovered(idx) + ) + self.results_box.add(result_item) + + self.results_box.show_all() + + self.results_scroll.show() + + def _update_input_action_text(self): + """Update the input field with action text (Spotlight-style).""" + # Check if search_entry is initialized + if not hasattr(self, "search_entry") or self.search_entry is None: + return + + # Get the current input text + current_text = self.search_entry.get_text() + + # Spotlight-style placeholder text + if self.triggered_plugin: + if self.results: + # Show more minimal placeholder for triggered mode + self.search_entry.set_placeholder_text( + f"Search {self.active_trigger.strip()}..." + ) + else: + # In trigger mode but no results yet + if current_text == self.active_trigger.strip(): + # Just the trigger keyword + self.search_entry.set_placeholder_text( + f"Search {self.active_trigger.strip()}..." + ) + else: + # Searching within trigger + self.search_entry.set_placeholder_text( + f"Searching {self.active_trigger.strip()}..." + ) + else: + # Not in trigger mode - show Spotlight-style help + if current_text == ":": + self.search_entry.set_placeholder_text("Available search triggers") + else: + self.search_entry.set_placeholder_text("Spotlight Search") + + def _clear_results(self): + """Clear all results.""" + self.results = [] + self.selected_index = 0 + for child in self.results_box.get_children(): + self.results_box.remove(child) + # Keep the results scroll visible even when empty + + def _handle_escape_key(self) -> bool: + """Handle escape key press.""" + # First check if there's a password entry widget that should handle Escape + password_entry_widget = self._find_password_entry_widget() + if password_entry_widget: + # Cancel the password entry + password_entry_widget.cancel_password_entry() + return True + elif self.opened_with_trigger: + # If launcher was opened with a trigger keyword, close directly + self.close_launcher() + return True + else: + # Hide launcher + self.close_launcher() + return True + + def _handle_backspace_key(self) -> bool: + """Handle backspace key press in trigger mode.""" + if self.triggered_plugin: + trigger_text = self.active_trigger.strip() + current_text = self.search_entry.get_text() + + # Set flag to prevent search change handler from interfering + self._processing_backspace = True + + # Allow normal backspace behavior first, then check if we need to exit trigger mode + # Don't intercept the backspace - let GTK handle it normally + + # Schedule a check after the backspace is processed + def check_trigger_after_backspace(): + try: + # Get the text after backspace has been processed + new_text = self.search_entry.get_text() + + # Check if we should exit trigger mode + # We need to check if the text still matches the trigger pattern + should_exit_trigger = False + + # If the active trigger ends with a space (like "calc "), + # we should exit if the text doesn't contain that space anymore + if self.active_trigger.endswith(" "): + # For triggers like "calc ", exit if text is just "calc" or doesn't start with "calc " + trigger_with_space = self.active_trigger.lower() + if not new_text.lower().startswith(trigger_with_space): + should_exit_trigger = True + else: + # For triggers without space, exit if text doesn't start with trigger + if not new_text.lower().startswith(trigger_text.lower()): + should_exit_trigger = True + + if should_exit_trigger: + self.triggered_plugin = None + self.active_trigger = "" + self._clear_results() + # Don't clear the text - let the user's edit stand + + # If we're still in trigger mode but the text changed, update the search + elif self.triggered_plugin and new_text != current_text: + # Trigger a search with the new text + self.query = new_text + GLib.timeout_add(50, self._perform_search, new_text) + + finally: + # Clear the backspace processing flag + self._processing_backspace = False + + return False # Don't repeat + + # Use idle_add to check after the backspace is processed + GLib.idle_add(check_trigger_after_backspace) + + # Allow the normal backspace to proceed + return False + + # Let normal backspace behavior continue for other cases + return False + + def _on_key_press(self, _widget, event): + """Handle key press events.""" + # Track keyboard activity + import time + + current_time = int(time.time() * 1000) + self._last_keyboard_activity = current_time + self._keyboard_used_recently = True + + keyval = event.keyval + + # Escape - handle password entry, exit trigger mode, or hide launcher + if keyval == Gdk.KEY_Escape: + return self._handle_escape_key() + + # Backspace - handle trigger mode backspace behavior + if keyval == Gdk.KEY_BackSpace: + return self._handle_backspace_key() + + # Up/Down - navigate results (alternative to Tab) + if keyval == Gdk.KEY_Up: + if self.focus_mode == "results" and self.results: + if self.selected_index > 0: + # Move to previous result + self.selected_index -= 1 + self._update_selection() + else: + # At first result, go back to search entry + self.focus_mode = "search" + self._focus_search_entry_without_selection() + elif self.results: + # If not in results mode but have results, enter results mode at last item + self.focus_mode = "results" + self.selected_index = len(self.results) - 1 + self._update_selection() + return True + + if keyval == Gdk.KEY_Down: + if self.focus_mode == "results" and self.results: + if self.selected_index < len(self.results) - 1: + # Move to next result + self.selected_index += 1 + self._update_selection() + else: + # At last result, wrap around to first result + self.selected_index = 0 + self._update_selection() + elif self.results: + # If not in results mode but have results, enter results mode at first item + self.focus_mode = "results" + self.selected_index = 0 + self._update_selection() + return True + + # Enter - activate selected result + if keyval == Gdk.KEY_Return: + # Check for Shift+Enter for alternative actions + if event.state & Gdk.ModifierType.SHIFT_MASK: + if self.results and 0 <= self.selected_index < len(self.results): + result = self.results[self.selected_index] + if result.data: + # Check for generic alternative action first + if result.data.get("alt_action"): + result.data["alt_action"]() + return True + # Fallback to pin_action for backward compatibility + elif result.data.get("pin_action"): + result.data["pin_action"]() + return True + + # Check if the selected result has a custom widget with Entry fields + if self.results and 0 <= self.selected_index < len(self.results): + result = self.results[self.selected_index] + if result.custom_widget: + # Check if the custom widget contains Entry widgets that should handle Enter + if self._custom_widget_has_entry(result.custom_widget): + # Let the custom widget handle the Enter key + # Find the focused Entry widget and trigger its activate signal + focused_entry = self._find_focused_entry_in_widget( + result.custom_widget + ) + if focused_entry: + focused_entry.emit("activate") + return True + + # Normal Enter behavior + self._activate_selected() + return True + + # Tab - cycle through focus areas and results + if keyval == Gdk.KEY_Tab: + if event.state & Gdk.ModifierType.SHIFT_MASK: + # Shift+Tab - reverse direction + if self.focus_mode == "results": + # Navigate through results in reverse + self._navigate_results_backward() + else: + self._cycle_focus_backward() + else: + # Tab - forward direction + if self.focus_mode == "results": + # Navigate through results forward + self._navigate_results_forward() + else: + self._cycle_focus_forward() + return True + + # Page Up/Page Down - navigate results faster + if keyval == Gdk.KEY_Page_Up: + if self.results: + self.selected_index = max(0, self.selected_index - PAGE_NAVIGATION_STEP) + self._update_selection() + return True + + if keyval == Gdk.KEY_Page_Down: + if self.results: + self.selected_index = min( + len(self.results) - 1, self.selected_index + PAGE_NAVIGATION_STEP + ) + self._update_selection() + return True + + # Home/End - go to first/last result + if keyval == Gdk.KEY_Home: + if self.results: + self.selected_index = 0 + self._update_selection() + return True + + if keyval == Gdk.KEY_End: + if self.results: + self.selected_index = len(self.results) - 1 + self._update_selection() + return True + + # Forward other keys to custom widgets if they can handle them + if self.results and 0 <= self.selected_index < len(self.results): + result = self.results[self.selected_index] + if result.custom_widget and hasattr(result.custom_widget, "on_key_press"): + # Try to forward the key event to the custom widget + if result.custom_widget.on_key_press(result.custom_widget, event): + return True + + return False + + def _custom_widget_has_entry(self, widget): + """Check if a custom widget contains Entry widgets.""" + + if isinstance(widget, Entry): + return True + + # Check children recursively + if hasattr(widget, "get_children"): + for child in widget.get_children(): + if self._custom_widget_has_entry(child): + return True + + return False + + def _find_focused_entry_in_widget(self, widget): + """Find the focused Entry widget within a custom widget.""" + + if isinstance(widget, Entry): + # Try multiple ways to check if this Entry is focused + try: + if widget.has_focus() or widget.is_focus(): + return widget + # Also check if this is the only Entry in the widget (likely to be the target) + return widget + except Exception: + # If focus checking fails, assume this Entry should handle the event + return widget + + # Check children recursively + if hasattr(widget, "get_children"): + for child in widget.get_children(): + focused_entry = self._find_focused_entry_in_widget(child) + if focused_entry: + return focused_entry + + return None + + def _find_password_entry_widget(self): + """Find a NetworkPasswordEntry widget in the current results.""" + for result in self.results: + if result.custom_widget: + # Check if this is a NetworkPasswordEntry widget + if ( + hasattr(result.custom_widget, "__class__") + and result.custom_widget.__class__.__name__ + == "NetworkPasswordEntry" + ): + return result.custom_widget + # Also check if it has the cancel_password_entry method (duck typing) + elif hasattr(result.custom_widget, "cancel_password_entry"): + return result.custom_widget + return None + + def _on_entry_activate(self, _entry): + """Handle entry activation (Enter key).""" + self._activate_selected() + + def _on_result_clicked(self, _result_item, index): + """Handle result item click.""" + # Only allow clicks when mouse is active or keyboard hasn't been used recently + if self._should_allow_mouse_interaction(): + self.selected_index = index + self._activate_selected() + + def _on_result_hovered(self, index): + """Handle result item hover.""" + # Only allow hover selection when mouse is active and keyboard hasn't been used recently + if self._should_allow_mouse_interaction(): + if 0 <= index < len(self.results): + self.selected_index = index + self.focus_mode = "results" # Switch to results focus mode + self._update_selection_visual_only() + + def _on_mouse_activity(self, widget, event): + """Track mouse activity to determine when mouse interactions should be enabled.""" + import time + + current_time = int(time.time() * 1000) + self._last_mouse_activity = current_time + self._mouse_active = True + + # Schedule a check to disable mouse activity after timeout + def check_mouse_timeout(): + if current_time - self._last_mouse_activity >= self._mouse_activity_timeout: + self._mouse_active = False + return False + + GLib.timeout_add(self._mouse_activity_timeout, check_mouse_timeout) + return False + + def _should_allow_mouse_interaction(self): + """Determine if mouse interactions should be allowed.""" + import time + + current_time = int(time.time() * 1000) + + # Don't allow mouse interactions immediately after launcher opens + if self._launcher_just_opened: + return False + + # Allow mouse interaction if: + # 1. Mouse is currently active (recent activity) + # 2. OR keyboard hasn't been used recently (2 seconds) + mouse_recently_active = ( + current_time - self._last_mouse_activity + ) < self._mouse_activity_timeout + keyboard_not_recently_used = ( + current_time - self._last_keyboard_activity + ) > self._keyboard_activity_timeout + + return mouse_recently_active or keyboard_not_recently_used + + def _is_mouse_over_results(self): + """Check if mouse is over the results area.""" + # Simple check - if we have results visible, assume user might be interacting + return len(self.results) > 0 and self.results_scroll.get_visible() + + def _update_selection(self): + """Update the visual selection of results with scrolling (for keyboard navigation).""" + children = self.results_box.get_children() + selected_widget = None + + for i, child in enumerate(children): + if isinstance(child, ResultItem): + is_selected = i == self.selected_index + child.set_selected(is_selected) + if is_selected: + selected_widget = child + # For custom widgets, we don't need to handle selection visually + # since they manage their own interaction + + # Focus custom widgets when selected for keyboard interaction + if self.results and 0 <= self.selected_index < len(self.results): + result = self.results[self.selected_index] + if result.custom_widget and result.custom_widget.get_can_focus(): + # Give focus to the custom widget for keyboard interaction + result.custom_widget.grab_focus() + + # Scroll to make the selected item visible + if selected_widget and self.results_scroll.get_visible(): + # Use immediate scrolling for better responsiveness + self._scroll_to_widget(selected_widget) + # Also schedule a more accurate scroll after layout is complete + GLib.idle_add(self._ensure_selected_visible) + + def _update_selection_visual_only(self): + """Update the visual selection of results without scrolling (for mouse hover).""" + children = self.results_box.get_children() + + for i, child in enumerate(children): + if isinstance(child, ResultItem): + is_selected = i == self.selected_index + child.set_selected(is_selected) + # For custom widgets, we don't need to handle selection visually + # since they manage their own interaction + + # Focus custom widgets when selected for keyboard interaction + if self.results and 0 <= self.selected_index < len(self.results): + result = self.results[self.selected_index] + if result.custom_widget and result.custom_widget.get_can_focus(): + # Give focus to the custom widget for keyboard interaction + result.custom_widget.grab_focus() + + def _scroll_to_widget(self, widget): + """Scroll the results container to make the widget visible.""" + if not widget or not self.results_scroll.get_visible(): + return + + # Debug output (can be removed in production) + # print(f"Scrolling to selected index: {self.selected_index}") + + # Get the scrolled window's vertical adjustment + vadjustment = self.results_scroll.get_vadjustment() + if not vadjustment: + return + + # Use a simpler approach: scroll to the selected item index + if self.results and 0 <= self.selected_index < len(self.results): + # Get all children to work with actual widgets + children = self.results_box.get_children() + if not children or self.selected_index >= len(children): + return + + # Get the selected child widget + selected_child = children[self.selected_index] + + # Try to get actual allocation, fallback to estimation + try: + allocation = selected_child.get_allocation() + item_height = allocation.height if allocation.height > 0 else 68 + item_y = allocation.y + except Exception: + # 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/src/window/launcher/plugin_base.py b/src/window/launcher/plugin_base.py new file mode 100644 index 0000000..f0898ec --- /dev/null +++ b/src/window/launcher/plugin_base.py @@ -0,0 +1,172 @@ +from abc import ABC, abstractmethod +from typing import List + +from window.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/src/window/launcher/plugin_manager.py b/src/window/launcher/plugin_manager.py new file mode 100644 index 0000000..e84add7 --- /dev/null +++ b/src/window/launcher/plugin_manager.py @@ -0,0 +1,183 @@ +import importlib +import importlib.util +import os +from typing import Dict, List, Type + +from window.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"window.launcher.plugins.{plugin_name}", plugin_path + ) + + if spec and spec.loader: + module = importlib.util.module_from_spec(spec) + + # Add the module to sys.window to support relative imports + import sys + + sys.modules[f"window.launcher.plugins.{plugin_name}"] = module + + spec.loader.exec_module(module) + + # Look for plugin class + for attr_name in dir(module): + attr = getattr(module, attr_name) + if ( + isinstance(attr, type) + and issubclass(attr, PluginBase) + and attr != PluginBase + ): + self.plugin_classes[plugin_name] = attr + break + + except Exception as e: + print(f"Failed to load plugin {plugin_name}: {e}") + + def _activate_default_plugins(self): + """Activate default plugins.""" + default_plugins = [ + "applications", + "calculator", + "system", + "clipboard", + "power", + "caffeine", + "screencapture", + "emoji", + "wallpaper", + "websearch", + "reminders", + "otp", + "password", + "bookmarks", + "tmux", + "colorpicker", + ] + + 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/src/window/launcher/plugins/__init__.py b/src/window/launcher/plugins/__init__.py new file mode 100644 index 0000000..666274b --- /dev/null +++ b/src/window/launcher/plugins/__init__.py @@ -0,0 +1,3 @@ +""" +Built-in plugins for the launcher. +""" diff --git a/src/window/launcher/plugins/applications.py b/src/window/launcher/plugins/applications.py new file mode 100644 index 0000000..fa272a8 --- /dev/null +++ b/src/window/launcher/plugins/applications.py @@ -0,0 +1,184 @@ +import json +import subprocess +from typing import List + +from fabric.utils import DesktopApp, get_desktop_applications, get_relative_path, re + +from utils.roam import modus_service +from window.launcher.plugin_base import PluginBase +from window.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): + pass + + def cleanup(self): + pass + + def _pin_application(self, app): + """Pin an application to the dock.""" + config_path = get_relative_path("../../../../config/dock.json") + try: + with open(config_path, "r") as file: + pinned_apps = json.load(file) + except (FileNotFoundError, json.JSONDecodeError): + pinned_apps = [] + + # Handle legacy format (dict with "pinned_apps" key) and convert to new format (simple list) + if isinstance(pinned_apps, dict): + pinned_apps = pinned_apps.get("pinned_apps", []) + + # Check if app is already pinned (by name/app_id) + app_id = app.name # Use app.name as the identifier + if app_id not in pinned_apps: + pinned_apps.append(app_id) + + # Save the updated list + with open(config_path, "w") as file: + json.dump(pinned_apps, file, indent=4) + + # Notify dock about the change via modus_service + if modus_service: + try: + dock_apps_json = json.dumps(pinned_apps) + modus_service.dock_apps = dock_apps_json + except Exception as e: + print(f"Failed to notify dock about pinned app change: {e}") + + def query(self, query_string: str) -> List[Result]: + """Search applications based on query.""" + if not query_string.strip(): + return self._get_all_applications() + + try: + applications = get_desktop_applications(include_hidden=False) + except Exception as e: + print(f"Failed to load applications: {e}") + applications = [] + + query = query_string.lower().strip() + results = [] + + for app in applications: + relevance = self._calculate_relevance(app, query) + if relevance > 0: + description = app.description or app.generic_name or "" + if len(description) > 80: + description = description[:70] + "..." + + result = Result( + title=app.display_name or app.name, + subtitle=description, + icon=app.get_icon_pixbuf(size=48), + action=lambda a=app: self._launch_application(a), + relevance=relevance, + plugin_name=self.display_name, + data={ + "app": app, + "pin_action": lambda a=app: self._pin_application(a), + }, + ) + results.append(result) + + return results + + def _calculate_relevance(self, app, query: str) -> float: + """Calculate relevance score for an application.""" + if not query: + return 0.0 + + # Get searchable text + name = (app.name or "").lower() + display_name = (app.display_name or "").lower() + description = (app.description or "").lower() + generic_name = (app.generic_name or "").lower() + executable = (app.executable or "").lower() + + # Exact matches get highest score + if query == name or query == display_name: + return 1.0 + + # Starts with matches get high score + if ( + name.startswith(query) + or display_name.startswith(query) + or generic_name.startswith(query) + ): + return 0.9 + + # Contains matches get medium score + if ( + query in name + or query in display_name + or query in description + or query in generic_name + ): + return 0.7 + + # Fuzzy matching for partial matches + if self._fuzzy_match(query, name) or self._fuzzy_match(query, display_name): + return 0.5 + + # Executable name matching + if executable and query in executable: + return 0.4 + + return 0.0 + + def _fuzzy_match(self, query: str, text: str) -> bool: + """Simple fuzzy matching algorithm.""" + if not query or not text: + return False + + # Create regex pattern for fuzzy matching + pattern = ".*".join(re.escape(char) for char in query) + return bool(re.search(pattern, text, re.IGNORECASE)) + + def _launch_application(self, app: DesktopApp): + + # Remove ALL % codes (e.g., %u, %U, %f, %F, %i, %c, etc.) + cleaned_command = re.sub(r"%\w+", "", app.command_line).strip() + + # Final command with hyprctl dispatch + final_command = f"hyprctl dispatch exec 'uwsm app -- {cleaned_command}'" + subprocess.Popen(final_command, shell=True) + + # app.launch() + + def _get_all_applications(self) -> List[Result]: + """Get a list of all available applications.""" + try: + applications = get_desktop_applications(include_hidden=False) + except Exception as e: + print(f"Failed to load applications: {e}") + return [] + + results = [] + + for app in applications: + # Truncate description + description = app.description or app.generic_name or "" + if len(description) > 80: + description = description[:70] + "..." + + result = Result( + title=app.display_name or app.name, + subtitle=description, + icon=app.get_icon_pixbuf(size=48), + action=lambda a=app: self._launch_application(a), + relevance=0.5, # Default relevance for all apps + plugin_name=self.display_name, + data={ + "app": app, + "pin_action": lambda a=app: self._pin_application(a), + }, + ) + results.append(result) + + return results diff --git a/src/window/launcher/plugins/bookmarks.py b/src/window/launcher/plugins/bookmarks.py new file mode 100644 index 0000000..417b924 --- /dev/null +++ b/src/window/launcher/plugins/bookmarks.py @@ -0,0 +1,784 @@ +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 import GLib, get_relative_path +from thefuzz import fuzz + +from window.launcher.plugin_base import PluginBase +from window.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 Exception: + 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 Exception: + 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/bookmarks.json") + ) + self.bookmark_manager = BookmarkManager(self.bookmark_file) + self.max_results = 15 + + # Cache for results + self._results_cache = {} + self._cache_timestamps = {} + self._cache_ttl = 30 # 30 seconds + + # Launcher instance for refreshing + self._launcher_instance = None + self._original_close_launcher = None + + def initialize(self): + """Initialize the bookmarks plugin.""" + self.set_triggers(["bm"]) + self._setup_launcher_hooks() + + def cleanup(self): + """Cleanup the bookmarks plugin.""" + self._results_cache.clear() + self._cache_timestamps.clear() + self._cleanup_launcher_hooks() + + def query(self, query_string: str) -> List[Result]: + """Process bookmark queries with caching.""" + query_key = query_string.strip() + current_time = time.time() + + # Check cache first (except for add/remove commands which should always execute) + if ( + not query_key.startswith(("add ", "remove ", "delete ", "rm ")) + and query_key in self._results_cache + and (current_time - self._cache_timestamps.get(query_key, 0)) + < self._cache_ttl + ): + return self._results_cache[query_key] + + query = query_key.lower() + results = [] + + if not query: + # Show recent/popular bookmarks when no query + results = self._get_recent_bookmarks() + elif query.startswith("add "): + # Add new bookmark (don't cache) + results = self._handle_add_command(query[4:].strip()) + elif query.startswith(("remove ", "delete ", "rm ")): + # Remove bookmark (don't cache) + command_parts = query_key.split(" ", 1) + if len(command_parts) > 1: + results = self._handle_remove_command(command_parts[1].strip()) + else: + results = self._show_remove_help() + else: + # Search bookmarks + results = self._search_bookmarks(query) + + # Cache results (except for add/remove commands) + if not query.startswith(("add ", "remove ", "delete ", "rm ")): + self._results_cache[query_key] = results + self._cache_timestamps[query_key] = current_time + + return results + + def _search_bookmarks(self, query: str) -> List[Result]: + """Search through bookmarks.""" + bookmarks = self.bookmark_manager.get_bookmarks() + + if not bookmarks: + return [ + Result( + title="No Bookmarks Found", + subtitle="Use 'add <url>' to add first bookmark", + icon_name="info", + action=lambda: None, + relevance=1.0, + plugin_name=self.display_name, + data={"type": "info", "keep_launcher_open": True}, + ) + ] + + # Search bookmarks + results = [] + for bookmark in bookmarks: + relevance = self._calculate_relevance(bookmark, query) + if relevance > 0.3: # Only show relevant results + result = self._create_bookmark_result(bookmark, relevance) + if result: + results.append(result) + + # Sort by relevance and limit results + results.sort(key=lambda r: r.relevance, reverse=True) + return results[: self.max_results] + + def _get_recent_bookmarks(self) -> List[Result]: + """Get recent/popular bookmarks when no query is provided.""" + bookmarks = self.bookmark_manager.get_bookmarks() + + if not bookmarks: + return [ + Result( + title="No Bookmarks Yet", + subtitle="Use 'add <title> <url>' to add first bookmark", + icon_name="info", + action=lambda: None, + relevance=1.0, + plugin_name=self.display_name, + data={"type": "help", "keep_launcher_open": True}, + ), + Result( + title="Example: Add Google", + subtitle="add Google https://google.com", + icon_name="info", + action=lambda: None, + relevance=0.9, + plugin_name=self.display_name, + data={"type": "example", "keep_launcher_open": True}, + ), + ] + + # Sort by access time (most recent first) and show top 10 + sorted_bookmarks = sorted( + bookmarks, key=lambda b: b.get("accessed", 0), reverse=True + ) + results = [] + for bookmark in sorted_bookmarks[:10]: + result = self._create_bookmark_result(bookmark, 0.8) + if result: + results.append(result) + + return results + + def _handle_add_command(self, args: str) -> List[Result]: + """Handle add bookmark command.""" + if not args: + return [ + Result( + title="Add Bookmark", + subtitle="Usage: add <title> <url> [description]", + icon_name="info", + action=lambda: None, + relevance=1.0, + plugin_name=self.display_name, + data={"type": "help", "keep_launcher_open": True}, + ), + Result( + title="Example", + subtitle="add Google https://google.com Search engine", + icon_name="info", + action=lambda: None, + relevance=0.9, + plugin_name=self.display_name, + data={"type": "example", "keep_launcher_open": True}, + ), + ] + + # Parse arguments: title url [description] + parts = args.split() + if len(parts) < 2: + return [ + Result( + title="Invalid Format", + subtitle="Usage: add <title> <url> [description]", + icon_name="alert", + action=lambda: None, + relevance=1.0, + plugin_name=self.display_name, + data={"type": "error", "keep_launcher_open": True}, + ) + ] + + title = parts[0] + url = parts[1] + description = " ".join(parts[2:]) if len(parts) > 2 else "" + + # Check if bookmark already exists + normalized_url = self.bookmark_manager._normalize_url(url) + existing_bookmarks = self.bookmark_manager.get_bookmarks() + already_exists = any( + bookmark["url"] == normalized_url for bookmark in existing_bookmarks + ) + + if already_exists: + # Truncate URL for display to prevent launcher resize + display_url = normalized_url + if len(display_url) > 35: + display_url = display_url[:32] + "..." + + return [ + Result( + title="Bookmark Already Exists", + subtitle=f"URL '{display_url}' already exists", + icon_name="alert", + action=lambda: None, + relevance=1.0, + plugin_name=self.display_name, + data={"type": "error", "keep_launcher_open": True}, + ) + ] + + # Show add action - will execute on Enter + domain = self.bookmark_manager._extract_domain(normalized_url) + + # Truncate domain if too long + if len(domain) > 25: + domain = domain[:22] + "..." + + subtitle = f"Click to add: {domain}" + if description: + # Truncate description to prevent launcher resize + max_desc_len = 35 - len(domain) # Account for domain + separator + if len(description) > max_desc_len: + description = description[: max_desc_len - 3] + "..." + subtitle += f" • {description}" + + # Truncate title for display + display_title = title + if len(display_title) > 25: + display_title = display_title[:22] + "..." + + return [ + Result( + title=f"Add bookmark '{display_title}'", + subtitle=subtitle, + icon_name="plus", + action=lambda: self._add_bookmark_action(title, url, description), + relevance=1.0, + plugin_name=self.display_name, + data={"type": "add", "name": title, "keep_launcher_open": True}, + ) + ] + + def _handle_remove_command(self, identifier: str) -> List[Result]: + """Handle remove bookmark command.""" + if not identifier: + return self._show_remove_help() + + # Find matching bookmarks + bookmarks = self.bookmark_manager.get_bookmarks() + identifier_lower = identifier.lower().strip() + + matching_bookmarks = [] + for bookmark in bookmarks: + if ( + bookmark["title"].lower() == identifier_lower + or bookmark["url"].lower() == identifier_lower + or self.bookmark_manager._extract_domain(bookmark["url"]).lower() + == identifier_lower + ): + matching_bookmarks.append(bookmark) + + if not matching_bookmarks: + return [ + Result( + title="Bookmark Not Found", + subtitle=f"No bookmark found matching '{identifier}'", + icon_name="alert", + action=lambda: None, + relevance=1.0, + plugin_name=self.display_name, + data={"type": "error", "keep_launcher_open": True}, + ) + ] + + # Show remove action - will execute on Enter + bookmark = matching_bookmarks[0] # Take first match + title = bookmark.get("title", "Untitled") + domain = self.bookmark_manager._extract_domain(bookmark.get("url", "")) + + # Truncate title and domain for display + display_title = title + if len(display_title) > 25: + display_title = display_title[:22] + "..." + + display_domain = domain + if len(display_domain) > 30: + display_domain = display_domain[:27] + "..." + + return [ + Result( + title=f"Remove '{display_title}'?", + subtitle=f"Click to confirm: {display_domain}", + icon_name="trash", + action=lambda: self._remove_bookmark_action(identifier), + relevance=1.0, + plugin_name=self.display_name, + data={"type": "remove", "name": title, "keep_launcher_open": True}, + ) + ] + + def _show_remove_help(self) -> List[Result]: + """Show help for remove command.""" + bookmarks = self.bookmark_manager.get_bookmarks() + results = [ + Result( + title="Remove Bookmark", + subtitle="Usage: remove <title|url|domain>", + icon_name="info", + action=lambda: None, + relevance=1.0, + plugin_name=self.display_name, + data={"type": "help", "keep_launcher_open": True}, + ) + ] + + # Show available bookmarks to remove + if bookmarks: + results.append( + Result( + title="Available Bookmarks:", + subtitle=f"{len(bookmarks)} bookmarks available to remove", + icon_name="bookmarks-organize", + action=lambda: None, + relevance=0.9, + plugin_name=self.display_name, + data={"type": "info", "keep_launcher_open": True}, + ) + ) + + # Show first few bookmarks as examples + for bookmark in bookmarks[:3]: + title = bookmark.get("title", "Untitled") + domain = self._extract_domain(bookmark.get("url", "")) + + # Truncate title and domain for consistent display + display_title = title + if len(display_title) > 20: + display_title = display_title[:17] + "..." + + display_domain = domain + if len(display_domain) > 20: + display_domain = display_domain[:17] + "..." + + results.append( + Result( + title=f"remove {display_title}", + subtitle=f"Click to remove: {display_title} ({display_domain})", + icon_name="trash", + action=lambda t=title: self._remove_bookmark_action(t), + relevance=0.8, + plugin_name=self.display_name, + data={ + "type": "remove_option", + "bookmark": bookmark, + "keep_launcher_open": True, + }, + ) + ) + + return results + + def _add_bookmark_action(self, title: str, url: str, description: str = ""): + """Execute the add bookmark action.""" + success = self.bookmark_manager.add_bookmark(title, url, description) + if success: + print(f"✓ Added bookmark '{title}' - {url}") + # Clear cache to force refresh + self._results_cache.clear() + self._cache_timestamps.clear() + # Reset to trigger word and refresh + self._reset_to_trigger() + else: + print(f"✗ Failed to add bookmark '{title}' - already exists") + + def _remove_bookmark_action(self, identifier: str): + """Execute the remove bookmark action.""" + success = self.bookmark_manager.remove_bookmark(identifier) + if success: + print(f"✓ Removed bookmark '{identifier}'") + # Clear cache to force refresh + self._results_cache.clear() + self._cache_timestamps.clear() + # Reset to trigger word and refresh + self._reset_to_trigger() + else: + print(f"✗ Failed to remove bookmark '{identifier}' - not found") + + def _remove_bookmark_with_reset(self, identifier: str): + """Execute the remove bookmark action via alt_action (Shift+Enter) and reset to trigger.""" + success = self.bookmark_manager.remove_bookmark(identifier) + if success: + print(f"✓ Removed bookmark '{identifier}'") + # Clear cache to force refresh + self._results_cache.clear() + self._cache_timestamps.clear() + # Reset to trigger word and refresh + self._reset_to_trigger() + else: + print(f"✗ Failed to remove bookmark '{identifier}' - not found") + + def _calculate_relevance(self, bookmark: Dict, query: str) -> float: + """Calculate relevance score for a bookmark.""" + title = bookmark.get("title", "").lower() + url = bookmark.get("url", "").lower() + description = bookmark.get("description", "").lower() + + # Exact title match + if query == title: + return 1.0 + + # Title starts with query + if title.startswith(query): + return 0.95 + + # Query in title + if query in title: + position = title.index(query) + position_score = 1.0 - (position / len(title)) + return 0.8 + (position_score * 0.1) + + # Query in URL + if query in url: + return 0.7 + + # Query in description + if query in description: + return 0.6 + + # Fuzzy matching for title + if len(query) >= 3: + fuzzy_score = fuzz.partial_ratio(query, title) / 100.0 + if fuzzy_score >= 0.7: + return fuzzy_score * 0.6 + + return 0.0 + + def _create_bookmark_result( + self, bookmark: Dict, relevance: float + ) -> Optional[Result]: + """Create a Result object for a bookmark.""" + try: + title = bookmark.get("title", "Untitled") + url = bookmark.get("url", "") + description = bookmark.get("description", "") + + # Truncate long titles to prevent launcher resize + if len(title) > 45: + title = title[:42] + "..." + + # Create subtitle with domain and description + domain = self._extract_domain(url) + + # Truncate domain if too long + if len(domain) > 30: + domain = domain[:27] + "..." + + if description: + # Truncate description to prevent launcher resize + # Account for domain + separator + max_desc_len = 50 - len(domain) + if len(description) > max_desc_len: + description = description[: max_desc_len - 3] + "..." + subtitle = f"{domain} • {description}" + else: + subtitle = domain + + # Final subtitle length check to ensure consistent launcher size + if len(subtitle) > 60: + subtitle = subtitle[:57] + "..." + + return Result( + title=title, + subtitle=subtitle, + icon_name="bookmark-organize", + action=lambda u=url: self._open_bookmark(u), + relevance=relevance, + plugin_name=self.display_name, + data={ + "type": "bookmark", + "url": url, + "domain": domain, + "description": description, + "keep_launcher_open": False, + "alt_action": lambda t=title: self._remove_bookmark_with_reset(t), + }, + ) + except Exception as e: + print(f"Error creating bookmark result: {e}") + return None + + def _extract_domain(self, url: str) -> str: + """Extract domain from URL.""" + try: + parsed = urlparse(url) + domain = parsed.netloc + # Remove www. prefix + if domain.startswith("www."): + domain = domain[4:] + return domain + except Exception: + 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: + + 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: + + 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/src/window/launcher/plugins/caffeine.py b/src/window/launcher/plugins/caffeine.py new file mode 100644 index 0000000..0df855a --- /dev/null +++ b/src/window/launcher/plugins/caffeine.py @@ -0,0 +1,204 @@ +import subprocess +from threading import Timer +from typing import List + +from fabric.utils import exec_shell_command_async, get_relative_path + +import shared.data as data +from window.launcher.plugin_base import PluginBase +from window.launcher.result import Result + + +class CaffeinePlugin(PluginBase): + """ + Plugin for preventing system idle using the inhibit script. + """ + + def __init__(self): + super().__init__() + self.display_name = "Caffeine" + self.description = "Prevent system from going idle" + self.inhibit_script = get_relative_path("../../../utils/inhibit.py") + + # Predefined durations + self.durations = { + "30m": "30 minutes", + "1h": "1 hour", + "2h": "2 hours", + "4h": "4 hours", + "8h": "8 hours", + "on": "On", + "off": "Off", + } + + def initialize(self): + """Initialize the caffeine plugin.""" + self.set_triggers(["caffeine"]) + + def cleanup(self): + """Cleanup the caffeine plugin.""" + pass + + def _create_inhibit_action(self, duration: str): + """Create an inhibit action that properly captures the duration.""" + + def action(): + try: + # Run the script in the background without waiting + subprocess.Popen( + ["python3", self.inhibit_script, duration], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, # Run in a new session to prevent hanging + ) + + # Send notification based on duration + if duration.lower() == "off": + # Deactivation notification + exec_shell_command_async( + f"notify-send '☕ Caffeine' 'Deactivated' -a '{ + data.APP_NAME_CAP + }' -e" + ) + else: + # Activation notification with duration + duration_text = self.durations.get(duration, duration) + exec_shell_command_async( + f"notify-send '☕ Caffeine' 'Activated for { + duration_text + }' -a '{data.APP_NAME_CAP}' -e" + ) + + # Schedule expiration notification for timed durations + if duration.lower() not in ["on", "off"]: + self._schedule_expiration_notification(duration, duration_text) + + except Exception as e: + print(f"Error starting inhibit script: {e}") + + return action + + def _parse_duration_to_seconds(self, duration_str: str) -> int: + """Parse duration string into seconds. Same logic as inhibit.py""" + try: + if duration_str.lower() in ["on", "off"]: + return 0 + elif duration_str.endswith("h"): + return int(float(duration_str[:-1]) * 3600) + elif duration_str.endswith("m"): + return int(float(duration_str[:-1]) * 60) + elif duration_str.endswith("s"): + return int(float(duration_str[:-1])) + else: + return int(duration_str) + except ValueError: + return 0 + + def _schedule_expiration_notification(self, duration: str, duration_text: str): + """Schedule a notification for when the caffeine effect expires.""" + seconds = self._parse_duration_to_seconds(duration) + if seconds > 0: + + def send_expiration_notification(): + exec_shell_command_async( + f"notify-send '☕ Caffeine' 'Expired after {duration_text}' -a '{ + data.APP_NAME_CAP + }' -e" + ) + + # Schedule the notification + timer = Timer(seconds, send_expiration_notification) + timer.daemon = True # Don't prevent program exit + timer.start() + + def _is_valid_duration(self, query: str) -> bool: + """Check if the query is a valid duration format.""" + if query in self.durations: + return True + if query.isdigit(): + return True + if query.endswith(("h", "m", "s")) and query[:-1].replace(".", "").isdigit(): + return True + return False + + def _get_default_action(self, query: str): + """Get the default action for a direct duration input.""" + if self._is_valid_duration(query): + return self._create_inhibit_action(query) + return None + + def query(self, query_string: str) -> List[Result]: + """Search caffeine durations based on query.""" + # For empty queries, show all available durations + if not query_string.strip(): + query_string = "" # Will match all durations in the loop below + + query = query_string.lower().strip() + results = [] + + # Handle direct search entry (e.g., "caffeine 30m" or just "30m") + if query.startswith("caffeine "): + query = query[9:].strip() # Remove "caffeine " prefix + # If query becomes empty after removing prefix, show all durations + if not query: + query = "" # Will match all durations in the loop below + elif query == "caffeine": + # Handle just "caffeine" without space - show all durations + query = "" + elif not query: + # Handle empty query - show all durations + pass + elif ( + query in self.durations + or query.isdigit() + or ( + query.endswith(("h", "m", "s")) + and query[:-1].replace(".", "").isdigit() + ) + ): + # If query is a valid duration without prefix, use it directly + pass + else: + return [] + + # Add custom duration option + if query.isdigit() or ( + query.endswith(("h", "m", "s")) and query[:-1].replace(".", "").isdigit() + ): + result = Result( + title=f"Custom: {query}", + subtitle="Set custom duration", + icon_name="caffeine", + action=self._create_inhibit_action(query), + relevance=1.0, + plugin_name=self.display_name, + data={"duration": query}, + ) + results.append(result) + + # Add predefined durations + for duration, description in self.durations.items(): + # If query is empty, show all durations; otherwise filter by query + if not query or query in duration or query in description.lower(): + # Special handling for "off" - it should stop inhibition + if duration.lower() == "off": + subtitle = "Stop idle inhibition" + else: + subtitle = f"Prevent idle for {description.lower()}" + + result = Result( + title=description, + subtitle=subtitle, + icon_name="caffeine", + action=self._create_inhibit_action(duration), + relevance=0.9 if query == duration else 0.7, + plugin_name=self.display_name, + data={"duration": duration}, + ) + results.append(result) + + # Set default action for direct duration input + if results: + results[0].default_action = self._get_default_action(query) + + return results diff --git a/src/window/launcher/plugins/calculator.py b/src/window/launcher/plugins/calculator.py new file mode 100644 index 0000000..566e0c7 --- /dev/null +++ b/src/window/launcher/plugins/calculator.py @@ -0,0 +1,163 @@ +import math +import re +import subprocess +import time +from typing import List + +from utils.conversion import Conversion +from window.launcher.plugin_base import PluginBase +from window.launcher.result import Result + + +class CalculatorPlugin(PluginBase): + """ + Plugin for calculating mathematical expressions and converting units. + """ + + def __init__(self): + super().__init__() + self.display_name = "Calculator" + self.description = "Evaluate mathematical expressions and convert units" + + # Safe functions for evaluation + self.safe_functions = { + "abs": abs, + "round": round, + "min": min, + "max": max, + "sum": sum, + "pow": pow, + "sqrt": math.sqrt, + "sin": math.sin, + "cos": math.cos, + "tan": math.tan, + "asin": math.asin, + "acos": math.acos, + "atan": math.atan, + "log": math.log, + "log10": math.log10, + "exp": math.exp, + "pi": math.pi, + "e": math.e, + } + + # Initialize conversion utility + self.converter = Conversion() + + # Pre-compiled regex patterns + self.expression_pattern = re.compile(r"[\d+\-*/^()=]") + self.number_pattern = re.compile(r"\d") + self.conversion_pattern = re.compile( + r"(\d+(?:\.\d+)?)\s*([a-zA-Z]+)\s*(?:to|in|=)\s*([a-zA-Z]+)" + ) + + # Cache for conversion results + self._conversion_cache = {} + self._last_cache_cleanup = time.time() + self._cache_cleanup_interval = 300 # 5 minutes + + def initialize(self): + """Initialize the files plugin.""" + self.set_triggers(["="]) + + def _cleanup_cache(self): + """Clean up old cache entries.""" + current_time = time.time() + if current_time - self._last_cache_cleanup > self._cache_cleanup_interval: + self._conversion_cache.clear() + self._last_cache_cleanup = current_time + + def query(self, query: str) -> List[Result]: + """Process a query and return results.""" + if not query: + return [] + + # Clean up cache periodically + self._cleanup_cache() + + # Check if it's a conversion query + conversion_match = self.conversion_pattern.match(query) + if conversion_match: + try: + value, from_unit, to_unit = conversion_match.groups() + value = float(value) + + # Check cache first + cache_key = f"{value}_{from_unit}_{to_unit}" + if cache_key in self._conversion_cache: + result = self._conversion_cache[cache_key] + subtitle = f"{value} {from_unit} = {result:.6g} {to_unit}" + else: + # Use the conversion utility + result = self.converter.convert(value, from_unit, to_unit) + # Cache the result + self._conversion_cache[cache_key] = result + subtitle = f"{value} {from_unit} = {result:.6g} {to_unit}" + + return [ + Result( + title=f"{result:.6g} {to_unit}", + subtitle=subtitle, + icon_name="calculator-symbolic", + action=lambda r=f"{result:.6g}": self._copy_to_clipboard(r), + relevance=1.0, + plugin_name=self.display_name, + data={"from": (value, from_unit), "to": (result, to_unit)}, + ) + ] + except ValueError as e: + return [ + Result( + title="Invalid conversion", + subtitle=str(e), + icon_name="calculator-symbolic", + relevance=0.0, + plugin_name=self.display_name, + ) + ] + + # Check if it's a math expression + if self.expression_pattern.search(query): + try: + # Evaluate the expression + result = eval(query, {"__builtins__": {}}, self.safe_functions) + if isinstance(result, (int, float)): + return [ + Result( + title=f"{result:.6g}", + subtitle=f"{query} = {result:.6g}", + icon_name="calculator-symbolic", + action=lambda r=f"{result:.6g}": self._copy_to_clipboard(r), + relevance=1.0, + plugin_name=self.display_name, + ) + ] + except Exception: + pass + + return [] + + def _format_cache_age(self, age_seconds: float) -> str: + """Format cache age for display.""" + if age_seconds < 60: + return f"{int(age_seconds)}s ago" + elif age_seconds < 3600: + return f"{int(age_seconds // 60)}m ago" + else: + return f"{int(age_seconds // 3600)}h ago" + + def _copy_to_clipboard(self, text: str): + """Copy text to clipboard using cliphist.""" + try: + # First copy to clipboard + subprocess.run(["wl-copy"], input=text.encode(), check=True) + # Then store in cliphist + subprocess.run(["cliphist", "store"], input=text.encode(), check=True) + except subprocess.CalledProcessError: + # If cliphist fails, at least we have the text in clipboard + pass + + def cleanup(self): + """Clean up resources.""" + self._conversion_cache.clear() + self.converter.cleanup() diff --git a/src/window/launcher/plugins/clipboard.py b/src/window/launcher/plugins/clipboard.py new file mode 100644 index 0000000..7db592f --- /dev/null +++ b/src/window/launcher/plugins/clipboard.py @@ -0,0 +1,435 @@ +import subprocess +import sys +import tempfile +import threading +from concurrent.futures import ThreadPoolExecutor +from typing import Dict, List, Optional + +from fabric.utils import GdkPixbuf, GLib, os, time + +from window.launcher.plugin_base import PluginBase +from window.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: + + 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/src/window/launcher/plugins/colorpicker.py b/src/window/launcher/plugins/colorpicker.py new file mode 100644 index 0000000..424ad9c --- /dev/null +++ b/src/window/launcher/plugins/colorpicker.py @@ -0,0 +1,146 @@ +import os +import re +import subprocess +from typing import List + +from fabric.utils import exec_shell_command, exec_shell_command_async, logger +from shared.data import APP_NAME +from window.launcher.plugin_base import PluginBase +from window.launcher.result import Result + + +class ColorPickerPlugin(PluginBase): + """ + Plugin for picking colors from the screen using hyprpicker. + """ + + def __init__(self): + super().__init__() + self.display_name = "Color Picker" + self.description = "Pick colors from the screen (HEX, RGB, HSV)" + self.tmp_icon_path = "/tmp/color.png" + + self.options = [ + {"name": "Pick HEX", "arg": "hex", "icon": "color-select-symbolic"}, + {"name": "Pick RGB", "arg": "rgb", "icon": "color-select-symbolic"}, + {"name": "Pick HSV", "arg": "hsv", "icon": "color-select-symbolic"}, + ] + + def initialize(self): + """Initialize the color picker plugin.""" + self.set_triggers(["color", "cp"]) + + def cleanup(self): + """Cleanup resources.""" + if os.path.exists(self.tmp_icon_path): + try: + os.remove(self.tmp_icon_path) + except OSError: + pass + + def query(self, query_string: str) -> List[Result]: + """Process a search query and return results.""" + q = query_string.strip().lower() + + results = [] + + # Filter options based on query + filtered = ( + self.options + if not q + else [o for o in self.options if q in o["name"].lower() or q == o["arg"]] + ) + + for option in filtered: + mode = option["arg"] + results.append( + Result( + title=f"Color Picker: {option['name']}", + subtitle=f"Pick and copy {mode.upper()} color to clipboard", + icon_name=option["icon"], + action=lambda m=mode: self._execute_pick(m), + relevance=1.0, + plugin_name=self.display_name, + ) + ) + + return results + + def _execute_pick(self, mode: str): + """Run hyprpicker, process output, copy to clipboard and notify.""" + try: + # Run hyprpicker + # -n: do not print a newline + # -f: format + raw = exec_shell_command(f"hyprpicker -n -f {mode}") + text = raw if isinstance(raw, str) else "" + + color = self._sanitize_color_output(mode, text) + if not color: + logger.warning( + f"[ColorPicker] Failed to parse color from output: {text}" + ) + return + + # Copy to clipboard + self._copy_to_clipboard(color) + + # Generate preview and notify + self._send_notification(mode, color) + + except Exception as e: + logger.error(f"[ColorPicker] Error during pick: {e}") + + def _sanitize_color_output(self, mode: str, text: str) -> str: + """Extract color value from hyprpicker output.""" + joined = " ".join(ln.strip() for ln in text.splitlines() if ln.strip()) + + if mode == "hex": + matches = re.findall(r"#?[0-9A-Fa-f]{6,8}", joined) + if not matches: + return "" + val = matches[-1] + return val if val.startswith("#") else f"#{val}" + + if mode in ("rgb", "hsv"): + # Matches "r, g, b" or "h, s, v" + matches = re.findall( + r"\d+(?:\.\d+)?\s*,\s*\d+(?:\.\d+)?\s*,\s*\d+(?:\.\d+)?", joined + ) + return matches[-1] if matches else "" + + return "" + + def _copy_to_clipboard(self, text: str): + """Copy text to clipboard using wl-copy and cliphist.""" + try: + subprocess.run(["wl-copy"], input=text.encode(), check=True) + subprocess.run(["cliphist", "store"], input=text.encode(), check=True) + except subprocess.CalledProcessError: + pass + + def _send_notification(self, mode: str, color: str): + """Create a color preview icon and send a notification.""" + # CSS-like color specification for ImageMagick + color_spec = ( + color + if mode == "hex" + else (f"rgb({color})" if mode == "rgb" else f"hsv({color})") + ) + + # Cleanup existing tmp icon + if os.path.exists(self.tmp_icon_path): + try: + os.remove(self.tmp_icon_path) + except OSError: + pass + + # Use magick to create a 64x64 color block + # Then send notification and cleanup + cmd = ( + f"magick -size 64x64 xc:'{color_spec}' {self.tmp_icon_path} >/dev/null 2>&1; " + f"notify-send 'Color Picked' '{mode.upper()}: {color}' -i {self.tmp_icon_path} -a '{APP_NAME}' -e; " + f"rm -f {self.tmp_icon_path}" + ) + + exec_shell_command_async(["bash", "-lc", cmd]) diff --git a/src/window/launcher/plugins/emoji.py b/src/window/launcher/plugins/emoji.py new file mode 100644 index 0000000..9b0fa8b --- /dev/null +++ b/src/window/launcher/plugins/emoji.py @@ -0,0 +1,291 @@ +import json +import subprocess +from collections import OrderedDict +from typing import Dict, List + +from fabric.utils import os, time, GLib, logger + +import shared.data as data +from window.launcher.plugin_base import PluginBase +from window.launcher.result import Result + + +class EmojiPlugin(PluginBase): + """ + Plugin for searching and copying emojis. + """ + + def __init__(self): + super().__init__() + self.name = "emoji" + self.display_name = "Emoji" + self.description = "Search and copy emojis" + self.emoji_data = {} + + # Language detection (default to en) + self.lang = os.getenv("LANG", "en").split("_")[0].split(".")[0] + if not self.lang: + self.lang = "en" + + # Paths + self.state_dir = os.path.join(GLib.get_user_state_dir(), data.APP_NAME) + self.emoji_path = os.path.join(self.state_dir, f"emojis_{self.lang}.json") + + # Use cache directory for recent emojis + self.recent_emoji_path = os.path.join(data.CACHE_DIR, "recent_emoji.json") + self.recent_emojis = OrderedDict() + self.max_recent_emojis = 20 + self._is_downloading = False + + def initialize(self): + """Initialize the emoji plugin.""" + self.set_triggers(["em"]) + self._load_recent_emojis() + + # Check if local data exists, if not trigger download + if not os.path.exists(self.emoji_path): + self._download_emoji_data() + else: + self._load_emoji_data() + + def cleanup(self): + """Cleanup the emoji plugin.""" + pass + + def _download_emoji_data(self, force=False): + """Download emoji annotations from CLDR repository.""" + if self._is_downloading and not force: + return + + os.makedirs(self.state_dir, exist_ok=True) + url = ( + "https://raw.githubusercontent.com/unicode-org/cldr-json/main/" + f"cldr-json/cldr-annotations-full/annotations/{self.lang}/annotations.json" + ) + + self._is_downloading = True + + def on_done(*args): + self._is_downloading = False + if os.path.exists(self.emoji_path): + self._load_emoji_data() + logger.info(f"[Emoji] Data updated for language: {self.lang}") + else: + logger.error(f"[Emoji] Download failed for URL: {url}") + + logger.info(f"[Emoji] Starting download: {url}") + from fabric.utils import exec_shell_command_async + + exec_shell_command_async(f"curl -L '{url}' -o '{self.emoji_path}'", on_done) + + def _load_emoji_data(self): + """Load emoji data from JSON file and parse CLDR structure.""" + try: + if os.path.exists(self.emoji_path): + with open(self.emoji_path, "r", encoding="utf-8") as f: + raw_data = json.load(f) + self.emoji_data = self._parse_cldr_json(raw_data) + else: + logger.warning(f"[Emoji] Local data missing: {self.emoji_path}") + except Exception as e: + logger.error(f"[Emoji] Error loading emoji data: {e}") + + def _parse_cldr_json(self, raw_data: Dict) -> Dict: + """Parse CLDR annotations JSON into a flatter format for searching.""" + try: + # Structure matches individual file: annotations -> annotations + annotations_container = raw_data.get("annotations", {}) + raw_annotations = annotations_container.get("annotations", {}) + + if not raw_annotations: + # Fallback to the nested structure just in case it's a full bundle + main = raw_data.get("main", {}) + lang_data = main.get(self.lang, main.get("en", {})) + annotations_container = lang_data.get("annotations", {}) + raw_annotations = annotations_container.get("annotations", {}) + + parsed = {} + for emoji, info in raw_annotations.items(): + # Extract TTL (name) + tts = info.get("tts", [""]) + name = tts[0] if isinstance(tts, list) else tts + + # Extract keywords + keywords = info.get("default", []) + if isinstance(keywords, str): + keywords = [keywords] + + parsed[emoji] = { + "name": name, + "keywords": keywords, + } + return parsed + except Exception as e: + logger.error(f"[Emoji] CLDR parsing error: {e}") + return {} + + def _load_recent_emojis(self): + """Load recently used emojis from JSON file.""" + try: + if os.path.exists(self.recent_emoji_path): + with open(self.recent_emoji_path, "r", encoding="utf-8") as f: + recent_data = json.load(f) + # Convert to OrderedDict to maintain order + self.recent_emojis = OrderedDict(recent_data) + else: + # Create empty recent emojis file + self.recent_emojis = OrderedDict() + self._save_recent_emojis() + except Exception as e: + print(f"Error loading recent emoji data: {e}") + self.recent_emojis = OrderedDict() + + def _save_recent_emojis(self): + """Save recently used emojis to JSON file.""" + try: + # Ensure the cache directory exists + os.makedirs(data.CACHE_DIR, exist_ok=True) + + with open(self.recent_emoji_path, "w", encoding="utf-8") as f: + json.dump(dict(self.recent_emojis), f, ensure_ascii=False, indent=2) + except Exception as e: + print(f"Error saving recent emoji data: {e}") + + def _add_to_recent(self, emoji: str): + """Add an emoji to the recent list.""" + # Remove if already exists (to move it to front) + if emoji in self.recent_emojis: + del self.recent_emojis[emoji] + + # Add to front with current timestamp + self.recent_emojis[emoji] = time.time() + + # Keep only the most recent emojis + while len(self.recent_emojis) > self.max_recent_emojis: + # Remove the oldest item + self.recent_emojis.popitem(last=False) + + # Save to file + self._save_recent_emojis() + + def _copy_to_clipboard(self, emoji: str): + """Copy emoji to clipboard and track usage.""" + try: + # Try Wayland first + try: + subprocess.run(["wl-copy"], input=emoji.encode(), check=True) + except subprocess.SubprocessError: + # Fall back to X11 + subprocess.run( + ["xclip", "-selection", "clipboard"], + input=emoji.encode(), + check=True, + ) + + # Track this emoji as recently used + self._add_to_recent(emoji) + + except Exception as e: + print(f"Failed to copy to clipboard: {e}") + + def query(self, query_string: str) -> List[Result]: + """Search emojis based on query.""" + results = [] + query = query_string.lower().strip() + + # Check if we need to download/reload data + if not self.emoji_data and not self._is_downloading: + if not os.path.exists(self.emoji_path): + self._download_emoji_data() + else: + self._load_emoji_data() + + # Handle update command + if query == "updatejson": + self._download_emoji_data(force=True) + return [ + Result( + title="Updating Emoji Database...", + subtitle=f"Downloading latest annotations for {self.lang}", + icon_name="view-refresh-symbolic", + relevance=1.0, + plugin_name=self.display_name, + ) + ] + + # If data is not yet loaded and not downloading, try loading it + if not self.emoji_data and not self._is_downloading: + self._load_emoji_data() + + # Show status if downloading + if self._is_downloading: + results.append( + Result( + title="Downloading Emoji Data...", + subtitle="Results will appear once finished", + icon_name="view-refresh-symbolic", + relevance=0.1, + plugin_name=self.display_name, + ) + ) + + # If no query, show recently used emojis + if not query: + if self.recent_emojis: + for emoji in reversed(list(self.recent_emojis.keys())): + if emoji in self.emoji_data: + info = self.emoji_data[emoji] + results.append(self._create_emoji_result(emoji, info, 1.0)) + return results + + # Search by name, keywords, or the emoji itself + for emoji, info in self.emoji_data.items(): + relevance = 0 + name = info.get("name", "").lower() + keywords = info.get("keywords", []) + + # Exact match with emoji + if query == emoji: + relevance = 1.0 + # Exact match with name + elif query == name: + relevance = 0.95 + # Starts with name + elif name.startswith(query): + relevance = 0.9 + # Contains name + elif query in name: + relevance = 0.8 + # In keywords + elif any(query == k.lower() for k in keywords): + relevance = 0.7 + elif any(query in k.lower() for k in keywords): + relevance = 0.6 + + if relevance > 0: + results.append(self._create_emoji_result(emoji, info, relevance)) + + # Sort by relevance and limit + results.sort(key=lambda x: x.relevance, reverse=True) + return results[:20] + + def _create_emoji_result(self, emoji: str, info: Dict, relevance: float) -> Result: + """Create a Result object for an emoji.""" + name = info.get("name", "").capitalize() + keywords = ", ".join(info.get("keywords", [])[:3]) + + is_recent = emoji in self.recent_emojis + subtitle = f"{keywords}" + (" • Recent" if is_recent else "") + + # Use larger font size for emojis to make them visible + icon_markup = f"<span size='xx-large'>{emoji}</span>" + + return Result( + title=name, + subtitle=subtitle, + icon_markup=icon_markup, + action=lambda e=emoji: self._copy_to_clipboard(e), + relevance=relevance, + plugin_name=self.display_name, + data={"emoji": emoji, "name": name, "recent": is_recent}, + ) diff --git a/src/window/launcher/plugins/otp.py b/src/window/launcher/plugins/otp.py new file mode 100644 index 0000000..bf859a4 --- /dev/null +++ b/src/window/launcher/plugins/otp.py @@ -0,0 +1,844 @@ +import json +import subprocess +import threading +from pathlib import Path +from typing import Dict, List, Optional + +from fabric.utils import GLib, get_relative_path, time + +from services.auth import ( + generate_totp, + get_time_remaining_with_blink, + parse_otpauth_uri, + scan_qr_and_add_account, + validate_base32_secret, +) +from window.launcher.plugin_base import PluginBase +from window.launcher.result import Result + + +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/accounts.json")) + self.secrets: Dict[str, Dict] = {} + self.last_update = 0 + + # Threading for auto-refresh + self.refresh_thread = None + self.stop_refresh = threading.Event() + + def initialize(self): + """Initialize the OTP plugin.""" + self.set_triggers(["otp"]) + self._load_secrets() + self._ensure_config_file() + self._start_refresh_thread() + + def cleanup(self): + """Cleanup the OTP plugin.""" + if self.refresh_thread and self.refresh_thread.is_alive(): + self.stop_refresh.set() + self.refresh_thread.join(timeout=1) + + def _load_secrets(self): + """Load secrets from JSON file.""" + try: + if self.secrets_file.exists(): + with open(self.secrets_file, "r", encoding="utf-8") as f: + self.secrets = json.load(f) + else: + self.secrets = {} + except Exception as e: + print(f"Error loading OTP secrets: {e}") + self.secrets = {} + + def _save_secrets(self): + """Save secrets to JSON file.""" + try: + self.secrets_file.parent.mkdir(parents=True, exist_ok=True) + with open(self.secrets_file, "w", encoding="utf-8") as f: + json.dump(self.secrets, f, indent=2) + except Exception as e: + print(f"Error saving OTP secrets: {e}") + + def _ensure_config_file(self): + """Ensure the config file exists.""" + if not self.secrets_file.exists(): + self.secrets_file.parent.mkdir(parents=True, exist_ok=True) + with open(self.secrets_file, "w", encoding="utf-8") as f: + json.dump({}, f, indent=2) + + def _start_refresh_thread(self): + """Start background thread for auto-refreshing tokens.""" + + def refresh_loop(): + while not self.stop_refresh.wait(5): + current_time = time.time() + if current_time - self.last_update >= 5: + self.last_update = current_time + # Only refresh if we have secrets and launcher is likely active + if self.secrets: + try: + self._selective_force_refresh() + except Exception: + pass + + self.refresh_thread = threading.Thread(target=refresh_loop, daemon=True) + self.refresh_thread.start() + + def _selective_force_refresh(self): + """Update time display in existing OTP result items.""" + try: + import gc + + 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: + # Use a small delay to ensure the action completes first + def trigger_refresh(): + try: + # Try to access the launcher through the fabric Application + from fabric import Application + + app = Application.get_default() + + if app and hasattr(app, "launcher"): + launcher = app.launcher + if launcher and hasattr(launcher, "search_entry"): + # Clear the search entry and set it to just "otp " + launcher.search_entry.set_text("otp ") + # Position cursor at the end + launcher.search_entry.set_position(-1) + # Trigger the search to show default OTP view + if hasattr(launcher, "_perform_search"): + launcher._perform_search("otp ") + return False + + # Fallback: try to find launcher instance through other means + import gc + + for obj in gc.get_objects(): + if ( + hasattr(obj, "__class__") + and obj.__class__.__name__ == "Launcher" + ): + if hasattr(obj, "search_entry") and hasattr( + obj, "_perform_search" + ): + obj.search_entry.set_text("otp ") + obj.search_entry.set_position(-1) + obj._perform_search("otp ") + return False + + except Exception as e: + print(f"Error forcing launcher refresh: {e}") + + return False # Don't repeat + + # Use a small delay to ensure the action completes first + GLib.timeout_add(50, trigger_refresh) + + except Exception as e: + print(f"Could not trigger refresh: {e}") + + def _remove_account_and_refresh(self, account_name: str): + """Remove an account and trigger refresh to return to default OTP view.""" + try: + if account_name in self.secrets: + # Remove the account + del self.secrets[account_name] + self._save_secrets() + + # Trigger refresh to return to default OTP view + self._trigger_refresh() + except Exception as e: + print(f"Error removing account {account_name}: {e}") + + def _generate_totp(self, secret: str) -> Optional[str]: + """Generate TOTP code from secret.""" + return generate_totp(secret) + + def _get_time_remaining_with_blink(self) -> str: + """Get time remaining with blinking effect.""" + return get_time_remaining_with_blink() + + def query(self, query_string: str) -> List[Result]: + """Process OTP queries.""" + query = query_string.strip() + + if not query: + return self._list_otp_codes() + + query_lower = query.lower() + if query_lower.startswith("add "): + add_content = query[4:].strip() + + # Handle both formats: with ``` and without ``` + if "```" in add_content: + # Old format: add account```secret``` + parts = add_content.split("```", 1) + if len(parts) == 2: + account_name = parts[0].strip() + secret_or_uri = parts[1].strip() + return self._handle_direct_add(account_name, secret_or_uri) + elif " " in add_content: + # New format: add account secret + parts = add_content.split(" ", 1) + if len(parts) == 2: + account_name = parts[0].strip() + secret_or_uri = parts[1].strip() + return self._handle_direct_add(account_name, secret_or_uri) + + return self._handle_add_command(add_content) + elif query_lower == "remove" or query_lower.startswith("remove "): + # Handle both "remove" and "remove accountname" + if query_lower == "remove": + remove_content = "" + else: + remove_content = query[7:].strip() + return self._handle_remove_command(remove_content) + elif query_lower == "qr" or query_lower.startswith("qr "): + # Handle QR scanning command + if query_lower == "qr": + qr_content = "" + else: + qr_content = query[3:].strip() + return self._handle_qr_command(qr_content) + else: + return self._search_accounts(query) + + def _handle_direct_add(self, account_name: str, secret_or_uri: str) -> List[Result]: + """Handle direct addition of OTP account.""" + if not account_name or not secret_or_uri: + return [ + Result( + title="Invalid format", + subtitle="Usage: add <account_name> <secret> or add <account_name>```<secret>```", + icon_name="info", + action=lambda: None, + relevance=1.0, + plugin_name=self.display_name, + data={"type": "help", "keep_launcher_open": True}, + ) + ] + + try: + if secret_or_uri.startswith("otpauth://"): + return self._handle_otpauth_uri(account_name, secret_or_uri) + else: + return self._handle_base32_secret(account_name, secret_or_uri) + except Exception as e: + print(f"OTP Debug: Error in _handle_direct_add: {e}") + return [ + Result( + title="Error adding account", + subtitle=f"Debug: {str(e)}", + icon_name="cancel", + action=lambda: None, + relevance=0.5, + plugin_name=self.display_name, + data={"type": "error", "keep_launcher_open": True}, + ) + ] + + def _list_otp_codes(self) -> List[Result]: + """List all OTP codes with current tokens.""" + results = [] + + if not self.secrets: + results.append( + Result( + title="No OTP accounts configured", + subtitle="Use 'add <account> <secret>' to add your first account", + icon_name="gtk-authentication-symbolic", + action=lambda: None, + relevance=1.0, + plugin_name=self.display_name, + data={"type": "empty", "keep_launcher_open": True}, + ) + ) + results.append( + Result( + title="Available commands:", + subtitle="add <account> <secret> | remove <account> | qr <account>", + icon_name="info", + action=lambda: None, + relevance=0.9, + plugin_name=self.display_name, + data={"type": "help", "keep_launcher_open": True}, + ) + ) + return results + + time_display = self._get_time_remaining_with_blink() + + for account_name, account_data in self.secrets.items(): + secret = account_data.get("secret", "") + issuer = account_data.get("issuer", "") + totp_code = self._generate_totp(secret) + + if totp_code: + display_name = f"{issuer} - {account_name}" if issuer else account_name + results.append( + Result( + title=f"{totp_code}", + subtitle_markup=f"{display_name} • { + time_display + } remaining • Shift+Enter: remove", + icon_name="gtk-authentication-symbolic", + action=lambda code=totp_code: self._copy_to_clipboard(code), + relevance=1.0, + plugin_name=self.display_name, + data={ + "type": "totp", + "account": account_name, + "code": totp_code, + "alt_action": lambda acc=account_name: ( + self._remove_account_and_refresh(acc) + ), + }, + ) + ) + else: + results.append( + Result( + title=f"Error: {account_name}", + subtitle="Invalid secret or configuration", + icon_name="dialog-cancel-symbolic", + action=lambda: None, + relevance=0.5, + plugin_name=self.display_name, + data={ + "type": "error", + "account": account_name, + "keep_launcher_open": True, + }, + ) + ) + + return results + + def _search_accounts(self, query: str) -> List[Result]: + """Search accounts by name or issuer.""" + results = [] + query_lower = query.lower() + + for account_name, account_data in self.secrets.items(): + issuer = account_data.get("issuer", "").lower() + account_lower = account_name.lower() + + if query_lower in account_lower or query_lower in issuer: + secret = account_data.get("secret", "") + totp_code = self._generate_totp(secret) + + if totp_code: + display_name = ( + f"{account_data.get('issuer', '')} - {account_name}" + if account_data.get("issuer") + else account_name + ) + time_display = self._get_time_remaining_with_blink() + + results.append( + Result( + title=f"{totp_code}", + subtitle_markup=f"{display_name} • { + time_display + } remaining • Shift+Enter: remove", + icon_name="gtk-authentication-symbolic", + action=lambda code=totp_code: self._copy_to_clipboard(code), + relevance=1.0, + plugin_name=self.display_name, + data={ + "type": "totp", + "account": account_name, + "code": totp_code, + "alt_action": lambda acc=account_name: ( + self._remove_account_and_refresh(acc) + ), + }, + ) + ) + + if not results: + results.append( + Result( + title=f"No accounts found for '{query}'", + subtitle="Use 'add <account> <secret>' to create new account", + icon_name="edit-find-symbolic", + action=lambda: None, + relevance=0.5, + plugin_name=self.display_name, + data={"type": "no_results", "keep_launcher_open": True}, + ) + ) + results.append( + Result( + title="Available commands:", + subtitle="add <account> <secret> | remove <account> | qr <account>", + icon_name="info", + action=lambda: None, + relevance=0.4, + plugin_name=self.display_name, + data={"type": "help", "keep_launcher_open": True}, + ) + ) + + return results + + def _handle_add_command(self, account_name: str) -> List[Result]: + """Handle manual addition of OTP secret.""" + if not account_name: + return [ + Result( + title="Enter account name", + subtitle="Usage: add <account_name> <secret>", + icon_name="dialog-question-symbolic", + action=lambda: None, + relevance=1.0, + plugin_name=self.display_name, + data={"type": "help", "keep_launcher_open": True}, + ) + ] + + return [ + Result( + title=f"To add '{account_name}':", + subtitle=f"Type: add {account_name} <secret>", + icon_name="info", + action=lambda: None, + relevance=1.0, + plugin_name=self.display_name, + data={ + "type": "instruction", + "account": account_name, + "keep_launcher_open": True, + }, + ), + Result( + title="Base32 Secret Format:", + subtitle="Example: add gmail JBSWY3DPEHPK3PXP", + icon_name="info", + action=lambda: None, + relevance=0.9, + plugin_name=self.display_name, + data={"type": "help", "keep_launcher_open": True}, + ), + Result( + title="otpauth URI Format:", + subtitle="Example: add github otpauth://totp/GitHub:user?secret=JBSWY3DPEHPK3PXP", + icon_name="info", + action=lambda: None, + relevance=0.9, + plugin_name=self.display_name, + data={"type": "help", "keep_launcher_open": True}, + ), + Result( + title="Remove Account:", + subtitle="Example: remove gmail", + icon_name="trash", + action=lambda: None, + relevance=0.8, + plugin_name=self.display_name, + data={"type": "help", "keep_launcher_open": True}, + ), + ] + + def _handle_qr_command(self, account_name: str) -> List[Result]: + """Handle QR scanning command.""" + if not account_name: + return [ + Result( + title="Scan QR Code", + subtitle="Click to scan QR code from screen", + icon_name="view-barcode-qr-symbolic", + action=lambda: self._scan_qr_and_add_account(""), + relevance=1.0, + plugin_name=self.display_name, + data={"type": "qr_scan"}, + ), + Result( + title="QR Scan Instructions:", + subtitle="Use 'qr <account_name>' to specify account name", + icon_name="info", + action=lambda: None, + relevance=0.9, + plugin_name=self.display_name, + data={"type": "help", "keep_launcher_open": True}, + ), + ] + else: + return [ + Result( + title=f"Scan QR Code for '{account_name}'", + subtitle="Click to scan QR code from screen", + icon_name="view-barcode-qr-symbolic", + action=lambda name=account_name: self._scan_qr_and_add_account( + name + ), + relevance=1.0, + plugin_name=self.display_name, + data={"type": "qr_scan"}, + ), + ] + + def _scan_qr_and_add_account(self, account_name: str): + """Scan QR code and add OTP account.""" + print(f"QR scan action called for account: '{account_name}'") + print("Starting QR scan process asynchronously...") + + # Run QR scanning asynchronously so launcher closes immediately + import threading + + thread = threading.Thread(target=self._scan_qr_async, args=(account_name,)) + thread.daemon = True + thread.start() + + def _scan_qr_async(self, account_name: str): + """Async QR scanning process.""" + result = scan_qr_and_add_account(account_name, str(self.secrets_file)) + + if result["success"]: + print(result["message"]) + # Reload secrets from file + self._load_secrets() + # Trigger refresh to show the new account + self._trigger_refresh() + else: + print(f"QR scan failed: {result['error']}") + + def _handle_remove_command(self, account_name: str) -> List[Result]: + """Handle removal of OTP account.""" + if not account_name: + # Show all available accounts for removal + results = [] + + if not self.secrets: + results.append( + Result( + title="No OTP accounts to remove", + subtitle="Use 'add <account> <secret>' to add accounts first", + icon_name="info", + action=lambda: None, + relevance=1.0, + plugin_name=self.display_name, + data={"type": "empty", "keep_launcher_open": True}, + ) + ) + else: + results.append( + Result( + title="Select account to remove:", + subtitle="Type: remove <account_name> to remove an account", + icon_name="user-trash-symbolic", + action=lambda: None, + relevance=1.0, + plugin_name=self.display_name, + data={"type": "help", "keep_launcher_open": True}, + ) + ) + + # Get time display for consistency with main OTP view + time_display = self._get_time_remaining_with_blink() + + # Show all accounts with their current OTP codes and remove actions + for acc_name, account_data in self.secrets.items(): + secret = account_data.get("secret", "") + issuer = account_data.get("issuer", "") + display_name = f"{issuer} - {acc_name}" if issuer else acc_name + + # Generate current TOTP code + totp_code = self._generate_totp(secret) + + if totp_code: + results.append( + Result( + title=f"{totp_code}", + subtitle_markup=f"Press Enter to remove • { + time_display + } remaining", + icon_name="user-trash-symbolic", + action=lambda acc=acc_name: ( + self._remove_account_and_refresh(acc) + ), + relevance=0.9, + plugin_name=self.display_name, + data={ + "type": "remove_instruction", + "account": acc_name, + "code": totp_code, + "keep_launcher_open": True, + }, + ) + ) + else: + results.append( + Result( + title=f"Error: {acc_name}", + subtitle="Press Enter to remove (Invalid secret)", + icon_name="user-trash-symbolic", + action=lambda acc=acc_name: ( + self._remove_account_and_refresh(acc) + ), + relevance=0.8, + plugin_name=self.display_name, + data={ + "type": "remove_instruction", + "account": acc_name, + "keep_launcher_open": True, + }, + ) + ) + + return results + + # Check if account exists + if account_name not in self.secrets: + return [ + Result( + title=f"Account '{account_name}' not found", + subtitle="Use 'remove' to see all available accounts", + icon_name="dialog-cancel-symbolic", + action=lambda: None, + relevance=0.5, + plugin_name=self.display_name, + data={"type": "error", "keep_launcher_open": True}, + ) + ] + + # Get account info for confirmation + account_data = self.secrets[account_name] + issuer = account_data.get("issuer", "") + display_name = f"{issuer} - {account_name}" if issuer else account_name + + # Show confirmation for removal + return [ + Result( + title=f"Remove '{display_name}'?", + subtitle="Press Enter to confirm removal", + icon_name="user-trash-symbolic", + action=lambda acc=account_name: self._remove_account_and_refresh(acc), + relevance=1.0, + plugin_name=self.display_name, + data={ + "type": "remove_confirm", + "account": account_name, + "keep_launcher_open": True, + }, + ) + ] + + def _handle_base32_secret(self, account_name: str, secret: str) -> List[Result]: + """Handle raw Base32 secret.""" + result = validate_base32_secret(secret) + + if not result["success"]: + return [ + Result( + title="Invalid Base32 secret", + subtitle=result["error"], + icon_name="dialog-cancel-symbolic", + action=lambda: None, + relevance=0.5, + plugin_name=self.display_name, + data={"type": "error", "keep_launcher_open": True}, + ) + ] + + self.secrets[account_name] = { + "secret": result["secret"], + "issuer": "", + "algorithm": "SHA1", + "digits": 6, + "period": 30, + } + self._save_secrets() + + return [ + Result( + title=f"✓ Added '{account_name}'", + subtitle=f"OTP account added successfully (secret: { + result['secret'][:4] + }...)", + icon_name="emblem-ok-symbolic", + action=lambda: self._trigger_refresh(), + relevance=1.0, + plugin_name=self.display_name, + data={"type": "success", "keep_launcher_open": True}, + ) + ] + + def _handle_otpauth_uri(self, account_name: str, uri: str) -> List[Result]: + """Handle otpauth:// URI.""" + result = parse_otpauth_uri(uri, account_name) + + if not result["success"]: + return [ + Result( + title="Error parsing otpauth URI", + subtitle=result["error"], + icon_name="dialog-cancel-symbolic", + action=lambda: None, + relevance=0.5, + plugin_name=self.display_name, + data={"type": "error", "keep_launcher_open": True}, + ) + ] + + self.secrets[result["account_name"]] = { + "secret": result["secret"], + "issuer": result["issuer"], + "algorithm": result["algorithm"], + "digits": result["digits"], + "period": result["period"], + } + self._save_secrets() + + display_name = ( + f"{result['issuer']} - {result['account_name']}" + if result["issuer"] + else result["account_name"] + ) + return [ + Result( + title=f"✓ Added '{display_name}'", + subtitle="OTP account added from URI", + icon_name="emblem-ok-symbolic", + action=lambda: self._trigger_refresh(), + relevance=1.0, + plugin_name=self.display_name, + data={"type": "success", "keep_launcher_open": True}, + ) + ] diff --git a/src/window/launcher/plugins/password.py b/src/window/launcher/plugins/password.py new file mode 100644 index 0000000..1019fac --- /dev/null +++ b/src/window/launcher/plugins/password.py @@ -0,0 +1,689 @@ +import base64 +import json +import subprocess +import threading +from datetime import datetime +from pathlib import Path +from typing import Dict, List, Optional + +from fabric.utils import GLib, get_relative_path, time + +from window.launcher.plugin_base import PluginBase +from window.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/passwords.json") + ) + self.password_manager = PasswordManager(self.password_file) + + # State for password visibility + self.revealed_passwords: Dict[str, str] = {} + + # Cache for results to avoid repeated queries + self._results_cache: Dict[str, List[Result]] = {} + self._cache_timestamps: Dict[str, float] = {} + self._cache_ttl = 5 # Cache results for 5 seconds + + # Track launcher state for auto-hiding passwords + self._launcher_instance = None + + def initialize(self): + """Initialize the password plugin.""" + self.set_triggers(["pass"]) + self._setup_launcher_hooks() + + def cleanup(self): + """Cleanup the password plugin.""" + self.revealed_passwords.clear() + self._results_cache.clear() + self._cache_timestamps.clear() + self._cleanup_launcher_hooks() + + def query(self, query_string: str) -> List[Result]: + """Process password manager queries with caching.""" + query_key = query_string.strip() + current_time = time.time() + + # Check cache first (except for add/remove commands which should always execute) + if ( + not query_key.startswith(("add ", "remove ", "delete ")) + and query_key in self._results_cache + and (current_time - self._cache_timestamps.get(query_key, 0)) + < self._cache_ttl + ): + return self._results_cache[query_key] + + results = [] + query = query_key.lower() + + # Handle different commands + if not query: + # Show all passwords + results.extend(self._list_all_passwords()) + elif query.startswith("add "): + # Add new password (don't cache) + results.extend(self._handle_add_command(query_string)) + elif query.startswith("remove ") or query.startswith("delete "): + # Remove password (don't cache) + results.extend(self._handle_remove_command(query_string)) + else: + # Search for specific password + results.extend(self._search_passwords(query)) + + # Cache results (except for add/remove commands) + if not query.startswith(("add ", "remove ", "delete ")): + self._results_cache[query_key] = results + self._cache_timestamps[query_key] = current_time + + return results + + def _list_all_passwords(self) -> List[Result]: + """List all stored passwords.""" + results = [] + password_names = self.password_manager.list_passwords() + + if not password_names: + results.append( + Result( + title="No passwords stored", + subtitle="Use 'pass add <name> <password>' to add your first password", + icon_name="password-symbolic", + action=lambda: None, + relevance=1.0, + plugin_name=self.display_name, + data={"type": "empty", "keep_launcher_open": True}, + ) + ) + results.append( + Result( + title="Available commands:", + subtitle="add <name> <password> | remove <name> | <name> (to search)", + icon_name="info", + action=lambda: None, + relevance=0.9, + plugin_name=self.display_name, + data={"type": "help", "keep_launcher_open": True}, + ) + ) + return results + + # Sort passwords alphabetically + password_names.sort() + + for name in password_names: + info = self.password_manager.get_password_info(name) + description = info.get("description", "") if info else "" + + # Check if password is revealed + if name in self.revealed_passwords: + title = f"{name}: {self.revealed_passwords[name]}" + subtitle = "Password revealed - Enter: copy | Shift+Enter: hide" + else: + title = f"{name}: {'*' * 8}" + subtitle = "Enter: copy | Shift+Enter: reveal password" + + if description: + subtitle += f" | {description}" + + results.append( + Result( + title=title, + subtitle=subtitle, + icon_name="key", + action=lambda n=name: self._copy_password_to_clipboard(n), + relevance=1.0, + plugin_name=self.display_name, + data={ + "type": "password", + "name": name, + "keep_launcher_open": False, + "alt_action": lambda n=name: self._toggle_password_visibility( + n + ), + }, + ) + ) + + return results + + def _handle_add_command(self, query_string: str) -> List[Result]: + """Handle add password command.""" + results = [] + parts = query_string.strip().split(" ", 3) + + if len(parts) < 3: + results.append( + Result( + title="Add Password - Invalid format", + subtitle="Usage: add <name> <password> [description]", + icon_name="cancel", + action=lambda: None, + relevance=1.0, + plugin_name=self.display_name, + data={"type": "error", "keep_launcher_open": True}, + ) + ) + return results + + name = parts[1] + password = parts[2] + description = parts[3] if len(parts) > 3 else "" + + # Check if password already exists + if name in self.password_manager.list_passwords(): + results.append( + Result( + title=f"Update password for '{name}'?", + subtitle="Password already exists. Click to update it.", + icon_name="key", + action=lambda: self._add_password_action( + name, password, description, update=True + ), + relevance=1.0, + plugin_name=self.display_name, + data={"type": "update", "name": name, "keep_launcher_open": False}, + ) + ) + else: + results.append( + Result( + title=f"Add password for '{name}'", + subtitle="Click to save password" + + (f" | {description}" if description else ""), + icon_name="plus", + action=lambda: self._add_password_action( + name, password, description + ), + relevance=1.0, + plugin_name=self.display_name, + data={"type": "add", "name": name, "keep_launcher_open": False}, + ) + ) + + return results + + def _handle_remove_command(self, query_string: str) -> List[Result]: + """Handle remove password command.""" + results = [] + parts = query_string.strip().split(" ", 1) + + if len(parts) < 2: + results.append( + Result( + title="Remove Password - Invalid format", + subtitle="Usage: remove <name>", + icon_name="cancel", + action=lambda: None, + relevance=1.0, + plugin_name=self.display_name, + data={"type": "error", "keep_launcher_open": True}, + ) + ) + return results + + name = parts[1] + + if name not in self.password_manager.list_passwords(): + results.append( + Result( + title=f"Password '{name}' not found", + subtitle="Check the name and try again", + icon_name="cancel", + action=lambda: None, + relevance=1.0, + plugin_name=self.display_name, + data={"type": "error", "keep_launcher_open": True}, + ) + ) + else: + results.append( + Result( + title=f"Remove password '{name}'?", + subtitle="Click to confirm deletion (this cannot be undone)", + icon_name="trash", + action=lambda: self._remove_password_action(name), + relevance=1.0, + plugin_name=self.display_name, + data={"type": "remove", "name": name, "keep_launcher_open": False}, + ) + ) + + return results + + def _search_passwords(self, query: str) -> List[Result]: + """Search for passwords by name.""" + results = [] + password_names = self.password_manager.list_passwords() + + # Filter passwords that match the query + matching_passwords = [ + name for name in password_names if query.lower() in name.lower() + ] + + if not matching_passwords: + results.append( + Result( + title=f"No passwords found matching '{query}'", + subtitle="Try a different search term or use 'pass' to see all passwords", + icon_name="magnifier", + action=lambda: None, + relevance=1.0, + plugin_name=self.display_name, + data={"type": "no_results", "keep_launcher_open": True}, + ) + ) + return results + + # Sort by relevance (exact match first, then starts with, then contains) + def get_relevance(name: str) -> float: + name_lower = name.lower() + query_lower = query.lower() + + if name_lower == query_lower: + return 1.0 + elif name_lower.startswith(query_lower): + return 0.9 + else: + return 0.7 + + matching_passwords.sort(key=get_relevance, reverse=True) + + for name in matching_passwords: + info = self.password_manager.get_password_info(name) + description = info.get("description", "") if info else "" + + # Check if password is revealed + if name in self.revealed_passwords: + title = f"{name}: {self.revealed_passwords[name]}" + subtitle = "Password revealed - Enter: copy | Shift+Enter: hide" + else: + title = f"{name}: {'*' * 8}" + subtitle = "Enter: copy | Shift+Enter: reveal password" + + if description: + subtitle += f" | {description}" + + results.append( + Result( + title=title, + subtitle=subtitle, + icon_name="key", + action=lambda n=name: self._copy_password_to_clipboard(n), + relevance=get_relevance(name), + plugin_name=self.display_name, + data={ + "type": "password", + "name": name, + "keep_launcher_open": False, + "alt_action": lambda n=name: self._toggle_password_visibility( + n + ), + }, + ) + ) + + return results + + def _add_password_action( + self, name: str, password: str, description: str = "", update: bool = False + ): + """Action to add/update a password.""" + try: + success = self.password_manager.add_password(name, password, description) + if success: + action_word = "updated" if update else "added" + # Clear cache to force refresh + self._results_cache.clear() + + # Send notification if available (non-blocking) + try: + subprocess.Popen( + [ + "notify-send", + "Password Manager", + f"Password '{name}' {action_word} successfully", + ] + ) + except Exception: + 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 Exception: + 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 Exception: + 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: + + 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/src/window/launcher/plugins/power.py b/src/window/launcher/plugins/power.py new file mode 100644 index 0000000..d6c2dd5 --- /dev/null +++ b/src/window/launcher/plugins/power.py @@ -0,0 +1,102 @@ +from typing import List + +from fabric.utils import exec_shell_command_async + +from window.launcher.plugin_base import PluginBase +from window.launcher.result import Result + + +class PowerPlugin(PluginBase): + """ + Plugin for system power management operations. + """ + + def __init__(self): + super().__init__() + self.display_name = "Power" + self.description = "System power management and control" + self.commands = { + "shutdown": { + "description": "Shutdown the system", + "icon": "system-shutdown-symbolic", + "action": self.shutdown, + }, + "restart": { + "description": "Restart the system", + "icon": "system-reboot-symbolic", + "action": self.restart, + }, + "lock": { + "description": "Lock the screen", + "icon": "system-lock-screen-symbolic", + "action": self.lock, + }, + "suspend": { + "description": "Suspend the system", + "icon": "system-suspend-symbolic", + "action": self.suspend, + }, + "logout": { + "description": "Logout from current session", + "icon": "system-log-out-symbolic", + "action": self.logout, + }, + } + + def initialize(self): + """Initialize the power plugin.""" + self.set_triggers(["power"]) + + def cleanup(self): + """Cleanup the power plugin.""" + pass + + def query(self, query_string: str) -> List[Result]: + """Search power commands based on query.""" + query = query_string.lower().strip() + results = [] + + # If no query, show all power commands + if not query: + for cmd, info in self.commands.items(): + result = Result( + title=cmd.capitalize(), + subtitle=info["description"], + icon_name=info["icon"], + action=info["action"], + relevance=1.0, + plugin_name=self.display_name, + data={"command": cmd}, + ) + results.append(result) + else: + # Filter commands based on query + for cmd, info in self.commands.items(): + if query in cmd.lower() or query in info["description"].lower(): + result = Result( + title=cmd.capitalize(), + subtitle=info["description"], + icon_name=info["icon"], + action=info["action"], + relevance=1.0 if query == cmd else 0.7, + plugin_name=self.display_name, + data={"command": cmd}, + ) + results.append(result) + + return results + + def shutdown(self, *args) -> None: + exec_shell_command_async("systemctl poweroff") + + def restart(self, *args) -> None: + exec_shell_command_async("systemctl reboot") + + def lock(self, *args) -> None: + exec_shell_command_async("loginctl lock-session") + + def suspend(self, *args) -> None: + exec_shell_command_async("systemctl suspend") + + def logout(self, *args) -> None: + exec_shell_command_async("hyprctl dispatch exit") diff --git a/src/window/launcher/plugins/reminders.py b/src/window/launcher/plugins/reminders.py new file mode 100644 index 0000000..4cd1357 --- /dev/null +++ b/src/window/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 + +from window.launcher.plugin_base import PluginBase +from window.launcher.result import Result + + +class Reminder: + """ + Represents a single reminder with its timer and metadata. + """ + + def __init__( + self, + reminder_id: int, + message: str, + target_time: datetime, + timer: threading.Timer, + ): + self.id = reminder_id + self.message = message + self.target_time = target_time + self.timer = timer + self.created_time = datetime.now() + + def cancel(self): + """Cancel this reminder.""" + if self.timer: + self.timer.cancel() + + def get_time_remaining(self) -> str: + """Get formatted time remaining until reminder.""" + now = datetime.now() + if self.target_time <= now: + return "Overdue" + + delta = self.target_time - now + total_seconds = int(delta.total_seconds()) + + if total_seconds < 60: + return f"{total_seconds}s" + elif total_seconds < 3600: + minutes = total_seconds // 60 + seconds = total_seconds % 60 + return f"{minutes}m {seconds}s" if seconds > 0 else f"{minutes}m" + else: + hours = total_seconds // 3600 + minutes = (total_seconds % 3600) // 60 + return f"{hours}h {minutes}m" if minutes > 0 else f"{hours}h" + + def get_target_time_str(self) -> str: + """Get formatted target time.""" + return self.target_time.strftime("%H:%M") + + +class RemindersPlugin(PluginBase): + """ + Time-based reminders plugin for the launcher. + """ + + def __init__(self): + super().__init__() + self.display_name = "Reminders" + self.description = "Set time-based reminders with notifications" + self.reminders: Dict[int, Reminder] = {} + self.next_id = 1 + + # Regex patterns for time parsing + self.time_patterns = { + "relative_time": re.compile(r"^(\d+)([smhd])$"), # 5m, 30s, 2h, 1d + "absolute_time": re.compile(r"^(\d{1,2}):(\d{2})$"), # 14:30, 9:15 + "relative_with_unit": re.compile( + r"^(\d+)\s*(min|mins|minute|minutes|hour|hours|sec|seconds|day|days)$", + re.IGNORECASE, + ), + } + + def initialize(self): + """Initialize the reminders plugin.""" + self.set_triggers(["remind"]) + + def cleanup(self): + """Cleanup the reminders plugin.""" + # Cancel all active reminders + for reminder in self.reminders.values(): + reminder.cancel() + self.reminders.clear() + + def _send_notification(self, title: str, message: str): + """Send a desktop notification using notify-send.""" + try: + subprocess.run( + ["notify-send", "-a", "Reminders", "-i", "alarm-clock", title, message], + check=False, + ) + except Exception as e: + print(f"Failed to send notification: {e}") + + def _parse_time_input(self, time_str: str) -> Optional[datetime]: + """ + Parse various time input formats and return target datetime. + + Supported formats: + - 5m, 30s, 2h, 1d (relative time) + - 14:30, 9:15 (absolute time today) + - 5 minutes, 2 hours (relative with full unit names) + """ + time_str = time_str.strip().lower() + + # Try relative time format (5m, 30s, 2h, 1d) + match = self.time_patterns["relative_time"].match(time_str) + if match: + value, unit = match.groups() + value = int(value) + + if unit == "s": + delta = timedelta(seconds=value) + elif unit == "m": + delta = timedelta(minutes=value) + elif unit == "h": + delta = timedelta(hours=value) + elif unit == "d": + delta = timedelta(days=value) + else: + return None + + return datetime.now() + delta + + # Try absolute time format (14:30, 9:15) + match = self.time_patterns["absolute_time"].match(time_str) + if match: + hour, minute = map(int, match.groups()) + if 0 <= hour <= 23 and 0 <= minute <= 59: + target = datetime.now().replace( + hour=hour, minute=minute, second=0, microsecond=0 + ) + # If the time has already passed today, schedule for tomorrow + if target <= datetime.now(): + target += timedelta(days=1) + return target + + # Try relative time with full unit names + match = self.time_patterns["relative_with_unit"].match(time_str) + if match: + value, unit = match.groups() + value = int(value) + unit = unit.lower() + + if unit in ["sec", "seconds"]: + delta = timedelta(seconds=value) + elif unit in ["min", "mins", "minute", "minutes"]: + delta = timedelta(minutes=value) + elif unit in ["hour", "hours"]: + delta = timedelta(hours=value) + elif unit in ["day", "days"]: + delta = timedelta(days=value) + else: + return None + + return datetime.now() + delta + + return None + + def _create_reminder(self, time_str: str, message: str) -> Optional[Reminder]: + """Create a new reminder with the given time and message.""" + target_time = self._parse_time_input(time_str) + if not target_time: + return None + + # Calculate delay in seconds + delay = (target_time - datetime.now()).total_seconds() + if delay <= 0: + return None + + # Create timer that will trigger the notification + timer = threading.Timer(delay, self._trigger_reminder, [self.next_id, message]) + + # Create reminder object + reminder = Reminder(self.next_id, message, target_time, timer) + + # Store reminder and start timer + self.reminders[self.next_id] = reminder + timer.start() + + # Increment ID for next reminder + self.next_id += 1 + + return reminder + + def _trigger_reminder(self, reminder_id: int, message: str): + """Trigger a reminder notification and remove it from active reminders.""" + # Send notification + self._send_notification("⏰ Reminder", message) + + # Remove from active reminders + if reminder_id in self.reminders: + del self.reminders[reminder_id] + + def _cancel_reminder(self, reminder_id: Optional[int] = None) -> int: + """Cancel a specific reminder or all reminders. Returns number of cancelled reminders.""" + if reminder_id is not None: + if reminder_id in self.reminders: + self.reminders[reminder_id].cancel() + del self.reminders[reminder_id] + return 1 + return 0 + else: + # Cancel all reminders + count = len(self.reminders) + for reminder in self.reminders.values(): + reminder.cancel() + self.reminders.clear() + return count + + def _format_time_remaining(self, total_seconds: float) -> str: + """Format time remaining in a human-readable way.""" + total_seconds = int(total_seconds) + + if total_seconds < 60: + return f"{total_seconds}s" + elif total_seconds < 3600: + minutes = total_seconds // 60 + seconds = total_seconds % 60 + return f"{minutes}m {seconds}s" if seconds > 0 else f"{minutes}m" + else: + hours = total_seconds // 3600 + minutes = (total_seconds % 3600) // 60 + return f"{hours}h {minutes}m" if minutes > 0 else f"{hours}h" + + def _create_and_confirm_reminder(self, time_str: str, message: str): + """Actually create the reminder when the user presses Enter.""" + reminder = self._create_reminder(time_str, message) + if reminder: + time_remaining = reminder.get_time_remaining() + self._send_notification( + "✅ Reminder Created", f"Reminder set for {time_remaining}: {message}" + ) + else: + self._send_notification( + "❌ Failed to Create Reminder", "The specified time may be in the past" + ) + + def query(self, query_string: str) -> List[Result]: + """Process reminder queries.""" + results = [] + query = query_string.strip() + + if not query: + # Show help and active reminders count + active_count = len(self.reminders) + results.append( + Result( + title="Reminders Help", + subtitle=f"Active reminders: { + active_count + } | Usage: remind 5m Take a break", + icon_name="alarm-timer", + action=lambda: None, + relevance=1.0, + plugin_name=self.display_name, + data={"type": "help"}, + ) + ) + + # Show quick examples + examples = [ + ("remind 5m Take a break", "Set 5 minute reminder"), + ("remind 14:30 Meeting", "Set reminder for 2:30 PM"), + ("remind list", "List active reminders"), + ("remind cancel", "Cancel all reminders"), + ] + + for example, desc in examples: + results.append( + Result( + title=example, + subtitle=desc, + icon_name="alarm-timer", + action=lambda: None, + relevance=0.8, + plugin_name=self.display_name, + data={"type": "example"}, + ) + ) + + return results + + # Handle list command + if query.lower() in ["list", "ls", "show"]: + if not self.reminders: + results.append( + Result( + title="No Active Reminders", + subtitle="Use 'remind 5m message' to set a reminder", + icon_name="timer-off", + action=lambda: None, + relevance=1.0, + plugin_name=self.display_name, + data={"type": "empty_list"}, + ) + ) + else: + for reminder in sorted( + self.reminders.values(), key=lambda r: r.target_time + ): + time_remaining = reminder.get_time_remaining() + target_time = reminder.get_target_time_str() + + results.append( + Result( + title=f"#{reminder.id}: {reminder.message}", + subtitle=f"In {time_remaining} (at {target_time})", + icon_name="alarm-timer", + action=lambda rid=reminder.id: self._cancel_reminder(rid), + relevance=1.0, + plugin_name=self.display_name, + data={"type": "active_reminder", "id": reminder.id}, + ) + ) + + return results + + # Handle cancel command + if query.lower().startswith("cancel") or query.lower().startswith("stop"): + parts = query.split() + if len(parts) == 1: + # Cancel all reminders + count = self._cancel_reminder() + results.append( + Result( + title=f"Cancelled {count} Reminders", + subtitle="All active reminders have been cancelled", + icon_name="timer-off", + action=lambda: None, + relevance=1.0, + plugin_name=self.display_name, + data={"type": "cancel_all"}, + ) + ) + else: + # Try to cancel specific reminder by ID + try: + reminder_id = int(parts[1]) + count = self._cancel_reminder(reminder_id) + if count > 0: + results.append( + Result( + title=f"Cancelled Reminder #{reminder_id}", + subtitle="Reminder has been cancelled", + icon_name="timer-off", + action=lambda: None, + relevance=1.0, + plugin_name=self.display_name, + data={"type": "cancel_specific"}, + ) + ) + else: + results.append( + Result( + title="Reminder Not Found", + subtitle=f"No reminder with ID #{reminder_id}", + icon_name="alert", + action=lambda: None, + relevance=0.5, + plugin_name=self.display_name, + data={"type": "error"}, + ) + ) + except ValueError: + results.append( + Result( + title="Invalid Reminder ID", + subtitle="Please provide a valid reminder ID number", + icon_name="alert", + action=lambda: None, + relevance=0.5, + plugin_name=self.display_name, + data={"type": "error"}, + ) + ) + + return results + + # Handle setting new reminders + parts = query.split(None, 1) + if len(parts) >= 1: + time_str = parts[0] + message = parts[1] if len(parts) > 1 else "Reminder" + + # Try to parse the time (but don't create the reminder yet!) + target_time = self._parse_time_input(time_str) + if target_time: + # Calculate delay and check if it's valid + delay = (target_time - datetime.now()).total_seconds() + if delay > 0: + # Show what would happen, but don't create the reminder yet + time_remaining = self._format_time_remaining(delay) + target_time_str = target_time.strftime("%H:%M") + + results.append( + Result( + title=f"Set Reminder: {message}", + subtitle=f"Will remind in {time_remaining} (at { + target_time_str + })", + icon_name="alarm-timer", + action=lambda ts=time_str, msg=message: ( + self._create_and_confirm_reminder(ts, msg) + ), + relevance=1.0, + plugin_name=self.display_name, + data={ + "type": "preview", + "time_str": time_str, + "message": message, + }, + ) + ) + else: + results.append( + Result( + title="Time is in the Past", + subtitle="Please specify a future time", + icon_name="alert", + action=lambda: None, + relevance=0.5, + plugin_name=self.display_name, + data={"type": "error"}, + ) + ) + else: + # Invalid time format + results.append( + Result( + title="Invalid Time Format", + subtitle="Use formats like: 5m, 30s, 2h, 14:30, or '5 minutes'", + icon_name="alert", + action=lambda: None, + relevance=0.5, + plugin_name=self.display_name, + data={"type": "error"}, + ) + ) + + return results diff --git a/src/window/launcher/plugins/screencapture.py b/src/window/launcher/plugins/screencapture.py new file mode 100644 index 0000000..d746813 --- /dev/null +++ b/src/window/launcher/plugins/screencapture.py @@ -0,0 +1,442 @@ +from typing import List +from services.screencapture import screen_capture_service +from window.launcher.plugin_base import PluginBase +from window.launcher.result import Result + + +class ScreencapturePlugin(PluginBase): + """ + Plugin for taking screenshots and screen recordings using screen-capture.sh script. + """ + + def __init__(self): + super().__init__() + self.display_name = "Screencapture" + self.description = "Take screenshots and screen recordings" + + def initialize(self): + """Initialize the screencapture plugin.""" + self.set_triggers(["sc"]) + + def cleanup(self): + """Cleanup the screencapture plugin.""" + pass + + def get_commands(self): + """Return available commands for this plugin.""" + return { + # Screenshot commands + "screenshot": "Take a screenshot of the main display", + "screenshot-region": "Take a screenshot of selected region", + "screenshot-both": "Take a screenshot of both displays", + "screenshot-active": "Take a screenshot of the active display", + # Recording commands (with audio) + "record": "Start recording main display with audio", + "record-region": "Start recording selected region with audio", + "record-active": "Start recording active display with audio", + # Recording overrides + "record-noaudio": "Start recording main display without audio", + "record-hq": "Start high-quality recording (eDP-1)", + "record-gif": "Start GIF recording (eDP-1)", + # Control commands + "stop": "Stop current recording", + # Conversion commands + "convert-webm": "Convert latest MKV recording to WebM format", + "convert-iphone": "Convert latest recordings for iPhone", + "convert-youtube": "Convert latest recordings for YouTube", + "convert-gif": "Convert latest recordings to GIF", + # File conversion + "convert-file": "Convert specific file (Usage: convert-file [type] [path])", + } + + def _activate_default_plugins(self): + """No action needed for script cleanup.""" + pass + + def _is_recording(self): + """Check if recording is currently active.""" + return screen_capture_service.is_recording + + def _get_command_result(self, command: str) -> Result: + """Get a Result object for a specific command.""" + # Map aliases manually if needed + if command == "ss": + command = "screenshot" + if command == "rec": + command = "record" + if command == "conv": + command = "convert" + + command_info = { + # Screenshot commands + "screenshot": ( + "Take Screenshot (eDP-1)", + "Capture the main display", + "camera-photo-symbolic", + lambda: screen_capture_service.screenshot("eDP-1"), + ), + "screenshot-region": ( + "Take Region Screenshot", + "Capture a selected region", + "camera-photo-symbolic", + lambda: screen_capture_service.screenshot("region"), + ), + "screenshot-both": ( + "Take Screenshot (Both Displays)", + "Capture both displays combined", + "video-joined-displays-symbolic", + lambda: screen_capture_service.screenshot("both"), + ), + "screenshot-active": ( + "Take Screenshot (Active)", + "Capture the active display", + "camera-photo-symbolic", + lambda: screen_capture_service.screenshot("active"), + ), + # Recording commands (with audio) + "record": ( + "Start Recording (eDP-1)", + "Record the main display with audio", + "media-record-symbolic", + lambda: screen_capture_service.record("eDP-1"), + ), + "record-region": ( + "Start Region Recording", + "Record a selected region with audio", + "media-record-symbolic", + lambda: screen_capture_service.record("selection"), + ), + "record-active": ( + "Start Recording (Active)", + "Record the active display with audio", + "media-record-symbolic", + lambda: screen_capture_service.record("active"), + ), + # Recording overrides + "record-noaudio": ( + "Start Recording No Audio (eDP-1)", + "Record without audio", + "media-record-symbolic", + lambda: screen_capture_service.record("eDP-1", no_audio=True), + ), + "record-hq": ( + "Start HQ Recording (eDP-1)", + "High-quality recording for YouTube", + "media-record-symbolic", + lambda: screen_capture_service.record("eDP-1", mode="hq"), + ), + "record-gif": ( + "Start GIF Recording (eDP-1)", + "Record as optimized GIF", + "media-record-symbolic", + lambda: screen_capture_service.record("eDP-1", mode="gif"), + ), + # Control commands + "stop": ( + "Stop Recording", + "Stop the current screen recording", + "media-playback-stop-symbolic", + lambda: screen_capture_service.stop_recording(), + ), + # Conversion commands + "convert-webm": ( + "Convert Latest to WebM", + "Convert latest MKV recording to WebM format", + "video-x-generic-symbolic", + lambda: screen_capture_service.convert("webm"), + ), + "convert-iphone": ( + "Convert Latest for iPhone", + "Convert latest MKV recording for iPhone", + "video-x-generic-symbolic", + lambda: screen_capture_service.convert("iphone"), + ), + "convert-youtube": ( + "Convert Latest for YouTube", + "Convert latest recording for YouTube", + "video-x-generic-symbolic", + lambda: screen_capture_service.convert("youtube"), + ), + "convert-gif": ( + "Convert Latest to GIF", + "Convert latest recording to GIF", + "image-x-generic-symbolic", + lambda: screen_capture_service.convert("gif"), + ), + } + + if command in command_info: + title, subtitle, icon, action = command_info[command] + if action is not None: # Regular command + return Result( + title=title, + subtitle=subtitle, + icon_name=icon, + action=action, + relevance=1.0, + plugin_name=self.display_name, + ) + else: # File-based command, show instruction + return Result( + title=title, + subtitle=subtitle, + icon_name=icon, + action=lambda: None, # No action for instruction + relevance=1.0, + plugin_name=self.display_name, + ) + + return None + + def query(self, query_string: str) -> List[Result]: + """Search for screencapture actions based on query.""" + # Import here to avoid circular imports + + # Clean the query string + query = query_string.strip().lower() + + results = [] + + # Parse query for file-based commands + query_parts = query.split() + if len(query_parts) >= 3 and query_parts[0] == "convert-file": + format_type = query_parts[1] + file_param = " ".join(query_parts[2:]) + if format_type in ["webm", "iphone", "youtube", "gif"]: + return [ + Result( + title=f"Convert {file_param} to {format_type.upper()}", + subtitle=f"Convert specified file to {format_type} format", + icon_name=( + "video-x-generic-symbolic" + if format_type != "gif" + else "image-x-generic-symbolic" + ), + action=lambda fp=file_param, ft=format_type: ( + screen_capture_service.convert(ft, fp) + ), + relevance=1.0, + plugin_name=self.display_name, + ) + ] + + # Check if query matches a command and return it as a result + command_result = self._get_command_result(query) + if command_result: + return [command_result] + + # Check recording status + is_recording = self._is_recording() + + # If recording is active, show stop button first with highest relevance + if is_recording: + results.append( + Result( + title="Stop Recording", + subtitle="Stop the current screen recording", + icon_name="media-playback-stop-symbolic", + action=lambda: screen_capture_service.stop_recording(), + relevance=2.0, # Highest relevance to appear at top + plugin_name=self.display_name, + ) + ) + + # Screenshot actions + results.extend( + [ + Result( + title="Take Screenshot", + subtitle="Capture the entire screen (eDP-1)", + icon_name="camera-photo-symbolic", + action=lambda: screen_capture_service.screenshot("eDP-1"), + relevance=1.0, + plugin_name=self.display_name, + ), + Result( + title="Take Region Screenshot", + subtitle="Capture a selected region", + icon_name="camera-photo-symbolic", + action=lambda: screen_capture_service.screenshot("region"), + relevance=0.9, + plugin_name=self.display_name, + ), + Result( + title="Take Screenshot (Both Displays)", + subtitle="Capture both displays combined", + icon_name="video-joined-displays-symbolic", + action=lambda: screen_capture_service.screenshot("both"), + relevance=0.8, + plugin_name=self.display_name, + ), + Result( + title="Take Screenshot (HDMI-A-1)", + subtitle="Capture HDMI display", + icon_name="video-display-symbolic", + action=lambda: screen_capture_service.screenshot("HDMI-A-1"), + relevance=0.7, + plugin_name=self.display_name, + ), + ] + ) + + # Standard recording actions + results.extend( + [ + Result( + title="Start Recording (eDP-1)", + subtitle="Record the main display with audio", + icon_name="media-record-symbolic", + action=lambda: screen_capture_service.record("eDP-1"), + relevance=0.7, + plugin_name=self.display_name, + ), + Result( + title="Start Region Recording", + subtitle="Record a selected region", + icon_name="media-record-symbolic", + action=lambda: screen_capture_service.record("selection"), + relevance=0.6, + plugin_name=self.display_name, + ), + Result( + title="Start Recording (HDMI-A-1)", + subtitle="Record HDMI display with audio", + icon_name="media-record-symbolic", + action=lambda: screen_capture_service.record("HDMI-A-1"), + relevance=0.5, + plugin_name=self.display_name, + ), + ] + ) + + # No-audio recording actions + results.extend( + [ + Result( + title="Start Recording No Audio (eDP-1)", + subtitle="Record the main display without audio", + icon_name="media-record-symbolic", + action=lambda: screen_capture_service.record( + "eDP-1", no_audio=True + ), + relevance=0.65, + plugin_name=self.display_name, + ), + Result( + title="Start Region Recording No Audio", + subtitle="Record a selected region without audio", + icon_name="media-record-symbolic", + action=lambda: screen_capture_service.record( + "selection", no_audio=True + ), + relevance=0.55, + plugin_name=self.display_name, + ), + Result( + title="Start Recording No Audio (HDMI-A-1)", + subtitle="Record HDMI display without audio", + icon_name="media-record-symbolic", + action=lambda: screen_capture_service.record( + "HDMI-A-1", no_audio=True + ), + relevance=0.45, + plugin_name=self.display_name, + ), + ] + ) + + # High-quality recording actions + results.extend( + [ + Result( + title="Start HQ Recording (eDP-1)", + subtitle="High-quality recording for YouTube", + icon_name="media-record-symbolic", + action=lambda: screen_capture_service.record("eDP-1", mode="hq"), + relevance=0.4, + plugin_name=self.display_name, + ), + Result( + title="Start HQ Region Recording", + subtitle="High-quality region recording", + icon_name="media-record-symbolic", + action=lambda: screen_capture_service.record( + "selection", mode="hq" + ), + relevance=0.3, + plugin_name=self.display_name, + ), + Result( + title="Start HQ Recording (HDMI-A-1)", + subtitle="High-quality HDMI recording", + icon_name="media-record-symbolic", + action=lambda: screen_capture_service.record("HDMI-A-1", mode="hq"), + relevance=0.2, + plugin_name=self.display_name, + ), + ] + ) + + # GIF recording actions + results.extend( + [ + Result( + title="Start GIF Recording (eDP-1)", + subtitle="Record as optimized GIF", + icon_name="media-record-symbolic", + action=lambda: screen_capture_service.record("eDP-1", mode="gif"), + relevance=0.1, + plugin_name=self.display_name, + ), + Result( + title="Start GIF Region Recording", + subtitle="Record selected region as GIF", + icon_name="media-record-symbolic", + action=lambda: screen_capture_service.record( + "selection", mode="gif" + ), + relevance=0.05, + plugin_name=self.display_name, + ), + ] + ) + + # Conversion actions + results.extend( + [ + Result( + title="Convert Latest to WebM", + subtitle="Convert latest MKV recording to WebM format", + icon_name="video-x-generic-symbolic", + action=lambda: screen_capture_service.convert("webm"), + relevance=0.01, + plugin_name=self.display_name, + ), + Result( + title="Convert Latest for iPhone", + subtitle="Convert latest MKV recording for iPhone compatibility", + icon_name="video-x-generic-symbolic", + action=lambda: screen_capture_service.convert("iphone"), + relevance=0.01, + plugin_name=self.display_name, + ), + Result( + title="Convert Latest for YouTube", + subtitle="Convert latest recording for YouTube upload", + icon_name="video-x-generic-symbolic", + action=lambda: screen_capture_service.convert("youtube"), + relevance=0.01, + plugin_name=self.display_name, + ), + Result( + title="Convert Latest to GIF", + subtitle="Convert latest recording to GIF format", + icon_name="image-x-generic-symbolic", + action=lambda: screen_capture_service.convert("gif"), + relevance=0.01, + plugin_name=self.display_name, + ), + ] + ) + + return results diff --git a/src/window/launcher/plugins/system.py b/src/window/launcher/plugins/system.py new file mode 100644 index 0000000..e850921 --- /dev/null +++ b/src/window/launcher/plugins/system.py @@ -0,0 +1,240 @@ +import json +import threading +from typing import List, Set, Union + +from fabric.utils import exec_shell_command_async, os, shlex, time + +import shared.data as data +from window.launcher.plugin_base import PluginBase +from window.launcher.result import Result + + +class SystemPlugin(PluginBase): + """ + Plugin for system commands and actions. + """ + + def __init__(self): + super().__init__() + self.display_name = "System" + self.description = "System commands and actions" + + # JSON cache file for system binaries + self.bin_cache_file = os.path.join(data.CACHE_DIR, "system_binaries.json") + + # In-memory cache for system binaries + self._bin_cache: Set[str] = set() + self._last_bin_update = 0 + self._bin_update_interval = 300 # 5 minutes + + # Background cache building + self._cache_building = False + self._cache_thread = None + + def initialize(self): + """Initialize the system plugin.""" + self.set_triggers(["bin"]) + self._load_bin_cache() + self._start_background_cache_update() + + def cleanup(self): + """Cleanup the system plugin.""" + self._bin_cache.clear() + if self._cache_thread and self._cache_thread.is_alive(): + # Note: We don't join the thread to avoid blocking cleanup + pass + + def _load_bin_cache(self): + """Load binary cache from JSON file.""" + try: + if os.path.exists(self.bin_cache_file): + with open(self.bin_cache_file, "r", encoding="utf-8") as f: + cache_data = json.load(f) + self._bin_cache = set(cache_data.get("binaries", [])) + self._last_bin_update = cache_data.get("last_update", 0) + else: + print( + "SystemPlugin: No cache file found, will build cache in background" + ) + except Exception as e: + print(f"SystemPlugin: Error loading binary cache: {e}") + self._bin_cache = set() + self._last_bin_update = 0 + + def _save_bin_cache(self): + """Save binary cache to JSON file.""" + try: + # Ensure the cache directory exists + os.makedirs(data.CACHE_DIR, exist_ok=True) + + cache_data = { + "binaries": sorted(list(self._bin_cache)), + "last_update": self._last_bin_update, + "cache_version": "1.0", + } + + with open(self.bin_cache_file, "w", encoding="utf-8") as f: + json.dump(cache_data, f, indent=2) + except Exception as e: + print(f"SystemPlugin: Error saving binary cache: {e}") + + def _start_background_cache_update(self): + """Start background thread to update binary cache.""" + current_time = time.time() + + # Check if cache needs updating + if ( + current_time - self._last_bin_update > self._bin_update_interval + or not self._bin_cache + ): + if not self._cache_building: + self._cache_building = True + self._cache_thread = threading.Thread( + target=self._build_bin_cache_background, daemon=True + ) + self._cache_thread.start() + + def _build_bin_cache_background(self): + """Build binary cache in background thread.""" + try: + new_cache = set() + processed_paths = set() # Avoid duplicate paths + + for path in os.environ["PATH"].split(":"): + # Skip empty paths and duplicates + if not path or path in processed_paths: + continue + processed_paths.add(path) + + if os.path.exists(path) and os.path.isdir(path): + try: + # Use os.scandir for better performance than os.listdir + with os.scandir(path) as entries: + for entry in entries: + if entry.is_file(follow_symlinks=False) and os.access( + entry.path, os.X_OK + ): + new_cache.add(entry.name) + except (PermissionError, FileNotFoundError, OSError): + continue + + # Update cache atomically + self._bin_cache = new_cache + self._last_bin_update = time.time() + + # Save to disk + self._save_bin_cache() + + except Exception as e: + print(f"SystemPlugin: Error building binary cache: {e}") + finally: + self._cache_building = False + + def query(self, query_string: str) -> List[Result]: + """Search for system commands matching the query.""" + query = query_string.strip() + + if not query: + return [] + + results = [] + + # Parse the query to extract binary name and arguments + query_parts = query.split() + if not query_parts: + return [] + + binary_query = query_parts[0].lower() + full_command = query # Keep the original case and spacing + + # Check system binaries + # Start background update if needed (non-blocking) - but only if cache is empty or very old + if not self._bin_cache or ( + time.time() - self._last_bin_update > self._bin_update_interval + ): + self._start_background_cache_update() + + # Optimize search with early termination and result limiting + exact_matches = [] + prefix_matches = [] + partial_matches = [] + max_results = 20 # Limit total results for performance + + for binary in self._bin_cache: + # Pre-compute lowercase once + binary_lower = binary.lower() + + # Skip if no match at all + if binary_query not in binary_lower: + continue + + # Categorize matches for better sorting + if binary_lower == binary_query: + # Exact match - highest priority + display_command = full_command + command_to_execute = full_command + relevance = 1.0 + exact_matches.append( + (binary, display_command, command_to_execute, relevance) + ) + elif binary_lower.startswith(binary_query): + # Prefix match - high priority + display_command = binary + command_to_execute = binary + relevance = 0.9 + prefix_matches.append( + (binary, display_command, command_to_execute, relevance) + ) + else: + # Partial match - lower priority + display_command = binary + command_to_execute = binary + relevance = 0.7 + partial_matches.append( + (binary, display_command, command_to_execute, relevance) + ) + + # Early termination if we have enough good matches + if len(exact_matches) + len(prefix_matches) >= max_results: + break + + # Combine results in priority order + all_matches = exact_matches + prefix_matches + partial_matches + + # Convert to Result objects (limit to max_results) + for binary, display_command, command_to_execute, relevance in all_matches[ + :max_results + ]: + result = Result( + title=display_command, + subtitle=f"Execute: {display_command}", + icon_name="terminal", + action=self._create_action(command_to_execute), + relevance=relevance, + plugin_name=self.display_name, + data={"command": command_to_execute, "id": binary}, + ) + results.append(result) + + return results # Already sorted by priority + + def _create_action(self, command: Union[str, List[str]]): + """Create an action function for the given command.""" + + def action(): + self._execute_command(command) + + return action + + def _execute_command(self, command: Union[str, List[str]]): + """Execute a system command.""" + try: + if isinstance(command, str): + # Handle string commands with arguments - split into list for proper execution + command_list = shlex.split(command) + exec_shell_command_async(command_list) + else: + # Handle list commands (backward compatibility) + exec_shell_command_async(command) + except Exception as e: + print(f"SystemPlugin: Error executing command '{command}': {e}") diff --git a/src/window/launcher/plugins/tmux.py b/src/window/launcher/plugins/tmux.py new file mode 100644 index 0000000..00d96b6 --- /dev/null +++ b/src/window/launcher/plugins/tmux.py @@ -0,0 +1,334 @@ +import subprocess +import threading +from typing import List + +from fabric.utils import exec_shell_command_async, time + +import shared.data as data +from window.launcher.plugin_base import PluginBase +from window.launcher.result import Result + + +class TmuxPlugin(PluginBase): + """ + Plugin for managing tmux sessions through the launcher. + """ + + def __init__(self): + super().__init__() + self.display_name = "Tmux Manager" + self.description = "Manage tmux sessions - create, attach, rename, and kill" + + # Cache for sessions to avoid repeated subprocess calls + self._sessions_cache = [] + self._last_cache_update = 0 + # Cache sessions for 10 seconds (increased from 2) + self._cache_ttl = 10 + + # Threading for auto-refresh - only when actively used + self.refresh_thread = None + self.stop_refresh = threading.Event() + self._last_query_time = 0 + # Stop refreshing after 30 seconds of inactivity + self._active_refresh_timeout = 30 + + def initialize(self): + """Initialize the tmux plugin.""" + self.set_triggers(["tmux"]) + # Don't start refresh thread immediately - start it when first used + + def cleanup(self): + """Cleanup the tmux plugin.""" + self.stop_refresh.set() + if self.refresh_thread: + self.refresh_thread.join(timeout=1) + self._sessions_cache.clear() + + def _start_refresh_thread(self): + """Start background thread to refresh session cache.""" + if not self.refresh_thread or not self.refresh_thread.is_alive(): + self.refresh_thread = threading.Thread( + target=self._refresh_sessions_background, daemon=True + ) + self.refresh_thread.start() + + def _refresh_sessions_background(self): + """Background thread to refresh sessions cache only when actively used.""" + while not self.stop_refresh.is_set(): + try: + current_time = time.time() + + # Stop refreshing if plugin hasn't been used recently + if current_time - self._last_query_time > self._active_refresh_timeout: + print("TmuxPlugin: Stopping background refresh due to inactivity") + break + + if current_time - self._last_cache_update > self._cache_ttl: + self._sessions_cache = self._get_tmux_sessions() + self._last_cache_update = current_time + + self.stop_refresh.wait(5) + except Exception as e: + print(f"TmuxPlugin: Error in refresh thread: {e}") + self.stop_refresh.wait(10) # Wait longer on error + + def _get_tmux_sessions(self): + """Get list of tmux sessions.""" + try: + result = subprocess.run( + ["tmux", "list-sessions", "-F", "#{session_name}"], + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0: + return [ + s.strip() for s in result.stdout.strip().split("\n") if s.strip() + ] + return [] + except ( + subprocess.TimeoutExpired, + subprocess.CalledProcessError, + FileNotFoundError, + ) as e: + print(f"TmuxPlugin: Error getting tmux sessions: {e}") + return [] + + def query(self, query_string: str) -> List[Result]: + """Process tmux queries.""" + query = query_string.strip().lower() + results = [] + + # Track usage and start refresh thread if needed + current_time = time.time() + self._last_query_time = current_time + + # Start refresh thread if not running and plugin is being used + if not self.refresh_thread or not self.refresh_thread.is_alive(): + self._start_refresh_thread() + + # Get current sessions (use cache if recent) + if current_time - self._last_cache_update > self._cache_ttl: + self._sessions_cache = self._get_tmux_sessions() + self._last_cache_update = current_time + + sessions = self._sessions_cache + + # Handle specific commands + if query.startswith("new ") or query.startswith("create "): + session_name = query.split(" ", 1)[1].strip() if " " in query else "" + results.append(self._create_new_session_result(session_name)) + + elif query.startswith("kill ") or query.startswith("delete "): + session_name = query.split(" ", 1)[1].strip() if " " in query else "" + if session_name: + matching_sessions = [ + s for s in sessions if session_name.lower() in s.lower() + ] + for session in matching_sessions: + results.append(self._create_kill_session_result(session)) + + elif query.startswith("rename "): + parts = query.split(" ", 2) + if len(parts) >= 3: + old_name, new_name = parts[1], parts[2] + if old_name in sessions: + results.append( + self._create_rename_session_result(old_name, new_name) + ) + + else: + # Show existing sessions for attachment + if sessions: + # Filter sessions based on query + if query: + filtered_sessions = [s for s in sessions if query in s.lower()] + else: + filtered_sessions = sessions + + for session in filtered_sessions: + results.append(self._create_attach_session_result(session)) + + # Always show option to create new session + if not query or "new" in query or "create" in query: + results.append( + self._create_new_session_result( + query + if query and not any(cmd in query for cmd in ["new", "create"]) + else "" + ) + ) + + return results + + def _create_attach_session_result(self, session_name: str) -> Result: + """Create result for attaching to a session.""" + return Result( + title=f"Attach to '{session_name}'", + subtitle=f"Connect to tmux session: {session_name}", + icon_name="terminal", + action=lambda: self._attach_to_session(session_name), + relevance=0.9, + data={"type": "attach", "session": session_name}, + ) + + def _create_new_session_result(self, session_name: str = "") -> Result: + """Create result for creating a new session.""" + display_name = session_name if session_name else "new session" + return Result( + title=f"Create '{display_name}'", + subtitle=f"Create new tmux session{ + f': {session_name}' if session_name else '' + }", + icon_name="plus", + action=lambda: self._create_session(session_name), + relevance=0.8, + data={"type": "create", "session": session_name}, + ) + + def _create_kill_session_result(self, session_name: str) -> Result: + """Create result for killing a session.""" + return Result( + title=f"Kill '{session_name}'", + subtitle=f"Terminate tmux session: {session_name}", + icon_name="trash", + action=lambda: self._kill_session(session_name), + relevance=0.7, + data={"type": "kill", "session": session_name}, + ) + + def _create_rename_session_result(self, old_name: str, new_name: str) -> Result: + """Create result for renaming a session.""" + return Result( + title=f"Rename '{old_name}' to '{new_name}'", + subtitle=f"Rename tmux session from {old_name} to {new_name}", + icon_name="config", + action=lambda: self._rename_session(old_name, new_name), + relevance=0.6, + data={"type": "rename", "old_session": old_name, "new_session": new_name}, + ) + + def _attach_to_session(self, session_name: str): + """Attach to an existing tmux session.""" + try: + terminal_cmd = self._get_terminal_command( + f"tmux attach-session -t '{session_name}'" + ) + exec_shell_command_async(terminal_cmd) + print(f"TmuxPlugin: Attaching to session '{session_name}'") + except Exception as e: + print(f"TmuxPlugin: Error attaching to session '{session_name}': {e}") + + def _create_session(self, session_name: str = ""): + """Create a new tmux session.""" + try: + if not session_name: + # Generate a default name + existing_sessions = self._get_tmux_sessions() + counter = 0 + while str(counter) in existing_sessions: + counter += 1 + session_name = str(counter) + + # Clean the session name + clean_name = session_name.strip().replace(" ", "_") + + # Create session + subprocess.run( + ["tmux", "new-session", "-d", "-s", clean_name], check=True, timeout=10 + ) + + # Launch terminal and attach + terminal_cmd = self._get_terminal_command( + f"tmux attach-session -t '{clean_name}'" + ) + exec_shell_command_async(terminal_cmd) + + # Refresh cache + self._sessions_cache = self._get_tmux_sessions() + self._last_cache_update = time.time() + + print(f"TmuxPlugin: Created and attached to session '{clean_name}'") + except Exception as e: + print(f"TmuxPlugin: Error creating session '{session_name}': {e}") + + def _kill_session(self, session_name: str): + """Kill a tmux session.""" + try: + subprocess.run( + ["tmux", "kill-session", "-t", session_name], check=True, timeout=10 + ) + + # Refresh cache + self._sessions_cache = self._get_tmux_sessions() + self._last_cache_update = time.time() + + print(f"TmuxPlugin: Killed session '{session_name}'") + except Exception as e: + print(f"TmuxPlugin: Error killing session '{session_name}': {e}") + + def _rename_session(self, old_name: str, new_name: str): + """Rename a tmux session.""" + try: + clean_name = new_name.strip().replace(" ", "_") + subprocess.run( + ["tmux", "rename-session", "-t", old_name, clean_name], + check=True, + timeout=10, + ) + + # Refresh cache + self._sessions_cache = self._get_tmux_sessions() + self._last_cache_update = time.time() + + print(f"TmuxPlugin: Renamed session '{old_name}' to '{clean_name}'") + except Exception as e: + print( + f"TmuxPlugin: Error renaming session '{old_name}' to '{new_name}': {e}" + ) + + def _get_terminal_command(self, cmd: str) -> str: + """Get terminal command based on configured terminal or available terminals.""" + # First try to use the configured terminal command + if hasattr(data, "TERMINAL_COMMAND") and data.TERMINAL_COMMAND: + parts = data.TERMINAL_COMMAND.split() + terminal = parts[0] + + try: + # Check if the configured terminal is available + subprocess.run( + ["which", terminal], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + return f"{data.TERMINAL_COMMAND} {cmd}" + except subprocess.CalledProcessError: + # If configured terminal is not available, fall back to defaults + pass + + # Fallback to checking available terminals + terminals = [ + ("kitty", f"kitty -e {cmd}"), + ("alacritty", f"alacritty -e {cmd}"), + ("foot", f"foot {cmd}"), + ("gnome-terminal", f"gnome-terminal -- {cmd}"), + ("konsole", f"konsole -e {cmd}"), + ("xfce4-terminal", f"xfce4-terminal -e '{cmd}'"), + ] + + for term, term_cmd in terminals: + try: + # Check if terminal is available + subprocess.run( + ["which", term], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + return term_cmd + except subprocess.CalledProcessError: + continue + + # Default fallback + return f"kitty -e {cmd}" diff --git a/src/window/launcher/plugins/wallpaper.py b/src/window/launcher/plugins/wallpaper.py new file mode 100644 index 0000000..ce482dd --- /dev/null +++ b/src/window/launcher/plugins/wallpaper.py @@ -0,0 +1,983 @@ +import colorsys +import hashlib +import json +import threading +from typing import Dict, List, Optional + +from fabric.utils import ( + GdkPixbuf, + GLib, + exec_shell_command_async, + logger, + os, + random, + re, + time, +) + +import shared.data as data +from window.launcher.plugin_base import PluginBase +from window.launcher.result import Result + + +class WallpaperPlugin(PluginBase): + """ + Plugin for wallpaper management with search, random selection, and matugen integration. + """ + + def __init__(self): + super().__init__() + self.display_name = "Wallpaper" + self.wallpapers = [] + self.cache_dir = f"{data.CACHE_DIR}/thumbs" + self.thumbnail_cache: Dict[str, Optional[GdkPixbuf.Pixbuf]] = {} + self.thumbnail_loading = set() # Track which thumbnails are being loaded + self.schemes = { + "scheme-tonal-spot": "Tonal Spot", + "scheme-content": "Content", + "scheme-expressive": "Expressive", + "scheme-fidelity": "Fidelity", + "scheme-fruit-salad": "Fruit Salad", + "scheme-monochrome": "Monochrome", + "scheme-neutral": "Neutral", + "scheme-rainbow": "Rainbow", + } + + def initialize(self): + """Initialize the wallpaper plugin.""" + self.set_triggers(["wall"]) + self._load_wallpapers() + os.makedirs(self.cache_dir, exist_ok=True) + # Start background thumbnail creation + self._start_background_thumbnail_creation() + + def cleanup(self): + """Cleanup the wallpaper plugin.""" + pass + + def _load_wallpapers(self): + """Load available wallpapers from the wallpapers directory.""" + try: + if os.path.exists(data.WALLPAPERS_DIR): + self.wallpapers = sorted( + [f for f in os.listdir(data.WALLPAPERS_DIR) if self._is_image(f)] + ) + else: + logger.error(f"Wallpapers directory not found: {data.WALLPAPERS_DIR}") + except Exception as e: + logger.error(f"Error loading wallpapers: {e}") + + def _is_image(self, filename: str) -> bool: + """Check if file is a supported image format.""" + return filename.lower().endswith( + (".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp") + ) + + def _get_matugen_state(self) -> bool: + """Get current matugen state from config.json.""" + self.matugen_enabled = True # Default to True + try: + with open(data.CONFIG_FILE, "r") as f: + config = json.load(f) + self.matugen_enabled = config.get("matugen_enabled", True) + except FileNotFoundError: + # File doesn't exist, keep default True + pass + except Exception as e: + logger.error(f"Error reading config file: {e}") + # Keep default True on error + + return self.matugen_enabled + + def _set_matugen_state(self, enabled: bool): + """Set matugen state and save to config.json.""" + self.matugen_enabled = enabled + + # Save the state to config.json + try: + # Read current config + config = {} + if os.path.exists(data.CONFIG_FILE): + with open(data.CONFIG_FILE, "r") as f: + config = json.load(f) + + # Update matugen state + config["matugen_enabled"] = enabled + + # Write back to config file + with open(data.CONFIG_FILE, "w") as f: + json.dump(config, f, indent=4) + + except Exception as e: + logger.error(f"Error writing matugen state to config: {e}") + + # Clear the search query after toggling matugen + self._clear_launcher_query() + + # # Send notification + # status = "enabled" if enabled else "disabled" + # exec_shell_command_async( + # f"notify-send '🎨 Matugen' 'Dynamic colors {status}' -a '{ + # data.APP_NAME_CAP + # }' -e" + # ) + + def _clear_launcher_query(self): + """Clear the launcher search query and reset to trigger.""" + try: + # Try to access the launcher through the fabric Application + + from fabric import Application + + app = Application.get_default() + + if app and hasattr(app, "launcher"): + launcher = app.launcher + if launcher and hasattr(launcher, "search_entry"): + + def clear_and_refresh(): + # Clear the search entry to just the trigger + launcher.search_entry.set_text("wall ") + # Position cursor at the end + launcher.search_entry.set_position(-1) + # Trigger the search to show default wallpaper view + if hasattr(launcher, "_perform_search"): + launcher._perform_search("wall ") + return False + + # Use a small delay to ensure the action completes first + GLib.timeout_add(50, clear_and_refresh) + return + + # Fallback: try to find launcher instance through other means + import gc + + for obj in gc.get_objects(): + if hasattr(obj, "__class__") and obj.__class__.__name__ == "Launcher": + if hasattr(obj, "search_entry") and hasattr(obj, "_perform_search"): + + def clear_and_refresh(): + obj.search_entry.set_text("wall ") + obj.search_entry.set_position(-1) + obj._perform_search("wall ") + return False + + GLib.timeout_add(50, clear_and_refresh) + return + + except Exception as e: + logger.error(f"Could not clear launcher query: {e}") + + def _get_cache_path(self, filename: str) -> str: + """Get cache path for wallpaper thumbnail.""" + file_hash = hashlib.md5(filename.encode("utf-8")).hexdigest() + return os.path.join(self.cache_dir, f"{file_hash}.png") + + def _create_thumbnail(self, filename: str) -> str: + """Create thumbnail for wallpaper if it doesn't exist.""" + full_path = os.path.join(data.WALLPAPERS_DIR, filename) + cache_path = self._get_cache_path(filename) + + if not os.path.exists(cache_path): + try: + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( + full_path, 32, 32, True + ) + pixbuf.savev(cache_path, "png", [], []) + except Exception as e: + logger.error(f"Error creating thumbnail for {filename}: {e}") + return None + + return cache_path + + def _start_background_thumbnail_creation(self): + """Start background thread to create thumbnails for all wallpapers.""" + + def create_thumbnails(): + for wallpaper in self.wallpapers: + if wallpaper not in self.thumbnail_loading: + self.thumbnail_loading.add(wallpaper) + try: + cache_path = self._create_thumbnail(wallpaper) + if cache_path and os.path.exists(cache_path): + # Load thumbnail into memory cache + try: + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( + cache_path, 32, 32, True + ) + self.thumbnail_cache[wallpaper] = pixbuf + except Exception as e: + logger.error( + f"Error loading thumbnail for {wallpaper}: {e}" + ) + self.thumbnail_cache[wallpaper] = None + else: + self.thumbnail_cache[wallpaper] = None + except Exception as e: + logger.error(f"Error processing thumbnail for {wallpaper}: {e}") + self.thumbnail_cache[wallpaper] = None + finally: + self.thumbnail_loading.discard(wallpaper) + + # Small delay to prevent overwhelming the system + time.sleep(0.01) + + # Start background thread + thread = threading.Thread(target=create_thumbnails, daemon=True) + thread.start() + + def _get_thumbnail_fast(self, filename: str) -> Optional[GdkPixbuf.Pixbuf]: + """Get thumbnail quickly from cache or return None if not ready.""" + # Return cached thumbnail if available + if filename in self.thumbnail_cache: + return self.thumbnail_cache[filename] + + # Check if thumbnail file exists and load it immediately + cache_path = self._get_cache_path(filename) + if os.path.exists(cache_path): + try: + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( + cache_path, 32, 32, True + ) + self.thumbnail_cache[filename] = pixbuf + return pixbuf + except Exception as e: + logger.error(f"Error loading thumbnail for {filename}: {e}") + self.thumbnail_cache[filename] = None + return None + + # If not in cache and file doesn't exist, trigger background creation + if filename not in self.thumbnail_loading: + self.thumbnail_loading.add(filename) + + def create_async(): + try: + cache_path = self._create_thumbnail(filename) + if cache_path and os.path.exists(cache_path): + try: + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( + cache_path, 32, 32, True + ) + self.thumbnail_cache[filename] = pixbuf + except Exception as e: + print(f"Error loading thumbnail for {filename}: {e}") + self.thumbnail_cache[filename] = None + else: + self.thumbnail_cache[filename] = None + except Exception as e: + print(f"Error creating thumbnail for {filename}: {e}") + self.thumbnail_cache[filename] = None + finally: + self.thumbnail_loading.discard(filename) + + thread = threading.Thread(target=create_async, daemon=True) + thread.start() + + return None + + def _set_wallpaper(self, filename: str, scheme: str = None): + """Set wallpaper and apply matugen color scheme if enabled.""" + full_path = os.path.join(data.WALLPAPERS_DIR, filename) + current_wall = os.path.expanduser("~/.current.wall") + + if scheme is None: + scheme = self._get_current_scheme() + + # Update current wallpaper symlink + if os.path.isfile(current_wall) or os.path.islink(current_wall): + os.remove(current_wall) + os.symlink(full_path, current_wall) + + # Always set the wallpaper image + exec_shell_command_async( + f'awww img "{ + full_path + }" -t outer --transition-duration 1.5 --transition-step 255 --transition-fps 60 -f Nearest' + ) + + # If Matugen is enabled, also apply the color scheme + matugen_enabled = self._get_matugen_state() + if matugen_enabled: + exec_shell_command_async( + f'matugen image "{full_path}" -t {scheme} --source-color-index 0' + ) + + 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_val: float = 0.5) -> str: + """Convert HSL color value to RGB HEX string.""" + # colorsys uses HLS, not HSL, and expects values between 0.0 and 1.0 + hue = h / 360.0 + r, g, b = colorsys.hls_to_rgb(hue, l_val, s) # Note the order: H, L, S + r_int, g_int, b_int = int(r * 255), int(g * 255), int(b * 255) + return f"#{r_int:02X}{g_int:02X}{b_int:02X}" + + 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} --source-color-index 0' + ) + + # 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} --source-color-index 0' + ) + + def _apply_hex_color_direct(self, hex_color: str, scheme: str = None): + """Apply hex color using matugen (following example_wallpapers.py pattern).""" + 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} --source-color-index 0' + ) + + # Send notification + exec_shell_command_async( + f"notify-send '🎨 Hex Color Applied' 'Applied color: {hex_color}' -a '{ + data.APP_NAME_CAP + }' -e" + ) + + def _generate_random_hex_color(self) -> str: + """Generate a random hex color.""" + hue = random.randint(0, 360) + return self._hsl_to_rgb_hex(hue) + + def _get_status_indicators(self) -> tuple: + """Get current status indicators for display.""" + current_scheme = self._get_current_scheme() + matugen_enabled = self._get_matugen_state() + scheme_name = self.schemes.get(current_scheme, current_scheme) + + indicators = [] + if not matugen_enabled: + indicators.append("Matugen Off") + + indicator_text = " • " + " • ".join(indicators) if indicators else "" + status_text = f"Matugen: { + 'Enabled' if matugen_enabled else 'Disabled' + } • Scheme: {scheme_name}" + + return indicator_text, status_text, current_scheme, matugen_enabled + + def query(self, query_string: str) -> List[Result]: + """Search wallpapers and provide management options.""" + results = [] + query = query_string.lower().strip() + + # Get status indicators for consistent display + indicator_text, status_text, current_scheme, matugen_enabled = ( + self._get_status_indicators() + ) + + # Special commands + if query.strip() == "random": + # Show result for random wallpaper (execute on Enter) + results.append( + Result( + title=f"Random Wallpaper{indicator_text}", + subtitle=f"Set a random wallpaper • {status_text}", + icon_name="media-playlist-shuffle-symbolic", + action=lambda: self._set_random_wallpaper(), + relevance=1.0, + plugin_name=self.display_name, + data={ + "action": "random", + "bypass_max_results": True, + "keep_launcher_open": True, + }, + ) + ) + elif query.startswith("random") and query.strip() != "random": + # Show suggestion for random wallpaper (partial match) + results.append( + Result( + title=f"Random Wallpaper{indicator_text}", + subtitle=f"Set a random wallpaper • {status_text}", + icon_name="media-playlist-shuffle-symbolic", + action=lambda: self._set_random_wallpaper(), + relevance=0.9, + plugin_name=self.display_name, + data={ + "action": "random_suggestion", + "bypass_max_results": True, + "keep_launcher_open": True, + }, + ) + ) + + # Hex color commands + if "color" in query or "hex" in query or query.startswith("#"): + # Check for scheme specification in the query + scheme = self._get_current_scheme() + for scheme_id, scheme_name in self.schemes.items(): + if ( + scheme_name.lower() in query.lower() + or scheme_id.lower() in query.lower() + ): + scheme = scheme_id + break + + # Check for hex color patterns + hex_match = re.search(r"#?([0-9A-Fa-f]{6})", query) + if hex_match: + hex_color = "#" + hex_match.group(1) + scheme_name = self.schemes.get(scheme, scheme) + + # Check if this is a complete hex color input (6 digits) + # Execute immediately when we have a complete 6-digit hex color + if len(hex_match.group(1)) == 6: + # Check if there's additional text after the hex color + hex_end_pos = hex_match.end() + remaining_text = query[hex_end_pos:].strip() + + # Only show result if no additional text after hex color (exact match) + if not remaining_text: + # Hex colors work when matugen is OFF (following example_wallpapers.py pattern) + if not matugen_enabled: + # Show result for hex color (execute on Enter) + results.append( + Result( + title=f"Apply Hex Color: {hex_color}{ + indicator_text + }", + subtitle=f"Apply with {scheme_name} scheme • { + status_text + }", + icon_name="color-picker-symbolic", + action=lambda c=hex_color, s=scheme: ( + self._apply_hex_color_direct(c, s) + ), + relevance=1.0, + plugin_name=self.display_name, + data={ + "action": "hex_color", + "color": hex_color, + "scheme": scheme, + "bypass_max_results": True, + "keep_launcher_open": True, + }, + ) + ) + else: + # Show error result when matugen is enabled + results.append( + Result( + title=f"Cannot Apply Hex Color: {hex_color}{ + indicator_text + }", + subtitle="Matugen is enabled • Disable matugen to use hex colors", + icon_name="color-picker-symbolic", + action=lambda: None, + relevance=1.0, + plugin_name=self.display_name, + data={ + "action": "hex_color_failed", + "color": hex_color, + "bypass_max_results": True, + }, + ) + ) + else: + # Show suggestion for hex color with additional text (partial match) + results.append( + Result( + title=f"Apply Hex Color: {hex_color}{indicator_text}", + subtitle=f"Apply with {scheme_name} scheme • { + status_text + }", + icon_name="color-picker-symbolic", + action=lambda c=hex_color, s=scheme: ( + self._apply_hex_color_direct(c, s) + if not matugen_enabled + else None + ), + relevance=0.9, + plugin_name=self.display_name, + data={ + "action": "hex_color_suggestion", + "color": hex_color, + "scheme": scheme, + "bypass_max_results": True, + "keep_launcher_open": True, + }, + ) + ) + else: + # Incomplete hex color, show as suggestion + results.append( + Result( + title=f"Hex Color (incomplete): {hex_color}", + subtitle="Complete the 6-digit hex color to apply", + icon_name="color-picker-symbolic", + action=lambda: None, + relevance=0.7, + plugin_name=self.display_name, + data={ + "action": "hex_color_incomplete", + "color": hex_color, + "bypass_max_results": True, + }, + ) + ) + elif "random" in query and ("color" in query or "hex" in query): + # Check for exact matches for random color commands + if ( + query.strip() == "color random" + or query.strip() == "hex random" + or query.strip() == "random color" + or query.strip() == "random hex" + ): + # Random hex color - show result (execute on Enter) + scheme_name = self.schemes.get(scheme, scheme) + + # Check if matugen is disabled (hex colors work when matugen is OFF) + if not matugen_enabled: + results.append( + Result( + title=f"Random Hex Color{indicator_text}", + subtitle=f"Generate and apply random color with { + scheme_name + } scheme • {status_text}", + icon_name="color-picker-symbolic", + action=lambda s=scheme: self._apply_hex_color_direct( + self._generate_random_hex_color(), s + ), + relevance=1.0, + plugin_name=self.display_name, + data={ + "action": "random_hex", + "scheme": scheme, + "bypass_max_results": True, + "keep_launcher_open": True, + }, + ) + ) + else: + # Show error result when matugen is enabled + results.append( + Result( + title=f"Cannot Apply Random Color{indicator_text}", + subtitle="Matugen is enabled • Disable matugen to use hex colors", + icon_name="color-picker-symbolic", + action=lambda: None, + relevance=1.0, + plugin_name=self.display_name, + data={ + "action": "random_hex_failed", + "bypass_max_results": True, + }, + ) + ) + else: + # Show suggestion for random hex color (partial match) + results.append( + Result( + title=f"Random Hex Color{indicator_text}", + subtitle=f"Generate and apply random color • {status_text}", + icon_name="color-picker-symbolic", + action=lambda s=scheme: ( + self._apply_hex_color_direct( + self._generate_random_hex_color(), s + ) + if not matugen_enabled + else None + ), + relevance=0.8, + plugin_name=self.display_name, + data={ + "action": "random_hex_suggestion", + "scheme": scheme, + "bypass_max_results": True, + "keep_launcher_open": True, + }, + ) + ) + else: + # Show hex color help + results.append( + Result( + title="Hex Color Commands", + subtitle="Use: color #FF5733, hex #00FF00, color random", + icon_name="color-picker-symbolic", + action=lambda: None, + relevance=0.8, + plugin_name=self.display_name, + data={"action": "hex_help", "bypass_max_results": True}, + ) + ) + + # Color scheme commands + if "scheme" in query: + current_scheme = self._get_current_scheme() + matugen_enabled = self._get_matugen_state() + + # Show all available schemes + for scheme_id, scheme_name in self.schemes.items(): + # Check if this scheme matches the query (for filtering) + if query.strip() == "scheme": + # Show all schemes when just "scheme" is typed + scheme_matches = True + else: + # Extract search terms from query (remove "scheme" keyword) + search_terms = query.replace("scheme", "").strip().split() + scheme_matches = False + + # Check if any search term matches the scheme name or ID + for term in search_terms: + if ( + term.lower() in scheme_name.lower() + or term.lower() in scheme_id.lower() + ): + scheme_matches = True + break + + if scheme_matches: + # Create indicators + indicators = [] + if scheme_id == current_scheme: + indicators.append("Current") + if not matugen_enabled: + indicators.append("Matugen Off") + + indicator_text = ( + " • " + " • ".join(indicators) if indicators else "" + ) + + results.append( + Result( + title=f"{scheme_name}{indicator_text}", + subtitle=f"Set color scheme to {scheme_name}" + + ( + " (requires matugen enabled)" + if not matugen_enabled + else "" + ), + icon_name="color-management-symbolic", + action=lambda s=scheme_id: self._set_current_scheme(s), + relevance=1.0 if scheme_id == current_scheme else 0.8, + plugin_name=self.display_name, + data={ + "action": "scheme_select", + "scheme": scheme_id, + "bypass_max_results": True, + "keep_launcher_open": True, + }, + ) + ) + + # Matugen controls + if "matugen" in query: + current_state = self._get_matugen_state() + + # Check for exact command matches + if query.strip() == "matugen on" or query.strip() == "matugen enable": + # Show result for enabling matugen (execute on Enter) + results.append( + Result( + title=f"Enable Matugen{indicator_text}", + subtitle=f"Turn on dynamic color generation • {status_text}", + icon_name="color-management-symbolic", + action=lambda: self._set_matugen_state(True), + relevance=0.9, + plugin_name=self.display_name, + data={ + "action": "matugen_on", + "bypass_max_results": True, + "keep_launcher_open": True, + }, + ) + ) + elif query.strip() == "matugen off" or query.strip() == "matugen disable": + # Show result for disabling matugen (execute on Enter) + results.append( + Result( + title=f"Disable Matugen{indicator_text}", + subtitle=f"Turn off dynamic color generation • {status_text}", + icon_name="color-management-symbolic", + action=lambda: self._set_matugen_state(False), + relevance=0.9, + plugin_name=self.display_name, + data={ + "action": "matugen_off", + "bypass_max_results": True, + "keep_launcher_open": True, + }, + ) + ) + elif query.strip() == "matugen toggle": + # Show result for toggling matugen (execute on Enter) + new_state = not current_state + results.append( + Result( + title=f"Toggle Matugen to { + 'Enabled' if new_state else 'Disabled' + }{indicator_text}", + subtitle=f"Switch matugen to { + 'enabled' if new_state else 'disabled' + } • {status_text}", + icon_name="color-management-symbolic", + action=lambda: self._set_matugen_state(new_state), + relevance=0.9, + plugin_name=self.display_name, + data={ + "action": "matugen_toggle", + "bypass_max_results": True, + "keep_launcher_open": True, + }, + ) + ) + elif ("on" in query or "enable" in query) and not query.strip().endswith( + ("on", "enable") + ): + # Show suggestion for enabling (partial match) + results.append( + Result( + title=f"Enable Matugen{indicator_text}", + subtitle=f"Turn on dynamic color generation • {status_text}", + icon_name="color-management-symbolic", + action=lambda: self._set_matugen_state(True), + relevance=0.8, + plugin_name=self.display_name, + data={ + "action": "matugen_on_suggestion", + "bypass_max_results": True, + "keep_launcher_open": True, + }, + ) + ) + elif ("off" in query or "disable" in query) and not query.strip().endswith( + ("off", "disable") + ): + # Show suggestion for disabling (partial match) + results.append( + Result( + title=f"Disable Matugen{indicator_text}", + subtitle=f"Turn off dynamic color generation • {status_text}", + icon_name="color-management-symbolic", + action=lambda: self._set_matugen_state(False), + relevance=0.8, + plugin_name=self.display_name, + data={ + "action": "matugen_off_suggestion", + "bypass_max_results": True, + "keep_launcher_open": True, + }, + ) + ) + elif "toggle" in query and not query.strip().endswith("toggle"): + # Show suggestion for toggling (partial match) + results.append( + Result( + title=f"Toggle Matugen{indicator_text}", + subtitle=f"Switch matugen state • {status_text}", + icon_name="color-management-symbolic", + action=lambda: self._set_matugen_state(not current_state), + relevance=0.8, + plugin_name=self.display_name, + data={ + "action": "matugen_toggle_suggestion", + "bypass_max_results": True, + "keep_launcher_open": True, + }, + ) + ) + else: + # Show current state with scheme info + results.append( + Result( + title=f"Matugen: {'Enabled' if current_state else 'Disabled'}{ + indicator_text + }", + subtitle=f"Dynamic colors • {status_text}", + icon_name="color-management-symbolic", + action=lambda: None, + relevance=0.8, + plugin_name=self.display_name, + data={"action": "matugen_status", "bypass_max_results": True}, + ) + ) + + # Status command + if query == "status" or query == "info": + results.append( + Result( + title=f"Wallpaper System Status{indicator_text}", + subtitle=status_text, + icon_name="color-management-symbolic", + action=lambda: None, + relevance=1.0, + plugin_name=self.display_name, + data={"action": "status", "bypass_max_results": True}, + ) + ) + + # Show quick actions + results.append( + Result( + title=f"Random Wallpaper{indicator_text}", + subtitle=f"Set a random wallpaper • {status_text}", + icon_name="media-playlist-shuffle-symbolic", + action=lambda: self._set_random_wallpaper(), + relevance=0.9, + plugin_name=self.display_name, + data={ + "action": "random_quick", + "bypass_max_results": True, + "keep_launcher_open": True, + }, + ) + ) + + # Search wallpapers by filename - Show ALL wallpapers like example_wallpapers.py + if not query or ( + query + and "matugen" not in query + and "random" not in query + and "scheme" not in query + and "status" not in query + and "info" not in query + and "color" not in query + and "hex" not in query + ): + matching_wallpapers = [] + for wallpaper in self.wallpapers: + if not query or query in wallpaper.lower(): + # Calculate relevance + relevance = 1.0 if query == wallpaper.lower() else 0.7 + if query and query in wallpaper.lower(): + relevance = 0.8 + matching_wallpapers.append((wallpaper, relevance)) + + # Sort by relevance and show ALL wallpapers (like example_wallpapers.py) + matching_wallpapers.sort(key=lambda x: x[1], reverse=True) + + # Show ALL wallpapers instead of limiting (following example_wallpapers.py pattern) + for wallpaper, relevance in matching_wallpapers: + # Use fast thumbnail loading + icon = self._get_thumbnail_fast(wallpaper) + + results.append( + Result( + title=f"{wallpaper}{indicator_text if not query else ''}", + subtitle=f"Set as wallpaper{ + ' • ' + status_text if not query else '' + }", + icon=icon, + icon_name="image-x-generic-symbolic" if not icon else None, + action=lambda w=wallpaper: self._set_wallpaper(w), + relevance=relevance, + plugin_name=self.display_name, + data={ + "wallpaper": wallpaper, + "action": "set", + "bypass_max_results": True, + "keep_launcher_open": True, + }, + ) + ) + + # Sort by relevance - no limit since we use bypass_max_results + results.sort(key=lambda x: x.relevance, reverse=True) + return results diff --git a/src/window/launcher/plugins/websearch.py b/src/window/launcher/plugins/websearch.py new file mode 100644 index 0000000..d48b358 --- /dev/null +++ b/src/window/launcher/plugins/websearch.py @@ -0,0 +1,259 @@ +import subprocess +import urllib.parse +from typing import List + +from window.launcher.plugin_base import PluginBase +from window.launcher.result import Result + + +class WebSearchPlugin(PluginBase): + """ + Web search plugin that supports multiple search engines. + """ + + def __init__(self): + super().__init__() + self.display_name = "Web Search" + self.description = "Search the web using various search engines" + self.current_trigger = "" # Track the current active trigger + + # Define search engines with their URLs and icons + self.search_engines = { + "google": { + "name": "Google", + "url": "https://www.google.com/search?q={}", + "icon": "google-symbolic", + "description": "Search with Google", + }, + "duckduckgo": { + "name": "DuckDuckGo", + "url": "https://duckduckgo.com/?q={}", + "icon": "preferences-desktop-search", + "description": "Search with DuckDuckGo (privacy-focused)", + }, + "youtube": { + "name": "YouTube", + "url": "https://www.youtube.com/results?search_query={}", + "icon": "youtube-symbolic", + "description": "Search videos on YouTube", + }, + "github": { + "name": "GitHub", + "url": "https://github.com/search?q={}", + "icon": "github-desktop-symbolic", + "description": "Search repositories on GitHub", + }, + "stackoverflow": { + "name": "Stack Overflow", + "url": "https://stackoverflow.com/search?q={}", + "icon": "stack", + "description": "Search programming questions on Stack Overflow", + }, + "wikipedia": { + "name": "Wikipedia", + "url": "https://en.wikipedia.org/wiki/Special:Search?search={}", + "icon": "search-menus-symbolic", + "description": "Search articles on Wikipedia", + }, + "reddit": { + "name": "Reddit", + "url": "https://www.reddit.com/search/?q={}", + "icon": "reddit-symbolic", + "description": "Search discussions on Reddit", + }, + "linkedin": { + "name": "LinkedIn", + "url": "https://www.linkedin.com/search/results/all/?keywords={}", + "icon": "link", + "description": "Search professionals and jobs on LinkedIn", + }, + } + + # Default search engine + self.default_engine = "google" + + def initialize(self): + """Initialize the web search plugin.""" + # Set up triggers for web search - only main triggers, not individual search engines + triggers = ["?"] + + # Don't add individual search engine triggers to avoid cluttering trigger keywords + # Search engines can still be used within web search context (e.g., "web google cats") + + self.set_triggers(triggers) + + def get_active_trigger(self, query_string: str) -> str: + """Override to track which trigger was activated.""" + trigger = super().get_active_trigger(query_string) + if trigger: + self.current_trigger = trigger.strip() + return trigger + + def cleanup(self): + """Cleanup the web search plugin.""" + pass + + def query(self, query_string: str) -> List[Result]: + """Process web search queries.""" + results = [] + + if not query_string.strip(): + # Show available search engines when no query + return self._get_search_engine_list() + + # Check if the query is a URL + if self._is_url(query_string): + results.append(self._create_url_result(query_string)) + return results + + # Parse query to check if it starts with a search engine name + engine_name, search_query = self._parse_engine_query(query_string) + + if engine_name and engine_name in self.search_engines: + # Specific search engine specified in query (e.g., "google cats") + if search_query: + # Search with specific engine + results.append(self._create_search_result(engine_name, search_query)) + else: + # Show specific engine info + results.append(self._create_engine_info_result(engine_name)) + else: + # General search - offer multiple engines + search_query = query_string.strip() + + # Add default search engine first + results.append( + self._create_search_result(self.default_engine, search_query) + ) + + # Add other popular search engines + popular_engines = ["duckduckgo", "youtube", "github"] + for engine in popular_engines: + if engine != self.default_engine: + results.append(self._create_search_result(engine, search_query)) + + return results + + def _parse_engine_query(self, query: str) -> tuple[str, str]: + """Parse query to extract search engine and search terms.""" + query = query.strip().lower() + + for engine in self.search_engines.keys(): + if query.startswith(f"{engine} "): + return engine, query[len(engine) :].strip() + elif query == engine: + return engine, "" + + return "", query + + def _is_url(self, text: str) -> bool: + """Check if the text is a URL.""" + text = text.strip().lower() + return text.startswith(("http://", "https://", "www.")) or ( + "." in text and " " not in text and len(text) > 3 + ) + + def _create_url_result(self, url: str) -> Result: + """Create a result for opening a URL directly.""" + # Add protocol if missing + if not url.startswith(("http://", "https://")): + if url.startswith("www."): + url = "https://" + url + else: + url = "https://" + url + + return Result( + title=f"Open {url}", + subtitle="Open this URL in your default browser", + icon_name="link", + action=lambda u=url: self._open_url(u), + relevance=1.0, + plugin_name=self.display_name, + data={"type": "url", "url": url}, + ) + + def _open_url(self, url: str): + """Open a URL directly in the default browser.""" + try: + subprocess.Popen( + ["xdg-open", url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL + ) + except Exception as e: + print(f"Failed to open URL: {e}") + + def _get_search_engine_list(self) -> List[Result]: + """Get list of available search engines.""" + results = [] + + for engine_id, engine_info in self.search_engines.items(): + result = Result( + title=engine_info["name"], + subtitle=engine_info["description"], + icon_name=engine_info["icon"], + action=lambda e=engine_id: self._show_engine_help(e), + relevance=1.0 if engine_id == self.default_engine else 0.8, + plugin_name=self.display_name, + data={"type": "engine_info", "engine": engine_id}, + ) + results.append(result) + + return results + + def _create_search_result(self, engine_id: str, query: str) -> Result: + """Create a search result for a specific engine and query.""" + engine_info = self.search_engines[engine_id] + + return Result( + title=f"Search '{query}' on {engine_info['name']}", + subtitle=f"{engine_info['description']} - {query}", + icon_name=engine_info["icon"], + action=lambda e=engine_id, q=query: self._perform_search(e, q), + relevance=1.0 if engine_id == self.default_engine else 0.9, + plugin_name=self.display_name, + data={"type": "search", "engine": engine_id, "query": query}, + ) + + def _create_engine_info_result(self, engine_id: str) -> Result: + """Create an info result for a specific search engine.""" + engine_info = self.search_engines[engine_id] + + return Result( + title=f"{engine_info['name']} Search", + subtitle=f"{engine_info['description']} - Type your search query", + icon_name=engine_info["icon"], + action=lambda: None, # No action for info result + relevance=1.0, + plugin_name=self.display_name, + data={"type": "engine_ready", "engine": engine_id}, + ) + + def _perform_search(self, engine_id: str, query: str): + """Perform a web search using the specified engine.""" + if not query.strip(): + return + + engine_info = self.search_engines.get(engine_id) + if not engine_info: + return + + # URL encode the search query + encoded_query = urllib.parse.quote_plus(query) + search_url = engine_info["url"].format(encoded_query) + + try: + # Open the search URL in the default browser + subprocess.Popen( + ["xdg-open", search_url], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except Exception as e: + print(f"Failed to open search URL: {e}") + + def _show_engine_help(self, engine_id: str): + """Show help for a specific search engine.""" + engine_info = self.search_engines.get(engine_id) + if engine_info: + print( + f"Search engine: {engine_info['name']} - {engine_info['description']}" + ) diff --git a/src/window/launcher/result.py b/src/window/launcher/result.py new file mode 100644 index 0000000..860225b --- /dev/null +++ b/src/window/launcher/result.py @@ -0,0 +1,52 @@ +from dataclasses import dataclass +from typing import Any, Callable, Optional + +from fabric.utils 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/src/window/launcher/result_item.py b/src/window/launcher/result_item.py new file mode 100644 index 0000000..8576f24 --- /dev/null +++ b/src/window/launcher/result_item.py @@ -0,0 +1,161 @@ +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 window.launcher.result import Result + + +class ResultItem(EventBox): + """ + Widget for displaying a single search result. + """ + + # Signals + @Signal + def clicked(self, index: int) -> None: + """Emitted when result is clicked.""" + pass + + @Signal + def hovered(self, index: int) -> None: + """Emitted when result is hovered.""" + pass + + def __init__( + self, result: Result, selected: bool = False, index: int = 0, **kwargs + ): + super().__init__(name="launcher-result-item", **kwargs) + + self.result = result + self._selected = selected + self.index = index + + # Setup UI + self._setup_ui() + + # Connect signals + self.connect("button-press-event", self._on_button_press) + self.connect("enter-notify-event", self._on_enter) + self.connect("leave-notify-event", self._on_leave) + + # Set initial selection state + self.set_selected(selected) + + def _setup_ui(self): + """Setup the result item UI.""" + # Main container + main_box = Box( + name="result-item-main", + orientation="h", + spacing=12, + h_align="fill", + v_align="center", + ) + self.add(main_box) + + # Icon + if self.result.icon: + icon_widget = Image(pixbuf=self.result.icon, name="result-item-icon") + elif self.result.icon_name: + icon_widget = Image( + icon_name=self.result.icon_name, icon_size=48, name="result-item-icon" + ) + elif self.result.icon_markup: + icon_widget = Label( + markup=self.result.icon_markup, name="launcher-icon-label" + ) + else: + # Default icon + icon_widget = Image( + icon_name="application-default-icon", + icon_size=48, + name="result-item-icon", + ) + + main_box.add(icon_widget) + + # Text container + text_box = Box( + name="result-item-text", + orientation="v", + spacing=2, + h_expand=True, + v_align="center", + ) + main_box.add(text_box) + + # Title + title_label = Label( + label=self.result.title, + name="result-item-title", + h_align="start", + v_align="center", + ellipsize="end", + ) + text_box.add(title_label) + + # Subtitle (if present) + if self.result.subtitle or self.result.subtitle_markup: + if self.result.subtitle_markup: + # Use markup for subtitle (supports Pango markup) + subtitle_label = Label( + markup=self.result.subtitle_markup, + name="result-item-subtitle", + h_align="start", + v_align="center", + ellipsize="end", + ) + else: + # Use plain text for subtitle + subtitle_label = Label( + label=self.result.subtitle, + name="result-item-subtitle", + h_align="start", + v_align="center", + ellipsize="end", + ) + text_box.add(subtitle_label) + + # Plugin name (small text) + if self.result.plugin_name: + plugin_label = Label( + label=f"via {self.result.plugin_name}", + name="result-item-plugin", + h_align="start", + v_align="center", + ellipsize="end", + ) + text_box.add(plugin_label) + + def set_selected(self, selected: bool): + """Set the selection state of this result item.""" + self._selected = selected + + if selected: + self.add_style_class("selected") + else: + self.remove_style_class("selected") + + def get_selected(self) -> bool: + """Get the selection state of this result item.""" + return self._selected + + def _on_button_press(self, widget, event): + """Handle button press events.""" + if event.button == 1: # Left click + self.clicked.emit(self.index) + return True + return False + + def _on_enter(self, widget, event): + """Handle mouse enter events.""" + # Emit hover signal to update selection + self.hovered.emit(self.index) + return False + + def _on_leave(self, widget, event): + """Handle mouse leave events.""" + # Could be used for hover effects cleanup + return False diff --git a/src/window/launcher/trigger_config.py b/src/window/launcher/trigger_config.py new file mode 100644 index 0000000..bbf77e2 --- /dev/null +++ b/src/window/launcher/trigger_config.py @@ -0,0 +1,57 @@ +import json +import os +from typing import Any, Dict, List + +from fabric.utils import get_relative_path + + +class TriggerConfig: + def __init__(self, config_path: str = None): + if config_path is None: + config_path = get_relative_path("../../../config/launcher.json") + + self.config_path = config_path + + # Load configuration from JSON file + config = {"launcher_config": {}, "settings": {}} + if os.path.exists(config_path): + try: + with open(config_path, "r", encoding="utf-8") as f: + config = json.load(f) + except Exception as e: + print(f"Error loading trigger config: {e}") + + self.config = config + self.launcher_config = self.config.get("launcher_config", {}) + + # Initialize settings with defaults + default_settings = { + "max_examples_shown": 2, + "default_icon": "application-default-icon", + "fallback_example_template": "{trigger} <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 = self.launcher_config.get(trigger, {}).get( + "icon", self.settings.get("default_icon", "application-default-icon") + ) + return icon + + def get_trigger_description(self, trigger: str) -> str: + return self.launcher_config.get(trigger, {}).get( + "description", f"{trigger} - No description available" + ) + + def get_all_triggers(self) -> Dict[str, Dict[str, Any]]: + return self.launcher_config.copy() diff --git a/src/window/notification/__init__.py b/src/window/notification/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/window/notification/__init__.py @@ -0,0 +1 @@ + diff --git a/src/window/notification/notification.py b/src/window/notification/notification.py new file mode 100644 index 0000000..d80bfaf --- /dev/null +++ b/src/window/notification/notification.py @@ -0,0 +1,1264 @@ +import hashlib +import time + +from fabric.notifications import ( + Notification, + NotificationAction, + NotificationCloseReason, +) +from fabric.utils import Gdk, GdkPixbuf, GLib, Gtk, logger, os +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.centerbox import CenterBox +from fabric.widgets.eventbox import EventBox +from fabric.widgets.image import Image +from fabric.widgets.label import Label +from fabric.widgets.wayland import WaylandWindow as Window + +import shared.data as data +from services.config import get_config, on_config_change +from services.modus import notification_service +from shared.widgets.clipping_box import ClippingBox +from shared.widgets.custom_image import CustomImage +from shared.widgets.customrevealer import SlideRevealer +from utils.functions import escape_markup_text, parse_timeout_string +from utils.roam import modus_service + +# ruff: noqa: I001 +from window.notification.unified_cache import ( + UNIFIED_NOTIFICATION_CACHE_DIR as _SHARED_NOTIFICATION_CACHE_DIR, + cleanup_cache as _unified_cleanup_cache, + cleanup_old_cache_files as _unified_cleanup_old_cache_files, + ensure_cache_dir as _ensure_unified_cache_dir, + get_fallback_icon as _shared_fallback_icon, + get_from_cache as _unified_get_from_cache, + get_unified_cache_key, + save_to_cache as _unified_save_to_cache, +) + +NOTIFICATION_WIDTH = 360 +NOTIFICATION_IMAGE_SIZE = 48 + +# Use shared unified cache directory from unified_cache +NOTIFICATION_ICON_CACHE_DIR = _SHARED_NOTIFICATION_CACHE_DIR +NOTIFICATION_IMAGE_CACHE_DIR = _SHARED_NOTIFICATION_CACHE_DIR + + +def ensure_notification_cache_dirs(): + """Ensure unified notification cache directory exists""" + _ensure_unified_cache_dir() + + +def cleanup_old_cache_files(): + """Clean up old notification cache files (older than 7 days)""" + try: + _unified_cleanup_old_cache_files() + except Exception as e: + logger.warning(f"Failed to cleanup notification cache: {e}") + + +get_cache_key = get_unified_cache_key + + +def save_pixbuf_to_cache(pixbuf, cache_key, cache_dir): + """Save a pixbuf to the specified cache directory""" + try: + ensure_notification_cache_dirs() + # Delegate to unified cache; ignore returned key for compatibility + result = _unified_save_to_cache(pixbuf, cache_key) + if result and result[0]: + return result[0] + return None + except Exception as e: + logger.warning(f"Failed to cache notification icon: {e}") + return None + + +def get_cached_pixbuf(cache_key, fallback_size=(48, 48), cache_dir=None): + """Get a cached pixbuf or return None if not found""" + if cache_dir is None: + cache_dir = NOTIFICATION_ICON_CACHE_DIR + + try: + pixbuf = _unified_get_from_cache(cache_key, fallback_size) + if pixbuf: + return pixbuf + except Exception as e: + logger.warning(f"Failed to load cached notification icon: {e}") + return None + + +def cache_notification_icon(source, size=(48, 48), app_name=None): + """Optimized notification icon caching with immediate pixbuf generation and caching""" + try: + ensure_notification_cache_dirs() + + # Handle different source types with optimized caching + if isinstance(source, str): + cache_key = get_unified_cache_key(source, size, app_name) + + # Check cache first for immediate return + cached_pixbuf = get_cached_pixbuf( + cache_key, fallback_size=size, cache_dir=NOTIFICATION_ICON_CACHE_DIR + ) + if cached_pixbuf: + return cached_pixbuf + + # Load, cache, and return icon in one optimized flow + if source.startswith("file://"): + # Local file URL - process and cache immediately + file_path = source[7:] + pixbuf = load_and_cache_local_icon(file_path, cache_key, size) + elif os.path.exists(source): + # Direct file path - process and cache immediately + pixbuf = load_and_cache_local_icon(source, cache_key, size) + else: + # Icon name - resolve from theme and cache immediately + pixbuf = load_and_cache_theme_icon(source, cache_key, size) + + return pixbuf + + elif hasattr(source, "scale_simple"): + # Already a pixbuf - cache it directly with optimized flow + cache_key = get_unified_cache_key(source, size, app_name) + + # Check cache first + cached_pixbuf = get_cached_pixbuf( + cache_key, fallback_size=size, cache_dir=NOTIFICATION_ICON_CACHE_DIR + ) + if cached_pixbuf: + return cached_pixbuf + + # Scale once and cache immediately + scaled_pixbuf = source.scale_simple( + size[0], size[1], GdkPixbuf.InterpType.BILINEAR + ) + save_pixbuf_to_cache(scaled_pixbuf, cache_key, NOTIFICATION_ICON_CACHE_DIR) + return scaled_pixbuf + + except Exception as e: + logger.warning(f"Failed to cache notification icon: {e}") + + # Return fallback with caching + return get_fallback_notification_icon(size) + + +def load_and_cache_local_icon(file_path, cache_key, size): + """Load a local icon file and cache it""" + try: + if os.path.exists(file_path): + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( + file_path, size[0], size[1], True + ) + save_pixbuf_to_cache(pixbuf, cache_key, NOTIFICATION_ICON_CACHE_DIR) + return pixbuf + except Exception as e: + logger.warning(f"Failed to load local notification icon {file_path}: {e}") + + return get_fallback_notification_icon(size) + + +def load_and_cache_theme_icon(icon_name, cache_key, size): + """Load an icon from the current theme and cache it""" + # For simplicity, just use fallback since theme icon loading is complex in GTK4 + return get_fallback_notification_icon(size) + + +def get_fallback_notification_icon(size=(48, 48)): + return _shared_fallback_icon(size) + + +def get_notification_image_cache_key(notification_id, image_pixbuf): + """Generate a deterministic cache key based on image content to prevent duplicate caching""" + try: + # Use image content hash for deterministic caching + if image_pixbuf and hasattr(image_pixbuf, "get_pixels"): + try: + pixel_data = image_pixbuf.get_pixels() + image_hash = hashlib.md5(pixel_data).hexdigest()[:8] + return image_hash + except Exception: + # If pixel data fails, use image dimensions + timestamp + try: + width = image_pixbuf.get_width() + height = image_pixbuf.get_height() + dimension_hash = hashlib.md5( + f"{width}x{height}".encode() + ).hexdigest()[:8] + return dimension_hash + except Exception: + pass + + # Fallback to timestamp for invalid pixbufs + return str(int(time.time()))[:8] + except Exception: + # Ultimate fallback + return str(int(time.time()))[:8] + + +def cache_notification_image(notification_id, image_pixbuf, size=(64, 64)): + """Smart notification image caching that avoids duplicate caching""" + try: + ensure_notification_cache_dirs() + + # Generate deterministic cache key based on image content + cache_key = get_notification_image_cache_key(notification_id, image_pixbuf) + try: + # Let unified cache handle scaling and saving + saved_path, _ = _unified_save_to_cache(image_pixbuf, cache_key, size) + if saved_path: + return saved_path, cache_key + return None, None + except Exception as scale_error: + logger.debug( + f"Failed to cache image (temp file likely gone): {scale_error}" + ) + return None, None + + except Exception as e: + logger.warning(f"Failed to cache notification image: {e}") + return None, None + + +def get_cached_notification_image(cache_key): + """Get a cached notification image or return None if not found""" + try: + return _unified_get_from_cache(cache_key) + except Exception as e: + logger.warning(f"Failed to load cached notification image: {e}") + return None + + +def cleanup_notification_image_cache(cache_key=None): + """Clean up notification image cache - specific key or all""" + try: + ensure_notification_cache_dirs() + _unified_cleanup_cache(cache_key) + except Exception as e: + logger.warning(f"Failed to cleanup notification image cache: {e}") + + +def cleanup_notification_specific_caches( + app_icon_source=None, notification_image_cache_key=None +): + """Clean up caches specific to a notification (both app icon and notification image) - SINGLE ICON SIZE""" + try: + # Clean up notification image cache + if notification_image_cache_key: + _unified_cleanup_cache(notification_image_cache_key) + + # Clean up app icon cache for this specific source (only 35x35 version) + if app_icon_source: + # Only clean 35x35 version since we only cache this size now + cache_key_35 = get_unified_cache_key(app_icon_source, (35, 35)) + _unified_cleanup_cache(cache_key_35) + + except Exception as e: + logger.warning(f"Failed to cleanup notification specific caches: {e}") + + +def cleanup_all_notification_caches(): + """Clean up ALL notification caches (icons and images)""" + try: + _unified_cleanup_cache() + except Exception as e: + logger.warning(f"Failed to cleanup all notification caches: {e}") + + +def verify_cache_persistence(): + """Verify that cached icons persist and can be loaded after restart""" + try: + icon_cache_files = [] + image_cache_files = [] + + if os.path.exists(NOTIFICATION_ICON_CACHE_DIR): + icon_cache_files = [ + f for f in os.listdir(NOTIFICATION_ICON_CACHE_DIR) if f.endswith(".png") + ] + + if os.path.exists(NOTIFICATION_IMAGE_CACHE_DIR): + image_cache_files = [ + f + for f in os.listdir(NOTIFICATION_IMAGE_CACHE_DIR) + if f.endswith(".png") + ] + + # Test loading a few cached items to verify they work + for cache_file in icon_cache_files[:2]: # Test first 2 icon files + try: + cache_path = os.path.join(NOTIFICATION_ICON_CACHE_DIR, cache_file) + GdkPixbuf.Pixbuf.new_from_file(cache_path) + except Exception as e: + logger.warning(f"Failed to load cached icon {cache_file}: {e}") + + for cache_file in image_cache_files[:2]: # Test first 2 image files + try: + cache_path = os.path.join(NOTIFICATION_IMAGE_CACHE_DIR, cache_file) + GdkPixbuf.Pixbuf.new_from_file(cache_path) + except Exception as e: + logger.warning(f"Failed to load cached image {cache_file}: {e}") + + return len(icon_cache_files) + len(image_cache_files) > 0 + + except Exception as e: + logger.error(f"Failed to verify cache persistence: {e}") + return False + + +def migrate_persistent_notifications(): + """Migrate persistent notifications to use cached assets when temp files are gone""" + try: + from services.modus import notification_service + + migrated_count = 0 + for cached_notification in notification_service.cached_notifications: + notification = cached_notification._notification + + # Check if notification has image_pixbuf but temp file might be gone + if hasattr(notification, "image_pixbuf") and notification.image_pixbuf: + try: + # Try to access pixel data to test if temp file still exists + notification.image_pixbuf.get_pixels() + except Exception: + # Temp file is gone, ensure app icon is cached as fallback + try: + if hasattr(notification, "app_icon") and notification.app_icon: + cache_notification_icon(notification.app_icon, (35, 35)) + migrated_count += 1 + + except Exception as cache_error: + logger.debug( + f"Failed to cache app icon for {notification.app_name}: { + cache_error + }" + ) + + except Exception as e: + logger.warning(f"Failed to migrate persistent notifications: {e}") + + +# Initialize cache and verify persistence on module load +ensure_notification_cache_dirs() +cleanup_old_cache_files() +verify_cache_persistence() + +# Run migration for persistent notifications on startup +try: + migrate_persistent_notifications() +except Exception as e: + logger.debug(f"Migration skipped (service not ready): {e}") + + +def preload_notification_assets(notification): + """Preload and cache notification assets with robust error handling for persistent notifications - SINGLE ICON SIZE""" + try: + # Cache app icon only at content size (35x35) - scale down for headers at runtime + if hasattr(notification, "app_icon") and notification.app_icon: + try: + # Only cache at 35x35 to reduce disk usage - headers will scale this down + cache_notification_icon(notification.app_icon, (35, 35)) + except Exception as icon_error: + logger.debug( + f"Failed to preload app icon for {notification.app_name}: { + icon_error + }" + ) + + # Cache notification image if available + if hasattr(notification, "image_pixbuf") and notification.image_pixbuf: + cache_notification_image( + notification.id, notification.image_pixbuf, (35, 35) + ) + + except Exception as e: + logger.warning(f"Failed to preload notification assets: {e}") + + +def smooth_revealer_animation(revealer: SlideRevealer, duration: int = 280): + """Configure revealer for ultra-smooth animation""" + revealer.duration = duration + + +class ActionButton(Button): + def __init__( + self, action: NotificationAction, index: int, total: int, notification_box + ): + super().__init__( + name="action-button", + h_expand=True, + on_clicked=self.on_clicked, + child=Label(name="button-label", label=action.label), + ) + self.action = action + self.notification_box = notification_box + style_class = ( + "start-action" + if index == 0 + else "end-action" + if index == total - 1 + else "middle-action" + ) + self.add_style_class(style_class) + self.connect( + "enter-notify-event", lambda *_: notification_box.hover_button(self) + ) + self.connect( + "leave-notify-event", lambda *_: notification_box.unhover_button(self) + ) + + def on_clicked(self, *_): + # Mark for cache cleanup when action button is clicked + self.notification_box._should_cleanup_cache = True + self.action.invoke() + self.action.parent.close("dismissed-by-user") + + +class NotificationWidget(Box): + def __init__( + self, + notification: Notification, + timeout_ms=None, + show_close_button=True, + name="notification", + **kwargs, + ): + # Get current timeout from config manager if not provided + if timeout_ms is None: + timeout_ms = self._get_current_notification_timeout() + + self.show_close_button = show_close_button + self.close_button = None + self._is_hovered = False + self.notification_image_cache_key = None # Track cached image for cleanup + self.app_icon_source = ( + notification.app_icon + ) # Track app icon source for cleanup + self._should_cleanup_cache = False # Only cleanup cache on manual dismissal + + super().__init__( + size=(NOTIFICATION_WIDTH, -1), + name=name, + orientation="v", + h_align="fill", + h_expand=True, + children=[ + self.create_content(notification), + self.create_action_buttons(notification), + ], + ) + + self.notification = notification + self.timeout_ms = timeout_ms + self._timeout_id = None + + # Add hover events to the main notification widget + self.connect("enter-notify-event", self._on_enter_notify) + self.connect("leave-notify-event", self._on_leave_notify) + + self.start_timeout() + + def create_header(self, notification): + """Create notification header with optimized cached app icon - SINGLE CACHE SIZE""" + try: + # Get 35x35 cached icon and scale down to 24x24 for header + cached_app_icon_pixbuf = cache_notification_icon( + notification.app_icon, (35, 35) + ) + + if cached_app_icon_pixbuf: + # Scale down the 35x35 cached icon to 24x24 for header display + header_icon_pixbuf = cached_app_icon_pixbuf.scale_simple( + 24, 24, GdkPixbuf.InterpType.BILINEAR + ) + app_icon = ClippingBox( + name="notification-icon", + children=Image(pixbuf=header_icon_pixbuf), + ) + else: + # Fallback to theme icon if caching fails completely + app_icon = ClippingBox( + name="notification-icon", + children=Image( + icon_name="notifications", + icon_size=24, + ), + ) + except Exception as e: + logger.warning(f"Failed to load cached header icon: {e}") + # Ultimate fallback + app_icon = ClippingBox( + name="notification-icon", + children=Image( + icon_name="notifications", + icon_size=24, + ), + ) + + return CenterBox( + name="notification-title", + start_children=[ + Box( + spacing=4, + children=[ + app_icon, + Label( + notification.app_name, + name="notification-app-name", + h_align="start", + ), + ], + ) + ], + end_children=[ + self.create_close_button() if self.show_close_button else Box() + ], + ) + + def create_content(self, notification): + return Box( + name="notification-content", + spacing=8, + children=[ + ClippingBox( + name="notification-image", + children=Image(pixbuf=self._get_notification_pixbuf(notification)), + ), + Box( + name="notification-text", + orientation="v", + v_align="center", + children=[ + Box( + name="notification-summary-box", + orientation="h", + children=[ + Label( + name="notification-summary", + markup=escape_markup_text( + notification.summary.replace("\n", " ") + ), + h_align="start", + max_chars_width=40, + ellipsization="end", + ), + # Label( + # name="notification-app-name", + # markup=" | " + notification.app_name, + # h_align="start", + # ellipsization="end", + # ), + ], + ), + ( + Label( + markup=escape_markup_text( + notification.body.replace("\n", " ") + ), + h_align="start", + max_chars_width=45, + ellipsization="end", + ) + if notification.body + else Label( + markup="", + h_align="start", + ellipsization="end", + ) + ), + ], + ), + Box(h_expand=True), + Box( + orientation="v", + children=[ + Button( + name="notification-close-button", + image=CustomImage(icon_name="close-symbolic", icon_size=18), + visible=True, # Initially hidden + on_clicked=lambda *_: self._manual_close(), + ), + Box(v_expand=True), + ], + ), + ], + ) + + def _on_enter_notify(self, widget, event): + self._is_hovered = True + if self.close_button: + self.close_button.set_visible(True) + self.pause_timeout() + return False + + def _on_leave_notify(self, widget, event): + self._is_hovered = False + if self.close_button: + self.close_button.set_visible(False) + self.resume_timeout() + return False + + def get_pixbuf(self, icon_path, width, height): + """Get pixbuf with caching support""" + try: + # Use the icon caching system + cached_pixbuf = cache_notification_icon(icon_path, (width, height)) + if cached_pixbuf: + return cached_pixbuf + except Exception as e: + logger.warning(f"Failed to get cached pixbuf for {icon_path}: {e}") + + # Fallback to original method if caching fails + if icon_path.startswith("file://"): + icon_path = icon_path[7:] + + if not os.path.exists(icon_path): + return get_fallback_notification_icon((width, height)) + + try: + pixbuf = GdkPixbuf.Pixbuf.new_from_file(icon_path) + if pixbuf: + return pixbuf.scale_simple(width, height, GdkPixbuf.InterpType.BILINEAR) + else: + return get_fallback_notification_icon((width, height)) + except Exception as e: + logger.error(f"Failed to load or scale icon: {e}") + return get_fallback_notification_icon((width, height)) + + def _get_notification_pixbuf(self, notification): + """Simplified notification pixbuf with NO CACHING for image-pixmap to prevent disk bloat""" + notification_id = getattr(notification, "id", int(time.time())) + + try: + # Try to get cached notification image first + if hasattr(notification, "image_pixbuf") and notification.image_pixbuf: + try: + cache_key = get_notification_image_cache_key( + notification_id, notification.image_pixbuf + ) + cached_image = get_cached_notification_image(cache_key) + if cached_image: + return cached_image + except Exception as image_error: + logger.debug(f"Notification image processing failed: {image_error}") + # Continue to app icon fallback + + except Exception as e: + logger.debug(f"Failed to process notification image: {e}") + + # Use cached app icon as fallback + try: + app_icon_source = getattr(notification, "app_icon", None) + if app_icon_source: + cached_app_icon = cache_notification_icon(app_icon_source, (35, 35)) + if cached_app_icon: + return cached_app_icon + except Exception as e: + logger.debug(f"Failed to get cached app icon: {e}") + + # Ultimate fallback + return get_fallback_notification_icon((35, 35)) + + def create_action_buttons(self, notification): + return Box( + name="notification-action-buttons", + spacing=4, + h_expand=True, + children=[ + ActionButton(action, i, len(notification.actions), self) + for i, action in enumerate(notification.actions) + ], + ) + + def start_timeout(self): + self.stop_timeout() + self._timeout_id = GLib.timeout_add(self.timeout_ms, self.close_notification) + + def stop_timeout(self): + if self._timeout_id is not None: + GLib.source_remove(self._timeout_id) + self._timeout_id = None + + def close_notification(self): + self.notification.close("expired") + self.stop_timeout() + return False + + def pause_timeout(self): + self.stop_timeout() + + def resume_timeout(self): + if not self._is_hovered: # Only resume if not hovered + self.start_timeout() + + def _manual_close(self): + """Handle manual close button click - just close notification""" + self.notification.close("dismissed-by-user") + + def destroy(self): + self.stop_timeout() + # Only clean up caches if this was a manual dismissal + if self._should_cleanup_cache: + cleanup_notification_specific_caches( + app_icon_source=getattr(self, "app_icon_source", None), + notification_image_cache_key=getattr( + self, "notification_image_cache_key", None + ), + ) + else: + logger.debug("Preserved caches for timeout/auto-dismissed notification") + super().destroy() + + # @staticmethod + def set_pointer_cursor(self, widget, cursor_name): + window = widget.get_window() + if window: + cursor = Gdk.Cursor.new_from_name(widget.get_display(), cursor_name) + window.set_cursor(cursor) + + def hover_button(self, button): + self.pause_timeout() + self.set_pointer_cursor(button, "hand2") + + def unhover_button(self, button): + # Don't resume timeout here since the notification itself might still be hovered + self.set_pointer_cursor(button, "arrow") + + @staticmethod + def _get_current_notification_timeout(): + """Get the current notification timeout from config manager.""" + try: + timeout_str = get_config( + "notification_timeout", data.NOTIFICATION_TIMEOUT_STR + ) + from shared.data import parse_timeout_string + + return parse_timeout_string(timeout_str) + except Exception as e: + logger.warning(f"Failed to get notification timeout from config: {e}") + return data.NOTIFICATION_TIMEOUT + + +class NotificationRevealer(SlideRevealer): + def __init__( + self, + notification: Notification, + on_transition_end=None, + parent_window=None, + **kwargs, + ): + self.notif_box = NotificationWidget(notification, show_close_button=False) + self.notification = notification + self.on_transition_end = on_transition_end + # Reference to NotificationCenter window for queue clearing + self.parent_window = parent_window + self._is_closing = False + + # Enhanced swipe detection variables for Android-style animation + self._drag_start_y = 0 + self._drag_start_x = 0 + self._is_dragging = False + self._swipe_threshold = 80 # Distance to trigger auto-dismiss + self._swipe_velocity_threshold = ( + 150 # Velocity to trigger dismiss even on shorter swipes + ) + self._swipe_in_progress = False + self._current_offset = 0 + self._last_drag_time = 0 + self._drag_velocity = 0 + self._spring_back_duration = 200 # Duration for spring-back animation + self._dismiss_threshold = 0.3 # Dismiss if swiped 30% of width + + # Animation state + self._animation_in_progress = False + self._spring_timer_id = None + self._css_provider = None + + # Wrap notification in EventBox for swipe detection + self.event_box = EventBox( + events=[ + "button-press-event", + "button-release-event", + "motion-notify-event", + ], + child=self.notif_box, + ) + + super().__init__( + child=self.event_box, + direction="right", + duration=280, # Faster, smoother duration + ) + + smooth_revealer_animation(self) + + # Connect our own handler that manages the slide animation + self.notification.connect("closed", self.on_resolved) + + self._animation_in_progress = True + + def _ease_out_cubic(self, t): + """Smoother easing function for better animation quality""" + return 1 - pow(1 - t, 3) + + def _ease_out_quart(self, t): + """Even smoother easing for ultra-smooth animations""" + return 1 - pow(1 - t, 4) + + def _apply_transform(self, offset_x, opacity, scale): + """Apply smooth CSS transforms for animation""" + try: + # Create CSS transformation + transform_css = f""" + opacity: {opacity}; + transform: translateX({offset_x}px) scale({scale}); + transition: none; + """ + + # Apply to the notification box + if hasattr(self.notif_box, "get_style_context"): + style_context = self.notif_box.get_style_context() + if style_context: + # Use CSS provider for smooth transforms + if not hasattr(self, "_css_provider") or not self._css_provider: + self._css_provider = Gtk.CssProvider() + style_context.add_provider( + self._css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION + ) + + css_data = f"* {{ {transform_css} }}" + self._css_provider.load_from_data(css_data.encode()) + + except Exception as e: + logger.debug(f"Transform apply failed (non-critical): {e}") + + def _animate_dismiss(self, start_offset): + """Animate the notification sliding out with smooth 60fps animation""" + target_offset = NOTIFICATION_WIDTH + 50 + duration = 200 # Slightly longer for smoother feel + + if self._spring_timer_id: + GLib.source_remove(self._spring_timer_id) + + start_time = GLib.get_monotonic_time() / 1000 + offset_diff = target_offset - start_offset + + def animate_step(): + current_time = GLib.get_monotonic_time() / 1000 + elapsed = current_time - start_time + progress = min(1.0, elapsed / duration) + + # Use smoother easing for premium feel + eased_progress = self._ease_out_quart(progress) + current_offset = start_offset + (offset_diff * eased_progress) + + # Smoother fade and scale transitions + opacity = max(0.0, 1.0 - (progress * 0.9)) # Gentler fade + scale = max(0.9, 1.0 - (progress * 0.1)) # Subtle scale + + self._apply_transform(current_offset, opacity, scale) + + if progress >= 1.0: + # Mark notification for cache cleanup on swipe dismissal + self.notif_box._should_cleanup_cache = True + try: + self.notification.close("dismissed-by-user") + except Exception: + pass + return False + + return True + + # Use consistent 60fps timing + self._animation_in_progress = True + self._spring_timer_id = GLib.timeout_add(16, animate_step) # ~60 FPS + + def _calculate_drag_velocity(self, current_x): + """Calculate the velocity of the drag gesture""" + current_time = GLib.get_monotonic_time() / 1000 + + if self._last_drag_time > 0: + time_diff = current_time - self._last_drag_time + if time_diff > 0: + distance_diff = current_x - self._drag_start_x - self._current_offset + self._drag_velocity = abs(distance_diff / time_diff) + + self._last_drag_time = current_time + + def _on_animation_complete(self, is_hiding=False): + if is_hiding: + # Manually destroy the notification widget since we disconnected its handler + self.notif_box.destroy() + + if self.on_transition_end: + self.on_transition_end() + self.destroy() + + def on_resolved( + self, + _notification: Notification, + reason: NotificationCloseReason, + ): + if self._is_closing: + return + + self._is_closing = True + + # Clean up any ongoing animations + if self._spring_timer_id: + GLib.source_remove(self._spring_timer_id) + + # Use different slide directions based on dismiss reason + if reason == "expired": + # Gentle fade-out for auto-dismiss + self.set_slide_direction("left") + self.duration = 250 # Slightly slower for natural feel + elif self._swipe_in_progress: + # Quick slide for swipe dismissals + self.duration = 150 + self.set_slide_direction("right") + else: + # Smooth slide for manual close + self.set_slide_direction("right") + self.duration = 200 + + self.hide() + # Consistent timing for smooth transitions + timeout_duration = self.duration + 50 + GLib.timeout_add(timeout_duration, lambda: self._on_animation_complete(True)) + + def destroy(self): + # Clean up CSS provider and timers + if self._spring_timer_id: + GLib.source_remove(self._spring_timer_id) + super().destroy() + + +class NotificationState: + IDLE = 0 + SHOWING = 1 + HIDING = 2 + TRANSITIONING = 3 # New state for smooth transitions + + +class ModusNoti(Window): + def __init__(self): + # Local config state mirroring services.config usage + self._current_config = { + "notification_timeout": data.NOTIFICATION_TIMEOUT_STR, + "notification_ignored_apps": data.NOTIFICATION_IGNORED_APPS_HISTORY, + "notification_limited_apps_history": data.NOTIFICATION_LIMITED_APPS_HISTORY, + } + + # Subscribe to config changes and apply initial config + on_config_change(self._on_config_changed) + self._apply_initial_config() + + self._server = notification_service + + self.notifications = Box( + v_expand=True, + h_expand=True, + style="margin: 1px 0px 1px 1px;", + orientation="v", + spacing=5, + ) + + # Enhanced queue system for ultra-smooth transitions + self.notification_queue = [] + self.current_notification = None + self.notification_state = NotificationState.IDLE + self._transition_timer_id = None + self._debounce_timer_id = None + self._last_notification_time = 0 + + # Queue management settings for smooth behavior + self.MAX_QUEUE_SIZE = 3 # Limit queue to prevent overwhelming + self.TRANSITION_DELAY = 100 # Smoother transition timing + self.DEBOUNCE_DELAY = 50 # Prevent rapid fire notifications + + self._server.connect("notification-added", self.on_new_notification) + Window.__init__( + self, + anchor="top right", + child=self.notifications, + layer="overlay", + title="modus-notifications", # More specific title for debugging + all_visible=True, + visible=False, # Start hidden, show only when we have content + exclusive=False, + ) + + def on_new_notification(self, fabric_notif, id): + notification: Notification = fabric_notif.get_notification_from_id(id) + + # Check if notification still exists (might have been removed already) + if not notification: + return + + if self._server.dont_disturb or modus_service.dont_disturb: + # Notification is already cached by the service, just don't show popup + return + + # Check if notification should be ignored based on current config + if self._should_ignore_notification(notification): + return + + # Preload assets immediately for optimal caching and display performance + preload_notification_assets(notification) + + # Implement smart queue management for smooth transitions + GLib.get_monotonic_time() / 1000 + + # If queue is getting full, remove oldest notifications smoothly + if len(self.notification_queue) >= self.MAX_QUEUE_SIZE: + # Remove oldest notification from queue (not current showing one) + if self.notification_queue: + oldest = self.notification_queue.pop(0) + try: + oldest.close("dismissed-by-user") + except Exception: + pass + + # Add new notification to queue + self.notification_queue.append(notification) + + # Debounce rapid notifications for smoother experience + if self._debounce_timer_id: + GLib.source_remove(self._debounce_timer_id) + + self._debounce_timer_id = GLib.timeout_add( + self.DEBOUNCE_DELAY, + lambda: self._process_notification_queue_debounced() or False, + ) + + def _process_notification_queue_debounced(self): + """Process queue after debounce delay for smooth transitions""" + self._debounce_timer_id = None + self._process_notification_queue() + return False + + def _process_notification_queue(self): + # If we're currently showing a notification and there's a new one in queue + if ( + self.notification_state == NotificationState.SHOWING + and self.current_notification + and self.notification_queue + ): + # Smooth transition: start hiding current notification + self.notification_state = NotificationState.TRANSITIONING + self._start_smooth_transition() + + elif ( + self.notification_state == NotificationState.IDLE + and self.notification_queue + ): + # If we're idle and have notifications in queue, show the next one + self._show_next_notification() + + def _start_smooth_transition(self): + """Start smooth transition between notifications""" + if self.current_notification and not self.current_notification._is_closing: + # Don't mark for cache cleanup during smooth transitions + # to maintain performance + + # Use shorter timeout for smooth transitions + self.current_notification.notif_box.timeout_ms = 100 + + # Force close current notification with smooth animation + try: + self.current_notification.notification.close("expired") + except Exception: + pass + + def _show_next_notification(self): + if ( + not self.notification_queue + or self.notification_state != NotificationState.IDLE + ): + return + + notification = self.notification_queue.pop(0) + + # Check if notification is still valid (might have been removed) + if not notification or not hasattr(notification, "app_icon"): + # Skip invalid notifications and try next one + if self.notification_queue: + self._show_next_notification() + return + + self.notification_state = NotificationState.SHOWING + + new_box = NotificationRevealer( + notification, + on_transition_end=lambda: self._on_notification_finished(new_box), + parent_window=self, + ) + + self.current_notification = new_box + + # Clear any existing children + for child in list(self.notifications.children): + try: + self.notifications.remove(child) + except Exception: + pass + + self.notifications.children = [new_box] + + # Show the window now that we have content to display + self.set_visible(True) + + new_box.show() + self.notifications.queue_resize() + + def start_animation(): + if new_box.get_parent() and new_box.get_realized(): + new_box.reveal() + return False + return True + + GLib.idle_add(start_animation) + + def _on_notification_finished(self, notification_box): + if notification_box != self.current_notification: + return + + # Cancel any pending transition timer + if self._transition_timer_id: + GLib.source_remove(self._transition_timer_id) + self._transition_timer_id = None + + # Safely remove notification box + try: + if notification_box in self.notifications.children: + self.notifications.remove(notification_box) + except Exception: + pass + + # Reset state + self.current_notification = None + self.notification_state = NotificationState.IDLE + + # Process next notification with optimized delay for ultra-smooth transitions + if self.notification_queue: + self._transition_timer_id = GLib.timeout_add( + self.TRANSITION_DELAY, # Consistent smooth timing + lambda: self._show_next_notification() or False, + ) + else: + # Hide window when no more notifications to show + self.set_visible(False) + + def show_next_notification(self): + # Legacy method for compatibility - redirect to new implementation + self._show_next_notification() + + def on_notification_finished(self, notification_box): + # Legacy method for compatibility - redirect to new implementation + self._on_notification_finished(notification_box) + + def clear_notification_queue(self): + """Clear queue with smooth cleanup""" + queue_length = len(self.notification_queue) + if queue_length > 0: + # Smooth dismissal of queued notifications + for notification in list(self.notification_queue): + try: + notification.close("dismissed-by-user") + except Exception: + pass + self.notification_queue.clear() + + # Also clean current notification if showing + if self.current_notification: + # Mark for cache cleanup when clearing queue + self.current_notification.notif_box._should_cleanup_cache = True + + # Clear animation timers + if self._transition_timer_id: + GLib.source_remove(self._transition_timer_id) + self._transition_timer_id = None + + if self._debounce_timer_id: + GLib.source_remove(self._debounce_timer_id) + self._debounce_timer_id = None + + def get_queue_length(self): + return len(self.notification_queue) + + def _should_ignore_notification(self, notification): + """Check if notification should be ignored based on current config.""" + try: + ignored_apps = self._current_config.get("notification_ignored_apps", []) + app_name = getattr(notification, "app_name", "") + + # Check if app name is in ignored list + if app_name in ignored_apps: + return True + + return False + except Exception as e: + logger.warning(f"Failed to check if notification should be ignored: {e}") + return False + + def update_config(self, new_config): + """Update notification configuration dynamically.""" + try: + # Handle notification timeout changes + if "notification_timeout" in new_config: + timeout_str = new_config["notification_timeout"] + # Parse the timeout string to milliseconds + + parse_timeout_string(timeout_str) + + # Handle ignored apps changes + if "notification_ignored_apps" in new_config: + new_config["notification_ignored_apps"] + + # Handle limited apps history changes + if "notification_limited_apps_history" in new_config: + new_config["notification_limited_apps_history"] + + # Update the current config + self._current_config.update(new_config) + + except Exception as e: + logger.error(f"[ModusNoti] Failed to update config: {e}") + + def _apply_initial_config(self): + try: + timeout_val = get_config( + "notification_timeout", data.NOTIFICATION_TIMEOUT_STR + ) + ignored_apps = get_config( + "notification_ignored_apps", data.NOTIFICATION_IGNORED_APPS_HISTORY + ) + limited_apps = get_config( + "notification_limited_apps_history", + data.NOTIFICATION_LIMITED_APPS_HISTORY, + ) + initial = { + "notification_timeout": timeout_val, + "notification_ignored_apps": ignored_apps, + "notification_limited_apps_history": limited_apps, + } + # Only apply if different + self.update_config(initial) + except Exception as e: + logger.error(f"[ModusNoti] Failed to apply initial config: {e}") + + def _on_config_changed(self, new_config: dict, old_config: dict): + try: + changes = {} + for key in ( + "notification_timeout", + "notification_ignored_apps", + "notification_limited_apps_history", + ): + if key in new_config and new_config.get(key) != old_config.get(key): + changes[key] = new_config.get(key) + + if changes: + self.update_config(changes) + except Exception as e: + logger.error(f"[ModusNoti] Error handling config change: {e}") diff --git a/src/window/notification/notification_center.py b/src/window/notification/notification_center.py new file mode 100644 index 0000000..e519389 --- /dev/null +++ b/src/window/notification/notification_center.py @@ -0,0 +1,869 @@ +from collections import defaultdict + +from fabric.utils import GLib, logger +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.centerbox import CenterBox +from fabric.widgets.eventbox import EventBox +from fabric.widgets.image import Image +from fabric.widgets.label import Label +from fabric.widgets.revealer import Revealer +from fabric.widgets.scrolledwindow import ScrolledWindow +from fabric.widgets.wayland import WaylandWindow as Window + +import shared.data as data +from services.modus import notification_service +from shared.widgets.clipping_box import ClippingBox +from shared.widgets.custom_image import CustomImage +from utils.functions import escape_markup_text +from window.notification.notification import ( + NotificationWidget, + cache_notification_icon, + cleanup_all_notification_caches, + cleanup_notification_specific_caches, + get_cached_notification_image, + preload_notification_assets, +) +from window.notification.unified_cache import ( + get_fallback_icon as get_fallback_notification_icon, +) +from window.notification.unified_cache import ( + get_from_cache, +) + + +class ExpandableNotificationGroup(Box): + def __init__(self, app_name, notifications, **kwargs): + super().__init__( + name="notification-group", orientation="v", spacing=0, **kwargs + ) + + self.app_name = app_name + self.notifications = notifications + self.is_expanded = False # Always start collapsed + + # Create collapsed state first (shows only latest notification) + self.create_collapsed_state() + + # Create expanded state (hidden initially) + self.create_expanded_state() + + # Ensure we start in collapsed state + self.collapsed_eventbox.set_visible(True) + self.expanded_container.set_visible(False) + + def create_collapsed_state(self): + latest_notification = self.notifications[0] # Most recent notification + + # Create clickable event box + self.collapsed_eventbox = EventBox( + events=["button-press-event"], + ) + self.collapsed_eventbox.connect("button-press-event", self.on_clicked) + + # Only create stacked effect if we have multiple notifications + num_notifications = len(self.notifications) + + if num_notifications == 1: + # Single notification - no stacking needed + notification_widget = NotificationCenterWidget( + notification=latest_notification + ) + self.collapsed_eventbox.add(notification_widget) + else: + # Multiple notifications - create stacked effect + # Create container for the entire stack + stack_container = Box( + name="notification-stack-container", + orientation="v", + spacing=0, + ) + + # Add bottom shadow layer first (deepest) + if num_notifications >= 3: + bottom_shadow = Box( + name="stack-shadow-bottom", + ) + stack_container.add(bottom_shadow) + + # Add middle shadow layer + if num_notifications >= 2: + middle_shadow = Box( + name="stack-shadow-middle", + ) + stack_container.add(middle_shadow) + + # Add the main notification content on top + main_notification = Box( + name="stack-main-notification", + spacing=8, + children=[ + ClippingBox( + name="notification-image", + children=Image( + pixbuf=self._get_notification_pixbuf_for_group( + latest_notification + ) + ), + ), + Box( + name="notification-text", + orientation="v", + v_align="center", + h_expand=True, + children=[ + Box( + name="notification-summary-box", + orientation="h", + children=[ + Label( + name="notification-summary", + markup=f"<b>{self.app_name}</b>", + h_align="start", + ellipsization="end", + ), + ], + ), + Label( + name="notification-body", + markup=escape_markup_text( + latest_notification._notification.summary.replace( + "\n", " " + ) + ), + max_chars_width=25, + h_align="start", + ellipsization="end", + ), + ], + ), + Box( + name="notification-count", + orientation="v", + children=[ + Button( + name="notification-close", + image=CustomImage( + icon_name="close-symbolic", icon_size=18 + ), + visible=True, + on_clicked=lambda *_: ( + self._close_single_notification_and_stop_propagation( + latest_notification + ) + ), + ), + Label( + name="notification-count-label", + label=f"{len(self.notifications)}", + h_align="end", + ), + ], + ), + ], + ) + stack_container.add(main_notification) + self.collapsed_eventbox.add(stack_container) + + self.add(self.collapsed_eventbox) + + # Create expanded state (hidden initially) + self.create_expanded_state() + + def _get_notification_pixbuf_for_group(self, cached_notification): + """Get notification pixbuf using cached image key - fallback to app icon""" + notification = cached_notification._notification + getattr(notification, "id", None) + + # First try to get cached notification image using stored cache key + if ( + hasattr(cached_notification, "cache_metadata") + and cached_notification.cache_metadata + ): + notification_image_cache_key = cached_notification.cache_metadata.get( + "notification_image_cache_key" + ) + if notification_image_cache_key: + try: + cached_image = get_cached_notification_image( + notification_image_cache_key + ) + if cached_image: + return cached_image + except Exception as e: + logger.debug(f"Failed to load cached notification image: {e}") + + # Fallback to app icon using cached key + if ( + hasattr(cached_notification, "cache_metadata") + and cached_notification.cache_metadata + ): + app_icon_cache_key = cached_notification.cache_metadata.get( + "app_icon_cache_key" + ) + if app_icon_cache_key: + try: + cached_app_icon = get_from_cache(app_icon_cache_key, (35, 35)) + if cached_app_icon: + return cached_app_icon + except Exception as e: + logger.debug(f"Failed to load cached app icon: {e}") + + # Final fallback - try to cache app icon directly if available + try: + app_icon_source = getattr(notification, "app_icon", None) + if app_icon_source: + cached_app_icon = cache_notification_icon(app_icon_source, (35, 35)) + if cached_app_icon: + return cached_app_icon + except Exception as e: + logger.debug(f"Failed to get directly cached app icon: {e}") + + # Ultimate fallback + return get_fallback_notification_icon((35, 35)) + + def create_expanded_state(self): + # Create main expanded container + self.expanded_container = Box( + name="notification-group-expanded-container", + orientation="v", + spacing=0, + ) + + # Header with app name and controls + self.header_content = Box( + orientation="h", + h_expand=True, + children=[ + Label( + name="notification-group-title", + markup=f"<b>{self.app_name}</b>", + h_align="start", + h_expand=True, + ), + Button( + name="notification-show-less", + label="Show less", + on_clicked=self.collapse, + h_align="end", + ), + Button( + name="notification-close-summery", + h_expand=False, + v_expand=False, + on_clicked=self.close_all, + image=CustomImage( + icon_name="close-symbolic", + name="notification-close-header", + icon_size=18, + h_align="end", + ), + visible=True, + ), + ], + ) + + # Wrap header in revealer for slide-up animation during collapse + self.header_revealer = Revealer( + child=self.header_content, + transition_type="slide-up", + transition_duration=300, + child_revealed=False, + ) + + # Box for individual notifications + self.notifications_list = Box( + name="notification-group-notifications", + orientation="v", + spacing=5, + ) + + # Add individual notifications to the list + for notification in self.notifications: + notification_widget = NotificationCenterWidget(notification=notification) + self.notifications_list.add(notification_widget) + + # Wrap notifications list in revealer for slide-down animation + self.notifications_revealer = Revealer( + child=self.notifications_list, + transition_type="slide-down", + transition_duration=300, + child_revealed=False, + ) + + # Wrap notifications revealer in crossfade revealer for closing animation + self.notifications_crossfade = Revealer( + child=self.notifications_revealer, + transition_type="crossfade", + transition_duration=250, + child_revealed=True, # Start revealed so crossfade works on close + ) + + # Add header revealer and notifications crossfade to container + self.expanded_container.add(self.header_revealer) + self.expanded_container.add(self.notifications_crossfade) + + # Add the container to the main group + self.add(self.expanded_container) + + # Hide the entire expanded container initially + self.expanded_container.set_visible(False) + + def on_clicked(self, widget, event): + if event.button == 1: # Left click + # Always allow expansion, even for single notifications + # This ensures single notifications can show their expanded entry view + self.expand() + return True + + def expand(self, *args): + """Expand to show all notifications in this group with slide-down animation""" + self.is_expanded = True + self.collapsed_eventbox.set_visible(False) + self.expanded_container.set_visible(True) + + # Show header immediately (no animation on expand) + self.header_revealer.set_reveal_child(True) + + # Ensure crossfade is revealed for expand + self.notifications_crossfade.set_reveal_child(True) + + # Small delay then animate notifications sliding down + GLib.timeout_add(50, lambda: self.notifications_revealer.set_reveal_child(True)) + + def collapse(self, *args): + """Collapse with header sliding up, notifications crossfading, then sliding up""" + self.is_expanded = False + + # Start header slide-up and notifications crossfade simultaneously + self.header_revealer.set_reveal_child(False) + self.notifications_crossfade.set_reveal_child(False) + + # Show collapsed state and hide expanded container halfway through crossfade + GLib.timeout_add(125, self._show_collapsed_midway) + + # After crossfade completes, start slide-up animation (just for cleanup) + GLib.timeout_add( + 260, lambda: self.notifications_revealer.set_reveal_child(False) + ) + + def _show_collapsed_midway(self): + """Show collapsed state and hide expanded container to prevent deformation""" + self.collapsed_eventbox.set_visible(True) + self.expanded_container.set_visible(False) + return False # Don't repeat timeout + + def _complete_collapse(self): + """Complete the collapse animation - no longer needed but kept for compatibility""" + return False # Don't repeat timeout + + def close_all(self, *args): + """Close all notifications in this group with proper cache cleanup""" + # Close all notifications in this group + for notification in self.notifications: + try: + # Get notification cache metadata for cleanup + cache_metadata = getattr(notification, "cache_metadata", {}) + + # Clean up caches using stored metadata + cleanup_notification_specific_caches( + app_icon_source=notification._notification.app_icon, + notification_image_cache_key=cache_metadata.get( + "notification_image_cache_key" + ), + ) + + notification_service.remove_cached_notification(notification.cache_id) + except Exception as e: + logger.error( + f"Error removing notification {notification.cache_id}: {e}" + ) + + def _close_single_notification(self, notification): + """Close a single notification from this group with proper cache cleanup""" + try: + # Get notification cache metadata for cleanup + cache_metadata = getattr(notification, "cache_metadata", {}) + + # Clean up caches using stored metadata + cleanup_notification_specific_caches( + app_icon_source=notification._notification.app_icon, + notification_image_cache_key=cache_metadata.get( + "notification_image_cache_key" + ), + ) + + notification_service.remove_cached_notification(notification.cache_id) + except Exception as e: + logger.error( + f"Error removing single notification {notification.cache_id}: {e}" + ) + + def _close_single_notification_and_stop_propagation(self, notification): + """Close notification and prevent click from expanding the group""" + self._close_single_notification(notification) + + # If this was the last notification in the group, the group will be removed + # by the notification_removed signal handler. If there are still notifications, + # we need to check if this group should be removed from view. + remaining_notifications = [ + n for n in self.notifications if n.cache_id != notification.cache_id + ] + + if not remaining_notifications: + # This was the last notification, the group will be destroyed by signal handler + pass + else: + # Update the notifications list and refresh the view immediately + self.notifications = remaining_notifications + # Force immediate UI update by destroying and recreating collapsed state + if hasattr(self, "collapsed_eventbox"): + self.collapsed_eventbox.destroy() + self.create_collapsed_state() + self.show_all() + + return True # Stop event propagation + + +class NotificationCenterWidget(NotificationWidget): + def __init__(self, notification, **kwargs): + self.notification_id = notification.cache_id + self.cache_metadata = getattr(notification, "cache_metadata", {}) + + super().__init__( + notification._notification, + timeout_ms=0, + show_close_button=True, + name="notification-centre-notifs", + **kwargs, + ) + + def _get_notification_pixbuf(self, notification): + """Get notification pixbuf using cached image key - fallback to app icon""" + getattr(notification, "id", None) + + # First try to get cached notification image using stored cache key + if self.cache_metadata: + notification_image_cache_key = self.cache_metadata.get( + "notification_image_cache_key" + ) + if notification_image_cache_key: + try: + cached_image = get_cached_notification_image( + notification_image_cache_key + ) + if cached_image: + return cached_image + except Exception as e: + logger.debug(f"Failed to load cached notification image: {e}") + + # Fallback to app icon using cached key + if self.cache_metadata: + app_icon_cache_key = self.cache_metadata.get("app_icon_cache_key") + if app_icon_cache_key: + try: + cached_app_icon = get_from_cache(app_icon_cache_key, (35, 35)) + if cached_app_icon: + return cached_app_icon + except Exception as e: + logger.debug(f"Failed to load cached app icon: {e}") + + # Final fallback - try to cache app icon directly if available + try: + app_icon_source = getattr(notification, "app_icon", None) + if app_icon_source: + cached_app_icon = cache_notification_icon(app_icon_source, (35, 35)) + if cached_app_icon: + return cached_app_icon + except Exception as e: + logger.debug(f"Failed to get directly cached app icon: {e}") + + # Ultimate fallback + return get_fallback_notification_icon((35, 35)) + + def create_content(self, notification): + # Create our custom close button for notification center + + self.close_button = Button( + name="notif-close-button", + image=CustomImage( + icon_name="close-symbolic", name="notification-close", icon_size=18 + ), + visible=True, # Always visible in notification center + on_clicked=self._on_close_clicked, + ) + self.close_button.connect( + "enter-notify-event", lambda *_: self.hover_button(self.close_button) + ) + self.close_button.connect( + "leave-notify-event", lambda *_: self.unhover_button(self.close_button) + ) + + # Create the content box manually with our custom close button + return Box( + name="notification-content", + spacing=8, + children=[ + ClippingBox( + name="notification-image", + children=Image(pixbuf=self._get_notification_pixbuf(notification)), + ), + Box( + name="notification-text", + orientation="v", + v_align="center", + children=[ + Box( + name="notification-summary-box", + orientation="h", + children=[ + Label( + name="notification-summary", + markup=escape_markup_text( + notification.summary.replace("\n", " ") + ), + h_align="start", + max_chars_width=25, + ellipsization="end", + ), + ], + ), + ( + Label( + markup=escape_markup_text( + notification.body.replace("\n", " ") + ), + h_align="start", + max_chars_width=35, + ellipsization="end", + ) + if notification.body + else Label( + markup="", + h_align="start", + ellipsization="end", + ) + ), + ], + ), + Box(h_expand=True), + Box( + orientation="v", + children=[ + self.close_button, # Use our custom close button + Box(v_expand=True), + ], + ), + ], + ) + + # Override to disable the action buttons + def create_action_buttons(self, notification): + return Box(name="notification-action-buttons") + + def _on_close_clicked(self, *args): + try: + # Use instance cache metadata instead of notification attribute + cache_metadata = self.cache_metadata + + # Clean up caches using stored metadata + cleanup_notification_specific_caches( + app_icon_source=self.notification.app_icon, + notification_image_cache_key=cache_metadata.get( + "notification_image_cache_key" + ), + ) + + notification_service.remove_cached_notification(self.notification_id) + except Exception as e: + logger.error(f"Error removing notification {self.notification_id}: {e}") + + # Override to disable timeout functionality + def start_timeout(self): + pass + + # Override to disable timeout functionality + def stop_timeout(self): + pass + + # Override to disable auto-close functionality + def close_notification(self): + return False + + +class NotificationCenter(Window): + def __init__(self): + super().__init__( + layer="overlay", + anchor="top right", + visible=False, + keyboard_mode="on-demand", + title="modus", + ) + + NOTIFICATION_CENTER_WIDTH = 410 + self.set_size_request(NOTIFICATION_CENTER_WIDTH, 600) + + # Group notifications by app name + self.notification_groups = defaultdict(list) + self.group_widgets = {} + + notification_service.connect( + "cached-notification-added", self.on_notification_added + ) + notification_service.connect( + "cached-notification-removed", self.on_notification_removed + ) + notification_service.connect("clear-all", self.on_clear_all) + notification_service.connect("notify::count", self.on_count_changed) + + main_box = Box( + orientation="v", + spacing=5, + name="noti-center-box", + ) + + self.scrolled = ScrolledWindow(h_expand=False, v_expand=False) + self.notifications_box = Box( + v_expand=False, + h_expand=False, + style="margin: 1px 0px 1px 1px;", + orientation="v", + spacing=5, + ) + self.scrolled.add(self.notifications_box) + main_box.add(self.scrolled) + + # No notifications label - REMOVED + + self.clear_all_button = Button( + name="noti-clear-button", + label="Clear", + on_clicked=self.clear_all_notifications, + visible=(notification_service.count > 0), + ) + self.button_centre_box = CenterBox( + center_children=[self.clear_all_button], + ) + main_box.add(self.button_centre_box) + + # Wrap main content in revealer for slide-left animation + self.main_revealer = Revealer( + child=main_box, + transition_type="slide-left", + transition_duration=400, + child_revealed=False, + ) + + self.children = self.main_revealer + + # Load existing notifications and group them + self._rebuild_notification_groups() + + self.add_keybinding("Escape", self._on_escape_pressed) + + def _rebuild_notification_groups(self): + """Rebuild notification groups from scratch with enhanced asset preloading and debugging""" + # Clear existing groups + self.notification_groups.clear() + self.group_widgets.clear() + + # Clear notifications box + for child in self.notifications_box.get_children(): + child.destroy() + + # Group notifications by app name and preload assets with debugging + rebuild_count = 0 + for cached_notification in notification_service.cached_notifications: + app_name = cached_notification._notification.app_name + getattr(cached_notification._notification, "id", None) + + # Skip ignored apps during rebuild + if app_name in data.NOTIFICATION_IGNORED_APPS_HISTORY: + continue + + # Preload assets for each cached notification to ensure display consistency + preload_notification_assets(cached_notification._notification) + + self.notification_groups[app_name].append(cached_notification) + rebuild_count += 1 + + # Create group widgets and handle limited apps + for app_name, notifications in self.notification_groups.items(): + # Sort notifications by ID (highest ID first - newest notifications) + # This ensures the latest notifications appear at the top of each group + notifications.sort( + key=lambda n: getattr(n._notification, "id", 0), reverse=True + ) + + # Handle limited apps history - only keep 5 notifications during rebuild + if app_name in data.NOTIFICATION_LIMITED_APPS_HISTORY: + if len(notifications) > 5: + # Keep only the 5 most recent notifications + self.notification_groups[app_name] = notifications[:5] + + group_widget = ExpandableNotificationGroup( + app_name, self.notification_groups[app_name] + ) + self.group_widgets[app_name] = group_widget + self.notifications_box.add(group_widget) + + def on_notification_added(self, service, cached_notification): + try: + app_name = cached_notification._notification.app_name + + # Check if this app should be ignored for history (don't add to notification center) + if app_name in data.NOTIFICATION_IGNORED_APPS_HISTORY: + return + + # Preload assets for notification center display (ensure caching consistency) + preload_notification_assets(cached_notification._notification) + + # Add to groups in sorted order (highest ID first - maintains newest-first ordering) + notifications_list = self.notification_groups[app_name] + new_notification_id = getattr(cached_notification._notification, "id", 0) + + # Find the correct position to insert based on ID (highest first) + insert_position = 0 + for i, existing_notification in enumerate(notifications_list): + existing_id = getattr(existing_notification._notification, "id", 0) + if new_notification_id > existing_id: + insert_position = i + break + insert_position = i + 1 + + notifications_list.insert(insert_position, cached_notification) + + # Handle limited apps history - only keep 5 notifications + if app_name in data.NOTIFICATION_LIMITED_APPS_HISTORY: + notifications_for_app = self.notification_groups[app_name] + if len(notifications_for_app) > 5: + # Remove oldest notifications beyond the limit + excess_notifications = notifications_for_app[5:] + self.notification_groups[app_name] = notifications_for_app[:5] + + # Remove excess notifications from the service cache + for excess_notification in excess_notifications: + try: + notification_service.remove_cached_notification( + excess_notification.cache_id + ) + except Exception as e: + logger.error(f"Error removing excess notification: {e}") + + # Update or create group widget + if app_name in self.group_widgets: + # Update existing group + group_widget = self.group_widgets[app_name] + group_widget.notifications = self.notification_groups[app_name] + # Refresh the group widget + self._refresh_group_widget(group_widget) + else: + # Create new group widget + group_widget = ExpandableNotificationGroup( + app_name, self.notification_groups[app_name] + ) + self.group_widgets[app_name] = group_widget + self.notifications_box.pack_start(group_widget, False, False, 0) + group_widget.show_all() + + except Exception as e: + logger.error(f"Error adding notification to group: {e}") + + def _refresh_group_widget(self, group_widget): + """Refresh a group widget's content""" + try: + # Remove existing children + for child in group_widget.get_children(): + group_widget.remove(child) + + # Recreate content + group_widget.create_collapsed_state() + group_widget.show_all() + + except Exception as e: + logger.error(f"Error refreshing group widget: {e}") + + def on_notification_removed(self, service, cached_notification): + try: + app_name = cached_notification._notification.app_name + + # Remove from groups + if app_name in self.notification_groups: + self.notification_groups[app_name] = [ + n + for n in self.notification_groups[app_name] + if n.cache_id != cached_notification.cache_id + ] + + # If no more notifications for this app, remove group widget + if not self.notification_groups[app_name]: + if app_name in self.group_widgets: + group_widget = self.group_widgets[app_name] + group_widget.destroy() + del self.group_widgets[app_name] + del self.notification_groups[app_name] + else: + # Update existing group widget + group_widget = self.group_widgets[app_name] + group_widget.notifications = self.notification_groups[app_name] + self._refresh_group_widget(group_widget) + + # Clean up caches + cleanup_notification_specific_caches( + app_icon_source=getattr(cached_notification, "app_icon_source", None), + notification_image_cache_key=getattr( + cached_notification, "notification_image_cache_key", None + ), + ) + + except Exception as e: + logger.error(f"Error removing notification from group: {e}") + + def on_clear_all(self, service): + try: + # Clear all groups + self.notification_groups.clear() + self.group_widgets.clear() + + # Clear all remaining cached notification images AND icons + cleanup_all_notification_caches() + for child in self.notifications_box.get_children(): + child.destroy() + except Exception as e: + logger.error(f"Error clearing notification groups: {e}") + + def on_count_changed(self, service, count=None): + current_count = notification_service.count + # No notifications label removed - only update clear button and scrolled visibility + self.clear_all_button.set_visible(current_count > 0) + self.scrolled.set_visible(current_count > 0) + + # Auto-close notification center when no notifications remain + if current_count == 0 and hasattr(self, "mousecapture"): + self.mousecapture.hide_child_window() + + def clear_all_notifications(self, *_): + # Clear all groups + self.notification_groups.clear() + self.group_widgets.clear() + + # Clear all remaining cached notification images AND icons when clear all is clicked + cleanup_all_notification_caches() # Clear ALL caches (icons + images) + notification_service.clear_all_cached_notifications() + if hasattr(self, "mousecapture"): + self.mousecapture.hide_child_window() + + def _on_escape_pressed(self, *_): + if hasattr(self, "mousecapture"): + self.mousecapture.hide_child_window() + + def _init_mousecapture(self, mousecapture): + self.mousecapture = mousecapture + + def _set_mousecapture(self, visible): + """Control notification center visibility with slide-left animation""" + if visible: + self.main_revealer.set_reveal_child(True) + else: + self.main_revealer.set_reveal_child(False) diff --git a/src/window/notification/unified_cache.py b/src/window/notification/unified_cache.py new file mode 100644 index 0000000..d690d89 --- /dev/null +++ b/src/window/notification/unified_cache.py @@ -0,0 +1,185 @@ +import hashlib +import time +import uuid + +from fabric.utils import GdkPixbuf, get_relative_path, logger, os + +import shared.data as data + +# Unified notification cache directory (for both app icons and notification images) +UNIFIED_NOTIFICATION_CACHE_DIR = os.path.join(data.CACHE_DIR, "notifications") + + +def ensure_cache_dir(): + """Ensure unified notification cache directory exists""" + os.makedirs(UNIFIED_NOTIFICATION_CACHE_DIR, exist_ok=True) + + +def get_unified_cache_key(source_data, size=None, app_name=None): + """Generate a unified cache key that works for both app icons and notification images""" + try: + if hasattr(source_data, "get_pixels"): + # For pixbuf data - use hash of pixel data for deterministic caching + try: + pixel_data = source_data.get_pixels() + image_hash = hashlib.md5(pixel_data).hexdigest()[:8] + return image_hash + except Exception: + # Fallback to random UUID if pixel data fails + return str(uuid.uuid4())[:8] + elif isinstance(source_data, str): + # For file paths - create hash-based name + if source_data.startswith("file://"): + source_data = source_data[7:] + + # Create hash from file path and size + hash_input = source_data + if size: + hash_input += f"_{size[0]}x{size[1]}" + + return hashlib.md5(hash_input.encode()).hexdigest()[:8] + else: + # Fallback to random UUID + return str(uuid.uuid4())[:8] + except Exception: + # Ultimate fallback + return str(uuid.uuid4())[:8] + + +def save_to_cache(pixbuf, cache_key, size=None): + """Save a pixbuf to the unified cache directory""" + try: + ensure_cache_dir() + cache_path = os.path.join(UNIFIED_NOTIFICATION_CACHE_DIR, f"{cache_key}.png") + + # Don't overwrite existing cache + if os.path.exists(cache_path): + logger.debug(f"Cache hit - already exists: {cache_key}") + return cache_path, cache_key + + # Scale if size is specified + if size and (pixbuf.get_width() != size[0] or pixbuf.get_height() != size[1]): + pixbuf = pixbuf.scale_simple( + size[0], size[1], GdkPixbuf.InterpType.BILINEAR + ) + + pixbuf.savev(cache_path, "png", [], []) + return cache_path, cache_key + except Exception as e: + logger.warning(f"Failed to cache notification asset: {e}") + return None, None + + +def get_from_cache(cache_key, size=None): + """Get a cached asset or return None if not found""" + try: + cache_path = os.path.join(UNIFIED_NOTIFICATION_CACHE_DIR, f"{cache_key}.png") + if os.path.exists(cache_path): + # logger.debug(f"Using cached asset: {cache_key}") + if size: + return GdkPixbuf.Pixbuf.new_from_file_at_scale( + cache_path, size[0], size[1], True + ) + else: + return GdkPixbuf.Pixbuf.new_from_file(cache_path) + except Exception as e: + logger.warning(f"Failed to load cached asset: {e}") + return None + + +def cleanup_cache(cache_key=None): + """Clean up unified cache - specific key or all""" + try: + ensure_cache_dir() + + if cache_key: + # Remove specific cached asset + cache_path = os.path.join( + UNIFIED_NOTIFICATION_CACHE_DIR, f"{cache_key}.png" + ) + if os.path.exists(cache_path): + os.unlink(cache_path) + else: + # Remove all cached assets + for filename in os.listdir(UNIFIED_NOTIFICATION_CACHE_DIR): + if filename.endswith(".png"): + filepath = os.path.join(UNIFIED_NOTIFICATION_CACHE_DIR, filename) + try: + os.unlink(filepath) + except Exception as e: + logger.warning(f"Failed to cleanup cache file {filename}: {e}") + except Exception as e: + logger.warning(f"Failed to cleanup cache: {e}") + + +def cleanup_old_cache_files(): + """Clean up old cache files (older than 7 days)""" + try: + if not os.path.exists(UNIFIED_NOTIFICATION_CACHE_DIR): + return + + current_time = time.time() + week_ago = current_time - (7 * 24 * 60 * 60) # 7 days + + for filename in os.listdir(UNIFIED_NOTIFICATION_CACHE_DIR): + filepath = os.path.join(UNIFIED_NOTIFICATION_CACHE_DIR, filename) + try: + if os.path.isfile(filepath): + file_mtime = os.path.getmtime(filepath) + if file_mtime < week_ago: + os.unlink(filepath) + except Exception as e: + logger.warning(f"Failed to cleanup cache file {filename}: {e}") + except Exception as e: + logger.warning(f"Failed to cleanup cache: {e}") + + +def verify_cache_persistence(): + """Verify that cached assets persist and can be loaded after restart""" + try: + cache_files = [] + + if os.path.exists(UNIFIED_NOTIFICATION_CACHE_DIR): + cache_files = [ + f + for f in os.listdir(UNIFIED_NOTIFICATION_CACHE_DIR) + if f.endswith(".png") + ] + + # Test loading a few cached items to verify they work + for cache_file in cache_files[:2]: # Test first 2 files + try: + cache_path = os.path.join(UNIFIED_NOTIFICATION_CACHE_DIR, cache_file) + GdkPixbuf.Pixbuf.new_from_file(cache_path) + except Exception as e: + logger.warning(f"Failed to load cached asset {cache_file}: {e}") + + return len(cache_files) > 0 + + except Exception as e: + logger.error(f"Failed to verify cache persistence: {e}") + return False + + +def get_fallback_icon(size=(48, 48)): + """Get the fallback notification icon""" + try: + fallback_path = get_relative_path("../../assets/icons/notification.png") + return GdkPixbuf.Pixbuf.new_from_file_at_scale( + fallback_path, size[0], size[1], True + ) + except Exception as e: + logger.warning(f"Failed to load fallback icon: {e}") + # Create a simple colored rectangle as ultimate fallback + try: + return GdkPixbuf.Pixbuf.new( + GdkPixbuf.Colorspace.RGB, True, 8, size[0], size[1] + ) + except Exception: + return None + + +# Initialize cache on module load +ensure_cache_dir() +cleanup_old_cache_files() +verify_cache_persistence() diff --git a/src/window/osd/__init__.py b/src/window/osd/__init__.py new file mode 100644 index 0000000..d65876c --- /dev/null +++ b/src/window/osd/__init__.py @@ -0,0 +1,8 @@ +from .main import OSDWindow # noqa: F401 +from .components import ( # noqa: F401 + AudioOSDContainer, + BrightnessOSDContainer, + MicrophoneOSDContainer, + CapsLockOSDContainer, + KeyboardLayoutOSDContainer, +) diff --git a/src/window/osd/components/__init__.py b/src/window/osd/components/__init__.py new file mode 100644 index 0000000..0a7ca48 --- /dev/null +++ b/src/window/osd/components/__init__.py @@ -0,0 +1,5 @@ +from .audio import AudioOSDContainer # noqa: F401 +from .brightness import BrightnessOSDContainer # noqa: F401 +from .microphone import MicrophoneOSDContainer # noqa: F401 +from .capslock import CapsLockOSDContainer # noqa: F401 +from .keyboard_layout import KeyboardLayoutOSDContainer # noqa: F401 diff --git a/src/window/osd/components/animated_scale.py b/src/window/osd/components/animated_scale.py new file mode 100644 index 0000000..46b8868 --- /dev/null +++ b/src/window/osd/components/animated_scale.py @@ -0,0 +1,30 @@ +from functools import partial + +from fabric.widgets.scale import Scale + +from shared.widgets.animator import Animator, cubic_bezier + + +class AnimatedScale(Scale): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.animator = ( + Animator( + duration=0.8, + timing_function=partial(cubic_bezier, 0.34, 1.56, 0.64, 1.0), + min_value=self.min_value, + max_value=self.value, + tick_widget=self, + notify_value=lambda anim: self.set_value(anim.value), + ) + .build() + .play() + .unwrap() + ) + + def animate_value(self, value: float): + if self.animator: + self.animator.pause() + self.animator.min_value = self.value + self.animator.max_value = value + self.animator.play() diff --git a/src/window/osd/components/audio.py b/src/window/osd/components/audio.py new file mode 100644 index 0000000..c33d4c9 --- /dev/null +++ b/src/window/osd/components/audio.py @@ -0,0 +1,100 @@ +import math +from fabric.audio import Audio +from utils.utils import svg_file +from fabric.widgets.scale import ScaleMark +from .animated_scale import AnimatedScale +from .base import BaseOSDContainer + + +class AudioOSDContainer(BaseOSDContainer): + def __init__(self, window, **kwargs): + super().__init__(window, **kwargs) + self.audio = Audio() + self._last_volume = None + self._last_muted = None + self._setup_specific_components() + self._connect_specific_signals() + + def _setup_specific_components(self): + self.osd_window_image = svg_file( + "volume/audio-volume.svg", + size=(100, 100), + name="osd-image", + h_align="center", + v_align="center", + h_expand=True, + v_expand=True, + ) + self.scale = AnimatedScale( + marks=(ScaleMark(value=i) for i in range(1, 100, 10)), + value=70, + min_value=0, + max_value=100, + increments=(1, 1), + orientation="h", + ) + self.add(self.osd_window_image) + self.add(self.scale) + + def _connect_specific_signals(self): + self.audio.connect("notify::speaker", self._on_speaker_changed) + if self.audio.speaker: + self._connect_speaker_signals() + + def _connect_speaker_signals(self): + if self.audio.speaker: + self.audio.speaker.connect("changed", self._on_speaker_stream_changed) + self._sync_with_audio() + + def _on_speaker_changed(self, *_): + self._connect_speaker_signals() + self.update() + + def _on_speaker_stream_changed(self, *_): + if self.audio.speaker: + current_volume = round(self.audio.speaker.volume) + current_muted = self.audio.speaker.muted + if self._last_volume != current_volume or self._last_muted != current_muted: + self._last_volume = current_volume + self._last_muted = current_muted + self.update() + + def _sync_with_audio(self): + if self.audio.speaker: + self._last_volume = round(self.audio.speaker.volume) + self._last_muted = self.audio.speaker.muted + self.scale.set_value(self._last_volume) + self._update_display() + + def _update_display(self): + if not self.audio.speaker: + return + + volume = ( + self._last_volume + if self._last_volume is not None + else round(self.audio.speaker.volume) + ) + muted = ( + self._last_muted + if self._last_muted is not None + else self.audio.speaker.muted + ) + + display_volume = 0 if (volume == 0 or muted) else volume + level = ( + 0 if display_volume == 0 else min(int(math.ceil(display_volume / 33)), 3) + ) + + self.osd_window_image.set_from_file(f"volume/audio-volume-{level}.svg") + + if muted or volume == 0: + self.scale.add_style_class("muted") + else: + self.scale.remove_style_class("muted") + + self.scale.animate_value(volume) + + def update(self, *_): + self._update_display() + super().update() diff --git a/src/window/osd/components/base.py b/src/window/osd/components/base.py new file mode 100644 index 0000000..30e2197 --- /dev/null +++ b/src/window/osd/components/base.py @@ -0,0 +1,145 @@ +from abc import abstractmethod +from fabric.utils import Gdk, GLib, invoke_repeater, remove_handler, time +from fabric.widgets.box import Box +from fabric.widgets.wayland import WaylandWindow as Window + + +class BaseOSDContainer(Box): + MAX_VISIBLE_TIME = 6.0 + COOLDOWN_MS = 200 + + def __init__(self, window: Window, **kwargs): + super().__init__(**kwargs, orientation="v", spacing=12, name="osd-container") + self.last_handler: int = 0 + self.window = window + self.osd = None + self.show_timestamp: float = 0.0 + self.watchdog_handler: int = 0 + self._update_in_progress = False + self._is_hovered = False + self._last_trigger_time: float = 0.0 + + GLib.idle_add(self._setup_window_signals) + + def _setup_window_signals(self): + self.window.add_events( + Gdk.EventMask.ENTER_NOTIFY_MASK | Gdk.EventMask.LEAVE_NOTIFY_MASK + ) + self.window.connect("enter-notify-event", self._on_enter_notify) + self.window.connect("leave-notify-event", self._on_leave_notify) + return False + + def _on_enter_notify(self, widget, event): + if event.detail != Gdk.NotifyType.INFERIOR: + self._is_hovered = True + + def _on_leave_notify(self, widget, event): + if event.detail != Gdk.NotifyType.INFERIOR: + self._is_hovered = False + + def is_hovered(self): + return self._is_hovered + + def remove_last_handler(self): + if self.last_handler: + try: + remove_handler(self.last_handler) + except Exception: + pass + self.last_handler = 0 + + def remove_watchdog_handler(self): + if self.watchdog_handler: + try: + GLib.source_remove(self.watchdog_handler) + except Exception: + pass + self.watchdog_handler = 0 + + def cleanup_all_handlers(self): + self.remove_last_handler() + self.remove_watchdog_handler() + self.show_timestamp = 0.0 + self._update_in_progress = False + + def hide_window(self): + if self.osd and hasattr(self.osd, "revealer"): + self.osd.revealer.set_reveal_child(False) + GLib.timeout_add(150, lambda: self.window.hide() or False) + else: + self.window.hide() + + def watchdog_force_hide(self, *_): + if not self.window.get_visible(): + self.cleanup_all_handlers() + return False + + elapsed = time.time() - self.show_timestamp + if elapsed >= self.MAX_VISIBLE_TIME: + if not self.is_hovered(): + self.hide_window() + self.cleanup_all_handlers() + return False + else: + return True + return True + + def update(self, *_): + if self._update_in_progress: + return + + current_time = time.time() + time_since_last_trigger = (current_time - self._last_trigger_time) * 1000 + + if time_since_last_trigger < self.COOLDOWN_MS: + return + + self._last_trigger_time = current_time + self._update_in_progress = True + + self.cleanup_all_handlers() + self.show_timestamp = time.time() + + if self.osd: + self.osd.show_container(self) + + self.window.show() + + self.focus() + self.last_handler = invoke_repeater(1700, self.unfocus, initial_call=False) + self.watchdog_handler = GLib.timeout_add(1000, self.watchdog_force_hide) + self._update_in_progress = False + + def focus(self, *_): + self.add_style_class("focused") + return False + + def unfocus(self, *_): + if not self.window.get_visible(): + self.cleanup_all_handlers() + return False + + self.remove_style_class("focused") + self.remove_last_handler() + self.last_handler = invoke_repeater(1700, self.unpop, initial_call=False) + return False + + def unpop(self, *_): + if not self.window.get_visible(): + self.cleanup_all_handlers() + return False + + if not self.is_hovered(): + self.hide_window() + self.cleanup_all_handlers() + else: + self.last_handler = invoke_repeater(500, self.unpop, initial_call=False) + return False + + @abstractmethod + def _setup_specific_components(self): + pass + + @abstractmethod + def _connect_specific_signals(self): + pass diff --git a/src/window/osd/components/brightness.py b/src/window/osd/components/brightness.py new file mode 100644 index 0000000..fd54841 --- /dev/null +++ b/src/window/osd/components/brightness.py @@ -0,0 +1,59 @@ +import math +from services.brightness import Brightness +from utils.utils import svg_file +from fabric.widgets.scale import ScaleMark +from .animated_scale import AnimatedScale +from .base import BaseOSDContainer + + +class BrightnessOSDContainer(BaseOSDContainer): + def __init__(self, window, **kwargs): + super().__init__(window, **kwargs) + self.brightness_service = Brightness.get_initial() + self._setup_specific_components() + self._connect_specific_signals() + + def _setup_specific_components(self): + self.osd_window_image = svg_file( + "brightness/brightness.svg", + size=(100, 100), + name="osd-image", + h_align="center", + v_align="center", + h_expand=True, + v_expand=True, + ) + self.scale = AnimatedScale( + marks=(ScaleMark(value=i) for i in range(0, 101, 10)), + value=70, + min_value=0, + max_value=100, + increments=(1, 1), + orientation="h", + ) + self.add(self.osd_window_image) + self.add(self.scale) + + def _connect_specific_signals(self): + self.brightness_service.connect("screen", self._on_screen_brightness_changed) + + def _on_screen_brightness_changed(self, _sender, value, *_args): + self.update() + + def _get_normalized_brightness(self): + return ( + self.brightness_service.screen_brightness + / self.brightness_service.max_screen + ) * 100 + + def _update_display(self): + normalized = self._get_normalized_brightness() + level = 0 if normalized == 0 else min(int(math.ceil(normalized / 33)), 3) + + self.osd_window_image.set_from_file(f"brightness/brightness-{level}.svg") + + self.scale.animate_value(normalized) + + def update(self, *_): + self._update_display() + super().update() diff --git a/src/window/osd/components/capslock.py b/src/window/osd/components/capslock.py new file mode 100644 index 0000000..4ff7543 --- /dev/null +++ b/src/window/osd/components/capslock.py @@ -0,0 +1,47 @@ +from fabric.widgets.label import Label +from utils.utils import svg_file +from .base import BaseOSDContainer +from services.capslock import CapsLock + + +class CapsLockOSDContainer(BaseOSDContainer): + def __init__(self, window, **kwargs): + super().__init__(window, **kwargs) + self.capslock = CapsLock.get_initial() + self._setup_specific_components() + self._connect_specific_signals() + + def _setup_specific_components(self): + self.osd_image = svg_file( + "misc/caps-lock.svg", + size=(100, 100), + name="osd-image", + h_align="center", + v_align="center", + h_expand=True, + v_expand=True, + ) + self.label = Label(name="osd-label") + self.add(self.osd_image) + self._update_display(self.capslock.is_on) + + def _connect_specific_signals(self): + self.capslock.connect("state_changed", self._on_caps_lock_state_changed) + + def _on_caps_lock_state_changed(self, _, is_on: bool): + self._update_display(is_on) + self.update() + + def _update_display(self, is_on: bool): + icon_path = "caps-lock.svg" if is_on else "caps-lock-off.svg" + self.osd_image.set_from_file(f"misc/{icon_path}") + if is_on: + self.add_style_class("capslock-on") + self.remove_style_class("capslock-off") + else: + self.add_style_class("capslock-off") + self.remove_style_class("capslock-on") + + def update(self, *_): + # We don't need a separate display update here as it's handled in the signal + super().update() diff --git a/src/window/osd/components/keyboard_layout.py b/src/window/osd/components/keyboard_layout.py new file mode 100644 index 0000000..45de256 --- /dev/null +++ b/src/window/osd/components/keyboard_layout.py @@ -0,0 +1,39 @@ +from fabric.widgets.label import Label +from utils.utils import svg_file +from .base import BaseOSDContainer +from services.keyboard_layout import KeyboardLayout + + +class KeyboardLayoutOSDContainer(BaseOSDContainer): + def __init__(self, window, **kwargs): + super().__init__(window, **kwargs) + self.keyboard_layout = KeyboardLayout.get_initial() + self._setup_specific_components() + self._connect_specific_signals() + + def _setup_specific_components(self): + self.osd_image = svg_file( + "misc/keyboard-layout.svg", + size=(100, 100), + name="osd-image", + h_align="center", + v_align="center", + h_expand=True, + v_expand=True, + ) + self.label = Label(name="osd-label") + self.add(self.osd_image) + self.add(self.label) + self.label.set_text(self.keyboard_layout.current_layout) + + def _connect_specific_signals(self): + self.keyboard_layout.connect("layout_changed", self._on_layout_changed) + + def _on_layout_changed(self, service, layout: str): + if not self.is_hovered(): + self.label.set_text(layout) + self.update() + + def update(self, *_): + self.label.set_text(self.keyboard_layout.current_layout) + super().update() diff --git a/src/window/osd/components/microphone.py b/src/window/osd/components/microphone.py new file mode 100644 index 0000000..a7fd590 --- /dev/null +++ b/src/window/osd/components/microphone.py @@ -0,0 +1,100 @@ +import math +from fabric.audio import Audio +from utils.utils import svg_file +from fabric.widgets.scale import ScaleMark +from .animated_scale import AnimatedScale +from .base import BaseOSDContainer + + +class MicrophoneOSDContainer(BaseOSDContainer): + def __init__(self, window, **kwargs): + super().__init__(window, **kwargs) + self.audio = Audio() + self._last_volume = None + self._last_muted = None + self._setup_specific_components() + self._connect_specific_signals() + + def _setup_specific_components(self): + self.osd_window_image = svg_file( + "mic/microphone.svg", + size=(100, 100), + name="osd-image", + h_align="center", + v_align="center", + h_expand=True, + v_expand=True, + ) + self.scale = AnimatedScale( + marks=(ScaleMark(value=i) for i in range(1, 100, 10)), + value=70, + min_value=0, + max_value=100, + increments=(1, 1), + orientation="h", + ) + self.add(self.osd_window_image) + self.add(self.scale) + + def _connect_specific_signals(self): + self.audio.connect("notify::microphone", self._on_microphone_changed) + if self.audio.microphone: + self._connect_microphone_signals() + + def _connect_microphone_signals(self): + if self.audio.microphone: + self.audio.microphone.connect("changed", self._on_microphone_stream_changed) + self._sync_with_audio() + + def _on_microphone_changed(self, *_): + self._connect_microphone_signals() + self.update() + + def _on_microphone_stream_changed(self, *_): + if self.audio.microphone: + current_volume = round(self.audio.microphone.volume) + current_muted = self.audio.microphone.muted + if self._last_volume != current_volume or self._last_muted != current_muted: + self._last_volume = current_volume + self._last_muted = current_muted + self.update() + + def _sync_with_audio(self): + if self.audio.microphone: + self._last_volume = round(self.audio.microphone.volume) + self._last_muted = self.audio.microphone.muted + self.scale.set_value(self._last_volume) + self._update_display() + + def _update_display(self): + if not self.audio.microphone: + return + + volume = ( + self._last_volume + if self._last_volume is not None + else round(self.audio.microphone.volume) + ) + muted = ( + self._last_muted + if self._last_muted is not None + else self.audio.microphone.muted + ) + + display_volume = 0 if (volume == 0 or muted) else volume + level = ( + 0 if display_volume == 0 else min(int(math.ceil(display_volume / 33)), 3) + ) + + self.osd_window_image.set_from_file(f"mic/microphone-{level}.svg") + + if muted or volume == 0: + self.scale.add_style_class("muted") + else: + self.scale.remove_style_class("muted") + + self.scale.animate_value(volume) + + def update(self, *_): + self._update_display() + super().update() diff --git a/src/window/osd/main.py b/src/window/osd/main.py new file mode 100644 index 0000000..9c1fa4b --- /dev/null +++ b/src/window/osd/main.py @@ -0,0 +1,83 @@ +from fabric.widgets.box import Box +from fabric.widgets.revealer import Revealer +from fabric.widgets.wayland import WaylandWindow as Window +from .components import ( + AudioOSDContainer, + BrightnessOSDContainer, + MicrophoneOSDContainer, + CapsLockOSDContainer, + KeyboardLayoutOSDContainer, +) + + +class OSD(Box): + def __init__(self, window: Window, **kwargs): + super().__init__(name="osd", **kwargs) + self.window = window + + self.revealer = Revealer( + transition_type="slide-up", + transition_duration=100, + child_revealed=False, + ) + self.children = [self.revealer] + + self.containers = { + "audio": AudioOSDContainer(window), + "brightness": BrightnessOSDContainer(window), + "microphone": MicrophoneOSDContainer(window), + "capslock": CapsLockOSDContainer(window), + "kb_layout": KeyboardLayoutOSDContainer(window), + } + + # Set the OSD reference in containers + for container in self.containers.values(): + container.osd = self + + self.current_container = None + + def show_container(self, container): + if self.current_container != container: + self.revealer.children = [container] + self.current_container = container + self.revealer.set_reveal_child(True) + + def show_audio_osd(self): + self.show_container(self.containers.get("audio")) + + def show_brightness_osd(self): + self.show_container(self.containers.get("brightness")) + + def show_microphone_osd(self): + self.show_container(self.containers.get("microphone")) + + def show_capslock_osd(self): + self.show_container(self.containers.get("capslock")) + + def show_kb_layout_osd(self): + self.show_container(self.containers.get("kb_layout")) + + +class OSDWindow(Window): + def __init__(self, **kwargs): + super().__init__( + name="osd-window", + title="modus-osd", + anchor="bottom", + margin="0px 0px 40px 0px", + visible=False, + all_visible=False, + pass_through=True, + layer="overlay", + **kwargs, + ) + self.osd = OSD(window=self) + self.add(self.osd) + + +if __name__ == "__main__": + from fabric import Application + + osd_window = OSDWindow() + app = Application("modus-osd", osd_window) + app.run() diff --git a/src/window/panel/components/__init__.py b/src/window/panel/components/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/window/panel/components/enhanced_system_tray.py b/src/window/panel/components/enhanced_system_tray.py new file mode 100644 index 0000000..f542e39 --- /dev/null +++ b/src/window/panel/components/enhanced_system_tray.py @@ -0,0 +1,106 @@ +from pathlib import Path +import weakref + +from fabric.system_tray.widgets import SystemTrayItem +from fabric.utils import GdkPixbuf, GLib, logger + +from services.config import get_config, on_config_change + +# Track active tray items for hot-reloading +_tracked_items = weakref.WeakSet() + + +def should_hide(item) -> bool: + """Check if a tray item should be hidden based on config.""" + ignore_list = get_config("systray_ignore", []) + if not ignore_list: + return False + + title = item.title if item.title else "" + identifier = item.identifier if item.identifier else "" + + # Check both title and identifier (case-insensitive for convenience) + for pattern in ignore_list: + p = pattern.lower() + if p in title.lower() or p in identifier.lower(): + return True + return False + + +def patched_do_update_properties(self, *_): + # Determine the icon name to use + item = self._item + icon_name = item.icon_name + attention_icon_name = item.attention_icon_name + + # Log item info for user debugging + logger.info( + f"[SysTray] Item: title='{getattr(item, 'title', '')}', identifier='{getattr(item, 'identifier', '')}'" + ) + + # Visibility check + is_hidden = should_hide(item) + self.set_visible(not is_hidden) + + if is_hidden: + return + + if item.status == "NeedsAttention" and attention_icon_name: + preferred_icon_name = attention_icon_name + else: + preferred_icon_name = icon_name + + # 1. Try the standard fabric/GTK implementation first + pixbuf = item.get_preferred_icon_pixbuf(self._icon_size) + + # 2. If standard lookup fails, try your specific absolute path logic + if pixbuf is None and preferred_icon_name: + path = Path(preferred_icon_name) + if path.is_absolute() and path.exists(): + try: + target_size = self._icon_size if self._icon_size else 24 + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( + str(path), + target_size, + target_size, + True, # Preserve aspect ratio + ) + except GLib.GError: + pixbuf = None + + # Apply the resulting pixbuf or the fallback "missing" icon + if pixbuf: + self._image.set_from_pixbuf(pixbuf) + else: + self._image.set_from_icon_name("image-missing", self._icon_size) + + tooltip = item.tooltip + self.set_tooltip_markup( + tooltip.description + or tooltip.title + or (item.title.title() if item.title else None) + or "Unknown" + ) + + +def apply_enhanced_system_tray(): + # Patch __init__ to track instances + original_init = SystemTrayItem.__init__ + + def patched_init(self, *args, **kwargs): + original_init(self, *args, **kwargs) + _tracked_items.add(self) + + SystemTrayItem.__init__ = patched_init + + # Replace the method on the class so all tray items use this logic + SystemTrayItem.do_update_properties = patched_do_update_properties + + # Setup hot-reload listener + def on_reload(new_config, old_config): + if new_config.get("systray_ignore") != old_config.get("systray_ignore"): + logger.info("[SysTray] Config changed, updating tray item visibility") + for item_widget in _tracked_items: + item_widget.do_update_properties() + + on_config_change(on_reload) diff --git a/src/window/panel/components/globalmenu.py b/src/window/panel/components/globalmenu.py new file mode 100644 index 0000000..458d648 --- /dev/null +++ b/src/window/panel/components/globalmenu.py @@ -0,0 +1,337 @@ +from fabric.hyprland.widgets import HyprlandActiveWindow as ActiveWindow +from fabric.utils import FormattedString, exec_shell_command_async +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.centerbox import CenterBox +from fabric.widgets.label import Label + +from shared.dialogs.about import About, AboutApp +from shared.window.dropdown import ModusDropdown, dropdown_divider, dropdowns +from shared.window.mousecapture import DropDownMouseCapture +from utils.app_name_resolver import format_window +from utils.roam import modus_service +from utils.utils import setup_cursor_hover + + +def has_active_window(): + return ( + modus_service.current_active_app_name + and modus_service.current_active_app_name != "Finder" + ) + + +def create_menu_button(label, on_clicked=None): + button_kwargs = { + "label": label, + "name": "global-menu", + } + + if on_clicked is not None: + button_kwargs["on_clicked"] = on_clicked + + button = Button(**button_kwargs) + setup_cursor_hover(button, "pointer") + return button + + +def create_dropdown_with_capture(dropdown_id, parent, dropdown_children, layer="top"): + dropdown = ModusDropdown( + dropdown_id=dropdown_id, + parent=parent, + dropdown_children=dropdown_children, + ) + mouse_capture = DropDownMouseCapture(layer=layer, child_window=dropdown) + return mouse_capture + + +def manage_button_style_classes(buttons, active_button=None, style_class="active"): + for button in buttons: + if button: # Check if button exists (some might be None) + button.remove_style_class(style_class) + + if active_button: + active_button.add_style_class(style_class) + + +def show_about_app(_=None): + if not has_active_window(): + return + + app_name = modus_service.current_active_app_name + wmclass = getattr(modus_service, "current_active_wm_class", "") + about_window = AboutApp(app_name=app_name, wmclass=wmclass) + about_window.toggle(None) + + +def on_click_subthread(button, on_clicked, on_click): + if on_clicked: + on_clicked(button) + else: + exec_shell_command_async(on_click, lambda *_: None) + + for dropdown in dropdowns: + if dropdown.is_visible(): + dropdown.hide_via_mousecapture() + break + + +def dropdown_option( + label: str = "", + keybind: str = "", + on_click='echo "ModusPanelDropdown Action"', + on_clicked=None, +): + btn = Button( + child=CenterBox( + start_children=[ + Label(label=label, h_align="start", name="dropdown-option-label"), + ], + end_children=[ + Label(label=keybind, h_align="end", name="dropdown-option-keybind") + ], + orientation="horizontal", + h_align="fill", + h_expand=True, + v_expand=True, + ), + name="dropdown-option", + h_align="fill", + on_clicked=lambda button: on_click_subthread(button, on_clicked, on_click), + h_expand=True, + v_expand=True, + ) + setup_cursor_hover(btn, "pointer") + return btn + + +class SystemDropdown(ModusDropdown): + def __init__(self, parent, **kwargs): + super().__init__( + dropdown_id="os-menu", + parent=parent, + dropdown_children=[ + dropdown_option( + "About this PC", on_clicked=lambda _: About().toggle(_) + ), + dropdown_divider("---------------------"), + dropdown_option( + "System Settings...", + # TODO: Open Modus own setting + # on_click="xdg-open settings", + ), + dropdown_divider("---------------------"), + dropdown_option("Force Quit", "", "hyprctl kill"), + dropdown_divider("---------------------"), + dropdown_option("Sleep", "", "systemctl suspend"), + dropdown_option("Restart", "", "systemctl reboot"), + dropdown_option("Shut Down", "", "shutdown now"), + dropdown_divider("---------------------"), + dropdown_option("Lock Screen", "󰘳 L", "hyprlock"), + ], + **kwargs, + ) + + +class GlobalMenuDropdowns: + def __init__(self, parent): + self.parent = parent + + self.system_dropdown = SystemDropdown(parent=parent) + self.menu_button_dropdown = DropDownMouseCapture( + layer="top", child_window=self.system_dropdown + ) + self.menu_button = Button( + label="Modus", + name="global-menu", + on_clicked=lambda _: self.menu_button_dropdown.toggle_mousecapture(), + ) + self.menu_button_dropdown.child_window.set_pointing_to(self.menu_button) + + self.global_title_menu_about = dropdown_option( + f"About {modus_service.current_active_app_name}", + on_clicked=show_about_app, + ) + self.global_menu_title = create_dropdown_with_capture( + "global-menu-title", + parent, + [self.global_title_menu_about], + ) + + self.global_menu_view = create_dropdown_with_capture( + "global-menu-view", + parent, + [ + dropdown_option( + "Enter Full Screen", + on_click="hyprctl dispatch fullscreen", + ), + ], + ) + self.global_menu_window = create_dropdown_with_capture( + "global-menu-window", + parent, + [ + dropdown_option( + "Zoom In", + "󰍉 +", + on_click="hyprctl -q keyword cursor:zoom_factor $(hyprctl getoption cursor:zoom_factor -j | jq '.float * 1.1')", + ), + dropdown_option( + "Zoom Out", + "󰍉 -", + on_click="hyprctl -q keyword cursor:zoom_factor $(hyprctl getoption cursor:zoom_factor -j | jq '(.float * 0.9) | if . < 1 then 1 else . end')", + ), + dropdown_divider("---------------------"), + dropdown_option( + "Move Window to Left", + on_click="hyprctl dispatch movewindow l", + ), + dropdown_option( + "Move Window to Right", + on_click="hyprctl dispatch movewindow r", + ), + dropdown_option( + "Cycle Through Windows", + on_click="hyprctl dispatch cyclenext", + ), + dropdown_divider("---------------------"), + dropdown_option("Float", on_click="hyprctl dispatch togglefloating"), + dropdown_option("Quit", on_click="hyprctl dispatch killactive"), + dropdown_option("Pseudo", on_click="hyprctl dispatch pseudo"), + dropdown_option( + "Toggle Split", on_click="hyprctl dispatch togglesplit" + ), + dropdown_option("Center", on_click="hyprctl dispatch centerwindow"), + dropdown_option("Group", on_click="hyprctl dispatch togglegroup"), + dropdown_option( + "Pin", + on_clicked=lambda _: exec_shell_command_async( + "bash ~/.config/scripts/winpin.sh", lambda *_: None + ), + ), + ], + ) + + self.global_menu_help = create_dropdown_with_capture( + "global-menu-help", + parent, + [ + dropdown_option( + "Modus", + on_click="xdg-open https://github.com/S4NKALP/Modus/issues", + ), + dropdown_divider("---------------------"), + dropdown_option( + "Hyprland Wiki", on_click="xdg-open https://wiki.hyprland.org/" + ), + ], + ) + + modus_service.connect( + "current-active-app-name-changed", self._on_active_app_changed + ) + + self.global_menu_button_title = Button( + child=ActiveWindow( + formatter=FormattedString( + "{ format_window(win_title, win_class) }", + format_window=format_window, + ) + ), + name="global-menu", + on_clicked=self._on_title_button_clicked, + ) + + self.global_menu_title.child_window.set_pointing_to( + self.global_menu_button_title + ) + # File, Edit and Go buttons are placeholders - no dropdowns implemented yet + self.global_menu_button_file = create_menu_button("File") + self.global_menu_button_edit = create_menu_button("Edit") + self.global_menu_button_go = create_menu_button("Go") + + self.global_menu_button_view = create_menu_button( + "View", + lambda _: self.global_menu_view.toggle_mousecapture(), + ) + self.global_menu_view.child_window.set_pointing_to(self.global_menu_button_view) + self.global_menu_button_window = create_menu_button( + "Window", + lambda _: self.global_menu_window.toggle_mousecapture(), + ) + self.global_menu_window.child_window.set_pointing_to( + self.global_menu_button_window + ) + self.global_menu_button_help = create_menu_button( + "Help", + lambda _: self.global_menu_help.toggle_mousecapture(), + ) + self.global_menu_help.child_window.set_pointing_to(self.global_menu_button_help) + + self.all_menu_buttons = [ + self.menu_button, + self.global_menu_button_title, + self.global_menu_button_file, + self.global_menu_button_edit, + self.global_menu_button_view, + self.global_menu_button_go, + self.global_menu_button_window, + self.global_menu_button_help, + ] + + self.dropdown_button_map = { + "os-menu": self.menu_button, + "global-menu-title": self.global_menu_button_title, + "global-menu-file": self.global_menu_button_file, + "global-menu-edit": self.global_menu_button_edit, + "global-menu-view": self.global_menu_button_view, + "global-menu-go": self.global_menu_button_go, + "global-menu-window": self.global_menu_button_window, + "global-menu-help": self.global_menu_button_help, + } + + modus_service.connect("current-dropdown-changed", self.changed_dropdown) + modus_service.connect("dropdowns-hide-changed", self.hide_dropdowns) + + def _on_title_button_clicked(self, _): + if has_active_window(): + self.global_menu_title.toggle_mousecapture() + + def _on_active_app_changed(self, _, value): + self.global_title_menu_about.set_property("label", f"About {value}") + + def hide_dropdowns(self, *_): + manage_button_style_classes(self.all_menu_buttons) + + def changed_dropdown(self, _, dropdown_id): + active_button = self.dropdown_button_map.get(dropdown_id) + manage_button_style_classes(self.all_menu_buttons, active_button) + + +class GlobalMenu(Box): + def __init__(self, parent_window=None, **kwargs): + if parent_window is None: + parent_window = kwargs.pop("parent_window", None) + + super().__init__( + name="globalmenu", orientation="horizontal", spacing=0, **kwargs + ) + + self.dropdown_system = GlobalMenuDropdowns(parent=parent_window) + + self.children = [ + self.dropdown_system.global_menu_button_title, + self.dropdown_system.global_menu_button_file, + self.dropdown_system.global_menu_button_edit, + self.dropdown_system.global_menu_button_view, + self.dropdown_system.global_menu_button_go, + self.dropdown_system.global_menu_button_window, + self.dropdown_system.global_menu_button_help, + ] + + def show_system_dropdown(self, imac_button): + self.dropdown_system.menu_button_dropdown.child_window.set_pointing_to( + imac_button + ) + mouse_capture = self.dropdown_system.menu_button_dropdown + mouse_capture.set_child_window_visible(not mouse_capture.is_visible()) diff --git a/src/window/panel/components/indicators.py b/src/window/panel/components/indicators.py new file mode 100644 index 0000000..2b9a462 --- /dev/null +++ b/src/window/panel/components/indicators.py @@ -0,0 +1,459 @@ +from fabric.bluetooth import BluetoothClient +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.label import Label +from fabric.widgets.wayland import WaylandWindow as Window + +from services.battery import BatteryService, DeviceState +from services.network import NetworkClient +from shared.window.battery_widget import BatteryControl +from shared.window.mousecapture import DropDownMouseCapture +from utils.functions import format_duration, get_wifi_icon_for_strength +from utils.roam import modus_service +from utils.utils import setup_cursor_hover, svg_file +from window.controlcenter.bluetooth import BluetoothConnections +from window.controlcenter.wifi import WifiConnections + + +def create_control_window(name_prefix): + return Window( + layer="overlay", + title="modus", + anchor="top right", + margin="2px 10px 0px 0px", + exclusivity="auto", + keyboard_mode="on-demand", + name=f"{name_prefix}-window", + visible=False, + ) + + +def create_mouse_capture(window): + return DropDownMouseCapture(layer="top", child_window=window) + + +def setup_control_center( + show_window, name_prefix, widget_class, parent, **widget_kwargs +): + if not show_window: + return None, None, None + + window = create_control_window(name_prefix) + widget = widget_class(parent, **widget_kwargs) + window.children = [widget] + mouse_capture = create_mouse_capture(window) + + return window, widget, mouse_capture + + +def handle_indicator_click(mouse_capture): + if mouse_capture: + mouse_capture.toggle_mousecapture() + + +def hide_control_center(mouse_capture): + if mouse_capture: + mouse_capture.hide_child_window() + + +class BluetoothIndicator(Box): + def __init__(self, show_window=True, **kwargs): + super().__init__(name="bluetooth-indicator", orientation="h", **kwargs) + self.show_window = show_window + + self.bluetooth = BluetoothClient() + self.bt_icon = svg_file("applets/bluetooth-clear.svg", size=22) + + self.bt_button = Button( + name="bt-button", child=self.bt_icon, on_clicked=self.on_bluetooth_clicked + ) + + self.add(self.bt_button) + + # Set pointer cursor on hover + setup_cursor_hover(self.bt_button, "pointer") + + # Setup control center using shared function + ( + self.bluetooth_window, + self.bluetooth_widget, + self.bluetooth_mousecapture, + ) = setup_control_center( + self.show_window, + "bluetooth", + BluetoothConnections, + self, + show_back_button=False, + ) + if self.bluetooth_window: + self.bluetooth_window._pointing_widget = self.bt_button + + modus_service.connect("bluetooth-changed", self.on_bluetooth_changed) + self.bluetooth.connect("changed", self.on_bluetooth_direct_changed) + self.bluetooth.connect("device-added", self.on_device_added) + self.bluetooth.connect("device-removed", self.on_device_removed) + + self.update_modus_service_bluetooth_state() + self.update_state() + + def update_state(self): + if not self.bluetooth.enabled: + self.bt_icon.dynamic_file("applets/bluetooth-off-clear.svg") + tooltip = "Bluetooth disabled" + else: + connected_devices = self.bluetooth.connected_devices + if connected_devices: + self.bt_icon.dynamic_file("applets/bluetooth-clear.svg") + if len(connected_devices) >= 1: + self.bt_icon.dynamic_file("applets/bluetooth-paired.svg") + device = connected_devices[0] + tooltip = f"Connected to {device.alias}" + if device.battery_percentage > 0: + tooltip += f" ({device.battery_percentage:.0f}%)" + else: + tooltip = f"Connected to {len(connected_devices)} devices" + else: + self.bt_icon.dynamic_file("applets/bluetooth-clear.svg") + tooltip = "No devices connected" + + self.bt_button.set_tooltip_text(tooltip) + + def on_bluetooth_changed(self, service, new_bluetooth_state): + self.update_state() + + def on_bluetooth_direct_changed(self, *args): + self.update_modus_service_bluetooth_state() + self.update_state() + + def on_device_added(self, _, address): + self.update_modus_service_bluetooth_state() + self.update_state() + + def on_device_removed(self, _, address): + self.update_modus_service_bluetooth_state() + self.update_state() + + def update_modus_service_bluetooth_state(self): + if not self.bluetooth.enabled: + bluetooth_state = "disabled" + else: + connected_devices = self.bluetooth.connected_devices + if connected_devices: + if len(connected_devices) == 1: + device = connected_devices[0] + bluetooth_state = f"connected:{device.alias}" + if ( + hasattr(device, "battery_percentage") + and device.battery_percentage > 0 + ): + bluetooth_state += f":{device.battery_percentage:.0f}%" + else: + bluetooth_state = f"connected:{len(connected_devices)}_devices" + else: + bluetooth_state = "enabled" + + modus_service.bluetooth = bluetooth_state + + def on_bluetooth_clicked(self, *args): + handle_indicator_click(self.bluetooth_mousecapture) + + def close_bluetooth(self, *args): + hide_control_center(self.bluetooth_mousecapture) + + def hide_controlcenter(self, *args): + hide_control_center(self.bluetooth_mousecapture) + + +class NetworkIndicator(Box): + def __init__(self, show_window=True, **kwargs): + super().__init__(name="network-indicator", orientation="h", **kwargs) + self.show_window = show_window + + self.network_service = NetworkClient() + + self.network_icon = svg_file("applets/wifi-clear.svg", size=22) + + self.network_button = Button( + name="network-button", + child=self.network_icon, + on_clicked=self.on_wifi_clicked, + ) + + self.add(self.network_button) + + # Set pointer cursor on hover + setup_cursor_hover(self.network_button, "pointer") + + # Setup control center using shared function + ( + self.wifi_window, + self.wifi_widget, + self.wifi_mousecapture, + ) = setup_control_center( + self.show_window, + "wifi", + WifiConnections, + self, + show_back_button=False, + ) + if self.wifi_window: + self.wifi_window._pointing_widget = self.network_button + + modus_service.connect("wlan-changed", self.on_wlan_changed) + self.network_service.connect("wifi-device-added", self.on_wifi_device_added) + self.network_service.connect( + "ethernet-device-added", self.on_ethernet_device_added + ) + self.network_service.connect("changed", self.on_network_changed) + + self.update_modus_service_wlan_state() + self.update_state() + + def on_wlan_changed(self, service, new_wlan_state): + self.update_state() + + def on_wifi_device_added(self, *args): + """Called when WiFi device is added""" + if self.network_service.wifi_device: + self.network_service.wifi_device.connect( + "changed", self.on_network_direct_changed + ) + self.update_modus_service_wlan_state() + self.update_state() + + def on_ethernet_device_added(self, *args): + if self.network_service.ethernet_device: + self.network_service.ethernet_device.connect( + "changed", self.on_network_direct_changed + ) + self.update_modus_service_wlan_state() + self.update_state() + + def on_network_direct_changed(self, *args): + self.update_modus_service_wlan_state() + self.update_state() + + def on_network_changed(self, *args): + self.update_modus_service_wlan_state() + self.update_state() + + def update_modus_service_wlan_state(self): + wlan_state = "disconnected" + + if self.network_service.wifi_device: + wifi = self.network_service.wifi_device + if not wifi.wireless_enabled: + wlan_state = "disabled" + elif wifi.active_access_point: + ap = wifi.active_access_point + wlan_state = f"connected:{ap.ssid}" + if ap.strength >= 0: + wlan_state += f":{ap.strength}%" + else: + wlan_state = "enabled" + + # Check Ethernet if WiFi is not connected + elif self.network_service.ethernet_device: + ethernet = self.network_service.ethernet_device + if ethernet.internet == "activated": + wlan_state = "ethernet:connected" + if hasattr(ethernet, "speed") and ethernet.speed: + wlan_state += f":{ethernet.speed}" + elif ethernet.internet == "activating": + wlan_state = "ethernet:connecting" + else: + wlan_state = "ethernet:disconnected" + + modus_service.wlan = wlan_state + + def update_state(self): + tooltip = "No network connection" + icon_file = "wifi-off-clear.svg" + + # Check WiFi first (prioritize WiFi over Ethernet) + if self.network_service.wifi_device: + wifi = self.network_service.wifi_device + if not wifi.wireless_enabled: + icon_file = "wifi-off-clear.svg" + tooltip = "WiFi disabled" + elif wifi.active_access_point: + ap = wifi.active_access_point + # Use dynamic WiFi icon based on signal strength + wifi_icon_path = get_wifi_icon_for_strength(ap.strength) + self.network_icon.set_from_file(wifi_icon_path) + tooltip = f"Connected to {ap.ssid}" + if ap.strength >= 0: + tooltip += f" ({ap.strength}%)" + self.network_button.set_tooltip_text(tooltip) + return # Early return to avoid setting icon again + else: + icon_file = "wifi-off-clear.svg" + tooltip = "WiFi disconnected" + + # Check Ethernet if WiFi is not connected + elif self.network_service.ethernet_device: + ethernet = self.network_service.ethernet_device + if ethernet.internet == "activated": + icon_file = "network-wired.svg" + tooltip = "Ethernet connected" + if hasattr(ethernet, "speed") and ethernet.speed: + tooltip += f" ({ethernet.speed})" + elif ethernet.internet == "activating": + icon_file = "network-wired.svg" + tooltip = "Ethernet connecting..." + else: + icon_file = "network-wired-offline.svg" + tooltip = "Ethernet disconnected" + + self.network_icon.dynamic_file(f"applets/{icon_file}") + self.network_button.set_tooltip_text(tooltip) + + def on_wifi_clicked(self, *args): + handle_indicator_click(self.wifi_mousecapture) + + def close_wifi(self, *args): + hide_control_center(self.wifi_mousecapture) + + def hide_controlcenter(self, *args): + hide_control_center(self.wifi_mousecapture) + + +class BatteryIndicator(Box): + def __init__(self, show_window=True, **kwargs): + super().__init__(name="battery-indicator", orientation="h", **kwargs) + self.show_window = show_window + + self.battery_service = BatteryService() + + self.battery_icon = svg_file("battery/battery-100.svg", size=23) + + self.battery_button = Button( + name="battery-button", + child=self.battery_icon, + on_clicked=self.on_battery_clicked, + ) + + self.battery_label = Label(name="battery-label", label="--- %") + + self.add(self.battery_label) + self.add(self.battery_button) + + # Set pointer cursor on hover + setup_cursor_hover(self.battery_button, "pointer") + + ( + self.battery_window, + self.battery_widget, + self.battery_mousecapture, + ) = setup_control_center( + self.show_window, + "battery", + BatteryControl, + self, + show_back_button=False, + ) + if self.battery_window: + self.battery_window._pointing_widget = self.battery_button + + modus_service.connect("battery-changed", self.on_battery_changed) + self.battery_service.connect("changed", self.on_battery_direct_changed) + + self.update_modus_service_battery_state() + self.update_state() + + def on_battery_changed(self, service, new_battery_state): + self.update_state() + + def on_battery_direct_changed(self, *args): + self.update_modus_service_battery_state() + self.update_state() + + def _get_percentage(self): + return int(self.battery_service.get_property("Percentage") or 0) + + def _get_state(self): + state_value = self.battery_service.get_property("State") + return DeviceState.get(state_value, "UNKNOWN") + + def _is_present(self): + return bool(self.battery_service.get_property("IsPresent")) + + def _get_time_to_empty(self): + return int(self.battery_service.get_property("TimeToEmpty") or 0) + + def _get_time_to_full(self): + return int(self.battery_service.get_property("TimeToFull") or 0) + + def _format_time(self, seconds: int) -> str: + return format_duration(seconds) + + def _get_battery_icon_file(self, percentage: int, is_charging: bool) -> str: + clamped = max(0, min(100, percentage)) + step = (clamped // 10) * 10 + filename = f"battery-{step:03d}{'-charging' if is_charging else ''}.svg" + return f"battery/{filename}" + + def update_modus_service_battery_state(self): + if not self._is_present(): + battery_state = "not_present" + else: + percentage = self._get_percentage() + state_str = self._get_state().lower() + + battery_state = f"{state_str}:{percentage}%" + + if state_str == "discharging": + time_to_empty = self._format_time(self._get_time_to_empty()) + if time_to_empty != "N/A": + battery_state += f":{time_to_empty}" + elif state_str == "charging": + time_to_full = self._format_time(self._get_time_to_full()) + if time_to_full != "N/A": + battery_state += f":{time_to_full}" + + modus_service.battery = battery_state + + def get_battery_tooltip(self, percentage, state): + tooltip = f"Battery: {percentage}%" + + if state == "CHARGING": + tooltip += " (Charging)" + time_to_full = self._format_time(self._get_time_to_full()) + if time_to_full != "N/A": + tooltip += f" - {time_to_full} until full" + elif state == "DISCHARGING": + time_to_empty = self._format_time(self._get_time_to_empty()) + if time_to_empty != "N/A": + tooltip += f" - {time_to_empty} remaining" + elif state == "FULLY_CHARGED": + tooltip += " (Fully charged)" + + return tooltip + + def update_state(self): + if not self._is_present(): + self.set_visible(False) + return + else: + self.set_visible(True) + + percentage = self._get_percentage() + state = self._get_state() + is_charging = state in ["CHARGING", "FULLY_CHARGED"] + + icon_file = self._get_battery_icon_file(percentage, is_charging) + tooltip = self.get_battery_tooltip(percentage, state) + percentage_text = f"{percentage}%" + + self.battery_icon.dynamic_file(icon_file) + self.battery_button.set_tooltip_text(tooltip) + self.battery_label.set_label(percentage_text) + + def on_battery_clicked(self, *args): + handle_indicator_click(self.battery_mousecapture) + + def close_battery(self, *args): + hide_control_center(self.battery_mousecapture) + + def hide_controlcenter(self, *args): + hide_control_center(self.battery_mousecapture) diff --git a/src/window/panel/components/recording_indicator.py b/src/window/panel/components/recording_indicator.py new file mode 100644 index 0000000..241346d --- /dev/null +++ b/src/window/panel/components/recording_indicator.py @@ -0,0 +1,212 @@ +from fabric.utils import GLib, os, time +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.label import Label +from services.screencapture import screen_capture_service + +from utils.utils import setup_cursor_hover, svg_file + + +class RecordingIndicator(Button): + def __init__(self, **kwargs): + super().__init__(name="panel-button", visible=True, **kwargs) + + self.recording_start_time = None + self.last_process_check = 0 + self.process_check_interval = 1.0 + self.timer_update_interval = 1000 + self.status_check_interval = 2000 + + self.timer_timeout_id = None + self.status_timeout_id = None + + self.recording_icon = svg_file("misc/media-record.svg", size=24) + + self.time_label = Label( + name="recording-time-label", + markup="00:00", + max_width_chars=5, + ellipsize="none", + ) + + self.recording_box = Box( + orientation="h", + spacing=2, + children=[self.recording_icon, self.time_label], + size=(80, -1), + ) + + self.add(self.recording_box) + + # Prevent container.show_all() from forcing this visible when no recording + try: + self.set_no_show_all(True) + except Exception: + pass + + self.connect("clicked", self.on_stop_recording) + try: + setup_cursor_hover(self, "pointer") + except Exception: + pass + self.connect("button-press-event", self.on_button_press) + self.set_visible(False) + + GLib.timeout_add(100, self._delayed_init) + + def on_button_press(self, *args): + GLib.timeout_add(100, lambda: self.remove_style_class("pressed") or False) + return False + + def is_recorder_running(self): + return screen_capture_service.is_recording + + def check_recording_status(self): + current_time = time.time() + self.last_process_check = current_time + + try: + is_recording = self.is_recorder_running() + + if is_recording: + if not self.get_visible(): + self.set_visible(True) + if self.timer_timeout_id is None: + self.timer_timeout_id = GLib.timeout_add( + self.timer_update_interval, self.update_timer_display + ) + + if self.recording_start_time is None: + self.recording_start_time = self.get_recording_start_time() + + self.update_timer_display() + else: + # Ensure it stays hidden when not recording + self.set_visible(False) + self.cleanup_recording_state() + + except Exception as e: + print(f"[DEBUG] Error checking recording status: {e}") + if self.get_visible(): + self.set_visible(False) + self.cleanup_recording_state() + + return True + + def update_timer_display(self): + if not self.get_visible() or self.recording_start_time is None: + return False + + try: + elapsed_seconds = int(time.time() - self.recording_start_time) + minutes = elapsed_seconds // 60 + seconds = elapsed_seconds % 60 + time_text = f"{minutes:02d}:{seconds:02d}" + + self.time_label.set_markup(time_text) + self.set_tooltip_text( + f"Recording in progress ({time_text}) - Click to stop" + ) + + return True + except Exception as e: + print(f"[DEBUG] Error updating timer display: {e}") + return False + + def cleanup_recording_state(self): + self.recording_start_time = None + + if self.timer_timeout_id: + GLib.source_remove(self.timer_timeout_id) + self.timer_timeout_id = None + + def get_recording_start_time(self): + wf_file = "/tmp/recording_start_time.txt" + gpu_file = "/tmp/gpu_recording_start_time.txt" + + def read_timestamp(path): + try: + with open(path, "r") as f: + content = f.read().strip() + if content: + t = float(content) + if abs(t - time.time()) <= 3600: + return t + return os.path.getmtime(path) + except (OSError, ValueError): + return None + + if os.path.exists(wf_file): + t = read_timestamp(wf_file) + if t: + print("[DEBUG] Using wf-recorder start time") + return t + + if os.path.exists(gpu_file): + t = read_timestamp(gpu_file) + if t: + print("[DEBUG] Using gpu-screen-recorder start time") + return t + + print("[DEBUG] No start time file found, using current time") + return time.time() + + def on_stop_recording(self, *args): + try: + self.set_visible(False) + self.cleanup_recording_state() + screen_capture_service.stop_recording() + + GLib.timeout_add(500, self._verify_recording_stopped) + GLib.timeout_add(1500, self._verify_recording_stopped) + GLib.timeout_add(3000, self._verify_recording_stopped) + + except Exception: + self.set_visible(False) + self.cleanup_recording_state() + + def _verify_recording_stopped(self): + try: + if self.is_recorder_running(): + if self.recording_start_time is None: + self.recording_start_time = self.get_recording_start_time() + + if self.recording_start_time: + self.set_visible(True) + self.update_timer_display() + + if self.timer_timeout_id is None: + self.timer_timeout_id = GLib.timeout_add( + self.timer_update_interval, self.update_timer_display + ) + else: + self.set_visible(False) + self.cleanup_recording_state() + + except Exception: + self.set_visible(False) + self.cleanup_recording_state() + + return False + + def _delayed_init(self): + try: + self.check_recording_status() + self.status_timeout_id = GLib.timeout_add( + self.status_check_interval, self.check_recording_status + ) + + except Exception as e: + print(f"[DEBUG] Error in delayed recording indicator init: {e}") + return False + + def destroy(self): + if self.timer_timeout_id: + GLib.source_remove(self.timer_timeout_id) + self.timer_timeout_id = None + + if self.status_timeout_id: + GLib.source_remove(self.status_timeout_id) + self.status_timeout_id = None + + super().destroy() diff --git a/src/window/panel/components/workspace.py b/src/window/panel/components/workspace.py new file mode 100644 index 0000000..8ceebc6 --- /dev/null +++ b/src/window/panel/components/workspace.py @@ -0,0 +1,79 @@ +from fabric.hyprland.widgets import HyprlandWorkspaces, WorkspaceButton +from fabric.widgets.box import Box + +from services.config import get_config, on_config_change +from utils.functions import is_special_workspace_id +from utils.utils import setup_cursor_hover + +# TODO: Support multi-monitor setups + + +class WorkspaceIndicator(Box): + def __init__(self, **kwargs): + Box.__init__( + self, name="workspace-indicator", orientation="h", spacing=4, **kwargs + ) + self._current_config = {"hide_special_workspace": True} + on_config_change(self._on_config_changed) + + self.workspaces = HyprlandWorkspaces( + name="workspaces", + spacing=4, + buttons_factory=self._get_button_factory(), + ) + + self.add(self.workspaces) + self.show_all() + + self._apply_initial_config() + + def _apply_initial_config(self): + new_value = get_config("hide_special_workspace", True) + if new_value != self._current_config["hide_special_workspace"]: + self._current_config["hide_special_workspace"] = new_value + self.update_config({"hide_special_workspace": new_value}) + + def _on_config_changed(self, new_config: dict, old_config: dict): + if "hide_special_workspace" in new_config and new_config.get( + "hide_special_workspace" + ) != old_config.get("hide_special_workspace"): + self._current_config["hide_special_workspace"] = new_config.get( + "hide_special_workspace", True + ) + self.update_config( + { + "hide_special_workspace": self._current_config[ + "hide_special_workspace" + ] + } + ) + + def _get_button_factory(self): + if self._current_config.get("hide_special_workspace", True): + return self._create_filtered_button + else: + return lambda ws_id: self._create_button_with_hover(ws_id) + + def _create_filtered_button(self, ws_id): + if self._current_config.get( + "hide_special_workspace", True + ) and is_special_workspace_id(ws_id): + return None + return self._create_button_with_hover(ws_id) + + def _create_button_with_hover(self, ws_id): + button = WorkspaceButton(id=ws_id, label=str(ws_id)) + setup_cursor_hover(button, "pointer") + return button + + def update_config(self, new_config: dict): + if "hide_special_workspace" in new_config: + button_factory = self._get_button_factory() + self.remove(self.workspaces) + self.workspaces = HyprlandWorkspaces( + name="workspaces", + spacing=4, + buttons_factory=button_factory, + ) + self.add(self.workspaces) + self.workspaces.show_all() diff --git a/src/window/panel/main.py b/src/window/panel/main.py new file mode 100644 index 0000000..f0c55ab --- /dev/null +++ b/src/window/panel/main.py @@ -0,0 +1,269 @@ +from fabric.system_tray.widgets import SystemTray +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.centerbox import CenterBox +from fabric.widgets.datetime import DateTime +from fabric.widgets.revealer import Revealer +from fabric.widgets.wayland import WaylandWindow as Window + +from services.config import get_config, on_config_change +from services.modus import notification_service +from shared.window.mousecapture import MouseCapture +from utils.roam import modus_service +from utils.utils import setup_cursor_hover, svg_file +from window.controlcenter.main import ModusControlCenter +from window.notification.notification_center import NotificationCenter +from window.panel.components.enhanced_system_tray import apply_enhanced_system_tray +from window.panel.components.globalmenu import GlobalMenu +from window.panel.components.indicators import ( + BatteryIndicator, + BluetoothIndicator, + NetworkIndicator, +) +from window.panel.components.recording_indicator import RecordingIndicator +from window.panel.components.workspace import WorkspaceIndicator + +apply_enhanced_system_tray() + + +class Panel(Window): + def __init__(self, **kwargs): + super().__init__( + name="bar", + title="modus", + layer="top", + anchor="left top right", + exclusivity="auto", + visible=False, + all_visible=False, + ) + self.globalmenu = GlobalMenu(parent_window=self) + + self.imac = Button( + name="panel-button", + child=svg_file("misc/logo.svg", size=18), + on_clicked=lambda *_: self.globalmenu.show_system_dropdown((self.imac)), + ) + setup_cursor_hover(self.imac, "pointer") + + self.tray = SystemTray(name="panel-button", spacing=4, icon_size=20) + + self.tray_revealer = Revealer( + name="tray-revealer", + child=self.tray, + child_revealed=False, + transition_type="slide-left", + transition_duration=300, + ) + + self.chevron_button = Button( + name="panel-button", + child=svg_file("misc/chevron-right.svg", size=16), + on_clicked=self.toggle_tray, + ) + setup_cursor_hover(self.chevron_button, "pointer") + + # Hide tray elements if empty + self.tray.connect("add", self._update_tray_visibility) + self.tray.connect("remove", self._update_tray_visibility) + + self.indicators = Box( + name="indicators", + orientation="h", + spacing=4, + ) + + self.search = Button( + name="panel-button", child=svg_file("misc/search.svg", size=22) + ) + setup_cursor_hover(self.search, "pointer") + + self.control_center = MouseCapture( + layer="top", child_window=ModusControlCenter() + ) + + self.control_center_btn = Button( + name="panel-button", + child=svg_file("misc/control-center.svg", size=22), + on_clicked=self.control_center.toggle_mousecapture, + ) + setup_cursor_hover(self.control_center_btn, "pointer") + self.control_center.child_window._pointing_widget = self.control_center_btn + + self.notification_center = MouseCapture( + layer="overlay", child_window=NotificationCenter() + ) + + self.notification_icon = svg_file( + "notifications/notification-inactive.svg", size=22 + ) + + self.notification_center_btn = Button( + name="panel-button", + child=self.notification_icon, + on_clicked=self.on_notification_icon_clicked, + ) + setup_cursor_hover(self.notification_center_btn, "pointer") + self.notification_center.child_window._pointing_widget = ( + self.notification_center_btn + ) + + self.datetime_btn = Button( + name="panel-button", + child=DateTime(name="date-time", formatters=["%a %-d %b %I:%M %P"]), + ) + setup_cursor_hover(self.datetime_btn, "pointer") + + self.workspace_indicator = WorkspaceIndicator() + self.recording_indicator = RecordingIndicator() + + # Create boxes and mount, to allow live updates + self.left_box = Box(name="window-left") + self.center_box = Box(name="window-center", children=self.recording_indicator) + self.right_box = Box(name="window-right", spacing=4, orientation="h") + + self.children = CenterBox( + name="panel", + start_children=self.left_box, + center_children=self.center_box, + end_children=self.right_box, + ) + + # Connect to DND state changes for notification icon + modus_service.connect("dont-disturb-changed", self.on_dnd_changed) + + # Connect to notification service for icon state updates + notification_service.connect( + "notify::count", self.on_notification_count_changed + ) + + # Set initial notification icon state + self.update_notification_icon() + + # Initial layout build + self._rebuild_layout_from_config() + + # Live updates + on_config_change(self._on_config_changed) + + self._update_tray_visibility() + self.show_all() + + def on_dnd_changed(self, _, dnd_state): + self.update_notification_icon() # Update notification icon when DND changes + + def on_notification_count_changed(self, service, *args): + self.update_notification_icon() + + def on_notification_icon_clicked(self, *args): + count = notification_service.count + if count > 0: + # Only open notification center if there are notifications + self.notification_center.toggle_mousecapture() + # Do nothing if no notifications + + def update_notification_icon(self): + count = notification_service.count + dnd_enabled = modus_service.dont_disturb + + if dnd_enabled: + # DND is enabled - show disabled icon + icon_file = "notification-disabled.svg" + elif count > 0: + # Has notifications - show active icon + icon_file = "notification-active.svg" + else: + # No notifications - show inactive icon + icon_file = "notification-inactive.svg" + + self.notification_icon.dynamic_file(f"notifications/{icon_file}") + + def _rebuild_layout_from_config(self): + # Left + left_children = [] + if get_config("imac_button", True): + left_children.append(self.imac) + if get_config("global_menu", True): + left_children.append(self.globalmenu) + self.left_box.children = left_children + + # Indicators (create fresh instances on each rebuild) + indicators_children = [] + if get_config("battery", True): + indicators_children.append(BatteryIndicator()) + if get_config("network", True): + indicators_children.append(NetworkIndicator()) + if get_config("bluetooth", True): + indicators_children.append(BluetoothIndicator()) + self.indicators.children = indicators_children + + # Right + right_children = [] + if get_config("workspace_indicator", True): + right_children.append(self.workspace_indicator) + + if get_config("systray", True): + right_children.extend([self.tray_revealer, self.chevron_button]) + + right_children.append(self.indicators) + + if get_config("search", True): + right_children.append(self.search) + if get_config("control_center", True): + right_children.append(self.control_center_btn) + if get_config("date_time", True): + right_children.append(self.datetime_btn) + if get_config("notification_center", True): + right_children.append(self.notification_center_btn) + + self.right_box.children = right_children + self._update_tray_visibility() + self.show_all() + + def _on_config_changed(self, new_config, old_config): + keys = { + "imac_button", + "global_menu", + "workspace_indicator", + "systray", + "battery", + "network", + "bluetooth", + "search", + "control_center", + "date_time", + "notification_center", + } + if any(new_config.get(k) != old_config.get(k) for k in keys): + self._rebuild_layout_from_config() + + # Always update tray visibility on config change just in case + self._update_tray_visibility() + + def _update_tray_visibility(self, *_): + # We check if there are any visible children in the tray + visible_children = [ + child for child in self.tray.get_children() if child.get_visible() + ] + has_items = len(visible_children) > 0 + + self.tray_revealer.set_visible(has_items) + self.chevron_button.set_visible(has_items) + + if not has_items: + # Reset state if it becomes hidden + self.tray_revealer.child_revealed = False + self.chevron_button.get_child().dynamic_file("misc/chevron-right.svg") + # Add extra spacing between workspace indicator and indicators when tray is hidden + self.indicators.set_margin_left(10) + else: + self.indicators.set_margin_left(0) + + def toggle_tray(self, *_): + current_state = self.tray_revealer.child_revealed + self.tray_revealer.child_revealed = not current_state + + if self.tray_revealer.child_revealed: + self.chevron_button.get_child().dynamic_file("misc/chevron-left.svg") + else: + self.chevron_button.get_child().dynamic_file("misc/chevron-right.svg") diff --git a/src/window/switcher.py b/src/window/switcher.py new file mode 100644 index 0000000..c16b305 --- /dev/null +++ b/src/window/switcher.py @@ -0,0 +1,307 @@ +import json + +from fabric.hyprland.widgets import get_hyprland_connection +from fabric.utils import Gdk +from fabric.widgets.box import Box +from fabric.widgets.eventbox import EventBox +from fabric.widgets.image import Image +from fabric.widgets.label import Label +from fabric.widgets.wayland import WaylandWindow as Window + +import shared.data as data +from utils.functions import is_special_workspace +from utils.icon_resolver import IconResolver + + +class ApplicationSwitcher(Window): + def __init__(self, **kwargs): + super().__init__( + name="application-switcher", + title="modus-switcher", + layer="top", + anchor="center", + exclusivity="auto", + keyboard_mode="exclusive", + visible=False, # Start hidden until explicitly shown + **kwargs, + ) + + self.conn = get_hyprland_connection() + self.icon_resolver = IconResolver() + self.windows = [] + self.current_index = 0 + self.tab_pressed = False + self.items_per_row = data.WINDOW_SWITCHER_ITEMS_PER_ROW + self.icon_size = 96 + self.items_per_row = 100 # Single row focus + + 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="h", + spacing=16, + h_align="center", + v_align="center", + ) + container.add(self.view) + + self.selection_label = Label( + name="switcher-selection-label", + label="", + h_align="center", + v_align="center", + style_classes=["switcher-selection-label"], + ) + container.add(self.selection_label) + + self.workspace_label = Label( + name="switcher-workspace-label", + label="", + h_align="center", + v_align="center", + style_classes=["switcher-workspace-label"], + ) + container.add(self.workspace_label) + self.connect("key-press-event", self.on_key_press) + self.connect("key-release-event", self.on_key_release) + + 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 create_icon_for_window(self, window): + """Create an icon image for a specific window""" + class_name = window.get("class", "").lower() + icon_img = self.icon_resolver.get_icon_pixbuf(class_name, self.icon_size) + if not icon_img: + icon_img = self.icon_resolver.get_icon_pixbuf( + "application-x-executable-symbolic", self.icon_size + ) + + icon_image = Image() + if icon_img: + icon_image.set_from_pixbuf(icon_img) + + return icon_image + + def _is_special_workspace(self, client): + return is_special_workspace(client) + + def update_windows(self) -> None: + for child in self.view.get_children(): + self.view.remove(child) + + 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=16, + h_align="center", + v_align="center", + ) + self.view.add(current_row) + + for i, window in enumerate(self.windows): + # Create icon image for this window + icon_image = self.create_icon_for_window(window) + + button_content = Box( + name="switcher-button", + orientation="v", + h_align="center", + v_align="center", + children=[ + Box( + name="switcher-icon-box", + style_classes=["window-basic", "sleek-border"], + children=[icon_image], + h_align="center", + v_align="center", + ), + ], + ) + + event_box = EventBox( + name="window-button", + style_classes=["active"] if i == self.current_index else None, + child=button_content, + ) + current_row.add(event_box) + + self.view.show_all() + self.update_selection() + except Exception as e: + print(f"Failed to update windows: {e}") + + def on_key_press(self, _, event): + keyval = event.keyval + state = event.state + alt_pressed = bool(state & Gdk.ModifierType.MOD1_MASK) + + if not self.windows: + return False + + if keyval == Gdk.KEY_Escape: + self.hide_switcher() + return True + + if keyval == Gdk.KEY_Tab: + if not self.tab_pressed or alt_pressed: + self.current_index = (self.current_index + 1) % len(self.windows) + self.update_selection() + self.tab_pressed = True + return True + + if keyval == Gdk.KEY_ISO_Left_Tab or ( + keyval == Gdk.KEY_Tab and (state & Gdk.ModifierType.SHIFT_MASK) + ): + self.current_index = (self.current_index - 1) % len(self.windows) + self.update_selection() + return True + + if keyval == Gdk.KEY_Return: + self.activate_selected() + self.hide_switcher() + return True + + if keyval == Gdk.KEY_Right or keyval == Gdk.KEY_l: + self.current_index = (self.current_index + 1) % len(self.windows) + self.update_selection() + return True + + if keyval == Gdk.KEY_Left or keyval == Gdk.KEY_h: + self.current_index = (self.current_index - 1) % len(self.windows) + self.update_selection() + return True + + if keyval == Gdk.KEY_Down: + next_index = self.current_index + self.items_per_row + if next_index < len(self.windows): + self.current_index = next_index + self.update_selection() + return True + + if keyval == Gdk.KEY_Up: + next_index = self.current_index - self.items_per_row + if next_index >= 0: + self.current_index = next_index + self.update_selection() + return True + + return False + + def on_key_release(self, _, event): + keyval = event.keyval + + if keyval in (Gdk.KEY_Alt_L, Gdk.KEY_Alt_R): + self.activate_selected() + self.hide_switcher() + return True + + if keyval == Gdk.KEY_Tab: + self.tab_pressed = False + return True + + return False + + def update_selection(self): + for row in self.view.get_children(): + for i, child in enumerate(row.get_children()): + index = self.view.get_children().index(row) * self.items_per_row + i + if index == self.current_index: + child.add_style_class("active") + # Update selection label + current_window = self.windows[self.current_index] + app_class = current_window.get("class", "Unknown") + # capitalize if it's all lowercase + if app_class.islower(): + app_class = app_class.capitalize() + self.selection_label.set_label(app_class) + + # Update workspace label + workspace = current_window.get("workspace", {}) + workspace_name = workspace.get("name", "Unknown") + self.workspace_label.set_label(f"Workspace {workspace_name}") + else: + child.remove_style_class("active") + + def activate_selected(self): + if not self.windows or self.current_index >= len(self.windows): + return + + window = self.windows[self.current_index] + address = window.get("address") + if address: + try: + command = f"/dispatch focuswindow address:{address}" + self.conn.send_command(command) + except Exception as e: + print(f"Failed to focus window: {e}") + + def grab_keyboard(self): + try: + display = Gdk.Display.get_default() + seat = display.get_default_seat() + window = self.get_window() + seat.grab(window, Gdk.SeatCapabilities.KEYBOARD, False, None, None, None) + except Exception as e: + print(f"Failed to grab keyboard: {e}") + + def ungrab_keyboard(self): + try: + display = Gdk.Display.get_default() + seat = display.get_default_seat() + seat.ungrab() + except Exception as e: + print(f"Failed to ungrab keyboard: {e}") diff --git a/src/window/todo/__init__.py b/src/window/todo/__init__.py new file mode 100644 index 0000000..c9af2aa --- /dev/null +++ b/src/window/todo/__init__.py @@ -0,0 +1 @@ +# Todo module diff --git a/src/window/todo/todo_widget.py b/src/window/todo/todo_widget.py new file mode 100644 index 0000000..a487ae5 --- /dev/null +++ b/src/window/todo/todo_widget.py @@ -0,0 +1,443 @@ +from datetime import datetime + +from fabric.utils import GLib +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 fabric.widgets.wayland import WaylandWindow as Window + +from services.todo import get_todo_service +from shared.window.mousecapture import MouseCapture +from utils.utils import svg_file + + +class TodoItem(Box): + """Individual todo item widget""" + + def __init__(self, todo_data, todo_list_widget, **kwargs): + self.todo_data = todo_data + self.todo_list_widget = todo_list_widget + self.editing = False + + super().__init__( + name="todo-item", + orientation="h", + spacing=8, + style_classes=["menu"], + **kwargs, + ) + + self._build_ui() + + def _build_ui(self): + """Build the todo item UI""" + # Checkbox for completion - using SVG icons + checkbox_icon = ( + "checkbox-check.svg" + if self.todo_data["completed"] + else "checkbox-uncheck.svg" + ) + self.checkbox_icon = svg_file( + "todo/" + checkbox_icon, + name="todo-checkbox-icon", + size=24, + ) + self.checkbox = Button( + name="todo-checkbox", + child=self.checkbox_icon, + on_clicked=self._toggle_completion, + ) + + # Todo text (can be converted to entry for editing) + text_content = self.todo_data["text"] + if self.todo_data["completed"]: + text_content = f"<s>{text_content}</s>" + + self.text_label = Label( + markup=text_content, + name="todo-text", + h_align="start", + h_expand=True, + line_wrap="word-char", + style_classes=( + ["title-widget"] + if not self.todo_data["completed"] + else ["status-label"] + ), + ) + + # Date/time label + created_at = datetime.fromisoformat(self.todo_data["created_at"]) + date_text = created_at.strftime("%b %d, %Y at %I:%M %p") + + self.date_label = Label( + label=date_text, + name="todo-date", + h_align="start", + style_classes=["todo-date-text"], + ) + + self.text_entry = Entry( + name="todo-text-entry", + text=self.todo_data["text"], + h_expand=True, + visible=False, + ) + self.text_entry.connect("activate", self._save_edit) + + # Priority indicator + # priority_symbols = {"high": "🔴", "medium": "🟡", "low": "🟢"} + # + # self.priority_label = Label( + # label=priority_symbols.get(self.todo_data["priority"], "🟡"), + # name="todo-priority-label", + # ) + # self.priority_button = Button( + # name="todo-priority", + # size=(20, 20), + # child=self.priority_label, + # on_clicked=self._cycle_priority, + # ) + # + # Edit button - using SVG icon + self.edit_icon = svg_file( + "todo/edit.svg", + name="todo-edit-icon", + size=12, + ) + self.edit_button = Button( + name="todo-edit", + child=self.edit_icon, + on_clicked=self._start_edit, + ) + + # Delete button - using SVG icon + self.delete_icon = svg_file( + "todo/delete-symbolic.svg", + name="todo-delete-icon", + size=12, + ) + self.delete_button = Button( + name="todo-delete", + child=self.delete_icon, + on_clicked=self._delete_todo, + ) + + # Text container that switches between label and entry + self.text_container = Box( + orientation="v", + h_expand=True, + children=[ + Box(orientation="h", children=[self.text_label]), + self.date_label, + ], + ) + + self.children = [ + self.checkbox, + self.text_container, + # self.priority_button, + self.edit_button, + self.delete_button, + ] + + def _toggle_completion(self, *_): + """Toggle todo completion status""" + get_todo_service().toggle_todo(self.todo_data["id"]) + + def _cycle_priority(self, *_): + """Cycle through priority levels""" + priorities = ["low", "medium", "high"] + current_index = priorities.index(self.todo_data["priority"]) + new_priority = priorities[(current_index + 1) % len(priorities)] + get_todo_service().set_priority(self.todo_data["id"], new_priority) + + def _start_edit(self, *_): + """Start editing the todo text""" + if self.editing: + return + + self.editing = True + self.text_container.children = [ + Box(orientation="h", children=[self.text_entry]), + self.date_label, + ] + self.text_entry.set_visible(True) + self.text_entry.grab_focus() + self.text_entry.set_position(-1) # Move cursor to end + + def _save_edit(self, *_): + """Save the edited todo text""" + if not self.editing: + return + + new_text = self.text_entry.get_text().strip() + if new_text: + get_todo_service().edit_todo(self.todo_data["id"], new_text) + + self._cancel_edit() + + def _cancel_edit(self): + """Cancel editing and revert to label""" + self.editing = False + self.text_container.children = [ + Box(orientation="h", children=[self.text_label]), + self.date_label, + ] + self.text_entry.set_visible(False) + + def _delete_todo(self, *_): + """Delete this todo""" + get_todo_service().delete_todo(self.todo_data["id"]) + + def update_from_data(self, todo_data): + """Update the widget from new todo data""" + self.todo_data = todo_data + + # Update checkbox icon by recreating it + checkbox_icon = ( + "checkbox-check.svg" if todo_data["completed"] else "checkbox-uncheck.svg" + ) + new_checkbox_icon = svg_file( + "todo/" + checkbox_icon, + name="todo-checkbox-icon", + size=20, + ) + self.checkbox.set_child(new_checkbox_icon) + self.checkbox_icon = new_checkbox_icon + + # Update text and styling with markup + text_content = todo_data["text"] + if todo_data["completed"]: + text_content = f"<s>{text_content}</s>" + + self.text_label.set_markup(text_content) + self.text_label.style_classes = ( + ["title-widget"] if not todo_data["completed"] else ["status-label"] + ) + + # Update date/time + created_at = datetime.fromisoformat(todo_data["created_at"]) + date_text = created_at.strftime("%b %d, %Y at %I:%M %p") + self.date_label.set_label(date_text) + + +class TodoListWidget(Window): + """Main todo list widget window""" + + def __init__(self, **kwargs): + super().__init__( + title="modus-todo", + anchor="top right", + margin="2px 10px 0px 0px", + exclusivity="auto", + keyboard_mode="on-demand", + name="todo-list-window", + visible=False, # Back to hidden by default + **kwargs, + ) + + self.todo_items = {} # Maps todo IDs to TodoItem widgets + + # Register callback with todo service + get_todo_service().add_callback(self._on_todo_event) + + self._build_ui() + self._refresh_todos() + + # Add keybinding for escape + self.add_keybinding("Escape", self.hide_todo_list) + + def _build_ui(self): + """Build the main UI""" + # Header with title and stats + self.stats_label = Label( + label="", + name="todo-stats", + style_classes=["status-label"], + h_align="start", + ) + + self.header = Box( + name="todo-header", + orientation="v", + children=[ + Label( + label="Todo List", + name="todo-title", + style_classes=["title"], + h_align="start", + ), + self.stats_label, + ], + ) + + # Add new todo section + self.new_todo_entry = Entry( + name="new-todo-entry", + placeholder_text="Add a new task...", + h_expand=True, + ) + self.new_todo_entry.connect("activate", self._add_todo) + + # Add button - using SVG icon + self.add_icon = svg_file( + "todo/plus-symbolic.svg", + name="add-todo-icon", + size=12, + ) + self.add_button = Button( + name="add-todo-button", + child=self.add_icon, + on_clicked=self._add_todo, + ) + + self.add_section = Box( + name="todo-add-section", + orientation="h", + spacing=8, + style_classes=["menu"], + children=[ + self.new_todo_entry, + self.add_button, + ], + ) + + # Todo items container + self.todos_container = Box( + name="todos-container", + orientation="v", + spacing=4, + ) + + # Scrolled window for todos + self.scrolled = ScrolledWindow( + name="todos-scrolled", + min_content_height=300, + max_content_height=500, + min_content_width=400, + child=self.todos_container, + policy="automatic", + v_expand=True, # Allow vertical expansion + ) + + # Clear completed button + self.clear_button = Button( + name="clear-completed-button", + label="Clear Completed", + style_classes=["status-label"], + on_clicked=self._clear_completed, + ) + + # Main container + self.main_container = Box( + name="todo-main-container", + orientation="v", + spacing=8, + style_classes=["menu"], + children=[ + self.header, + self.add_section, + self.scrolled, + self.clear_button, + ], + ) + + self.children = [self.main_container] + + def _add_todo(self, *_): + """Add a new todo""" + text = self.new_todo_entry.get_text().strip() + if text: + get_todo_service().add_todo(text) + self.new_todo_entry.set_text("") + + def _clear_completed(self, *_): + """Clear all completed todos""" + get_todo_service().clear_completed() + + def _on_todo_event(self, event_type, data=None): + """Handle todo service events via callback""" + if event_type == "todos-changed": + GLib.idle_add(self._refresh_todos) + elif event_type == "todo-added": + GLib.idle_add(self._refresh_todos) + elif event_type == "todo-deleted": + GLib.idle_add(self._refresh_todos) + elif event_type in ["todo-toggled", "todo-edited", "todo-priority-changed"]: + if data and data["id"] in self.todo_items: + GLib.idle_add( + lambda: self.todo_items[data["id"]].update_from_data(data) + ) + GLib.idle_add(self._update_stats) + + def _refresh_todos(self, *_): + """Refresh the entire todo list""" + # Clear existing items + self.todo_items.clear() + self.todos_container.children = [] + + # Get all todos + todos = get_todo_service().todos + + # Sort todos: incomplete first, then by priority, then by creation date + def sort_key(todo): + priority_order = {"high": 0, "medium": 1, "low": 2} + return ( + todo["completed"], # False (incomplete) comes before True (completed) + priority_order.get(todo["priority"], 1), + todo["created_at"], + ) + + sorted_todos = sorted(todos, key=sort_key) + + # Create todo item widgets + for todo in sorted_todos: + todo_item = TodoItem(todo, self) + self.todo_items[todo["id"]] = todo_item + + # Update container children + self.todos_container.children = list(self.todo_items.values()) + + # Update stats + self._update_stats() + + def _update_stats(self): + """Update the statistics display""" + stats = get_todo_service().get_stats() + stats_text = f"{stats['pending']} pending, {stats['completed']} completed" + self.stats_label.set_label(stats_text) + + def set_visible(self, visible): + """Override set_visible for debugging""" + super().set_visible(visible) + + def hide_todo_list(self, *_): + """Hide the todo list""" + if hasattr(self, "_mousecapture_parent"): + self._mousecapture_parent.toggle_mousecapture() + self.set_visible(False) + + def _init_mousecapture(self, mousecapture): + """Initialize mousecapture parent reference""" + self._mousecapture_parent = mousecapture + + def destroy(self): + """Clean up when destroyed""" + # Remove callback from todo service + get_todo_service().remove_callback(self._on_todo_event) + + super().destroy() + + +class TodoListCapture(MouseCapture): + """MouseCapture wrapper for the todo list""" + + def __init__(self, **kwargs): + super().__init__( + layer="top", + child_window=TodoListWidget(), + **kwargs, + ) diff --git a/start.py b/start.py new file mode 100644 index 0000000..f9ff948 --- /dev/null +++ b/start.py @@ -0,0 +1,24 @@ +import os +import sys + +# Ensure the 'src' directory is in the python path +# This allows 'import main' and other src-relative imports to work from the root +src_path = os.path.join(os.path.dirname(__file__), "src") +if src_path not in sys.path: + sys.path.insert(0, src_path) + +def run_app(): + from main import main as app_main + app_main() + + +def run_lock(): + from lock import main as lock_main + lock_main() + + +if __name__ == "__main__": + if len(sys.argv) > 1 and sys.argv[1] == "lock": + run_lock() + else: + run_app() diff --git a/styles/colors.css b/styles/colors.css deleted file mode 100644 index 1a391d3..0000000 --- a/styles/colors.css +++ /dev/null @@ -1,32 +0,0 @@ -:vars { - --foreground: #e4e1e9; - --background: #131318; - --cursor: #e4e1e9; - --primary: #bec2ff; - --on-primary: #262b60; - --secondary: #c5c4dd; - --on-secondary: #2e2f42; - --tertiary: #e7b9d5; - --on-tertiary: #45263c; - --surface: #131318; - --surface-bright: #39393f; - --error: #ffb4ab; - --error-dim: #ff8678; - --on-error: #690005; - --error-container: #93000a; - --outline: #91909a; - --shadow: #000000; - --red: #ffb2b9; - --red-dim: #ff7f8b; - --green: #95d5a7; - --green-dim: #70c789; - --yellow: #b8cf84; - --yellow-dim: #a3c15f; - --blue: #bec2ff; - --blue-dim: #8b92ff; - --magenta: #e4b7f3; - --magenta-dim: #d48bec; - --cyan: #82d3e2; - --cyan-dim: #59c4d8; - --white: #82d3e0; -} diff --git a/uv.lock b/uv.lock index feaadb7..b444ac0 100644 --- a/uv.lock +++ b/uv.lock @@ -95,7 +95,7 @@ wheels = [ [[package]] name = "modus" version = "0.1.0" -source = { virtual = "." } +source = { editable = "." } dependencies = [ { name = "fabric" }, { name = "pam" },