diff --git a/modules/launcher/plugins/wallpaper.py b/modules/launcher/plugins/wallpaper.py deleted file mode 100644 index a40ca51..0000000 --- a/modules/launcher/plugins/wallpaper.py +++ /dev/null @@ -1,869 +0,0 @@ -import colorsys -import hashlib -import json -import os -import random -import re -import threading -import time -from typing import Dict, List, Optional - -import config.data as data -import utils.icons as icons -from fabric.utils.helpers import exec_shell_command_async -from gi.repository import GdkPixbuf -from modules.launcher.plugin_base import PluginBase -from modules.launcher.result import Result -from PIL import Image - - -class WallpaperPlugin(PluginBase): - """ - Plugin for wallpaper management with search, random selection, and matugen integration. - """ - - def __init__(self): - super().__init__() - self.display_name = "Wallpaper" - self.wallpapers = [] - self.cache_dir = f"{data.CACHE_DIR}/thumbs" - self.thumbnail_cache: Dict[str, Optional[GdkPixbuf.Pixbuf]] = {} - self.thumbnail_loading = set() # Track which thumbnails are being loaded - self.schemes = { - "scheme-tonal-spot": "Tonal Spot", - "scheme-content": "Content", - "scheme-expressive": "Expressive", - "scheme-fidelity": "Fidelity", - "scheme-fruit-salad": "Fruit Salad", - "scheme-monochrome": "Monochrome", - "scheme-neutral": "Neutral", - "scheme-rainbow": "Rainbow", - } - - def initialize(self): - """Initialize the wallpaper plugin.""" - self.set_triggers(["wall"]) - self._load_wallpapers() - os.makedirs(self.cache_dir, exist_ok=True) - # Start background thumbnail creation - self._start_background_thumbnail_creation() - - def cleanup(self): - """Cleanup the wallpaper plugin.""" - pass - - def _load_wallpapers(self): - """Load available wallpapers from the wallpapers directory.""" - try: - if os.path.exists(data.WALLPAPERS_DIR): - self.wallpapers = sorted( - [f for f in os.listdir(data.WALLPAPERS_DIR) if self._is_image(f)] - ) - else: - print(f"Wallpapers directory not found: {data.WALLPAPERS_DIR}") - except Exception as e: - print(f"Error loading wallpapers: {e}") - - def _is_image(self, filename: str) -> bool: - """Check if file is a supported image format.""" - return filename.lower().endswith( - (".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp") - ) - - def _get_matugen_state(self) -> bool: - """Get current matugen state from config.json.""" - self.matugen_enabled = True # Default to True - try: - with open(data.CONFIG_FILE, "r") as f: - config = json.load(f) - self.matugen_enabled = config.get("matugen_enabled", True) - except FileNotFoundError: - # File doesn't exist, keep default True - pass - except Exception as e: - print(f"Error reading config file: {e}") - # Keep default True on error - - return self.matugen_enabled - - def _set_matugen_state(self, enabled: bool): - """Set matugen state and save to config.json.""" - self.matugen_enabled = enabled - - # Save the state to config.json - try: - # Read current config - config = {} - if os.path.exists(data.CONFIG_FILE): - with open(data.CONFIG_FILE, "r") as f: - config = json.load(f) - - # Update matugen state - config["matugen_enabled"] = enabled - - # Write back to config file - with open(data.CONFIG_FILE, "w") as f: - json.dump(config, f, indent=4) - - except Exception as e: - print(f"Error writing matugen state to config: {e}") - - # # 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 _get_cache_path(self, filename: str) -> str: - """Get cache path for wallpaper thumbnail.""" - file_hash = hashlib.md5(filename.encode("utf-8")).hexdigest() - return os.path.join(self.cache_dir, f"{file_hash}.png") - - def _create_thumbnail(self, filename: str) -> str: - """Create thumbnail for wallpaper if it doesn't exist.""" - full_path = os.path.join(data.WALLPAPERS_DIR, filename) - cache_path = self._get_cache_path(filename) - - if not os.path.exists(cache_path): - try: - with Image.open(full_path) as img: - # Use faster thumbnail creation with smaller size for better performance - img.thumbnail((32, 32), Image.Resampling.LANCZOS) - img.save(cache_path, "PNG", optimize=True) - except Exception as e: - print(f"Error creating thumbnail for {filename}: {e}") - return None - - return cache_path - - def _start_background_thumbnail_creation(self): - """Start background thread to create thumbnails for all wallpapers.""" - - def create_thumbnails(): - for wallpaper in self.wallpapers: - if wallpaper not in self.thumbnail_loading: - self.thumbnail_loading.add(wallpaper) - try: - cache_path = self._create_thumbnail(wallpaper) - if cache_path and os.path.exists(cache_path): - # Load thumbnail into memory cache - try: - pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( - cache_path, 32, 32, True - ) - self.thumbnail_cache[wallpaper] = pixbuf - except Exception as e: - print(f"Error loading thumbnail for {wallpaper}: {e}") - self.thumbnail_cache[wallpaper] = None - else: - self.thumbnail_cache[wallpaper] = None - except Exception as e: - print(f"Error processing thumbnail for {wallpaper}: {e}") - self.thumbnail_cache[wallpaper] = None - finally: - self.thumbnail_loading.discard(wallpaper) - - # Small delay to prevent overwhelming the system - time.sleep(0.01) - - # Start background thread - thread = threading.Thread(target=create_thumbnails, daemon=True) - thread.start() - - def _get_thumbnail_fast(self, filename: str) -> Optional[GdkPixbuf.Pixbuf]: - """Get thumbnail quickly from cache or return None if not ready.""" - # Return cached thumbnail if available - if filename in self.thumbnail_cache: - return self.thumbnail_cache[filename] - - # Check if thumbnail file exists and load it immediately - cache_path = self._get_cache_path(filename) - if os.path.exists(cache_path): - try: - pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( - cache_path, 32, 32, True - ) - self.thumbnail_cache[filename] = pixbuf - return pixbuf - except Exception as e: - print(f"Error loading thumbnail for {filename}: {e}") - self.thumbnail_cache[filename] = None - return None - - # If not in cache and file doesn't exist, trigger background creation - if filename not in self.thumbnail_loading: - self.thumbnail_loading.add(filename) - - def create_async(): - try: - cache_path = self._create_thumbnail(filename) - if cache_path and os.path.exists(cache_path): - try: - pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( - cache_path, 32, 32, True - ) - self.thumbnail_cache[filename] = pixbuf - except Exception as e: - print(f"Error loading thumbnail for {filename}: {e}") - self.thumbnail_cache[filename] = None - else: - self.thumbnail_cache[filename] = None - except Exception as e: - print(f"Error creating thumbnail for {filename}: {e}") - self.thumbnail_cache[filename] = None - finally: - self.thumbnail_loading.discard(filename) - - thread = threading.Thread(target=create_async, daemon=True) - thread.start() - - return None - - def _set_wallpaper(self, filename: str, scheme: str = None): - """Set wallpaper with optional matugen integration.""" - full_path = os.path.join(data.WALLPAPERS_DIR, filename) - current_wall = os.path.expanduser("~/.current.wall") - - if scheme is None: - scheme = self._get_current_scheme() - - # Update current wallpaper symlink - if os.path.isfile(current_wall) or os.path.islink(current_wall): - os.remove(current_wall) - os.symlink(full_path, current_wall) - - # Apply wallpaper with or without matugen - matugen_enabled = self._get_matugen_state() - if matugen_enabled: - exec_shell_command_async(f'matugen image "{full_path}" -t {scheme}') - else: - exec_shell_command_async( - f'swww img "{ - full_path - }" -t outer --transition-duration 1.5 --transition-step 255 --transition-fps 60 -f Nearest' - ) - - def _set_random_wallpaper(self): - """Set a random wallpaper.""" - if not self.wallpapers: - return - - filename = random.choice(self.wallpapers) - self._set_wallpaper(filename) - # Show notification for immediate feedback - exec_shell_command_async( - f"notify-send '🎲 Random Wallpaper' 'Applied: {filename}' -a '{ - data.APP_NAME_CAP - }' -e" - ) - return filename - - def _hsl_to_rgb_hex(self, h: float, s: float = 1.0, l: float = 0.5) -> str: - """Convert HSL color value to RGB HEX string.""" - # colorsys uses HLS, not HSL, and expects values between 0.0 and 1.0 - hue = h / 360.0 - r, g, b = colorsys.hls_to_rgb(hue, l, s) # Note the order: H, L, S - r_int, g_int, b_int = int(r * 255), int(g * 255), int(b * 255) - return f"#{r_int:02X}{g_int:02X}{b_int:02X}" - - def _is_valid_hex_color(self, hex_color: str) -> bool: - """Check if string is a valid hex color.""" - if not hex_color.startswith("#"): - hex_color = "#" + hex_color - return bool(re.match(r"^#[0-9A-Fa-f]{6}$", hex_color)) - - def _get_current_scheme(self) -> str: - """Get current color scheme (default to tonal-spot).""" - # You could store this in a config file, for now use default - return "scheme-tonal-spot" - - def _set_current_scheme(self, scheme: str): - """Set current color scheme.""" - # For now, just use it immediately. Could be stored in config later. - scheme_name = self.schemes.get(scheme, scheme) - matugen_enabled = self._get_matugen_state() - - def _apply_hex_color(self, hex_color: str, scheme: str = None): - """Apply hex color using matugen. Assumes matugen is enabled.""" - if not hex_color.startswith("#"): - hex_color = "#" + hex_color - - if scheme is None: - scheme = self._get_current_scheme() - - exec_shell_command_async(f'matugen color hex "{hex_color}" -t {scheme}') - - def _generate_random_hex_color(self) -> str: - """Generate a random hex color.""" - hue = random.randint(0, 360) - return self._hsl_to_rgb_hex(hue) - - def _get_status_indicators(self) -> tuple: - """Get current status indicators for display.""" - current_scheme = self._get_current_scheme() - matugen_enabled = self._get_matugen_state() - scheme_name = self.schemes.get(current_scheme, current_scheme) - - indicators = [] - if not matugen_enabled: - indicators.append("⚠ Matugen Off") - - indicator_text = " • " + " • ".join(indicators) if indicators else "" - status_text = f"Matugen: { - 'Enabled' if matugen_enabled else 'Disabled' - } • Scheme: {scheme_name}" - - return indicator_text, status_text, current_scheme, matugen_enabled - - def query(self, query_string: str) -> List[Result]: - """Search wallpapers and provide management options.""" - results = [] - query = query_string.lower().strip() - - # Get status indicators for consistent display - indicator_text, status_text, current_scheme, matugen_enabled = ( - self._get_status_indicators() - ) - - # Special commands - if query.strip() == "random": - # Show result for random wallpaper (execute on Enter) - results.append( - Result( - title=f"Random Wallpaper{indicator_text}", - subtitle=f"Set a random wallpaper • {status_text}", - icon_markup=random.choice( - [ - icons.dice_1, - icons.dice_2, - icons.dice_3, - icons.dice_4, - icons.dice_5, - icons.dice_6, - ] - ), - action=lambda: self._set_random_wallpaper(), - relevance=1.0, - plugin_name=self.display_name, - data={ - "action": "random", - "bypass_max_results": True, - "keep_launcher_open": True, - }, - ) - ) - elif query.startswith("random") and query.strip() != "random": - # Show suggestion for random wallpaper (partial match) - results.append( - Result( - title=f"Random Wallpaper{indicator_text}", - subtitle=f"Set a random wallpaper • {status_text}", - icon_markup=random.choice( - [ - icons.dice_1, - icons.dice_2, - icons.dice_3, - icons.dice_4, - icons.dice_5, - icons.dice_6, - ] - ), - action=lambda: self._set_random_wallpaper(), - relevance=0.9, - plugin_name=self.display_name, - data={ - "action": "random_suggestion", - "bypass_max_results": True, - "keep_launcher_open": True, - }, - ) - ) - - # Hex color commands - if "color" in query or "hex" in query or query.startswith("#"): - # Check for scheme specification in the query - scheme = self._get_current_scheme() - for scheme_id, scheme_name in self.schemes.items(): - if ( - scheme_name.lower() in query.lower() - or scheme_id.lower() in query.lower() - ): - scheme = scheme_id - break - - # Check for hex color patterns - hex_match = re.search(r"#?([0-9A-Fa-f]{6})", query) - if hex_match: - hex_color = "#" + hex_match.group(1) - scheme_name = self.schemes.get(scheme, scheme) - - # Check if this is a complete hex color input (6 digits) - # Execute immediately when we have a complete 6-digit hex color - if len(hex_match.group(1)) == 6: - # Check if there's additional text after the hex color - hex_end_pos = hex_match.end() - remaining_text = query[hex_end_pos:].strip() - - # Only show result if no additional text after hex color (exact match) - if not remaining_text: - # Check if matugen is enabled - if matugen_enabled: - # Show result for hex color (execute on Enter) - results.append( - Result( - title=f"Apply Hex Color: {hex_color}{ - indicator_text - }", - subtitle=f"Apply with {scheme_name} scheme • { - status_text - }", - icon_markup=icons.palette, - action=lambda c=hex_color, - s=scheme: self._apply_hex_color(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 disabled - results.append( - Result( - title=f"Cannot Apply Hex Color: {hex_color}{ - indicator_text - }", - subtitle="Matugen is disabled • Enable matugen to use hex colors", - icon_markup=icons.palette, - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={ - "action": "hex_color_failed", - "color": hex_color, - "scheme": scheme, - "bypass_max_results": True, - }, - ) - ) - else: - # Show suggestion for hex color with additional text (partial match) - results.append( - Result( - title=f"Apply Hex Color: {hex_color}{indicator_text}", - subtitle=f"Apply with {scheme_name} scheme • { - status_text - }", - icon_markup=icons.palette, - action=lambda c=hex_color, - s=scheme: self._apply_hex_color(c, s) - if matugen_enabled - else None, - relevance=0.9, - plugin_name=self.display_name, - data={ - "action": "hex_color_suggestion", - "color": hex_color, - "scheme": scheme, - "bypass_max_results": True, - "keep_launcher_open": True, - }, - ) - ) - else: - # Incomplete hex color, show as suggestion - results.append( - Result( - title=f"Hex Color (incomplete): {hex_color}", - subtitle="Complete the 6-digit hex color to apply", - icon_markup=icons.palette, - action=lambda: None, - relevance=0.7, - plugin_name=self.display_name, - data={ - "action": "hex_color_incomplete", - "color": hex_color, - "bypass_max_results": True, - }, - ) - ) - elif "random" in query and ("color" in query or "hex" in query): - # Check for exact matches for random color commands - if ( - query.strip() == "color random" - or query.strip() == "hex random" - or query.strip() == "random color" - or query.strip() == "random hex" - ): - # Random hex color - show result (execute on Enter) - scheme_name = self.schemes.get(scheme, scheme) - - # Check if matugen is enabled - if matugen_enabled: - results.append( - Result( - title=f"Random Hex Color{indicator_text}", - subtitle=f"Generate and apply random color with { - scheme_name - } scheme • {status_text}", - icon_markup=icons.palette, - action=lambda s=scheme: self._apply_hex_color( - 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 disabled - results.append( - Result( - title=f"Cannot Apply Random Color{indicator_text}", - subtitle="Matugen is disabled • Enable matugen to use hex colors", - icon_markup=icons.palette, - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={ - "action": "random_hex_failed", - "bypass_max_results": True, - }, - ) - ) - else: - # Show suggestion for random hex color (partial match) - results.append( - Result( - title=f"Random Hex Color{indicator_text}", - subtitle=f"Generate and apply random color • {status_text}", - icon_markup=icons.palette, - action=lambda: self._apply_hex_color( - self._generate_random_hex_color(), scheme - ) - if matugen_enabled - else None, - relevance=0.8, - plugin_name=self.display_name, - data={ - "action": "random_hex_suggestion", - "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, or add scheme name", - icon_markup=icons.palette, - action=lambda: None, - relevance=0.8, - plugin_name=self.display_name, - data={"action": "hex_help", "bypass_max_results": True}, - ) - ) - - # Color scheme commands - if "scheme" in query: - current_scheme = self._get_current_scheme() - matugen_enabled = self._get_matugen_state() - - # Show all available schemes - for scheme_id, scheme_name in self.schemes.items(): - # Check if this scheme matches the query (for filtering) - if ( - query.strip() == "scheme" - or scheme_name.lower() in query.lower() - or scheme_id.lower() in query.lower() - ): - # Create indicators - indicators = [] - if scheme_id == current_scheme: - indicators.append("● Current") - if not matugen_enabled: - indicators.append("⚠ Matugen Off") - - indicator_text = ( - " • " + " • ".join(indicators) if indicators else "" - ) - - results.append( - Result( - title=f"{scheme_name}{indicator_text}", - subtitle=f"Set color scheme to {scheme_name}" - + ( - " (requires matugen enabled)" - if not matugen_enabled - else "" - ), - icon_markup=icons.palette, - action=lambda s=scheme_id: self._set_current_scheme(s), - relevance=1.0 if scheme_id == current_scheme else 0.8, - plugin_name=self.display_name, - data={ - "action": "scheme_select", - "scheme": scheme_id, - "bypass_max_results": True, - "keep_launcher_open": True, - }, - ) - ) - - # Matugen controls - if "matugen" in query: - current_state = self._get_matugen_state() - - # Check for exact command matches - if query.strip() == "matugen on" or query.strip() == "matugen enable": - # Show result for enabling matugen (execute on Enter) - results.append( - Result( - title=f"Enable Matugen{indicator_text}", - subtitle=f"Turn on dynamic color generation • {status_text}", - icon_markup=icons.palette, - action=lambda: self._set_matugen_state(True), - relevance=0.9, - plugin_name=self.display_name, - data={ - "action": "matugen_on", - "bypass_max_results": True, - "keep_launcher_open": True, - }, - ) - ) - elif query.strip() == "matugen off" or query.strip() == "matugen disable": - # Show result for disabling matugen (execute on Enter) - results.append( - Result( - title=f"Disable Matugen{indicator_text}", - subtitle=f"Turn off dynamic color generation • {status_text}", - icon_markup=icons.palette, - action=lambda: self._set_matugen_state(False), - relevance=0.9, - plugin_name=self.display_name, - data={ - "action": "matugen_off", - "bypass_max_results": True, - "keep_launcher_open": True, - }, - ) - ) - elif query.strip() == "matugen toggle": - # Show result for toggling matugen (execute on Enter) - new_state = not current_state - results.append( - Result( - title=f"Toggle Matugen to { - 'Enabled' if new_state else 'Disabled' - }{indicator_text}", - subtitle=f"Switch matugen to { - 'enabled' if new_state else 'disabled' - } • {status_text}", - icon_markup=icons.palette, - action=lambda: self._set_matugen_state(new_state), - relevance=0.9, - plugin_name=self.display_name, - data={ - "action": "matugen_toggle", - "bypass_max_results": True, - "keep_launcher_open": True, - }, - ) - ) - elif ("on" in query or "enable" in query) and not query.strip().endswith( - ("on", "enable") - ): - # Show suggestion for enabling (partial match) - results.append( - Result( - title=f"Enable Matugen{indicator_text}", - subtitle=f"Turn on dynamic color generation • {status_text}", - icon_markup=icons.palette, - action=lambda: self._set_matugen_state(True), - relevance=0.8, - plugin_name=self.display_name, - data={ - "action": "matugen_on_suggestion", - "bypass_max_results": True, - "keep_launcher_open": True, - }, - ) - ) - elif ("off" in query or "disable" in query) and not query.strip().endswith( - ("off", "disable") - ): - # Show suggestion for disabling (partial match) - results.append( - Result( - title=f"Disable Matugen{indicator_text}", - subtitle=f"Turn off dynamic color generation • {status_text}", - icon_markup=icons.palette, - action=lambda: self._set_matugen_state(False), - relevance=0.8, - plugin_name=self.display_name, - data={ - "action": "matugen_off_suggestion", - "bypass_max_results": True, - "keep_launcher_open": True, - }, - ) - ) - elif "toggle" in query and not query.strip().endswith("toggle"): - # Show suggestion for toggling (partial match) - results.append( - Result( - title=f"Toggle Matugen{indicator_text}", - subtitle=f"Switch matugen state • {status_text}", - icon_markup=icons.palette, - action=lambda: self._set_matugen_state(not current_state), - relevance=0.8, - plugin_name=self.display_name, - data={ - "action": "matugen_toggle_suggestion", - "bypass_max_results": True, - "keep_launcher_open": True, - }, - ) - ) - else: - # Show current state with scheme info - results.append( - Result( - title=f"Matugen: {'Enabled' if current_state else 'Disabled'}{ - indicator_text - }", - subtitle=f"Dynamic colors • {status_text}", - icon_markup=icons.palette, - action=lambda: None, - relevance=0.8, - plugin_name=self.display_name, - data={"action": "matugen_status", "bypass_max_results": True}, - ) - ) - - # Status command - if query == "status" or query == "info": - results.append( - Result( - title=f"Wallpaper System Status{indicator_text}", - subtitle=status_text, - icon_markup=icons.palette, - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"action": "status", "bypass_max_results": True}, - ) - ) - - # Handle empty query (just trigger keywords: wall, wallpaper, wp) - if not query: - # Show status and quick actions when just typing trigger keywords - results.append( - Result( - title=f"Wallpaper System{indicator_text}", - subtitle=f"{ - status_text - } • Type commands: random, scheme, status, matugen", - icon_markup=icons.wallpapers, - action=lambda: None, - relevance=1.0, - plugin_name=self.display_name, - data={"action": "overview", "bypass_max_results": True}, - ) - ) - - # Show quick actions - results.append( - Result( - title=f"Random Wallpaper{indicator_text}", - subtitle=f"Set a random wallpaper • {status_text}", - icon_markup=random.choice( - [ - icons.dice_1, - icons.dice_2, - icons.dice_3, - icons.dice_4, - icons.dice_5, - icons.dice_6, - ] - ), - action=lambda: self._set_random_wallpaper(), - relevance=0.9, - plugin_name=self.display_name, - data={ - "action": "random_quick", - "bypass_max_results": True, - "keep_launcher_open": True, - }, - ) - ) - - # Search wallpapers by filename - 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 limit results for better performance - matching_wallpapers.sort(key=lambda x: x[1], reverse=True) - - # Limit to first 50 results for performance (can be increased if needed) - max_results = 50 if query else 20 # Show fewer when no query to load faster - matching_wallpapers = matching_wallpapers[:max_results] - - for wallpaper, relevance in matching_wallpapers: - # Use fast thumbnail loading - icon = self._get_thumbnail_fast(wallpaper) - - results.append( - Result( - title=f"{wallpaper}{indicator_text if not query else ''}", - subtitle=f"Set as wallpaper{ - ' • ' + status_text if not query else '' - }", - icon=icon, - icon_markup=icons.wallpapers if not icon else None, - action=lambda w=wallpaper: self._set_wallpaper(w), - relevance=relevance, - plugin_name=self.display_name, - data={ - "wallpaper": wallpaper, - "action": "set", - "bypass_max_results": True, - "keep_launcher_open": True, - }, - ) - ) - - # Sort by relevance - no limit since we use bypass_max_results - results.sort(key=lambda x: x.relevance, reverse=True) - return results diff --git a/modules/launcher/plugins/wallpapers.py b/modules/launcher/plugins/wallpapers.py new file mode 100644 index 0000000..1a24398 --- /dev/null +++ b/modules/launcher/plugins/wallpapers.py @@ -0,0 +1,543 @@ +import colorsys +import concurrent.futures +import hashlib +import os +import random # <--- AÑADIDO +import shutil +from concurrent.futures import ThreadPoolExecutor + +from fabric.utils.helpers import exec_shell_command_async +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.entry import Entry +from fabric.widgets.label import Label +from fabric.widgets.scrolledwindow import ScrolledWindow +from gi.repository import Gdk, GdkPixbuf, Gio, GLib, Gtk, Pango +from PIL import Image + +import config.config +import config.data as data +import modules.icons as icons + + +class WallpaperSelector(Box): + CACHE_DIR = f"{data.CACHE_DIR}/thumbs" # Changed from wallpapers to thumbs + + def __init__(self, **kwargs): + # Delete the old cache directory if it exists + old_cache_dir = f"{data.CACHE_DIR}/wallpapers" + if os.path.exists(old_cache_dir): + shutil.rmtree(old_cache_dir) + + super().__init__(name="wallpapers", spacing=4, orientation="v", h_expand=False, v_expand=False, **kwargs) + os.makedirs(self.CACHE_DIR, exist_ok=True) + + # Process old wallpapers: use os.scandir for efficiency and only loop + # over image files that actually need renaming (they're not already lowercase + # and with hyphens instead of spaces) + with os.scandir(data.WALLPAPERS_DIR) as entries: + for entry in entries: + if entry.is_file() and self._is_image(entry.name): + # Check if the file needs renaming: file should be lowercase and have hyphens instead of spaces + if entry.name != entry.name.lower() or " " in entry.name: + new_name = entry.name.lower().replace(" ", "-") + full_path = os.path.join(data.WALLPAPERS_DIR, entry.name) + new_full_path = os.path.join(data.WALLPAPERS_DIR, new_name) + try: + os.rename(full_path, new_full_path) + print(f"Renamed old wallpaper '{full_path}' to '{new_full_path}'") + except Exception as e: + print(f"Error renaming file {full_path}: {e}") + + # Refresh the file list after potential renaming + self.files = sorted([f for f in os.listdir(data.WALLPAPERS_DIR) if self._is_image(f)]) + self.thumbnails = [] + self.thumbnail_queue = [] + self.executor = ThreadPoolExecutor(max_workers=4) # Shared executor + + # Variable to control the selection (similar to AppLauncher) + self.selected_index = -1 + + # Initialize UI components + self.viewport = Gtk.IconView(name="wallpaper-icons") + self.viewport.set_model(Gtk.ListStore(GdkPixbuf.Pixbuf, str)) + self.viewport.set_pixbuf_column(0) + # Hide text column so only the image is shown + self.viewport.set_text_column(-1) + self.viewport.set_item_width(0) + self.viewport.connect("item-activated", self.on_wallpaper_selected) + # self.viewport.connect("selection-changed", self._on_selection_changed) # Removed connection + + self.scrolled_window = ScrolledWindow( + name="scrolled-window", + spacing=10, + h_expand=True, + v_expand=True, + h_align="fill", + v_align="fill", + child=self.viewport, + propagate_width=False, + propagate_height=False, + ) + + self.search_entry = Entry( + name="search-entry-walls", + placeholder="Search Wallpapers...", + h_expand=True, + h_align="fill", + notify_text=lambda entry, *_: self.arrange_viewport(entry.get_text()), + on_key_press_event=self.on_search_entry_key_press, + ) + self.search_entry.props.xalign = 0.5 + self.search_entry.connect("focus-out-event", self.on_search_entry_focus_out) + + self.schemes = { + "scheme-tonal-spot": "Tonal Spot", + "scheme-content": "Content", + "scheme-expressive": "Expressive", + "scheme-fidelity": "Fidelity", + "scheme-fruit-salad": "Fruit Salad", + "scheme-monochrome": "Monochrome", + "scheme-neutral": "Neutral", + "scheme-rainbow": "Rainbow", + } + + self.scheme_dropdown = Gtk.ComboBoxText() + self.scheme_dropdown.set_name("scheme-dropdown") + self.scheme_dropdown.set_tooltip_text("Select color scheme") + for key, display_name in self.schemes.items(): + self.scheme_dropdown.append(key, display_name) + self.scheme_dropdown.set_active_id("scheme-tonal-spot") + self.scheme_dropdown.connect("changed", self.on_scheme_changed) + + # Load matugen state from the dedicated file + self.matugen_enabled = True # Default to True + try: + with open(data.MATUGEN_STATE_FILE, 'r') as f: + content = f.read().strip().lower() + if content == "false": + self.matugen_enabled = False + elif content == "true": + self.matugen_enabled = True + # Any other content defaults to True + except FileNotFoundError: + # File doesn't exist, keep default True and create it on first toggle + pass + except Exception as e: + print(f"Error reading matugen state file: {e}") + # Keep default True on error + + # Create a switcher to enable/disable Matugen (enabled by default) + self.matugen_switcher = Gtk.Switch(name="matugen-switcher") + self.matugen_switcher.set_tooltip_text("Toggle dynamic colors") + self.matugen_switcher.set_vexpand(False) + self.matugen_switcher.set_hexpand(False) + self.matugen_switcher.set_valign(Gtk.Align.CENTER) + self.matugen_switcher.set_halign(Gtk.Align.CENTER) + self.matugen_switcher.set_active(self.matugen_enabled) + self.matugen_switcher.connect("notify::active", self.on_switch_toggled) + + self.mat_icon = Label(name="mat-label", markup=icons.palette) + + self.random_wall = Button( + name="random-wall-button", + child=Label(name="random-wall-label", markup=icons.dice_1), + tooltip_text="Random Wallpaper", + ) + self.random_wall.connect("clicked", self.set_random_wallpaper) # <--- AÑADIDO + + # Add the switcher to the header_box's start_children + self.header_box = Box( + name="header-box", + spacing=8, + orientation="h", + children=[self.random_wall, self.search_entry, self.scheme_dropdown, self.matugen_switcher], + ) + + self.add(self.header_box) + + # Create the custom color selector components + self.hue_slider = Gtk.Scale( + orientation=Gtk.Orientation.HORIZONTAL, # Changed from VERTICAL + adjustment=Gtk.Adjustment(value=0, lower=0, upper=360, step_increment=1, page_increment=10), + draw_value=False, # Hide the default value text + digits=0, + # inverted=True, # Removed inverted for horizontal + name="hue-slider", # For CSS styling + ) + + # Changed expand/align for horizontal orientation + self.hue_slider.set_hexpand(True) + self.hue_slider.set_halign(Gtk.Align.FILL) + self.hue_slider.set_vexpand(False) # Ensure it doesn't expand vertically + self.hue_slider.set_valign(Gtk.Align.CENTER) # Center vertically within its box + + self.apply_color_button = Button(name="apply-color-button", child=Label(name="apply-color-label", markup=icons.accept)) + self.apply_color_button.connect("clicked", self.on_apply_color_clicked) + self.apply_color_button.set_vexpand(False) # Ensure button doesn't expand vertically + self.apply_color_button.set_valign(Gtk.Align.CENTER) # Center button vertically + + self.custom_color_selector_box = Box( + orientation="h", spacing=5, name="custom-color-selector-box", # Changed orientation to horizontal + h_align="center" # Center the horizontal box + ) + self.custom_color_selector_box.add(self.hue_slider) + self.custom_color_selector_box.add(self.apply_color_button) + self.custom_color_selector_box.set_halign(Gtk.Align.FILL) + + # Add the scrolled window (grid) and the custom color selector box directly + # to the main WallpaperSelector box (which is already vertical) + self.pack_start(self.scrolled_window, True, True, 0) # Add grid, expand + self.pack_start(self.custom_color_selector_box, False, False, 0) # Add custom selector, don't expand + + # Removed the old main_content_box and its add + + self._start_thumbnail_thread() + self.connect("map", self.on_map) + self.setup_file_monitor() + self.show_all() + self.randomize_dice_icon() + # Ensure the search entry gets focus when starting + self.search_entry.grab_focus() + + def randomize_dice_icon(self): + dice_icons = [ + icons.dice_1, + icons.dice_2, + icons.dice_3, + icons.dice_4, + icons.dice_5, + icons.dice_6, + ] + chosen_icon = random.choice(dice_icons) + label = self.random_wall.get_child() + if isinstance(label, Label): + label.set_markup(chosen_icon) + + def set_random_wallpaper(self, widget, external=False): + if not self.files: + print("No wallpapers available to set a random one.") + return + + file_name = random.choice(self.files) + full_path = os.path.join(data.WALLPAPERS_DIR, file_name) + selected_scheme = self.scheme_dropdown.get_active_id() + current_wall = os.path.expanduser(f"~/.current.wall") + + if os.path.isfile(current_wall) or os.path.islink(current_wall): # Check for link too + os.remove(current_wall) + os.symlink(full_path, current_wall) + + if self.matugen_switcher.get_active(): + exec_shell_command_async(f'matugen image "{full_path}" -t {selected_scheme}') + else: + exec_shell_command_async( + f'swww img "{full_path}" -t outer --transition-duration 1.5 --transition-step 255 --transition-fps 60 -f Nearest' + ) + + print(f"Set random wallpaper: {file_name}") + + if external: + exec_shell_command_async(f"notify-send '🎲 Wallpaper' 'Setting a random wallpaper 🎨' -a '{data.APP_NAME_CAP}' -i '{full_path}' -e") + + self.randomize_dice_icon() + + def setup_file_monitor(self): + gfile = Gio.File.new_for_path(data.WALLPAPERS_DIR) + self.file_monitor = gfile.monitor_directory(Gio.FileMonitorFlags.NONE, None) + self.file_monitor.connect("changed", self.on_directory_changed) + + def on_directory_changed(self, monitor, file, other_file, event_type): + file_name = file.get_basename() + if event_type == Gio.FileMonitorEvent.DELETED: + if file_name in self.files: + self.files.remove(file_name) + cache_path = self._get_cache_path(file_name) + if os.path.exists(cache_path): + try: + os.remove(cache_path) + except Exception as e: + print(f"Error deleting cache {cache_path}: {e}") + self.thumbnails = [(p, n) for p, n in self.thumbnails if n != file_name] + GLib.idle_add(self.arrange_viewport, self.search_entry.get_text()) + elif event_type == Gio.FileMonitorEvent.CREATED: + if self._is_image(file_name): + # Convert filename to lowercase and replace spaces with "-" + new_name = file_name.lower().replace(" ", "-") + full_path = os.path.join(data.WALLPAPERS_DIR, file_name) + new_full_path = os.path.join(data.WALLPAPERS_DIR, new_name) + if new_name != file_name: + try: + os.rename(full_path, new_full_path) + file_name = new_name + print(f"Renamed file '{full_path}' to '{new_full_path}')") + except Exception as e: + print(f"Error renaming file {full_path}: {e}") + if file_name not in self.files: + self.files.append(file_name) + self.files.sort() + self.executor.submit(self._process_file, file_name) + elif event_type == Gio.FileMonitorEvent.CHANGED: + if self._is_image(file_name) and file_name in self.files: + cache_path = self._get_cache_path(file_name) + if os.path.exists(cache_path): + try: + os.remove(cache_path) + except Exception as e: + print(f"Error deleting cache for changed file {file_name}: {e}") + self.executor.submit(self._process_file, file_name) + + def arrange_viewport(self, query: str = ""): + model = self.viewport.get_model() + model.clear() + filtered_thumbnails = [ + (thumb, name) + for thumb, name in self.thumbnails + if query.casefold() in name.casefold() + ] + filtered_thumbnails.sort(key=lambda x: x[1].lower()) + for pixbuf, file_name in filtered_thumbnails: + model.append([pixbuf, file_name]) + # If the search entry is empty, no icon is selected; otherwise, select the first one. + if query.strip() == "": + self.viewport.unselect_all() + self.selected_index = -1 + elif len(model) > 0: + self.update_selection(0) + + def on_wallpaper_selected(self, iconview, path): + model = iconview.get_model() + file_name = model[path][1] + full_path = os.path.join(data.WALLPAPERS_DIR, file_name) + selected_scheme = self.scheme_dropdown.get_active_id() + current_wall = os.path.expanduser(f"~/.current.wall") + if os.path.isfile(current_wall) or os.path.islink(current_wall): + os.remove(current_wall) + os.symlink(full_path, current_wall) + if self.matugen_switcher.get_active(): + # Matugen is enabled: run the normal command. + exec_shell_command_async(f'matugen image "{full_path}" -t {selected_scheme}') + else: + # Matugen is disabled: run the alternative swww command. + exec_shell_command_async( + f'swww img "{full_path}" -t outer --transition-duration 1.5 --transition-step 255 --transition-fps 60 -f Nearest' + ) + + def on_scheme_changed(self, combo): + selected_scheme = combo.get_active_id() + print(f"Color scheme selected: {selected_scheme}") + + def on_search_entry_key_press(self, widget, event): + if event.state & Gdk.ModifierType.SHIFT_MASK: + if event.keyval in (Gdk.KEY_Up, Gdk.KEY_Down): + schemes_list = list(self.schemes.keys()) + current_id = self.scheme_dropdown.get_active_id() + current_index = schemes_list.index(current_id) if current_id in schemes_list else 0 + new_index = (current_index - 1) % len(schemes_list) if event.keyval == Gdk.KEY_Up else (current_index + 1) % len(schemes_list) + self.scheme_dropdown.set_active(new_index) + return True + elif event.keyval == Gdk.KEY_Right: + self.scheme_dropdown.popup() + return True + + if event.keyval in (Gdk.KEY_Up, Gdk.KEY_Down, Gdk.KEY_Left, Gdk.KEY_Right): + self.move_selection_2d(event.keyval) + return True + elif event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter): + if self.selected_index != -1: + path = Gtk.TreePath.new_from_indices([self.selected_index]) + self.on_wallpaper_selected(self.viewport, path) + return True + return False + + # Removed _on_selection_changed method + + def move_selection_2d(self, keyval): + model = self.viewport.get_model() + total_items = len(model) + if total_items == 0: + return + + # --- Determine Column Count --- + columns = self.viewport.get_columns() + + # If get_columns returns 0 or -1 (auto), try to estimate by checking item rows + if columns <= 0 and total_items > 0: + estimated_cols = 0 + try: + # Check the row of the first item (should be 0) + first_item_path = Gtk.TreePath.new_from_indices([0]) + base_row = self.viewport.get_item_row(first_item_path) + + # Find the index of the first item in the *next* row + for i in range(1, total_items): + path = Gtk.TreePath.new_from_indices([i]) + row = self.viewport.get_item_row(path) + if row > base_row: + estimated_cols = i # The number of items in the first row + break + + # If loop finished without finding a new row, all items are in one row + if estimated_cols == 0: + estimated_cols = total_items + + columns = max(1, estimated_cols) + except Exception: + # Fallback if get_item_row fails (e.g., widget not realized) + columns = 1 + elif columns <= 0 and total_items == 0: + columns = 1 # Should not happen due to early return, but safe + + # Ensure columns is at least 1 after all checks + columns = max(1, columns) + + # --- Navigation Logic --- + current_index = self.selected_index + new_index = current_index + + if current_index == -1: + # If nothing is selected, select the first or last item based on direction + if keyval in (Gdk.KEY_Down, Gdk.KEY_Right): + new_index = 0 + elif keyval in (Gdk.KEY_Up, Gdk.KEY_Left): + new_index = total_items - 1 + if total_items == 0: new_index = -1 # Handle edge case + + else: + # Calculate potential new index based on key press + if keyval == Gdk.KEY_Up: + potential_new_index = current_index - columns + # Only update if the new index is valid (>= 0) + if potential_new_index >= 0: + new_index = potential_new_index + elif keyval == Gdk.KEY_Down: + potential_new_index = current_index + columns + # Only update if the new index is valid (< total_items) + if potential_new_index < total_items: + new_index = potential_new_index + elif keyval == Gdk.KEY_Left: + # Only update if not already in the first column (index % columns != 0) + # and the index is greater than 0 + if current_index > 0 and current_index % columns != 0: + new_index = current_index - 1 + elif keyval == Gdk.KEY_Right: + # Only update if not in the last column ((index + 1) % columns != 0) + # and not the very last item (index < total_items - 1) + if current_index < total_items - 1 and (current_index + 1) % columns != 0: + new_index = current_index + 1 + + # Only update if the index actually changed and is valid + if new_index != self.selected_index and 0 <= new_index < total_items: + self.update_selection(new_index) + elif total_items > 0 and self.selected_index == -1 and 0 <= new_index < total_items: + # Handle selecting the first item when starting from -1 + self.update_selection(new_index) + + def update_selection(self, new_index: int): + self.viewport.unselect_all() + path = Gtk.TreePath.new_from_indices([new_index]) + self.viewport.select_path(path) + self.viewport.scroll_to_path(path, False, 0.5, 0.5) # Ensure the selected icon is visible + self.selected_index = new_index + + def _start_thumbnail_thread(self): + thread = GLib.Thread.new("thumbnail-loader", self._preload_thumbnails, None) + + def _preload_thumbnails(self, _data): + futures = [self.executor.submit(self._process_file, file_name) for file_name in self.files] + concurrent.futures.wait(futures) + GLib.idle_add(self._process_batch) + + def _process_file(self, file_name): + full_path = os.path.join(data.WALLPAPERS_DIR, file_name) + cache_path = self._get_cache_path(file_name) + if not os.path.exists(cache_path): + try: + with Image.open(full_path) as img: + width, height = img.size + side = min(width, height) + left = (img.width - side) // 2 + top = (height - side) // 2 + right = left + side + bottom = top + side + img_cropped = img.crop((left, top, right, bottom)) + img_cropped.thumbnail((96, 96), Image.Resampling.LANCZOS) + img_cropped.save(cache_path, "PNG") + except Exception as e: + print(f"Error processing {file_name}: {e}") + return + self.thumbnail_queue.append((cache_path, file_name)) + GLib.idle_add(self._process_batch) + + def _process_batch(self): + batch = self.thumbnail_queue[:10] + del self.thumbnail_queue[:10] + for cache_path, file_name in batch: + try: + pixbuf = GdkPixbuf.Pixbuf.new_from_file(cache_path) + self.thumbnails.append((pixbuf, file_name)) + self.viewport.get_model().append([pixbuf, file_name]) + except Exception as e: + print(f"Error loading thumbnail {cache_path}: {e}") + if self.thumbnail_queue: + GLib.idle_add(self._process_batch) + return False + + def _get_cache_path(self, file_name: str) -> str: + file_hash = hashlib.md5(file_name.encode("utf-8")).hexdigest() + return os.path.join(self.CACHE_DIR, f"{file_hash}.png") + + @staticmethod + def _is_image(file_name: str) -> bool: + return file_name.lower().endswith(('.png', '.jpg', '.jpeg', '.bmp', '.gif', '.webp')) + + def on_search_entry_focus_out(self, widget, event): + if self.get_mapped(): + widget.grab_focus() + return False + + def on_map(self, widget): + """Handles the map signal to set initial visibility of the color selector.""" + # Set visibility based on the loaded state when the widget becomes visible + self.custom_color_selector_box.set_visible(not self.matugen_enabled) + + def hsl_to_rgb_hex(self, h: float, s: float = 1.0, l: float = 0.5) -> str: + """Converts HSL color value to RGB HEX string.""" + # colorsys uses HLS, not HSL, and expects values between 0.0 and 1.0 + hue = h / 360.0 + r, g, b = colorsys.hls_to_rgb(hue, l, s) # Note the order: H, L, S + r_int, g_int, b_int = int(r * 255), int(g * 255), int(b * 255) + return f"#{r_int:02X}{g_int:02X}{b_int:02X}" + + def rgba_to_hex(self, rgba: Gdk.RGBA) -> str: + """Converts Gdk.RGBA to a HEX color string.""" + r = int(rgba.red * 255) + g = int(rgba.green * 255) + b = int(rgba.blue * 255) + return f"#{r:02X}{g:02X}{b:02X}" + + def on_switch_toggled(self, switch, gparam): + """Handles the toggling of the Matugen switch.""" + is_active = switch.get_active() + self.matugen_enabled = is_active + # self.scheme_dropdown.set_sensitive(is_active) + self.custom_color_selector_box.set_visible(not is_active) # Toggle visibility + + # Save the state to the dedicated file + try: + with open(data.MATUGEN_STATE_FILE, 'w') as f: + f.write(str(is_active)) + except Exception as e: + print(f"Error writing matugen state file: {e}") + + def on_apply_color_clicked(self, button): + """Applies the color selected by the hue slider via matugen.""" + hue_value = self.hue_slider.get_value() # Get value from 0-360 + hex_color = self.hsl_to_rgb_hex(hue_value) # Convert HSL(hue, 1.0, 0.5) to HEX + print(f"Applying color from slider: H={hue_value}, HEX={hex_color}") + selected_scheme = self.scheme_dropdown.get_active_id() + # Run matugen with the chosen hex color and selected scheme + exec_shell_command_async(f'matugen color hex "{hex_color}" -t {selected_scheme}') + # Optionally save the chosen color to config if needed later + # config.config.bind_vars["matugen_hex_color"] = hex_color + # config.config.save_config() # Removed as save_config doesn't exist diff --git a/modules/switcher.py b/modules/switcher.py index 1e0d727..2babfb7 100644 --- a/modules/switcher.py +++ b/modules/switcher.py @@ -181,12 +181,12 @@ class ApplicationSwitcher(Window): self.hide_switcher() return True - if keyval == Gdk.KEY_Right: + 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: + 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