fedora
This commit is contained in:
@@ -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
|
||||
+436
@@ -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()
|
||||
+53
@@ -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
|
||||
+23
@@ -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."
|
||||
+331
@@ -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())
|
||||
+242
@@ -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))
|
||||
|
||||
+11
@@ -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}")
|
||||
+71
@@ -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()
|
||||
+21
@@ -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))
|
||||
+191
@@ -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)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
+88
@@ -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])
|
||||
+347
@@ -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)
|
||||
Reference in New Issue
Block a user