Files
create/mods/sdk/kindred_sdk/theme.py
forbes 18532e3bd7 feat(sdk): add panel provider and theme engine to kcsdk (#352, #353)
- IPanelProvider: abstract interface for dock panels with PySide widget bridging
- PyIPanelProvider/PyProviderHolder: pybind11 trampoline + GIL-safe holder
- WidgetBridge: PySide QWidget → C++ QWidget* conversion via Shiboken
- SDKRegistry: panel registration, creation, and lifecycle management
- ThemeEngine: C++ singleton with minimal YAML parser, palette cache,
  getColor/allTokens/formatQss matching Python Palette API
- kcsdk bindings: DockArea, PanelPersistence enums, panel functions,
  theme_color, theme_tokens, format_qss, load_palette
- dock.py: kcsdk delegation with FreeCADGui fallback
- theme.py: kcsdk delegation with Python YAML fallback
2026-02-28 14:53:38 -06:00

231 lines
7.0 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 _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)