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) self.files = [] GLib.idle_add(self._load_wallpapers_async().__next__) 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 _load_wallpapers_async(self): """Non-blocking wallpaper processing.""" # 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}") yield # Process files in small batches to keep UI responsive file_list = os.listdir(data.WALLPAPERS_DIR) batch_size = 20 # Process files in batches for i in range(0, len(file_list), batch_size): batch = file_list[i : i + batch_size] for filename in batch: if self._is_image(filename): self.files.append(filename) # Sort the current batch to maintain order self.files.sort() # Yield to let the main loop process events yield True # Final sort of the complete list self.files.sort() # Start thumbnail loading after files are processed self._start_thumbnail_thread() # Return False to stop the idle callback yield False 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'awww 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 awww command. exec_shell_command_async( f'awww 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