diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 35e9c15621..c524bc3a14 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -116,6 +116,17 @@ mods/silo/ [submodule → silo-mod.git] FreeCAD workbench ├── silo_commands.py Commands + FreeCADSiloSettings adapter └── silo_origin.py FileOrigin backend for Silo (via SDK) +src/Gui/SDK/ KCSDK C++ shared library (libKCSDK.so) + ├── KCSDKGlobal.h DLL export macros + ├── Types.h Plain C++ types (ContextDef, DockArea, PanelPersistence) + ├── IPanelProvider.h Abstract dock panel interface + ├── WidgetBridge.h/.cpp PySide QWidget <-> C++ QWidget* (via Gui::PythonWrapper) + ├── SDKRegistry.h/.cpp Singleton registry — contexts, panels, providers + └── bindings/ pybind11 module (kcsdk.so) + ├── kcsdk_py.cpp Module definition — enums, functions, classes + ├── PyIPanelProvider.h Trampoline for Python subclassing + └── PyProviderHolder.h GIL-safe forwarding wrapper + src/Gui/EditingContext.h/.cpp EditingContextResolver singleton + context registry src/Gui/BreadcrumbToolBar.h/.cpp Color-coded breadcrumb toolbar (Catppuccin Mocha) src/Gui/FileOrigin.h/.cpp FileOrigin base class + LocalFileOrigin diff --git a/docs/INTEGRATION_PLAN.md b/docs/INTEGRATION_PLAN.md index 5e81260862..1a5582ea81 100644 --- a/docs/INTEGRATION_PLAN.md +++ b/docs/INTEGRATION_PLAN.md @@ -167,6 +167,52 @@ Theme colors are now centralized in the SDK's YAML palette (`mods/sdk/kindred_sd --- +### Phase 7: KCSDK — C++-backed SDK module -- IN PROGRESS + +**Goal:** Replace the pure-Python SDK wrappers with a C++ shared library (`libKCSDK.so`) and pybind11 bindings (`kcsdk.so`). This gives addons a stable, typed API with proper GIL safety and enables future C++ addon development without Python. + +**Architecture:** + +``` +Python Addons (silo, future addons, ...) + | +kindred_sdk (mods/sdk/) <- convenience layer (try kcsdk, fallback FreeCADGui) + | +kcsdk.so (pybind11 module) <- C++ API bindings + | +KCSDK (C++ shared library) <- SDKRegistry + provider interfaces + | +FreeCADGui (EditingContextResolver, DockWindowManager, OriginManager, ...) +``` + +**Sub-phases:** + +| # | Issue | Status | Description | +|---|-------|--------|-------------| +| 1 | #350 | DONE | Scaffold KCSDK library + kcsdk pybind11 module | +| 2 | #351 | DONE | Migrate editing context API to kcsdk | +| 3 | #352 | DONE | Panel provider system (IPanelProvider) | +| 4 | #353 | — | C++ theme engine | +| 5 | #354 | — | Toolbar provider system (IToolbarProvider) | +| 6 | #355 | — | Menu and action system | +| 7 | #356 | — | Status bar provider + origin migration | +| 8 | #357 | — | Deprecation cleanup + SDK v1.0.0 | + +**Key files:** + +- `src/Gui/SDK/` — C++ library (KCSDKGlobal.h, Types.h, SDKRegistry, IPanelProvider, WidgetBridge) +- `src/Gui/SDK/bindings/` — pybind11 module (kcsdk_py.cpp, PyIPanelProvider, PyProviderHolder) +- `mods/sdk/kindred_sdk/` — Python wrappers with kcsdk/legacy fallback + +**Design decisions:** + +- **No Qt in public C++ API** — `Types.h` uses `std::string`, `std::vector`, `std::function`. Qt conversion happens internally in `SDKRegistry.cpp`. +- **GIL-safe Python callables** — Python callbacks stored via `std::make_shared` with `py::gil_scoped_acquire` before every invocation. +- **PySide widget bridging** — `WidgetBridge::toQWidget()` converts PySide QWidget objects to C++ `QWidget*` via `Gui::PythonWrapper` (Shiboken). +- **Provider pattern** — Interfaces like `IPanelProvider` enable addons to register factories. The registry calls `create_widget()` once and manages the lifecycle through `DockWindowManager`. + +--- + ## Design decisions 1. **`Create::` namespace prefix.** All Kindred Create C++ features use this prefix to distinguish them from FreeCAD core. diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 29fe819bca..d129a051d2 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -30,6 +30,7 @@ - [Build System](./development/build-system.md) - [Gui Module Build](./development/gui-build-integration.md) - [Package.xml Schema Extensions](./development/package-xml-schema.md) +- [Writing an Addon](./development/writing-an-addon.md) # Silo Server @@ -76,4 +77,5 @@ - [OriginSelectorWidget](./reference/cpp-origin-selector-widget.md) - [FileOriginPython Bridge](./reference/cpp-file-origin-python.md) - [Creating a Custom Origin (C++)](./reference/cpp-custom-origin-guide.md) +- [KCSDK Python API](./reference/kcsdk-python.md) - [KCSolve Python API](./reference/kcsolve-python.md) diff --git a/docs/src/development/writing-an-addon.md b/docs/src/development/writing-an-addon.md new file mode 100644 index 0000000000..5244216892 --- /dev/null +++ b/docs/src/development/writing-an-addon.md @@ -0,0 +1,283 @@ +# Writing an Addon + +This guide walks through creating a Kindred Create addon from scratch. Addons are Python packages in the `mods/` directory that extend Create with commands, panels, and UI modifications through the SDK. + +## Addon structure + +A minimal addon has this layout: + +``` +mods/my-addon/ +├── package.xml # Manifest (required) +├── Init.py # Console-phase bootstrap +├── InitGui.py # GUI-phase bootstrap +└── my_addon/ + ├── __init__.py + └── commands.py # Your commands +``` + +## Step 1: Create the manifest + +Every addon needs a `package.xml` with a `` extension block. The `` tag is required for `InitGui.py` to be loaded, even if your addon doesn't register a workbench. + +```xml + + + my-addon + My custom addon for Kindred Create. + 0.1.0 + Your Name + LGPL-2.1-or-later + + + + MyAddonWorkbench + + + + 0.1.5 + 70 + true + + sdk + + + +``` + +### Priority ranges + +| Range | Use | +|-------|-----| +| 0-9 | SDK and core infrastructure | +| 10-49 | Foundation addons | +| 50-99 | Standard addons (ztools, silo) | +| 100+ | Optional/user addons | + +See [Package.xml Schema Extensions](./package-xml-schema.md) for the full schema. + +## Step 2: Console bootstrap (Init.py) + +`Init.py` runs during FreeCAD's console initialization, before the GUI exists. Use it for non-GUI setup. + +```python +import FreeCAD + +FreeCAD.Console.PrintLog("my-addon: loaded (console)\n") +``` + +## Step 3: GUI bootstrap (InitGui.py) + +`InitGui.py` runs when the GUI is ready. This is where you register commands, contexts, panels, and overlays. + +```python +import FreeCAD +import FreeCADGui + +FreeCAD.Console.PrintLog("my-addon: loaded (GUI)\n") + + +def _deferred_setup(): + """Register commands and UI after the main window is ready.""" + from my_addon import commands + commands.register() + + +from PySide.QtCore import QTimer +QTimer.singleShot(2000, _deferred_setup) +``` + +Deferred setup via `QTimer.singleShot()` avoids timing issues during startup. See [Create Module Bootstrap](../reference/create-module-bootstrap.md) for the full timer cascade. + +## Step 4: Register commands + +FreeCAD commands use `Gui.addCommand()`. This is a stable FreeCAD API and does not need SDK wrappers. + +```python +# my_addon/commands.py + +import FreeCAD +import FreeCADGui + + +class MyCommand: + def GetResources(self): + return { + "MenuText": "My Command", + "ToolTip": "Does something useful", + } + + def Activated(self): + FreeCAD.Console.PrintMessage("My command activated\n") + + def IsActive(self): + return FreeCAD.ActiveDocument is not None + + +def register(): + FreeCADGui.addCommand("MyAddon_MyCommand", MyCommand()) +``` + +## Step 5: Inject into editing contexts + +Use the SDK to add your commands to existing toolbar contexts, rather than creating a standalone workbench. + +```python +from kindred_sdk import inject_commands + +# Add your command to the PartDesign body context toolbar +inject_commands("partdesign.body", "PartDesign", ["MyAddon_MyCommand"]) +``` + +Built-in contexts you can inject into: `sketcher.edit`, `assembly.edit`, `partdesign.feature`, `partdesign.body`, `assembly.idle`, `spreadsheet`, `empty_document`, `no_document`. + +## Step 6: Register a custom context + +If your addon has its own editing mode, register a context to control which toolbars are visible. + +```python +from kindred_sdk import register_context + +def _is_my_object_in_edit(): + import FreeCADGui + doc = FreeCADGui.activeDocument() + if doc and doc.getInEdit(): + obj = doc.getInEdit().Object + return obj.isDerivedFrom("App::FeaturePython") and hasattr(obj, "MyAddonType") + return False + +register_context( + "myaddon.edit", + "Editing {name}", + "#f9e2af", # Catppuccin yellow + ["MyAddonToolbar", "StandardViews"], + _is_my_object_in_edit, + priority=55, +) +``` + +## Step 7: Register a dock panel + +For panels that live in the dock area (like Silo's database panels), use the SDK panel registration. + +### Simple approach (recommended for most addons) + +```python +from kindred_sdk import register_dock_panel + +def _create_my_panel(): + from PySide import QtWidgets + widget = QtWidgets.QTreeWidget() + widget.setHeaderLabels(["Name", "Value"]) + return widget + +register_dock_panel( + "MyAddonPanel", # unique object name + "My Addon", # title bar text + _create_my_panel, + area="right", + delay_ms=3000, # create 3 seconds after startup +) +``` + +### Advanced approach (IPanelProvider) + +For full control over panel behavior, implement the `IPanelProvider` interface directly: + +```python +import kcsdk + +class MyPanelProvider(kcsdk.IPanelProvider): + def id(self): + return "myaddon.inspector" + + def title(self): + return "Inspector" + + def create_widget(self): + from PySide import QtWidgets + tree = QtWidgets.QTreeWidget() + tree.setHeaderLabels(["Property", "Value"]) + return tree + + def preferred_area(self): + return kcsdk.DockArea.Left + + def context_affinity(self): + return "myaddon.edit" # only visible in your custom context + +# Register and create +kcsdk.register_panel(MyPanelProvider()) +kcsdk.create_panel("myaddon.inspector") +``` + +## Step 8: Use theme colors + +The SDK provides the Catppuccin Mocha palette for consistent theming. + +```python +from kindred_sdk import get_theme_tokens, load_palette + +# Quick lookup +tokens = get_theme_tokens() +blue = tokens["blue"] # "#89b4fa" +error = tokens["error"] # mapped from semantic role + +# Full palette object +palette = load_palette() +palette.get("accent.primary") # semantic role lookup +palette.get("mauve") # direct color lookup + +# Format QSS templates +qss = palette.format_qss("background: {base}; color: {text};") +``` + +## Complete example + +Putting it all together, here's a minimal addon that adds a command and a dock panel: + +``` +mods/my-addon/ +├── package.xml +├── Init.py +├── InitGui.py +└── my_addon/ + ├── __init__.py + └── commands.py +``` + +**InitGui.py:** +```python +import FreeCAD + +def _setup(): + from my_addon.commands import register + from kindred_sdk import inject_commands, register_dock_panel + + register() + inject_commands("partdesign.body", "PartDesign", ["MyAddon_MyCommand"]) + + from PySide import QtWidgets + register_dock_panel( + "MyAddonPanel", "My Addon", + lambda: QtWidgets.QLabel("Hello from my addon"), + area="right", delay_ms=0, + ) + +from PySide.QtCore import QTimer +QTimer.singleShot(2500, _setup) +``` + +## Key patterns + +- **Use `kindred_sdk` wrappers** instead of `FreeCADGui.*` internals. The SDK handles fallback and error logging. +- **Defer initialization** with `QTimer.singleShot()` to avoid startup timing issues. +- **Declare `sdk`** in your manifest to ensure the SDK loads before your addon. +- **Inject commands into existing contexts** rather than creating standalone workbenches. This gives users a unified toolbar experience. +- **Use theme tokens** from the palette for colors. Don't hardcode hex values. + +## Related + +- [KCSDK Python API Reference](../reference/kcsdk-python.md) +- [Package.xml Schema Extensions](./package-xml-schema.md) +- [Create Module Bootstrap](../reference/create-module-bootstrap.md) diff --git a/docs/src/reference/kcsdk-python.md b/docs/src/reference/kcsdk-python.md new file mode 100644 index 0000000000..8477c921ef --- /dev/null +++ b/docs/src/reference/kcsdk-python.md @@ -0,0 +1,252 @@ +# KCSDK Python API Reference + +The `kcsdk` module provides Python access to the Kindred Create addon SDK. It is built with pybind11 and installed alongside the Create module. + +The `kindred_sdk` package (`mods/sdk/kindred_sdk/`) provides convenience wrappers that route through `kcsdk` when available, falling back to legacy `FreeCADGui.*` bindings. Addons should prefer `kindred_sdk` over importing `kcsdk` directly. + +```python +import kcsdk # C++ bindings (low-level) +import kindred_sdk # Python wrappers (recommended) +``` + +## Module constants + +| Name | Value | Description | +|------|-------|-------------| +| `API_VERSION_MAJOR` | `1` | KCSDK API major version | + +## Enums + +### DockArea + +Dock widget placement area. Values match `Qt::DockWidgetArea`. + +| Value | Integer | Description | +|-------|---------|-------------| +| `DockArea.Left` | 1 | Left dock area | +| `DockArea.Right` | 2 | Right dock area | +| `DockArea.Top` | 4 | Top dock area | +| `DockArea.Bottom` | 8 | Bottom dock area | + +### PanelPersistence + +Whether a dock panel's visibility survives application restarts. + +| Value | Description | +|-------|-------------| +| `PanelPersistence.Session` | Visible until application close | +| `PanelPersistence.Persistent` | Saved to preferences and restored on next launch | + +## Editing Context API + +These functions manage the context-aware UI system. Contexts control which toolbars are visible based on the current editing state. + +### register_context(id, label, color, toolbars, match, priority=50) + +Register an editing context. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `id` | `str` | Unique identifier (e.g. `"myaddon.edit"`) | +| `label` | `str` | Display label template. Supports `{name}` placeholder | +| `color` | `str` | Hex color for breadcrumb (e.g. `"#f38ba8"`) | +| `toolbars` | `list[str]` | Toolbar names to show when active | +| `match` | `callable` | Zero-arg callable returning `True` when active | +| `priority` | `int` | Higher values checked first. Default 50 | + +```python +kcsdk.register_context( + "myworkbench.edit", + "Editing {name}", + "#89b4fa", + ["MyToolbar", "StandardViews"], + lambda: is_my_object_in_edit(), + priority=60, +) +``` + +### unregister_context(id) + +Remove a previously registered editing context. + +### register_overlay(id, toolbars, match) + +Register an editing overlay. Overlays add toolbars to whatever context is currently active when `match()` returns `True`. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `id` | `str` | Unique overlay identifier | +| `toolbars` | `list[str]` | Toolbar names to append | +| `match` | `callable` | Zero-arg callable returning `True` when the overlay applies | + +### unregister_overlay(id) + +Remove a previously registered overlay. + +### inject_commands(context_id, toolbar_name, commands) + +Inject additional commands into an existing context's toolbar. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `context_id` | `str` | Target context identifier | +| `toolbar_name` | `str` | Toolbar within that context | +| `commands` | `list[str]` | Command names to add | + +```python +kcsdk.inject_commands("partdesign.body", "PartDesign", ["MyAddon_CustomFeature"]) +``` + +### current_context() + +Return the current editing context as a dict, or `None`. + +Keys: `id`, `label`, `color`, `toolbars`, `breadcrumb`, `breadcrumbColors`. + +### refresh() + +Force re-resolution of the editing context. + +## Panel Provider API + +These functions manage dock panel registration. Panels are created through the `IPanelProvider` interface and managed by `DockWindowManager`. + +### IPanelProvider + +Abstract base class for dock panel providers. Subclass in Python to create custom panels. + +Three methods must be implemented: + +```python +class MyPanel(kcsdk.IPanelProvider): + def id(self): + return "myaddon.panel" + + def title(self): + return "My Panel" + + def create_widget(self): + from PySide import QtWidgets + label = QtWidgets.QLabel("Hello from my addon") + return label +``` + +Optional methods with defaults: + +| Method | Return type | Default | Description | +|--------|-------------|---------|-------------| +| `preferred_area()` | `DockArea` | `DockArea.Right` | Dock placement area | +| `persistence()` | `PanelPersistence` | `PanelPersistence.Session` | Visibility persistence | +| `context_affinity()` | `str` | `""` (always visible) | Only show in named context | + +```python +class SidePanel(kcsdk.IPanelProvider): + def id(self): return "myaddon.side" + def title(self): return "Side Panel" + def create_widget(self): + from PySide import QtWidgets + return QtWidgets.QTreeWidget() + def preferred_area(self): + return kcsdk.DockArea.Left + def context_affinity(self): + return "partdesign.body" # only visible in PartDesign body context +``` + +### register_panel(provider) + +Register a dock panel provider. The provider is stored in the registry until `create_panel()` is called to instantiate the actual dock widget. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `provider` | `IPanelProvider` | Panel provider instance | + +### unregister_panel(id) + +Remove a registered panel provider and destroy its dock widget if created. + +### create_panel(id) + +Instantiate the dock widget for a registered panel. Calls the provider's `create_widget()` once and embeds the result in a `QDockWidget` via `DockWindowManager`. Skips silently if the panel already exists. + +### create_all_panels() + +Instantiate dock widgets for all registered panels. + +### registered_panels() + +Return IDs of all registered panel providers as `list[str]`. + +### available() + +Return names of all registered providers (across all provider types) as `list[str]`. + +## `kindred_sdk` Convenience Wrappers + +The `kindred_sdk` Python package wraps the `kcsdk` C++ module with input validation, error handling, and fallback to legacy APIs. + +### kindred_sdk.register_dock_panel(object_name, title, widget_factory, area="right", delay_ms=0) + +High-level dock panel registration. Creates an anonymous `IPanelProvider` internally and schedules creation via `QTimer`. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `object_name` | `str` | | Qt object name (used as panel ID) | +| `title` | `str` | | Dock widget title | +| `widget_factory` | `callable` | | Zero-arg callable returning a `QWidget` | +| `area` | `str` | `"right"` | `"left"`, `"right"`, `"top"`, or `"bottom"` | +| `delay_ms` | `int` | `0` | Defer creation by this many milliseconds | + +```python +from kindred_sdk import register_dock_panel +from PySide import QtWidgets + +register_dock_panel( + "MyAddonPanel", + "My Addon", + lambda: QtWidgets.QLabel("Hello"), + area="left", + delay_ms=2000, +) +``` + +### Other `kindred_sdk` Wrappers + +These mirror the `kcsdk` functions with added type validation and try/except error handling: + +| Function | Maps to | +|----------|---------| +| `kindred_sdk.register_context()` | `kcsdk.register_context()` | +| `kindred_sdk.unregister_context()` | `kcsdk.unregister_context()` | +| `kindred_sdk.register_overlay()` | `kcsdk.register_overlay()` | +| `kindred_sdk.unregister_overlay()` | `kcsdk.unregister_overlay()` | +| `kindred_sdk.inject_commands()` | `kcsdk.inject_commands()` | +| `kindred_sdk.current_context()` | `kcsdk.current_context()` | +| `kindred_sdk.refresh_context()` | `kcsdk.refresh()` | +| `kindred_sdk.register_origin()` | `FreeCADGui.addOrigin()` | +| `kindred_sdk.unregister_origin()` | `FreeCADGui.removeOrigin()` | +| `kindred_sdk.get_theme_tokens()` | YAML palette lookup | +| `kindred_sdk.load_palette()` | `Palette` object from YAML | +| `kindred_sdk.create_version()` | Kindred Create version string | +| `kindred_sdk.freecad_version()` | FreeCAD version tuple | + +## Architecture + +``` +Python Addon Code + | +kindred_sdk (mods/sdk/) <- convenience wrappers + validation + | +kcsdk.so (pybind11 module) <- C++ API bindings + | +libKCSDK.so (C++ shared library) <- SDKRegistry + provider interfaces + | +FreeCADGui (EditingContextResolver, DockWindowManager, OriginManager, ...) +``` + +When `kcsdk` is not available (console mode, build not installed), `kindred_sdk` falls back to legacy `FreeCADGui.*` Python bindings. + +## Related + +- [Writing an Addon](../development/writing-an-addon.md) +- [Package.xml Schema Extensions](../development/package-xml-schema.md) +- [Create Module Bootstrap](./create-module-bootstrap.md) diff --git a/mods/sdk/kindred_sdk/dock.py b/mods/sdk/kindred_sdk/dock.py index f1e9eb3d24..b2940f8229 100644 --- a/mods/sdk/kindred_sdk/dock.py +++ b/mods/sdk/kindred_sdk/dock.py @@ -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 diff --git a/mods/sdk/kindred_sdk/theme.py b/mods/sdk/kindred_sdk/theme.py index e554903260..8aa81f11d0 100644 --- a/mods/sdk/kindred_sdk/theme.py +++ b/mods/sdk/kindred_sdk/theme.py @@ -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 {} diff --git a/src/Gui/EditingContext.cpp b/src/Gui/EditingContext.cpp index 2ee94e2f13..a64726c9e3 100644 --- a/src/Gui/EditingContext.cpp +++ b/src/Gui/EditingContext.cpp @@ -124,6 +124,10 @@ EditingContextResolver::EditingContextResolver() app.signalActivatedViewProvider.connect( [this](const ViewProviderDocumentObject*, const char*) { refresh(); } ); + + // Resolve the initial context so currentContext() returns a valid state + // before any signals fire (e.g. when BreadcrumbToolBar queries on creation). + refresh(); } EditingContextResolver::~EditingContextResolver() diff --git a/src/Gui/SDK/CMakeLists.txt b/src/Gui/SDK/CMakeLists.txt index 783d18a8ce..981c4ad7a7 100644 --- a/src/Gui/SDK/CMakeLists.txt +++ b/src/Gui/SDK/CMakeLists.txt @@ -3,6 +3,11 @@ set(KCSDK_SRCS KCSDKGlobal.h Types.h + IPanelProvider.h + WidgetBridge.h + WidgetBridge.cpp + ThemeEngine.h + ThemeEngine.cpp SDKRegistry.h SDKRegistry.cpp ) diff --git a/src/Gui/SDK/IPanelProvider.h b/src/Gui/SDK/IPanelProvider.h new file mode 100644 index 0000000000..9416c1cfd3 --- /dev/null +++ b/src/Gui/SDK/IPanelProvider.h @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +/**************************************************************************** + * * + * Copyright (c) 2025 Kindred Systems * + * * + * This file is part of FreeCAD. * + * * + * FreeCAD is free software: you can redistribute it and/or modify it * + * under the terms of the GNU Lesser General Public License as * + * published by the Free Software Foundation, either version 2.1 of the * + * License, or (at your option) any later version. * + * * + * FreeCAD is distributed in the hope that it will be useful, but * + * WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * + * Lesser General Public License for more details. * + * * + * You should have received a copy of the GNU Lesser General Public * + * License along with FreeCAD. If not, see * + * . * + * * + ***************************************************************************/ + +#ifndef KCSDK_IPANELPROVIDER_H +#define KCSDK_IPANELPROVIDER_H + +#include + +#include "KCSDKGlobal.h" +#include "Types.h" + +class QWidget; + +namespace KCSDK +{ + +/// Abstract interface for addon-provided dock panels. +/// +/// Addons implement this interface to register dock panel factories with +/// the SDK registry. The registry calls create_widget() once to +/// instantiate the panel and embeds the result in a QDockWidget managed +/// by DockWindowManager. +class KCSDKExport IPanelProvider +{ +public: + virtual ~IPanelProvider() = default; + + /// Unique panel identifier (e.g. "silo.auth", "silo.activity"). + virtual std::string id() const = 0; + + /// Title displayed in the dock widget title bar. + virtual std::string title() const = 0; + + /// Create the panel widget. Called exactly once by the registry. + /// Ownership of the returned widget transfers to the caller. + virtual QWidget* create_widget() = 0; + + /// Preferred dock area. Default: Right. + virtual DockArea preferred_area() const + { + return DockArea::Right; + } + + /// Whether visibility persists across sessions. Default: Session. + virtual PanelPersistence persistence() const + { + return PanelPersistence::Session; + } + + /// Editing context affinity. If non-empty, the panel is only shown + /// when the named context is active. Default: "" (always visible). + virtual std::string context_affinity() const + { + return {}; + } +}; + +} // namespace KCSDK + +#endif // KCSDK_IPANELPROVIDER_H diff --git a/src/Gui/SDK/SDKRegistry.cpp b/src/Gui/SDK/SDKRegistry.cpp index c05610ef81..3c051c9d64 100644 --- a/src/Gui/SDK/SDKRegistry.cpp +++ b/src/Gui/SDK/SDKRegistry.cpp @@ -22,11 +22,13 @@ ***************************************************************************/ #include "SDKRegistry.h" +#include "IPanelProvider.h" #include #include #include +#include #include namespace KCSDK @@ -88,7 +90,12 @@ SDKRegistry::~SDKRegistry() = default; std::vector SDKRegistry::available() const { std::lock_guard lock(mutex_); - return {}; + std::vector result; + result.reserve(panels_.size()); + for (const auto& [id, _] : panels_) { + result.push_back(id); + } + return result; } // -- Editing context API ---------------------------------------------------- @@ -154,4 +161,95 @@ void SDKRegistry::refresh() Gui::EditingContextResolver::instance()->refresh(); } +// -- Panel provider API ----------------------------------------------------- + +void SDKRegistry::registerPanel(std::unique_ptr provider) +{ + if (!provider) { + return; + } + std::string id = provider->id(); + std::lock_guard lock(mutex_); + panels_[id] = std::move(provider); + Base::Console().log("KCSDK: registered panel provider '%s'\n", id.c_str()); +} + +void SDKRegistry::unregisterPanel(const std::string& id) +{ + std::lock_guard lock(mutex_); + + auto it = panels_.find(id); + if (it == panels_.end()) { + return; + } + + // Remove the dock widget if it was created. + auto* dwm = Gui::DockWindowManager::instance(); + if (dwm) { + dwm->removeDockWindow(id.c_str()); + } + + panels_.erase(it); + Base::Console().log("KCSDK: unregistered panel provider '%s'\n", id.c_str()); +} + +void SDKRegistry::createPanel(const std::string& id) +{ + std::lock_guard lock(mutex_); + + auto it = panels_.find(id); + if (it == panels_.end()) { + Base::Console().warning("KCSDK: no panel provider '%s' registered\n", id.c_str()); + return; + } + + auto* dwm = Gui::DockWindowManager::instance(); + if (!dwm) { + return; + } + + // Skip if already created. + if (dwm->getDockWindow(id.c_str())) { + return; + } + + IPanelProvider* provider = it->second.get(); + QWidget* widget = provider->create_widget(); + if (!widget) { + Base::Console().warning("KCSDK: panel '%s' create_widget() returned null\n", + id.c_str()); + return; + } + + auto qtArea = static_cast(provider->preferred_area()); + dwm->addDockWindow(id.c_str(), widget, qtArea); +} + +void SDKRegistry::createAllPanels() +{ + // Collect IDs under lock, then create outside to avoid recursive locking. + std::vector ids; + { + std::lock_guard lock(mutex_); + ids.reserve(panels_.size()); + for (const auto& [id, _] : panels_) { + ids.push_back(id); + } + } + for (const auto& id : ids) { + createPanel(id); + } +} + +std::vector SDKRegistry::registeredPanels() const +{ + std::lock_guard lock(mutex_); + std::vector result; + result.reserve(panels_.size()); + for (const auto& [id, _] : panels_) { + result.push_back(id); + } + return result; +} + } // namespace KCSDK diff --git a/src/Gui/SDK/SDKRegistry.h b/src/Gui/SDK/SDKRegistry.h index 17b2a0c410..c002e3ca8b 100644 --- a/src/Gui/SDK/SDKRegistry.h +++ b/src/Gui/SDK/SDKRegistry.h @@ -24,8 +24,10 @@ #ifndef KCSDK_SDKREGISTRY_H #define KCSDK_SDKREGISTRY_H +#include #include #include +#include #include #include "KCSDKGlobal.h" @@ -34,6 +36,8 @@ namespace KCSDK { +class IPanelProvider; + /// Current KCSDK API major version. Addons should check this at load time. constexpr int API_VERSION_MAJOR = 1; @@ -81,6 +85,23 @@ public: /// Force re-resolution of the editing context. void refresh(); + // -- Panel provider API ------------------------------------------------ + + /// Register a dock panel provider. Ownership transfers to the registry. + void registerPanel(std::unique_ptr provider); + + /// Remove a registered panel provider and its dock widget (if created). + void unregisterPanel(const std::string& id); + + /// Instantiate the dock widget for a registered panel. + void createPanel(const std::string& id); + + /// Instantiate dock widgets for all registered panels. + void createAllPanels(); + + /// Return IDs of all registered panel providers. + std::vector registeredPanels() const; + private: SDKRegistry(); @@ -90,6 +111,7 @@ private: SDKRegistry& operator=(SDKRegistry&&) = delete; mutable std::mutex mutex_; + std::unordered_map> panels_; }; } // namespace KCSDK diff --git a/src/Gui/SDK/ThemeEngine.cpp b/src/Gui/SDK/ThemeEngine.cpp new file mode 100644 index 0000000000..61b0859e8f --- /dev/null +++ b/src/Gui/SDK/ThemeEngine.cpp @@ -0,0 +1,272 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +/**************************************************************************** + * * + * Copyright (c) 2025 Kindred Systems * + * * + * This file is part of FreeCAD. * + * * + * FreeCAD is free software: you can redistribute it and/or modify it * + * under the terms of the GNU Lesser General Public License as * + * published by the Free Software Foundation, either version 2.1 of the * + * License, or (at your option) any later version. * + * * + * FreeCAD is distributed in the hope that it will be useful, but * + * WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * + * Lesser General Public License for more details. * + * * + * You should have received a copy of the GNU Lesser General Public * + * License along with FreeCAD. If not, see * + * . * + * * + ***************************************************************************/ + +#include "ThemeEngine.h" + +#include +#include + +#include +#include + +namespace KCSDK +{ + +// -- Singleton -------------------------------------------------------------- + +ThemeEngine& ThemeEngine::instance() +{ + static ThemeEngine engine; + return engine; +} + +// -- Path resolution -------------------------------------------------------- + +std::string ThemeEngine::resolvePalettePath(const std::string& name) +{ + std::string home = App::Application::getHomePath(); + return home + "Mod/Create/kindred_sdk/palettes/" + name + ".yaml"; +} + +// -- Minimal YAML parser ---------------------------------------------------- + +bool ThemeEngine::parseYaml(const std::string& path, Palette& out) +{ + std::ifstream file(path); + if (!file.is_open()) { + return false; + } + + out.colors.clear(); + out.roles.clear(); + out.name.clear(); + out.slug.clear(); + + std::string currentSection; + std::string line; + + while (std::getline(file, line)) { + // Strip trailing whitespace (including \r on Windows) + while (!line.empty() && (line.back() == '\r' || line.back() == ' ' || line.back() == '\t')) { + line.pop_back(); + } + + // Skip blank lines and comments + if (line.empty() || line[0] == '#') { + continue; + } + + // Detect indentation + std::size_t indent = 0; + while (indent < line.size() && (line[indent] == ' ' || line[indent] == '\t')) { + ++indent; + } + + // Find the colon separator + auto colonPos = line.find(':', indent); + if (colonPos == std::string::npos) { + continue; + } + + // Extract key + std::string key = line.substr(indent, colonPos - indent); + // Trim trailing whitespace from key + while (!key.empty() && (key.back() == ' ' || key.back() == '\t')) { + key.pop_back(); + } + + // Extract value (everything after ": ") + std::string value; + std::size_t valueStart = colonPos + 1; + while (valueStart < line.size() && line[valueStart] == ' ') { + ++valueStart; + } + if (valueStart < line.size()) { + value = line.substr(valueStart); + // Strip surrounding quotes + if (value.size() >= 2 + && ((value.front() == '"' && value.back() == '"') + || (value.front() == '\'' && value.back() == '\''))) { + value = value.substr(1, value.size() - 2); + } + } + + if (indent == 0) { + // Top-level key + if (value.empty()) { + // Start of a nested section + currentSection = key; + } + else { + if (key == "name") { + out.name = value; + } + else if (key == "slug") { + out.slug = value; + } + currentSection.clear(); + } + } + else if (!currentSection.empty()) { + // Nested key within a section + if (currentSection == "colors") { + out.colors[key] = value; + } + else if (currentSection == "roles") { + // Roles map semantic names to color names — resolve to hex + auto it = out.colors.find(value); + if (it != out.colors.end()) { + out.roles[key] = it->second; + } + else { + // Store the raw name; will remain unresolved + out.roles[key] = value; + } + } + } + } + + return true; +} + +// -- Public API ------------------------------------------------------------- + +bool ThemeEngine::loadPalette(const std::string& name) +{ + std::lock_guard lock(mutex_); + + // Return cached if already loaded + if (cache_.count(name)) { + activePalette_ = name; + return true; + } + + std::string path = resolvePalettePath(name); + Palette palette; + if (!parseYaml(path, palette)) { + Base::Console().warning("KCSDK: palette file not found: %s\n", path.c_str()); + return false; + } + + if (palette.name.empty()) { + palette.name = name; + } + if (palette.slug.empty()) { + palette.slug = name; + } + + Base::Console().log("KCSDK: loaded palette '%s' (%zu colors, %zu roles)\n", + palette.name.c_str(), palette.colors.size(), palette.roles.size()); + + cache_[name] = std::move(palette); + activePalette_ = name; + return true; +} + +std::string ThemeEngine::getColor(const std::string& token) const +{ + std::lock_guard lock(mutex_); + + auto cacheIt = cache_.find(activePalette_); + if (cacheIt == cache_.end()) { + return {}; + } + + const Palette& pal = cacheIt->second; + + // Check roles first, then raw colors (matching Python Palette.get()) + auto roleIt = pal.roles.find(token); + if (roleIt != pal.roles.end()) { + return roleIt->second; + } + + auto colorIt = pal.colors.find(token); + if (colorIt != pal.colors.end()) { + return colorIt->second; + } + + return {}; +} + +std::unordered_map ThemeEngine::allTokens() const +{ + std::lock_guard lock(mutex_); + + auto cacheIt = cache_.find(activePalette_); + if (cacheIt == cache_.end()) { + return {}; + } + + const Palette& pal = cacheIt->second; + + // Start with colors, overlay roles (roles take precedence for same-named keys) + std::unordered_map result = pal.colors; + for (const auto& [key, value] : pal.roles) { + result[key] = value; + } + return result; +} + +std::string ThemeEngine::formatQss(const std::string& templateStr) const +{ + auto tokens = allTokens(); + if (tokens.empty()) { + return templateStr; + } + + std::regex pattern(R"(\{([a-z][a-z0-9_.]*)\})"); + + std::string result; + auto begin = std::sregex_iterator(templateStr.begin(), templateStr.end(), pattern); + auto end = std::sregex_iterator(); + + std::size_t lastPos = 0; + for (auto it = begin; it != end; ++it) { + const auto& match = *it; + // Append text before this match + result.append(templateStr, lastPos, match.position() - lastPos); + + std::string key = match[1].str(); + auto tokenIt = tokens.find(key); + if (tokenIt != tokens.end()) { + result.append(tokenIt->second); + } + else { + // Leave unknown tokens as-is + result.append(match[0].str()); + } + lastPos = match.position() + match.length(); + } + // Append remaining text + result.append(templateStr, lastPos); + + return result; +} + +std::string ThemeEngine::activePaletteName() const +{ + std::lock_guard lock(mutex_); + return activePalette_; +} + +} // namespace KCSDK diff --git a/src/Gui/SDK/ThemeEngine.h b/src/Gui/SDK/ThemeEngine.h new file mode 100644 index 0000000000..3dba56cfc9 --- /dev/null +++ b/src/Gui/SDK/ThemeEngine.h @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +/**************************************************************************** + * * + * Copyright (c) 2025 Kindred Systems * + * * + * This file is part of FreeCAD. * + * * + * FreeCAD is free software: you can redistribute it and/or modify it * + * under the terms of the GNU Lesser General Public License as * + * published by the Free Software Foundation, either version 2.1 of the * + * License, or (at your option) any later version. * + * * + * FreeCAD is distributed in the hope that it will be useful, but * + * WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * + * Lesser General Public License for more details. * + * * + * You should have received a copy of the GNU Lesser General Public * + * License along with FreeCAD. If not, see * + * . * + * * + ***************************************************************************/ + +#ifndef KCSDK_THEMEENGINE_H +#define KCSDK_THEMEENGINE_H + +#include +#include +#include + +#include "KCSDKGlobal.h" + +namespace KCSDK +{ + +/// A loaded color palette with raw color tokens and semantic roles. +struct Palette +{ + std::string name; + std::string slug; + std::unordered_map colors; + std::unordered_map roles; +}; + +/// Singleton theme engine that loads YAML palettes and provides color lookup. +/// +/// Palette files use a minimal YAML subset (flat key-value pairs with one +/// level of nesting) matching the format in +/// ``mods/sdk/kindred_sdk/palettes/catppuccin-mocha.yaml``. +/// +/// Thread safety: all public methods are internally synchronized. +class KCSDKExport ThemeEngine +{ +public: + static ThemeEngine& instance(); + + /// Load a named palette from the palettes directory. + /// Returns false if the file was not found or could not be parsed. + bool loadPalette(const std::string& name = "catppuccin-mocha"); + + /// Look up a color by role first, then by raw color name. + /// Returns the hex string (e.g. "#89b4fa") or empty string if not found. + std::string getColor(const std::string& token) const; + + /// Return all color tokens as {name: "#hex"} (colors + resolved roles). + std::unordered_map allTokens() const; + + /// Substitute {token} placeholders in a QSS template string. + /// Unknown tokens are left as-is. + std::string formatQss(const std::string& templateStr) const; + + /// Return the name of the currently active palette, or empty if none. + std::string activePaletteName() const; + +private: + ThemeEngine() = default; + ~ThemeEngine() = default; + + ThemeEngine(const ThemeEngine&) = delete; + ThemeEngine& operator=(const ThemeEngine&) = delete; + + /// Parse a minimal YAML palette file into a Palette struct. + static bool parseYaml(const std::string& path, Palette& out); + + /// Resolve the filesystem path to a named palette. + static std::string resolvePalettePath(const std::string& name); + + mutable std::mutex mutex_; + std::unordered_map cache_; + std::string activePalette_; +}; + +} // namespace KCSDK + +#endif // KCSDK_THEMEENGINE_H diff --git a/src/Gui/SDK/Types.h b/src/Gui/SDK/Types.h index 45fad4856d..a8d0109a5f 100644 --- a/src/Gui/SDK/Types.h +++ b/src/Gui/SDK/Types.h @@ -63,6 +63,22 @@ struct KCSDKExport ContextSnapshot std::vector breadcrumbColors; }; +/// Dock widget area. Values match Qt::DockWidgetArea for direct casting. +enum class DockArea +{ + Left = 1, + Right = 2, + Top = 4, + Bottom = 8, +}; + +/// Whether a dock panel's visibility persists across sessions. +enum class PanelPersistence +{ + Session, ///< Visible until application close. + Persistent, ///< Saved to preferences and restored on next launch. +}; + } // namespace KCSDK #endif // KCSDK_TYPES_H diff --git a/src/Gui/SDK/WidgetBridge.cpp b/src/Gui/SDK/WidgetBridge.cpp new file mode 100644 index 0000000000..e6e381d438 --- /dev/null +++ b/src/Gui/SDK/WidgetBridge.cpp @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +/**************************************************************************** + * * + * Copyright (c) 2025 Kindred Systems * + * * + * This file is part of FreeCAD. * + * * + * FreeCAD is free software: you can redistribute it and/or modify it * + * under the terms of the GNU Lesser General Public License as * + * published by the Free Software Foundation, either version 2.1 of the * + * License, or (at your option) any later version. * + * * + * FreeCAD is distributed in the hope that it will be useful, but * + * WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * + * Lesser General Public License for more details. * + * * + * You should have received a copy of the GNU Lesser General Public * + * License along with FreeCAD. If not, see * + * . * + * * + ***************************************************************************/ + +#include "WidgetBridge.h" + +#include + +#include + +#include + +namespace KCSDK +{ + +QWidget* WidgetBridge::toQWidget(PyObject* pyWidget) +{ + if (!pyWidget) { + return nullptr; + } + + Gui::PythonWrapper wrap; + wrap.loadWidgetsModule(); + + QObject* obj = wrap.toQObject(Py::Object(pyWidget)); + return qobject_cast(obj); +} + +} // namespace KCSDK diff --git a/src/Gui/SDK/WidgetBridge.h b/src/Gui/SDK/WidgetBridge.h new file mode 100644 index 0000000000..06194f3e8c --- /dev/null +++ b/src/Gui/SDK/WidgetBridge.h @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +/**************************************************************************** + * * + * Copyright (c) 2025 Kindred Systems * + * * + * This file is part of FreeCAD. * + * * + * FreeCAD is free software: you can redistribute it and/or modify it * + * under the terms of the GNU Lesser General Public License as * + * published by the Free Software Foundation, either version 2.1 of the * + * License, or (at your option) any later version. * + * * + * FreeCAD is distributed in the hope that it will be useful, but * + * WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * + * Lesser General Public License for more details. * + * * + * You should have received a copy of the GNU Lesser General Public * + * License along with FreeCAD. If not, see * + * . * + * * + ***************************************************************************/ + +#ifndef KCSDK_WIDGETBRIDGE_H +#define KCSDK_WIDGETBRIDGE_H + +#include "KCSDKGlobal.h" + +struct _object; +using PyObject = _object; + +class QWidget; + +namespace KCSDK +{ + +/// Utility for converting between PySide QWidget objects and C++ QWidget*. +/// +/// Uses Gui::PythonWrapper (Shiboken) internally. +class KCSDKExport WidgetBridge +{ +public: + /// Extract a C++ QWidget* from a PySide QWidget PyObject. + /// Returns nullptr if the conversion fails. + static QWidget* toQWidget(PyObject* pyWidget); +}; + +} // namespace KCSDK + +#endif // KCSDK_WIDGETBRIDGE_H diff --git a/src/Gui/SDK/bindings/CMakeLists.txt b/src/Gui/SDK/bindings/CMakeLists.txt index 1b2ae723e0..29cf90a1af 100644 --- a/src/Gui/SDK/bindings/CMakeLists.txt +++ b/src/Gui/SDK/bindings/CMakeLists.txt @@ -2,6 +2,8 @@ set(KCSDKPy_SRCS kcsdk_py.cpp + PyIPanelProvider.h + PyProviderHolder.h ) add_library(kcsdk_py SHARED ${KCSDKPy_SRCS}) diff --git a/src/Gui/SDK/bindings/PyIPanelProvider.h b/src/Gui/SDK/bindings/PyIPanelProvider.h new file mode 100644 index 0000000000..3294eee490 --- /dev/null +++ b/src/Gui/SDK/bindings/PyIPanelProvider.h @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +/**************************************************************************** + * * + * Copyright (c) 2025 Kindred Systems * + * * + * This file is part of FreeCAD. * + * * + * FreeCAD is free software: you can redistribute it and/or modify it * + * under the terms of the GNU Lesser General Public License as * + * published by the Free Software Foundation, either version 2.1 of the * + * License, or (at your option) any later version. * + * * + * FreeCAD is distributed in the hope that it will be useful, but * + * WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * + * Lesser General Public License for more details. * + * * + * You should have received a copy of the GNU Lesser General Public * + * License along with FreeCAD. If not, see * + * . * + * * + ***************************************************************************/ + +#ifndef KCSDK_PYIPANELPROVIDER_H +#define KCSDK_PYIPANELPROVIDER_H + +#include +#include + +#include + +namespace KCSDK +{ + +/// pybind11 trampoline class for IPanelProvider. +/// Enables Python subclasses that override virtual methods. +class PyIPanelProvider : public IPanelProvider +{ +public: + using IPanelProvider::IPanelProvider; + + // ── Pure virtuals ────────────────────────────────────────────── + + std::string id() const override + { + PYBIND11_OVERRIDE_PURE(std::string, IPanelProvider, id); + } + + std::string title() const override + { + PYBIND11_OVERRIDE_PURE(std::string, IPanelProvider, title); + } + + // create_widget() is NOT overridden here — pybind11 cannot handle + // QWidget* (no type_caster for Qt types). Python dispatch for + // create_widget() goes through PyProviderHolder which calls + // obj_.attr("create_widget")() and converts via WidgetBridge. + QWidget* create_widget() override + { + return nullptr; + } + + // ── Virtuals with defaults ───────────────────────────────────── + + DockArea preferred_area() const override + { + PYBIND11_OVERRIDE(DockArea, IPanelProvider, preferred_area); + } + + PanelPersistence persistence() const override + { + PYBIND11_OVERRIDE(PanelPersistence, IPanelProvider, persistence); + } + + std::string context_affinity() const override + { + PYBIND11_OVERRIDE(std::string, IPanelProvider, context_affinity); + } +}; + +} // namespace KCSDK + +#endif // KCSDK_PYIPANELPROVIDER_H diff --git a/src/Gui/SDK/bindings/PyProviderHolder.h b/src/Gui/SDK/bindings/PyProviderHolder.h new file mode 100644 index 0000000000..50108fac35 --- /dev/null +++ b/src/Gui/SDK/bindings/PyProviderHolder.h @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +/**************************************************************************** + * * + * Copyright (c) 2025 Kindred Systems * + * * + * This file is part of FreeCAD. * + * * + * FreeCAD is free software: you can redistribute it and/or modify it * + * under the terms of the GNU Lesser General Public License as * + * published by the Free Software Foundation, either version 2.1 of the * + * License, or (at your option) any later version. * + * * + * FreeCAD is distributed in the hope that it will be useful, but * + * WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * + * Lesser General Public License for more details. * + * * + * You should have received a copy of the GNU Lesser General Public * + * License along with FreeCAD. If not, see * + * . * + * * + ***************************************************************************/ + +#ifndef KCSDK_PYPROVIDERHOLDER_H +#define KCSDK_PYPROVIDERHOLDER_H + +#include + +#include +#include + +namespace py = pybind11; + +namespace KCSDK +{ + +/// GIL-safe forwarding wrapper that holds a Python IPanelProvider instance. +/// +/// Stores the py::object to prevent garbage collection. Acquires the GIL +/// before every call into Python. For create_widget(), the Python return +/// (a PySide QWidget) is converted to a C++ QWidget* via WidgetBridge. +/// +/// Follows the PySolverHolder pattern from kcsolve_py.cpp. +class PyProviderHolder : public IPanelProvider +{ +public: + explicit PyProviderHolder(py::object obj) + : obj_(std::move(obj)) + { + provider_ = obj_.cast(); + } + + std::string id() const override + { + py::gil_scoped_acquire gil; + return provider_->id(); + } + + std::string title() const override + { + py::gil_scoped_acquire gil; + return provider_->title(); + } + + QWidget* create_widget() override + { + py::gil_scoped_acquire gil; + try { + // Call the Python create_widget() which returns a PySide QWidget. + py::object pyWidget = obj_.attr("create_widget")(); + if (pyWidget.is_none()) { + return nullptr; + } + return WidgetBridge::toQWidget(pyWidget.ptr()); + } + catch (py::error_already_set& e) { + e.discard_as_unraisable(__func__); + return nullptr; + } + } + + DockArea preferred_area() const override + { + py::gil_scoped_acquire gil; + return provider_->preferred_area(); + } + + PanelPersistence persistence() const override + { + py::gil_scoped_acquire gil; + return provider_->persistence(); + } + + std::string context_affinity() const override + { + py::gil_scoped_acquire gil; + return provider_->context_affinity(); + } + +private: + py::object obj_; ///< Prevents Python GC — keeps reference alive. + IPanelProvider* provider_; ///< Raw pointer into trampoline inside obj_. +}; + +} // namespace KCSDK + +#endif // KCSDK_PYPROVIDERHOLDER_H diff --git a/src/Gui/SDK/bindings/kcsdk_py.cpp b/src/Gui/SDK/bindings/kcsdk_py.cpp index cdb4db8c47..fa19879e59 100644 --- a/src/Gui/SDK/bindings/kcsdk_py.cpp +++ b/src/Gui/SDK/bindings/kcsdk_py.cpp @@ -25,9 +25,14 @@ #include #include +#include #include +#include #include +#include "PyIPanelProvider.h" +#include "PyProviderHolder.h" + namespace py = pybind11; using namespace KCSDK; @@ -177,4 +182,116 @@ PYBIND11_MODULE(kcsdk, m) SDKRegistry::instance().refresh(); }, "Force re-resolution of the editing context."); + + // -- Enums -------------------------------------------------------------- + + py::enum_(m, "DockArea") + .value("Left", DockArea::Left) + .value("Right", DockArea::Right) + .value("Top", DockArea::Top) + .value("Bottom", DockArea::Bottom); + + py::enum_(m, "PanelPersistence") + .value("Session", PanelPersistence::Session) + .value("Persistent", PanelPersistence::Persistent); + + // -- Panel provider API ------------------------------------------------- + + py::class_(m, "IPanelProvider") + .def(py::init<>()) + .def("id", &IPanelProvider::id) + .def("title", &IPanelProvider::title) + // create_widget() is not bound directly — Python subclasses override + // it and return a PySide QWidget. The C++ side invokes it via + // PyProviderHolder which handles the PySide→QWidget* conversion + // through WidgetBridge. + .def("preferred_area", &IPanelProvider::preferred_area) + .def("persistence", &IPanelProvider::persistence) + .def("context_affinity", &IPanelProvider::context_affinity); + + m.def("register_panel", + [](py::object provider) { + auto holder = std::make_unique(std::move(provider)); + SDKRegistry::instance().registerPanel(std::move(holder)); + }, + py::arg("provider"), + "Register a dock panel provider.\n\n" + "Parameters\n" + "----------\n" + "provider : IPanelProvider\n" + " Panel provider instance implementing id(), title(), create_widget()."); + + m.def("unregister_panel", + [](const std::string& id) { + SDKRegistry::instance().unregisterPanel(id); + }, + py::arg("id"), + "Remove a registered panel provider and its dock widget."); + + m.def("create_panel", + [](const std::string& id) { + SDKRegistry::instance().createPanel(id); + }, + py::arg("id"), + "Instantiate the dock widget for a registered panel."); + + m.def("create_all_panels", + []() { + SDKRegistry::instance().createAllPanels(); + }, + "Instantiate dock widgets for all registered panels."); + + m.def("registered_panels", + []() { + return SDKRegistry::instance().registeredPanels(); + }, + "Return IDs of all registered panel providers."); + + // -- Theme engine API --------------------------------------------------- + + m.def("theme_color", + [](const std::string& token) { + auto& engine = ThemeEngine::instance(); + if (engine.activePaletteName().empty()) { + engine.loadPalette(); + } + return engine.getColor(token); + }, + py::arg("token"), + "Look up a color by role or name.\n\n" + "Returns the hex string (e.g. \"#89b4fa\") or empty string if not found.\n" + "Auto-loads the default palette on first call."); + + m.def("theme_tokens", + []() { + auto& engine = ThemeEngine::instance(); + if (engine.activePaletteName().empty()) { + engine.loadPalette(); + } + return engine.allTokens(); + }, + "Return all color tokens as {name: \"#hex\"}.\n\n" + "Includes both raw colors and resolved semantic roles.\n" + "Auto-loads the default palette on first call."); + + m.def("format_qss", + [](const std::string& templateStr) { + auto& engine = ThemeEngine::instance(); + if (engine.activePaletteName().empty()) { + engine.loadPalette(); + } + return engine.formatQss(templateStr); + }, + py::arg("template_str"), + "Substitute {token} placeholders in a QSS template.\n\n" + "Both raw color names ({blue}) and dotted role names\n" + "({accent.primary}) are supported. Unknown tokens are left as-is.\n" + "Auto-loads the default palette on first call."); + + m.def("load_palette", + [](const std::string& name) { + return ThemeEngine::instance().loadPalette(name); + }, + py::arg("name") = "catppuccin-mocha", + "Load a named palette. Returns True on success."); }