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
This commit is contained in:
forbes
2026-02-28 14:53:38 -06:00
parent 60c0489d73
commit 64644eb623
15 changed files with 1130 additions and 15 deletions

View File

@@ -1,18 +1,40 @@
"""Deferred dock panel registration helper.
"""Dock panel registration helper.
Replaces the manual ``QTimer.singleShot()`` + duplicate-check +
try/except pattern used in ``src/Mod/Create/InitGui.py``.
Routes through the ``kcsdk`` C++ module (IPanelProvider / DockWindowManager)
when available, falling back to direct PySide QDockWidget creation for
backwards compatibility.
"""
import FreeCAD
# Try to import the C++ SDK module; None if not yet built/installed.
try:
import kcsdk as _kcsdk
except ImportError:
_kcsdk = None
_AREA_MAP = {
"left": 1, # Qt.LeftDockWidgetArea
"right": 2, # Qt.RightDockWidgetArea
"top": 4, # Qt.TopDockWidgetArea
"bottom": 8, # Qt.BottomDockWidgetArea
"left": 1, # Qt.LeftDockWidgetArea / DockArea.Left
"right": 2, # Qt.RightDockWidgetArea / DockArea.Right
"top": 4, # Qt.TopDockWidgetArea / DockArea.Top
"bottom": 8, # Qt.BottomDockWidgetArea / DockArea.Bottom
}
_DOCK_AREA_MAP = None # lazily populated from kcsdk
def _get_dock_area(area_str):
"""Convert area string to kcsdk.DockArea enum value."""
global _DOCK_AREA_MAP
if _DOCK_AREA_MAP is None:
_DOCK_AREA_MAP = {
"left": _kcsdk.DockArea.Left,
"right": _kcsdk.DockArea.Right,
"top": _kcsdk.DockArea.Top,
"bottom": _kcsdk.DockArea.Bottom,
}
return _DOCK_AREA_MAP.get(area_str)
def register_dock_panel(object_name, title, widget_factory, area="right", delay_ms=0):
"""Register a dock panel, optionally deferred.
@@ -38,15 +60,62 @@ def register_dock_panel(object_name, title, widget_factory, area="right", delay_
if not callable(widget_factory):
raise TypeError("widget_factory must be callable")
qt_area = _AREA_MAP.get(area)
if qt_area is None:
if area not in _AREA_MAP:
raise ValueError(f"area must be one of {list(_AREA_MAP)}, got {area!r}")
if _kcsdk is not None:
_register_via_kcsdk(object_name, title, widget_factory, area, delay_ms)
else:
_register_via_pyside(object_name, title, widget_factory, area, delay_ms)
def _register_via_kcsdk(object_name, title, widget_factory, area, delay_ms):
"""Register using the C++ SDK panel provider system."""
dock_area = _get_dock_area(area)
class _AnonymousProvider(_kcsdk.IPanelProvider):
def id(self):
return object_name
def title(self):
return title
def create_widget(self):
return widget_factory()
def preferred_area(self):
return dock_area
try:
_kcsdk.register_panel(_AnonymousProvider())
def _create():
try:
_kcsdk.create_panel(object_name)
except Exception as e:
FreeCAD.Console.PrintLog(
f"kindred_sdk: Panel '{object_name}' creation failed: {e}\n"
)
from PySide.QtCore import QTimer
QTimer.singleShot(max(0, delay_ms), _create)
except Exception as e:
FreeCAD.Console.PrintLog(
f"kindred_sdk: kcsdk panel registration failed for '{object_name}', "
f"falling back: {e}\n"
)
_register_via_pyside(object_name, title, widget_factory, area, delay_ms)
def _register_via_pyside(object_name, title, widget_factory, area, delay_ms):
"""Legacy fallback: create dock widget directly via PySide."""
qt_area = _AREA_MAP[area]
def _create():
try:
from PySide import QtCore, QtWidgets
import FreeCADGui
from PySide import QtCore, QtWidgets
mw = FreeCADGui.getMainWindow()
if mw is None:
@@ -61,7 +130,9 @@ def register_dock_panel(object_name, title, widget_factory, area="right", delay_
panel.setWidget(widget)
mw.addDockWidget(QtCore.Qt.DockWidgetArea(qt_area), panel)
except Exception as e:
FreeCAD.Console.PrintLog(f"kindred_sdk: Dock panel '{object_name}' skipped: {e}\n")
FreeCAD.Console.PrintLog(
f"kindred_sdk: Dock panel '{object_name}' skipped: {e}\n"
)
try:
from PySide.QtCore import QTimer

View File

@@ -135,15 +135,51 @@ _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")
@@ -152,7 +188,9 @@ def load_palette(name="catppuccin-mocha"):
try:
raw = _load_yaml(path)
except Exception as e:
FreeCAD.Console.PrintWarning(f"kindred_sdk: Failed to load palette '{name}': {e}\n")
FreeCAD.Console.PrintWarning(
f"kindred_sdk: Failed to load palette '{name}': {e}\n"
)
return None
palette = Palette(
@@ -172,9 +210,20 @@ def load_palette(name="catppuccin-mocha"):
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``.
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 {}