nixos/shared/modules/services/theme_switcher/helix_theme_tester.py
2026-01-23 13:44:07 -07:00

251 lines
9.0 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Simple Helix Theme Tester - Generate Helix theme from image for testing.
Usage:
./helix_theme_tester.py test image.jpg
./helix_theme_tester.py test image.jpg --light
./helix_theme_tester.py test image.jpg --output custom_name.toml
"""
import argparse
import subprocess
import sys
from pathlib import Path
import json
# Import from existing color_mapper
from color_mapper import create_semantic_mapping, apply_semantic_mapping
def generate_palette_from_image(image_path: Path, is_light: bool = False) -> bool:
"""Run pywal on image to generate colors.json"""
cmd = ['wal', '-i', str(image_path), '--backend', 'haishoku', '-n']
if is_light:
cmd.append('-l')
try:
subprocess.run(cmd, check=True, timeout=30)
print(f"✓ Generated palette from {image_path.name}")
return True
except subprocess.CalledProcessError as e:
print(f"✗ Failed to generate palette: {e}")
return False
except FileNotFoundError:
print("✗ Error: 'wal' command not found. Is pywal16 installed?")
return False
def generate_helix_theme(output_name: str = "pywal_test") -> bool:
"""Generate Helix theme using color_mapper logic"""
colors_json = Path.home() / ".cache/wal/colors.json"
if not colors_json.exists():
print(f"✗ colors.json not found at {colors_json}")
return False
# Use existing color_mapper to create semantic mapping
print("✓ Analyzing colors and creating semantic mapping...")
mapping = create_semantic_mapping(colors_json)
# Print the mapping for visibility
print("\nSemantic color mapping:")
for role, color in mapping["semantic_mapping"].items():
props = mapping["color_analysis"][color]
print(f" {role:12} -> {color} ({props['category']:8} {props['hex']})")
# Check for contrast issues
print("\nContrast analysis:")
bg_lightness = mapping.get("color_analysis", {}).get("color0", {}).get("lightness", 50)
for role, color_key in mapping["semantic_mapping"].items():
color_props = mapping["color_analysis"][color_key]
contrast = abs(color_props["lightness"] - bg_lightness)
status = "" if contrast > 30 else ""
print(f" {status} {role:12}: contrast {contrast:.1f}")
# Apply to helix template
helix_template = Path(__file__).parent / "templates/helix_semantic.toml"
helix_output = Path.home() / f".config/helix/themes/{output_name}.toml"
helix_output.parent.mkdir(parents=True, exist_ok=True)
apply_semantic_mapping(helix_template, helix_output, mapping)
print(f"\n✓ Helix theme created: {helix_output}")
print(f"\nTest in Helix:")
print(f" hx some_file.py")
print(f" :theme {output_name}")
return True
def main():
parser = argparse.ArgumentParser(
description='Generate Helix theme from image for testing'
)
parser.add_argument('command', choices=['test'], help='Command to run')
parser.add_argument('image', type=Path, help='Path to image file')
parser.add_argument('--light', action='store_true', help='Generate light theme')
parser.add_argument('--output', default='pywal_test', help='Output theme name')
args = parser.parse_args()
if not args.image.exists():
print(f"✗ Image not found: {args.image}")
return 1
print(f"Testing with image: {args.image}")
print(f"Mode: {'light' if args.light else 'dark'}\n")
# Step 1: Generate palette
if not generate_palette_from_image(args.image, args.light):
return 1
# Step 2: Generate Helix theme
if not generate_helix_theme(args.output):
return 1
print("\n✓ Done! Now test the theme in Helix and iterate.")
return 0
if __name__ == '__main__':
sys.exit(main())
Enhanced color_mapper.py with Tweaking
Let me show you what minimal enhancements to add for tweaking:
# Add to color_mapper.py after existing imports
def adjust_color_lightness(hex_color: str, factor: float) -> str:
"""
Adjust color lightness by factor.
factor > 1.0 = lighter, factor < 1.0 = darker
"""
rgb = hex_to_rgb(hex_color)
h, s, l = rgb_to_hsl(rgb)
# Adjust lightness
l = max(0, min(100, l * factor))
# Convert back to RGB then hex
r, g, b = colorsys.hls_to_rgb(h/360, l/100, s/100)
return f"#{int(r*255):02x}{int(g*255):02x}{int(b*255):02x}"
def adjust_color_saturation(hex_color: str, factor: float) -> str:
"""
Adjust color saturation by factor.
factor > 1.0 = more saturated, factor < 1.0 = less saturated
"""
rgb = hex_to_rgb(hex_color)
h, s, l = rgb_to_hsl(rgb)
# Adjust saturation
s = max(0, min(100, s * factor))
# Convert back
r, g, b = colorsys.hls_to_rgb(h/360, l/100, s/100)
return f"#{int(r*255):02x}{int(g*255):02x}{int(b*255):02x}"
def calculate_contrast_ratio(color1_hex: str, color2_hex: str) -> float:
"""Calculate WCAG contrast ratio between two colors"""
def relative_luminance(hex_color):
rgb = hex_to_rgb(hex_color)
r, g, b = [x / 255.0 for x in rgb]
# Apply gamma correction
r = r / 12.92 if r <= 0.03928 else ((r + 0.055) / 1.055) ** 2.4
g = g / 12.92 if g <= 0.03928 else ((g + 0.055) / 1.055) ** 2.4
b = b / 12.92 if b <= 0.03928 else ((b + 0.055) / 1.055) ** 2.4
return 0.2126 * r + 0.7152 * g + 0.0722 * b
l1 = relative_luminance(color1_hex)
l2 = relative_luminance(color2_hex)
lighter = max(l1, l2)
darker = min(l1, l2)
return (lighter + 0.05) / (darker + 0.05)
def enforce_minimum_contrast(fg_hex: str, bg_hex: str, min_ratio: float = 4.5) -> str:
"""
Adjust foreground color to meet minimum contrast ratio with background.
Preserves hue and saturation, only adjusts lightness.
"""
current_ratio = calculate_contrast_ratio(fg_hex, bg_hex)
if current_ratio >= min_ratio:
return fg_hex # Already meets requirement
# Determine if we need to lighten or darken
bg_rgb = hex_to_rgb(bg_hex)
bg_h, bg_s, bg_l = rgb_to_hsl(bg_rgb)
fg_rgb = hex_to_rgb(fg_hex)
fg_h, fg_s, fg_l = rgb_to_hsl(fg_rgb)
# If background is dark, lighten foreground; if light, darken foreground
step = 5 if bg_l < 50 else -5
# Iteratively adjust lightness
attempts = 0
while attempts < 20: # Prevent infinite loop
fg_l += step
fg_l = max(0, min(100, fg_l))
# Convert back to hex
r, g, b = colorsys.hls_to_rgb(fg_h/360, fg_l/100, fg_s/100)
adjusted_hex = f"#{int(r*255):02x}{int(g*255):02x}{int(b*255):02x}"
if calculate_contrast_ratio(adjusted_hex, bg_hex) >= min_ratio:
return adjusted_hex
attempts += 1
# If we couldn't meet contrast, return best effort
return adjusted_hex
# Add parameters to apply_semantic_mapping
def apply_semantic_mapping(template_path: Path, output_path: Path, mapping: Dict,
adjustments: Dict = None):
"""
Apply semantic mapping to Helix template.
adjustments: Optional dict with tweaking parameters:
{
'comment': {'contrast': 4.5, 'saturation': 1.2},
'error': {'saturation': 1.3, 'force_hue': (0, 30)},
}
"""
with open(template_path) as f:
template = f.read()
# Read pywal colors
colors_json = Path.home() / ".cache/wal/colors.json"
with open(colors_json) as f:
colors_data = json.load(f)
# Get background for contrast calculations
bg_color = colors_data["special"]["background"]
# Replace standard pywal colors first
output = template
for key, value in colors_data["special"].items():
output = output.replace(f"{{{key}}}", value)
for key, value in colors_data["colors"].items():
output = output.replace(f"{{{key}}}", value)
# Now apply semantic colors with optional adjustments
semantic = mapping["semantic_mapping"]
for role in ["error", "warning", "comment", "string", "keyword", "number", "info"]:
color_key = semantic[role]
color_hex = colors_data["colors"][color_key]
# Apply adjustments if provided
if adjustments and role in adjustments:
adj = adjustments[role]
# Apply saturation adjustment
if 'saturation' in adj:
color_hex = adjust_color_saturation(color_hex, adj['saturation'])
# Apply lightness adjustment
if 'lightness' in adj:
color_hex = adjust_color_lightness(color_hex, adj['lightness'])
# Enforce minimum contrast
if 'contrast' in adj:
color_hex = enforce_minimum_contrast(color_hex, bg_color, adj['contrast'])
# Replace in template
placeholder = f"{{{role.upper()}_COLOR}}"
output = output.replace(placeholder, color_hex)
with open(output_path, 'w') as f:
f.write(output)