diff --git a/modules/launcher/components/wallpapers.py b/modules/launcher/components/wallpapers.py index d8d2197..b208407 100644 --- a/modules/launcher/components/wallpapers.py +++ b/modules/launcher/components/wallpapers.py @@ -1,33 +1,33 @@ import os import hashlib -from gi.repository import GdkPixbuf, Gtk, GLib, Gio, Gdk +import shutil +import colorsys +from gi.repository import GdkPixbuf, Gtk, GLib, Gio, Gdk, Pango from fabric.widgets.box import Box +from fabric.widgets.button import Button from fabric.widgets.centerbox import CenterBox from fabric.widgets.entry import Entry from fabric.widgets.scrolledwindow import ScrolledWindow from fabric.widgets.label import Label from fabric.utils.helpers import exec_shell_command_async +import modules.icons as icons +import config.data as data +import config.config from PIL import Image import concurrent.futures from concurrent.futures import ThreadPoolExecutor -import utils.icons as icons -import config.data as data - class WallpaperSelector(Box): - CACHE_DIR = f"{data.CACHE_DIR}/thumbnails" + CACHE_DIR = f"{data.CACHE_DIR}/thumbnails" # Changed from wallpapers to thumbs def __init__(self, **kwargs): - super().__init__( - name="wallpapers", - spacing=4, - orientation="v", - h_expand=False, - v_expand=False, - **kwargs, - ) + + 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): @@ -38,16 +38,12 @@ class WallpaperSelector(Box): 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}'" - ) + 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 possible renaming - self.files = sorted( - [f for f in os.listdir(data.WALLPAPERS_DIR) if self._is_image(f)] - ) + # 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 @@ -63,6 +59,7 @@ class WallpaperSelector(Box): 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", @@ -101,13 +98,31 @@ class WallpaperSelector(Box): self.scheme_dropdown.set_active_id("scheme-tonal-spot") self.scheme_dropdown.connect("changed", self.on_scheme_changed) + # Load matugen state from the dedicated file + self.matugen_enabled = True # Default to True + try: + with open(data.MATUGEN_STATE_FILE, 'r') as f: + content = f.read().strip().lower() + if content == "false": + self.matugen_enabled = False + elif content == "true": + self.matugen_enabled = True + # Any other content defaults to True + except FileNotFoundError: + # File doesn't exist, keep default True and create it on first toggle + pass + except Exception as e: + print(f"Error reading matugen state file: {e}") + # Keep default True on error + # Create a switcher to enable/disable Matugen (enabled by default) self.matugen_switcher = Gtk.Switch(name="matugen-switcher") self.matugen_switcher.set_vexpand(False) self.matugen_switcher.set_hexpand(False) self.matugen_switcher.set_valign(Gtk.Align.CENTER) self.matugen_switcher.set_halign(Gtk.Align.CENTER) - self.matugen_switcher.set_active(True) + 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) @@ -116,14 +131,54 @@ class WallpaperSelector(Box): name="header-box", spacing=8, orientation="h", + # Removed color button and label from here start_children=[self.matugen_switcher, self.mat_icon], center_children=[self.search_entry], end_children=[self.scheme_dropdown], ) self.add(self.header_box) - self.add(self.scrolled_window) + + # Create the custom color selector components + self.hue_slider = Gtk.Scale( + orientation=Gtk.Orientation.HORIZONTAL, # Changed from VERTICAL + adjustment=Gtk.Adjustment(value=0, lower=0, upper=360, step_increment=1, page_increment=10), + draw_value=False, # Hide the default value text + digits=0, + # inverted=True, # Removed inverted for horizontal + name="hue-slider", # For CSS styling + ) + + # Changed expand/align for horizontal orientation + self.hue_slider.set_hexpand(True) + self.hue_slider.set_halign(Gtk.Align.FILL) + self.hue_slider.set_vexpand(False) # Ensure it doesn't expand vertically + self.hue_slider.set_valign(Gtk.Align.CENTER) # Center vertically within its box + + self.apply_color_button = Button(name="apply-color-button", child=Label(name="apply-color-label", markup=icons.accept)) + self.apply_color_button.connect("clicked", self.on_apply_color_clicked) + self.apply_color_button.set_vexpand(False) # Ensure button doesn't expand vertically + self.apply_color_button.set_valign(Gtk.Align.CENTER) # Center button vertically + + self.custom_color_selector_box = Box( + orientation="h", spacing=5, name="custom-color-selector-box", # Changed orientation to horizontal + h_align="center" # Center the horizontal box + ) + self.custom_color_selector_box.add(self.hue_slider) + self.custom_color_selector_box.add(self.apply_color_button) + self.custom_color_selector_box.set_halign(Gtk.Align.FILL) + + # Add the scrolled window (grid) and the custom color selector box directly + # to the main WallpaperSelector box (which is already vertical) + self.pack_start(self.scrolled_window, True, True, 0) # Add grid, expand + self.pack_start(self.custom_color_selector_box, False, False, 0) # Add custom selector, don't expand + + # Removed the old main_content_box and its add + self._start_thumbnail_thread() + self.connect("map", self.on_map) # Connect the map signal + # Set initial sensitivity based on loaded state + # self.scheme_dropdown.set_sensitive(self.matugen_enabled) # Ensure sensitivity is set correctly on load self.setup_file_monitor() # Initialize file monitoring self.show_all() # Ensure the search entry gets focus when starting @@ -157,7 +212,7 @@ class WallpaperSelector(Box): try: os.rename(full_path, new_full_path) file_name = new_name - print(f"Renamed file '{full_path}' to '{new_full_path}'") + 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: @@ -197,13 +252,17 @@ class WallpaperSelector(Box): file_name = model[path][1] full_path = os.path.join(data.WALLPAPERS_DIR, file_name) selected_scheme = self.scheme_dropdown.get_active_id() + current_wall = os.path.expanduser(f"~/.current.wall") + if os.path.isfile(current_wall): + os.remove(current_wall) + os.symlink(full_path, current_wall) if self.matugen_switcher.get_active(): # Matugen is enabled: run the normal command. - exec_shell_command_async(f"matugen image {full_path} -t {selected_scheme}") + 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" + 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): @@ -215,14 +274,8 @@ class WallpaperSelector(Box): 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) - ) + 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: @@ -239,53 +292,101 @@ class WallpaperSelector(Box): 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 - if self.selected_index == -1: - new_index = ( - 0 if keyval in (Gdk.KEY_Down, Gdk.KEY_Right) else total_items - 1 - ) - else: - current_index = self.selected_index - allocation = self.viewport.get_allocation() - item_width = 108 # Approximate item width including margins - columns = max(1, allocation.width // item_width) - if keyval == Gdk.KEY_Right: - new_index = current_index + 1 - elif keyval == Gdk.KEY_Left: - new_index = current_index - 1 - elif keyval == Gdk.KEY_Down: - new_index = current_index + columns - elif keyval == Gdk.KEY_Up: - new_index = current_index - columns - if new_index < 0: - new_index = 0 - if new_index >= total_items: - new_index = total_items - 1 + # --- Determine Column Count --- + columns = self.viewport.get_columns() - self.update_selection(new_index) + # 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.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 - ] + 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) @@ -297,7 +398,7 @@ class WallpaperSelector(Box): with Image.open(full_path) as img: width, height = img.size side = min(width, height) - left = (width - side) // 2 + left = (img.width - side) // 2 top = (height - side) // 2 right = left + side bottom = top + side @@ -330,11 +431,55 @@ class WallpaperSelector(Box): @staticmethod def _is_image(file_name: str) -> bool: - return file_name.lower().endswith( - (".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp") - ) + 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