From 18532e3bd75e68e252c9b4639c5dd2a4da91e337 Mon Sep 17 00:00:00 2001 From: forbes Date: Sat, 28 Feb 2026 14:53:38 -0600 Subject: [PATCH 1/3] feat(sdk): add panel provider and theme engine to kcsdk (#352, #353) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- mods/sdk/kindred_sdk/dock.py | 95 +++++++-- mods/sdk/kindred_sdk/theme.py | 53 ++++- src/Gui/SDK/CMakeLists.txt | 5 + src/Gui/SDK/IPanelProvider.h | 80 +++++++ src/Gui/SDK/SDKRegistry.cpp | 100 ++++++++- src/Gui/SDK/SDKRegistry.h | 22 ++ src/Gui/SDK/ThemeEngine.cpp | 272 ++++++++++++++++++++++++ src/Gui/SDK/ThemeEngine.h | 95 +++++++++ src/Gui/SDK/Types.h | 16 ++ src/Gui/SDK/WidgetBridge.cpp | 48 +++++ src/Gui/SDK/WidgetBridge.h | 50 +++++ src/Gui/SDK/bindings/CMakeLists.txt | 2 + src/Gui/SDK/bindings/PyIPanelProvider.h | 83 ++++++++ src/Gui/SDK/bindings/PyProviderHolder.h | 107 ++++++++++ src/Gui/SDK/bindings/kcsdk_py.cpp | 117 ++++++++++ 15 files changed, 1130 insertions(+), 15 deletions(-) create mode 100644 src/Gui/SDK/IPanelProvider.h create mode 100644 src/Gui/SDK/ThemeEngine.cpp create mode 100644 src/Gui/SDK/ThemeEngine.h create mode 100644 src/Gui/SDK/WidgetBridge.cpp create mode 100644 src/Gui/SDK/WidgetBridge.h create mode 100644 src/Gui/SDK/bindings/PyIPanelProvider.h create mode 100644 src/Gui/SDK/bindings/PyProviderHolder.h 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/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."); } From 1b38d7b24b90b9ab0ee495b4253880c0f689c059 Mon Sep 17 00:00:00 2001 From: forbes Date: Sat, 28 Feb 2026 14:53:44 -0600 Subject: [PATCH 2/3] fix(editing-context): resolve initial context on construction EditingContextResolver constructor did not call refresh(), leaving d->current as a default empty EditingContext. When BreadcrumbToolBar queried currentContext() on creation, it received an empty context with no breadcrumb segments, causing the navbar to appear blank. Add refresh() at end of constructor so the initial state is resolved before any View3DInventor queries it. --- src/Gui/EditingContext.cpp | 4 ++++ 1 file changed, 4 insertions(+) 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() From 5a0be2804d6a415a434d0b68414e5b8af47b4c9b Mon Sep 17 00:00:00 2001 From: forbes Date: Sat, 28 Feb 2026 14:53:51 -0600 Subject: [PATCH 3/3] docs(sdk): add KCSDK API reference and addon developer guide - docs/src/reference/kcsdk-python.md: full kcsdk Python API reference - docs/src/development/writing-an-addon.md: step-by-step addon guide - docs/INTEGRATION_PLAN.md: add Phase 7 KCSDK section - docs/ARCHITECTURE.md: add src/Gui/SDK/ to source layout - docs/src/SUMMARY.md: add new pages to mdBook navigation --- docs/ARCHITECTURE.md | 11 + docs/INTEGRATION_PLAN.md | 46 ++++ docs/src/SUMMARY.md | 2 + docs/src/development/writing-an-addon.md | 283 +++++++++++++++++++++++ docs/src/reference/kcsdk-python.md | 252 ++++++++++++++++++++ 5 files changed, 594 insertions(+) create mode 100644 docs/src/development/writing-an-addon.md create mode 100644 docs/src/reference/kcsdk-python.md 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)