Files
create/mods/sdk/kindred_sdk/theme.py
forbes-0023 e667aceead
Some checks failed
Build and Test / build (pull_request) Failing after 1m40s
feat(addon-system): create kindred-addon-sdk package (#249)
Add mods/sdk/ with the kindred_sdk Python package providing a stable
API layer for addon integration with Kindred Create platform features.

Modules:
- context: editing context/overlay registration wrappers
- theme: YAML-driven palette system (Catppuccin Mocha)
- origin: FileOrigin registration helpers
- dock: deferred dock panel registration
- compat: version detection utilities

The SDK loads at priority 0 (before all other addons) via the existing
manifest-driven loader. Theme colors are defined in a single YAML
palette file instead of hardcoded Python dicts, enabling future theme
support and eliminating color duplication across addons.

Closes #249
2026-02-17 08:36:27 -06:00

182 lines
5.4 KiB
Python

"""YAML-driven theme system.
Loads color palettes from YAML files and provides runtime access to
color tokens, semantic roles, QSS template formatting, and FreeCAD
preference-pack value conversion.
"""
import os
import re
import FreeCAD
class Palette:
"""A loaded color palette with raw tokens and semantic roles."""
def __init__(self, name, slug, colors, roles):
self.name = name
self.slug = slug
self.colors = dict(colors)
self.roles = {k: colors[v] for k, v in roles.items() if v in colors}
def get(self, key):
"""Look up a color by role first, then by raw color name.
Returns the hex string or *None* if not found.
"""
return self.roles.get(key) or self.colors.get(key)
@staticmethod
def hex_to_rgba_uint(hex_color):
"""Convert ``#RRGGBB`` to FreeCAD's unsigned-int RGBA format.
>>> Palette.hex_to_rgba_uint("#cdd6f4")
3453416703
"""
h = hex_color.lstrip("#")
r = int(h[0:2], 16)
g = int(h[2:4], 16)
b = int(h[4:6], 16)
a = 255
return (r << 24) | (g << 16) | (b << 8) | a
def format_qss(self, template):
"""Substitute ``{token}`` placeholders in a QSS template string.
Both raw color names (``{blue}``) and dotted role names
(``{accent.primary}``) are supported. Dotted names are tried
first so they take precedence over any same-named color.
Unknown tokens are left as-is.
"""
lookup = {}
lookup.update(self.colors)
# Roles use dotted names which aren't valid Python identifiers,
# so we do regex-based substitution.
lookup.update(self.roles)
def _replace(m):
key = m.group(1)
return lookup.get(key, m.group(0))
return re.sub(r"\{([a-z][a-z0-9_.]*)\}", _replace, template)
def __repr__(self):
return f"Palette({self.name!r}, {len(self.colors)} colors, {len(self.roles)} roles)"
# ---------------------------------------------------------------------------
# YAML loading with fallback
# ---------------------------------------------------------------------------
def _load_yaml(path):
"""Load a YAML file, preferring PyYAML if available."""
try:
import yaml
with open(path) as f:
return yaml.safe_load(f)
except ImportError:
return _load_yaml_fallback(path)
def _load_yaml_fallback(path):
"""Minimal YAML parser for flat key-value palette files.
Handles the subset of YAML used by palette files: top-level keys
with string/scalar values, and one level of nested mappings.
"""
data = {}
current_section = None
with open(path) as f:
for line in f:
stripped = line.rstrip()
# Skip blank lines and comments
if not stripped or stripped.startswith("#"):
continue
# Detect indentation
indent = len(line) - len(line.lstrip())
# Top-level key
if indent == 0 and ":" in stripped:
key, _, value = stripped.partition(":")
key = key.strip()
value = value.strip().strip('"').strip("'")
if value:
data[key] = value
current_section = None
else:
# Start of a nested section
current_section = key
data[current_section] = {}
continue
# Nested key (indented)
if current_section is not None and indent > 0 and ":" in stripped:
key, _, value = stripped.partition(":")
key = key.strip()
value = value.strip().strip('"').strip("'")
data[current_section][key] = value
return data
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
_cache = {}
_PALETTES_DIR = os.path.join(os.path.dirname(__file__), "palettes")
def load_palette(name="catppuccin-mocha"):
"""Load a named palette from the ``palettes/`` directory.
Results are cached; subsequent calls with the same *name* return
the same ``Palette`` instance.
"""
if name in _cache:
return _cache[name]
path = os.path.join(_PALETTES_DIR, f"{name}.yaml")
if not os.path.isfile(path):
FreeCAD.Console.PrintWarning(f"kindred_sdk: Palette file not found: {path}\n")
return None
try:
raw = _load_yaml(path)
except Exception as e:
FreeCAD.Console.PrintWarning(f"kindred_sdk: Failed to load palette '{name}': {e}\n")
return None
palette = Palette(
name=raw.get("name", name),
slug=raw.get("slug", name),
colors=raw.get("colors", {}),
roles=raw.get("roles", {}),
)
_cache[name] = palette
FreeCAD.Console.PrintLog(
f"kindred_sdk: Loaded palette '{palette.name}' ({len(palette.colors)} colors)\n"
)
return palette
def get_theme_tokens(name="catppuccin-mocha"):
"""Return a dict of ``{token_name: "#hex"}`` for all colors in a palette.
This is a convenience shorthand for ``load_palette(name).colors``.
Returns a copy so callers cannot mutate the cached palette.
"""
palette = load_palette(name)
if palette is None:
return {}
return dict(palette.colors)