diff --git a/Assets/ColorScheme/Noctalia-default/Noctalia-default.json b/Assets/ColorScheme/Noctalia-default/Noctalia-default.json index 27f8c637d..326a5928e 100644 --- a/Assets/ColorScheme/Noctalia-default/Noctalia-default.json +++ b/Assets/ColorScheme/Noctalia-default/Noctalia-default.json @@ -35,4 +35,4 @@ "mHover": "#0e0e43", "mOnHover": "#fef29a" } -} +} \ No newline at end of file diff --git a/Assets/settings-default.json b/Assets/settings-default.json index 91df9c3e7..d65b47247 100644 --- a/Assets/settings-default.json +++ b/Assets/settings-default.json @@ -381,7 +381,9 @@ "schedulingMode": "off", "manualSunrise": "06:30", "manualSunset": "18:30", - "matugenSchemeType": "scheme-fruit-salad" + "matugenSchemeType": "scheme-fruit-salad", + "generationBackend": "matugen", + "internalThemerMode": "material" }, "templates": { "activeTemplates": [], diff --git a/Bin/colors.py b/Bin/colors.py new file mode 100644 index 000000000..fc5ac6d3d --- /dev/null +++ b/Bin/colors.py @@ -0,0 +1,1551 @@ +#!/usr/bin/env python3 +""" +Themer - Wallpaper-based color extraction and theme generation. + +A CLI tool that extracts dominant colors from wallpaper images and generates +Material Design or simpler accent-based color themes with light/dark variants. + +No external dependencies - uses only Python 3.11+ stdlib. + +Usage: + python3 colors.py IMAGE_PATH [OPTIONS] + +Options: + --material Generate Material-style colors (default) + --normal Generate simpler accent-based palette + --dark Generate dark theme only + --light Generate light theme only + --both Generate both themes (default) + --output FILE Write JSON to file (stdout if omitted) + +Example: + python3 colors.py ~/wallpaper.png --material --both + python3 colors.py ~/wallpaper.jpg --dark -o theme.json + +Author: Noctalia Shell +License: MIT +""" + +from __future__ import annotations + +import argparse +import json +import re +import struct +import sys +import zlib +try: + import tomllib +except ImportError: + # Fallback to tomli if available (for older python), or error + try: + import tomli as tomllib + except ImportError: + tomllib = None + +from dataclasses import dataclass +from pathlib import Path +from typing import Literal, Any + +# ============================================================================= +# Type Definitions +# ============================================================================= + +RGB = tuple[int, int, int] +HSL = tuple[float, float, float] +ThemeMode = Literal["dark", "light"] + + +@dataclass +class Color: + """Represents a color with RGB values (0-255).""" + r: int + g: int + b: int + + @classmethod + def from_rgb(cls, rgb: RGB) -> Color: + return cls(rgb[0], rgb[1], rgb[2]) + + @classmethod + def from_hex(cls, hex_str: str) -> Color: + """Parse hex color string (#RRGGBB or RRGGBB).""" + hex_str = hex_str.lstrip('#') + return cls( + int(hex_str[0:2], 16), + int(hex_str[2:4], 16), + int(hex_str[4:6], 16) + ) + + def to_rgb(self) -> RGB: + return (self.r, self.g, self.b) + + def to_hex(self) -> str: + """Convert to hex string (#RRGGBB).""" + return f"#{self.r:02x}{self.g:02x}{self.b:02x}" + + def to_hsl(self) -> HSL: + """Convert RGB to HSL.""" + return rgb_to_hsl(self.r, self.g, self.b) + + @classmethod + def from_hsl(cls, h: float, s: float, l: float) -> Color: + """Create Color from HSL values.""" + r, g, b = hsl_to_rgb(h, s, l) + return cls(r, g, b) + + +# ============================================================================= +# Color Utilities (RGB/HSL Conversion) +# ============================================================================= + +def rgb_to_hsl(r: int, g: int, b: int) -> HSL: + """ + Convert RGB (0-255) to HSL (0-360, 0-1, 0-1). + + Args: + r: Red component (0-255) + g: Green component (0-255) + b: Blue component (0-255) + + Returns: + Tuple of (hue, saturation, lightness) + """ + r_norm = r / 255.0 + g_norm = g / 255.0 + b_norm = b / 255.0 + + max_c = max(r_norm, g_norm, b_norm) + min_c = min(r_norm, g_norm, b_norm) + delta = max_c - min_c + + # Lightness + l = (max_c + min_c) / 2.0 + + if delta == 0: + h = 0.0 + s = 0.0 + else: + # Saturation + s = delta / (1 - abs(2 * l - 1)) if l != 0 and l != 1 else 0 + + # Hue + if max_c == r_norm: + h = 60.0 * (((g_norm - b_norm) / delta) % 6) + elif max_c == g_norm: + h = 60.0 * (((b_norm - r_norm) / delta) + 2) + else: + h = 60.0 * (((r_norm - g_norm) / delta) + 4) + + return (h, s, l) + + +def hsl_to_rgb(h: float, s: float, l: float) -> RGB: + """ + Convert HSL (0-360, 0-1, 0-1) to RGB (0-255). + + Args: + h: Hue (0-360) + s: Saturation (0-1) + l: Lightness (0-1) + + Returns: + Tuple of (r, g, b) + """ + if s == 0: + # Achromatic (gray) + v = int(round(l * 255)) + return (v, v, v) + + def hue_to_rgb(p: float, q: float, t: float) -> float: + if t < 0: + t += 1 + if t > 1: + t -= 1 + if t < 1/6: + return p + (q - p) * 6 * t + if t < 1/2: + return q + if t < 2/3: + return p + (q - p) * (2/3 - t) * 6 + return p + + q = l * (1 + s) if l < 0.5 else l + s - l * s + p = 2 * l - q + h_norm = h / 360.0 + + r = hue_to_rgb(p, q, h_norm + 1/3) + g = hue_to_rgb(p, q, h_norm) + b = hue_to_rgb(p, q, h_norm - 1/3) + + return ( + int(round(r * 255)), + int(round(g * 255)), + int(round(b * 255)) + ) + + +def adjust_lightness(color: Color, target_l: float) -> Color: + """Adjust a color's lightness to a target value (0-1).""" + h, s, _ = color.to_hsl() + return Color.from_hsl(h, s, target_l) + + +def shift_hue(color: Color, degrees: float) -> Color: + """Shift a color's hue by specified degrees.""" + h, s, l = color.to_hsl() + new_h = (h + degrees) % 360 + return Color.from_hsl(new_h, s, l) + + +def _adjust_surface(color: Color, s_max: float, l_target: float) -> Color: + """Derive a surface color from a base color with saturation limit and target lightness.""" + h, s, _ = color.to_hsl() + return Color.from_hsl(h, min(s, s_max), l_target) + + + +def saturate(color: Color, amount: float) -> Color: + """Adjust saturation by amount (-1 to 1).""" + h, s, l = color.to_hsl() + new_s = max(0.0, min(1.0, s + amount)) + return Color.from_hsl(h, new_s, l) + + +# ============================================================================= +# Contrast Utilities (WCAG Luminance/Contrast) +# ============================================================================= + +def relative_luminance(r: int, g: int, b: int) -> float: + """ + Calculate relative luminance per WCAG 2.1. + + The formula converts sRGB to linear RGB, then applies the luminance formula: + L = 0.2126 * R + 0.7152 * G + 0.0722 * B + + Args: + r, g, b: RGB components (0-255) + + Returns: + Relative luminance (0-1) + """ + def linearize(c: int) -> float: + c_norm = c / 255.0 + if c_norm <= 0.03928: + return c_norm / 12.92 + return ((c_norm + 0.055) / 1.055) ** 2.4 + + r_lin = linearize(r) + g_lin = linearize(g) + b_lin = linearize(b) + + return 0.2126 * r_lin + 0.7152 * g_lin + 0.0722 * b_lin + + +def contrast_ratio(color1: Color, color2: Color) -> float: + """ + Calculate WCAG contrast ratio between two colors. + + Returns a value between 1:1 (identical) and 21:1 (black/white). + """ + l1 = relative_luminance(color1.r, color1.g, color1.b) + l2 = relative_luminance(color2.r, color2.g, color2.b) + + lighter = max(l1, l2) + darker = min(l1, l2) + + return (lighter + 0.05) / (darker + 0.05) + + +def is_dark(color: Color) -> bool: + """Determine if a color is perceptually dark.""" + return relative_luminance(color.r, color.g, color.b) < 0.179 + + +def ensure_contrast( + foreground: Color, + background: Color, + min_ratio: float = 4.5, + prefer_light: bool | None = None +) -> Color: + """ + Adjust foreground color to meet minimum contrast ratio against background. + + Args: + foreground: The color to adjust + background: The background color (not modified) + min_ratio: Minimum contrast ratio (default 4.5 for WCAG AA) + prefer_light: If True, prefer lightening; if False, prefer darkening; + if None, auto-detect based on background + + Returns: + Adjusted foreground color meeting contrast requirements + """ + current_ratio = contrast_ratio(foreground, background) + if current_ratio >= min_ratio: + return foreground + + h, s, l = foreground.to_hsl() + bg_dark = is_dark(background) + + # Determine direction to adjust + if prefer_light is None: + prefer_light = bg_dark + + # Binary search for the right lightness + if prefer_light: + low, high = l, 1.0 + else: + low, high = 0.0, l + + best_color = foreground + for _ in range(20): # Max iterations + mid = (low + high) / 2 + test_color = Color.from_hsl(h, s, mid) + ratio = contrast_ratio(test_color, background) + + if ratio >= min_ratio: + best_color = test_color + if prefer_light: + high = mid + else: + low = mid + else: + if prefer_light: + low = mid + else: + high = mid + + return best_color + +# Alias for consistent naming +_ensure_contrast = ensure_contrast + + + +def get_contrasting_color(background: Color, min_ratio: float = 4.5) -> Color: + """Get a contrasting foreground color (black or white variant).""" + if is_dark(background): + # Light foreground for dark background + fg = Color(243, 237, 247) # Off-white + else: + # Dark foreground for light background + fg = Color(14, 14, 67) # Dark blue-black + + return ensure_contrast(fg, background, min_ratio) + + +# ============================================================================= +# Image Reader (PNG/JPEG Parsing) +# ============================================================================= + +class ImageReadError(Exception): + """Raised when image cannot be read or parsed.""" + pass + + +def read_png(path: Path) -> list[RGB]: + """ + Parse a PNG file and extract RGB pixels. + + Supports 8-bit RGB and RGBA color types (most common for wallpapers). + Uses zlib for IDAT decompression and handles PNG filters. + """ + with open(path, 'rb') as f: + data = f.read() + + # Verify PNG signature + if data[:8] != b'\x89PNG\r\n\x1a\n': + raise ImageReadError("Invalid PNG signature") + + pos = 8 + width = 0 + height = 0 + bit_depth = 0 + color_type = 0 + idat_chunks: list[bytes] = [] + + while pos < len(data): + # Read chunk length and type + chunk_len = struct.unpack('>I', data[pos:pos+4])[0] + chunk_type = data[pos+4:pos+8] + chunk_data = data[pos+8:pos+8+chunk_len] + pos += 12 + chunk_len # length + type + data + crc + + if chunk_type == b'IHDR': + width = struct.unpack('>I', chunk_data[0:4])[0] + height = struct.unpack('>I', chunk_data[4:8])[0] + bit_depth = chunk_data[8] + color_type = chunk_data[9] + + if bit_depth != 8: + raise ImageReadError(f"Unsupported bit depth: {bit_depth}") + if color_type not in (2, 6): # RGB or RGBA + raise ImageReadError(f"Unsupported color type: {color_type}") + + elif chunk_type == b'IDAT': + idat_chunks.append(chunk_data) + + elif chunk_type == b'IEND': + break + + if not idat_chunks or width == 0: + raise ImageReadError("Missing image data") + + # Decompress all IDAT chunks + compressed = b''.join(idat_chunks) + raw_data = zlib.decompress(compressed) + + # Calculate bytes per pixel and row + bpp = 3 if color_type == 2 else 4 # RGB or RGBA + stride = width * bpp + 1 # +1 for filter byte + + pixels: list[RGB] = [] + prev_row: list[int] = [0] * (width * bpp) + + for y in range(height): + row_start = y * stride + filter_type = raw_data[row_start] + row_data = list(raw_data[row_start + 1:row_start + stride]) + + # Apply PNG filter reconstruction + unfiltered = _png_unfilter(row_data, prev_row, bpp, filter_type) + prev_row = unfiltered + + # Extract RGB values (skip alpha if present) + for x in range(width): + idx = x * bpp + r, g, b = unfiltered[idx], unfiltered[idx+1], unfiltered[idx+2] + pixels.append((r, g, b)) + + return pixels + + +def _png_unfilter( + row: list[int], + prev_row: list[int], + bpp: int, + filter_type: int +) -> list[int]: + """Apply PNG filter reconstruction.""" + result = [0] * len(row) + + for i in range(len(row)): + x = row[i] + a = result[i - bpp] if i >= bpp else 0 + b = prev_row[i] + c = prev_row[i - bpp] if i >= bpp else 0 + + if filter_type == 0: # None + result[i] = x + elif filter_type == 1: # Sub + result[i] = (x + a) & 0xFF + elif filter_type == 2: # Up + result[i] = (x + b) & 0xFF + elif filter_type == 3: # Average + result[i] = (x + (a + b) // 2) & 0xFF + elif filter_type == 4: # Paeth + result[i] = (x + _paeth_predictor(a, b, c)) & 0xFF + else: + raise ImageReadError(f"Unknown PNG filter type: {filter_type}") + + return result + + +def _paeth_predictor(a: int, b: int, c: int) -> int: + """Paeth predictor for PNG filter reconstruction.""" + p = a + b - c + pa = abs(p - a) + pb = abs(p - b) + pc = abs(p - c) + + if pa <= pb and pa <= pc: + return a + elif pb <= pc: + return b + return c + + +def read_jpeg(path: Path) -> list[RGB]: + """ + Parse a JPEG file and extract RGB pixels. + + Supports baseline (SOF0), extended (SOF1), and progressive (SOF2) JPEG. + This is a simplified decoder that extracts dimensions then samples colors. + """ + with open(path, 'rb') as f: + data = f.read() + + # Verify JPEG signature (SOI marker) + if data[:2] != b'\xff\xd8': + raise ImageReadError("Invalid JPEG signature") + + pos = 2 + width = 0 + height = 0 + + # SOF markers that contain image dimensions + # SOF0=Baseline, SOF1=Extended, SOF2=Progressive, SOF3=Lossless + # SOF5-7=Differential variants, SOF9-11=Arithmetic coding variants + sof_markers = {0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, + 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF} + + # Standalone markers (no length field) + standalone_markers = {0xD0, 0xD1, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, # RST0-7 + 0xD8, # SOI + 0xD9, # EOI + 0x01} # TEM + + while pos < len(data) - 1: + # Find next marker + if data[pos] != 0xFF: + pos += 1 + continue + + # Skip padding 0xFF bytes + while pos < len(data) and data[pos] == 0xFF: + pos += 1 + + if pos >= len(data): + break + + marker = data[pos] + pos += 1 + + # Check for SOF marker (contains dimensions) + if marker in sof_markers: + if pos + 7 <= len(data): + # Skip segment length (2 bytes), precision (1 byte) + height = struct.unpack('>H', data[pos+3:pos+5])[0] + width = struct.unpack('>H', data[pos+5:pos+7])[0] + break + + # End of image + if marker == 0xD9: + break + + # Skip segment data for markers with length field + if marker not in standalone_markers and marker != 0x00: + if pos + 2 <= len(data): + seg_len = struct.unpack('>H', data[pos:pos+2])[0] + pos += seg_len + + if width == 0 or height == 0: + raise ImageReadError("Could not parse JPEG dimensions") + + # Since full JPEG decoding is extremely complex without external libraries, + # we fall back to sampling the raw data for color approximation. + return _sample_jpeg_colors(data, width, height) + + +def _read_image_imagemagick(path: Path) -> list[RGB]: + """ + Read image using ImageMagick's convert command. + + Converts image to PPM format (trivial to parse) and extracts RGB pixels. + This method works accurately for any image format ImageMagick supports. + """ + import subprocess + + # Use magick or convert command + # -depth 8: 8 bits per channel + # -resize: downsample for performance (we don't need full resolution for color extraction) + # ppm: output as PPM format (easy to parse) + + # Downsample to max 200x200 for performance + resize_spec = "200x200>" + + try: + # Try 'magick convert' first (ImageMagick 7+), fallback to 'convert' (ImageMagick 6) + try: + result = subprocess.run( + ['magick', 'convert', str(path), '-resize', resize_spec, '-depth', '8', 'ppm:-'], + capture_output=True, + check=True + ) + except FileNotFoundError: + result = subprocess.run( + ['convert', str(path), '-resize', resize_spec, '-depth', '8', 'ppm:-'], + capture_output=True, + check=True + ) + except subprocess.CalledProcessError as e: + raise ImageReadError(f"ImageMagick failed: {e.stderr.decode()}") + except FileNotFoundError: + raise ImageReadError("ImageMagick not found. Please install imagemagick.") + + ppm_data = result.stdout + return _parse_ppm(ppm_data) + + +def _parse_ppm(data: bytes) -> list[RGB]: + """ + Parse PPM (Portable Pixmap) binary format. + + PPM P6 format: + P6 + width height + maxval + + """ + pos = 0 + tokens: list[str] = [] + + # Read header tokens (need 4: P6, width, height, maxval) + while len(tokens) < 4 and pos < len(data): + # Skip whitespace + while pos < len(data) and data[pos:pos+1] in (b' ', b'\t', b'\n', b'\r'): + pos += 1 + + # Skip comments + if pos < len(data) and data[pos:pos+1] == b'#': + while pos < len(data) and data[pos:pos+1] != b'\n': + pos += 1 + continue + + # Read token + token_start = pos + while pos < len(data) and data[pos:pos+1] not in (b' ', b'\t', b'\n', b'\r', b'#'): + pos += 1 + + if pos > token_start: + tokens.append(data[token_start:pos].decode('ascii')) + + if len(tokens) < 4 or tokens[0] != 'P6': + raise ImageReadError(f"Invalid PPM format: {tokens}") + + width = int(tokens[1]) + height = int(tokens[2]) + maxval = int(tokens[3]) + + # Skip exactly one whitespace character after maxval (per PPM spec) + if pos < len(data) and data[pos:pos+1] in (b' ', b'\t', b'\n', b'\r'): + pos += 1 + + pixel_data = data[pos:] + + # Parse RGB triplets + pixels: list[RGB] = [] + scale = 255.0 / maxval if maxval != 255 else 1.0 + + for i in range(0, min(len(pixel_data), width * height * 3), 3): + if i + 2 < len(pixel_data): + r = int(pixel_data[i] * scale) + g = int(pixel_data[i + 1] * scale) + b = int(pixel_data[i + 2] * scale) + pixels.append((r, g, b)) + + if not pixels: + raise ImageReadError("No pixels extracted from PPM data") + + return pixels + + +def read_image(path: Path) -> list[RGB]: + """ + Read an image file and return its pixels as RGB tuples. + + Uses ImageMagick for accurate color extraction from any format. + Falls back to native PNG parsing if ImageMagick is unavailable. + """ + suffix = path.suffix.lower() + + # Try ImageMagick first (works for any format) + try: + return _read_image_imagemagick(path) + except ImageReadError: + # Fall back to native parsing for PNG + if suffix == '.png': + return read_png(path) + raise + + +# ============================================================================= +# Palette Extraction (K-means Clustering) +# ============================================================================= + +def downsample_pixels(pixels: list[RGB], factor: int = 4) -> list[RGB]: + """ + Downsample pixels for faster processing. + + Takes every Nth pixel to reduce dataset size while maintaining + color distribution characteristics. + """ + if factor <= 1: + return pixels + + # Calculate step based on factor squared (for 2D image) + step = factor * factor + return pixels[::step] + + +def color_distance_hsl(c1: HSL, c2: HSL) -> float: + """ + Calculate perceptual distance between two colors in HSL space. + + Hue is weighted less for low-saturation colors (grays). + """ + h1, s1, l1 = c1 + h2, s2, l2 = c2 + + # Hue distance (circular) + dh = min(abs(h1 - h2), 360 - abs(h1 - h2)) / 180.0 + + # Weight hue by average saturation (grays have similar hues but shouldn't match) + avg_sat = (s1 + s2) / 2 + dh_weighted = dh * avg_sat + + ds = abs(s1 - s2) + dl = abs(l1 - l2) + + return (dh_weighted ** 2 + ds ** 2 + dl ** 2) ** 0.5 + + +def kmeans_cluster( + colors: list[RGB], + k: int = 5, + iterations: int = 15 +) -> list[tuple[RGB, int]]: + """ + Perform K-means clustering on colors. + + Returns list of (centroid_rgb, cluster_size) tuples, sorted by cluster size. + Uses deterministic initialization for reproducible results. + """ + if len(colors) < k: + # Not enough colors, return what we have + unique = list(set(colors)) + return [(c, colors.count(c)) for c in unique[:k]] + + # Convert to HSL for perceptual clustering + colors_hsl = [rgb_to_hsl(*c) for c in colors] + + # Deterministic initialization: pick evenly spaced colors from sorted list + sorted_indices = sorted(range(len(colors_hsl)), key=lambda i: colors_hsl[i]) + step = len(sorted_indices) // k + centroids = [colors_hsl[sorted_indices[i * step]] for i in range(k)] + + # K-means iterations + for _ in range(iterations): + # Assign colors to nearest centroid + clusters: list[list[HSL]] = [[] for _ in range(k)] + + for color in colors_hsl: + min_dist = float('inf') + min_idx = 0 + for i, centroid in enumerate(centroids): + dist = color_distance_hsl(color, centroid) + if dist < min_dist: + min_dist = dist + min_idx = i + clusters[min_idx].append(color) + + # Update centroids + new_centroids = [] + for i, cluster in enumerate(clusters): + if cluster: + avg_h = sum(c[0] for c in cluster) / len(cluster) + avg_s = sum(c[1] for c in cluster) / len(cluster) + avg_l = sum(c[2] for c in cluster) / len(cluster) + new_centroids.append((avg_h, avg_s, avg_l)) + else: + new_centroids.append(centroids[i]) + + centroids = new_centroids + + # Final assignment and counting + cluster_counts = [0] * k + for color in colors_hsl: + min_dist = float('inf') + min_idx = 0 + for i, centroid in enumerate(centroids): + dist = color_distance_hsl(color, centroid) + if dist < min_dist: + min_dist = dist + min_idx = i + cluster_counts[min_idx] += 1 + + # Convert centroids back to RGB and pair with counts + results = [] + for i, centroid in enumerate(centroids): + rgb = hsl_to_rgb(*centroid) + results.append((rgb, cluster_counts[i])) + + # Sort by cluster size (most common first) + results.sort(key=lambda x: -x[1]) + + return results + + +def extract_palette(pixels: list[RGB], k: int = 5) -> list[Color]: + """ + Extract K dominant colors from pixel data. + + Args: + pixels: List of RGB tuples + k: Number of colors to extract + + Returns: + List of Color objects, sorted by dominance + """ + # Downsample for performance + sampled = downsample_pixels(pixels, factor=4) + + # Filter out very dark, very bright, and desaturated pixels + # This ensures we get vibrant, usable theme colors + filtered = [] + for p in sampled: + h, s, l = rgb_to_hsl(*p) + # Keep colors that are: + # - Not too dark (L > 0.15) + # - Not too bright (L < 0.85) + # - Reasonably saturated (S > 0.15) + if 0.15 < l < 0.85 and s > 0.15: + filtered.append(p) + + # Fall back to all pixels if filtering removed too many + if len(filtered) < k * 10: + # Try a less strict filter + filtered = [] + for p in sampled: + lum = relative_luminance(*p) + if 0.05 < lum < 0.95: + filtered.append(p) + + if len(filtered) < k * 10: + filtered = sampled + + # Cluster + clusters = kmeans_cluster(filtered, k=k) + + # Post-filter: remove very dark colors from results + result_colors = [] + for rgb, count in clusters: + color = Color.from_rgb(rgb) + h, s, l = color.to_hsl() + # Skip very dark colors (they'll be used for surfaces anyway) + if l > 0.20 or len(result_colors) == 0: + result_colors.append(color) + + # Ensure we have enough colors by deriving from primary + while len(result_colors) < k: + primary = result_colors[0] + offset = len(result_colors) * 30 + result_colors.append(shift_hue(primary, offset)) + + return result_colors[:k] + + +def find_error_color(palette: list[Color]) -> Color: + """ + Find or generate an error color (red-biased). + + Looks for existing red in palette, otherwise returns a default. + """ + # Look for a red-ish color in the palette + for color in palette: + h, s, l = color.to_hsl() + # Red hues: 0-30 or 330-360 + if (h <= 30 or h >= 330) and s > 0.4 and 0.3 < l < 0.7: + return color + + # Default error red + return Color.from_hex("#FD4663") + + +def derive_harmonious_colors(primary: Color) -> tuple[Color, Color, Color]: + """ + Derive secondary and tertiary colors as harmonious complements to primary. + + Uses color theory: + - Secondary: Analogous (30° hue shift) - similar but distinct + - Tertiary: Split-complementary (150° hue shift) - contrasting but harmonious + - Quaternary: Complementary (180° hue shift) - for accents + + Returns: + Tuple of (secondary, tertiary, quaternary) colors + """ + h, s, l = primary.to_hsl() + + # Secondary: analogous - similar warmth/coolness, shifted hue + secondary = Color.from_hsl((h + 30) % 360, s, l) + + # Tertiary: split-complementary - provides contrast while staying harmonious + tertiary = Color.from_hsl((h + 150) % 360, s, l) + + # Quaternary: complementary - opposite on color wheel + quaternary = Color.from_hsl((h + 180) % 360, s, l) + + return secondary, tertiary, quaternary + + +# ============================================================================= +# Theme Generation (Material/Normal) +# ============================================================================= + + +def generate_material_dark(palette: list[Color]) -> dict[str, str]: + """ + Generate Material Design dark theme from palette. + + Dark theme characteristics: + - Dark surfaces (low luminance backgrounds) + - Bright, slightly desaturated accent colors + - High contrast text + - Secondary/tertiary derived as harmonious colors from primary + """ + primary = palette[0] if palette else Color(255, 245, 155) + # Derive harmonious colors from primary + secondary, tertiary, quaternary = derive_harmonious_colors(primary) + error = find_error_color(palette) + + # Adjust colors for dark theme + # Primary should be bright enough to stand out on dark surface + h, s, _ = primary.to_hsl() + primary_adjusted = Color.from_hsl(h, min(s, 0.7), 0.75) + + h, s, _ = secondary.to_hsl() + secondary_adjusted = Color.from_hsl(h, min(s, 0.6), 0.70) + + h, s, _ = tertiary.to_hsl() + tertiary_adjusted = Color.from_hsl(h, min(s, 0.6), 0.75) + + # Surface colors (very dark, slightly tinted with primary hue) + surface_h, _, _ = primary.to_hsl() + surface = Color.from_hsl(surface_h, 0.6, 0.05) + surface_variant = Color.from_hsl(surface_h, 0.5, 0.10) + + base_primary = primary # Use primary for hue base + mSurface = _adjust_surface(base_primary, 0.6, 0.05) + mSurfaceVariant = _adjust_surface(base_primary, 0.45, 0.14) # Slightly lighter/diff saturation + + # Foreground colors - Ensure they are readable but not too saturated (avoid yellow text) + # Use primary hue but low saturation (0.05) for text + text_h, _, _ = base_primary.to_hsl() + base_on_surface = Color.from_hsl(text_h, 0.05, 0.95) + mOnSurface = _ensure_contrast(base_on_surface, mSurface, 4.5) + + base_on_surface_variant = Color.from_hsl(text_h, 0.05, 0.80) + mOnSurfaceVariant = _ensure_contrast(base_on_surface_variant, mSurfaceVariant, 4.5) + + mOutline = _adjust_surface(base_primary, 0.30, 0.40) + + # Dark foreground for bright backgrounds + dark_fg = Color.from_hsl(base_primary.to_hsl()[0], 0.7, 0.10) + + # Ensure contrast + on_primary = _ensure_contrast(dark_fg, primary_adjusted, 4.5) + on_secondary = _ensure_contrast(dark_fg, secondary_adjusted, 4.5) + on_tertiary = _ensure_contrast(dark_fg, tertiary_adjusted, 4.5) + on_error = _ensure_contrast(dark_fg, error, 4.5) + + # Outline and shadow + shadow = mSurface + + return { + "mPrimary": primary_adjusted.to_hex(), + "mOnPrimary": on_primary.to_hex(), + "mSecondary": secondary_adjusted.to_hex(), + "mOnSecondary": on_secondary.to_hex(), + "mTertiary": tertiary_adjusted.to_hex(), + "mOnTertiary": on_tertiary.to_hex(), + "mError": error.to_hex(), + "mOnError": on_error.to_hex(), + "mSurface": mSurface.to_hex(), + "mOnSurface": mOnSurface.to_hex(), + "mSurfaceVariant": mSurfaceVariant.to_hex(), + "mOnSurfaceVariant": mOnSurfaceVariant.to_hex(), + "mOutline": mOutline.to_hex(), + "mShadow": shadow.to_hex(), + } + + +def generate_material_light(palette: list[Color]) -> dict[str, str]: + """ + Generate Material Design light theme from palette. + + Light theme characteristics: + - Light surfaces (high luminance backgrounds) + - Darker, more saturated accent colors + - Dark text for readability + """ + primary = palette[0] if palette else Color(93, 101, 245) + secondary = palette[1] if len(palette) > 1 else shift_hue(primary, 30) + tertiary = palette[2] if len(palette) > 2 else shift_hue(primary, 180) + error = find_error_color(palette) + + # Adjust colors for light theme + # Primary should be darker to stand out on light surface + h, s, _ = primary.to_hsl() + primary_adjusted = Color.from_hsl(h, min(s + 0.1, 0.8), 0.50) + + h, s, _ = secondary.to_hsl() + secondary_adjusted = Color.from_hsl(h, min(s, 0.5), 0.55) + + h, s, _ = tertiary.to_hsl() + tertiary_adjusted = Color.from_hsl(h, 0.8, 0.15) + + # Surface colors (very light, slightly tinted with primary hue) + base_primary = primary + mSurface = _adjust_surface(base_primary, 0.4, 0.94) + mSurfaceVariant = _adjust_surface(base_primary, 0.5, 0.97) + + # Foreground colors - Ensure they are readable but not too saturated (avoid yellow text) + # Use primary hue but low saturation (0.05) for text + text_h, _, _ = base_primary.to_hsl() + base_on_surface = Color.from_hsl(text_h, 0.05, 0.10) + mOnSurface = _ensure_contrast(base_on_surface, mSurface, 4.5) + + base_on_surface_variant = Color.from_hsl(text_h, 0.05, 0.45) + mOnSurfaceVariant = _ensure_contrast(base_on_surface_variant, mSurfaceVariant, 4.5) + + # Light foreground for dark backgrounds + light_fg = Color.from_hsl(base_primary.to_hsl()[0], 0.2, 0.90) + + # Ensure contrast + on_primary = _ensure_contrast(light_fg, primary_adjusted, 4.5) + on_secondary = _ensure_contrast(light_fg, secondary_adjusted, 4.5) + on_tertiary = _ensure_contrast(Color(254, 242, 154), tertiary_adjusted, 4.5) + on_error = _ensure_contrast(Color(14, 14, 67), error, 4.5) + + # Outline and shadow + mOutline = _adjust_surface(base_primary, 0.5, 0.60) + shadow = Color(243, 237, 247) + + return { + "mPrimary": primary_adjusted.to_hex(), + "mOnPrimary": on_primary.to_hex(), + "mSecondary": secondary_adjusted.to_hex(), + "mOnSecondary": on_secondary.to_hex(), + "mTertiary": tertiary_adjusted.to_hex(), + "mOnTertiary": on_tertiary.to_hex(), + "mError": error.to_hex(), + "mOnError": on_error.to_hex(), + "mSurface": mSurface.to_hex(), + "mOnSurface": mOnSurface.to_hex(), + "mSurfaceVariant": mSurfaceVariant.to_hex(), + "mOnSurfaceVariant": mOnSurfaceVariant.to_hex(), + "mOutline": mOutline.to_hex(), + "mShadow": shadow.to_hex(), + } + + +def generate_normal_dark(palette: list[Color]) -> dict[str, str]: + """ + Generate wallust-style dark theme from palette. + + More vibrant than Material - uses palette colors directly and keeps + surfaces saturated with the primary hue. Outputs same keys as Material. + """ + # Use extracted colors directly (wallust style) + primary = palette[0] if palette else Color(255, 245, 155) + secondary = palette[1] if len(palette) > 1 else shift_hue(primary, 30) + tertiary = palette[2] if len(palette) > 2 else shift_hue(primary, 60) + quaternary = palette[3] if len(palette) > 3 else shift_hue(primary, 180) + error = find_error_color(palette) + + # Keep colors vibrant - preserve saturation + h, s, l = primary.to_hsl() + primary_adjusted = Color.from_hsl(h, max(s, 0.7), max(l, 0.65)) + + h, s, l = secondary.to_hsl() + secondary_adjusted = Color.from_hsl(h, max(s, 0.6), max(l, 0.60)) + + h, s, l = tertiary.to_hsl() + tertiary_adjusted = Color.from_hsl(h, max(s, 0.5), max(l, 0.60)) + + # Surface: COLORFUL dark - a deep, saturated version of primary + # Heuristic: Shift Cyan (160-200) slightly towards Blue (+10) to avoid "Teal" look + surface_hue, s, _ = palette[0].to_hsl() + if 160 <= surface_hue <= 200: + surface_hue = (surface_hue + 10) % 360 + + base_surface = Color.from_hsl(surface_hue, s, 0.5) # l doesn't matter for next step + + # Preserving saturation (up to 0.9) to be true to primary color + mSurface = _adjust_surface(base_surface, 0.90, 0.12) + mSurfaceVariant = _adjust_surface(base_surface, 0.80, 0.16) + + # Text colors - desaturated + text_h, _, _ = palette[0].to_hsl() + base_on_surface = Color.from_hsl(text_h, 0.05, 0.95) + mOnSurface = _ensure_contrast(base_on_surface, mSurface, 4.5) + + base_on_surface_variant = Color.from_hsl(text_h, 0.05, 0.80) + mOnSurfaceVariant = _ensure_contrast(base_on_surface_variant, mSurfaceVariant, 4.5) + + mOutline = _adjust_surface(palette[0], 0.10, 0.30) + + # Contrasting foregrounds for accent colors + dark_fg = Color.from_hsl(palette[0].to_hsl()[0], 0.3, 0.08) + on_primary = _ensure_contrast(dark_fg, primary_adjusted, 4.5) + on_secondary = _ensure_contrast(dark_fg, secondary_adjusted, 4.5) + on_tertiary = _ensure_contrast(dark_fg, tertiary_adjusted, 4.5) + on_error = _ensure_contrast(dark_fg, error, 4.5) + + # Outline uses primary hue, more saturated + shadow = mSurface + + return { + "mPrimary": primary_adjusted.to_hex(), + "mOnPrimary": on_primary.to_hex(), + "mSecondary": secondary_adjusted.to_hex(), + "mOnSecondary": on_secondary.to_hex(), + "mTertiary": tertiary_adjusted.to_hex(), + "mOnTertiary": on_tertiary.to_hex(), + "mError": error.to_hex(), + "mOnError": on_error.to_hex(), + "mSurface": mSurface.to_hex(), + "mOnSurface": mOnSurface.to_hex(), + "mSurfaceVariant": mSurfaceVariant.to_hex(), + "mOnSurfaceVariant": mOnSurfaceVariant.to_hex(), + "mOutline": mOutline.to_hex(), + "mShadow": shadow.to_hex(), + } + + +def generate_normal_light(palette: list[Color]) -> dict[str, str]: + """ + Generate wallust-style light theme from palette. + + More vibrant than Material - uses palette colors directly and keeps + surfaces saturated with the primary hue. Outputs same keys as Material. + """ + # Use extracted colors directly + primary = palette[0] if palette else Color(93, 101, 245) + secondary = palette[1] if len(palette) > 1 else shift_hue(primary, 30) + tertiary = palette[2] if len(palette) > 2 else shift_hue(primary, 60) + quaternary = palette[3] if len(palette) > 3 else shift_hue(primary, 180) + error = find_error_color(palette) + + # Keep colors vibrant - darken for visibility on light bg + h, s, l = primary.to_hsl() + primary_adjusted = Color.from_hsl(h, max(s, 0.7), min(l, 0.45)) + + h, s, l = secondary.to_hsl() + secondary_adjusted = Color.from_hsl(h, max(s, 0.6), min(l, 0.40)) + + h, s, l = tertiary.to_hsl() + tertiary_adjusted = Color.from_hsl(h, max(s, 0.5), min(l, 0.35)) + + # Surface: COLORFUL light - a pastel, saturated version of primary + # Preserving saturation (up to 0.9) to be true to primary color + mSurface = _adjust_surface(palette[0], 0.90, 0.90) + mSurfaceVariant = _adjust_surface(palette[0], 0.80, 0.85) + + # Foreground colors - tinted with primary hue + text_h, _, _ = palette[0].to_hsl() + base_on_surface = Color.from_hsl(text_h, 0.05, 0.10) + mOnSurface = _ensure_contrast(base_on_surface, mSurface, 4.5) + + base_on_surface_variant = Color.from_hsl(text_h, 0.05, 0.35) + mOnSurfaceVariant = _ensure_contrast(base_on_surface_variant, mSurfaceVariant, 4.5) + + # Contrasting foregrounds + light_fg = Color.from_hsl(text_h, 0.1, 0.95) + on_primary = ensure_contrast(light_fg, primary_adjusted, 4.5) + on_secondary = ensure_contrast(light_fg, secondary_adjusted, 4.5) + on_tertiary = ensure_contrast(light_fg, tertiary_adjusted, 4.5) + on_error = ensure_contrast(light_fg, error, 4.5) + + # Outline uses primary hue, more saturated + surface_h, surface_s, _ = palette[0].to_hsl() + mOutline = Color.from_hsl(surface_h, max(surface_s * 0.4, 0.25), 0.65) + shadow = Color.from_hsl(surface_h, max(surface_s * 0.3, 0.15), 0.80) + + return { + "mPrimary": primary_adjusted.to_hex(), + "mOnPrimary": on_primary.to_hex(), + "mSecondary": secondary_adjusted.to_hex(), + "mOnSecondary": on_secondary.to_hex(), + "mTertiary": tertiary_adjusted.to_hex(), + "mOnTertiary": on_tertiary.to_hex(), + "mError": error.to_hex(), + "mOnError": on_error.to_hex(), + "mSurface": mSurface.to_hex(), + "mOnSurface": mOnSurface.to_hex(), + "mSurfaceVariant": mSurfaceVariant.to_hex(), + "mOnSurfaceVariant": mOnSurfaceVariant.to_hex(), + "mOutline": mOutline.to_hex(), + "mShadow": shadow.to_hex(), + } + + +def generate_theme( + palette: list[Color], + mode: ThemeMode, + material: bool = True +) -> dict[str, str]: + """Generate theme for specified mode.""" + if material: + if mode == "dark": + return generate_material_dark(palette) + return generate_material_light(palette) + else: + if mode == "dark": + return generate_normal_dark(palette) + return generate_normal_light(palette) + + +# ============================================================================= +# Template Rendering (Matugen Compatibility) +# ============================================================================= + +class TemplateRenderer: + """ + Renders templates using the generated theme colors. + Compatible with Matugen-style {{colors.name.mode.format}} tags. + """ + + # Map from Matugen color names to theme keys + COLOR_MAP = { + "primary": "mPrimary", + "on_primary": "mOnPrimary", + "primary_container": "mPrimary", # Mapped to Accent (Bright) + "on_primary_container": "mOnPrimary", # Mapped to Text on Accent (Dark) + "secondary": "mSecondary", + "on_secondary": "mOnSecondary", + "secondary_container": "mSecondary", # Mapped to Accent + "on_secondary_container": "mOnSecondary",# Mapped to Text on Accent + "tertiary": "mTertiary", + "on_tertiary": "mOnTertiary", + "tertiary_container": "mTertiary", # Mapped to Accent + "on_tertiary_container": "mOnTertiary", # Mapped to Text on Accent + "error": "mError", + "on_error": "mOnError", + "error_container": "mError", # Fallback + "on_error_container": "mOnError", # Fallback + "surface": "mSurface", + "on_surface": "mOnSurface", + "surface_variant": "mSurfaceVariant", + "on_surface_variant": "mOnSurfaceVariant", + "outline": "mOutline", + "outline_variant": "mOutline", + "shadow": "mShadow", + "scrim": "mShadow", + "inverse_surface": "mOnSurface", # Fallback + "inverse_on_surface": "mSurface", # Fallback + "inverse_primary": "mOnPrimary", # Fallback + "background": "mSurface", + "on_background": "mOnSurface", + + # Surface Containers (Material 3) + "surface_container_lowest": "mSurface", # Fallback + "surface_container_low": "mSurface", # Fallback + "surface_container": "mSurfaceVariant", # Fallback + "surface_container_high": "mSurfaceVariant", # Fallback + "surface_container_highest": "mSurfaceVariant", # Fallback + "surface_dim": "mSurface", # Fallback + "surface_bright": "mSurfaceVariant", # Fallback + + # Fixed colors (Material 3) + "primary_fixed": "mPrimary", # Fallback + "primary_fixed_dim": "mPrimary", # Fallback + "on_primary_fixed": "mOnPrimary", # Fallback + "on_primary_fixed_variant": "mOnPrimary",# Fallback + "secondary_fixed": "mSecondary", # Fallback + "secondary_fixed_dim": "mSecondary", # Fallback + "on_secondary_fixed": "mOnSecondary", # Fallback + "on_secondary_fixed_variant": "mOnSecondary", # Fallback + "tertiary_fixed": "mTertiary", # Fallback + "tertiary_fixed_dim": "mTertiary", # Fallback + "on_tertiary_fixed": "mOnTertiary", # Fallback + "on_tertiary_fixed_variant": "mOnTertiary", # Fallback + + # Custom/Noctalia keys + "hover": "mHover", + "on_hover": "mOnHover", + } + + def __init__(self, theme_data: dict[str, dict[str, str]]): + self.theme_data = theme_data + + def _get_color_value(self, color_name: str, mode: str, format_type: str) -> str: + """Get processed color value for a template tag.""" + # Map color name to theme key + key = self.COLOR_MAP.get(color_name) + if not key: + return f"{{{{UNKNOWN_COLOR_{color_name}}}}}" + + # Get relevant mode data + # Handle 'default' mode (active mode if only one generated, or first available) + if mode == "default": + mode_data = self.theme_data.get("dark") or self.theme_data.get("light") + else: + mode_data = self.theme_data.get(mode) + + if not mode_data: + return f"{{{{UNKNOWN_MODE_{mode}}}}}" + + hex_color = mode_data.get(key) + if not hex_color: + return f"{{{{UNKNOWN_KEY_{key}}}}}" + + # Apply format + if format_type == "hex": + return hex_color + elif format_type == "hex_stripped": + return hex_color.lstrip('#') + elif format_type == "rgb": + c = Color.from_hex(hex_color) + return f"{c.r}, {c.g}, {c.b}" + elif format_type == "rgba": + c = Color.from_hex(hex_color) + return f"{c.r}, {c.g}, {c.b}, 1.0" + elif format_type in ("hue", "saturation", "lightness"): + c = Color.from_hex(hex_color) + h, s, l = c.to_hsl() + if format_type == "hue": return str(int(h)) + if format_type == "saturation": return str(int(s * 100)) + if format_type == "lightness": return str(int(l * 100)) + + return hex_color + + def render(self, template_text: str) -> str: + """Replace all tags in template text.""" + # Generic pattern for {{colors.name.mode.format}} + pattern = r"\{\{\s*colors\.([a-z_0-9]+)\.([a-z_0-9]+)\.([a-z_0-9]+)\s*\}\}" + + def replace(match): + color_name, mode, format_type = match.groups() + return self._get_color_value(color_name, mode, format_type) + + return re.sub(pattern, replace, template_text) + + def render_file(self, input_path: Path, output_path: Path): + """Render a template file to an output path.""" + try: + template_text = input_path.read_text() + rendered_text = self.render(template_text) + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(rendered_text) + except Exception as e: + print(f"Error rendering template {input_path}: {e}", file=sys.stderr) + + def process_config_file(self, config_path: Path): + """Process Matugen TOML configuration file.""" + if not tomllib: + print("Error: tomllib module not available (requires Python 3.11+)", file=sys.stderr) + return + + try: + with open(config_path, "rb") as f: + data = tomllib.load(f) + + # Matugen config structure: https://github.com/InioX/matugen + # [config] section (ignored) + # [templates.name] sections + + templates = data.get("templates", {}) + for name, template in templates.items(): + input_path = template.get("input_path") + output_path = template.get("output_path") + + if not input_path or not output_path: + continue + + self.render_file(Path(input_path).expanduser(), Path(output_path).expanduser()) + + # Matugen supports post_hook, we probably can't easily support that blindly + # without shell=True which is risky, but let's see if we need it. + # TemplateProcessor.qml puts post_hook in the TOML. + # We should execute it if possible to fully replicate behavior. + post_hook = template.get("post_hook") + if post_hook: + import subprocess + try: + subprocess.run(post_hook, shell=True, check=False) + except Exception as e: + print(f"Error running post_hook for {name}: {e}", file=sys.stderr) + + except Exception as e: + print(f"Error processing config file {config_path}: {e}", file=sys.stderr) + +def parse_args() -> argparse.Namespace: + """Parse command-line arguments.""" + parser = argparse.ArgumentParser( + prog='themer', + description='Extract color palettes from wallpapers and generate themes', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + python3 colors.py wallpaper.png --material --both + python3 colors.py wallpaper.jpg --dark -o theme.json + python3 colors.py ~/Pictures/bg.png --normal --light + """ + ) + + parser.add_argument( + 'image', + type=Path, + help='Path to wallpaper image (PNG/JPG)' + ) + + # Theme style (mutually exclusive) + style_group = parser.add_mutually_exclusive_group() + style_group.add_argument( + '--material', + action='store_true', + default=True, + help='Generate Material-style colors (default)' + ) + style_group.add_argument( + '--normal', + action='store_true', + help='Generate simpler accent-based palette' + ) + + # Theme mode (mutually exclusive) + mode_group = parser.add_mutually_exclusive_group() + mode_group.add_argument( + '--dark', + action='store_true', + help='Generate dark theme only' + ) + mode_group.add_argument( + '--light', + action='store_true', + help='Generate light theme only' + ) + mode_group.add_argument( + '--both', + action='store_true', + default=True, + help='Generate both dark and light themes (default)' + ) + + parser.add_argument( + '--output', '-o', + type=Path, + help='Write JSON output to file (stdout if omitted)' + ) + + parser.add_argument( + '--render', '-r', + action='append', + help='Render a template (input_path:output_path)' + ) + + # Matugen compatibility arguments + parser.add_argument( + '--config', '-c', + type=Path, + help='Path to Matugen TOML configuration file' + ) + parser.add_argument( + '--mode', + choices=['dark', 'light'], + help='Override theme mode (for Matugen compatibility)' + ) + parser.add_argument( + '--type', '-t', + help='Scheme type (ignored, for Matugen compatibility)' + ) + + return parser.parse_args() + + +def main() -> int: + """Main entry point.""" + args = parse_args() + + # Validate image path + if not args.image.exists(): + print(f"Error: Image not found: {args.image}", file=sys.stderr) + return 1 + + if not args.image.is_file(): + print(f"Error: Not a file: {args.image}", file=sys.stderr) + return 1 + + # Read image + try: + pixels = read_image(args.image) + except ImageReadError as e: + print(f"Error reading image: {e}", file=sys.stderr) + return 1 + except Exception as e: + print(f"Unexpected error reading image: {e}", file=sys.stderr) + return 1 + + # Extract palette + # Use more colors for Material mode + k = 5 # Extract 5 dominant colors for both modes + palette = extract_palette(pixels, k=k) + + if not palette: + print("Error: Could not extract colors from image", file=sys.stderr) + return 1 + + # Determine which themes to generate + use_material = not args.normal + + # Handle --mode compatibility + arg_dark = args.dark + arg_light = args.light + arg_both = args.both + + if args.mode == 'dark': + arg_dark = True + arg_light = False + arg_both = False + elif args.mode == 'light': + arg_dark = False + arg_light = True + arg_both = False + + result: dict[str, dict[str, str]] = {} + + if arg_dark: + result["dark"] = generate_theme(palette, "dark", use_material) + elif arg_light: + result["light"] = generate_theme(palette, "light", use_material) + else: + # Generate both (default) + result["dark"] = generate_theme(palette, "dark", use_material) + result["light"] = generate_theme(palette, "light", use_material) + + # Output JSON + json_output = json.dumps(result, indent=2) + + if args.output: + try: + args.output.write_text(json_output) + print(f"Theme written to: {args.output}", file=sys.stderr) + except IOError as e: + print(f"Error writing output: {e}", file=sys.stderr) + return 1 + elif not args.render and not args.config: + print(json_output) + + # Process templates + if args.render or args.config: + renderer = TemplateRenderer(result) + + if args.render: + for render_spec in args.render: + if ':' not in render_spec: + print(f"Error: Invalid render spec (must be input:output): {render_spec}", file=sys.stderr) + continue + + input_str, output_str = render_spec.split(':', 1) + input_path = Path(input_str).expanduser() + output_path = Path(output_str).expanduser() + + if not input_path.exists(): + print(f"Error: Template not found: {input_path}", file=sys.stderr) + continue + + renderer.render_file(input_path, output_path) + + if args.config: + if not args.config.exists(): + print(f"Error: Config file not found: {args.config}", file=sys.stderr) + else: + renderer.process_config_file(args.config) + + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/Commons/Settings.qml b/Commons/Settings.qml index f583a3d8d..231b3caae 100644 --- a/Commons/Settings.qml +++ b/Commons/Settings.qml @@ -603,6 +603,8 @@ Singleton { property string manualSunrise: "06:30" property string manualSunset: "18:30" property string matugenSchemeType: "scheme-fruit-salad" + property string generationBackend: "matugen" + property string internalThemerMode: "material" } // templates toggles diff --git a/Modules/Panels/Settings/Tabs/ColorScheme/ColorsSubTab.qml b/Modules/Panels/Settings/Tabs/ColorScheme/ColorsSubTab.qml index 9bcda83a4..e25a5fbac 100644 --- a/Modules/Panels/Settings/Tabs/ColorScheme/ColorsSubTab.qml +++ b/Modules/Panels/Settings/Tabs/ColorScheme/ColorsSubTab.qml @@ -209,11 +209,35 @@ ColumnLayout { } } + NToggle { + label: "Use Internal Generator" + description: "Use experimental Python generator instead of Matugen" + enabled: Settings.data.colorSchemes.useWallpaperColors && ProgramCheckerService.pythonAvailable + visible: Settings.data.colorSchemes.useWallpaperColors && ProgramCheckerService.pythonAvailable + checked: Settings.data.colorSchemes.generationBackend === "internal" + onToggled: checked => { + Settings.data.colorSchemes.generationBackend = checked ? "internal" : "matugen"; + AppThemeService.generate(); + } + } + + NToggle { + label: "Use Material Design" + description: "Generate Material Design colors (on) or simpler accents (off)" + enabled: Settings.data.colorSchemes.useWallpaperColors && Settings.data.colorSchemes.generationBackend === "internal" + visible: Settings.data.colorSchemes.useWallpaperColors && Settings.data.colorSchemes.generationBackend === "internal" + checked: Settings.data.colorSchemes.internalThemerMode === "material" + onToggled: checked => { + Settings.data.colorSchemes.internalThemerMode = checked ? "material" : "normal"; + AppThemeService.generate(); + } + } + NComboBox { label: I18n.tr("panels.color-scheme.color-source-matugen-scheme-type-label") description: I18n.tr("panels.color-scheme.color-source-matugen-scheme-type-description") - enabled: Settings.data.colorSchemes.useWallpaperColors - visible: Settings.data.colorSchemes.useWallpaperColors + enabled: Settings.data.colorSchemes.useWallpaperColors && Settings.data.colorSchemes.generationBackend !== "internal" + visible: Settings.data.colorSchemes.useWallpaperColors && Settings.data.colorSchemes.generationBackend !== "internal" model: [ { diff --git a/Services/System/ProgramCheckerService.qml b/Services/System/ProgramCheckerService.qml index f37032a7d..eaa0e9bcc 100644 --- a/Services/System/ProgramCheckerService.qml +++ b/Services/System/ProgramCheckerService.qml @@ -17,6 +17,7 @@ Singleton { property bool wlsunsetAvailable: false property bool app2unitAvailable: false property bool gnomeCalendarAvailable: false + property bool pythonAvailable: false property bool wtypeAvailable: false // Programs to check - maps property names to commands @@ -26,7 +27,9 @@ Singleton { "wlsunsetAvailable": ["sh", "-c", "command -v wlsunset"], "app2unitAvailable": ["sh", "-c", "command -v app2unit"], "gnomeCalendarAvailable": ["sh", "-c", "command -v gnome-calendar"], - "wtypeAvailable": ["sh", "-c", "command -v wtype"] + "gnomeCalendarAvailable": ["sh", "-c", "command -v gnome-calendar"], + "wtypeAvailable": ["sh", "-c", "command -v wtype"], + "pythonAvailable": ["sh", "-c", "command -v python3"] }) // Discord client auto-detection diff --git a/Services/Theming/TemplateProcessor.qml b/Services/Theming/TemplateProcessor.qml index 9cdc0e07f..dce6c6e09 100644 --- a/Services/Theming/TemplateProcessor.qml +++ b/Services/Theming/TemplateProcessor.qml @@ -58,9 +58,19 @@ Singleton { if (!content) return; const wp = wallpaperPath.replace(/'/g, "'\\''"); - const script = buildMatugenScript(content, wp, mode); + + // Determine backend + let backend = Settings.data.colorSchemes.generationBackend; + if (!backend) backend = "matugen"; // Default + + // Fallback if matugen not available but python is + if (backend === "matugen" && !ProgramCheckerService.matugenAvailable && ProgramCheckerService.pythonAvailable) { + backend = "internal"; + } - generateProcess.generator = "matugen"; + const script = buildMatugenScript(content, wp, mode, backend); + + generateProcess.generator = backend; generateProcess.command = ["sh", "-lc", script]; generateProcess.running = true; } @@ -259,7 +269,7 @@ Singleton { return false; } - function buildMatugenScript(content, wallpaper, mode) { + function buildMatugenScript(content, wallpaper, mode, backend) { const delimiter = "MATUGEN_CONFIG_EOF_" + Math.random().toString(36).substr(2, 9); const pathEsc = dynamicConfigPath.replace(/'/g, "'\\''"); const wpDelimiter = "WALLPAPER_PATH_EOF_" + Math.random().toString(36).substr(2, 9); @@ -267,13 +277,24 @@ Singleton { // Use heredoc for wallpaper path to avoid all escaping issues let script = `cat > '${pathEsc}' << '${delimiter}'\n${content}\n${delimiter}\n`; script += `NOCTALIA_WP_PATH=$(cat << '${wpDelimiter}'\n${wallpaper}\n${wpDelimiter}\n)\n`; - script += 'matugen '; - if (ProgramCheckerService.matugenVersion >= "3.1.0") { - // Matugen 3.1.0+ supports --continue-on-error to process all templates even if some fail - script += '--continue-on-error '; + + if (backend === "internal") { + // Use colors.py (Python implementation) + const scriptPath = Quickshell.shellDir + "/Bin/colors.py"; + const styleFlag = (Settings.data.colorSchemes.internalThemerMode === "normal") ? "--normal" : "--material"; + script += `python3 "${scriptPath}" "$NOCTALIA_WP_PATH" ${styleFlag} --config '${pathEsc}' --mode ${mode} --type ${Settings.data.colorSchemes.matugenSchemeType} `; + // Note: colors.py handles template rendering via --config, effectively mimicking matugen + } else { + // Use Matugen (Rust implementation) + script += 'matugen '; + if (ProgramCheckerService.matugenVersion >= "3.1.0") { + // Matugen 3.1.0+ supports --continue-on-error to process all templates even if some fail + script += '--continue-on-error '; + } + script += `image "$NOCTALIA_WP_PATH" --config '${pathEsc}' --mode ${mode} --type ${Settings.data.colorSchemes.matugenSchemeType}`; } - script += `image "$NOCTALIA_WP_PATH" --config '${pathEsc}' --mode ${mode} --type ${Settings.data.colorSchemes.matugenSchemeType}`; - script += buildUserTemplateCommand("$NOCTALIA_WP_PATH", mode); + + script += buildUserTemplateCommand("$NOCTALIA_WP_PATH", mode, backend); return script + "\n"; } @@ -550,7 +571,7 @@ Singleton { // ================================================================================ // USER TEMPLATES, advanced usage // ================================================================================ - function buildUserTemplateCommand(input, mode) { + function buildUserTemplateCommand(input, mode, backend) { if (!Settings.data.templates.enableUserTemplates) return ""; @@ -560,7 +581,14 @@ Singleton { // If input is a shell variable (starts with $), use double quotes to allow expansion // Otherwise, use single quotes for safety with file paths const inputQuoted = input.startsWith("$") ? `"${input}"` : `'${input.replace(/'/g, "'\\''")}'`; - script += ` matugen image ${inputQuoted} --config '${userConfigPath}' --mode ${mode} --type ${Settings.data.colorSchemes.matugenSchemeType}\n`; + + if (backend === "internal") { + const scriptPath = Quickshell.shellDir + "/Bin/colors.py"; + const styleFlag = (Settings.data.colorSchemes.internalThemerMode === "normal") ? "--normal" : "--material"; + script += ` python3 "${scriptPath}" ${inputQuoted} ${styleFlag} --config '${userConfigPath}' --mode ${mode} --type ${Settings.data.colorSchemes.matugenSchemeType}\n`; + } else { + script += ` matugen image ${inputQuoted} --config '${userConfigPath}' --mode ${mode} --type ${Settings.data.colorSchemes.matugenSchemeType}\n`; + } script += "fi"; return script;