251 lines
9.0 KiB
Python
Executable File
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)
|