From 18532e3bd75e68e252c9b4639c5dd2a4da91e337 Mon Sep 17 00:00:00 2001 From: forbes Date: Sat, 28 Feb 2026 14:53:38 -0600 Subject: [PATCH] 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."); }