template-processor: removed the old 'sed' implementation and moved it to python

This commit is contained in:
Lemmy
2026-01-19 10:10:53 -05:00
parent 607b8ee85c
commit ed5078adf3
5 changed files with 464 additions and 518 deletions
+3
View File
@@ -17,6 +17,7 @@ from .image import read_image, ImageReadError
from .palette import extract_palette
from .theme import generate_theme
from .renderer import TemplateRenderer
from .scheme import expand_predefined_scheme
__all__ = [
# Color
@@ -44,4 +45,6 @@ __all__ = [
"generate_theme",
# Renderer
"TemplateRenderer",
# Scheme
"expand_predefined_scheme",
]
+289
View File
@@ -0,0 +1,289 @@
"""
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, adjust_surface
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 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 = _hex_to_color(scheme_data["mOutline"])
# 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 from the surface color
surface_h, surface_s, _ = surface.to_hsl()
base_surface = Color.from_hsl(surface_h, surface_s, 0.5)
if is_dark:
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)
surface_dim = adjust_surface(base_surface, 0.85, 0.08)
surface_bright = adjust_surface(base_surface, 0.75, 0.24)
else:
surface_container_lowest = adjust_surface(base_surface, 0.85, 0.96)
surface_container_low = adjust_surface(base_surface, 0.85, 0.92)
surface_container = adjust_surface(base_surface, 0.80, 0.86)
surface_container_high = adjust_surface(base_surface, 0.75, 0.84)
surface_container_highest = adjust_surface(base_surface, 0.70, 0.80)
surface_dim = adjust_surface(base_surface, 0.85, 0.82)
surface_bright = adjust_surface(base_surface, 0.90, 0.95)
# 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))
# Shadow and scrim
shadow = surface # Use surface color for shadow in dark mode
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(),
}
+117 -84
View File
@@ -41,7 +41,7 @@ import sys
from pathlib import Path
# Import from lib package
from lib import read_image, ImageReadError, extract_palette, generate_theme, TemplateRenderer
from lib import read_image, ImageReadError, extract_palette, generate_theme, TemplateRenderer, expand_predefined_scheme
def parse_args() -> argparse.Namespace:
@@ -62,7 +62,8 @@ Examples:
parser.add_argument(
'image',
type=Path,
help='Path to wallpaper image (PNG/JPG) or JSON color palette'
nargs='?',
help='Path to wallpaper image (PNG/JPG) or JSON color palette (not required if --scheme is used)'
)
# Theme style (mutually exclusive)
@@ -121,6 +122,12 @@ Examples:
help='Theme mode: dark or light'
)
parser.add_argument(
'--scheme',
type=Path,
help='Path to predefined scheme JSON file (bypasses image extraction)'
)
return parser.parse_args()
@@ -128,95 +135,121 @@ def main() -> int:
"""Main entry point."""
args = parse_args()
# Validate image path
if not args.image.exists():
print(f"Error: Image not found: {args.image}", file=sys.stderr)
return 1
# Initialize result dictionary
result: dict[str, dict[str, str]] = {}
# Check if input is a JSON palette (Predefined Scheme bypass)
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 both/all modes since predefined scheme usually provides the correct palette for the requested mode
result["dark"] = flat_colors
result["light"] = flat_colors
# Skip extraction logic
palette = None
except Exception as e:
print(f"Error reading JSON palette: {e}", file=sys.stderr)
return 1
else:
# Standard Image Extraction
# Validate image path is a file
if not args.image.is_file():
print(f"Error: Not a file: {args.image}", file=sys.stderr)
return 1
# Read image
try:
pixels = read_image(args.image)
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
k = 5
palette = extract_palette(pixels, k=k)
if not palette:
print("Error: Could not extract colors from image", file=sys.stderr)
return 1
# Determine which themes to generate
use_material = args.material
# Handle --mode compatibility
arg_dark = args.dark
arg_light = args.light
arg_both = args.both
# Determine mode from arguments
if args.mode == 'dark':
arg_dark = True
arg_light = False
arg_both = False
modes = ["dark"]
elif args.mode == 'light':
arg_dark = False
arg_light = True
arg_both = False
modes = ["light"]
elif args.dark:
modes = ["dark"]
elif args.light:
modes = ["light"]
else:
modes = ["dark", "light"]
if palette:
if arg_dark:
result["dark"] = generate_theme(palette, "dark", use_material)
elif arg_light:
result["light"] = generate_theme(palette, "light", use_material)
# 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)
elif "mPrimary" in scheme_data:
# Single-mode format - use same colors for requested mode
result[mode] = expand_predefined_scheme(scheme_data, mode)
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 (legacy Predefined Scheme bypass)
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:
# Generate both (default)
result["dark"] = generate_theme(palette, "dark", use_material)
result["light"] = generate_theme(palette, "light", use_material)
# Standard Image Extraction
if not args.image.is_file():
print(f"Error: Not a file: {args.image}", file=sys.stderr)
return 1
try:
pixels = read_image(args.image)
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
k = 5
palette = extract_palette(pixels, k=k)
if not palette:
print("Error: Could not extract colors from image", file=sys.stderr)
return 1
# Generate theme for each mode
use_material = args.material
for mode in modes:
result[mode] = generate_theme(palette, mode, use_material)
# Output JSON
json_output = json.dumps(result, indent=2)
-128
View File
@@ -1,128 +0,0 @@
pragma Singleton
import QtQuick
import Quickshell
import "../../Helpers/ColorsConvert.js" as ColorsConvert
Singleton {
id: root
/**
* Generate Material Design 3 color palette from base colors
* @param colors - Object with mPrimary, mSecondary, mTertiary, mError, mSurface, etc.
* @param isDarkMode - Boolean indicating dark or light mode
* @param isStrict - Boolean; if true, use mSurfaceVariant/mOnSurfaceVariant/mOutline directly
* @returns Object with all MD3 color roles
*/
function generatePalette(colors, isDarkMode, isStrict) {
const c = hex => {
const hsl = ColorsConvert.hexToHSL(hex);
return {
"default": {
"hex": hex,
"hex_stripped": hex.replace(/^#/, ""),
"hue": hsl.h,
"saturation": hsl.s,
"lightness": hsl.l
}
};
};
// Generate container colors
const primaryContainer = ColorsConvert.generateContainerColor(colors.mPrimary, isDarkMode);
const secondaryContainer = ColorsConvert.generateContainerColor(colors.mSecondary, isDarkMode);
const tertiaryContainer = ColorsConvert.generateContainerColor(colors.mTertiary, isDarkMode);
// Generate "on" colors
const onPrimary = ColorsConvert.generateOnColor(colors.mPrimary, isDarkMode);
const onSecondary = ColorsConvert.generateOnColor(colors.mSecondary, isDarkMode);
const onTertiary = ColorsConvert.generateOnColor(colors.mTertiary, isDarkMode);
const onPrimaryContainer = ColorsConvert.generateOnColor(primaryContainer, isDarkMode);
const onSecondaryContainer = ColorsConvert.generateOnColor(secondaryContainer, isDarkMode);
const onTertiaryContainer = ColorsConvert.generateOnColor(tertiaryContainer, isDarkMode);
// Generate error colors (standard red-based)
const errorContainer = ColorsConvert.generateContainerColor(colors.mError, isDarkMode);
const onError = ColorsConvert.generateOnColor(colors.mError, isDarkMode);
const onErrorContainer = ColorsConvert.generateOnColor(errorContainer, isDarkMode);
// Surface is same as background in Material Design 3
const surface = colors.mSurface;
const onSurface = isStrict ? colors.mOnSurface : ColorsConvert.generateOnColor(colors.mSurface, isDarkMode);
// Generate surface variant (slightly different tone)
const surfaceVariant = isStrict ? colors.mSurfaceVariant : ColorsConvert.adjustLightness(colors.mSurface, isDarkMode ? 5 : -3);
const onSurfaceVariant = isStrict ? colors.mOnSurfaceVariant : ColorsConvert.generateOnColor(surfaceVariant, isDarkMode);
// Generate surface containers (progressive elevation)
const surfaceContainerLowest = ColorsConvert.generateSurfaceVariant(surface, 0, isDarkMode);
const surfaceContainerLow = ColorsConvert.generateSurfaceVariant(surface, 1, isDarkMode);
const surfaceContainer = ColorsConvert.generateSurfaceVariant(surface, 2, isDarkMode);
const surfaceContainerHigh = ColorsConvert.generateSurfaceVariant(surface, 3, isDarkMode);
const surfaceContainerHighest = ColorsConvert.generateSurfaceVariant(surface, 4, isDarkMode);
// Generate outline colors (for borders/dividers)
const outline = isStrict ? colors.mOutline : ColorsConvert.adjustLightnessAndSaturation(colors.mOnSurface, isDarkMode ? -30 : 30, isDarkMode ? -30 : 30);
const outlineVariant = ColorsConvert.adjustLightness(outline, isDarkMode ? -20 : 20);
// Generate surface_dim (darker/dimmer surface variant)
const surfaceDim = ColorsConvert.generateSurfaceVariant(surface, -1, isDarkMode);
// Generate "fixed" colors (high-chroma accents that are consistent across modes)
// Fixed colors are lighter in dark mode, darker in light mode - opposite of containers
const primaryFixed = isDarkMode ? ColorsConvert.adjustLightness(colors.mPrimary, 30) : ColorsConvert.adjustLightness(colors.mPrimary, -10);
const primaryFixedDim = ColorsConvert.adjustLightness(primaryFixed, isDarkMode ? -15 : 10);
const onPrimaryFixedVariant = ColorsConvert.generateOnColor(primaryFixedDim, isDarkMode);
const secondaryFixed = isDarkMode ? ColorsConvert.adjustLightness(colors.mSecondary, 30) : ColorsConvert.adjustLightness(colors.mSecondary, -10);
const tertiaryFixed = isDarkMode ? ColorsConvert.adjustLightness(colors.mTertiary, 30) : ColorsConvert.adjustLightness(colors.mTertiary, -10);
const tertiaryFixedDim = ColorsConvert.adjustLightness(tertiaryFixed, isDarkMode ? -15 : 10);
const onTertiaryFixed = ColorsConvert.generateOnColor(tertiaryFixed, isDarkMode);
// Shadow is always pitch black
const shadow = "#000000";
return {
"primary": c(colors.mPrimary),
"on_primary": c(onPrimary),
"primary_container": c(primaryContainer),
"on_primary_container": c(onPrimaryContainer),
"secondary": c(colors.mSecondary),
"on_secondary": c(onSecondary),
"secondary_container": c(secondaryContainer),
"on_secondary_container": c(onSecondaryContainer),
"tertiary": c(colors.mTertiary),
"on_tertiary": c(onTertiary),
"tertiary_container": c(tertiaryContainer),
"on_tertiary_container": c(onTertiaryContainer),
"error": c(colors.mError),
"on_error": c(onError),
"error_container": c(errorContainer),
"on_error_container": c(onErrorContainer),
"background": c(surface),
"on_background": c(onSurface),
"surface": c(surface),
"on_surface": c(onSurface),
"surface_variant": c(surfaceVariant),
"on_surface_variant": c(onSurfaceVariant),
"surface_container_lowest": c(surfaceContainerLowest),
"surface_container_low": c(surfaceContainerLow),
"surface_container": c(surfaceContainer),
"surface_container_high": c(surfaceContainerHigh),
"surface_container_highest": c(surfaceContainerHighest),
"outline": c(outline),
"outline_variant": c(outlineVariant),
"shadow": c(shadow),
"surface_dim": c(surfaceDim),
"primary_fixed": c(primaryFixed),
"primary_fixed_dim": c(primaryFixedDim),
"on_primary_fixed_variant": c(onPrimaryFixedVariant),
"secondary_fixed": c(secondaryFixed),
"tertiary_fixed": c(tertiaryFixed),
"tertiary_fixed_dim": c(tertiaryFixedDim),
"on_tertiary_fixed": c(onTertiaryFixed)
};
}
}
+55 -306
View File
@@ -4,7 +4,6 @@ import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Io
import qs.Commons
import qs.Services.System
import qs.Services.Theming
@@ -66,76 +65,70 @@ Singleton {
generateProcess.running = true;
}
// Queue for processing templates one by one
property var templateQueue: []
property var currentTemplateContext: null
readonly property string schemeJsonPath: Settings.cacheDir + "predefined-scheme.json"
readonly property string predefinedConfigPath: Settings.cacheDir + "theming.predefined.toml"
/**
* Process predefined color scheme using sed scripts
* Dual-path architecture (predefined uses sed scripts)
* Templates are processed one by one for better error reporting
* Process predefined color scheme using Python template processor
* Uses --scheme flag to expand 14-color scheme to full 48-color palette
*/
function processPredefinedScheme(schemeData, mode) {
// 1. Handle terminal themes (pre-rendered file copy)
handleTerminalThemes(mode);
const colors = schemeData[mode];
const homeDir = Quickshell.env("HOME");
// Build queue of templates to process
templateQueue = buildTemplateQueue(colors, mode, schemeData, homeDir);
// Add user templates if enabled
const userScript = buildUserTemplateCommandForPredefined(schemeData, mode);
if (userScript) {
templateQueue.push({
id: "user-templates",
script: userScript
});
}
// Start processing
processNextTemplate();
}
function buildTemplateQueue(colors, mode, schemeData, homeDir) {
const queue = [];
TemplateRegistry.applications.forEach(app => {
if (app.id === "discord") {
if (isTemplateEnabled("discord")) {
const items = buildDiscordTemplateItems(app, colors, homeDir);
items.forEach(item => queue.push(item));
}
} else if (app.id === "code") {
if (isTemplateEnabled("code")) {
const items = buildCodeTemplateItems(app, colors, homeDir);
items.forEach(item => queue.push(item));
}
} else {
if (isTemplateEnabled(app.id)) {
const items = buildAppTemplateItems(app, colors, mode, homeDir, schemeData);
items.forEach(item => queue.push(item));
}
}
});
return queue;
}
function processNextTemplate() {
if (templateQueue.length === 0) {
currentTemplateContext = null;
// 2. Build TOML config for application templates
const tomlContent = buildPredefinedTemplateConfig(mode);
if (!tomlContent) {
Logger.d("TemplateProcessor", "No application templates enabled for predefined scheme");
return;
}
const item = templateQueue.shift();
currentTemplateContext = item;
// 3. Build script to write files and run Python
const schemeJsonPathEsc = schemeJsonPath.replace(/'/g, "'\\''");
const configPathEsc = predefinedConfigPath.replace(/'/g, "'\\''");
templateProcess.command = ["sh", "-lc", item.script];
templateProcess.running = true;
// Use heredoc delimiters for safe JSON/TOML content
const schemeDelimiter = "SCHEME_JSON_EOF_" + Math.random().toString(36).substr(2, 9);
const tomlDelimiter = "TOML_CONFIG_EOF_" + Math.random().toString(36).substr(2, 9);
let script = "";
// Write scheme JSON
script += `cat > '${schemeJsonPathEsc}' << '${schemeDelimiter}'\n`;
script += JSON.stringify(schemeData, null, 2) + "\n";
script += `${schemeDelimiter}\n`;
// Write TOML config
script += `cat > '${configPathEsc}' << '${tomlDelimiter}'\n`;
script += tomlContent + "\n";
script += `${tomlDelimiter}\n`;
// Run Python template processor with --scheme flag
script += `python3 "${templateProcessorScript}" --scheme '${schemeJsonPathEsc}' --config '${configPathEsc}' --mode ${mode}\n`;
// Add user templates if enabled
script += buildUserTemplateCommandForPredefined(schemeData, mode);
generateProcess.generator = "predefined";
generateProcess.command = ["sh", "-lc", script];
generateProcess.running = true;
}
/**
* Build TOML config for predefined scheme templates (excludes terminal themes)
*/
function buildPredefinedTemplateConfig(mode) {
var lines = [];
addApplicationTheming(lines, mode);
if (lines.length > 0) {
return ["[config]"].concat(lines).join("\n") + "\n";
}
return "";
}
// ================================================================================
// WALLPAPER-BASED GENERATION (internal python)
// WALLPAPER-BASED GENERATION
// ================================================================================
function buildThemeConfig() {
var lines = [];
@@ -279,197 +272,6 @@ Singleton {
return script + "\n";
}
// ================================================================================
// PREDEFINED SCHEME GENERATION (queue-based, template by template)
// ================================================================================
function buildDiscordTemplateItems(discordApp, colors, homeDir) {
const items = [];
const palette = ColorPaletteGenerator.generatePalette(colors, Settings.data.colorSchemes.darkMode, false);
discordApp.clients.forEach(client => {
if (!isDiscordClientEnabled(client.name))
return;
const templatePath = `${Quickshell.shellDir}/Assets/Templates/${discordApp.input}`;
const outputPath = `${client.path}/themes/noctalia.theme.css`.replace("~", homeDir);
const outputDir = outputPath.substring(0, outputPath.lastIndexOf('/'));
const baseConfigDir = outputDir.replace("/themes", "");
let script = "";
script += `if [ -d "${baseConfigDir}" ]; then\n`;
script += ` mkdir -p ${outputDir}\n`;
script += ` cp '${templatePath}' '${outputPath}'\n`;
script += ` ${replaceColorsInFile(outputPath, palette)}`;
script += `fi\n`;
items.push({
id: `discord-${client.name}`,
outputPath: outputPath,
script: script
});
});
return items;
}
function buildCodeTemplateItems(codeApp, colors, homeDir) {
const items = [];
const palette = ColorPaletteGenerator.generatePalette(colors, Settings.data.colorSchemes.darkMode, false);
codeApp.clients.forEach(client => {
if (!isCodeClientEnabled(client.name))
return;
const templatePath = `${Quickshell.shellDir}/Assets/Templates/${codeApp.input}`;
const outputPath = client.path.replace("~", homeDir);
const outputDir = outputPath.substring(0, outputPath.lastIndexOf('/'));
let baseConfigDir = "";
if (client.name === "code") {
baseConfigDir = homeDir + "/.vscode";
} else if (client.name === "codium") {
baseConfigDir = homeDir + "/.vscode-oss";
}
let script = "";
script += `if [ -d "${baseConfigDir}" ]; then\n`;
script += ` mkdir -p ${outputDir}\n`;
script += ` cp '${templatePath}' '${outputPath}'\n`;
script += ` ${replaceColorsInFile(outputPath, palette)}`;
script += `fi\n`;
items.push({
id: `code-${client.name}`,
outputPath: outputPath,
script: script
});
});
return items;
}
function buildAppTemplateItems(app, colors, mode, homeDir, schemeData) {
const items = [];
const palette = ColorPaletteGenerator.generatePalette(colors, Settings.data.colorSchemes.darkMode, app.strict || false);
const hasDualModePatterns = app.dualMode || false;
let darkPalette, lightPalette;
if (hasDualModePatterns && schemeData) {
darkPalette = ColorPaletteGenerator.generatePalette(schemeData.dark, true, app.strict || false);
lightPalette = ColorPaletteGenerator.generatePalette(schemeData.light, false, app.strict || false);
}
if (app.id === "emacs" && app.checkDoomFirst) {
const doomPath = app.outputs[0].path.replace("~", homeDir);
const doomDir = doomPath.substring(0, doomPath.lastIndexOf('/'));
const doomConfigDir = doomDir.substring(0, doomDir.lastIndexOf('/'));
const standardPath = app.outputs[1].path.replace("~", homeDir);
const standardDir = standardPath.substring(0, standardPath.lastIndexOf('/'));
const templatePath = `${Quickshell.shellDir}/Assets/Templates/${app.input}`;
let script = "";
script += `if [ -d "${doomConfigDir}" ]; then\n`;
script += ` mkdir -p ${doomDir}\n`;
script += ` cp '${templatePath}' '${doomPath}'\n`;
script += replaceColorsInFile(doomPath, palette);
script += `else\n`;
script += ` mkdir -p ${standardDir}\n`;
script += ` cp '${templatePath}' '${standardPath}'\n`;
script += replaceColorsInFile(standardPath, palette);
script += `fi\n`;
items.push({
id: app.id,
outputPath: doomPath,
script: script
});
} else {
app.outputs.forEach((output, idx) => {
const templatePath = `${Quickshell.shellDir}/Assets/Templates/${app.input}`;
const outputPath = output.path.replace("~", homeDir);
const outputDir = outputPath.substring(0, outputPath.lastIndexOf('/'));
let script = "";
script += `mkdir -p ${outputDir}\n`;
const templateFile = output.input ? `${Quickshell.shellDir}/Assets/Templates/${output.input}` : templatePath;
script += `cp '${templateFile}' '${outputPath}'\n`;
script += replaceColorsInFile(outputPath, palette);
if (hasDualModePatterns && darkPalette && lightPalette) {
script += replaceColorsInFileWithMode(outputPath, darkPalette, lightPalette);
}
// Add postProcess only on last output
if (app.postProcess && idx === app.outputs.length - 1) {
script += app.postProcess(mode);
}
items.push({
id: app.outputs.length > 1 ? `${app.id}-${idx}` : app.id,
outputPath: outputPath,
script: script
});
});
}
return items;
}
function replaceColorsInFile(filePath, colors) {
let expressions = [];
Object.keys(colors).forEach(colorKey => {
const colorData = colors[colorKey].default;
const hexValue = colorData.hex;
const hexStrippedValue = colorData.hex_stripped;
const escapedHex = hexValue.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const escapedHexStripped = hexStrippedValue.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Batch all replacements into a single sed command to avoid ARG_MAX limits
expressions.push(`-e 's/{{colors\\.${colorKey}\\.default\\.hex_stripped}}/${escapedHexStripped}/g'`);
expressions.push(`-e 's/{{colors\\.${colorKey}\\.default\\.hex}}/${escapedHex}/g'`);
// HSL components
expressions.push(`-e 's/{{colors\\.${colorKey}\\.default\\.hue}}/${colorData.hue}/g'`);
expressions.push(`-e 's/{{colors\\.${colorKey}\\.default\\.saturation}}/${colorData.saturation}/g'`);
expressions.push(`-e 's/{{colors\\.${colorKey}\\.default\\.lightness}}/${colorData.lightness}/g'`);
});
return `sed -i ${expressions.join(' ')} '${filePath}'\n`;
}
function replaceColorsInFileWithMode(filePath, darkColors, lightColors) {
let expressions = [];
// Replace dark mode patterns
Object.keys(darkColors).forEach(colorKey => {
const colorData = darkColors[colorKey].default;
const escapedHex = colorData.hex.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const escapedHexStripped = colorData.hex_stripped.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
expressions.push(`-e 's/{{colors\\.${colorKey}\\.dark\\.hex_stripped}}/${escapedHexStripped}/g'`);
expressions.push(`-e 's/{{colors\\.${colorKey}\\.dark\\.hex}}/${escapedHex}/g'`);
expressions.push(`-e 's/{{colors\\.${colorKey}\\.dark\\.hue}}/${colorData.hue}/g'`);
expressions.push(`-e 's/{{colors\\.${colorKey}\\.dark\\.saturation}}/${colorData.saturation}/g'`);
expressions.push(`-e 's/{{colors\\.${colorKey}\\.dark\\.lightness}}/${colorData.lightness}/g'`);
});
// Replace light mode patterns
Object.keys(lightColors).forEach(colorKey => {
const colorData = lightColors[colorKey].default;
const escapedHex = colorData.hex.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const escapedHexStripped = colorData.hex_stripped.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
expressions.push(`-e 's/{{colors\\.${colorKey}\\.light\\.hex_stripped}}/${escapedHexStripped}/g'`);
expressions.push(`-e 's/{{colors\\.${colorKey}\\.light\\.hex}}/${escapedHex}/g'`);
expressions.push(`-e 's/{{colors\\.${colorKey}\\.light\\.hue}}/${colorData.hue}/g'`);
expressions.push(`-e 's/{{colors\\.${colorKey}\\.light\\.saturation}}/${colorData.saturation}/g'`);
expressions.push(`-e 's/{{colors\\.${colorKey}\\.light\\.lightness}}/${colorData.lightness}/g'`);
});
// Batch all replacements into a single sed command to avoid ARG_MAX limits
return `sed -i ${expressions.join(' ')} '${filePath}'\n`;
}
// ================================================================================
// TERMINAL THEMES (predefined schemes use pre-rendered files)
// ================================================================================
@@ -574,24 +376,14 @@ Singleton {
return "";
const userConfigPath = getUserConfigPath();
const colors = schemeData[mode];
const palette = ColorPaletteGenerator.generatePalette(colors, Settings.data.colorSchemes.darkMode, false);
const tempJsonPath = Settings.cacheDir + "predefined-colors.json";
const tempJsonPathEsc = tempJsonPath.replace(/'/g, "'\\''");
// Reuse the scheme JSON already written by processPredefinedScheme()
const schemeJsonPathEsc = schemeJsonPath.replace(/'/g, "'\\''");
let script = "\n# Execute user templates with predefined scheme colors\n";
script += `if [ -f '${userConfigPath}' ]; then\n`;
script += ` cat > '${tempJsonPathEsc}' << 'EOF'\n`;
script += JSON.stringify({
"colors": palette
}, null, 2) + "\n";
script += "EOF\n";
script += "EOF\n";
// Call template-processor.py with JSON file as first arg (it will detect extension)
script += ` python3 "${templateProcessorScript}" '${tempJsonPathEsc}' --config '${userConfigPath}' --mode ${mode}\n`;
// Use --scheme flag with the already-written scheme JSON
script += ` python3 "${templateProcessorScript}" --scheme '${schemeJsonPathEsc}' --config '${userConfigPath}' --mode ${mode}\n`;
script += "fi";
return script;
@@ -643,49 +435,6 @@ Singleton {
}
}
// ------------
// Process for queue-based template processing (predefined schemes)
Process {
id: templateProcess
workingDirectory: Quickshell.shellDir
running: false
onExited: function (exitCode) {
if (exitCode !== 0) {
const ctx = currentTemplateContext;
const errText = stderr.text ? stderr.text.trim() : "";
const outText = stdout.text ? stdout.text.trim() : "";
const description = errText || outText || "Unknown error";
Logger.e("TemplateProcessor", `Template "${ctx?.id}" failed (exit code ${exitCode}): ${description}`);
if (ctx?.outputPath) {
Logger.e("TemplateProcessor", ` Output path: ${ctx.outputPath}`);
}
Logger.d("TemplateProcessor", ` Script: ${ctx?.script?.substring(0, 300)}`);
}
// Continue with next template regardless of success/failure
processNextTemplate();
}
stdout: StdioCollector {
onStreamFinished: {
if (this.text && this.text.trim() !== "") {
Logger.d("TemplateProcessor", "templateProcess stdout:", this.text.trim());
}
}
}
stderr: StdioCollector {
onStreamFinished: {
if (this.text && this.text.trim() !== "") {
// Log template errors/warnings from Python script
Logger.e("TemplateProcessor", this.text.trim());
}
}
}
}
// ------------
Process {
id: copyProcess