This commit is contained in:
2026-06-03 21:26:54 +02:00
parent 05e6b8d061
commit d21e5175d5
125 changed files with 41986 additions and 0 deletions
+629
View File
@@ -0,0 +1,629 @@
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