add noctalia and fuzzel

This commit is contained in:
2026-05-23 21:20:58 +02:00
parent 364801f1a3
commit 9a3eceb3ab
599 changed files with 204318 additions and 0 deletions
@@ -0,0 +1,242 @@
#!/usr/bin/env python3
import gi
gi.require_version('EDataServer', '1.2')
gi.require_version('ECal', '2.0')
gi.require_version('ICalGLib', "3.0")
import json, sys
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
from gi.repository import ECal, EDataServer, ICalGLib
start_time = int(sys.argv[1])
end_time = int(sys.argv[2])
print(f"Starting with time range: {start_time} to {end_time}", file=sys.stderr)
all_events = []
def safe_get_time(ical_time):
if not ical_time:
return None, False
try:
year, month, day = ical_time.get_year(), ical_time.get_month(), ical_time.get_day()
is_all_day = hasattr(ical_time, "is_date") and ical_time.is_date()
if is_all_day:
# All-day events (birthdays, holidays) should not need
# to be timezone converted
return int(datetime(year, month, day).timestamp()), True
hour, minute, second = ical_time.get_hour(), ical_time.get_minute(), ical_time.get_second()
# Determine timezone for proper conversion
tz_obj = ical_time.get_timezone() if hasattr(ical_time, 'get_timezone') else None
tzid = tz_obj.get_tzid() if tz_obj else None
tz = None
if ical_time.is_utc() if hasattr(ical_time, 'is_utc') else False:
tz = timezone.utc # Explicit UTC time
elif tzid:
# Evolution uses non-standard format: /freeassociation.sourceforge.net/America/Los_Angeles
# Strip prefix to get IANA name: America/Los_Angeles
iana = tzid.replace('/freeassociation.sourceforge.net/', '') if tzid.startswith('/') else tzid
try: tz = ZoneInfo(iana)
except: pass
# Create timezone-aware datetime
dt = datetime(year, month, day, hour, minute, second, tzinfo=tz)
return int(dt.timestamp()), False
except:
return None, False
def add_event(summary, calendar_name, start_ts, end_ts, location="", description="", all_day=False, calendar_uid="", uid=""):
all_events.append({
'calendar': calendar_name,
'summary': summary,
'start': start_ts,
'end': end_ts,
'location': location,
'description': description,
'calendar_uid': calendar_uid,
'uid': uid
})
registry = EDataServer.SourceRegistry.new_sync(None)
sources = registry.list_sources(EDataServer.SOURCE_EXTENSION_CALENDAR)
for source in sources:
if not source.get_enabled():
continue
calendar_name = source.get_display_name()
print(f"\nProcessing calendar: {calendar_name}", file=sys.stderr)
try:
client = ECal.Client.connect_sync(source, ECal.ClientSourceType.EVENTS, 5, None)
start_dt = datetime.fromtimestamp(start_time)
end_dt = datetime.fromtimestamp(end_time)
start_str = start_dt.strftime("%Y%m%dT%H%M%S")
end_str = end_dt.strftime("%Y%m%dT%H%M%S")
query = f'(occur-in-time-range? (make-time "{start_str}") (make-time "{end_str}"))'
success, raw_events = client.get_object_list_sync(query, None)
if not success or not raw_events:
continue
for raw_obj in raw_events:
obj = raw_obj[1] if isinstance(raw_obj, tuple) else raw_obj
comp = None
if isinstance(obj, ICalGLib.Component):
comp = obj
elif isinstance(obj, ECal.Component):
try:
ical_str = obj.to_string()
temp_comp = ICalGLib.Component.new_from_string(ical_str)
if temp_comp.getName() == "VEVENT":
comp = temp_comp
except Exception:
comp = None
if not comp:
summary = getattr(obj, "get_summary", lambda: "(No title)")()
dtstart = getattr(obj, "get_dtstart", lambda: None)()
dtend = getattr(obj, "get_dtend", lambda: None)()
location = getattr(obj, "get_location", lambda: "")() or ""
description = getattr(obj, "get_description", lambda: "")() or ""
start_ts, all_day = safe_get_time(dtstart)
end_ts, _ = safe_get_time(dtend)
if start_ts:
if end_ts is None:
end_ts = start_ts + 3600
event_uid = getattr(obj, "get_uid", lambda: "")() or ""
add_event(summary, calendar_name, start_ts, end_ts, location, description,
calendar_uid=source.get_uid(), uid=event_uid)
continue
summary = getattr(comp, "get_summary", lambda: "(No title)")()
location = getattr(comp, "get_location", lambda: "")() or ""
description = getattr(comp, "get_description", lambda: "")() or ""
dtstart = getattr(comp, "get_dtstart", lambda: None)()
dtend = getattr(comp, "get_dtend", lambda: None)()
start_ts, all_day = safe_get_time(dtstart)
end_ts, _ = safe_get_time(dtend)
if end_ts is None and start_ts is not None:
end_ts = start_ts + 3600
rrule_getter = getattr(comp, "get_first_property", None)
if rrule_getter:
rrule_prop = comp.get_first_property(73) # ICAL_RRULE_PROPERTY
if rrule_prop:
rrule_value = rrule_prop.get_value() # ICalGLib.Value
try:
recurrence = rrule_value.get_recur() # -> ICalGLib.Recurrence
except AttributeError:
rrule_str = str(rrule_value)
recurrence = ICalGLib.Recurrence.new_from_string(rrule_str)
if recurrence:
freq = recurrence.get_freq()
rdates = getattr(comp, "get_rdate_list", lambda: [])()
exdates = getattr(comp, "get_exdate_list", lambda: [])()
# --- normal event ---
if not rrule_prop and not rdates:
add_event(summary, calendar_name, start_ts, end_ts, location, description,
calendar_uid=source.get_uid(), uid=comp.get_uid() or "")
continue
# --- recurrent events ---
if freq:
summary = comp.get_summary() or "(No title)"
dtstart = comp.get_dtstart()
dtend = comp.get_dtend()
start_ts, all_day = safe_get_time(dtstart)
end_ts, _ = safe_get_time(dtend)
if end_ts is None and start_ts is not None:
end_ts = start_ts + 3600 # 1h default
interval = recurrence.get_interval() or 1
count = recurrence.get_count()
until_dt = recurrence.get_until()
until_ts, _ = safe_get_time(until_dt) if until_dt else (None, False)
if until_ts is None:
until_ts = end_time
occurrences = []
current_ts = start_ts
added = 0
match freq:
case 0: #SECONDLY
delta = timedelta(seconds=interval)
while (current_ts <= until_ts) and (not count or added < count):
occurrences.append((current_ts, current_ts + (end_ts - start_ts)))
current_ts += int(delta.total_seconds())
added += 1
case 1: #MINUTELY
delta = timedelta(minutes=interval)
while (current_ts <= until_ts) and (not count or added < count):
occurrences.append((current_ts, current_ts + (end_ts - start_ts)))
current_ts += int(delta.total_seconds())
added += 1
case 2: #HOURLY
delta = timedelta(hours=interval)
while (current_ts <= until_ts) and (not count or added < count):
occurrences.append((current_ts, current_ts + (end_ts - start_ts)))
current_ts += int(delta.total_seconds())
added += 1
case 3: # DAILY
delta = timedelta(days=interval)
while (current_ts <= until_ts) and (not count or added < count):
occurrences.append((current_ts, current_ts + (end_ts - start_ts)))
current_ts += int(delta.total_seconds())
added += 1
case 4: # WEEKLY
delta = timedelta(weeks=interval)
while (current_ts <= until_ts) and (not count or added < count):
occurrences.append((current_ts, current_ts + (end_ts - start_ts)))
current_ts += int(delta.total_seconds())
added += 1
case 5: # MONTHLY
from dateutil.relativedelta import relativedelta
dt = datetime.fromtimestamp(current_ts)
while (current_ts <= until_ts) and (not count or added < count):
occurrences.append((current_ts, current_ts + (end_ts - start_ts)))
dt += relativedelta(months=interval)
current_ts = int(dt.timestamp())
added += 1
case 6: # YEARLY
from dateutil.relativedelta import relativedelta
dt = datetime.fromtimestamp(current_ts)
while (current_ts <= until_ts) and (not count or added < count):
occurrences.append((current_ts, current_ts + (end_ts - start_ts)))
dt += relativedelta(years=interval)
current_ts = int(dt.timestamp())
added += 1
case _: # NONE
occurrences.append((start_ts, end_ts))
# --- add occurences to all_events ---
for occ_start, occ_end in occurrences:
add_event(summary, calendar_name, occ_start, occ_end, location, description,
calendar_uid=source.get_uid(), uid=comp.get_uid() or "")
except Exception as e:
print(f" Error for {calendar_name}: {e}", file=sys.stderr)
all_events.sort(key=lambda x: x['start'])
print(json.dumps(all_events, indent=4))
@@ -0,0 +1,11 @@
#!/usr/bin/env python3
import gi
gi.require_version('EDataServer', '1.2')
gi.require_version('ECal', '2.0')
try:
from gi.repository import ECal, EDataServer
print("available")
except ImportError as e:
print(f"unavailable: {e}")
@@ -0,0 +1,71 @@
#!/usr/bin/env python3
import json
import os
import re
import subprocess
import sys
from datetime import datetime
from pathlib import Path
def get_khal_date_format():
"""Read the khal config and extract the longdatetimeformat."""
xdg_config = os.environ.get('XDG_CONFIG_HOME', os.path.expanduser('~/.config'))
config_path = Path(xdg_config) / 'khal' / 'config'
if not config_path.exists():
return '%c'
with open(config_path, 'r') as f:
for line in f:
if m := re.match(r'^longdatetimeformat\s?=\s?(.+?)\s*$', line):
date_format = m.group(1).strip()
return date_format
return '%c'
def to_khal(date_str, khal_format):
dt = datetime.strptime(date_str, "%Y-%m-%d")
return dt.strftime(khal_format)
def from_khal(date_str, khal_format):
if not date_str:
return ''
dt = datetime.strptime(date_str, khal_format)
return dt.isoformat()
def convert_event(event, khal_format):
event['start-long-full'] = from_khal(event.get('start-long-full', ''), khal_format)
event['end-long-full'] = from_khal(event.get('end-long-full', ''), khal_format)
return event
def main():
start_date = sys.argv[1]
duration = sys.argv[2]
khal_format = get_khal_date_format()
khal_start = to_khal(start_date, khal_format)
cmd = [
'khal', 'list',
'--json', 'uid',
'--json', 'title',
'--json', 'start-long-full',
'--json', 'end-long-full',
'--json', 'calendar',
'--json', 'description',
'--json', 'location',
'--json', 'repeat-pattern',
khal_start,
duration
]
result = subprocess.run(cmd, capture_output=True, text=True)
output = result.stdout.strip()
for line in output.split('\n'):
day_events = json.loads(line)
print(json.dumps([convert_event(e, khal_format) for e in day_events]))
if __name__ == '__main__':
main()
@@ -0,0 +1,21 @@
#!/usr/bin/env python3
import gi
gi.require_version('EDataServer', '1.2')
import json
from gi.repository import EDataServer
registry = EDataServer.SourceRegistry.new_sync(None)
sources = registry.list_sources(EDataServer.SOURCE_EXTENSION_CALENDAR)
calendars = []
for source in sources:
if source.get_enabled():
calendars.append({
'uid': source.get_uid(),
'name': source.get_display_name(),
'enabled': True
})
print(json.dumps(calendars))
@@ -0,0 +1,191 @@
#!/usr/bin/env python3
import errno
import os
import pty
import select
import subprocess
import sys
import time
# flake8: noqa: E501 # Line too long
version = "0.0.2-1"
def log(msg) -> None:
sys.stdout.write(f"[pair] {msg}\n")
sys.stdout.flush() # Flush to ensure the message is passed
def pair_fast():
if len(sys.argv) < 5:
log("Usage: bluetooth-pair.py <addr> <pairWaitSeconds> <attempts> <intervalSec>")
sys.exit(2)
addr = sys.argv[1]
# We won't use pair_wait_seconds in the same way, but we'll respect the timeout logic.
pair_wait_seconds = float(sys.argv[2])
if pair_wait_seconds < 30:
log(f"Warning: pairWaitSeconds ({pair_wait_seconds}s) is too short. Enforcing 45s minimum.")
pair_wait_seconds = 45.0
attempts = int(sys.argv[3])
interval_sec = float(sys.argv[4])
if not addr or len(addr) < 17:
# Basic MAC address length check
log(f"Invalid Bluetooth address: '{addr}'")
sys.exit(2)
# m/s PTY for interactive control
mfd, sfd = pty.openpty()
# Start bluetoothctl
subprocess.Popen(['bluetoothctl'], stdin=sfd, stdout=sfd, stderr=sfd, close_fds=True, text=True)
os.close(sfd)
def send_command(cmd):
log(f"Sending cmd: {cmd}")
os.write(mfd, (cmd + "\n").encode('utf-8'))
def read_output(timeout=1.0):
# Reads available output from mfd
output = b""
end_time = time.time() + timeout
while time.time() < end_time:
r, _, _ = select.select([mfd], [], [], 0.1)
if mfd in r:
try:
data = os.read(mfd, 1024)
if not data:
break
output += data
except OSError as e:
if e.errno == errno.EIO:
break
raise
else:
pass
return output.decode('utf-8', errors='replace')
log("Initializing bluetoothctl...")
time.sleep(1) # Wait for startup
# initial_out = read_output(timeout=1)
# print(initial_out) # Debug
send_command("agent on")
send_command("default-agent")
# send_command("power on") # If we are pairing bluetooth is already powered on
time.sleep(0.5)
# Pair directly since the device is already discovered in the UI/Panel (Removed previous scan/wait part)
log(f"Attempting to pair with {addr}...")
send_command(f"pair {addr}")
# Loop to watch for confirmation or success
start_time = time.time()
paired = False
log("Waiting for pairing sequence start...")
while time.time() - start_time < pair_wait_seconds:
out = read_output(timeout=0.5)
if out:
print(out, end='')
# Device not found yet
device_not_discovered: list[str] = [f"Device {addr} not available"]
if any(e in out for e in device_not_discovered):
log(f"Device {addr} is discovered yet...")
pair_wait_seconds += 30 # Add additional time for device discovery
# Confirm Passkey
# Numberic Comparison (NC) 1 of 4 - Tested pairing with my iPhone.
expected_confirmation: list[str] = ["Confirm passkey", "yes/no", "Request confirmation"]
if any(e in out for e in expected_confirmation):
log("Detected passkey prompt. Sending 'yes'.")
send_command("yes")
# Authorization Request
expected_auth: list[str] = ["Authorize service", "Request authorization"]
if any(e in out for e in expected_auth):
log("Detected authorization request. Sending 'yes'.")
send_command("yes")
# Interactive PIN/Passkey Entry (Device displays code, User must enter on PC)
expected_pin: list[str] = ["Enter passkey", "Enter PIN code", "Passkey: "]
if any(e in out for e in expected_pin):
log("Device requested PIN/Passkey. Waiting for user input...")
log("PIN_REQUIRED") # Signal to service, to prompt user.
try:
# Read PIN from stdin (blocking)
user_pin = sys.stdin.readline().strip()
if user_pin:
log(f"Received PIN: {user_pin}, relaying to bluetoothctl...")
send_command(user_pin)
except Exception as e:
log(f"Error reading stdin: {e}")
break
# Just Works (JW) is implicit (no prompt)
expected_success: list[str] = ["Pairing successful", "Paired: yes", "Bonded: yes"]
if any(e in out for e in expected_success):
paired = True
log("Pairing successful detected in stream.")
break
if "Failed to pair" in out:
log("Pairing failed explicitly.")
break
expected_already_paired: list[str] = ["Already joined", "Already exists"]
if any(e in out for e in expected_already_paired):
paired = True
log("Device already paired.")
break
# Double check pairing status via info command if not sure
if not paired:
send_command(f"info {addr}")
time.sleep(1)
out = read_output(timeout=1)
if "Paired: yes" in out:
paired = True
if paired:
log("Device is paired. Trusting...")
send_command(f"trust {addr}")
time.sleep(1)
log("Connecting...")
connected = False
for i in range(attempts):
send_command(f"connect {addr}")
# Wait a bit for connection
time.sleep(interval_sec)
# Check status
send_command(f"info {addr}")
time.sleep(1)
out = read_output(timeout=1)
if "Connected: yes" in out:
log("Connected successfully, we are done here.")
connected = True
break
else:
log(f"Connection attempt {i + 1}/{attempts} failed. Retrying...")
if connected:
send_command("quit")
sys.exit(0)
else:
log("Failed to connect after all attempts.")
send_command("quit")
sys.exit(1)
else:
log("Failed to pair within timeout.")
send_command("quit")
sys.exit(1)
if __name__ == "__main__":
pair_fast()
@@ -0,0 +1,188 @@
#!/usr/bin/env python3
import asyncio
import os
import sys
import shutil
from pathlib import Path
async def run_command(*args):
process = await asyncio.create_subprocess_exec(
*args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
print(f"Error running {' '.join(args)}: {stderr.decode().strip()}", file=sys.stderr)
return stdout.decode().strip()
def theme_exists(theme_name: str) -> bool:
"""Check if a GTK theme exists in common locations."""
search_paths = [
Path.home() / ".themes",
Path.home() / ".local/share/themes",
Path("/usr/share/themes"),
Path("/usr/local/share/themes"),
]
# Add paths from XDG_DATA_DIRS
xdg_data_dirs = os.environ.get("XDG_DATA_DIRS", "")
if xdg_data_dirs:
for path in xdg_data_dirs.split(":"):
if path:
search_paths.append(Path(path) / "themes")
for base_path in search_paths:
if (base_path / theme_name).is_dir():
return True
return False
GTK_IMPORT = '@import url("noctalia.css");'
def ensure_gtk_css_import(gtk_css: Path, colors_file: Path, label: str) -> bool:
"""
Append the noctalia.css import to gtk.css if not already present.
If gtk.css doesn't exist, create it with the import.
Does not overwrite user modifications (similar to niri template).
"""
if not colors_file.exists():
print(f"Error: {label} noctalia.css not found at {colors_file}", file=sys.stderr)
return False
if gtk_css.exists() or gtk_css.is_symlink():
content = gtk_css.read_text()
# Already has the import (flexible: allow optional whitespace / different quoting)
if "noctalia.css" in content and "@import" in content:
return True
# Need to modify — handle symlinks carefully
target = gtk_css
if gtk_css.is_symlink():
resolved = gtk_css.resolve()
if os.access(resolved, os.W_OK):
# Writable symlink (e.g. dotfiles): edit the target directly
target = resolved
else:
# Read-only symlink (e.g. NixOS): convert to local file
gtk_css.unlink()
gtk_css.write_text(resolved.read_text())
# Append import to the end
new_content = content.rstrip()
if new_content and not new_content.endswith("\n"):
new_content += "\n"
new_content += "\n" + GTK_IMPORT + "\n"
target.write_text(new_content)
print(f"Appended {label} noctalia.css import to gtk.css")
else:
gtk_css.write_text(GTK_IMPORT + "\n")
print(f"Created {label} gtk.css with noctalia.css import")
return True
async def apply_gtk3_colors(config_dir: Path):
gtk3_dir = config_dir / "gtk-3.0"
colors_file = gtk3_dir / "noctalia.css"
gtk_css = gtk3_dir / "gtk.css"
return ensure_gtk_css_import(gtk_css, colors_file, "GTK3")
async def apply_gtk4_colors(config_dir: Path):
gtk4_dir = config_dir / "gtk-4.0"
colors_file = gtk4_dir / "noctalia.css"
gtk_css = gtk4_dir / "gtk.css"
return ensure_gtk_css_import(gtk_css, colors_file, "GTK4")
async def sync_system_appearance(mode: str, *, update_gtk_theme: bool = True) -> None:
"""
Push light/dark to org.gnome.desktop.interface (gsettings or dconf fallback).
Used by the GTK template post-hook and ColorSchemeService when "Sync system theme"
is on (both set color-scheme and gtk-theme when themes exist). --appearance-only
skips CSS and only updates color-scheme for narrow tooling use.
"""
has_gsettings = shutil.which("gsettings")
has_dconf = shutil.which("dconf")
if not has_gsettings and not has_dconf:
print("No gsettings or dconf found, skip system appearance sync")
return
target_theme = "adw-gtk3" if mode == "light" else "adw-gtk3-dark"
theme_available = update_gtk_theme and theme_exists(target_theme)
if update_gtk_theme and not theme_available:
print(f"Theme '{target_theme}' not found, skipping GTK theme set")
if has_gsettings:
schemas = await run_command("gsettings", "list-schemas")
if schemas and "org.gnome.desktop.interface" in schemas:
await run_command("gsettings", "set", "org.gnome.desktop.interface", "color-scheme", f"prefer-{mode}")
if theme_available:
await run_command("gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", f"{target_theme}")
return
if has_dconf:
await run_command("dconf", "write", "/org/gnome/desktop/interface/color-scheme", f"'prefer-{mode}'")
if theme_available:
await run_command("dconf", "write", "/org/gnome/desktop/interface/gtk-theme", f"'{target_theme}'")
async def get_config_dir() -> Path:
# Returns the XDG config home (e.g. ~/.config)
# GTK config lives at ~/.config/gtk-3.0/ and ~/.config/gtk-4.0/.
# 1. XDG standard
if value := os.environ.get("XDG_CONFIG_HOME"):
return Path(value).expanduser()
# 2. fallback
return Path.home() / ".config"
def parse_args():
argv = sys.argv[1:]
appearance_only = False
if argv and argv[0] == "--appearance-only":
appearance_only = True
argv = argv[1:]
if len(argv) != 1 or argv[0] not in ("dark", "light"):
print(
"Usage: gtk-refresh.py [--appearance-only] (dark|light)",
file=sys.stderr,
)
sys.exit(1)
return appearance_only, argv[0]
async def main():
appearance_only, mode = parse_args()
if appearance_only:
await sync_system_appearance(mode, update_gtk_theme=False)
return
config_dir = await get_config_dir()
if not config_dir.is_dir():
print(f"Error: Config directory not found: {config_dir}", file=sys.stderr)
sys.exit(1)
(config_dir / "gtk-3.0").mkdir(parents=True, exist_ok=True)
(config_dir / "gtk-4.0").mkdir(parents=True, exist_ok=True)
results = await asyncio.gather(apply_gtk3_colors(config_dir), apply_gtk4_colors(config_dir))
if all(results):
await sync_system_appearance(mode, update_gtk_theme=True)
print("GTK colors applied successfully")
else:
# Still push light/dark preference so portal/GTK apps follow the shell even when
# gtk.css / noctalia.css setup failed.
await sync_system_appearance(mode, update_gtk_theme=False)
sys.exit(1)
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,58 @@
"""
Theming library - Color extraction and theme generation.
This package provides:
- HCT color space implementation (CAM16, Hct, TonalPalette)
- Material Design 3 scheme generation
- Color utilities (RGB, HSL conversions)
- Image reading and palette extraction
- Template rendering (Matugen compatible)
"""
from .color import Color, rgb_to_hsl, hsl_to_rgb, adjust_surface
from .hct import Hct, Cam16, TonalPalette, TemperatureCache, fix_if_disliked
from .material import MaterialScheme, SchemeContent, harmonize_color
from .contrast import ensure_contrast, contrast_ratio, is_dark
from .image import read_image, ImageReadError
from .palette import extract_palette
from .quantizer import extract_source_color, source_color_to_rgb
from .theme import generate_theme
from .renderer import TemplateRenderer
from .scheme import expand_predefined_scheme, inject_terminal_colors
__all__ = [
# Color
"Color",
"rgb_to_hsl",
"hsl_to_rgb",
"adjust_surface",
# HCT
"Hct",
"Cam16",
"TonalPalette",
"TemperatureCache",
"fix_if_disliked",
# Material
"MaterialScheme",
"SchemeContent",
"harmonize_color",
# Contrast
"ensure_contrast",
"contrast_ratio",
"is_dark",
# Image
"read_image",
"ImageReadError",
# Palette
"extract_palette",
# Quantizer (Wu + Score algorithm matching matugen)
"extract_source_color",
"source_color_to_rgb",
# Theme
"generate_theme",
# Renderer
"TemplateRenderer",
# Scheme
"expand_predefined_scheme",
"inject_terminal_colors",
]
@@ -0,0 +1,353 @@
"""
Color representation and conversion utilities.
This module provides the Color class and functions for converting between
RGB, HSL, and Lab color spaces.
"""
import math
from dataclasses import dataclass
from typing import TYPE_CHECKING
# Type aliases
RGB = tuple[int, int, int]
HSL = tuple[float, float, float]
LAB = tuple[float, float, float]
if TYPE_CHECKING:
from .hct import Hct
@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)
def to_hct(self) -> 'Hct':
"""Convert to HCT color space."""
from .hct import Hct
return Hct.from_rgb(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)
@classmethod
def from_hct(cls, hct: 'Hct') -> 'Color':
"""Create Color from HCT."""
r, g, b = hct.to_rgb()
return cls(r, g, b)
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 hue_distance(h1: float, h2: float) -> float:
"""Calculate minimum angular distance between two hues (0-180)."""
diff = abs(h1 - h2)
return min(diff, 360 - diff)
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)
# =============================================================================
# Lab Color Space (CIE L*a*b*)
# =============================================================================
# D65 white point
_WHITE_X = 95.047
_WHITE_Y = 100.0
_WHITE_Z = 108.883
def _linearize(channel: int) -> float:
"""Convert sRGB channel (0-255) to linear RGB (0-1)."""
normalized = channel / 255.0
if normalized <= 0.04045:
return normalized / 12.92
return math.pow((normalized + 0.055) / 1.055, 2.4)
def _delinearize(linear: float) -> int:
"""Convert linear RGB (0-1) to sRGB channel (0-255)."""
if linear <= 0.0031308:
normalized = linear * 12.92
else:
normalized = 1.055 * math.pow(linear, 1.0 / 2.4) - 0.055
return max(0, min(255, round(normalized * 255)))
def _lab_f(t: float) -> float:
"""Lab forward transform function."""
if t > 0.008856:
return math.pow(t, 1.0 / 3.0)
return (903.3 * t + 16.0) / 116.0
def _lab_f_inv(t: float) -> float:
"""Lab inverse transform function."""
if t > 0.206893:
return t * t * t
return (116.0 * t - 16.0) / 903.3
def rgb_to_lab(r: int, g: int, b: int) -> LAB:
"""
Convert sRGB (0-255) to CIE L*a*b*.
Returns:
Tuple of (L*, a*, b*) where L* is 0-100
"""
# sRGB to linear RGB
linear_r = _linearize(r)
linear_g = _linearize(g)
linear_b = _linearize(b)
# Linear RGB to XYZ (D65)
x = 0.4124564 * linear_r + 0.3575761 * linear_g + 0.1804375 * linear_b
y = 0.2126729 * linear_r + 0.7151522 * linear_g + 0.0721750 * linear_b
z = 0.0193339 * linear_r + 0.1191920 * linear_g + 0.9503041 * linear_b
# Scale to 0-100 range
x *= 100.0
y *= 100.0
z *= 100.0
# XYZ to Lab
fx = _lab_f(x / _WHITE_X)
fy = _lab_f(y / _WHITE_Y)
fz = _lab_f(z / _WHITE_Z)
L = 116.0 * fy - 16.0
a = 500.0 * (fx - fy)
b = 200.0 * (fy - fz)
return (L, a, b)
def lab_to_rgb(L: float, a: float, b: float) -> RGB:
"""
Convert CIE L*a*b* to sRGB (0-255).
Args:
L: Lightness (0-100)
a: Green-red component
b: Blue-yellow component
Returns:
Tuple of (r, g, b)
"""
# Lab to XYZ
fy = (L + 16.0) / 116.0
fx = a / 500.0 + fy
fz = fy - b / 200.0
x = _WHITE_X * _lab_f_inv(fx)
y = _WHITE_Y * _lab_f_inv(fy)
z = _WHITE_Z * _lab_f_inv(fz)
# Scale back to 0-1 range
x /= 100.0
y /= 100.0
z /= 100.0
# XYZ to linear RGB
linear_r = 3.2404542 * x - 1.5371385 * y - 0.4985314 * z
linear_g = -0.9692660 * x + 1.8760108 * y + 0.0415560 * z
linear_b = 0.0556434 * x - 0.2040259 * y + 1.0572252 * z
# Clamp and delinearize
return (
_delinearize(max(0.0, min(1.0, linear_r))),
_delinearize(max(0.0, min(1.0, linear_g))),
_delinearize(max(0.0, min(1.0, linear_b)))
)
def lab_distance(lab1: LAB, lab2: LAB) -> float:
"""
Calculate Euclidean distance between two Lab colors.
This is a simple perceptual distance metric.
"""
dL = lab1[0] - lab2[0]
da = lab1[1] - lab2[1]
db = lab1[2] - lab2[2]
return math.sqrt(dL * dL + da * da + db * db)
def find_closest_color(
compare_to: str,
colors: list[dict[str, str]]
) -> str:
"""
Find the closest named color from a list (matugen-compatible).
Uses Lab color space Euclidean distance for perceptual color matching.
Args:
compare_to: Hex color to compare (e.g., "#ff5500")
colors: List of {"name": "...", "color": "#..."} dicts
Returns:
Name of the closest color, or empty string if no colors provided
"""
if not colors:
return ""
# Parse target color
target = Color.from_hex(compare_to)
target_lab = rgb_to_lab(target.r, target.g, target.b)
closest_name = ""
closest_dist = float('inf')
for entry in colors:
try:
entry_color = Color.from_hex(entry["color"])
entry_lab = rgb_to_lab(entry_color.r, entry_color.g, entry_color.b)
dist = lab_distance(target_lab, entry_lab)
if dist < closest_dist:
closest_dist = dist
closest_name = entry["name"]
except (KeyError, ValueError):
# Skip invalid entries
continue
return closest_name
@@ -0,0 +1,123 @@
"""
Contrast calculation utilities (WCAG luminance and contrast).
This module provides functions for calculating relative luminance,
contrast ratios, and ensuring accessible color combinations.
"""
from .color import Color
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
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)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,366 @@
"""
Image reading utilities for PNG and JPEG files.
This module provides functions for extracting RGB pixels from image files
without external dependencies (except ImageMagick for fallback).
"""
import struct
import zlib
from pathlib import Path
# Type alias
RGB = tuple[int, int, int]
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 _sample_jpeg_colors(data: bytes, width: int, height: int) -> list[RGB]:
"""
Sample colors from JPEG data without full decoding.
This is a rough approximation that samples byte triplets from the
compressed data. Not accurate, but provides some color information.
For accurate results, use ImageMagick via read_image().
"""
# Sample every Nth byte triplet from the image data
# Skip headers and look for image data after SOS marker
pixels: list[RGB] = []
step = max(1, len(data) // (width * height // 16))
for i in range(0, len(data) - 2, step):
r, g, b = data[i], data[i+1], data[i+2]
# Filter out obviously non-image data (markers, etc.)
if not (r == 0xFF and g in (0xD8, 0xD9, 0xE0, 0xE1)):
pixels.append((r, g, b))
return pixels if pixels else [(128, 128, 128)]
def _read_image_imagemagick(path: Path, resize_filter: str = "Triangle") -> 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)
# Resize to 112x112 to match matugen's color extraction
# Use -filter Triangle (bilinear) for M3 schemes to match matugen's FilterType::Triangle default
# Use -filter Box for k-means schemes (sharper, preserves distinct color regions)
# Use -depth 8 -colorspace sRGB -strip to reduce variance between HDRI/non-HDRI builds
resize_spec = "112x112!"
try:
# Try 'magick' first (ImageMagick 7+), fallback to 'convert' (ImageMagick 6)
try:
result = subprocess.run(
['magick', str(path), '-filter', resize_filter, '-resize', resize_spec,
'-depth', '8', '-colorspace', 'sRGB', '-strip', 'ppm:-'],
capture_output=True,
check=True
)
except FileNotFoundError:
result = subprocess.run(
['convert', str(path), '-filter', resize_filter, '-resize', resize_spec,
'-depth', '8', '-colorspace', 'sRGB', '-strip', '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
<binary RGB data>
"""
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, resize_filter: str = "Triangle") -> 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.
Args:
path: Path to the image file.
resize_filter: ImageMagick resize filter. "Triangle" for M3 schemes
(matches matugen), "Box" for k-means schemes.
"""
suffix = path.suffix.lower()
# Try ImageMagick first (works for any format)
try:
return _read_image_imagemagick(path, resize_filter)
except ImageReadError:
# Fall back to native parsing for PNG
if suffix == '.png':
return read_png(path)
raise
@@ -0,0 +1,540 @@
"""
Material Design 3 color scheme implementation.
This module provides scheme classes for generating MD3 color schemes
from a source color using the HCT color space.
Supported schemes (matching Matugen):
- SchemeTonalSpot: Default Android 12-13 scheme, mid-vibrancy
- SchemeFruitSalad: Bold/playful with -50° hue rotation
- SchemeRainbow: Chromatic accents with grayscale neutrals
- SchemeContent: Preserves source color's chroma
"""
from .hct import Hct, TonalPalette, TemperatureCache, fix_if_disliked
# =============================================================================
# Tone Values (shared across all schemes)
# =============================================================================
# Tone values for Material Design 3 (dark theme)
DARK_TONES = {
'primary': 80,
'on_primary': 20,
'primary_container': 30,
'on_primary_container': 90,
'secondary': 80,
'on_secondary': 20,
'secondary_container': 30,
'on_secondary_container': 90,
'tertiary': 80,
'on_tertiary': 20,
'tertiary_container': 30,
'on_tertiary_container': 90,
'error': 80,
'on_error': 20,
'error_container': 30,
'on_error_container': 90,
'surface': 6,
'on_surface': 90,
'surface_variant': 30,
'on_surface_variant': 80,
'surface_container_lowest': 4,
'surface_container_low': 10,
'surface_container': 12,
'surface_container_high': 17,
'surface_container_highest': 22,
'outline': 60,
'outline_variant': 30,
'shadow': 0,
'scrim': 0,
'inverse_surface': 90,
'inverse_on_surface': 20,
'inverse_primary': 40,
}
# Tone values for Material Design 3 (light theme)
LIGHT_TONES = {
'primary': 40,
'on_primary': 100,
'primary_container': 90,
'on_primary_container': 10,
'secondary': 40,
'on_secondary': 100,
'secondary_container': 90,
'on_secondary_container': 10,
'tertiary': 40,
'on_tertiary': 100,
'tertiary_container': 90,
'on_tertiary_container': 10,
'error': 40,
'on_error': 100,
'error_container': 90,
'on_error_container': 10,
'surface': 98,
'on_surface': 10,
'surface_variant': 90,
'on_surface_variant': 30,
'surface_container_lowest': 100,
'surface_container_low': 96,
'surface_container': 94,
'surface_container_high': 92,
'surface_container_highest': 90,
'outline': 50,
'outline_variant': 80,
'shadow': 0,
'scrim': 0,
'inverse_surface': 20,
'inverse_on_surface': 95,
'inverse_primary': 80,
}
# Monochrome scheme uses different tone values (from material-colors library)
# Primary/tertiary get special treatment for higher contrast in grayscale
MONOCHROME_DARK_TONES = {
**DARK_TONES,
'primary': 100, # White (was 80)
'on_primary': 10, # Near-black (was 20)
'primary_container': 85, # Light gray (was 30)
'on_primary_container': 0, # Black (was 90)
'tertiary': 90, # Light gray (was 80)
'on_tertiary': 10, # Near-black (was 20)
'tertiary_container': 60, # Mid gray (was 30)
'on_tertiary_container': 0, # Black (was 90)
'secondary_container': 30, # Same as normal
}
MONOCHROME_LIGHT_TONES = {
**LIGHT_TONES,
'primary': 0, # Black (was 40)
'on_primary': 90, # Light gray (was 100)
'primary_container': 25, # Dark gray (was 90)
'on_primary_container': 100, # White (was 10)
'tertiary': 25, # Dark gray (was 40)
'on_tertiary': 90, # Light gray (was 100)
'tertiary_container': 49, # Mid gray (was 90)
'on_tertiary_container': 100, # White (was 10)
'secondary_container': 90, # Same as normal
}
# =============================================================================
# Base Scheme Class
# =============================================================================
class _BaseScheme:
"""Base class for all Material Design 3 schemes."""
# Error palette is the same for all schemes
error_palette: TonalPalette
def __init__(self, source_color: Hct):
"""Initialize with source color. Subclasses must set palettes."""
self.source = source_color
self.error_palette = TonalPalette(25.0, 84.0) # Material red
@classmethod
def from_rgb(cls, r: int, g: int, b: int) -> '_BaseScheme':
"""Create scheme from RGB color."""
return cls(Hct.from_rgb(r, g, b))
@classmethod
def from_hex(cls, hex_color: str) -> '_BaseScheme':
"""Create scheme from hex color string."""
hex_color = hex_color.lstrip('#')
r = int(hex_color[0:2], 16)
g = int(hex_color[2:4], 16)
b = int(hex_color[4:6], 16)
return cls.from_rgb(r, g, b)
def get_dark_scheme(self) -> dict[str, str]:
"""Generate dark theme color dictionary."""
return self._generate_scheme(is_dark=True)
def get_light_scheme(self) -> dict[str, str]:
"""Generate light theme color dictionary."""
return self._generate_scheme(is_dark=False)
def _generate_scheme(self, is_dark: bool) -> dict[str, str]:
"""Generate scheme with appropriate tone values."""
tones = DARK_TONES if is_dark else LIGHT_TONES
scheme = {
# Primary colors
'primary': self.primary_palette.get_hex(tones['primary']),
'on_primary': self.primary_palette.get_hex(tones['on_primary']),
'primary_container': self.primary_palette.get_hex(tones['primary_container']),
'on_primary_container': self.primary_palette.get_hex(tones['on_primary_container']),
# Surface tint (same as primary, used for M3 elevation tinting)
'surface_tint': self.primary_palette.get_hex(tones['primary']),
# Secondary colors
'secondary': self.secondary_palette.get_hex(tones['secondary']),
'on_secondary': self.secondary_palette.get_hex(tones['on_secondary']),
'secondary_container': self.secondary_palette.get_hex(tones['secondary_container']),
'on_secondary_container': self.secondary_palette.get_hex(tones['on_secondary_container']),
# Tertiary colors
'tertiary': self.tertiary_palette.get_hex(tones['tertiary']),
'on_tertiary': self.tertiary_palette.get_hex(tones['on_tertiary']),
'tertiary_container': self.tertiary_palette.get_hex(tones['tertiary_container']),
'on_tertiary_container': self.tertiary_palette.get_hex(tones['on_tertiary_container']),
# Error colors
'error': self.error_palette.get_hex(tones['error']),
'on_error': self.error_palette.get_hex(tones['on_error']),
'error_container': self.error_palette.get_hex(tones['error_container']),
'on_error_container': self.error_palette.get_hex(tones['on_error_container']),
# Surface colors
'surface': self.neutral_palette.get_hex(tones['surface']),
'on_surface': self.neutral_palette.get_hex(tones['on_surface']),
'surface_variant': self.neutral_variant_palette.get_hex(tones['surface_variant']),
'on_surface_variant': self.neutral_variant_palette.get_hex(tones['on_surface_variant']),
# Surface containers
'surface_container_lowest': self.neutral_palette.get_hex(tones['surface_container_lowest']),
'surface_container_low': self.neutral_palette.get_hex(tones['surface_container_low']),
'surface_container': self.neutral_palette.get_hex(tones['surface_container']),
'surface_container_high': self.neutral_palette.get_hex(tones['surface_container_high']),
'surface_container_highest': self.neutral_palette.get_hex(tones['surface_container_highest']),
# Outline and other
'outline': self.neutral_variant_palette.get_hex(tones['outline']),
'outline_variant': self.neutral_variant_palette.get_hex(tones['outline_variant']),
'shadow': self.neutral_palette.get_hex(tones['shadow']),
'scrim': self.neutral_palette.get_hex(tones['scrim']),
# Inverse colors
'inverse_surface': self.neutral_palette.get_hex(tones['inverse_surface']),
'inverse_on_surface': self.neutral_palette.get_hex(tones['inverse_on_surface']),
'inverse_primary': self.primary_palette.get_hex(tones['inverse_primary']),
# Background (alias for surface)
'background': self.neutral_palette.get_hex(tones['surface']),
'on_background': self.neutral_palette.get_hex(tones['on_surface']),
# Surface dim and bright
'surface_dim': self.neutral_palette.get_hex(87 if not is_dark else 6),
'surface_bright': self.neutral_palette.get_hex(98 if not is_dark else 24),
# Fixed colors - consistent across light/dark modes (MD3 spec)
'primary_fixed': self.primary_palette.get_hex(90),
'primary_fixed_dim': self.primary_palette.get_hex(80),
'on_primary_fixed': self.primary_palette.get_hex(10),
'on_primary_fixed_variant': self.primary_palette.get_hex(30),
'secondary_fixed': self.secondary_palette.get_hex(90),
'secondary_fixed_dim': self.secondary_palette.get_hex(80),
'on_secondary_fixed': self.secondary_palette.get_hex(10),
'on_secondary_fixed_variant': self.secondary_palette.get_hex(30),
'tertiary_fixed': self.tertiary_palette.get_hex(90),
'tertiary_fixed_dim': self.tertiary_palette.get_hex(80),
'on_tertiary_fixed': self.tertiary_palette.get_hex(10),
'on_tertiary_fixed_variant': self.tertiary_palette.get_hex(30),
}
return scheme
# =============================================================================
# Scheme Implementations
# =============================================================================
class SchemeTonalSpot(_BaseScheme):
"""
Tonal Spot scheme - the default Android 12-13 Material You scheme.
Uses fixed chroma values for consistent, harmonious palettes:
- Primary: source hue, chroma 48
- Secondary: source hue, chroma 16
- Tertiary: hue +60°, chroma 24
- Neutrals: low chroma (tinted with source hue)
"""
def __init__(self, source_color: Hct):
super().__init__(source_color)
# Primary: source hue with fixed chroma 48
self.primary_palette = TonalPalette(source_color.hue, 48.0)
# Secondary: source hue with lower chroma 16
self.secondary_palette = TonalPalette(source_color.hue, 16.0)
# Tertiary: 60° hue rotation with chroma 24
tertiary_hue = (source_color.hue + 60.0) % 360.0
self.tertiary_palette = TonalPalette(tertiary_hue, 24.0)
# Neutral: source hue with very low chroma (tinted grays)
self.neutral_palette = TonalPalette(source_color.hue, 4.0)
# Neutral variant: slightly more chroma for contrast
self.neutral_variant_palette = TonalPalette(source_color.hue, 8.0)
class SchemeFruitSalad(_BaseScheme):
"""
Fruit Salad scheme - bold, playful theme with hue rotation.
Designed for expressive, colorful themes:
- Primary: hue -50°, chroma 48
- Secondary: hue -50°, chroma 36
- Tertiary: source hue (original), chroma 36
- Neutrals: tinted (chroma 10-16)
"""
def __init__(self, source_color: Hct):
super().__init__(source_color)
# Rotate hue by -50° for primary and secondary
rotated_hue = (source_color.hue - 50.0) % 360.0
# Primary: rotated hue with chroma 48
self.primary_palette = TonalPalette(rotated_hue, 48.0)
# Secondary: rotated hue with chroma 36
self.secondary_palette = TonalPalette(rotated_hue, 36.0)
# Tertiary: original source hue with chroma 36
self.tertiary_palette = TonalPalette(source_color.hue, 36.0)
# Neutral: source hue with higher chroma (tinted)
self.neutral_palette = TonalPalette(source_color.hue, 10.0)
# Neutral variant: even more tinted
self.neutral_variant_palette = TonalPalette(source_color.hue, 16.0)
class SchemeRainbow(_BaseScheme):
"""
Rainbow scheme - chromatic accents with grayscale neutrals.
Same structure as Tonal Spot but with pure grayscale neutrals:
- Primary: source hue, chroma 48
- Secondary: source hue, chroma 16
- Tertiary: hue +60°, chroma 24
- Neutrals: pure grayscale (chroma 0)
"""
def __init__(self, source_color: Hct):
super().__init__(source_color)
# Primary: source hue with fixed chroma 48
self.primary_palette = TonalPalette(source_color.hue, 48.0)
# Secondary: source hue with lower chroma 16
self.secondary_palette = TonalPalette(source_color.hue, 16.0)
# Tertiary: 60° hue rotation with chroma 24
tertiary_hue = (source_color.hue + 60.0) % 360.0
self.tertiary_palette = TonalPalette(tertiary_hue, 24.0)
# Neutral: pure grayscale (chroma 0)
self.neutral_palette = TonalPalette(0.0, 0.0)
# Neutral variant: also grayscale
self.neutral_variant_palette = TonalPalette(0.0, 0.0)
class SchemeContent(_BaseScheme):
"""
Content scheme - preserves source color's chroma.
This is the Material Design 3 "content" scheme that preserves the source
color's characteristics while creating harmonious palettes:
- Primary: source hue and chroma (full preservation)
- Secondary: same hue, reduced chroma: max(chroma - 32, chroma * 0.5)
- Tertiary: analogous color from temperature analysis (warm-cool harmony)
- Neutrals: low chroma (chroma / 8, tinted with source hue)
"""
def __init__(self, source_color: Hct):
super().__init__(source_color)
# Primary: preserve source color's hue and chroma (full preservation)
self.primary_palette = TonalPalette(source_color.hue, source_color.chroma)
# Secondary: same hue, reduced chroma
# Formula from matugen: max(chroma - 32, chroma * 0.5)
secondary_chroma = max(source_color.chroma - 32.0, source_color.chroma * 0.5)
self.secondary_palette = TonalPalette(source_color.hue, secondary_chroma)
# Tertiary: use analogous color from temperature analysis
# Get 3 analogous colors with 6 divisions, pick the last one (most different)
temp_cache = TemperatureCache(source_color)
analogous_colors = temp_cache.analogous(3, 6)
tertiary_hct = fix_if_disliked(analogous_colors[-1])
self.tertiary_palette = TonalPalette.from_hct(tertiary_hct)
# Neutral: source hue, low chroma (chroma / 8)
neutral_chroma = source_color.chroma / 8.0
self.neutral_palette = TonalPalette(source_color.hue, neutral_chroma)
# Neutral variant: slightly more chroma (chroma / 8 + 4)
neutral_variant_chroma = (source_color.chroma / 8.0) + 4.0
self.neutral_variant_palette = TonalPalette(source_color.hue, neutral_variant_chroma)
class SchemeMonochrome(_BaseScheme):
"""
Material Design 3 Monochrome scheme.
All color palettes use chroma=0.0, producing a pure grayscale theme.
Only the error color retains saturation for accessibility.
Uses special tone mappings (different from other M3 schemes) for higher
contrast in grayscale - e.g., primary is tone 100 (white) in dark mode.
Palette configuration:
- Primary: chroma 0.0 (grayscale)
- Secondary: chroma 0.0 (grayscale)
- Tertiary: chroma 0.0 (grayscale)
- Neutral: chroma 0.0 (grayscale)
- Neutral variant: chroma 0.0 (grayscale)
- Error: hue 25°, chroma 84 (vibrant red)
"""
def __init__(self, source_color: Hct):
super().__init__(source_color)
# All palettes use chroma=0 (grayscale)
# Source hue is preserved but irrelevant at chroma 0
self.primary_palette = TonalPalette(source_color.hue, 0.0)
self.secondary_palette = TonalPalette(source_color.hue, 0.0)
self.tertiary_palette = TonalPalette(source_color.hue, 0.0)
self.neutral_palette = TonalPalette(source_color.hue, 0.0)
self.neutral_variant_palette = TonalPalette(source_color.hue, 0.0)
# Error palette keeps vibrant red for accessibility
self.error_palette = TonalPalette(25.0, 84.0)
def _generate_scheme(self, is_dark: bool) -> dict[str, str]:
"""Generate scheme with monochrome-specific tone values."""
# Monochrome uses different tones for higher contrast in grayscale
tones = MONOCHROME_DARK_TONES if is_dark else MONOCHROME_LIGHT_TONES
scheme = {
# Primary colors
'primary': self.primary_palette.get_hex(tones['primary']),
'on_primary': self.primary_palette.get_hex(tones['on_primary']),
'primary_container': self.primary_palette.get_hex(tones['primary_container']),
'on_primary_container': self.primary_palette.get_hex(tones['on_primary_container']),
# Surface tint (same as primary, used for M3 elevation tinting)
'surface_tint': self.primary_palette.get_hex(tones['primary']),
# Secondary colors
'secondary': self.secondary_palette.get_hex(tones['secondary']),
'on_secondary': self.secondary_palette.get_hex(tones['on_secondary']),
'secondary_container': self.secondary_palette.get_hex(tones['secondary_container']),
'on_secondary_container': self.secondary_palette.get_hex(tones['on_secondary_container']),
# Tertiary colors
'tertiary': self.tertiary_palette.get_hex(tones['tertiary']),
'on_tertiary': self.tertiary_palette.get_hex(tones['on_tertiary']),
'tertiary_container': self.tertiary_palette.get_hex(tones['tertiary_container']),
'on_tertiary_container': self.tertiary_palette.get_hex(tones['on_tertiary_container']),
# Error colors
'error': self.error_palette.get_hex(tones['error']),
'on_error': self.error_palette.get_hex(tones['on_error']),
'error_container': self.error_palette.get_hex(tones['error_container']),
'on_error_container': self.error_palette.get_hex(tones['on_error_container']),
# Surface colors
'surface': self.neutral_palette.get_hex(tones['surface']),
'on_surface': self.neutral_palette.get_hex(tones['on_surface']),
'surface_variant': self.neutral_variant_palette.get_hex(tones['surface_variant']),
'on_surface_variant': self.neutral_variant_palette.get_hex(tones['on_surface_variant']),
# Surface containers
'surface_container_lowest': self.neutral_palette.get_hex(tones['surface_container_lowest']),
'surface_container_low': self.neutral_palette.get_hex(tones['surface_container_low']),
'surface_container': self.neutral_palette.get_hex(tones['surface_container']),
'surface_container_high': self.neutral_palette.get_hex(tones['surface_container_high']),
'surface_container_highest': self.neutral_palette.get_hex(tones['surface_container_highest']),
# Outline and other
'outline': self.neutral_variant_palette.get_hex(tones['outline']),
'outline_variant': self.neutral_variant_palette.get_hex(tones['outline_variant']),
'shadow': self.neutral_palette.get_hex(tones['shadow']),
'scrim': self.neutral_palette.get_hex(tones['scrim']),
# Inverse colors
'inverse_surface': self.neutral_palette.get_hex(tones['inverse_surface']),
'inverse_on_surface': self.neutral_palette.get_hex(tones['inverse_on_surface']),
'inverse_primary': self.primary_palette.get_hex(tones['inverse_primary']),
# Background (same as surface in MD3)
'background': self.neutral_palette.get_hex(tones['surface']),
'on_background': self.neutral_palette.get_hex(tones['on_surface']),
# Surface dim and bright
'surface_dim': self.neutral_palette.get_hex(tones['surface']),
'surface_bright': self.neutral_palette.get_hex(tones['surface_container_highest'] + 5),
# Fixed colors
'primary_fixed': self.primary_palette.get_hex(90),
'primary_fixed_dim': self.primary_palette.get_hex(80),
'on_primary_fixed': self.primary_palette.get_hex(10),
'on_primary_fixed_variant': self.primary_palette.get_hex(30),
'secondary_fixed': self.secondary_palette.get_hex(90),
'secondary_fixed_dim': self.secondary_palette.get_hex(80),
'on_secondary_fixed': self.secondary_palette.get_hex(10),
'on_secondary_fixed_variant': self.secondary_palette.get_hex(30),
'tertiary_fixed': self.tertiary_palette.get_hex(90),
'tertiary_fixed_dim': self.tertiary_palette.get_hex(80),
'on_tertiary_fixed': self.tertiary_palette.get_hex(10),
'on_tertiary_fixed_variant': self.tertiary_palette.get_hex(30),
}
return scheme
# Backward compatibility alias
MaterialScheme = SchemeContent
# =============================================================================
# Helper Functions
# =============================================================================
def harmonize_color(design_color: Hct, source_color: Hct, amount: float = 0.5) -> Hct:
"""
Shift a design color's hue towards a source color's hue.
Used to make custom colors feel more cohesive with the theme.
Args:
design_color: The color to adjust
source_color: The reference color to harmonize towards
amount: How much to shift (0-1, default 0.5)
Returns:
Harmonized HCT color
"""
diff = _hue_difference(source_color.hue, design_color.hue)
rotation = min(diff * amount, 15.0) # Max 15° rotation
if _shorter_rotation(source_color.hue, design_color.hue) < 0:
rotation = -rotation
new_hue = (design_color.hue + rotation) % 360.0
return Hct(new_hue, design_color.chroma, design_color.tone)
def _hue_difference(hue1: float, hue2: float) -> float:
"""Calculate the absolute difference between two hues."""
diff = abs(hue1 - hue2)
return min(diff, 360.0 - diff)
def _shorter_rotation(from_hue: float, to_hue: float) -> float:
"""Calculate the shorter rotation direction between hues."""
diff = to_hue - from_hue
if diff > 180.0:
return diff - 360.0
elif diff < -180.0:
return diff + 360.0
return diff
@@ -0,0 +1,672 @@
"""
Palette extraction using K-means clustering.
This module provides functions for extracting dominant colors from images
using perceptual color distance calculations and k-means clustering.
"""
import math
from .color import Color, rgb_to_hsl, hsl_to_rgb, hue_distance, rgb_to_lab, lab_to_rgb, lab_distance
from .hct import Cam16, Hct
# Type aliases
RGB = tuple[int, int, int]
HSL = tuple[float, float, float]
LAB = tuple[float, float, float]
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 kmeans_cluster(
colors: list[RGB],
k: int = 5,
iterations: int = 10
) -> list[tuple[RGB, RGB, int]]:
"""
Perform K-means clustering on colors in Lab color space.
Lab space is perceptually uniform, matching matugen's approach.
Returns list of (centroid_rgb, representative_rgb, cluster_size) tuples,
sorted by cluster size.
- centroid_rgb: averaged color from the cluster (smoother, blended)
- representative_rgb: actual image pixel closest to centroid
"""
if len(colors) < k:
# Not enough colors, return what we have (same color for centroid and representative)
unique = list(set(colors))
return [(c, c, colors.count(c)) for c in unique[:k]]
# Convert to Lab for perceptual clustering (like matugen's WSMeans)
colors_lab = [rgb_to_lab(*c) for c in colors]
# Deterministic initialization: pick evenly spaced colors from sorted list
# Sort by L (lightness) first for better spread
sorted_indices = sorted(range(len(colors_lab)), key=lambda i: colors_lab[i][0])
step = len(sorted_indices) // k
centroids = [colors_lab[sorted_indices[i * step]] for i in range(k)]
# K-means iterations
assignments = [0] * len(colors_lab)
for _ in range(iterations):
# Assign colors to nearest centroid
for idx, color in enumerate(colors_lab):
min_dist = float('inf')
min_cluster = 0
for i, centroid in enumerate(centroids):
dist = lab_distance(color, centroid)
if dist < min_dist:
min_dist = dist
min_cluster = i
assignments[idx] = min_cluster
# Update centroids (simple mean in Lab space)
new_centroids = []
for i in range(k):
cluster_colors = [colors_lab[j] for j in range(len(colors_lab)) if assignments[j] == i]
if cluster_colors:
avg_L = sum(c[0] for c in cluster_colors) / len(cluster_colors)
avg_a = sum(c[1] for c in cluster_colors) / len(cluster_colors)
avg_b = sum(c[2] for c in cluster_colors) / len(cluster_colors)
new_centroids.append((avg_L, avg_a, avg_b))
else:
new_centroids.append(centroids[i])
centroids = new_centroids
# Final assignment and count, also find representative pixel (closest to centroid)
cluster_counts = [0] * k
cluster_representatives: list[tuple[RGB, float]] = [(colors[0], float('inf'))] * k
for idx, color_lab in enumerate(colors_lab):
cluster_idx = assignments[idx]
cluster_counts[cluster_idx] += 1
# Track the pixel closest to the centroid as the representative
dist = lab_distance(color_lab, centroids[cluster_idx])
if dist < cluster_representatives[cluster_idx][1]:
cluster_representatives[cluster_idx] = (colors[idx], dist)
# Return both centroid (averaged) and representative (actual pixel) colors
results = []
for i in range(k):
if cluster_counts[i] > 0:
# Convert Lab centroid back to RGB
centroid_rgb = lab_to_rgb(*centroids[i])
representative_rgb = cluster_representatives[i][0]
results.append((centroid_rgb, representative_rgb, cluster_counts[i]))
# Sort by cluster size (most common first)
results.sort(key=lambda x: -x[2])
return results
def _score_colors_chroma(
colors_with_counts: list[tuple[RGB, int]],
) -> list[tuple[Color, float]]:
"""
Score colors prioritizing chroma (vibrancy) over area coverage.
Uses count^0.3 weighting so saturated colors win even with small area.
Used for "vibrant" mode to find the most eye-catching colors.
Args:
colors_with_counts: List of (RGB, count) tuples from clustering
Returns:
List of (Color, score) tuples, sorted by score descending
"""
result_colors = []
for rgb, count in colors_with_counts:
color = Color.from_rgb(rgb)
try:
hct = color.to_hct()
# Chroma contribution - prefer colorful colors
chroma_score = hct.chroma
# Tone penalty - prefer mid-tones (40-60 is ideal)
if hct.tone < 20:
tone_penalty = (20 - hct.tone) * 2
elif hct.tone > 80:
tone_penalty = (hct.tone - 80) * 1.5
elif hct.tone < 40:
tone_penalty = (40 - hct.tone) * 0.5
elif hct.tone > 60:
tone_penalty = (hct.tone - 60) * 0.3
else:
tone_penalty = 0
# Hue penalty - slight penalty for yellow-green hues
if 80 < hct.hue < 110:
hue_penalty = 5
else:
hue_penalty = 0
# Combined score: chroma minus penalties, balanced with count
# Using count^0.3 so chroma dominates while still considering area
score = (chroma_score - tone_penalty - hue_penalty) * (count ** 0.3)
result_colors.append((color, score))
except (ValueError, ZeroDivisionError):
result_colors.append((color, 0.0))
result_colors.sort(key=lambda x: -x[1])
return result_colors
def _hue_to_family(hue: float) -> int:
"""
Map hue to perceptual color family.
Uses non-uniform ranges that match human color perception:
- 0: RED (330-30°, wraps around)
- 1: ORANGE (30-60°)
- 2: YELLOW (60-105°)
- 3: GREEN (105-190°, includes green-leaning teal)
- 4: BLUE (190-270°, includes cyan)
- 5: PURPLE (270-330°)
"""
if hue >= 330 or hue < 30:
return 0 # RED
elif hue < 60:
return 1 # ORANGE
elif hue < 105:
return 2 # YELLOW
elif hue < 190:
return 3 # GREEN (includes green-leaning teal)
elif hue < 270:
return 4 # BLUE (includes cyan)
else:
return 5 # PURPLE
def _score_colors_count(
colors_with_counts: list[tuple[RGB, int]],
) -> list[tuple[Color, float]]:
"""
Score colors prioritizing pixel count (area coverage) by hue family.
Groups colors into perceptual hue families, sums counts per family,
then picks the dominant family. This is more faithful to human perception
where we see "green" as a category, not individual shades.
Args:
colors_with_counts: List of (RGB, count) tuples from clustering
Returns:
List of (Color, score) tuples, sorted by family dominance then count
"""
MIN_CHROMA = 10.0 # Filter out near-gray colors
# First pass: collect colorful colors and group by hue family
hue_families: dict[int, list[tuple[Color, float, float, int]]] = {} # family -> [(color, hue, chroma, count), ...]
for rgb, count in colors_with_counts:
color = Color.from_rgb(rgb)
try:
hct = color.to_hct()
if hct.chroma >= MIN_CHROMA:
family = _hue_to_family(hct.hue)
if family not in hue_families:
hue_families[family] = []
hue_families[family].append((color, hct.hue, hct.chroma, count))
except (ValueError, ZeroDivisionError):
pass
# If no colorful colors found, fall back to all colors
if not hue_families:
result = []
for rgb, count in colors_with_counts:
color = Color.from_rgb(rgb)
result.append((color, float(count)))
result.sort(key=lambda x: -x[1])
return result
# Calculate total count per hue family
family_totals: list[tuple[int, int]] = []
for family, colors in hue_families.items():
total = sum(c[3] for c in colors)
family_totals.append((family, total))
# Sort families by total count (dominant family first)
family_totals.sort(key=lambda x: -x[1])
# Build result: colors from dominant families first, sorted by count within each family
result_colors = []
for family, _ in family_totals:
family_colors = hue_families[family]
# Sort by count descending, chroma as tiebreaker
family_colors.sort(key=lambda x: (-x[3], -x[2]))
for color, hue, chroma, count in family_colors:
# Score encodes family rank + count for proper ordering
family_rank = next(i for i, (f, _) in enumerate(family_totals) if f == family)
score = (len(family_totals) - family_rank) * 1000000 + count * 1000 + chroma
result_colors.append((color, score))
result_colors.sort(key=lambda x: -x[1])
return result_colors
def _family_center_hue(family: int) -> float:
"""Get the center hue for a family index."""
# Family centers based on _hue_to_family ranges:
# 0: RED (330-30°, wraps) -> center 0°
# 1: ORANGE (30-60°) -> center 45°
# 2: YELLOW (60-105°) -> center 82.5°
# 3: GREEN (105-190°) -> center 147.5°
# 4: BLUE (190-270°) -> center 230°
# 5: PURPLE (270-330°) -> center 300°
centers = [0.0, 45.0, 82.5, 147.5, 230.0, 300.0]
return centers[family]
def _circular_hue_diff(h1: float, h2: float) -> float:
"""Calculate circular hue difference (0-180)."""
diff = abs(h1 - h2)
return min(diff, 360.0 - diff)
def _score_colors_dysfunctional(
colors_with_counts: list[tuple[RGB, int]],
) -> list[tuple[Color, float]]:
"""
Score colors prioritizing the 2nd most dominant hue family.
Like count scoring but skips the dominant family (and any families
too close to it) to pick a visually distinct secondary color.
Args:
colors_with_counts: List of (RGB, count) tuples from clustering
Returns:
List of (Color, score) tuples, sorted by family dominance then count
"""
MIN_CHROMA = 10.0 # Filter out near-gray colors
MIN_HUE_DISTANCE = 45.0 # Minimum hue distance from dominant family
MIN_COUNT_RATIO = 0.02 # Distant family must have at least 2% of total colorful pixels
# First pass: collect colorful colors and group by hue family
hue_families: dict[int, list[tuple[Color, float, float, int]]] = {} # family -> [(color, hue, chroma, count), ...]
for rgb, count in colors_with_counts:
color = Color.from_rgb(rgb)
try:
hct = color.to_hct()
if hct.chroma >= MIN_CHROMA:
family = _hue_to_family(hct.hue)
if family not in hue_families:
hue_families[family] = []
hue_families[family].append((color, hct.hue, hct.chroma, count))
except (ValueError, ZeroDivisionError):
pass
# If no colorful colors found, fall back to all colors
if not hue_families:
result = []
for rgb, count in colors_with_counts:
color = Color.from_rgb(rgb)
result.append((color, float(count)))
result.sort(key=lambda x: -x[1])
return result
# Calculate total count per hue family
family_totals: list[tuple[int, int]] = []
for family, colors in hue_families.items():
total = sum(c[3] for c in colors)
family_totals.append((family, total))
# Sort families by total count (dominant family first)
family_totals.sort(key=lambda x: -x[1])
# Find the dominant family and its center hue
dominant_family, dominant_count = family_totals[0]
dominant_center = _family_center_hue(dominant_family)
total_colorful_pixels = sum(count for _, count in family_totals)
min_count = total_colorful_pixels * MIN_COUNT_RATIO
# Find families that are far enough from the dominant one AND have enough pixels
distant_families = []
close_families = [dominant_family]
for family, count in family_totals[1:]:
family_center = _family_center_hue(family)
hue_diff = _circular_hue_diff(dominant_center, family_center)
if hue_diff >= MIN_HUE_DISTANCE and count >= min_count:
# Get max chroma in this family - we want families with vibrant colors
max_chroma = max(c[2] for c in hue_families[family])
distant_families.append((family, count, hue_diff, max_chroma))
else:
close_families.append(family)
# Build result: colors from distant families first
result_colors = []
# Sort distant families by weighted score: hue_distance * max_chroma
# This balances visual distinctness (hue distance) with color quality (chroma)
# A family that's far away AND has good colors beats one that's close with great colors
distant_families.sort(key=lambda x: -(x[2] * x[3]))
for family, _, _, _ in distant_families:
family_colors = hue_families[family]
# Sort by chroma descending - we want the most vibrant color from this family
# Count is tiebreaker to avoid picking tiny noise clusters
family_colors.sort(key=lambda x: (-x[2], -x[3]))
for color, hue, chroma, count in family_colors:
# Score encodes family rank + chroma for proper ordering
# Chroma is primary (we want vibrant), count is tiebreaker
family_rank = next(i for i, (f, _, _, _) in enumerate(distant_families) if f == family)
score = (len(distant_families) - family_rank) * 1000000 + chroma * 1000 + count
result_colors.append((color, score))
# Add colors from close families (including dominant) at lower priority
for family in close_families:
family_colors = hue_families[family]
family_colors.sort(key=lambda x: (-x[3], -x[2]))
for color, hue, chroma, count in family_colors:
# Lower score than all distant-family colors
score = count * 1000 + chroma
result_colors.append((color, score))
result_colors.sort(key=lambda x: -x[1])
return result_colors
def _score_colors_muted(
colors_with_counts: list[tuple[RGB, int]],
) -> list[tuple[Color, float]]:
"""
Score colors for muted mode - pure pixel count without chroma filtering.
Unlike count scoring which filters to chroma >= 10, this accepts all colors
including grayscale. Designed for monochrome/monotonal wallpapers where
the dominant color may have very low or zero saturation.
Args:
colors_with_counts: List of (RGB, count) tuples from clustering
Returns:
List of (Color, score) tuples, sorted by count descending
"""
result = []
for rgb, count in colors_with_counts:
color = Color.from_rgb(rgb)
result.append((color, float(count)))
result.sort(key=lambda x: -x[1])
return result
def _score_colors_population(
colors_with_counts: list[tuple[RGB, int]],
total_pixels: int
) -> list[tuple[Color, float]]:
"""
Score colors using Material Design's Score algorithm.
This matches matugen's scoring approach exactly:
- Build per-hue population histogram (360 buckets)
- Calculate "excited proportions" (±15° hue window sum)
- Score: proportion * 100 * 0.7 + (chroma - 48) * weight
- Filter by chroma >= 5 and proportion >= 1%
- Deduplicate by maximizing hue distance
Args:
colors_with_counts: List of (RGB, count) tuples from clustering
total_pixels: Total number of pixels in the sample
Returns:
List of (Color, score) tuples, sorted by score descending
"""
# Constants matching Material Score
TARGET_CHROMA = 48.0
WEIGHT_PROPORTION = 0.7
WEIGHT_CHROMA_ABOVE = 0.3
WEIGHT_CHROMA_BELOW = 0.1
CUTOFF_CHROMA = 5.0
CUTOFF_EXCITED_PROPORTION = 0.01
# Build per-hue population histogram (360 buckets)
hue_population = [0] * 360
population_sum = 0
colors_hct: list[tuple[Color, Hct, int]] = []
for rgb, count in colors_with_counts:
try:
color = Color.from_rgb(rgb)
hct = color.to_hct()
hue_bucket = int(hct.hue) % 360
hue_population[hue_bucket] += count
population_sum += count
colors_hct.append((color, hct, count))
except (ValueError, ZeroDivisionError):
continue
if not colors_hct or population_sum == 0:
# Fallback: return colors without scoring
result = []
for rgb, count in colors_with_counts:
color = Color.from_rgb(rgb)
result.append((color, float(count)))
return sorted(result, key=lambda x: -x[1])
# Calculate "excited proportions" - sum of proportions in ±15° hue window
hue_excited_proportions = [0.0] * 360
for hue in range(360):
proportion = hue_population[hue] / population_sum
# Spread to neighboring hues (±15°, so 30° total window)
for offset in range(-14, 16):
neighbor_hue = (hue + offset) % 360
hue_excited_proportions[neighbor_hue] += proportion
# Score each color
scored_hcts: list[tuple[Color, Hct, float]] = []
for color, hct, count in colors_hct:
hue_bucket = int(hct.hue) % 360
proportion = hue_excited_proportions[hue_bucket]
# Filter by chroma and proportion
if hct.chroma < CUTOFF_CHROMA:
continue
if proportion <= CUTOFF_EXCITED_PROPORTION:
continue
# Proportion score (70% weight)
proportion_score = proportion * 100.0 * WEIGHT_PROPORTION
# Chroma score: (chroma - target) * weight
# This gives bonus for high chroma, penalty for low chroma
if hct.chroma < TARGET_CHROMA:
chroma_weight = WEIGHT_CHROMA_BELOW
else:
chroma_weight = WEIGHT_CHROMA_ABOVE
chroma_score = (hct.chroma - TARGET_CHROMA) * chroma_weight
score = proportion_score + chroma_score
scored_hcts.append((color, hct, score))
if not scored_hcts:
# Fallback if filtering removed everything
result = []
for rgb, count in colors_with_counts:
color = Color.from_rgb(rgb)
result.append((color, float(count)))
return sorted(result, key=lambda x: -x[1])
# Sort by score descending
scored_hcts.sort(key=lambda x: -x[2])
# Deduplicate by hue distance - pick colors maximizing hue diversity
# Start at 90° minimum distance, decrease to 15° if needed
chosen_colors: list[tuple[Color, float]] = []
for min_hue_diff in range(90, 14, -1):
chosen_colors.clear()
for color, hct, score in scored_hcts:
# Check if this hue is far enough from all chosen colors
is_far_enough = True
for chosen_color, _ in chosen_colors:
chosen_hct = chosen_color.to_hct()
if hue_distance(hct.hue, chosen_hct.hue) < min_hue_diff:
is_far_enough = False
break
if is_far_enough:
chosen_colors.append((color, score))
# Stop if we have enough colors (4 is Material default)
if len(chosen_colors) >= 4:
break
# If we found enough colors, stop decreasing threshold
if len(chosen_colors) >= 4:
break
# If deduplication yielded nothing, fall back to top scored
if not chosen_colors:
chosen_colors = [(c, s) for c, h, s in scored_hcts[:4]]
return chosen_colors
def extract_palette(
pixels: list[RGB],
k: int = 5,
scoring: str = "population"
) -> list[Color]:
"""
Extract K dominant colors from pixel data.
Args:
pixels: List of RGB tuples
k: Number of colors to extract
scoring: Scoring method:
- "population": matugen-like, representative colors (M3 schemes)
- "chroma": vibrant, chroma-prioritized with centroid averaging
- "count": area-dominant, picks by pixel count (faithful mode)
- "dysfunctional": picks 2nd most dominant color family
- "muted": like count but without chroma filtering (monochrome wallpapers)
Returns:
List of Color objects, sorted by score
"""
# Downsample for performance
sampled = downsample_pixels(pixels, factor=4)
total_sampled = len(sampled)
# For population scoring, we need many clusters then score/filter them
# For chroma scoring, fewer clusters work fine
if scoring == "population":
# Use more clusters for Material scoring (like matugen's 128-256)
cluster_count = min(128, max(k * 10, len(set(sampled)) // 10))
# Don't pre-filter for population scoring - let the Score algorithm filter
# This matches matugen which quantizes all pixels, then filters in scoring
filtered = sampled
elif scoring == "count":
# Faithful mode: many clusters to capture color diversity, no pre-filtering
# Scoring will filter to colorful colors and pick by count
cluster_count = 48
filtered = sampled
elif scoring == "dysfunctional":
# Dysfunctional mode: same as count but picks 2nd dominant family
cluster_count = 48
filtered = sampled
elif scoring == "muted":
# Muted mode: similar to count but accepts low-chroma colors
# For monochrome/monotonal wallpapers
cluster_count = 24
filtered = sampled
else:
# Vibrant mode: more clusters to capture high-chroma colors that might
# otherwise get averaged away, with colorfulness pre-filter
cluster_count = 20
# Filter to colorful pixels for smoother averaged results
filtered = []
for p in sampled:
try:
cam = Cam16.from_rgb(p[0], p[1], p[2])
if cam.chroma >= 5.0:
filtered.append(p)
except (ValueError, ZeroDivisionError):
continue
if len(filtered) < cluster_count * 2:
filtered = sampled
# Cluster - returns (centroid_rgb, representative_rgb, count) tuples
clusters = kmeans_cluster(filtered, k=cluster_count)
# Score colors based on method
# - chroma: centroid colors (averaged, smoother - vibrant mode)
# - count: representative pixels by area dominance (faithful mode)
# - muted: like count but accepts low/zero chroma (monochrome wallpapers)
# - population: representative colors with Material scoring (M3 schemes)
if scoring == "chroma":
# Use centroid colors for vibrant mode (smoother, blended)
colors_for_scoring = [(c[0], c[2]) for c in clusters]
scored = _score_colors_chroma(colors_for_scoring)
elif scoring == "count":
# Use representative colors with count scoring (faithful mode)
colors_for_scoring = [(c[1], c[2]) for c in clusters]
scored = _score_colors_count(colors_for_scoring)
elif scoring == "dysfunctional":
# Use representative colors with dysfunctional scoring (2nd dominant family)
colors_for_scoring = [(c[1], c[2]) for c in clusters]
scored = _score_colors_dysfunctional(colors_for_scoring)
elif scoring == "muted":
# Use representative colors with muted scoring (no chroma filter)
colors_for_scoring = [(c[1], c[2]) for c in clusters]
scored = _score_colors_muted(colors_for_scoring)
else:
# Use representative colors for M3 schemes
colors_for_scoring = [(c[1], c[2]) for c in clusters]
scored = _score_colors_population(colors_for_scoring, total_sampled)
# Extract colors
final_colors = [c[0] for c in scored]
# Ensure we have enough colors by deriving from primary using HCT
while len(final_colors) < k:
if not final_colors:
final_colors.append(Color.from_hex("#6750A4"))
continue
primary = final_colors[0]
primary_hct = primary.to_hct()
offset = len(final_colors) * 60.0
new_hct = Hct((primary_hct.hue + offset) % 360.0, primary_hct.chroma, primary_hct.tone)
final_colors.append(Color.from_hct(new_hct))
return final_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")
@@ -0,0 +1,815 @@
"""
Wu and WSMeans quantizer implementations matching material-color-utilities.
Wu implements Xiaolin Wu's color quantization algorithm from Graphics Gems II (1991).
WSMeans refines Wu's output via weighted k-means in Lab space (QuantizerCelebi pipeline).
Together they match the QuantizerCelebi pipeline used by matugen/material-color-utilities.
"""
from typing import Dict, List, Tuple
from .color import rgb_to_lab, lab_to_rgb
# Constants matching material-color-utilities
INDEX_BITS = 5
SIDE_LENGTH = 33 # (1 << INDEX_BITS) + 1
TOTAL_SIZE = 35937 # SIDE_LENGTH^3
# Direction constants
DIR_RED = 0
DIR_GREEN = 1
DIR_BLUE = 2
class Box:
"""Represents a box in RGB color space."""
__slots__ = ('r0', 'r1', 'g0', 'g1', 'b0', 'b1', 'vol')
def __init__(self):
self.r0 = 0
self.r1 = 0
self.g0 = 0
self.g1 = 0
self.b0 = 0
self.b1 = 0
self.vol = 0
def _get_index(r: int, g: int, b: int) -> int:
"""Calculate 3D array index from RGB coordinates."""
return (r << (INDEX_BITS * 2)) + (r << (INDEX_BITS + 1)) + r + (g << INDEX_BITS) + g + b
def _argb_from_rgb(r: int, g: int, b: int) -> int:
"""Convert RGB to ARGB integer format."""
return (255 << 24) | ((r & 0xFF) << 16) | ((g & 0xFF) << 8) | (b & 0xFF)
def _rgb_from_argb(argb: int) -> Tuple[int, int, int]:
"""Extract RGB from ARGB integer."""
return ((argb >> 16) & 0xFF, (argb >> 8) & 0xFF, argb & 0xFF)
class QuantizerWu:
"""
Wu color quantizer implementation.
Divides image pixels into clusters by recursively cutting an RGB cube,
based on the weight of pixels in each area of the cube.
"""
def __init__(self):
self.weights: List[int] = []
self.moments_r: List[int] = []
self.moments_g: List[int] = []
self.moments_b: List[int] = []
self.moments: List[float] = []
self.cubes: List[Box] = []
def quantize(self, pixels: List[int], max_colors: int) -> List[int]:
"""
Quantize pixels to a reduced color palette.
Args:
pixels: List of colors in ARGB integer format
max_colors: Maximum number of colors to return
Returns:
List of colors in ARGB format
"""
self._construct_histogram(pixels)
self._compute_moments()
result_count = self._create_boxes(max_colors)
return self._create_result(result_count)
def _construct_histogram(self, pixels: List[int]):
"""Build histogram of pixel colors."""
self.weights = [0] * TOTAL_SIZE
self.moments_r = [0] * TOTAL_SIZE
self.moments_g = [0] * TOTAL_SIZE
self.moments_b = [0] * TOTAL_SIZE
self.moments = [0.0] * TOTAL_SIZE
# Count pixels by color
count_by_color: Dict[int, int] = {}
for pixel in pixels:
# Only count fully opaque pixels
if (pixel >> 24) & 0xFF == 255:
count_by_color[pixel] = count_by_color.get(pixel, 0) + 1
bits_to_remove = 8 - INDEX_BITS
for pixel, count in count_by_color.items():
red = (pixel >> 16) & 0xFF
green = (pixel >> 8) & 0xFF
blue = pixel & 0xFF
i_r = (red >> bits_to_remove) + 1
i_g = (green >> bits_to_remove) + 1
i_b = (blue >> bits_to_remove) + 1
index = _get_index(i_r, i_g, i_b)
self.weights[index] += count
self.moments_r[index] += count * red
self.moments_g[index] += count * green
self.moments_b[index] += count * blue
self.moments[index] += count * (red * red + green * green + blue * blue)
def _compute_moments(self):
"""Compute cumulative moments for efficient volume calculations."""
for r in range(1, SIDE_LENGTH):
area = [0] * SIDE_LENGTH
area_r = [0] * SIDE_LENGTH
area_g = [0] * SIDE_LENGTH
area_b = [0] * SIDE_LENGTH
area2 = [0.0] * SIDE_LENGTH
for g in range(1, SIDE_LENGTH):
line = 0
line_r = 0
line_g = 0
line_b = 0
line2 = 0.0
for b in range(1, SIDE_LENGTH):
index = _get_index(r, g, b)
line += self.weights[index]
line_r += self.moments_r[index]
line_g += self.moments_g[index]
line_b += self.moments_b[index]
line2 += self.moments[index]
area[b] += line
area_r[b] += line_r
area_g[b] += line_g
area_b[b] += line_b
area2[b] += line2
prev_index = _get_index(r - 1, g, b)
self.weights[index] = self.weights[prev_index] + area[b]
self.moments_r[index] = self.moments_r[prev_index] + area_r[b]
self.moments_g[index] = self.moments_g[prev_index] + area_g[b]
self.moments_b[index] = self.moments_b[prev_index] + area_b[b]
self.moments[index] = self.moments[prev_index] + area2[b]
def _create_boxes(self, max_colors: int) -> int:
"""Create color boxes by recursive cutting."""
self.cubes = [Box() for _ in range(max_colors)]
volume_variance = [0.0] * max_colors
# Initialize first box to cover entire color space
self.cubes[0].r1 = SIDE_LENGTH - 1
self.cubes[0].g1 = SIDE_LENGTH - 1
self.cubes[0].b1 = SIDE_LENGTH - 1
generated_color_count = max_colors
next_box = 0
i = 1
while i < max_colors:
if self._cut(self.cubes[next_box], self.cubes[i]):
volume_variance[next_box] = (
self._variance(self.cubes[next_box])
if self.cubes[next_box].vol > 1 else 0.0
)
volume_variance[i] = (
self._variance(self.cubes[i])
if self.cubes[i].vol > 1 else 0.0
)
else:
volume_variance[next_box] = 0.0
i -= 1
# Find box with maximum variance
next_box = 0
temp = volume_variance[0]
for j in range(1, i + 1):
if volume_variance[j] > temp:
temp = volume_variance[j]
next_box = j
if temp <= 0.0:
generated_color_count = i + 1
break
i += 1
return generated_color_count
def _create_result(self, color_count: int) -> List[int]:
"""Extract final colors from boxes."""
colors = []
for i in range(color_count):
cube = self.cubes[i]
weight = self._volume(cube, self.weights)
if weight > 0:
r = int(self._volume(cube, self.moments_r) / weight)
g = int(self._volume(cube, self.moments_g) / weight)
b = int(self._volume(cube, self.moments_b) / weight)
color = _argb_from_rgb(r, g, b)
colors.append(color)
return colors
def _variance(self, cube: Box) -> float:
"""Calculate variance within a box."""
dr = self._volume(cube, self.moments_r)
dg = self._volume(cube, self.moments_g)
db = self._volume(cube, self.moments_b)
xx = (
self.moments[_get_index(cube.r1, cube.g1, cube.b1)]
- self.moments[_get_index(cube.r1, cube.g1, cube.b0)]
- self.moments[_get_index(cube.r1, cube.g0, cube.b1)]
+ self.moments[_get_index(cube.r1, cube.g0, cube.b0)]
- self.moments[_get_index(cube.r0, cube.g1, cube.b1)]
+ self.moments[_get_index(cube.r0, cube.g1, cube.b0)]
+ self.moments[_get_index(cube.r0, cube.g0, cube.b1)]
- self.moments[_get_index(cube.r0, cube.g0, cube.b0)]
)
hypotenuse = dr * dr + dg * dg + db * db
volume = self._volume(cube, self.weights)
if volume == 0:
return 0.0
return xx - hypotenuse / volume
def _cut(self, one: Box, two: Box) -> bool:
"""Cut a box into two boxes along the optimal axis."""
whole_r = self._volume(one, self.moments_r)
whole_g = self._volume(one, self.moments_g)
whole_b = self._volume(one, self.moments_b)
whole_w = self._volume(one, self.weights)
max_r_cut, max_r = self._maximize(
one, DIR_RED, one.r0 + 1, one.r1, whole_r, whole_g, whole_b, whole_w
)
max_g_cut, max_g = self._maximize(
one, DIR_GREEN, one.g0 + 1, one.g1, whole_r, whole_g, whole_b, whole_w
)
max_b_cut, max_b = self._maximize(
one, DIR_BLUE, one.b0 + 1, one.b1, whole_r, whole_g, whole_b, whole_w
)
if max_r >= max_g and max_r >= max_b:
if max_r_cut < 0:
return False
direction = DIR_RED
cut_location = max_r_cut
elif max_g >= max_r and max_g >= max_b:
direction = DIR_GREEN
cut_location = max_g_cut
else:
direction = DIR_BLUE
cut_location = max_b_cut
two.r1 = one.r1
two.g1 = one.g1
two.b1 = one.b1
if direction == DIR_RED:
one.r1 = cut_location
two.r0 = one.r1
two.g0 = one.g0
two.b0 = one.b0
elif direction == DIR_GREEN:
one.g1 = cut_location
two.r0 = one.r0
two.g0 = one.g1
two.b0 = one.b0
else: # DIR_BLUE
one.b1 = cut_location
two.r0 = one.r0
two.g0 = one.g0
two.b0 = one.b1
one.vol = (one.r1 - one.r0) * (one.g1 - one.g0) * (one.b1 - one.b0)
two.vol = (two.r1 - two.r0) * (two.g1 - two.g0) * (two.b1 - two.b0)
return True
def _maximize(
self,
cube: Box,
direction: int,
first: int,
last: int,
whole_r: int,
whole_g: int,
whole_b: int,
whole_w: int,
) -> Tuple[int, float]:
"""Find the optimal cut position along an axis."""
bottom_r = self._bottom(cube, direction, self.moments_r)
bottom_g = self._bottom(cube, direction, self.moments_g)
bottom_b = self._bottom(cube, direction, self.moments_b)
bottom_w = self._bottom(cube, direction, self.weights)
max_val = 0.0
cut = -1
for i in range(first, last):
half_r = bottom_r + self._top(cube, direction, i, self.moments_r)
half_g = bottom_g + self._top(cube, direction, i, self.moments_g)
half_b = bottom_b + self._top(cube, direction, i, self.moments_b)
half_w = bottom_w + self._top(cube, direction, i, self.weights)
if half_w == 0:
continue
temp = (half_r * half_r + half_g * half_g + half_b * half_b) / half_w
half_r = whole_r - half_r
half_g = whole_g - half_g
half_b = whole_b - half_b
half_w = whole_w - half_w
if half_w == 0:
continue
temp += (half_r * half_r + half_g * half_g + half_b * half_b) / half_w
if temp > max_val:
max_val = temp
cut = i
return cut, max_val
def _volume(self, cube: Box, moment: List) -> int:
"""Calculate volume sum using inclusion-exclusion."""
return (
moment[_get_index(cube.r1, cube.g1, cube.b1)]
- moment[_get_index(cube.r1, cube.g1, cube.b0)]
- moment[_get_index(cube.r1, cube.g0, cube.b1)]
+ moment[_get_index(cube.r1, cube.g0, cube.b0)]
- moment[_get_index(cube.r0, cube.g1, cube.b1)]
+ moment[_get_index(cube.r0, cube.g1, cube.b0)]
+ moment[_get_index(cube.r0, cube.g0, cube.b1)]
- moment[_get_index(cube.r0, cube.g0, cube.b0)]
)
def _bottom(self, cube: Box, direction: int, moment: List) -> int:
"""Calculate bottom sum for maximize."""
if direction == DIR_RED:
return (
-moment[_get_index(cube.r0, cube.g1, cube.b1)]
+ moment[_get_index(cube.r0, cube.g1, cube.b0)]
+ moment[_get_index(cube.r0, cube.g0, cube.b1)]
- moment[_get_index(cube.r0, cube.g0, cube.b0)]
)
elif direction == DIR_GREEN:
return (
-moment[_get_index(cube.r1, cube.g0, cube.b1)]
+ moment[_get_index(cube.r1, cube.g0, cube.b0)]
+ moment[_get_index(cube.r0, cube.g0, cube.b1)]
- moment[_get_index(cube.r0, cube.g0, cube.b0)]
)
else: # DIR_BLUE
return (
-moment[_get_index(cube.r1, cube.g1, cube.b0)]
+ moment[_get_index(cube.r1, cube.g0, cube.b0)]
+ moment[_get_index(cube.r0, cube.g1, cube.b0)]
- moment[_get_index(cube.r0, cube.g0, cube.b0)]
)
def _top(self, cube: Box, direction: int, position: int, moment: List) -> int:
"""Calculate top sum for maximize."""
if direction == DIR_RED:
return (
moment[_get_index(position, cube.g1, cube.b1)]
- moment[_get_index(position, cube.g1, cube.b0)]
- moment[_get_index(position, cube.g0, cube.b1)]
+ moment[_get_index(position, cube.g0, cube.b0)]
)
elif direction == DIR_GREEN:
return (
moment[_get_index(cube.r1, position, cube.b1)]
- moment[_get_index(cube.r1, position, cube.b0)]
- moment[_get_index(cube.r0, position, cube.b1)]
+ moment[_get_index(cube.r0, position, cube.b0)]
)
else: # DIR_BLUE
return (
moment[_get_index(cube.r1, cube.g1, position)]
- moment[_get_index(cube.r1, cube.g0, position)]
- moment[_get_index(cube.r0, cube.g1, position)]
+ moment[_get_index(cube.r0, cube.g0, position)]
)
def quantize_wu(pixels: List[Tuple[int, int, int]], max_colors: int = 128) -> Dict[int, int]:
"""
Quantize RGB pixels using Wu algorithm.
Args:
pixels: List of (R, G, B) tuples
max_colors: Maximum colors to extract
Returns:
Dictionary mapping ARGB colors to pixel counts
"""
# Convert RGB tuples to ARGB integers
argb_pixels = [_argb_from_rgb(r, g, b) for r, g, b in pixels]
# Run Wu quantizer
quantizer = QuantizerWu()
result_colors = quantizer.quantize(argb_pixels, max_colors)
# Build color to count mapping in box order (matching Rust's IndexMap insertion order)
# Wu returns colors with count 0; WSMeans uses only the keys as starting clusters
color_to_count: Dict[int, int] = {c: 0 for c in result_colors}
return color_to_count
# =============================================================================
# WSMeans Quantizer - weighted k-means refinement in Lab space
# =============================================================================
# Mask for 48-bit LCG state
_LCG_MASK = (1 << 48) - 1
class _Random:
"""LCG matching Java's java.util.Random / material-color-utilities."""
def __init__(self, seed: int):
self._seed = (seed ^ 0x5DEECE66D) & _LCG_MASK
def _next(self, bits: int) -> int:
self._seed = (self._seed * 0x5DEECE66D + 0xB) & _LCG_MASK
# Unsigned right shift: treat as unsigned 48-bit, shift, return as signed 32-bit
val = self._seed >> (48 - bits)
# Convert to signed 32-bit int to match Java behavior
if val >= (1 << 31):
val -= (1 << 32)
return val
def next_range(self, range_val: int) -> int:
if (range_val & -range_val) == range_val:
# Power of 2
return (range_val * self._next(31)) >> 31
while True:
bits = self._next(31)
val = bits % range_val
if bits - val + (range_val - 1) >= 0:
return val
def _lab_distance_squared(a: Tuple[float, float, float], b: Tuple[float, float, float]) -> float:
"""Squared Euclidean distance in Lab space (no sqrt)."""
dL = a[0] - b[0]
da = a[1] - b[1]
db = a[2] - b[2]
return dL * dL + da * da + db * db
def quantize_wsmeans(
pixels: List[Tuple[int, int, int]],
max_colors: int,
starting_clusters: List[int],
) -> Dict[int, int]:
"""
Refine quantized colors via weighted k-means in Lab space.
Port of QuantizerWsmeans from material-colors-0.4.2 Rust crate.
Args:
pixels: List of (R, G, B) tuples (original image pixels)
max_colors: Maximum number of colors
starting_clusters: List of ARGB colors from Wu quantizer
Returns:
Dictionary mapping ARGB colors to pixel counts
"""
# Deduplicate pixels, build count map and Lab points
pixel_to_count: Dict[int, int] = {}
unique_pixels: List[int] = [] # ARGB values in insertion order
points: List[Tuple[float, float, float]] = [] # Lab coordinates
for r, g, b in pixels:
argb = _argb_from_rgb(r, g, b)
if argb in pixel_to_count:
pixel_to_count[argb] += 1
else:
unique_pixels.append(argb)
points.append(rgb_to_lab(r, g, b))
pixel_to_count[argb] = 1
cluster_count = min(max_colors, len(points))
if cluster_count == 0:
return {}
# Convert starting clusters from ARGB to Lab
clusters: List[Tuple[float, float, float]] = []
for argb in starting_clusters:
cr, cg, cb = _rgb_from_argb(argb)
clusters.append(rgb_to_lab(cr, cg, cb))
# Fill remaining clusters with actual image pixels using seeded LCG
additional_needed = cluster_count - len(clusters)
if additional_needed > 0:
rng = _Random(0x42688)
indices: List[int] = []
for _ in range(additional_needed):
index = rng.next_range(len(points))
while index in indices:
index = rng.next_range(len(points))
indices.append(index)
for index in indices:
clusters.append(points[index])
# Initialize assignments
cluster_indices = [i % cluster_count for i in range(len(points))]
# Distance matrix and sorted index matrix
distance_to_index_matrix: List[List[List]] = [
[[0.0, j] for j in range(cluster_count)]
for _ in range(cluster_count)
]
pixel_count_sums = [0] * cluster_count
for iteration in range(10):
points_moved = 0
# Compute inter-cluster distance matrix
for i in range(cluster_count):
for j in range(i + 1, cluster_count):
dist = _lab_distance_squared(clusters[i], clusters[j])
distance_to_index_matrix[j][i][0] = dist
distance_to_index_matrix[j][i][1] = i
distance_to_index_matrix[i][j][0] = dist
distance_to_index_matrix[i][j][1] = j
# Sort row by distance
distance_to_index_matrix[i].sort(key=lambda x: x[0])
# Assignment step: find nearest cluster for each point
for i in range(len(points)):
point = points[i]
prev_idx = cluster_indices[i]
prev_dist = _lab_distance_squared(point, clusters[prev_idx])
min_dist = prev_dist
new_idx = -1
for j in range(cluster_count):
# Triangle inequality: skip if inter-cluster dist >= 4 * current dist
if distance_to_index_matrix[prev_idx][j][0] >= 4.0 * prev_dist:
continue
dist = _lab_distance_squared(point, clusters[j])
if dist < min_dist:
min_dist = dist
new_idx = j
if new_idx != -1:
points_moved += 1
cluster_indices[i] = new_idx
# Early stop
if points_moved == 0 and iteration > 0:
break
# Update step: compute new centroids as weighted mean in Lab space
component_l = [0.0] * cluster_count
component_a = [0.0] * cluster_count
component_b = [0.0] * cluster_count
for k in range(cluster_count):
pixel_count_sums[k] = 0
for i in range(len(points)):
cidx = cluster_indices[i]
pt = points[i]
count = pixel_to_count[unique_pixels[i]]
pixel_count_sums[cidx] += count
component_l[cidx] += pt[0] * count
component_a[cidx] += pt[1] * count
component_b[cidx] += pt[2] * count
for i in range(cluster_count):
count = pixel_count_sums[i]
if count == 0:
clusters[i] = (0.0, 0.0, 0.0)
else:
clusters[i] = (
component_l[i] / count,
component_a[i] / count,
component_b[i] / count,
)
# Build result: convert cluster centroids from Lab to ARGB with populations
cluster_argbs: List[int] = []
cluster_populations: List[int] = []
for i in range(cluster_count):
count = pixel_count_sums[i]
if count == 0:
continue
lab = clusters[i]
cr, cg, cb = lab_to_rgb(lab[0], lab[1], lab[2])
argb = _argb_from_rgb(cr, cg, cb)
if argb in cluster_argbs:
continue
cluster_argbs.append(argb)
cluster_populations.append(count)
color_to_count: Dict[int, int] = {}
for i in range(len(cluster_argbs)):
color_to_count[cluster_argbs[i]] = cluster_populations[i]
return color_to_count
# =============================================================================
# Score Algorithm - ranks colors for UI theme suitability
# =============================================================================
# Score constants matching material-color-utilities
TARGET_CHROMA = 48.0
WEIGHT_PROPORTION = 0.7
WEIGHT_CHROMA_ABOVE = 0.3
WEIGHT_CHROMA_BELOW = 0.1
CUTOFF_CHROMA = 5.0
CUTOFF_EXCITED_PROPORTION = 0.01
FALLBACK_COLOR_ARGB = 0xFF4285F4 # Google Blue
def _sanitize_degrees(degrees: float) -> int:
"""Sanitize degrees to 0-359 range."""
return int(degrees) % 360
def _difference_degrees(a: float, b: float) -> float:
"""Calculate the shortest distance between two angles."""
diff = abs(a - b)
return min(diff, 360.0 - diff)
def score_colors(
color_to_population: Dict[int, int],
desired: int = 4,
fallback_color: int = FALLBACK_COLOR_ARGB,
filter_colors: bool = True,
) -> List[int]:
"""
Rank colors based on suitability for UI themes.
Given a map of colors to population counts, removes unsuitable colors
and ranks the rest based on chroma and proportion.
Args:
color_to_population: Dict mapping ARGB colors to pixel counts
desired: Maximum number of colors to return
fallback_color: Color to return if no suitable colors found
filter_colors: Whether to filter out low-chroma/low-proportion colors
Returns:
List of ARGB colors sorted by suitability (best first)
"""
# Import here to avoid circular dependency
from .hct import Cam16, Hct
# Build HCT colors and hue population histogram
colors_hct: List[Tuple[int, Hct]] = []
hue_population = [0] * 360
population_sum = 0
for argb, population in color_to_population.items():
r = (argb >> 16) & 0xFF
g = (argb >> 8) & 0xFF
b = argb & 0xFF
try:
hct = Hct.from_rgb(r, g, b)
colors_hct.append((argb, hct))
hue = _sanitize_degrees(hct.hue)
hue_population[hue] += population
population_sum += population
except (ValueError, ZeroDivisionError):
continue
if not colors_hct or population_sum == 0:
return [fallback_color]
# Calculate "excited proportions" - sum of proportions in ±15° hue window
hue_excited_proportions = [0.0] * 360
for hue in range(360):
proportion = hue_population[hue] / population_sum
for offset in range(-14, 16):
neighbor_hue = _sanitize_degrees(hue + offset)
hue_excited_proportions[neighbor_hue] += proportion
# Score each color
scored_hct: List[Tuple[int, Hct, float]] = []
for argb, hct in colors_hct:
hue = _sanitize_degrees(round(hct.hue))
proportion = hue_excited_proportions[hue]
# Filter by chroma and proportion
if filter_colors:
if hct.chroma < CUTOFF_CHROMA:
continue
if proportion <= CUTOFF_EXCITED_PROPORTION:
continue
# Proportion score (70% weight)
proportion_score = proportion * 100.0 * WEIGHT_PROPORTION
# Chroma score
if hct.chroma < TARGET_CHROMA:
chroma_weight = WEIGHT_CHROMA_BELOW
else:
chroma_weight = WEIGHT_CHROMA_ABOVE
chroma_score = (hct.chroma - TARGET_CHROMA) * chroma_weight
score = proportion_score + chroma_score
scored_hct.append((argb, hct, score))
if not scored_hct:
return [fallback_color]
# Sort by score descending
scored_hct.sort(key=lambda x: -x[2])
# Deduplicate by hue distance - maximize hue diversity
# Start at 90° (max for 4 colors), decrease to 15° minimum
chosen_colors: List[Tuple[int, Hct]] = []
for diff_degrees in range(90, 14, -1):
chosen_colors.clear()
for argb, hct, score in scored_hct:
# Check if this hue is far enough from all chosen colors
is_duplicate = False
for chosen_argb, chosen_hct in chosen_colors:
if _difference_degrees(hct.hue, chosen_hct.hue) < diff_degrees:
is_duplicate = True
break
if not is_duplicate:
chosen_colors.append((argb, hct))
if len(chosen_colors) >= desired:
break
if len(chosen_colors) >= desired:
break
if not chosen_colors:
return [fallback_color]
return [argb for argb, hct in chosen_colors]
def extract_source_color(
pixels: List[Tuple[int, int, int]],
fallback_color: int = FALLBACK_COLOR_ARGB,
) -> int:
"""
Extract the primary source color from image pixels.
Uses Wu + WSMeans quantizer (QuantizerCelebi) + Score algorithm matching
matugen/material-color-utilities.
Args:
pixels: List of (R, G, B) tuples
fallback_color: Color to return if extraction fails
Returns:
Source color in ARGB format
"""
from .hct import Cam16
if not pixels:
return fallback_color
# Quantize using Wu + WSMeans (QuantizerCelebi pipeline like matugen)
wu_result = quantize_wu(pixels, max_colors=128)
starting_clusters = list(wu_result.keys())
color_to_count = quantize_wsmeans(pixels, 128, starting_clusters)
# Filter out low-chroma colors before scoring (like matugen)
filtered = {}
for argb, count in color_to_count.items():
r = (argb >> 16) & 0xFF
g = (argb >> 8) & 0xFF
b = argb & 0xFF
try:
cam = Cam16.from_rgb(r, g, b)
if cam.chroma >= 5.0:
filtered[argb] = count
except (ValueError, ZeroDivisionError):
continue
if not filtered:
filtered = color_to_count
# Score and rank colors
ranked = score_colors(filtered, desired=4, fallback_color=fallback_color)
return ranked[0] if ranked else fallback_color
def source_color_to_rgb(argb: int) -> Tuple[int, int, int]:
"""Convert ARGB integer to RGB tuple."""
return _rgb_from_argb(argb)
@@ -0,0 +1,351 @@
"""
Predefined scheme expansion - Convert 14-color schemes to full palette.
This module expands predefined color schemes (like Tokyo-Night) from their
14 core colors to the full 48-color palette used by templates.
Input format (14 colors):
mPrimary, mOnPrimary, mSecondary, mOnSecondary, mTertiary, mOnTertiary,
mError, mOnError, mSurface, mOnSurface, mSurfaceVariant, mOnSurfaceVariant,
mOutline, mHover
Output: Full 48-color palette matching generate_theme() output.
"""
from typing import Literal
from .color import Color
from .contrast import ensure_contrast
ThemeMode = Literal["dark", "light"]
def _hex_to_color(hex_str: str) -> Color:
"""Convert hex string to Color object."""
hex_str = hex_str.lstrip("#")
r = int(hex_str[0:2], 16)
g = int(hex_str[2:4], 16)
b = int(hex_str[4:6], 16)
return Color(r, g, b)
def _make_container_dark(base: Color) -> Color:
"""Generate container color for dark mode."""
h, s, l = base.to_hsl()
return Color.from_hsl(h, min(s + 0.15, 1.0), max(l - 0.35, 0.15))
def _make_container_light(base: Color) -> Color:
"""Generate container color for light mode."""
h, s, l = base.to_hsl()
return Color.from_hsl(h, max(s - 0.20, 0.30), min(l + 0.35, 0.85))
def _make_fixed_dark(base: Color) -> tuple[Color, Color]:
"""Generate fixed and fixed_dim colors for dark mode."""
h, s, _ = base.to_hsl()
fixed = Color.from_hsl(h, max(s, 0.70), 0.85)
fixed_dim = Color.from_hsl(h, max(s, 0.65), 0.75)
return fixed, fixed_dim
def _make_fixed_light(base: Color) -> tuple[Color, Color]:
"""Generate fixed and fixed_dim colors for light mode."""
h, s, _ = base.to_hsl()
fixed = Color.from_hsl(h, max(s, 0.70), 0.40)
fixed_dim = Color.from_hsl(h, max(s, 0.65), 0.30)
return fixed, fixed_dim
def _interpolate_color(c1: Color, c2: Color, t: float) -> Color:
"""Interpolate between two colors. t=0 returns c1, t=1 returns c2."""
r = int(c1.r + (c2.r - c1.r) * t)
g = int(c1.g + (c2.g - c1.g) * t)
b = int(c1.b + (c2.b - c1.b) * t)
return Color(max(0, min(255, r)), max(0, min(255, g)), max(0, min(255, b)))
def expand_predefined_scheme(scheme_data: dict[str, str], mode: ThemeMode) -> dict[str, str]:
"""
Expand 14-color predefined scheme to full 48-color palette.
Args:
scheme_data: Dictionary with keys like mPrimary, mSecondary, etc.
mode: "dark" or "light"
Returns:
Dictionary with all 48 color names mapped to hex values.
"""
is_dark = mode == "dark"
# Parse input colors
primary = _hex_to_color(scheme_data["mPrimary"])
on_primary = _hex_to_color(scheme_data["mOnPrimary"])
secondary = _hex_to_color(scheme_data["mSecondary"])
on_secondary = _hex_to_color(scheme_data["mOnSecondary"])
tertiary = _hex_to_color(scheme_data["mTertiary"])
on_tertiary = _hex_to_color(scheme_data["mOnTertiary"])
error = _hex_to_color(scheme_data["mError"])
on_error = _hex_to_color(scheme_data["mOnError"])
surface = _hex_to_color(scheme_data["mSurface"])
on_surface = _hex_to_color(scheme_data["mOnSurface"])
surface_variant = _hex_to_color(scheme_data["mSurfaceVariant"])
on_surface_variant = _hex_to_color(scheme_data["mOnSurfaceVariant"])
outline_raw = _hex_to_color(scheme_data["mOutline"])
shadow = _hex_to_color(scheme_data.get("mShadow", scheme_data["mSurface"]))
# Generate container colors
if is_dark:
primary_container = _make_container_dark(primary)
secondary_container = _make_container_dark(secondary)
tertiary_container = _make_container_dark(tertiary)
error_container = _make_container_dark(error)
else:
primary_container = _make_container_light(primary)
secondary_container = _make_container_light(secondary)
tertiary_container = _make_container_light(tertiary)
error_container = _make_container_light(error)
# Generate "on container" colors with proper contrast
primary_h, primary_s, _ = primary.to_hsl()
secondary_h, secondary_s, _ = secondary.to_hsl()
tertiary_h, tertiary_s, _ = tertiary.to_hsl()
error_h, error_s, _ = error.to_hsl()
if is_dark:
# Light text on dark containers
on_primary_container = ensure_contrast(
Color.from_hsl(primary_h, primary_s, 0.90), primary_container, 4.5
)
on_secondary_container = ensure_contrast(
Color.from_hsl(secondary_h, secondary_s, 0.90), secondary_container, 4.5
)
on_tertiary_container = ensure_contrast(
Color.from_hsl(tertiary_h, tertiary_s, 0.90), tertiary_container, 4.5
)
on_error_container = ensure_contrast(
Color.from_hsl(error_h, error_s, 0.90), error_container, 4.5
)
else:
# Dark text on light containers
on_primary_container = ensure_contrast(
Color.from_hsl(primary_h, primary_s, 0.15), primary_container, 4.5
)
on_secondary_container = ensure_contrast(
Color.from_hsl(secondary_h, secondary_s, 0.15), secondary_container, 4.5
)
on_tertiary_container = ensure_contrast(
Color.from_hsl(tertiary_h, tertiary_s, 0.15), tertiary_container, 4.5
)
on_error_container = ensure_contrast(
Color.from_hsl(error_h, error_s, 0.15), error_container, 4.5
)
# Generate fixed colors
if is_dark:
primary_fixed, primary_fixed_dim = _make_fixed_dark(primary)
secondary_fixed, secondary_fixed_dim = _make_fixed_dark(secondary)
tertiary_fixed, tertiary_fixed_dim = _make_fixed_dark(tertiary)
else:
primary_fixed, primary_fixed_dim = _make_fixed_light(primary)
secondary_fixed, secondary_fixed_dim = _make_fixed_light(secondary)
tertiary_fixed, tertiary_fixed_dim = _make_fixed_light(tertiary)
# Generate "on fixed" colors
if is_dark:
on_primary_fixed = ensure_contrast(
Color.from_hsl(primary_h, 0.15, 0.15), primary_fixed, 4.5
)
on_primary_fixed_variant = ensure_contrast(
Color.from_hsl(primary_h, 0.15, 0.20), primary_fixed_dim, 4.5
)
on_secondary_fixed = ensure_contrast(
Color.from_hsl(secondary_h, 0.15, 0.15), secondary_fixed, 4.5
)
on_secondary_fixed_variant = ensure_contrast(
Color.from_hsl(secondary_h, 0.15, 0.20), secondary_fixed_dim, 4.5
)
on_tertiary_fixed = ensure_contrast(
Color.from_hsl(tertiary_h, 0.15, 0.15), tertiary_fixed, 4.5
)
on_tertiary_fixed_variant = ensure_contrast(
Color.from_hsl(tertiary_h, 0.15, 0.20), tertiary_fixed_dim, 4.5
)
else:
on_primary_fixed = ensure_contrast(
Color.from_hsl(primary_h, 0.15, 0.90), primary_fixed, 4.5
)
on_primary_fixed_variant = ensure_contrast(
Color.from_hsl(primary_h, 0.15, 0.85), primary_fixed_dim, 4.5
)
on_secondary_fixed = ensure_contrast(
Color.from_hsl(secondary_h, 0.15, 0.90), secondary_fixed, 4.5
)
on_secondary_fixed_variant = ensure_contrast(
Color.from_hsl(secondary_h, 0.15, 0.85), secondary_fixed_dim, 4.5
)
on_tertiary_fixed = ensure_contrast(
Color.from_hsl(tertiary_h, 0.15, 0.90), tertiary_fixed, 4.5
)
on_tertiary_fixed_variant = ensure_contrast(
Color.from_hsl(tertiary_h, 0.15, 0.85), tertiary_fixed_dim, 4.5
)
# Generate surface containers using mSurfaceVariant as the middle container
# This respects the scheme author's color choices
surface_h, surface_s, surface_l = surface.to_hsl()
sv_h, sv_s, sv_l = surface_variant.to_hsl()
# surface_container = mSurfaceVariant (direct assignment)
surface_container = surface_variant
if is_dark:
# Dark mode: surface is darkest, surface_variant is the middle container
# Lower containers interpolate between surface and surface_variant
surface_container_lowest = _interpolate_color(surface, surface_variant, 0.2)
surface_container_low = _interpolate_color(surface, surface_variant, 0.5)
# Higher containers go beyond surface_variant (lighter)
surface_container_high = Color.from_hsl(sv_h, sv_s, min(sv_l + 0.04, 0.40))
surface_container_highest = Color.from_hsl(sv_h, sv_s, min(sv_l + 0.08, 0.45))
# Dim is darker than surface, bright is lighter than highest container
surface_dim = Color.from_hsl(surface_h, surface_s, max(surface_l - 0.04, 0.02))
surface_bright = Color.from_hsl(sv_h, sv_s, min(sv_l + 0.12, 0.50))
else:
# Light mode: surface is lightest, surface_variant is the middle container
# Lower containers interpolate between surface and surface_variant
surface_container_lowest = _interpolate_color(surface, surface_variant, 0.2)
surface_container_low = _interpolate_color(surface, surface_variant, 0.5)
# Higher containers go beyond surface_variant (darker)
surface_container_high = Color.from_hsl(sv_h, sv_s, max(sv_l - 0.04, 0.60))
surface_container_highest = Color.from_hsl(sv_h, sv_s, max(sv_l - 0.08, 0.55))
# Dim is darker than highest, bright is lighter than surface
surface_dim = Color.from_hsl(sv_h, sv_s, max(sv_l - 0.12, 0.50))
surface_bright = Color.from_hsl(surface_h, surface_s, min(surface_l + 0.03, 0.98))
# Ensure outline has sufficient contrast against surface (3:1 minimum for UI)
outline = ensure_contrast(outline_raw, surface, 3.0)
# Generate outline variant
outline_h, outline_s, outline_l = outline.to_hsl()
if is_dark:
outline_variant = Color.from_hsl(outline_h, outline_s, max(outline_l - 0.15, 0.1))
else:
outline_variant = Color.from_hsl(outline_h, outline_s, min(outline_l + 0.15, 0.9))
# Scrim is always black
scrim = Color(0, 0, 0)
# Inverse colors
if is_dark:
inverse_surface = Color.from_hsl(surface_h, 0.08, 0.90)
inverse_on_surface = Color.from_hsl(surface_h, 0.05, 0.15)
inverse_primary = Color.from_hsl(primary_h, max(primary_s * 0.8, 0.5), 0.40)
else:
inverse_surface = Color.from_hsl(surface_h, 0.08, 0.15)
inverse_on_surface = Color.from_hsl(surface_h, 0.05, 0.90)
inverse_primary = Color.from_hsl(primary_h, max(primary_s * 0.8, 0.5), 0.70)
# Background is same as surface in MD3
background = surface
on_background = on_surface
return {
# Primary
"primary": primary.to_hex(),
"on_primary": on_primary.to_hex(),
"primary_container": primary_container.to_hex(),
"on_primary_container": on_primary_container.to_hex(),
"primary_fixed": primary_fixed.to_hex(),
"primary_fixed_dim": primary_fixed_dim.to_hex(),
"on_primary_fixed": on_primary_fixed.to_hex(),
"on_primary_fixed_variant": on_primary_fixed_variant.to_hex(),
# Secondary
"secondary": secondary.to_hex(),
"on_secondary": on_secondary.to_hex(),
"secondary_container": secondary_container.to_hex(),
"on_secondary_container": on_secondary_container.to_hex(),
"secondary_fixed": secondary_fixed.to_hex(),
"secondary_fixed_dim": secondary_fixed_dim.to_hex(),
"on_secondary_fixed": on_secondary_fixed.to_hex(),
"on_secondary_fixed_variant": on_secondary_fixed_variant.to_hex(),
# Tertiary
"tertiary": tertiary.to_hex(),
"on_tertiary": on_tertiary.to_hex(),
"tertiary_container": tertiary_container.to_hex(),
"on_tertiary_container": on_tertiary_container.to_hex(),
"tertiary_fixed": tertiary_fixed.to_hex(),
"tertiary_fixed_dim": tertiary_fixed_dim.to_hex(),
"on_tertiary_fixed": on_tertiary_fixed.to_hex(),
"on_tertiary_fixed_variant": on_tertiary_fixed_variant.to_hex(),
# Error
"error": error.to_hex(),
"on_error": on_error.to_hex(),
"error_container": error_container.to_hex(),
"on_error_container": on_error_container.to_hex(),
# Surface
"surface": surface.to_hex(),
"on_surface": on_surface.to_hex(),
"surface_variant": surface_variant.to_hex(),
"on_surface_variant": on_surface_variant.to_hex(),
"surface_dim": surface_dim.to_hex(),
"surface_bright": surface_bright.to_hex(),
# Surface containers
"surface_container_lowest": surface_container_lowest.to_hex(),
"surface_container_low": surface_container_low.to_hex(),
"surface_container": surface_container.to_hex(),
"surface_container_high": surface_container_high.to_hex(),
"surface_container_highest": surface_container_highest.to_hex(),
# Outline and other
"outline": outline.to_hex(),
"outline_variant": outline_variant.to_hex(),
"shadow": shadow.to_hex(),
"scrim": scrim.to_hex(),
# Inverse
"inverse_surface": inverse_surface.to_hex(),
"inverse_on_surface": inverse_on_surface.to_hex(),
"inverse_primary": inverse_primary.to_hex(),
# Background
"background": background.to_hex(),
"on_background": on_background.to_hex(),
}
def inject_terminal_colors(result: dict[str, str], scheme_mode_data: dict) -> dict[str, str]:
"""Flatten scheme's terminal section into template-ready color keys.
Adds keys like terminal_foreground, terminal_normal_black, terminal_bright_red, etc.
so predefined terminal templates can reference them as
{{colors.terminal_foreground.default.hex_stripped}}.
Args:
result: Expanded color palette dict to augment.
scheme_mode_data: Raw scheme JSON mode data (e.g., scheme_data["dark"]).
Returns:
The same result dict with terminal_ keys added.
"""
terminal = scheme_mode_data.get("terminal")
if not terminal:
return result
# Map of JSON keys to flattened key names
direct_keys = {
"foreground": "terminal_foreground",
"background": "terminal_background",
"cursor": "terminal_cursor",
"cursorText": "terminal_cursor_text",
"selectionFg": "terminal_selection_fg",
"selectionBg": "terminal_selection_bg",
}
for json_key, flat_key in direct_keys.items():
if json_key in terminal:
result[flat_key] = terminal[json_key]
# ANSI normal/bright color groups
for group in ("normal", "bright"):
if group in terminal:
for name, hex_val in terminal[group].items():
result[f"terminal_{group}_{name}"] = hex_val
return result
@@ -0,0 +1,878 @@
"""
Theme generation functions for Material and Normal modes.
This module provides functions for generating complete color themes
from a color palette, supporting both Material Design 3 and a more
vibrant "wallust-style" theme.
Supported scheme types:
- tonal-spot: Default Android 12-13 scheme (recommended)
- fruit-salad: Bold/playful with hue rotation
- rainbow: Chromatic accents with grayscale neutrals
- monochrome: Pure grayscale M3 scheme (chroma = 0)
- vibrant: Prioritizes the most saturated colors regardless of area
- faithful: Prioritizes dominant colors by area coverage
- muted: Preserves hue but caps saturation low (for monochrome wallpapers)
"""
from typing import Literal
from .color import Color, shift_hue, hue_distance, adjust_surface
from .contrast import ensure_contrast
from .material import SchemeTonalSpot, SchemeFruitSalad, SchemeRainbow, SchemeContent, SchemeMonochrome
from .palette import find_error_color
# Type aliases
ThemeMode = Literal["dark", "light"]
SchemeType = Literal["tonal-spot", "fruit-salad", "rainbow", "content", "monochrome", "vibrant", "faithful", "muted"]
# Map scheme type strings to classes
SCHEME_CLASSES = {
"tonal-spot": SchemeTonalSpot,
"fruit-salad": SchemeFruitSalad,
"rainbow": SchemeRainbow,
"content": SchemeContent,
"monochrome": SchemeMonochrome,
# "vibrant", "faithful", and "muted" use generate_*_* functions, not a scheme class
}
def generate_material_dark(palette: list[Color], scheme_type: str = "tonal-spot") -> dict[str, str]:
"""
Generate Material Design 3 dark theme from palette using HCT color space.
Args:
palette: List of extracted colors (primary color is index 0)
scheme_type: One of "tonal-spot", "fruit-salad", "rainbow"
Returns:
Dictionary of color token names to hex values
"""
primary = palette[0] if palette else Color(255, 245, 155)
# Get the appropriate scheme class
scheme_class = SCHEME_CLASSES.get(scheme_type, SchemeTonalSpot)
scheme = scheme_class.from_rgb(primary.r, primary.g, primary.b)
return scheme.get_dark_scheme()
def generate_material_light(palette: list[Color], scheme_type: str = "tonal-spot") -> dict[str, str]:
"""
Generate Material Design 3 light theme from palette using HCT color space.
Args:
palette: List of extracted colors (primary color is index 0)
scheme_type: One of "tonal-spot", "fruit-salad", "rainbow"
Returns:
Dictionary of color token names to hex values
"""
primary = palette[0] if palette else Color(93, 101, 245)
# Get the appropriate scheme class
scheme_class = SCHEME_CLASSES.get(scheme_type, SchemeTonalSpot)
scheme = scheme_class.from_rgb(primary.r, primary.g, primary.b)
return scheme.get_light_scheme()
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)
# But check if colors are distinct enough - if not, derive from primary
primary = palette[0] if palette else Color(255, 245, 155)
primary_h, primary_s, primary_l = primary.to_hsl()
# Secondary: use palette[1] only if hue is >30° different, otherwise derive
MIN_HUE_DISTANCE = 30
if len(palette) > 1:
sec_h, _, _ = palette[1].to_hsl()
if hue_distance(primary_h, sec_h) > MIN_HUE_DISTANCE:
secondary = palette[1]
else:
# Colors too similar - shift hue by 30° to stay in same color family
secondary = shift_hue(primary, 30)
else:
secondary = shift_hue(primary, 30)
# Tertiary: use palette[2] only if hue is >30° different from both primary and secondary
if len(palette) > 2:
ter_h, _, _ = palette[2].to_hsl()
sec_h, _, _ = secondary.to_hsl()
if hue_distance(primary_h, ter_h) > MIN_HUE_DISTANCE and hue_distance(sec_h, ter_h) > MIN_HUE_DISTANCE:
tertiary = palette[2]
else:
# Colors too similar - shift hue by 60° from primary to stay closer to original
tertiary = shift_hue(primary, 60)
else:
tertiary = shift_hue(primary, 60)
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))
# Container colors - darker, more saturated versions of accent colors
def make_container_dark(base: Color) -> Color:
h, s, l = base.to_hsl()
return Color.from_hsl(h, min(s + 0.15, 1.0), max(l - 0.35, 0.15))
primary_container = make_container_dark(primary_adjusted)
secondary_container = make_container_dark(secondary_adjusted)
tertiary_container = make_container_dark(tertiary_adjusted)
error_container = make_container_dark(error)
# 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
# Reduce saturation for warm hues (red/orange/yellow) - they feel overwhelming as surfaces
# Warm hues: 0-60 and 300-360
if surface_hue < 60 or surface_hue > 300:
surface_saturation_cap = 0.35 # More desaturated for warm colors
elif 60 <= surface_hue < 120:
surface_saturation_cap = 0.50 # Moderate for yellow-greens
else:
surface_saturation_cap = 0.90 # Keep cool colors vibrant
base_surface = Color.from_hsl(surface_hue, min(s, surface_saturation_cap), 0.5)
# Preserving saturation (up to the cap) to be true to primary color
surface = adjust_surface(base_surface, surface_saturation_cap, 0.12)
surface_variant = adjust_surface(base_surface, min(0.80, surface_saturation_cap), 0.16)
# Surface containers - progressive lightness for visual hierarchy (keep primary hue)
surface_container_lowest = adjust_surface(base_surface, 0.85, 0.06)
surface_container_low = adjust_surface(base_surface, 0.85, 0.10)
surface_container = adjust_surface(base_surface, 0.70, 0.20)
surface_container_high = adjust_surface(base_surface, 0.75, 0.18)
surface_container_highest = adjust_surface(base_surface, 0.70, 0.22)
# Text colors - desaturated
text_h, _, _ = palette[0].to_hsl()
base_on_surface = Color.from_hsl(text_h, 0.05, 0.95)
on_surface = ensure_contrast(base_on_surface, surface, 4.5)
base_on_surface_variant = Color.from_hsl(text_h, 0.05, 0.70)
on_surface_variant = ensure_contrast(base_on_surface_variant, surface_variant, 4.5)
outline = ensure_contrast(adjust_surface(palette[0], 0.10, 0.30), surface, 3.0)
outline_variant = ensure_contrast(adjust_surface(palette[0], 0.10, 0.40), surface, 3.0)
# Contrasting foregrounds - dark text on bright accent colors
dark_fg = Color.from_hsl(palette[0].to_hsl()[0], 0.20, 0.12) # Darker for better contrast
on_primary = ensure_contrast(dark_fg, primary_adjusted, 7.0) # Higher contrast target
on_secondary = ensure_contrast(dark_fg, secondary_adjusted, 7.0)
on_tertiary = ensure_contrast(dark_fg, tertiary_adjusted, 7.0)
on_error = ensure_contrast(dark_fg, error, 7.0)
# "On" colors for containers - light text on dark containers, tinted with respective color
# Explicitly prefer_light=True since containers in dark mode are dark
on_primary_container = ensure_contrast(Color.from_hsl(primary_h, primary_s, 0.90), primary_container, 4.5, prefer_light=True)
sec_h, sec_s, _ = secondary.to_hsl()
on_secondary_container = ensure_contrast(Color.from_hsl(sec_h, sec_s, 0.90), secondary_container, 4.5, prefer_light=True)
ter_h, ter_s, _ = tertiary.to_hsl()
on_tertiary_container = ensure_contrast(Color.from_hsl(ter_h, ter_s, 0.90), tertiary_container, 4.5, prefer_light=True)
err_h, err_s, _ = error.to_hsl()
on_error_container = ensure_contrast(Color.from_hsl(err_h, err_s, 0.90), error_container, 4.5, prefer_light=True)
# Shadow and scrim
shadow = surface
scrim = Color(0, 0, 0) # Pure black
# Inverse colors - for inverted surfaces (light surface on dark theme)
inv_h = palette[0].to_hsl()[0]
inverse_surface = Color.from_hsl(inv_h, 0.08, 0.90)
inverse_on_surface = Color.from_hsl(inv_h, 0.05, 0.15)
inverse_primary = Color.from_hsl(primary_h, max(primary_s * 0.8, 0.5), 0.40)
# Background aliases (same as surface in MD3)
background = surface
on_background = on_surface
# Fixed colors - high-chroma accents consistent across light/dark
# In dark mode: lighter versions of accent colors
def make_fixed_dark(base: Color) -> tuple[Color, Color]:
h, s, _ = base.to_hsl()
fixed = Color.from_hsl(h, max(s, 0.70), 0.85) # Light, saturated
fixed_dim = Color.from_hsl(h, max(s, 0.65), 0.75) # Slightly darker
return fixed, fixed_dim
primary_fixed, primary_fixed_dim = make_fixed_dark(primary_adjusted)
secondary_fixed, secondary_fixed_dim = make_fixed_dark(secondary_adjusted)
tertiary_fixed, tertiary_fixed_dim = make_fixed_dark(tertiary_adjusted)
# "On" colors for fixed - dark text on light fixed colors
on_primary_fixed = ensure_contrast(Color.from_hsl(primary_h, 0.15, 0.15), primary_fixed, 4.5)
on_primary_fixed_variant = ensure_contrast(Color.from_hsl(primary_h, 0.15, 0.20), primary_fixed_dim, 4.5)
on_secondary_fixed = ensure_contrast(Color.from_hsl(secondary.to_hsl()[0], 0.15, 0.15), secondary_fixed, 4.5)
on_secondary_fixed_variant = ensure_contrast(Color.from_hsl(secondary.to_hsl()[0], 0.15, 0.20), secondary_fixed_dim, 4.5)
on_tertiary_fixed = ensure_contrast(Color.from_hsl(tertiary.to_hsl()[0], 0.15, 0.15), tertiary_fixed, 4.5)
on_tertiary_fixed_variant = ensure_contrast(Color.from_hsl(tertiary.to_hsl()[0], 0.15, 0.20), tertiary_fixed_dim, 4.5)
# Surface dim - darker than surface for dimmed areas
surface_dim = adjust_surface(base_surface, 0.85, 0.08)
# Surface bright - lighter than surface
surface_bright = adjust_surface(base_surface, 0.75, 0.24)
return {
# Primary
"primary": primary_adjusted.to_hex(),
"on_primary": on_primary.to_hex(),
"primary_container": primary_container.to_hex(),
"on_primary_container": on_primary_container.to_hex(),
"primary_fixed": primary_fixed.to_hex(),
"primary_fixed_dim": primary_fixed_dim.to_hex(),
"on_primary_fixed": on_primary_fixed.to_hex(),
"on_primary_fixed_variant": on_primary_fixed_variant.to_hex(),
"surface_tint": primary_adjusted.to_hex(),
# Secondary
"secondary": secondary_adjusted.to_hex(),
"on_secondary": on_secondary.to_hex(),
"secondary_container": secondary_container.to_hex(),
"on_secondary_container": on_secondary_container.to_hex(),
"secondary_fixed": secondary_fixed.to_hex(),
"secondary_fixed_dim": secondary_fixed_dim.to_hex(),
"on_secondary_fixed": on_secondary_fixed.to_hex(),
"on_secondary_fixed_variant": on_secondary_fixed_variant.to_hex(),
# Tertiary
"tertiary": tertiary_adjusted.to_hex(),
"on_tertiary": on_tertiary.to_hex(),
"tertiary_container": tertiary_container.to_hex(),
"on_tertiary_container": on_tertiary_container.to_hex(),
"tertiary_fixed": tertiary_fixed.to_hex(),
"tertiary_fixed_dim": tertiary_fixed_dim.to_hex(),
"on_tertiary_fixed": on_tertiary_fixed.to_hex(),
"on_tertiary_fixed_variant": on_tertiary_fixed_variant.to_hex(),
# Error
"error": error.to_hex(),
"on_error": on_error.to_hex(),
"error_container": error_container.to_hex(),
"on_error_container": on_error_container.to_hex(),
# Surface
"surface": surface.to_hex(),
"on_surface": on_surface.to_hex(),
"surface_variant": surface_variant.to_hex(),
"on_surface_variant": on_surface_variant.to_hex(),
"surface_dim": surface_dim.to_hex(),
"surface_bright": surface_bright.to_hex(),
# Surface containers
"surface_container_lowest": surface_container_lowest.to_hex(),
"surface_container_low": surface_container_low.to_hex(),
"surface_container": surface_container.to_hex(),
"surface_container_high": surface_container_high.to_hex(),
"surface_container_highest": surface_container_highest.to_hex(),
# Outline and other
"outline": outline.to_hex(),
"outline_variant": outline_variant.to_hex(),
"shadow": shadow.to_hex(),
"scrim": scrim.to_hex(),
# Inverse
"inverse_surface": inverse_surface.to_hex(),
"inverse_on_surface": inverse_on_surface.to_hex(),
"inverse_primary": inverse_primary.to_hex(),
# Background
"background": background.to_hex(),
"on_background": on_background.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, but check if distinct enough
primary = palette[0] if palette else Color(93, 101, 245)
primary_h, _, _ = primary.to_hsl()
# Secondary: use palette[1] only if hue is >30° different
MIN_HUE_DISTANCE = 30
if len(palette) > 1:
sec_h, _, _ = palette[1].to_hsl()
if hue_distance(primary_h, sec_h) > MIN_HUE_DISTANCE:
secondary = palette[1]
else:
secondary = shift_hue(primary, 30)
else:
secondary = shift_hue(primary, 30)
# Tertiary: use palette[2] only if hue is >30° different from both
if len(palette) > 2:
ter_h, _, _ = palette[2].to_hsl()
sec_h, _, _ = secondary.to_hsl()
if hue_distance(primary_h, ter_h) > MIN_HUE_DISTANCE and hue_distance(sec_h, ter_h) > MIN_HUE_DISTANCE:
tertiary = palette[2]
else:
tertiary = shift_hue(primary, 60)
else:
tertiary = shift_hue(primary, 60)
error = find_error_color(palette)
# Keep colors vibrant - darken for visibility on light bg
# Clamp lightness to [0.25, 0.45] so colors are never near-black nor washed out
h, s, l = primary.to_hsl()
primary_adjusted = Color.from_hsl(h, max(s, 0.7), max(min(l, 0.45), 0.25))
h, s, l = secondary.to_hsl()
secondary_adjusted = Color.from_hsl(h, max(s, 0.6), max(min(l, 0.40), 0.22))
h, s, l = tertiary.to_hsl()
tertiary_adjusted = Color.from_hsl(h, max(s, 0.5), max(min(l, 0.35), 0.20))
# Container colors - lighter, less saturated versions of accent colors for light mode
def make_container_light(base: Color) -> Color:
h, s, l = base.to_hsl()
return Color.from_hsl(h, max(s - 0.20, 0.30), min(l + 0.35, 0.85))
primary_container = make_container_light(primary_adjusted)
secondary_container = make_container_light(secondary_adjusted)
tertiary_container = make_container_light(tertiary_adjusted)
error_container = make_container_light(error)
# Surface: COLORFUL light - a pastel, saturated version of primary
# Preserving saturation (up to 0.9) to be true to primary color
surface = adjust_surface(palette[0], 0.90, 0.90)
surface_variant = adjust_surface(palette[0], 0.80, 0.78) # Darker than surface
# Surface containers - progressive darkening for light mode (keep primary hue)
surface_container_lowest = adjust_surface(palette[0], 0.85, 0.96) # Lightest
surface_container_low = adjust_surface(palette[0], 0.85, 0.92)
surface_container = adjust_surface(palette[0], 0.80, 0.86)
surface_container_high = adjust_surface(palette[0], 0.75, 0.84)
surface_container_highest = adjust_surface(palette[0], 0.70, 0.80) # Darkest
# Foreground colors - tinted with primary hue
text_h, _, _ = palette[0].to_hsl()
base_on_surface = Color.from_hsl(text_h, 0.05, 0.10)
on_surface = ensure_contrast(base_on_surface, surface, 4.5)
base_on_surface_variant = Color.from_hsl(text_h, 0.05, 0.35)
on_surface_variant = ensure_contrast(base_on_surface_variant, surface_variant, 4.5)
# Contrasting foregrounds - light text on dark accent colors
light_fg = Color.from_hsl(text_h, 0.1, 0.98) # Brighter for better contrast
on_primary = ensure_contrast(light_fg, primary_adjusted, 7.0) # Higher contrast target
on_secondary = ensure_contrast(light_fg, secondary_adjusted, 7.0)
on_tertiary = ensure_contrast(light_fg, tertiary_adjusted, 7.0)
on_error = ensure_contrast(light_fg, error, 7.0)
# "On" colors for containers - dark text on light containers, tinted with respective color
# Explicitly prefer_light=False since containers in light mode are light
primary_h, primary_s, _ = primary.to_hsl()
on_primary_container = ensure_contrast(Color.from_hsl(primary_h, primary_s, 0.15), primary_container, 4.5, prefer_light=False)
sec_h, sec_s, _ = secondary.to_hsl()
on_secondary_container = ensure_contrast(Color.from_hsl(sec_h, sec_s, 0.15), secondary_container, 4.5, prefer_light=False)
ter_h, ter_s, _ = tertiary.to_hsl()
on_tertiary_container = ensure_contrast(Color.from_hsl(ter_h, ter_s, 0.15), tertiary_container, 4.5, prefer_light=False)
err_h, err_s, _ = error.to_hsl()
on_error_container = ensure_contrast(Color.from_hsl(err_h, err_s, 0.15), error_container, 4.5, prefer_light=False)
# Fixed colors - high-chroma accents consistent across light/dark
# In light mode: darker versions of accent colors
def make_fixed_light(base: Color) -> tuple[Color, Color]:
h, s, _ = base.to_hsl()
fixed = Color.from_hsl(h, max(s, 0.70), 0.40) # Darker, saturated
fixed_dim = Color.from_hsl(h, max(s, 0.65), 0.30) # Even darker
return fixed, fixed_dim
primary_fixed, primary_fixed_dim = make_fixed_light(primary_adjusted)
secondary_fixed, secondary_fixed_dim = make_fixed_light(secondary_adjusted)
tertiary_fixed, tertiary_fixed_dim = make_fixed_light(tertiary_adjusted)
# "On" colors for fixed - light text on dark fixed colors
on_primary_fixed = ensure_contrast(Color.from_hsl(primary_h, 0.15, 0.90), primary_fixed, 4.5)
on_primary_fixed_variant = ensure_contrast(Color.from_hsl(primary_h, 0.15, 0.85), primary_fixed_dim, 4.5)
on_secondary_fixed = ensure_contrast(Color.from_hsl(secondary.to_hsl()[0], 0.15, 0.90), secondary_fixed, 4.5)
on_secondary_fixed_variant = ensure_contrast(Color.from_hsl(secondary.to_hsl()[0], 0.15, 0.85), secondary_fixed_dim, 4.5)
on_tertiary_fixed = ensure_contrast(Color.from_hsl(tertiary.to_hsl()[0], 0.15, 0.90), tertiary_fixed, 4.5)
on_tertiary_fixed_variant = ensure_contrast(Color.from_hsl(tertiary.to_hsl()[0], 0.15, 0.85), tertiary_fixed_dim, 4.5)
# Surface dim - slightly darker than surface
surface_dim = adjust_surface(palette[0], 0.85, 0.82)
# Surface bright - brighter than surface
surface_bright = adjust_surface(palette[0], 0.90, 0.95)
# Outline uses primary hue, more saturated
surface_h, surface_s, _ = palette[0].to_hsl()
outline = ensure_contrast(Color.from_hsl(surface_h, max(surface_s * 0.4, 0.25), 0.65), surface, 3.0)
outline_variant = ensure_contrast(Color.from_hsl(surface_h, max(surface_s * 0.3, 0.20), 0.75), surface, 3.0)
shadow = Color.from_hsl(surface_h, max(surface_s * 0.3, 0.15), 0.80)
scrim = Color(0, 0, 0) # Pure black
# Inverse colors - for inverted surfaces (dark surface on light theme)
inverse_surface = Color.from_hsl(surface_h, 0.08, 0.15)
inverse_on_surface = Color.from_hsl(surface_h, 0.05, 0.90)
inverse_primary = Color.from_hsl(primary_h, max(primary_s * 0.8, 0.5), 0.70)
# Background aliases (same as surface in MD3)
background = surface
on_background = on_surface
return {
# Primary
"primary": primary_adjusted.to_hex(),
"on_primary": on_primary.to_hex(),
"primary_container": primary_container.to_hex(),
"on_primary_container": on_primary_container.to_hex(),
"primary_fixed": primary_fixed.to_hex(),
"primary_fixed_dim": primary_fixed_dim.to_hex(),
"on_primary_fixed": on_primary_fixed.to_hex(),
"on_primary_fixed_variant": on_primary_fixed_variant.to_hex(),
"surface_tint": primary_adjusted.to_hex(),
# Secondary
"secondary": secondary_adjusted.to_hex(),
"on_secondary": on_secondary.to_hex(),
"secondary_container": secondary_container.to_hex(),
"on_secondary_container": on_secondary_container.to_hex(),
"secondary_fixed": secondary_fixed.to_hex(),
"secondary_fixed_dim": secondary_fixed_dim.to_hex(),
"on_secondary_fixed": on_secondary_fixed.to_hex(),
"on_secondary_fixed_variant": on_secondary_fixed_variant.to_hex(),
# Tertiary
"tertiary": tertiary_adjusted.to_hex(),
"on_tertiary": on_tertiary.to_hex(),
"tertiary_container": tertiary_container.to_hex(),
"on_tertiary_container": on_tertiary_container.to_hex(),
"tertiary_fixed": tertiary_fixed.to_hex(),
"tertiary_fixed_dim": tertiary_fixed_dim.to_hex(),
"on_tertiary_fixed": on_tertiary_fixed.to_hex(),
"on_tertiary_fixed_variant": on_tertiary_fixed_variant.to_hex(),
# Error
"error": error.to_hex(),
"on_error": on_error.to_hex(),
"error_container": error_container.to_hex(),
"on_error_container": on_error_container.to_hex(),
# Surface
"surface": surface.to_hex(),
"on_surface": on_surface.to_hex(),
"surface_variant": surface_variant.to_hex(),
"on_surface_variant": on_surface_variant.to_hex(),
"surface_dim": surface_dim.to_hex(),
"surface_bright": surface_bright.to_hex(),
# Surface containers
"surface_container_lowest": surface_container_lowest.to_hex(),
"surface_container_low": surface_container_low.to_hex(),
"surface_container": surface_container.to_hex(),
"surface_container_high": surface_container_high.to_hex(),
"surface_container_highest": surface_container_highest.to_hex(),
# Outline and other
"outline": outline.to_hex(),
"outline_variant": outline_variant.to_hex(),
"shadow": shadow.to_hex(),
"scrim": scrim.to_hex(),
# Inverse
"inverse_surface": inverse_surface.to_hex(),
"inverse_on_surface": inverse_on_surface.to_hex(),
"inverse_primary": inverse_primary.to_hex(),
# Background
"background": background.to_hex(),
"on_background": on_background.to_hex(),
}
def generate_muted_dark(palette: list[Color]) -> dict[str, str]:
"""
Generate muted dark theme from palette.
Designed for monochrome/monotonal wallpapers - preserves the dominant hue
but caps saturation to very low values for a subtle, understated look.
Outputs same keys as Material for compatibility.
"""
# Use primary color's hue but with very low saturation
primary = palette[0] if palette else Color(128, 128, 128)
primary_h, primary_s, primary_l = primary.to_hsl()
# Derive secondary and tertiary with subtle hue shifts (monochromatic feel)
# Much smaller shifts than normal mode since we want cohesion
secondary = shift_hue(primary, 15)
tertiary = shift_hue(primary, 30)
error = find_error_color(palette)
# Cap saturation low - this is the key difference from normal mode
MUTED_SAT_PRIMARY = 0.15
MUTED_SAT_SECONDARY = 0.12
MUTED_SAT_TERTIARY = 0.10
MUTED_SAT_SURFACE = 0.08
h, s, l = primary.to_hsl()
primary_adjusted = Color.from_hsl(h, min(s, MUTED_SAT_PRIMARY), max(l, 0.65))
h, s, l = secondary.to_hsl()
secondary_adjusted = Color.from_hsl(h, min(s, MUTED_SAT_SECONDARY), max(l, 0.60))
h, s, l = tertiary.to_hsl()
tertiary_adjusted = Color.from_hsl(h, min(s, MUTED_SAT_TERTIARY), max(l, 0.60))
# Container colors - darker, slightly saturated versions
def make_container_dark(base: Color) -> Color:
h, s, l = base.to_hsl()
return Color.from_hsl(h, min(s + 0.05, MUTED_SAT_PRIMARY), max(l - 0.35, 0.15))
primary_container = make_container_dark(primary_adjusted)
secondary_container = make_container_dark(secondary_adjusted)
tertiary_container = make_container_dark(tertiary_adjusted)
error_container = make_container_dark(error)
# Surface: very low saturation, preserving hue for subtle tint
surface_hue = primary_h
base_surface = Color.from_hsl(surface_hue, MUTED_SAT_SURFACE, 0.5)
surface = adjust_surface(base_surface, MUTED_SAT_SURFACE, 0.12)
surface_variant = adjust_surface(base_surface, MUTED_SAT_SURFACE, 0.16)
# Surface containers - progressive lightness with minimal saturation
surface_container_lowest = adjust_surface(base_surface, MUTED_SAT_SURFACE, 0.06)
surface_container_low = adjust_surface(base_surface, MUTED_SAT_SURFACE, 0.10)
surface_container = adjust_surface(base_surface, MUTED_SAT_SURFACE, 0.20)
surface_container_high = adjust_surface(base_surface, MUTED_SAT_SURFACE, 0.18)
surface_container_highest = adjust_surface(base_surface, MUTED_SAT_SURFACE, 0.22)
# Text colors - near-neutral with slight hue tint
base_on_surface = Color.from_hsl(primary_h, 0.03, 0.95)
on_surface = ensure_contrast(base_on_surface, surface, 4.5)
base_on_surface_variant = Color.from_hsl(primary_h, 0.03, 0.70)
on_surface_variant = ensure_contrast(base_on_surface_variant, surface_variant, 4.5)
outline = ensure_contrast(Color.from_hsl(primary_h, 0.05, 0.30), surface, 3.0)
outline_variant = ensure_contrast(Color.from_hsl(primary_h, 0.05, 0.40), surface, 3.0)
# Contrasting foregrounds
dark_fg = Color.from_hsl(primary_h, 0.10, 0.12)
on_primary = ensure_contrast(dark_fg, primary_adjusted, 7.0)
on_secondary = ensure_contrast(dark_fg, secondary_adjusted, 7.0)
on_tertiary = ensure_contrast(dark_fg, tertiary_adjusted, 7.0)
on_error = ensure_contrast(dark_fg, error, 7.0)
# "On" colors for containers
on_primary_container = ensure_contrast(Color.from_hsl(primary_h, 0.05, 0.90), primary_container, 4.5, prefer_light=True)
sec_h, _, _ = secondary.to_hsl()
on_secondary_container = ensure_contrast(Color.from_hsl(sec_h, 0.05, 0.90), secondary_container, 4.5, prefer_light=True)
ter_h, _, _ = tertiary.to_hsl()
on_tertiary_container = ensure_contrast(Color.from_hsl(ter_h, 0.05, 0.90), tertiary_container, 4.5, prefer_light=True)
err_h, _, _ = error.to_hsl()
on_error_container = ensure_contrast(Color.from_hsl(err_h, 0.05, 0.90), error_container, 4.5, prefer_light=True)
# Shadow and scrim
shadow = surface
scrim = Color(0, 0, 0)
# Inverse colors
inverse_surface = Color.from_hsl(primary_h, 0.05, 0.90)
inverse_on_surface = Color.from_hsl(primary_h, 0.03, 0.15)
inverse_primary = Color.from_hsl(primary_h, min(primary_s * 0.5, MUTED_SAT_PRIMARY), 0.40)
# Background aliases
background = surface
on_background = on_surface
# Fixed colors - still muted
def make_fixed_dark(base: Color) -> tuple[Color, Color]:
h, s, _ = base.to_hsl()
fixed = Color.from_hsl(h, min(s, MUTED_SAT_PRIMARY), 0.85)
fixed_dim = Color.from_hsl(h, min(s, MUTED_SAT_PRIMARY), 0.75)
return fixed, fixed_dim
primary_fixed, primary_fixed_dim = make_fixed_dark(primary_adjusted)
secondary_fixed, secondary_fixed_dim = make_fixed_dark(secondary_adjusted)
tertiary_fixed, tertiary_fixed_dim = make_fixed_dark(tertiary_adjusted)
# "On" colors for fixed
on_primary_fixed = ensure_contrast(Color.from_hsl(primary_h, 0.05, 0.15), primary_fixed, 4.5)
on_primary_fixed_variant = ensure_contrast(Color.from_hsl(primary_h, 0.05, 0.20), primary_fixed_dim, 4.5)
on_secondary_fixed = ensure_contrast(Color.from_hsl(sec_h, 0.05, 0.15), secondary_fixed, 4.5)
on_secondary_fixed_variant = ensure_contrast(Color.from_hsl(sec_h, 0.05, 0.20), secondary_fixed_dim, 4.5)
on_tertiary_fixed = ensure_contrast(Color.from_hsl(ter_h, 0.05, 0.15), tertiary_fixed, 4.5)
on_tertiary_fixed_variant = ensure_contrast(Color.from_hsl(ter_h, 0.05, 0.20), tertiary_fixed_dim, 4.5)
# Surface dim and bright
surface_dim = adjust_surface(base_surface, MUTED_SAT_SURFACE, 0.08)
surface_bright = adjust_surface(base_surface, MUTED_SAT_SURFACE, 0.24)
return {
# Primary
"primary": primary_adjusted.to_hex(),
"on_primary": on_primary.to_hex(),
"primary_container": primary_container.to_hex(),
"on_primary_container": on_primary_container.to_hex(),
"primary_fixed": primary_fixed.to_hex(),
"primary_fixed_dim": primary_fixed_dim.to_hex(),
"on_primary_fixed": on_primary_fixed.to_hex(),
"on_primary_fixed_variant": on_primary_fixed_variant.to_hex(),
"surface_tint": primary_adjusted.to_hex(),
# Secondary
"secondary": secondary_adjusted.to_hex(),
"on_secondary": on_secondary.to_hex(),
"secondary_container": secondary_container.to_hex(),
"on_secondary_container": on_secondary_container.to_hex(),
"secondary_fixed": secondary_fixed.to_hex(),
"secondary_fixed_dim": secondary_fixed_dim.to_hex(),
"on_secondary_fixed": on_secondary_fixed.to_hex(),
"on_secondary_fixed_variant": on_secondary_fixed_variant.to_hex(),
# Tertiary
"tertiary": tertiary_adjusted.to_hex(),
"on_tertiary": on_tertiary.to_hex(),
"tertiary_container": tertiary_container.to_hex(),
"on_tertiary_container": on_tertiary_container.to_hex(),
"tertiary_fixed": tertiary_fixed.to_hex(),
"tertiary_fixed_dim": tertiary_fixed_dim.to_hex(),
"on_tertiary_fixed": on_tertiary_fixed.to_hex(),
"on_tertiary_fixed_variant": on_tertiary_fixed_variant.to_hex(),
# Error
"error": error.to_hex(),
"on_error": on_error.to_hex(),
"error_container": error_container.to_hex(),
"on_error_container": on_error_container.to_hex(),
# Surface
"surface": surface.to_hex(),
"on_surface": on_surface.to_hex(),
"surface_variant": surface_variant.to_hex(),
"on_surface_variant": on_surface_variant.to_hex(),
"surface_dim": surface_dim.to_hex(),
"surface_bright": surface_bright.to_hex(),
# Surface containers
"surface_container_lowest": surface_container_lowest.to_hex(),
"surface_container_low": surface_container_low.to_hex(),
"surface_container": surface_container.to_hex(),
"surface_container_high": surface_container_high.to_hex(),
"surface_container_highest": surface_container_highest.to_hex(),
# Outline and other
"outline": outline.to_hex(),
"outline_variant": outline_variant.to_hex(),
"shadow": shadow.to_hex(),
"scrim": scrim.to_hex(),
# Inverse
"inverse_surface": inverse_surface.to_hex(),
"inverse_on_surface": inverse_on_surface.to_hex(),
"inverse_primary": inverse_primary.to_hex(),
# Background
"background": background.to_hex(),
"on_background": on_background.to_hex(),
}
def generate_muted_light(palette: list[Color]) -> dict[str, str]:
"""
Generate muted light theme from palette.
Designed for monochrome/monotonal wallpapers - preserves the dominant hue
but caps saturation to very low values for a subtle, understated look.
Outputs same keys as Material for compatibility.
"""
primary = palette[0] if palette else Color(128, 128, 128)
primary_h, primary_s, _ = primary.to_hsl()
# Derive secondary and tertiary with subtle hue shifts
secondary = shift_hue(primary, 15)
tertiary = shift_hue(primary, 30)
error = find_error_color(palette)
# Cap saturation low
MUTED_SAT_PRIMARY = 0.15
MUTED_SAT_SECONDARY = 0.12
MUTED_SAT_TERTIARY = 0.10
MUTED_SAT_SURFACE = 0.08
h, s, l = primary.to_hsl()
primary_adjusted = Color.from_hsl(h, min(s, MUTED_SAT_PRIMARY), min(l, 0.45))
h, s, l = secondary.to_hsl()
secondary_adjusted = Color.from_hsl(h, min(s, MUTED_SAT_SECONDARY), min(l, 0.40))
h, s, l = tertiary.to_hsl()
tertiary_adjusted = Color.from_hsl(h, min(s, MUTED_SAT_TERTIARY), min(l, 0.35))
# Container colors - lighter, less saturated
def make_container_light(base: Color) -> Color:
h, s, l = base.to_hsl()
return Color.from_hsl(h, max(s - 0.05, 0.05), min(l + 0.35, 0.85))
primary_container = make_container_light(primary_adjusted)
secondary_container = make_container_light(secondary_adjusted)
tertiary_container = make_container_light(tertiary_adjusted)
error_container = make_container_light(error)
# Surface: very low saturation, preserving hue for subtle tint
surface = adjust_surface(primary, MUTED_SAT_SURFACE, 0.90)
surface_variant = adjust_surface(primary, MUTED_SAT_SURFACE, 0.78)
# Surface containers - progressive darkening with minimal saturation
surface_container_lowest = adjust_surface(primary, MUTED_SAT_SURFACE, 0.96)
surface_container_low = adjust_surface(primary, MUTED_SAT_SURFACE, 0.92)
surface_container = adjust_surface(primary, MUTED_SAT_SURFACE, 0.86)
surface_container_high = adjust_surface(primary, MUTED_SAT_SURFACE, 0.84)
surface_container_highest = adjust_surface(primary, MUTED_SAT_SURFACE, 0.80)
# Text colors - near-neutral with slight hue tint
base_on_surface = Color.from_hsl(primary_h, 0.03, 0.10)
on_surface = ensure_contrast(base_on_surface, surface, 4.5)
base_on_surface_variant = Color.from_hsl(primary_h, 0.03, 0.35)
on_surface_variant = ensure_contrast(base_on_surface_variant, surface_variant, 4.5)
# Contrasting foregrounds
light_fg = Color.from_hsl(primary_h, 0.05, 0.98)
on_primary = ensure_contrast(light_fg, primary_adjusted, 7.0)
on_secondary = ensure_contrast(light_fg, secondary_adjusted, 7.0)
on_tertiary = ensure_contrast(light_fg, tertiary_adjusted, 7.0)
on_error = ensure_contrast(light_fg, error, 7.0)
# "On" colors for containers
on_primary_container = ensure_contrast(Color.from_hsl(primary_h, 0.05, 0.15), primary_container, 4.5, prefer_light=False)
sec_h, _, _ = secondary.to_hsl()
on_secondary_container = ensure_contrast(Color.from_hsl(sec_h, 0.05, 0.15), secondary_container, 4.5, prefer_light=False)
ter_h, _, _ = tertiary.to_hsl()
on_tertiary_container = ensure_contrast(Color.from_hsl(ter_h, 0.05, 0.15), tertiary_container, 4.5, prefer_light=False)
err_h, _, _ = error.to_hsl()
on_error_container = ensure_contrast(Color.from_hsl(err_h, 0.05, 0.15), error_container, 4.5, prefer_light=False)
# Fixed colors - still muted
def make_fixed_light(base: Color) -> tuple[Color, Color]:
h, s, _ = base.to_hsl()
fixed = Color.from_hsl(h, min(s, MUTED_SAT_PRIMARY), 0.40)
fixed_dim = Color.from_hsl(h, min(s, MUTED_SAT_PRIMARY), 0.30)
return fixed, fixed_dim
primary_fixed, primary_fixed_dim = make_fixed_light(primary_adjusted)
secondary_fixed, secondary_fixed_dim = make_fixed_light(secondary_adjusted)
tertiary_fixed, tertiary_fixed_dim = make_fixed_light(tertiary_adjusted)
# "On" colors for fixed
on_primary_fixed = ensure_contrast(Color.from_hsl(primary_h, 0.05, 0.90), primary_fixed, 4.5)
on_primary_fixed_variant = ensure_contrast(Color.from_hsl(primary_h, 0.05, 0.85), primary_fixed_dim, 4.5)
on_secondary_fixed = ensure_contrast(Color.from_hsl(sec_h, 0.05, 0.90), secondary_fixed, 4.5)
on_secondary_fixed_variant = ensure_contrast(Color.from_hsl(sec_h, 0.05, 0.85), secondary_fixed_dim, 4.5)
on_tertiary_fixed = ensure_contrast(Color.from_hsl(ter_h, 0.05, 0.90), tertiary_fixed, 4.5)
on_tertiary_fixed_variant = ensure_contrast(Color.from_hsl(ter_h, 0.05, 0.85), tertiary_fixed_dim, 4.5)
# Surface dim and bright
surface_dim = adjust_surface(primary, MUTED_SAT_SURFACE, 0.82)
surface_bright = adjust_surface(primary, MUTED_SAT_SURFACE, 0.95)
# Outline
outline = ensure_contrast(Color.from_hsl(primary_h, 0.05, 0.65), surface, 3.0)
outline_variant = ensure_contrast(Color.from_hsl(primary_h, 0.05, 0.75), surface, 3.0)
shadow = Color.from_hsl(primary_h, 0.05, 0.80)
scrim = Color(0, 0, 0)
# Inverse colors
inverse_surface = Color.from_hsl(primary_h, 0.05, 0.15)
inverse_on_surface = Color.from_hsl(primary_h, 0.03, 0.90)
inverse_primary = Color.from_hsl(primary_h, min(primary_s * 0.5, MUTED_SAT_PRIMARY), 0.70)
# Background aliases
background = surface
on_background = on_surface
return {
# Primary
"primary": primary_adjusted.to_hex(),
"on_primary": on_primary.to_hex(),
"primary_container": primary_container.to_hex(),
"on_primary_container": on_primary_container.to_hex(),
"primary_fixed": primary_fixed.to_hex(),
"primary_fixed_dim": primary_fixed_dim.to_hex(),
"on_primary_fixed": on_primary_fixed.to_hex(),
"on_primary_fixed_variant": on_primary_fixed_variant.to_hex(),
"surface_tint": primary_adjusted.to_hex(),
# Secondary
"secondary": secondary_adjusted.to_hex(),
"on_secondary": on_secondary.to_hex(),
"secondary_container": secondary_container.to_hex(),
"on_secondary_container": on_secondary_container.to_hex(),
"secondary_fixed": secondary_fixed.to_hex(),
"secondary_fixed_dim": secondary_fixed_dim.to_hex(),
"on_secondary_fixed": on_secondary_fixed.to_hex(),
"on_secondary_fixed_variant": on_secondary_fixed_variant.to_hex(),
# Tertiary
"tertiary": tertiary_adjusted.to_hex(),
"on_tertiary": on_tertiary.to_hex(),
"tertiary_container": tertiary_container.to_hex(),
"on_tertiary_container": on_tertiary_container.to_hex(),
"tertiary_fixed": tertiary_fixed.to_hex(),
"tertiary_fixed_dim": tertiary_fixed_dim.to_hex(),
"on_tertiary_fixed": on_tertiary_fixed.to_hex(),
"on_tertiary_fixed_variant": on_tertiary_fixed_variant.to_hex(),
# Error
"error": error.to_hex(),
"on_error": on_error.to_hex(),
"error_container": error_container.to_hex(),
"on_error_container": on_error_container.to_hex(),
# Surface
"surface": surface.to_hex(),
"on_surface": on_surface.to_hex(),
"surface_variant": surface_variant.to_hex(),
"on_surface_variant": on_surface_variant.to_hex(),
"surface_dim": surface_dim.to_hex(),
"surface_bright": surface_bright.to_hex(),
# Surface containers
"surface_container_lowest": surface_container_lowest.to_hex(),
"surface_container_low": surface_container_low.to_hex(),
"surface_container": surface_container.to_hex(),
"surface_container_high": surface_container_high.to_hex(),
"surface_container_highest": surface_container_highest.to_hex(),
# Outline and other
"outline": outline.to_hex(),
"outline_variant": outline_variant.to_hex(),
"shadow": shadow.to_hex(),
"scrim": scrim.to_hex(),
# Inverse
"inverse_surface": inverse_surface.to_hex(),
"inverse_on_surface": inverse_on_surface.to_hex(),
"inverse_primary": inverse_primary.to_hex(),
# Background
"background": background.to_hex(),
"on_background": on_background.to_hex(),
}
def generate_theme(
palette: list[Color],
mode: ThemeMode,
scheme_type: str = "tonal-spot"
) -> dict[str, str]:
"""
Generate theme for specified mode and scheme type.
Args:
palette: List of extracted colors
mode: "dark" or "light"
scheme_type: One of "tonal-spot", "fruit-salad", "rainbow", "vibrant", "faithful", "dysfunctional", "muted"
Returns:
Dictionary of color token names to hex values
"""
# Handle vibrant/faithful/dysfunctional modes (use generate_normal_* functions)
# All three use same theme generation, but different color extraction (handled in palette.py)
if scheme_type in ("vibrant", "faithful", "dysfunctional"):
if mode == "dark":
return generate_normal_dark(palette)
return generate_normal_light(palette)
# Handle muted mode (low saturation, monochrome wallpapers)
if scheme_type == "muted":
if mode == "dark":
return generate_muted_dark(palette)
return generate_muted_light(palette)
# All other schemes use Material Design 3 generation
if mode == "dark":
return generate_material_dark(palette, scheme_type)
return generate_material_light(palette, scheme_type)
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
import os
import json
import sys
import urllib.request
import urllib.parse
from pathlib import Path
# Registry URL for color schemes
REGISTRY_URL = "https://raw.githubusercontent.com/noctalia-dev/noctalia-colorschemes/main/registry.json"
RAW_BASE_URL = "https://raw.githubusercontent.com/noctalia-dev/noctalia-colorschemes/main/"
def is_valid_format(data):
"""Check if the scheme data has the new terminal format."""
for variant in ['dark', 'light']:
if variant in data:
v_data = data[variant]
if isinstance(v_data, dict) and 'terminal' in v_data:
term = v_data['terminal']
if isinstance(term, dict) and 'normal' in term:
if isinstance(term['normal'], dict) and 'black' in term['normal']:
return True
return False
def get_registry():
"""Fetch the remote registry to get correct paths for schemes."""
try:
with urllib.request.urlopen(REGISTRY_URL) as response:
return json.loads(response.read().decode())
except Exception as e:
print(f"Error fetching registry: {e}")
return None
def migrate(config_dir):
colorschemes_dir = Path(config_dir) / "colorschemes"
if not colorschemes_dir.exists():
return
registry = get_registry()
if not registry:
return
# Map name to path from registry
theme_map = {t['name']: t['path'] for t in registry.get('themes', [])}
for scheme_dir in colorschemes_dir.iterdir():
if not scheme_dir.is_dir():
continue
scheme_name = scheme_dir.name
json_file = scheme_dir / f"{scheme_name}.json"
if not json_file.exists():
continue
try:
with open(json_file, 'r') as f:
data = json.load(f)
except Exception:
continue
if not is_valid_format(data):
print(f"Scheme '{scheme_name}' has old format. Attempting to redownload...")
# Use registry path if available, otherwise fallback to name
remote_path = theme_map.get(scheme_name, scheme_name)
# Encode URL parts to handle spaces and special characters
encoded_path = urllib.parse.quote(remote_path)
encoded_name = urllib.parse.quote(scheme_name)
remote_url = f"{RAW_BASE_URL}{encoded_path}/{encoded_name}.json"
try:
with urllib.request.urlopen(remote_url) as response:
new_data = json.loads(response.read().decode())
with open(json_file, 'w') as f:
json.dump(new_data, f, indent=2)
print(f"Successfully migrated '{scheme_name}'")
except Exception as e:
print(f"Failed to migrate '{scheme_name}': {e}")
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: migrate-colorschemes.py <config_dir>")
sys.exit(1)
migrate(sys.argv[1])
@@ -0,0 +1,347 @@
#!/usr/bin/env python3
"""
Noctalia's Template processor - Wallpaper-based color extraction and theme generation.
A CLI tool that extracts dominant colors from wallpaper images and generates palettes with optional templating.
Supported scheme types:
- tonal-spot: Default Android 12-13 Material You scheme (recommended)
- content: Preserves source color's chroma with temperature-based tertiary (matugen default)
- fruit-salad: Bold/playful with -50° hue rotation
- rainbow: Chromatic accents with grayscale neutrals
- monochrome: Pure grayscale M3 scheme (chroma = 0, only error has color)
- vibrant: Prioritizes the most saturated colors regardless of area coverage
- faithful: Prioritizes dominant colors by area, what you see is what you get
- dysfunctional: Like faithful but picks the 2nd most dominant color family
- muted: Preserves hue but caps saturation low (for monochrome/monotonal wallpapers)
Usage:
python3 template-processor.py IMAGE_OR_JSON [OPTIONS]
Options:
--scheme-type Scheme type: tonal-spot (default), content, fruit-salad, rainbow, monochrome, vibrant, faithful, dysfunctional, muted
--dark Generate dark theme only
--light Generate light theme only
--both Generate both themes (default)
-o, --output Write JSON output to file (stdout if omitted)
-r, --render Render a template (input_path:output_path)
-c, --config Path to TOML configuration file with template definitions
--mode Theme mode: dark or light
Input:
Can be an image file (PNG/JPG) or a JSON color palette file.
Example:
python3 template-processor.py ~/wallpaper.png --scheme-type tonal-spot
python3 template-processor.py ~/wallpaper.png --scheme-type fruit-salad --dark
python3 template-processor.py ~/wallpaper.jpg --dark -o theme.json
python3 template-processor.py ~/wallpaper.png -r template.txt:output.txt
python3 template-processor.py ~/wallpaper.png -c config.toml --mode dark
Author: Noctalia Team
License: MIT
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
# Import from lib package
from lib import (
read_image, ImageReadError, extract_palette, generate_theme,
TemplateRenderer, expand_predefined_scheme,
extract_source_color, source_color_to_rgb, Color,
)
from lib.scheme import inject_terminal_colors
def parse_args() -> argparse.Namespace:
"""Parse command-line arguments."""
parser = argparse.ArgumentParser(
prog='template-processor',
description='Extract color palettes from wallpapers and generate themes',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python3 template-processor.py wallpaper.png # tonal-spot (default), both themes
python3 template-processor.py wallpaper.png --scheme-type content --dark # content scheme, dark only
python3 template-processor.py wallpaper.jpg --dark -o theme.json # output to file
python3 template-processor.py wallpaper.png -r template.txt:output.txt # render template
python3 template-processor.py wallpaper.png -c config.toml --mode dark # render config, dark only
"""
)
parser.add_argument(
'image',
type=Path,
nargs='?',
help='Path to wallpaper image (PNG/JPG) or JSON color palette (not required if --scheme is used)'
)
# Scheme type selection
parser.add_argument(
'--scheme-type',
choices=['tonal-spot', 'content', 'fruit-salad', 'rainbow', 'monochrome', 'vibrant', 'faithful', 'dysfunctional', 'muted'],
default='tonal-spot',
help='Color scheme type (default: tonal-spot)'
)
# 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)'
)
parser.add_argument(
'--config', '-c',
type=Path,
help='Path to TOML configuration file with template definitions'
)
parser.add_argument(
'--mode',
choices=['dark', 'light'],
help='Theme mode: dark or light'
)
parser.add_argument(
'--scheme',
type=Path,
help='Path to predefined scheme JSON file (bypasses image extraction)'
)
parser.add_argument(
'--default-mode',
choices=['dark', 'light'],
default='dark',
help='Theme mode to use for "default" in templates (default: dark)'
)
return parser.parse_args()
def main() -> int:
"""Main entry point."""
args = parse_args()
# Initialize result dictionary
result: dict[str, dict[str, str]] = {}
# Determine mode from arguments
if args.mode == 'dark':
modes = ["dark"]
elif args.mode == 'light':
modes = ["light"]
elif args.dark:
modes = ["dark"]
elif args.light:
modes = ["light"]
else:
modes = ["dark", "light"]
# Path 1: Predefined scheme (--scheme flag)
if args.scheme:
if not args.scheme.exists():
print(f"Error: Scheme file not found: {args.scheme}", file=sys.stderr)
return 1
try:
with open(args.scheme, 'r') as f:
scheme_data = json.load(f)
# Scheme format: {"dark": {"mPrimary": "#...", ...}, "light": {...}}
# or single mode: {"mPrimary": "#...", ...}
for mode in modes:
if mode in scheme_data:
# Multi-mode format
result[mode] = expand_predefined_scheme(scheme_data[mode], mode)
inject_terminal_colors(result[mode], scheme_data[mode])
elif "mPrimary" in scheme_data:
# Single-mode format - use same colors for requested mode
result[mode] = expand_predefined_scheme(scheme_data, mode)
inject_terminal_colors(result[mode], scheme_data)
else:
print(f"Error: Invalid scheme format - missing '{mode}' or 'mPrimary'", file=sys.stderr)
return 1
except json.JSONDecodeError as e:
print(f"Error parsing scheme JSON: {e}", file=sys.stderr)
return 1
except KeyError as e:
print(f"Error: Missing required color in scheme: {e}", file=sys.stderr)
return 1
except Exception as e:
print(f"Error processing scheme: {e}", file=sys.stderr)
return 1
# Path 2: Image-based extraction (default)
else:
# Validate image argument is provided
if args.image is None:
print("Error: Image path is required (unless --scheme is used)", file=sys.stderr)
return 1
# Validate image path
if not args.image.exists():
print(f"Error: Image not found: {args.image}", file=sys.stderr)
return 1
# Check if input is a JSON palette (Predefined Color Scheme)
if args.image.suffix.lower() == '.json':
try:
with open(args.image, 'r') as f:
input_data = json.load(f)
# Expect {"colors": ...} or direct dict
colors_data = input_data.get("colors", input_data)
# Flatten QML-style object structure if needed
# structure: key -> { default: { hex: "#..." } } or key -> "#..."
flat_colors = {}
for k, v in colors_data.items():
if isinstance(v, dict) and 'default' in v and 'hex' in v['default']:
flat_colors[k] = v['default']['hex']
elif isinstance(v, str):
flat_colors[k] = v
else:
# Best effort fallback
flat_colors[k] = str(v)
# Assign to requested modes
for mode in modes:
result[mode] = flat_colors
except Exception as e:
print(f"Error reading JSON palette: {e}", file=sys.stderr)
return 1
else:
# Standard Image Extraction
if not args.image.is_file():
print(f"Error: Not a file: {args.image}", file=sys.stderr)
return 1
# Determine scheme type
scheme_type = args.scheme_type
# M3 schemes use Triangle filter (matches matugen), others use Box
# (sharper downscale preserves distinct color regions for k-means)
m3_schemes = {"tonal-spot", "content", "fruit-salad", "rainbow", "monochrome"}
resize_filter = "Triangle" if scheme_type in m3_schemes else "Box"
try:
pixels = read_image(args.image, resize_filter)
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 based on scheme type:
# - M3 schemes (tonal-spot, fruit-salad, rainbow, content): Use Wu quantizer + Score
# This matches matugen's color extraction exactly
# - vibrant: Use k-means clustering for colorful/blended colors
# - faithful: Use Wu quantizer for primary (dominant by area), k-means for accents
# - dysfunctional: Like faithful but picks 2nd most dominant color family
# - muted: Like count but without chroma filtering (for monochrome wallpapers)
if scheme_type == "vibrant":
# K-means with chroma scoring for vibrant, blended colors
palette = extract_palette(pixels, k=5, scoring="chroma")
elif scheme_type == "faithful":
# K-means with count scoring - picks dominant color by area coverage
# This ensures primary reflects what you actually see in the image
palette = extract_palette(pixels, k=5, scoring="count")
elif scheme_type == "dysfunctional":
# K-means with dysfunctional scoring - picks 2nd most dominant color family
# For when the dominant color is not what you want as primary
palette = extract_palette(pixels, k=5, scoring="dysfunctional")
elif scheme_type == "muted":
# K-means with muted scoring - accepts low/zero chroma colors
# For monochrome/monotonal wallpapers where dominant color has low saturation
palette = extract_palette(pixels, k=5, scoring="muted")
else:
# Wu quantizer + Score algorithm (matches matugen)
source_argb = extract_source_color(pixels)
r, g, b = source_color_to_rgb(source_argb)
palette = [Color(r, g, b)]
if not palette:
print("Error: Could not extract colors from image", file=sys.stderr)
return 1
# Generate theme for each mode
for mode in modes:
result[mode] = generate_theme(palette, mode, scheme_type)
# 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:
image_path = str(args.image) if args.image else None
renderer = TemplateRenderer(result, default_mode=args.default_mode, image_path=image_path, scheme_type=args.scheme_type)
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())
@@ -0,0 +1,30 @@
#!/usr/bin/env python3
# Finds all installed Noctalia theme extensions for VSCode/VSCodium.
import sys
from pathlib import Path
def find_all_noctalia_themes(extensions_dir: Path, prefix: str) -> list[str]:
# Bail early if the extensions directory doesn't exist
if not extensions_dir.is_dir():
return []
# Collect all directories matching the extension prefix
candidates = [d for d in extensions_dir.iterdir() if d.is_dir() and d.name.startswith(prefix)]
# Return theme file paths for all matching extensions
return [str(d / "themes" / "NoctaliaTheme-color-theme.json") for d in candidates]
if __name__ == "__main__":
# Resolve ~ in the provided extensions directory path
extensions_dir = Path(sys.argv[1]).expanduser()
prefix = sys.argv[2] if len(sys.argv) > 2 else "noctalia.noctaliatheme-"
# Print the resolved paths to stdout for the QML Process to capture
results = find_all_noctalia_themes(extensions_dir, prefix)
if results:
for path in results:
print(path)
else:
print(f"No matching extension found in {extensions_dir}", file=sys.stderr)
sys.exit(1)