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,540 @@
#!/usr/bin/env -S bash
# Ensure at least one argument is provided.
if [ "$#" -lt 1 ]; then
# Print usage information to standard error.
echo "Error: No application specified." >&2
echo "Usage: $0 {kitty|ghostty|foot|alacritty|wezterm|fuzzel|walker|pywalfox|cava|yazi|labwc|niri|hyprland|sway|scroll|mango|btop|zathura} [dark|light]" >&2
exit 1
fi
APP_NAME="$1"
MODE="${2:-}" # Optional second argument for dark/light mode
# --- Apply theme based on the application name ---
case "$APP_NAME" in
kitty)
KITTY_CONF="$HOME/.config/kitty/kitty.conf"
if [ -w "$KITTY_CONF" ]; then
kitty +kitten themes --reload-in=all noctalia
else
kitty +runpy "from kitty.utils import *; reload_conf_in_all_kitties()"
fi
;;
ghostty)
# Check both potential config files
CONFIG_FILES=("$HOME/.config/ghostty/config" "$HOME/.config/ghostty/config.ghostty")
FOUND_CONFIG=false
for CONFIG_FILE in "${CONFIG_FILES[@]}"; do
if [ -f "$CONFIG_FILE" ]; then
FOUND_CONFIG=true
# Check if theme is already set to noctalia (flexible spacing)
if grep -qE "^theme\s*=\s*noctalia$" "$CONFIG_FILE"; then
: # Already correct
elif grep -qE "^theme\s*=" "$CONFIG_FILE"; then
# Replace existing theme line in-place
sed -i -E 's/^theme\s*=.*/theme = noctalia/' "$CONFIG_FILE"
else
# Add the new theme line to the end of the file
echo "theme = noctalia" >>"$CONFIG_FILE"
fi
fi
done
if [ "$FOUND_CONFIG" = true ]; then
# Only signal if ghostty is running
pgrep -f ghostty >/dev/null && pkill -SIGUSR2 ghostty || true
else
echo "Error: No ghostty config file found at ${CONFIG_FILES[*]}" >&2
exit 1
fi
;;
foot)
CONFIG_FILE="$HOME/.config/foot/foot.ini"
# Check if the config file exists, create it if it doesn't.
if [ ! -f "$CONFIG_FILE" ]; then
# Create the config directory if it doesn't exist
mkdir -p "$(dirname "$CONFIG_FILE")"
# Create the config file with the noctalia theme
cat >"$CONFIG_FILE" <<'EOF'
[main]
include=~/.config/foot/themes/noctalia
EOF
else
# Check if theme is already set to noctalia
if ! grep -q "include.*noctalia" "$CONFIG_FILE"; then
# Remove any existing theme include line to prevent duplicates.
sed -i '/include=.*themes/d' "$CONFIG_FILE"
if grep -q '^\[main\]' "$CONFIG_FILE"; then
# Insert the include line after the existing [main] section header
sed -i '/^\[main\]/a include=~/.config/foot/themes/noctalia' "$CONFIG_FILE"
else
# If [main] doesn't exist, create it at the beginning with the include
sed -i '1i [main]\ninclude=~/.config/foot/themes/noctalia\n' "$CONFIG_FILE"
fi
fi
fi
;;
alacritty)
CONFIG_FILE="$HOME/.config/alacritty/alacritty.toml"
NEW_THEME_PATH='~/.config/alacritty/themes/noctalia.toml'
# Check if the config file exists, create it if it doesn't.
if [ ! -f "$CONFIG_FILE" ]; then
# Create the config directory if it doesn't exist
mkdir -p "$(dirname "$CONFIG_FILE")"
# Create the config file with the noctalia theme import
cat >"$CONFIG_FILE" <<'EOF'
[general]
import = [
"~/.config/alacritty/themes/noctalia.toml"
]
EOF
else
# Check if noctalia theme is already imported (any path variant)
if grep -q 'noctalia\.toml' "$CONFIG_FILE"; then
# Update old relative path to new absolute path if needed
if grep -q '"themes/noctalia.toml"' "$CONFIG_FILE"; then
sed -i 's|"themes/noctalia.toml"|"'"$NEW_THEME_PATH"'"|g' "$CONFIG_FILE"
fi
# Already has noctalia import with correct path, nothing to do
else
# No noctalia import found, add it
if grep -q '^\[general\]' "$CONFIG_FILE"; then
# Check if import line already exists under [general]
if grep -q '^import\s*=' "$CONFIG_FILE"; then
# Append to existing import array (before the closing bracket)
sed -i '/^import\s*=\s*\[/,/\]/{/\]/s|]| "'"$NEW_THEME_PATH"'",\n]|}' "$CONFIG_FILE"
else
# Add import line after [general] section header
sed -i '/^\[general\]/a import = ["'"$NEW_THEME_PATH"'"]' "$CONFIG_FILE"
fi
else
# Create [general] section with import at the beginning of the file
sed -i '1i [general]\nimport = ["'"$NEW_THEME_PATH"'"]\n' "$CONFIG_FILE"
fi
fi
fi
;;
wezterm)
CONFIG_FILE="$HOME/.config/wezterm/wezterm.lua"
WEZTERM_SCHEME_LINE='config.color_scheme = "Noctalia"'
# Check if the config file exists.
if [ -f "$CONFIG_FILE" ]; then
# Check if theme is already set to Noctalia (matches 'Noctalia' or "Noctalia")
if ! grep -q "^\s*config\.color_scheme\s*=\s*['\"]Noctalia['\"]\s*" "$CONFIG_FILE"; then
# Not set to Noctalia. Check if *any* color_scheme line exists.
if grep -q '^\s*config\.color_scheme\s*=' "$CONFIG_FILE"; then
# It exists, so we replace it with our desired line.
sed -i "s|^\(\s*config\.color_scheme\s*=\s*\).*$|\1\"Noctalia\"|" "$CONFIG_FILE"
else
# It doesn't exist, so we add it before the 'return config' line.
if grep -q '^\s*return\s*config' "$CONFIG_FILE"; then
# 'return config' exists. Insert the line before it.
sed -i '/^\s*return\s*config/i\'"$WEZTERM_SCHEME_LINE" "$CONFIG_FILE"
else
# This is a problem. We can't find the insertion point.
echo "Warning: 'config.color_scheme' not set and 'return config' line not found." >&2
echo " Make sure $CONFIG_FILE is correct: https://wezterm.org/config/files.html" >&2
fi
fi
fi
# touching the config file fools wezterm into reloading it
touch "$CONFIG_FILE"
else
echo "Error: wezterm.lua not found at $CONFIG_FILE" >&2
echo "Instructions to create it: https://wezterm.org/config/files.html" >&2
exit 1
fi
;;
fuzzel)
CONFIG_FILE="$HOME/.config/fuzzel/fuzzel.ini"
# Check if the config file exists, create it if it doesn't.
if [ ! -f "$CONFIG_FILE" ]; then
# Create the config directory if it doesn't exist
mkdir -p "$(dirname "$CONFIG_FILE")"
# Create the config file with the noctalia theme
cat >"$CONFIG_FILE" <<'EOF'
include=~/.config/fuzzel/themes/noctalia
EOF
else
# Check if theme is already set to noctalia
if grep -q "^include=~/.config/fuzzel/themes/noctalia$" "$CONFIG_FILE"; then
: # Already correct
elif grep -q "^include=.*themes" "$CONFIG_FILE"; then
# Replace existing theme include line in-place
sed -i 's|^include=.*themes.*|include=~/.config/fuzzel/themes/noctalia|' "$CONFIG_FILE"
else
# Add the new theme include line
echo "include=~/.config/fuzzel/themes/noctalia" >>"$CONFIG_FILE"
fi
fi
;;
walker)
CONFIG_FILE="$HOME/.config/walker/config.toml"
# Check if the config file exists.
if [ -f "$CONFIG_FILE" ]; then
# Check if theme is already set to noctalia (flexible spacing)
if grep -qE '^theme\s*=\s*"noctalia"' "$CONFIG_FILE"; then
: # Already correct
elif grep -qE '^theme\s*=' "$CONFIG_FILE"; then
# Replace existing theme line in-place
sed -i -E 's/^theme\s*=.*/theme = "noctalia"/' "$CONFIG_FILE"
else
echo 'theme = "noctalia"' >>"$CONFIG_FILE"
fi
else
echo "Error: walker config file not found at $CONFIG_FILE" >&2
exit 1
fi
;;
vicinae)
# Apply the theme
vicinae theme set noctalia
;;
pywalfox)
# Set dark/light mode first if MODE is specified
if [ -n "$MODE" ]; then
if [ "$MODE" = "dark" ] || [ "$MODE" = "light" ]; then
pywalfox "$MODE"
else
echo "Warning: Invalid mode '$MODE'. Expected 'dark' or 'light'. Skipping mode switch." >&2
fi
fi
# Update the theme
pywalfox update
;;
cava)
CONFIG_FILE="$HOME/.config/cava/config"
THEME_MODIFIED=false
# Check if the config file exists.
if [ -f "$CONFIG_FILE" ]; then
# Check if [color] section exists
if grep -q '^\[color\]' "$CONFIG_FILE"; then
# Check if theme is already set to noctalia under [color] (flexible spacing)
if sed -n '/^\[color\]/,/^\[/p' "$CONFIG_FILE" | grep -qE '^theme\s*=\s*"noctalia"'; then
: # Already correct
elif sed -n '/^\[color\]/,/^\[/p' "$CONFIG_FILE" | grep -qE '^theme\s*='; then
# Replace existing theme line under [color]
sed -i -E '/^\[color\]/,/^\[/{s/^theme\s*=.*/theme = "noctalia"/}' "$CONFIG_FILE"
THEME_MODIFIED=true
else
# Add theme line after [color]
sed -i '/^\[color\]/a theme = "noctalia"' "$CONFIG_FILE"
THEME_MODIFIED=true
fi
else
# Add [color] section with theme at the end of file
echo "" >>"$CONFIG_FILE"
echo "[color]" >>"$CONFIG_FILE"
echo 'theme = "noctalia"' >>"$CONFIG_FILE"
THEME_MODIFIED=true
fi
# Reload cava if it's running, but only if it's not using stdin config
if pgrep -f cava >/dev/null; then
# Check if Cava is running with -p /dev/stdin (standalone cava)
if ! pgrep -af cava | grep -q -- "-p.*stdin"; then
pkill -USR1 cava
fi
fi
else
echo "Error: cava config file not found at $CONFIG_FILE" >&2
exit 1
fi
;;
yazi)
CONFIG_FILE="$HOME/.config/yazi/theme.toml"
# Create config directory if it doesn't exist
mkdir -p "$(dirname "$CONFIG_FILE")"
if [ ! -f "$CONFIG_FILE" ]; then
cat >"$CONFIG_FILE" <<'EOF'
[flavor]
dark = "noctalia"
light = "noctalia"
EOF
else
# Check if [flavor] section exists
if grep -q '^\[flavor\]' "$CONFIG_FILE"; then
# Update or add dark/light lines under [flavor]
if sed -n '/^\[flavor\]/,/^\[/p' "$CONFIG_FILE" | grep -q '^dark\s*='; then
sed -i '/^\[flavor\]/,/^\[/{s/^dark\s*=.*/dark = "noctalia"/}' "$CONFIG_FILE"
else
sed -i '/^\[flavor\]/a dark = "noctalia"' "$CONFIG_FILE"
fi
if sed -n '/^\[flavor\]/,/^\[/p' "$CONFIG_FILE" | grep -q '^light\s*='; then
sed -i '/^\[flavor\]/,/^\[/{s/^light\s*=.*/light = "noctalia"/}' "$CONFIG_FILE"
else
sed -i '/^\[flavor\]/,/^dark/a light = "noctalia"' "$CONFIG_FILE"
fi
else
# Add [flavor] section at the end
echo "" >>"$CONFIG_FILE"
echo "[flavor]" >>"$CONFIG_FILE"
echo 'dark = "noctalia"' >>"$CONFIG_FILE"
echo 'light = "noctalia"' >>"$CONFIG_FILE"
fi
fi
;;
labwc)
# Update the theme
labwc -r
;;
niri)
CONFIG_FILE="$HOME/.config/niri/config.kdl"
INCLUDE_LINE='include "./noctalia.kdl"'
# Check if the config file exists.
if [ ! -f "$CONFIG_FILE" ]; then
mkdir -p "$(dirname "$CONFIG_FILE")"
echo -e "\n$INCLUDE_LINE\n" >"$CONFIG_FILE"
else
# Check if noctalia include already exists (flexible: quotes, ./ prefix)
if grep -qE 'include\s+["'"'"'](\./)?noctalia\.kdl["'"'"']' "$CONFIG_FILE"; then
: # Already included
else
# Add the include line to the end of the file
echo -e "\n$INCLUDE_LINE\n" >>"$CONFIG_FILE"
fi
fi
;;
hyprland)
echo "🎨 Applying 'noctalia' theme to Hyprland..."
CONFIG_DIR="$HOME/.config/hypr"
CONFIG_FILE="$CONFIG_DIR/hyprland.conf"
THEME_FILE="$CONFIG_DIR/noctalia/noctalia-colors.conf"
INCLUDE_LINE="source = $THEME_FILE"
# Check if the config file exists.
if [ ! -f "$CONFIG_FILE" ]; then
echo "Config file not found, creating $CONFIG_FILE..."
mkdir -p "$(dirname "$CONFIG_FILE")"
echo -e "\n$INCLUDE_LINE\n" >"$CONFIG_FILE"
echo "Created new config file with noctalia theme."
else
# Check if noctalia theme source already exists (flexible matching)
if grep -qE 'source\s*=\s*.*noctalia.*\.conf' "$CONFIG_FILE"; then
echo "Theme already included, skipping modification."
else
# Only convert symlink when we actually need to write (NixOS read-only symlinks)
if [ -L "$CONFIG_FILE" ] && [ ! -w "$CONFIG_FILE" ]; then
echo "Detected read-only symlink, converting to local file..."
cp --remove-destination "$(readlink -f "$CONFIG_FILE")" "$CONFIG_FILE"
chmod +w "$CONFIG_FILE"
fi
# Add the include line to the end of the file
echo -e "\n$INCLUDE_LINE\n" >>"$CONFIG_FILE"
echo "✅ Added noctalia theme include to config."
fi
fi
# Reload hyprland
hyprctl reload
;;
sway)
echo "🎨 Applying 'noctalia' theme to Sway..."
CONFIG_DIR="$HOME/.config/sway"
CONFIG_FILE="$CONFIG_DIR/config"
INCLUDE_LINE='include ~/.config/sway/noctalia'
# Check if the config file exists.
if [ ! -f "$CONFIG_FILE" ]; then
echo "Config file not found, creating $CONFIG_FILE..."
mkdir -p "$(dirname "$CONFIG_FILE")"
echo -e "\n$INCLUDE_LINE\n" >"$CONFIG_FILE"
echo "Created new config file with noctalia theme."
else
# Check if noctalia include already exists (flexible matching)
if grep -qE 'include\s+.*noctalia' "$CONFIG_FILE"; then
echo "Theme already included, skipping modification."
else
# Only convert symlink when we actually need to write (NixOS read-only symlinks)
if [ -L "$CONFIG_FILE" ] && [ ! -w "$CONFIG_FILE" ]; then
echo "Detected read-only symlink, converting to local file..."
cp --remove-destination "$(readlink -f "$CONFIG_FILE")" "$CONFIG_FILE"
chmod +w "$CONFIG_FILE"
fi
# Add the include line to the end of the file
echo -e "\n$INCLUDE_LINE\n" >>"$CONFIG_FILE"
echo "✅ Added noctalia theme include to config."
fi
fi
# Reload sway
swaymsg reload
;;
scroll)
echo "Applying 'noctalia' theme to Scroll..."
CONFIG_DIR="$HOME/.config/scroll"
CONFIG_FILE="$CONFIG_DIR/config"
INCLUDE_LINE='include ~/.config/scroll/noctalia'
# Check if the config file exists.
if [ ! -f "$CONFIG_FILE" ]; then
echo "Config file not found, creating $CONFIG_FILE..."
mkdir -p "$(dirname "$CONFIG_FILE")"
echo -e "\n$INCLUDE_LINE\n" >"$CONFIG_FILE"
echo "Created new config file with noctalia theme."
else
# Check if noctalia include already exists (flexible matching)
if grep -qE 'include\s+.*noctalia' "$CONFIG_FILE"; then
echo "Theme already included, skipping modification."
else
# Only convert symlink when we actually need to write
if [ -L "$CONFIG_FILE" ] && [ ! -w "$CONFIG_FILE" ]; then
echo "Detected read-only symlink, converting to local file..."
cp --remove-destination "$(readlink -f "$CONFIG_FILE")" "$CONFIG_FILE"
chmod +w "$CONFIG_FILE"
fi
# Add the include line to the end of the file
echo -e "\n$INCLUDE_LINE\n" >>"$CONFIG_FILE"
echo "Added noctalia theme include to config."
fi
fi
# Reload scroll
scrollmsg reload
;;
mango)
CONFIG_DIR="$HOME/.config/mango"
MAIN_CONFIG="$CONFIG_DIR/config.conf"
THEME_FILE="$CONFIG_DIR/noctalia.conf"
BACKUP_FILE="$CONFIG_DIR/theme.conf.bak"
# This sources the noctalia theme file
SOURCE_LINE="source = $THEME_FILE"
# Color variables that should be moved to theme file
COLOR_VARS="shadowscolor|rootcolor|bordercolor|focuscolor|maximizescreencolor|urgentcolor|scratchpadcolor|globalcolor|overlaycolor"
# Create config directory if it doesn't exist
mkdir -p "$CONFIG_DIR"
# Check if theme is already sourced in main config
if [ -f "$MAIN_CONFIG" ] && grep -qF "$SOURCE_LINE" "$MAIN_CONFIG"; then
: # Theme already set
else
# First-time setup: backup and remove legacy color definitions
# Scan all .conf files in config directory for legacy color variables
for conf_file in "$CONFIG_DIR"/*.conf; do
# Skip if no .conf files exist or if it's the theme file itself
[ -e "$conf_file" ] || continue
[ "$conf_file" = "$THEME_FILE" ] && continue
# Check if this file contains any color variable definitions
if grep -qE "^($COLOR_VARS)\s*=" "$conf_file"; then
# Extract and append color definitions to backup file
grep -E "^($COLOR_VARS)\s*=" "$conf_file" >>"$BACKUP_FILE"
# Remove color definitions from original file
if [ -L "$conf_file" ] && [ ! -w "$conf_file" ]; then
# Read-only symlink (e.g. NixOS): convert to local file
cp --remove-destination "$(readlink -f "$conf_file")" "$conf_file"
chmod +w "$conf_file"
sed -i -E "/^($COLOR_VARS)\s*=/d" "$conf_file"
else
# Edit the real file, preserving any writable symlink
sed -i -E "/^($COLOR_VARS)\s*=/d" "$(readlink -f "$conf_file")"
fi
fi
done
# Only convert symlink when we actually need to write
if [ -L "$MAIN_CONFIG" ] && [ ! -w "$MAIN_CONFIG" ]; then
echo "Detected read-only symlink, converting to local file..."
cp --remove-destination "$(readlink -f "$MAIN_CONFIG")" "$MAIN_CONFIG"
chmod +w "$MAIN_CONFIG"
fi
# Add source line to main config
if [ -f "$MAIN_CONFIG" ]; then
echo "" >>"$MAIN_CONFIG"
echo "# This sources the noctalia theme" >>"$MAIN_CONFIG"
echo -e "\n$SOURCE_LINE\n" >>"$MAIN_CONFIG"
else
echo "# This sources the noctalia theme" >"$MAIN_CONFIG"
echo -e "\n$SOURCE_LINE\n" >>"$MAIN_CONFIG"
fi
fi
# Trigger live reload
if command -v mmsg >/dev/null 2>&1; then
mmsg -s -d reload_config
else
echo "Warning: mmsg command not found, manual restart may be needed." >&2
fi
;;
btop)
CONFIG_FILE="$HOME/.config/btop/btop.conf"
if [ -f "$CONFIG_FILE" ]; then
# Check if theme is already set to noctalia (flexible spacing)
if grep -qE '^color_theme\s*=\s*"noctalia"' "$CONFIG_FILE"; then
: # Already correct
elif grep -qE '^color_theme\s*=' "$CONFIG_FILE"; then
# Replace existing color_theme line in-place
sed -i -E 's/^color_theme\s*=.*/color_theme = "noctalia"/' "$CONFIG_FILE"
else
echo 'color_theme = "noctalia"' >>"$CONFIG_FILE"
fi
if pgrep -x btop >/dev/null; then
pkill -SIGUSR2 -x btop
fi
else
echo "Warning: btop config file not found at $CONFIG_FILE" >&2
fi
;;
zathura)
ZATHURA_INSTANCES=$(dbus-send --session \
--dest=org.freedesktop.DBus \
--type=method_call \
--print-reply \
/org/freedesktop/DBus \
org.freedesktop.DBus.ListNames |
grep -o 'org.pwmt.zathura.PID-[0-9]*')
for id in $ZATHURA_INSTANCES; do
dbus-send --session \
--dest="$id" \
--type=method_call \
/org/pwmt/zathura \
org.pwmt.zathura.ExecuteCommand \
string:"source"
done
;;
*)
# Handle unknown application names.
echo "Error: Unknown application '$APP_NAME'." >&2
exit 1
;;
esac
@@ -0,0 +1,436 @@
#!/usr/bin/env python3
"""
Build settings search index from QML source files.
Parses settings tab QML files to extract searchable metadata
(i18n keys, widget types, tab/sub-tab locations, visibility conditions).
Output: Assets/settings-search-index.json
Usage:
python Scripts/dev/build-settings-search-index.py
"""
import json
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent.parent
SETTINGS_DIR = ROOT / "Modules" / "Panels" / "Settings"
TABS_DIR = SETTINGS_DIR / "Tabs"
OUTPUT = ROOT / "Assets" / "settings-search-index.json"
# Widget types that have searchable label/description
WIDGET_TYPES = (
"NToggle",
"NComboBox",
"NValueSlider",
"NSpinBox",
"NSearchableComboBox",
"NTextInputButton",
"NTextInput",
"NCheckbox",
"NLabel",
"NColorChoice",
"HookRow",
)
# Regex patterns
RE_WIDGET_OPEN = re.compile(
r"^\s*(" + "|".join(WIDGET_TYPES) + r")\s*\{", re.MULTILINE
)
RE_LABEL = re.compile(r'label:\s*I18n\.tr\("([^"]+)"')
RE_DESCRIPTION = re.compile(r'description:\s*I18n\.tr\("([^"]+)"')
RE_VISIBLE = re.compile(r'^\s*visible:\s*(.+?)(?:\s*;)?\s*$')
# Prefixes that indicate externally-resolvable conditions (singleton services or globals).
# Conditions referencing local variables (root., parent., model, index, etc.) are skipped.
RESOLVABLE_PREFIXES = (
"CompositorService.",
"Settings.data.",
"Quickshell.",
"IdleService.",
"SystemStatService.",
"SoundService.",
"BluetoothService.",
"LocationService.",
"false",
)
def parse_component_declarations(content: str) -> dict[str, str]:
"""
Parse Component declarations from SettingsContent.qml.
Returns: component_id -> QML type name (e.g. "generalTab" -> "GeneralTab")
"""
components = {}
# Match patterns like:
# Component {
# id: generalTab
# GeneralTab {}
# }
pattern = re.compile(
r"Component\s*\{\s*\n\s*id:\s*(\w+)\s*\n\s*(\w+)\s*\{",
re.MULTILINE,
)
for m in pattern.finditer(content):
comp_id = m.group(1)
type_name = m.group(2)
components[comp_id] = type_name
return components
def parse_tabs_model_order(content: str) -> list[tuple[str, str]]:
"""
Parse updateTabsModel() to get the ordered list of (source_id, label_key) pairs.
Returns: list of (component_id, i18n_label_key) in display order.
"""
# Find the updateTabsModel function body
match = re.search(r"function updateTabsModel\(\)\s*\{", content)
if not match:
return []
func_body = content[match.end():]
# Extract tab entries: each has "label" and "source" fields
entries = []
for m in re.finditer(
r'"label":\s*"([^"]+)"[^}]*?"source":\s*(\w+)',
func_body,
re.DOTALL,
):
entries.append((m.group(2), m.group(1)))
return entries
def build_tab_mappings(content: str) -> tuple[dict[str, int], dict[str, str]]:
"""
Build mappings from QML type name to tabsModel index and label key.
Parses Component declarations and updateTabsModel() order.
Returns: (type_to_index, type_to_label)
- type_to_index: e.g. {"GeneralTab": 0, ...}
- type_to_label: e.g. {"GeneralTab": "common.general", ...}
"""
components = parse_component_declarations(content)
entries = parse_tabs_model_order(content)
type_to_index = {}
type_to_label = {}
for idx, (source_id, label_key) in enumerate(entries):
type_name = components.get(source_id)
if type_name:
type_to_index[type_name] = idx
type_to_label[type_name] = label_key
return type_to_index, type_to_label
def get_subtab_info(parent_tab_file: Path) -> tuple[list[str], list[str | None]]:
"""
Parse a parent tab file to get subtab order and labels.
Returns: (subtab_type_names, subtab_label_keys)
- subtab_type_names: list of component names like ["VolumesSubTab", ...]
- subtab_label_keys: list of i18n keys like ["common.volumes", ...] (same order)
"""
content = parent_tab_file.read_text()
# Extract NTabButton labels in order from NTabBar
labels = []
in_tabbar = False
tabbar_depth = 0
for line in content.splitlines():
stripped = line.strip()
if not in_tabbar:
if re.match(r"NTabBar\s*\{", stripped):
in_tabbar = True
tabbar_depth = 1
continue
continue
tabbar_depth += stripped.count("{") - stripped.count("}")
if tabbar_depth <= 0:
break
# Match text: I18n.tr("...") inside NTabButton
m = re.search(r'text:\s*I18n\.tr\("([^"]+)"', stripped)
if m:
labels.append(m.group(1))
# Extract subtab component names from NTabView
subtabs = []
in_tabview = False
tabview_depth = 0
for line in content.splitlines():
stripped = line.strip()
if not in_tabview:
if re.match(r"NTabView\s*\{", stripped):
in_tabview = True
tabview_depth = 1
continue
continue
tabview_depth += stripped.count("{") - stripped.count("}")
if tabview_depth <= 0:
break
# Match component instantiations like "VolumesSubTab {}" or "VolumesSubTab {"
m = re.match(r"(\w+SubTab)\s*\{", stripped)
if m:
subtabs.append(m.group(1))
# Pad labels list if shorter than subtabs (shouldn't happen, but safety)
while len(labels) < len(subtabs):
labels.append(None)
return subtabs, labels[:len(subtabs)]
def resolve_tab_info(
qml_file: Path,
type_to_index: dict[str, int],
type_to_label: dict[str, str],
) -> tuple[int | None, str | None, int | None, str | None]:
"""
Determine the tab index, tab label, sub-tab index, and sub-tab label for a QML file.
Returns (tab_index, tab_label_key, sub_tab_index, sub_tab_label_key)
"""
parent = qml_file.parent
stem = qml_file.stem
# Top-level tab files (directly in Tabs/)
if parent == TABS_DIR:
tab_index = type_to_index.get(stem)
tab_label = type_to_label.get(stem)
return tab_index, tab_label, None, None
# Sub-directory files
dir_name = parent.name # e.g. "Audio", "Bar"
parent_type = f"{dir_name}Tab" # e.g. "AudioTab"
tab_index = type_to_index.get(parent_type)
tab_label = type_to_label.get(parent_type)
if tab_index is None:
return None, None, None, None
# Skip the parent tab file itself (e.g. AudioTab.qml) — still scan for widgets
if stem.endswith("Tab") and not stem.endswith("SubTab"):
return tab_index, tab_label, None, None
# Determine sub-tab index and label from parent tab's NTabBar/NTabView
parent_tab_file = parent / f"{dir_name}Tab.qml"
if not parent_tab_file.exists():
return tab_index, tab_label, None, None
subtab_names, subtab_labels = get_subtab_info(parent_tab_file)
try:
idx = subtab_names.index(stem)
sub_label = subtab_labels[idx] if idx < len(subtab_labels) else None
return tab_index, tab_label, idx, sub_label
except ValueError:
# File doesn't map to any subtab (e.g. a dialog). If the parent tab
# has subtabs, the focus ring can't reach widgets inside dialogs, so
# exclude them from the index.
if subtab_names:
return None, None, None, None
return tab_index, tab_label, None, None
def is_resolvable_condition(cond: str) -> bool:
"""Check if a visibility condition can be resolved at runtime by the shell."""
# Strip negation for prefix checking
check = cond.lstrip("!").lstrip(" ").lstrip("(").lstrip(" ")
return any(check.startswith(p) for p in RESOLVABLE_PREFIXES)
def build_scope_visibility(content: str) -> dict[int, list[str]]:
"""
For each line number, compute the list of inherited visibility conditions
from all enclosing QML scopes.
Tracks brace-depth to maintain a scope stack. When a ``visible:`` property
is found, it is associated with the innermost scope. The conditions are
inherited by all lines within that scope.
Returns: line_number -> list of condition strings
"""
lines = content.splitlines()
# Each entry: visibility condition string or None
scope_stack: list[str | None] = []
result: dict[int, list[str]] = {}
for line_num, raw_line in enumerate(lines):
stripped = raw_line.strip()
# Record inherited conditions BEFORE processing this line's braces.
# This means a widget opening on this line inherits from parent scopes,
# not from its own scope (which hasn't been populated yet).
result[line_num] = [c for c in scope_stack if c is not None]
# Process opening braces — push new scopes
n_opens = stripped.count("{")
for _ in range(n_opens):
scope_stack.append(None)
# Check for visible: property — assign to the innermost scope
vis_match = RE_VISIBLE.match(stripped)
if vis_match and scope_stack:
cond = vis_match.group(1).strip()
if cond != "true":
scope_stack[-1] = cond
# Process closing braces — pop scopes
n_closes = stripped.count("}")
for _ in range(n_closes):
if scope_stack:
scope_stack.pop()
return result
def extract_widget_blocks(content: str) -> list[tuple[str, str, int]]:
"""
Extract (widget_type, block_text, start_line) tuples from QML content.
Uses brace-depth tracking to capture the full widget block.
"""
results = []
lines = content.splitlines()
i = 0
while i < len(lines):
m = RE_WIDGET_OPEN.match(lines[i])
if m:
widget_type = m.group(1)
depth = 0
block_lines = []
start_line = i
j = i
while j < len(lines):
line = lines[j]
block_lines.append(line)
depth += line.count("{") - line.count("}")
if depth <= 0:
break
j += 1
block_text = "\n".join(block_lines)
results.append((widget_type, block_text, start_line))
i = j + 1
else:
i += 1
return results
def extract_entries(
qml_file: Path,
type_to_index: dict[str, int],
type_to_label: dict[str, str],
) -> list[dict]:
"""Extract all searchable settings entries from a QML file."""
tab_index, tab_label, sub_tab, sub_tab_label = resolve_tab_info(
qml_file, type_to_index, type_to_label
)
if tab_index is None:
return []
content = qml_file.read_text()
scope_vis = build_scope_visibility(content)
entries = []
for widget_type, block, start_line in extract_widget_blocks(content):
label_match = RE_LABEL.search(block)
if not label_match:
continue
label_key = label_match.group(1)
desc_match = RE_DESCRIPTION.search(block)
desc_key = desc_match.group(1) if desc_match else None
# Collect visibility conditions: ancestor scopes + widget's own visible:
conditions = list(scope_vis.get(start_line, []))
widget_vis = re.search(
r'^\s*visible:\s*(.+?)(?:\s*;)?\s*$', block, re.MULTILINE
)
if widget_vis:
cond = widget_vis.group(1).strip()
if cond != "true" and cond not in conditions:
conditions.append(cond)
# Keep only externally-resolvable conditions
conditions = [c for c in conditions if is_resolvable_condition(c)]
entry = {
"labelKey": label_key,
"descriptionKey": desc_key,
"widget": widget_type,
"tab": tab_index,
"tabLabel": tab_label,
"subTab": sub_tab,
}
if sub_tab_label is not None:
entry["subTabLabel"] = sub_tab_label
if conditions:
entry["visibleWhen"] = conditions
entries.append(entry)
return entries
def main():
if not TABS_DIR.exists():
print(f"Error: Tabs directory not found: {TABS_DIR}", file=sys.stderr)
sys.exit(1)
settings_content = SETTINGS_DIR / "SettingsContent.qml"
if not settings_content.exists():
print(f"Error: SettingsContent.qml not found: {settings_content}", file=sys.stderr)
sys.exit(1)
# Build type -> tabsModel index/label mappings from SettingsContent.qml
content = settings_content.read_text()
type_to_index, type_to_label = build_tab_mappings(content)
if not type_to_index:
print("Error: Could not parse tab model from SettingsContent.qml", file=sys.stderr)
sys.exit(1)
print(f"Parsed {len(type_to_index)} tab types from SettingsContent.qml")
all_entries = []
seen_labels = set()
# Scan all QML files in Tabs/ (recursive)
for qml_file in sorted(TABS_DIR.rglob("*.qml")):
entries = extract_entries(qml_file, type_to_index, type_to_label)
for entry in entries:
if entry["labelKey"] not in seen_labels:
seen_labels.add(entry["labelKey"])
all_entries.append(entry)
# Write output
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
with open(OUTPUT, "w") as f:
json.dump(all_entries, f, indent=2)
print(f"Generated {len(all_entries)} entries -> {OUTPUT.relative_to(ROOT)}")
if __name__ == "__main__":
main()
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# Generate a registry.json from all color schemes in Assets/ColorScheme
# Output format matches ~/Development/misc/noctalia/noctalia-colorschemes/registry.json
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
COLORSCHEME_DIR="$PROJECT_ROOT/Assets/ColorScheme"
# Start JSON output
echo '{'
echo ' "version": 1,'
echo ' "themes": ['
first=true
for dir in "$COLORSCHEME_DIR"/*/; do
[ -d "$dir" ] || continue
name=$(basename "$dir")
json_file="$dir/$name.json"
[ -f "$json_file" ] || continue
# Read the JSON file content
content=$(cat "$json_file")
# Extract dark and light objects using jq
dark=$(echo "$content" | jq -c '.dark')
light=$(echo "$content" | jq -c '.light')
# Skip if missing dark or light
[ "$dark" = "null" ] || [ "$light" = "null" ] && continue
# Add comma before all but first entry
if [ "$first" = true ]; then
first=false
else
echo ','
fi
# Output theme entry
printf ' {\n'
printf ' "name": "%s",\n' "$name"
printf ' "path": "%s",\n' "$name"
printf ' "dark": %s,\n' "$dark"
printf ' "light": %s\n' "$light"
printf ' }'
done
echo ''
echo ' ]'
echo '}'
@@ -0,0 +1,73 @@
#!/bin/bash
# Pull translations from Noctalia Translate API
# Usage: ./i18n-pull.sh <project-slug> <output-dir>
# Example: ./i18n-pull.sh noctalia-shell ./Assets/Translations
set -e
# Configuration
API_BASE="${I18N_API_BASE:-https://i18n.noctalia.dev}"
PROJECT_SLUG="${1:-noctalia-shell}"
OUTPUT_DIR="${2:-./Assets/Translations}"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo -e "${YELLOW}Pulling translations for project: ${PROJECT_SLUG}${NC}"
echo "API: ${API_BASE}"
echo "Output: ${OUTPUT_DIR}"
echo ""
# Confirmation
read -p "Pull translations and overwrite local files? [y/N] " -n 1 -r
echo ""
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Aborted."
exit 0
fi
echo ""
# Create output directory if it doesn't exist
mkdir -p "$OUTPUT_DIR"
# Pull all translations
RESPONSE=$(curl -s -w "\n%{http_code}" "${API_BASE}/api/projects/${PROJECT_SLUG}/pull")
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
BODY=$(echo "$RESPONSE" | sed '$d')
if [ "$HTTP_CODE" != "200" ]; then
echo -e "${RED}Error: HTTP ${HTTP_CODE}${NC}"
echo "$BODY"
exit 1
fi
# Check if jq is available
if ! command -v jq &> /dev/null; then
echo -e "${RED}Error: jq is required but not installed${NC}"
echo "Install with: apt install jq (Linux) or brew install jq (macOS)"
exit 1
fi
# Get list of locales
LOCALES=$(echo "$BODY" | jq -r 'keys[]')
if [ -z "$LOCALES" ]; then
echo -e "${RED}Error: No translations found${NC}"
exit 1
fi
# Save each locale to a separate file
COUNT=0
for LOCALE in $LOCALES; do
OUTPUT_FILE="${OUTPUT_DIR}/${LOCALE}.json"
echo "$BODY" | jq ".[\"${LOCALE}\"]" > "$OUTPUT_FILE"
echo -e "${GREEN}Saved: ${OUTPUT_FILE}${NC}"
COUNT=$((COUNT + 1))
done
echo ""
echo -e "${GREEN}Successfully pulled ${COUNT} language(s)${NC}"
@@ -0,0 +1,143 @@
#!/bin/bash
# Push translations to Noctalia Translate API
# Usage: TRANSLATION_PUSH_SECRET=your_secret ./push-translations.sh [--overwrite] [--lang <code>] [/path/to/Assets/Translations]
# Or set the secret in environment and pass the path as argument
#
# Options:
# --overwrite Overwrite existing translations
# --lang <code> Upload only a single language (e.g., --lang fr)
set -e
# Parse arguments
OVERWRITE=false
TRANSLATIONS_DIR="Assets/Translations"
SINGLE_LANG=""
while [[ $# -gt 0 ]]; do
case $1 in
--overwrite)
OVERWRITE=true
shift
;;
--lang)
SINGLE_LANG="$2"
shift 2
;;
*)
TRANSLATIONS_DIR="$1"
shift
;;
esac
done
# Configuration
API_URL="${TRANSLATION_API_URL:-https://i18n.noctalia.dev}"
PROJECT_SLUG="${TRANSLATION_PROJECT:-noctalia-shell}"
# Check for secret
if [ -z "$NOCTALIA_SHELL_TRANSLATION_PUSH_SECRET" ]; then
echo "Error: NOCTALIA_SHELL_TRANSLATION_PUSH_SECRET environment variable is required"
exit 1
fi
# Check if directory exists
if [ ! -d "$TRANSLATIONS_DIR" ]; then
echo "Error: Directory not found: $TRANSLATIONS_DIR"
exit 1
fi
# Check for jq
if ! command -v jq &> /dev/null; then
echo "Error: jq is required but not installed"
echo "Install with: apt install jq"
exit 1
fi
echo "Pushing translations from: $TRANSLATIONS_DIR"
echo "Target: $API_URL/api/projects/$PROJECT_SLUG/push"
# Build combined JSON object
COMBINED_JSON="{}"
if [ -n "$SINGLE_LANG" ]; then
# Single language mode
file="$TRANSLATIONS_DIR/$SINGLE_LANG.json"
if [ ! -f "$file" ]; then
echo "Error: Language file not found: $file"
exit 1
fi
echo " Loading: $SINGLE_LANG ($SINGLE_LANG.json)"
COMBINED_JSON=$(echo "$COMBINED_JSON" | jq --arg locale "$SINGLE_LANG" --slurpfile content "$file" '. + {($locale): $content[0]}')
else
# All languages mode
for file in "$TRANSLATIONS_DIR"/*.json; do
if [ -f "$file" ]; then
# Extract locale from filename (e.g., en.json -> en)
filename=$(basename "$file")
locale="${filename%.json}"
echo " Loading: $locale ($filename)"
# Add this locale's translations to the combined object
COMBINED_JSON=$(echo "$COMBINED_JSON" | jq --arg locale "$locale" --slurpfile content "$file" '. + {($locale): $content[0]}')
fi
done
fi
# Count locales
LOCALE_COUNT=$(echo "$COMBINED_JSON" | jq 'keys | length')
echo "Found $LOCALE_COUNT locale(s)"
if [ "$LOCALE_COUNT" -eq 0 ]; then
echo "Error: No JSON files found in $TRANSLATIONS_DIR"
exit 1
fi
# Check if English exists (only required when pushing all languages)
if [ -z "$SINGLE_LANG" ] && ! echo "$COMBINED_JSON" | jq -e '.en' > /dev/null 2>&1; then
echo "Error: English (en.json) is required"
exit 1
fi
# Confirmation
echo ""
if [ -n "$SINGLE_LANG" ]; then
read -p "Push '$SINGLE_LANG' to $API_URL? [y/N] " -n 1 -r
else
read -p "Push $LOCALE_COUNT locale(s) to $API_URL? [y/N] " -n 1 -r
fi
echo ""
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Aborted."
exit 0
fi
# Build URL with optional overwrite parameter
PUSH_URL="$API_URL/api/projects/$PROJECT_SLUG/push"
if [ "$OVERWRITE" = true ]; then
PUSH_URL="$PUSH_URL?overwrite=true"
echo "Overwrite mode enabled"
fi
# Push to API
echo "Pushing to API..."
RESPONSE=$(echo "$COMBINED_JSON" | curl -s -w "\n%{http_code}" -X POST \
"$PUSH_URL" \
-H "Authorization: Bearer $NOCTALIA_SHELL_TRANSLATION_PUSH_SECRET" \
-H "Content-Type: application/json" \
-d @-)
# Extract HTTP status code (last line) and body (everything else)
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
BODY=$(echo "$RESPONSE" | sed '$d')
if [ "$HTTP_CODE" -eq 200 ]; then
echo "Success!"
echo "$BODY" | jq .
else
echo "Error: HTTP $HTTP_CODE"
echo "$BODY" | jq . 2>/dev/null || echo "$BODY"
exit 1
fi
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Test script for notification replacement functionality
echo "Testing notification replacement..."
echo ""
# Send initial notification and capture the ID
echo "Step 1: Sending initial notification 'asdf'..."
NOTIF_ID=$(notify-send -p "asdf")
echo "Notification ID: $NOTIF_ID"
echo ""
# Wait a moment for the notification to appear
sleep 1
# Replace the notification
echo "Step 2: Replacing notification $NOTIF_ID with 'test'..."
notify-send -r "$NOTIF_ID" -p "test"
echo ""
echo "The notification should now show 'test' instead of 'asdf'."
echo "If it still shows 'asdf', the replacement is not working."
@@ -0,0 +1,52 @@
#!/usr/bin/env -S bash
echo "Sending test notifications..."
# Send a bunch of notifications with numbers
for i in {1..4}; do
notify-send "Notification $i" "This is test notification number $i with a very long text that will probably break the layout or maybe not? Who knows? Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."
sleep 1
done
echo "All notifications sent!"
# Additional tests for icon/image handling
if command -v notify-send >/dev/null 2>&1; then
echo "Sending icon/image tests..."
# 1) Themed icon name
notify-send -i dialog-information "Icon name test" "Should resolve from theme (dialog-information)"
sleep 1
# 2) Absolute path if a sample image exists
SAMPLE_IMG="/usr/share/pixmaps/steam.png"
if [ -f "$SAMPLE_IMG" ]; then
notify-send -i "$SAMPLE_IMG" "Absolute path test" "Should show the provided image path"
fi
sleep 1
# 3) file:// URL form
if [ -f "$SAMPLE_IMG" ]; then
notify-send -i "file://$SAMPLE_IMG" "file:// URL test" "Should display after stripping scheme"
fi
sleep 1
echo "Icon/image tests sent!"
fi
# A test notification with actions
gdbus call --session \
--dest org.freedesktop.Notifications \
--object-path /org/freedesktop/Notifications \
--method org.freedesktop.Notifications.Notify \
"my-app" \
0 \
"dialog-question" \
"Confirmation Required" \
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Do you want to proceed with the action? " \
"['default', 'OK', 'cancel', 'Cancel', 'maybe', 'Maybe', 'undecided', 'Undecided']" \
"{}" \
5000
@@ -0,0 +1,51 @@
#!/usr/bin/env -S bash
set -euo pipefail
# QML Formatter Script
# Suppress Qt debug logging from qmlformat
export QT_LOGGING_RULES="qt.qmldom.*=false"
# Find qmlformat binary
QMLFORMAT=""
for path in "/usr/lib64/qt6/bin/qmlformat" "/usr/lib/qt6/bin/qmlformat"; do
if [ -x "$path" ]; then
QMLFORMAT="$path"
break
fi
done
# Fallback to PATH
if [ -z "$QMLFORMAT" ] && command -v qmlformat &>/dev/null; then
QMLFORMAT="qmlformat"
fi
if [ -z "$QMLFORMAT" ]; then
echo "No 'qmlformat' found in standard locations or PATH." >&2
echo "To proceed, install it via 'qt6-tools', 'qt6-declarative-tools' or 'qt6-qtdeclarative-devel'" >&2
exit 1
fi
# Detect qmlformat version for flag compatibility
EXTRA_FLAGS=""
if version=$("$QMLFORMAT" --version 2>&1) && [[ "$version" =~ ([0-9]+\.[0-9]+) ]]; then
if [[ "$(printf '%s\n6.10\n' "${BASH_REMATCH[1]}" | sort -V | head -1)" == "6.10" ]]; then
EXTRA_FLAGS="-S --semicolon-rule always"
fi
fi
format_file() {
${QMLFORMAT} -w 2 -W 360 ${EXTRA_FLAGS} -i "$1" || { echo "Failed: $1" >&2; return 1; }
}
export -f format_file
export QMLFORMAT EXTRA_FLAGS
# Find all .qml files
mapfile -t all_files < <(find "${1:-.}" -name "*.qml" -type f)
[ ${#all_files[@]} -eq 0 ] && { echo "No QML files found"; exit 0; }
echo "Formatting ${#all_files[@]} files..."
printf '%s\0' "${all_files[@]}" | \
xargs -0 -P "${QMLFMT_JOBS:-$(nproc)}" -I {} bash -c 'format_file "$@"' _ {} \
&& echo "Done" || { echo "Errors occurred" >&2; exit 1; }
@@ -0,0 +1,92 @@
#!/bin/bash
# Find qsb binary in common locations.
QSB_PATHS=(
"/usr/lib/qt6/bin/qsb"
"/usr/lib64/qt6/bin/qsb"
)
QSB_BIN=""
for path in "${QSB_PATHS[@]}"; do
if [ -x "$path" ]; then
QSB_BIN="$path"
break
fi
done
if [ -z "$QSB_BIN" ]; then
echo "Error: qsb binary not found in any of: ${QSB_PATHS[*]}"
exit 1
fi
# Directory containing the source shaders.
SOURCE_DIR="Shaders/frag/"
# Directory where the compiled shaders will be saved.
DEST_DIR="Shaders/qsb/"
# Check if the source directory exists.
if [ ! -d "$SOURCE_DIR" ]; then
echo "Source directory $SOURCE_DIR not found!"
exit 1
fi
# Create the destination directory if it doesn't exist.
mkdir -p "$DEST_DIR"
# Array to hold the list of full paths to the shaders.
SHADERS_TO_COMPILE=()
# Specific files mode.
if [ "$#" -gt 0 ]; then
# Loop through all command-line arguments ($@ holds all arguments).
for SINGLE_FILE in "$@"; do
# Construct the full path to the source file.
FULL_PATH="$SOURCE_DIR$SINGLE_FILE"
# Check if the specified file exists in the SOURCE_DIR.
if [ ! -f "$FULL_PATH" ]; then
echo "Error: Specified file '$SINGLE_FILE' not found in $SOURCE_DIR! Skipping."
continue
fi
# Add the valid file to the compilation list.
SHADERS_TO_COMPILE+=("$FULL_PATH")
done
# Check if any valid files were found to compile.
if [ ${#SHADERS_TO_COMPILE[@]} -eq 0 ]; then
echo "No valid shaders found to compile."
exit 1
fi
# Whole directory mode (no argument provided).
else
# Use find to generate the list of files and assign it to the array.
while IFS= read -r shader_path; do
if [ -n "$shader_path" ]; then
SHADERS_TO_COMPILE+=("$shader_path")
fi
done < <(find "$SOURCE_DIR" -maxdepth 1 -name "*.frag")
fi
# Loop through the list of shaders to compile.
for shader in "${SHADERS_TO_COMPILE[@]}"; do
# Get the base name of the file (e.g., wp_fade).
shader_name=$(basename "$shader" .frag)
# Construct the output path for the compiled shader.
output_path="$DEST_DIR$shader_name.frag.qsb"
# Construct and run the qsb command.
"$QSB_BIN" --qt6 -o "$output_path" "$shader"
# Print a message to confirm compilation.
echo "Compiled $(basename "$shader") to $output_path"
done
echo "Shader compilation complete."
@@ -0,0 +1,331 @@
#!/usr/bin/env python3
"""
Analyze Noctalia's template-processor color extraction.
Usage:
./template-processor-analysis.py <wallpaper_path>
./template-processor-analysis.py ~/Pictures/Wallpapers/example.png
Shows extracted colors for all scheme types and compares M3 schemes with matugen.
Scheme types:
- M3 schemes (tonal-spot, fruit-salad, rainbow, content): Compared with matugen
- vibrant: Prioritizes the most saturated colors regardless of area
- faithful: Prioritizes dominant colors by area coverage
- dysfunctional: Like faithful but picks the 2nd most dominant color family
- muted: Preserves hue but caps saturation low (for monochrome wallpapers)
"""
import argparse
import json
import subprocess
import sys
from pathlib import Path
# Add the theming lib to path
SCRIPT_DIR = Path(__file__).parent.resolve()
THEMING_DIR = SCRIPT_DIR.parent / "python" / "src" / "theming"
sys.path.insert(0, str(THEMING_DIR))
from lib.color import Color
from lib.hct import Hct
def hue_diff(h1: float, h2: float) -> float:
"""Calculate circular hue difference."""
diff = abs(h1 - h2)
return min(diff, 360.0 - diff)
def hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
"""Convert hex to RGB tuple."""
h = hex_color.lstrip('#')
return tuple(int(h[i:i+2], 16) for i in (0, 2, 4))
def rgb_distance(hex1: str, hex2: str) -> float:
"""Calculate Euclidean RGB distance (0-441 range)."""
r1, g1, b1 = hex_to_rgb(hex1)
r2, g2, b2 = hex_to_rgb(hex2)
return ((r1-r2)**2 + (g1-g2)**2 + (b1-b2)**2) ** 0.5
def get_hct(hex_color: str) -> Hct:
"""Convert hex color to HCT."""
return Color.from_hex(hex_color).to_hct()
def hue_to_name(hue: float) -> str:
"""Convert hue to color name."""
if hue < 30 or hue >= 330:
return "RED"
elif hue < 60:
return "ORANGE"
elif hue < 90:
return "YELLOW"
elif hue < 150:
return "GREEN"
elif hue < 210:
return "CYAN"
elif hue < 270:
return "BLUE"
elif hue < 330:
return "PURPLE"
return "RED"
def run_our_processor(image_path: Path, scheme: str) -> dict | None:
"""Run our template-processor and return colors."""
cmd = [
sys.executable,
str(THEMING_DIR / "template-processor.py"),
str(image_path),
"--scheme-type", scheme,
"--dark"
]
try:
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
data = json.loads(result.stdout)
return data.get("dark", {})
except (subprocess.CalledProcessError, json.JSONDecodeError) as e:
print(f"Error running our processor: {e}", file=sys.stderr)
return None
def run_matugen(image_path: Path, scheme: str) -> dict | None:
"""Run matugen and return colors."""
matugen_scheme = f"scheme-{scheme}"
cmd = [
"matugen", "image", str(image_path),
"--json", "hex",
"--dry-run",
"-t", matugen_scheme
]
try:
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
data = json.loads(result.stdout)
colors = data.get("colors", {})
# Extract dark mode values
return {k: v.get("dark", v) for k, v in colors.items() if isinstance(v, dict)}
except subprocess.CalledProcessError as e:
print(f"Error running matugen: {e}", file=sys.stderr)
return None
except json.JSONDecodeError as e:
print(f"Error parsing matugen output: {e}", file=sys.stderr)
return None
def analyze_vibrant_faithful_muted(image_path: Path) -> None:
"""Analyze vibrant, faithful, dysfunctional, and muted mode outputs."""
print("\n" + "=" * 78)
print("VIBRANT vs FAITHFUL vs DYSFUNCTIONAL vs MUTED COMPARISON")
print("=" * 78)
print()
print("Vibrant: Prioritizes the most saturated colors regardless of area")
print("Faithful: Prioritizes dominant colors by area coverage")
print("Dysfunctional: Like faithful but picks 2nd most dominant color family")
print("Muted: Preserves hue but caps saturation low (monochrome wallpapers)")
print()
print("-" * 78)
print(f"{'Mode':<14} {'Color':<12} {'Hex':<10} {'Hue':>8} {'Chroma':>8} {'Name':<10}")
print("-" * 78)
for scheme in ["vibrant", "faithful", "dysfunctional", "muted"]:
colors = run_our_processor(image_path, scheme)
if not colors:
print(f"{scheme}: Failed to get colors")
continue
for key in ["primary", "secondary", "tertiary"]:
hex_color = colors.get(key, "")
if not hex_color:
continue
try:
hct = get_hct(hex_color)
name = hue_to_name(hct.hue)
print(f"{scheme:<14} {key:<12} {hex_color:<10} {hct.hue:>7.1f}° {hct.chroma:>7.1f} {name:<10}")
except Exception as e:
print(f"{scheme:<14} {key:<12} Error: {e}")
print("-" * 78)
# Summary comparison
vibrant = run_our_processor(image_path, "vibrant")
faithful = run_our_processor(image_path, "faithful")
dysfunctional = run_our_processor(image_path, "dysfunctional")
muted = run_our_processor(image_path, "muted")
if vibrant and faithful and dysfunctional and muted:
print()
print("Summary:")
v_hct = get_hct(vibrant.get("primary", "#000000"))
f_hct = get_hct(faithful.get("primary", "#000000"))
d_hct = get_hct(dysfunctional.get("primary", "#000000"))
m_hct = get_hct(muted.get("primary", "#000000"))
v_name = hue_to_name(v_hct.hue)
f_name = hue_to_name(f_hct.hue)
d_name = hue_to_name(d_hct.hue)
m_name = hue_to_name(m_hct.hue)
vf_diff = hue_diff(v_hct.hue, f_hct.hue)
fd_diff = hue_diff(f_hct.hue, d_hct.hue)
print(f" Vibrant primary: {vibrant.get('primary')} ({v_name}, hue {v_hct.hue:.0f}°, chroma {v_hct.chroma:.1f})")
print(f" Faithful primary: {faithful.get('primary')} ({f_name}, hue {f_hct.hue:.0f}°, chroma {f_hct.chroma:.1f})")
print(f" Dysfunctional primary:{dysfunctional.get('primary')} ({d_name}, hue {d_hct.hue:.0f}°, chroma {d_hct.chroma:.1f})")
print(f" Muted primary: {muted.get('primary')} ({m_name}, hue {m_hct.hue:.0f}°, chroma {m_hct.chroma:.1f})")
print(f" V-F hue diff: {vf_diff:.1f}°")
print(f" F-D hue diff: {fd_diff:.1f}°")
if vf_diff > 60:
print(f" → Vibrant/Faithful picked DIFFERENT color families ({v_name} vs {f_name})")
else:
print(f" → Vibrant/Faithful picked SIMILAR colors")
if fd_diff > 30:
print(f" → Faithful/Dysfunctional picked DIFFERENT color families ({f_name} vs {d_name})")
else:
print(f" → Faithful/Dysfunctional picked SIMILAR colors (may only have 1 dominant family)")
# Note the muted chroma reduction
if m_hct.chroma < 20:
print(f" → Muted successfully reduced chroma to {m_hct.chroma:.1f}")
else:
print(f" → Muted chroma still moderately high ({m_hct.chroma:.1f})")
def compare_m3_schemes(image_path: Path, has_matugen: bool) -> None:
"""Compare all M3 schemes between our processor and matugen."""
schemes = ["tonal-spot", "fruit-salad", "rainbow", "content", "monochrome"]
color_keys = ["primary", "secondary", "tertiary", "surface", "on_surface"]
print("\n" + "=" * 78)
print("M3 SCHEMES" + (" (compared with matugen)" if has_matugen else ""))
print("=" * 78)
if has_matugen:
# Header for comparison mode
print(f"{'Scheme':<12} {'Color':<14} {'Ours':<10} {'Matugen':<10} {'Diff':>10} {'Match':<10}")
print("-" * 78)
for scheme in schemes:
ours = run_our_processor(image_path, scheme)
matugen = run_matugen(image_path, scheme)
if not ours or not matugen:
print(f"{scheme}: Failed to get colors")
continue
for key in color_keys:
our_hex = ours.get(key, "")
mat_hex = matugen.get(key, "")
if not our_hex or not mat_hex:
continue
try:
our_hct = get_hct(our_hex)
mat_hct = get_hct(mat_hex)
avg_chroma = (our_hct.chroma + mat_hct.chroma) / 2
# For low-chroma colors, use RGB distance instead of hue
if avg_chroma < 15:
rgb_dist = rgb_distance(our_hex, mat_hex)
if rgb_dist < 10:
match = "excellent"
elif rgb_dist < 25:
match = "good"
elif rgb_dist < 50:
match = "fair"
else:
match = "poor"
diff_str = f"{rgb_dist:>5.1f} rgb"
else:
diff = hue_diff(our_hct.hue, mat_hct.hue)
if diff < 5:
match = "excellent"
elif diff < 15:
match = "good"
elif diff < 30:
match = "fair"
else:
match = "poor"
diff_str = f"{diff:>5.1f} hue"
print(f"{scheme:<12} {key:<14} {our_hex:<10} {mat_hex:<10} {diff_str:>10} {match:<10}")
except Exception as e:
print(f"{scheme:<12} {key:<14} Error: {e}")
print("-" * 78)
else:
# Header for standalone mode
print(f"{'Scheme':<12} {'Color':<14} {'Hex':<10} {'Hue':>8} {'Chroma':>8} {'Name':<10}")
print("-" * 78)
for scheme in schemes:
ours = run_our_processor(image_path, scheme)
if not ours:
print(f"{scheme}: Failed to get colors")
continue
for key in ["primary", "secondary", "tertiary"]:
our_hex = ours.get(key, "")
if not our_hex:
continue
try:
hct = get_hct(our_hex)
name = hue_to_name(hct.hue)
print(f"{scheme:<12} {key:<14} {our_hex:<10} {hct.hue:>7.1f}° {hct.chroma:>7.1f} {name:<10}")
except Exception as e:
print(f"{scheme:<12} {key:<14} Error: {e}")
print("-" * 78)
def main() -> int:
parser = argparse.ArgumentParser(
description="Analyze Noctalia template-processor color extraction"
)
parser.add_argument(
"wallpaper",
type=Path,
help="Path to wallpaper image"
)
parser.add_argument(
"--no-matugen",
action="store_true",
help="Skip matugen comparison"
)
args = parser.parse_args()
if not args.wallpaper.exists():
print(f"Error: File not found: {args.wallpaper}", file=sys.stderr)
return 1
print(f"\nAnalyzing: {args.wallpaper.name}")
# Check if matugen is available
has_matugen = False
if not args.no_matugen:
try:
subprocess.run(["matugen", "--version"], capture_output=True, check=True)
has_matugen = True
except (subprocess.CalledProcessError, FileNotFoundError):
print("Note: matugen not found, skipping M3 comparison")
# Always show vibrant vs faithful vs muted first (most useful)
analyze_vibrant_faithful_muted(args.wallpaper)
# Then show M3 schemes
compare_m3_schemes(args.wallpaper, has_matugen)
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -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)