"""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 _kcsdk_available(): """Return the kcsdk module if available, else None.""" try: import kcsdk return kcsdk except ImportError: return None def load_palette(name="catppuccin-mocha"): """Load a named palette from the ``palettes/`` directory. When the C++ ``kcsdk`` module is available (GUI mode), delegates to ``kcsdk.load_palette()`` and builds a ``Palette`` from the C++ token map. Falls back to the Python YAML loader for console mode. Results are cached; subsequent calls with the same *name* return the same ``Palette`` instance. """ if name in _cache: return _cache[name] # Try C++ backend first kcsdk = _kcsdk_available() if kcsdk is not None: try: if kcsdk.load_palette(name): tokens = kcsdk.theme_tokens() # Separate colors from roles by checking if the token # existed in the original colors set. Since the C++ engine # merges them, we rebuild by loading the YAML for metadata. # Simpler approach: use all tokens as colors (roles are # already resolved to hex values in the C++ engine). palette = Palette( name=name, slug=name, colors=tokens, roles={}, ) _cache[name] = palette return palette except Exception: pass # Fall through to Python loader 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. When the C++ ``kcsdk`` module is available, delegates directly to ``kcsdk.theme_tokens()`` for best performance. Falls back to the Python palette loader otherwise. Returns a copy so callers cannot mutate the cached palette. """ kcsdk = _kcsdk_available() if kcsdk is not None: try: kcsdk.load_palette(name) return dict(kcsdk.theme_tokens()) except Exception: pass # Fall through to Python loader palette = load_palette(name) if palette is None: return {} return dict(palette.colors)