From 63e591a6265177ffc7b02e98810a94ca6e1d282a Mon Sep 17 00:00:00 2001 From: forbes Date: Fri, 27 Feb 2026 13:36:48 -0600 Subject: [PATCH 1/9] feat(sdk): scaffold KCSDK library + kcsdk pybind11 module (#350) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the KCSDK C++ shared library and kcsdk pybind11 module, establishing the build infrastructure for the C++-backed addon SDK. New files: - src/Gui/SDK/KCSDKGlobal.h — DLL export macros - src/Gui/SDK/SDKRegistry.h/.cpp — empty singleton with API_VERSION_MAJOR=1 - src/Gui/SDK/CMakeLists.txt — builds KCSDK shared library - src/Gui/SDK/bindings/kcsdk_py.cpp — pybind11 module exposing version + available() - src/Gui/SDK/bindings/CMakeLists.txt — builds kcsdk pybind11 module Modified: - src/Gui/CMakeLists.txt — add_subdirectory(SDK) Verified: import kcsdk; kcsdk.API_VERSION_MAJOR == 1; kcsdk.available() == [] --- src/Gui/CMakeLists.txt | 1 + src/Gui/SDK/CMakeLists.txt | 31 +++++++++++++ src/Gui/SDK/KCSDKGlobal.h | 37 +++++++++++++++ src/Gui/SDK/SDKRegistry.cpp | 51 +++++++++++++++++++++ src/Gui/SDK/SDKRegistry.h | 71 +++++++++++++++++++++++++++++ src/Gui/SDK/bindings/CMakeLists.txt | 30 ++++++++++++ src/Gui/SDK/bindings/kcsdk_py.cpp | 40 ++++++++++++++++ 7 files changed, 261 insertions(+) create mode 100644 src/Gui/SDK/CMakeLists.txt create mode 100644 src/Gui/SDK/KCSDKGlobal.h create mode 100644 src/Gui/SDK/SDKRegistry.cpp create mode 100644 src/Gui/SDK/SDKRegistry.h create mode 100644 src/Gui/SDK/bindings/CMakeLists.txt create mode 100644 src/Gui/SDK/bindings/kcsdk_py.cpp diff --git a/src/Gui/CMakeLists.txt b/src/Gui/CMakeLists.txt index 1949574784..68c35c6be2 100644 --- a/src/Gui/CMakeLists.txt +++ b/src/Gui/CMakeLists.txt @@ -14,6 +14,7 @@ endif() add_subdirectory(Stylesheets) add_subdirectory(PreferencePacks) add_subdirectory(PreferencePackTemplates) +add_subdirectory(SDK) if(BUILD_WITH_CONDA) add_definitions(-DFC_CONDA) diff --git a/src/Gui/SDK/CMakeLists.txt b/src/Gui/SDK/CMakeLists.txt new file mode 100644 index 0000000000..24e37f5e2c --- /dev/null +++ b/src/Gui/SDK/CMakeLists.txt @@ -0,0 +1,31 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later + +set(KCSDK_SRCS + KCSDKGlobal.h + SDKRegistry.h + SDKRegistry.cpp +) + +add_library(KCSDK SHARED ${KCSDK_SRCS}) + +target_include_directories(KCSDK + PUBLIC + ${CMAKE_SOURCE_DIR}/src + ${CMAKE_BINARY_DIR}/src +) + +target_link_libraries(KCSDK + PRIVATE + FreeCADBase +) + +if(FREECAD_WARN_ERROR) + target_compile_warn_error(KCSDK) +endif() + +SET_BIN_DIR(KCSDK KCSDK /Mod/Create) +INSTALL(TARGETS KCSDK DESTINATION ${CMAKE_INSTALL_LIBDIR}) + +if(FREECAD_USE_PYBIND11) + add_subdirectory(bindings) +endif() diff --git a/src/Gui/SDK/KCSDKGlobal.h b/src/Gui/SDK/KCSDKGlobal.h new file mode 100644 index 0000000000..724239c257 --- /dev/null +++ b/src/Gui/SDK/KCSDKGlobal.h @@ -0,0 +1,37 @@ +// 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 + +#ifndef KCSDK_GLOBAL_H +#define KCSDK_GLOBAL_H + +#ifndef KCSDKExport +# ifdef KCSDK_EXPORTS +# define KCSDKExport FREECAD_DECL_EXPORT +# else +# define KCSDKExport FREECAD_DECL_IMPORT +# endif +#endif + +#endif // KCSDK_GLOBAL_H diff --git a/src/Gui/SDK/SDKRegistry.cpp b/src/Gui/SDK/SDKRegistry.cpp new file mode 100644 index 0000000000..d625a9758a --- /dev/null +++ b/src/Gui/SDK/SDKRegistry.cpp @@ -0,0 +1,51 @@ +// 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 "SDKRegistry.h" + +#include + +namespace KCSDK +{ + +SDKRegistry& SDKRegistry::instance() +{ + static SDKRegistry reg; + return reg; +} + +SDKRegistry::SDKRegistry() +{ + Base::Console().log("KCSDK: registry initialized (API v%d)\n", API_VERSION_MAJOR); +} + +SDKRegistry::~SDKRegistry() = default; + +std::vector SDKRegistry::available() const +{ + std::lock_guard lock(mutex_); + // No providers registered yet — will be populated in subsequent phases. + return {}; +} + +} // namespace KCSDK diff --git a/src/Gui/SDK/SDKRegistry.h b/src/Gui/SDK/SDKRegistry.h new file mode 100644 index 0000000000..050f2760a9 --- /dev/null +++ b/src/Gui/SDK/SDKRegistry.h @@ -0,0 +1,71 @@ +// 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_SDKREGISTRY_H +#define KCSDK_SDKREGISTRY_H + +#include +#include +#include + +#include "KCSDKGlobal.h" + +namespace KCSDK +{ + +/// Current KCSDK API major version. Addons should check this at load time. +constexpr int API_VERSION_MAJOR = 1; + +/// Singleton registry for SDK-managed UI providers. +/// +/// This is the central coordination point for the Kindred addon SDK. +/// Provider interfaces (IPanelProvider, IToolbarProvider, etc.) will be +/// added in subsequent phases. For now this establishes the singleton +/// pattern, thread safety, and API versioning. +/// +/// Thread safety: all public methods are internally synchronized. +class KCSDKExport SDKRegistry +{ +public: + /// Access the singleton instance. + static SDKRegistry& instance(); + + ~SDKRegistry(); + + /// Return names of all registered providers (across all provider types). + std::vector available() const; + +private: + SDKRegistry(); + + SDKRegistry(const SDKRegistry&) = delete; + SDKRegistry& operator=(const SDKRegistry&) = delete; + SDKRegistry(SDKRegistry&&) = delete; + SDKRegistry& operator=(SDKRegistry&&) = delete; + + mutable std::mutex mutex_; +}; + +} // namespace KCSDK + +#endif // KCSDK_SDKREGISTRY_H diff --git a/src/Gui/SDK/bindings/CMakeLists.txt b/src/Gui/SDK/bindings/CMakeLists.txt new file mode 100644 index 0000000000..1b2ae723e0 --- /dev/null +++ b/src/Gui/SDK/bindings/CMakeLists.txt @@ -0,0 +1,30 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later + +set(KCSDKPy_SRCS + kcsdk_py.cpp +) + +add_library(kcsdk_py SHARED ${KCSDKPy_SRCS}) + +target_include_directories(kcsdk_py + PRIVATE + ${CMAKE_SOURCE_DIR}/src + ${CMAKE_BINARY_DIR}/src + ${pybind11_INCLUDE_DIR} +) + +target_link_libraries(kcsdk_py + PRIVATE + pybind11::module + Python3::Python + KCSDK +) + +if(FREECAD_WARN_ERROR) + target_compile_warn_error(kcsdk_py) +endif() + +SET_BIN_DIR(kcsdk_py kcsdk /Mod/Create) +SET_PYTHON_PREFIX_SUFFIX(kcsdk_py) + +INSTALL(TARGETS kcsdk_py DESTINATION ${CMAKE_INSTALL_LIBDIR}) diff --git a/src/Gui/SDK/bindings/kcsdk_py.cpp b/src/Gui/SDK/bindings/kcsdk_py.cpp new file mode 100644 index 0000000000..fc30cb4515 --- /dev/null +++ b/src/Gui/SDK/bindings/kcsdk_py.cpp @@ -0,0 +1,40 @@ +// 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 +#include + +#include + +namespace py = pybind11; +using namespace KCSDK; + +PYBIND11_MODULE(kcsdk, m) +{ + m.doc() = "KCSDK — Kindred Create addon SDK C++ API"; + m.attr("API_VERSION_MAJOR") = API_VERSION_MAJOR; + + m.def("available", []() { + return SDKRegistry::instance().available(); + }, "Return names of all registered providers."); +} From 60c0489d735274566f578b883c4026f426618127 Mon Sep 17 00:00:00 2001 From: forbes Date: Fri, 27 Feb 2026 14:05:07 -0600 Subject: [PATCH 2/9] feat(sdk): migrate editing context API to kcsdk (#351) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add context/overlay registration, injection, query, and refresh to the KCSDK C++ library and kcsdk pybind11 module. New files: - src/Gui/SDK/Types.h — ContextDef, OverlayDef, ContextSnapshot structs (plain C++, no Qt in public API) Modified: - src/Gui/SDK/SDKRegistry.h/.cpp — register_context/overlay, unregister, inject_commands, current_context, refresh (delegates to EditingContextResolver with std↔Qt conversion) - src/Gui/SDK/CMakeLists.txt — add Types.h, link FreeCADGui - src/Gui/SDK/bindings/kcsdk_py.cpp — bind all context functions with GIL-safe match callable wrapping and dict-based snapshot return - mods/sdk/kindred_sdk/context.py — try kcsdk first, fall back to FreeCADGui for backwards compatibility --- mods/sdk/kindred_sdk/context.py | 49 ++++++++--- src/Gui/SDK/CMakeLists.txt | 2 + src/Gui/SDK/SDKRegistry.cpp | 108 ++++++++++++++++++++++- src/Gui/SDK/SDKRegistry.h | 26 ++++++ src/Gui/SDK/Types.h | 68 +++++++++++++++ src/Gui/SDK/bindings/kcsdk_py.cpp | 140 ++++++++++++++++++++++++++++++ 6 files changed, 382 insertions(+), 11 deletions(-) create mode 100644 src/Gui/SDK/Types.h diff --git a/mods/sdk/kindred_sdk/context.py b/mods/sdk/kindred_sdk/context.py index 41019b5492..659105f041 100644 --- a/mods/sdk/kindred_sdk/context.py +++ b/mods/sdk/kindred_sdk/context.py @@ -1,12 +1,17 @@ """Editing context and overlay registration wrappers. -Thin wrappers around FreeCADGui editing context bindings. If the -underlying C++ API changes during an upstream rebase, only this module -needs to be updated. +Routes through the ``kcsdk`` C++ module when available, falling back to +the legacy ``FreeCADGui`` Python bindings 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 + def _gui(): """Lazy import of FreeCADGui (not available in console mode).""" @@ -41,7 +46,12 @@ def register_context(context_id, label, color, toolbars, match, priority=50): raise TypeError("match must be callable") try: - _gui().registerEditingContext(context_id, label, color, toolbars, match, priority) + if _kcsdk is not None: + _kcsdk.register_context(context_id, label, color, toolbars, match, priority) + else: + _gui().registerEditingContext( + context_id, label, color, toolbars, match, priority + ) except Exception as e: FreeCAD.Console.PrintWarning( f"kindred_sdk: Failed to register context '{context_id}': {e}\n" @@ -54,7 +64,10 @@ def unregister_context(context_id): raise TypeError(f"context_id must be str, got {type(context_id).__name__}") try: - _gui().unregisterEditingContext(context_id) + if _kcsdk is not None: + _kcsdk.unregister_context(context_id) + else: + _gui().unregisterEditingContext(context_id) except Exception as e: FreeCAD.Console.PrintWarning( f"kindred_sdk: Failed to unregister context '{context_id}': {e}\n" @@ -84,7 +97,10 @@ def register_overlay(overlay_id, toolbars, match): raise TypeError("match must be callable") try: - _gui().registerEditingOverlay(overlay_id, toolbars, match) + if _kcsdk is not None: + _kcsdk.register_overlay(overlay_id, toolbars, match) + else: + _gui().registerEditingOverlay(overlay_id, toolbars, match) except Exception as e: FreeCAD.Console.PrintWarning( f"kindred_sdk: Failed to register overlay '{overlay_id}': {e}\n" @@ -97,7 +113,10 @@ def unregister_overlay(overlay_id): raise TypeError(f"overlay_id must be str, got {type(overlay_id).__name__}") try: - _gui().unregisterEditingOverlay(overlay_id) + if _kcsdk is not None: + _kcsdk.unregister_overlay(overlay_id) + else: + _gui().unregisterEditingOverlay(overlay_id) except Exception as e: FreeCAD.Console.PrintWarning( f"kindred_sdk: Failed to unregister overlay '{overlay_id}': {e}\n" @@ -124,7 +143,10 @@ def inject_commands(context_id, toolbar_name, commands): raise TypeError(f"commands must be list, got {type(commands).__name__}") try: - _gui().injectEditingCommands(context_id, toolbar_name, commands) + if _kcsdk is not None: + _kcsdk.inject_commands(context_id, toolbar_name, commands) + else: + _gui().injectEditingCommands(context_id, toolbar_name, commands) except Exception as e: FreeCAD.Console.PrintWarning( f"kindred_sdk: Failed to inject commands into '{context_id}': {e}\n" @@ -138,15 +160,22 @@ def current_context(): ``breadcrumbColors``. Returns ``None`` if no context is active. """ try: + if _kcsdk is not None: + return _kcsdk.current_context() return _gui().currentEditingContext() except Exception as e: - FreeCAD.Console.PrintWarning(f"kindred_sdk: Failed to get current context: {e}\n") + FreeCAD.Console.PrintWarning( + f"kindred_sdk: Failed to get current context: {e}\n" + ) return None def refresh_context(): """Force re-resolution and update of the editing context.""" try: - _gui().refreshEditingContext() + if _kcsdk is not None: + _kcsdk.refresh() + else: + _gui().refreshEditingContext() except Exception as e: FreeCAD.Console.PrintWarning(f"kindred_sdk: Failed to refresh context: {e}\n") diff --git a/src/Gui/SDK/CMakeLists.txt b/src/Gui/SDK/CMakeLists.txt index 24e37f5e2c..783d18a8ce 100644 --- a/src/Gui/SDK/CMakeLists.txt +++ b/src/Gui/SDK/CMakeLists.txt @@ -2,6 +2,7 @@ set(KCSDK_SRCS KCSDKGlobal.h + Types.h SDKRegistry.h SDKRegistry.cpp ) @@ -17,6 +18,7 @@ target_include_directories(KCSDK target_link_libraries(KCSDK PRIVATE FreeCADBase + FreeCADGui ) if(FREECAD_WARN_ERROR) diff --git a/src/Gui/SDK/SDKRegistry.cpp b/src/Gui/SDK/SDKRegistry.cpp index d625a9758a..c05610ef81 100644 --- a/src/Gui/SDK/SDKRegistry.cpp +++ b/src/Gui/SDK/SDKRegistry.cpp @@ -23,11 +23,55 @@ #include "SDKRegistry.h" +#include +#include + #include +#include namespace KCSDK { +// -- Helpers: std ↔ Qt conversion (internal) -------------------------------- + +namespace +{ + +QString toQString(const std::string& s) +{ + return QString::fromUtf8(s.data(), static_cast(s.size())); +} + +std::string fromQString(const QString& s) +{ + QByteArray utf8 = s.toUtf8(); + return std::string(utf8.constData(), utf8.size()); +} + +QStringList toQStringList(const std::vector& v) +{ + QStringList out; + out.reserve(static_cast(v.size())); + for (const auto& s : v) { + out.append(toQString(s)); + } + return out; +} + +std::vector fromQStringList(const QStringList& list) +{ + std::vector out; + out.reserve(list.size()); + for (const auto& s : list) { + out.push_back(fromQString(s)); + } + return out; +} + +} // anonymous namespace + +// -- Singleton -------------------------------------------------------------- + SDKRegistry& SDKRegistry::instance() { static SDKRegistry reg; @@ -44,8 +88,70 @@ SDKRegistry::~SDKRegistry() = default; std::vector SDKRegistry::available() const { std::lock_guard lock(mutex_); - // No providers registered yet — will be populated in subsequent phases. return {}; } +// -- Editing context API ---------------------------------------------------- + +void SDKRegistry::registerContext(const ContextDef& def) +{ + Gui::ContextDefinition guiDef; + guiDef.id = toQString(def.id); + guiDef.labelTemplate = toQString(def.labelTemplate); + guiDef.color = toQString(def.color); + guiDef.toolbars = toQStringList(def.toolbars); + guiDef.priority = def.priority; + guiDef.match = def.match; + + Gui::EditingContextResolver::instance()->registerContext(guiDef); +} + +void SDKRegistry::unregisterContext(const std::string& id) +{ + Gui::EditingContextResolver::instance()->unregisterContext(toQString(id)); +} + +void SDKRegistry::registerOverlay(const OverlayDef& def) +{ + Gui::OverlayDefinition guiDef; + guiDef.id = toQString(def.id); + guiDef.toolbars = toQStringList(def.toolbars); + guiDef.match = def.match; + + Gui::EditingContextResolver::instance()->registerOverlay(guiDef); +} + +void SDKRegistry::unregisterOverlay(const std::string& id) +{ + Gui::EditingContextResolver::instance()->unregisterOverlay(toQString(id)); +} + +void SDKRegistry::injectCommands(const std::string& contextId, + const std::string& toolbarName, + const std::vector& commands) +{ + Gui::EditingContextResolver::instance()->injectCommands( + toQString(contextId), toQString(toolbarName), toQStringList(commands)); +} + +ContextSnapshot SDKRegistry::currentContext() const +{ + Gui::EditingContext ctx = + Gui::EditingContextResolver::instance()->currentContext(); + + ContextSnapshot snap; + snap.id = fromQString(ctx.id); + snap.label = fromQString(ctx.label); + snap.color = fromQString(ctx.color); + snap.toolbars = fromQStringList(ctx.toolbars); + snap.breadcrumb = fromQStringList(ctx.breadcrumb); + snap.breadcrumbColors = fromQStringList(ctx.breadcrumbColors); + return snap; +} + +void SDKRegistry::refresh() +{ + Gui::EditingContextResolver::instance()->refresh(); +} + } // namespace KCSDK diff --git a/src/Gui/SDK/SDKRegistry.h b/src/Gui/SDK/SDKRegistry.h index 050f2760a9..17b2a0c410 100644 --- a/src/Gui/SDK/SDKRegistry.h +++ b/src/Gui/SDK/SDKRegistry.h @@ -29,6 +29,7 @@ #include #include "KCSDKGlobal.h" +#include "Types.h" namespace KCSDK { @@ -55,6 +56,31 @@ public: /// Return names of all registered providers (across all provider types). std::vector available() const; + // -- Editing context API (delegates to EditingContextResolver) ---------- + + /// Register an editing context. + void registerContext(const ContextDef& def); + + /// Remove a previously registered editing context. + void unregisterContext(const std::string& id); + + /// Register an editing overlay (additive, not exclusive). + void registerOverlay(const OverlayDef& def); + + /// Remove a previously registered editing overlay. + void unregisterOverlay(const std::string& id); + + /// Inject additional commands into a context's toolbar. + void injectCommands(const std::string& contextId, + const std::string& toolbarName, + const std::vector& commands); + + /// Return a snapshot of the current editing context. + ContextSnapshot currentContext() const; + + /// Force re-resolution of the editing context. + void refresh(); + private: SDKRegistry(); diff --git a/src/Gui/SDK/Types.h b/src/Gui/SDK/Types.h new file mode 100644 index 0000000000..45fad4856d --- /dev/null +++ b/src/Gui/SDK/Types.h @@ -0,0 +1,68 @@ +// 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_TYPES_H +#define KCSDK_TYPES_H + +#include +#include +#include + +#include "KCSDKGlobal.h" + +namespace KCSDK +{ + +/// Plain-C++ context definition. No Qt types in the public SDK API. +struct KCSDKExport ContextDef +{ + std::string id; + std::string labelTemplate; + std::string color; + std::vector toolbars; + int priority = 50; + std::function match; +}; + +/// Plain-C++ overlay definition. +struct KCSDKExport OverlayDef +{ + std::string id; + std::vector toolbars; + std::function match; +}; + +/// Snapshot of the resolved editing context (read-only). +struct KCSDKExport ContextSnapshot +{ + std::string id; + std::string label; + std::string color; + std::vector toolbars; + std::vector breadcrumb; + std::vector breadcrumbColors; +}; + +} // namespace KCSDK + +#endif // KCSDK_TYPES_H diff --git a/src/Gui/SDK/bindings/kcsdk_py.cpp b/src/Gui/SDK/bindings/kcsdk_py.cpp index fc30cb4515..cdb4db8c47 100644 --- a/src/Gui/SDK/bindings/kcsdk_py.cpp +++ b/src/Gui/SDK/bindings/kcsdk_py.cpp @@ -23,12 +23,56 @@ #include #include +#include #include +#include namespace py = pybind11; using namespace KCSDK; +// -- Dict conversion helpers ------------------------------------------------ + +namespace +{ + +/// Convert a ContextSnapshot to a Python dict (same keys as ApplicationPy). +py::dict contextSnapshotToDict(const ContextSnapshot& snap) +{ + py::dict d; + d["id"] = snap.id; + d["label"] = snap.label; + d["color"] = snap.color; + d["toolbars"] = snap.toolbars; + d["breadcrumb"] = snap.breadcrumb; + d["breadcrumbColors"] = snap.breadcrumbColors; + return d; +} + +/// Wrap a Python callable as a GIL-safe std::function. +/// The py::object is copied (reference counted) so it survives beyond the +/// calling scope. The GIL is acquired before every invocation. +std::function wrapMatchCallable(py::object pyCallable) +{ + // Copy the py::object to prevent GC. + auto held = std::make_shared(std::move(pyCallable)); + return [held]() -> bool { + py::gil_scoped_acquire gil; + try { + py::object result = (*held)(); + return result.cast(); + } + catch (py::error_already_set& e) { + e.discard_as_unraisable(__func__); + return false; + } + }; +} + +} // anonymous namespace + +// -- Module ----------------------------------------------------------------- + PYBIND11_MODULE(kcsdk, m) { m.doc() = "KCSDK — Kindred Create addon SDK C++ API"; @@ -37,4 +81,100 @@ PYBIND11_MODULE(kcsdk, m) m.def("available", []() { return SDKRegistry::instance().available(); }, "Return names of all registered providers."); + + // -- Editing context API ------------------------------------------------ + + m.def("register_context", + [](const std::string& id, + const std::string& label, + const std::string& color, + const std::vector& toolbars, + py::object match, + int priority) { + if (!py::isinstance(match) && !py::hasattr(match, "__call__")) { + throw py::type_error("match must be callable"); + } + ContextDef def; + def.id = id; + def.labelTemplate = label; + def.color = color; + def.toolbars = toolbars; + def.priority = priority; + def.match = wrapMatchCallable(std::move(match)); + SDKRegistry::instance().registerContext(def); + }, + py::arg("id"), + py::arg("label"), + py::arg("color"), + py::arg("toolbars"), + py::arg("match"), + py::arg("priority") = 50, + "Register an editing context.\n\n" + "Parameters\n" + "----------\n" + "id : str\n Unique context identifier.\n" + "label : str\n Display label template ({name} placeholder).\n" + "color : str\n Hex color for breadcrumb.\n" + "toolbars : list[str]\n Toolbar names to show when active.\n" + "match : callable\n Zero-arg callable returning True when active.\n" + "priority : int\n Higher values checked first (default 50)."); + + m.def("unregister_context", + [](const std::string& id) { + SDKRegistry::instance().unregisterContext(id); + }, + py::arg("id"), + "Unregister an editing context."); + + m.def("register_overlay", + [](const std::string& id, + const std::vector& toolbars, + py::object match) { + if (!py::isinstance(match) && !py::hasattr(match, "__call__")) { + throw py::type_error("match must be callable"); + } + OverlayDef def; + def.id = id; + def.toolbars = toolbars; + def.match = wrapMatchCallable(std::move(match)); + SDKRegistry::instance().registerOverlay(def); + }, + py::arg("id"), + py::arg("toolbars"), + py::arg("match"), + "Register an editing overlay (additive toolbars)."); + + m.def("unregister_overlay", + [](const std::string& id) { + SDKRegistry::instance().unregisterOverlay(id); + }, + py::arg("id"), + "Unregister an editing overlay."); + + m.def("inject_commands", + [](const std::string& contextId, + const std::string& toolbarName, + const std::vector& commands) { + SDKRegistry::instance().injectCommands(contextId, toolbarName, commands); + }, + py::arg("context_id"), + py::arg("toolbar_name"), + py::arg("commands"), + "Inject commands into a context's toolbar."); + + m.def("current_context", + []() -> py::object { + ContextSnapshot snap = SDKRegistry::instance().currentContext(); + if (snap.id.empty()) { + return py::none(); + } + return contextSnapshotToDict(snap); + }, + "Return the current editing context as a dict, or None."); + + m.def("refresh", + []() { + SDKRegistry::instance().refresh(); + }, + "Force re-resolution of the editing context."); } From 64644eb6235ecb81fd2478f28e39a01220ff48ad Mon Sep 17 00:00:00 2001 From: forbes Date: Sat, 28 Feb 2026 14:53:38 -0600 Subject: [PATCH 3/9] 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 5f8557fc83dc8a03b399a4a72b8f5a62e7272bfb Mon Sep 17 00:00:00 2001 From: forbes Date: Sat, 28 Feb 2026 14:53:44 -0600 Subject: [PATCH 4/9] 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 ab4054eb9e15efc9a46ba7f853c181ef4ffff715 Mon Sep 17 00:00:00 2001 From: forbes Date: Sat, 28 Feb 2026 14:53:51 -0600 Subject: [PATCH 5/9] 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) From 4eb643a26fee4c009f3d965d03ad71e8332fb790 Mon Sep 17 00:00:00 2001 From: forbes Date: Sun, 1 Mar 2026 09:32:25 -0600 Subject: [PATCH 6/9] feat(sdk): add IToolbarProvider interface to kcsdk (#354) --- mods/sdk/kindred_sdk/__init__.py | 28 ++++---- mods/sdk/kindred_sdk/toolbar.py | 37 +++++++++++ src/Gui/SDK/CMakeLists.txt | 1 + src/Gui/SDK/IToolbarProvider.h | 60 +++++++++++++++++ src/Gui/SDK/SDKRegistry.cpp | 55 ++++++++++++++- src/Gui/SDK/SDKRegistry.h | 14 ++++ src/Gui/SDK/bindings/CMakeLists.txt | 2 + src/Gui/SDK/bindings/PyIToolbarProvider.h | 65 ++++++++++++++++++ src/Gui/SDK/bindings/PyToolbarHolder.h | 81 +++++++++++++++++++++++ src/Gui/SDK/bindings/kcsdk_py.cpp | 37 +++++++++++ 10 files changed, 366 insertions(+), 14 deletions(-) create mode 100644 mods/sdk/kindred_sdk/toolbar.py create mode 100644 src/Gui/SDK/IToolbarProvider.h create mode 100644 src/Gui/SDK/bindings/PyIToolbarProvider.h create mode 100644 src/Gui/SDK/bindings/PyToolbarHolder.h diff --git a/mods/sdk/kindred_sdk/__init__.py b/mods/sdk/kindred_sdk/__init__.py index 67f8e400b9..d889224ff5 100644 --- a/mods/sdk/kindred_sdk/__init__.py +++ b/mods/sdk/kindred_sdk/__init__.py @@ -1,19 +1,20 @@ # kindred-addon-sdk — stable API for Kindred Create addon integration -from kindred_sdk.version import SDK_VERSION -from kindred_sdk.context import ( - register_context, - unregister_context, - register_overlay, - unregister_overlay, - inject_commands, - current_context, - refresh_context, -) -from kindred_sdk.theme import get_theme_tokens, load_palette -from kindred_sdk.origin import register_origin, unregister_origin -from kindred_sdk.dock import register_dock_panel from kindred_sdk.compat import create_version, freecad_version +from kindred_sdk.context import ( + current_context, + inject_commands, + refresh_context, + register_context, + register_overlay, + unregister_context, + unregister_overlay, +) +from kindred_sdk.dock import register_dock_panel +from kindred_sdk.origin import register_origin, unregister_origin +from kindred_sdk.theme import get_theme_tokens, load_palette +from kindred_sdk.toolbar import register_toolbar +from kindred_sdk.version import SDK_VERSION __all__ = [ "SDK_VERSION", @@ -26,6 +27,7 @@ __all__ = [ "refresh_context", "get_theme_tokens", "load_palette", + "register_toolbar", "register_origin", "unregister_origin", "register_dock_panel", diff --git a/mods/sdk/kindred_sdk/toolbar.py b/mods/sdk/kindred_sdk/toolbar.py new file mode 100644 index 0000000000..c14a295626 --- /dev/null +++ b/mods/sdk/kindred_sdk/toolbar.py @@ -0,0 +1,37 @@ +"""Toolbar provider registration. + +Wraps the C++ ``kcsdk.register_toolbar()`` API with a Python fallback +that extracts toolbar data and calls ``inject_commands()`` directly. +""" + + +def _kcsdk_available(): + """Return the kcsdk module if available, else None.""" + try: + import kcsdk + + return kcsdk + except ImportError: + return None + + +def register_toolbar(provider): + """Register a toolbar provider for automatic context injection. + + When the C++ ``kcsdk`` module is available, delegates to its + ``register_toolbar()`` which stores the provider and auto-injects + commands into the target editing contexts. + + Falls back to extracting data from the provider and calling + ``inject_commands()`` directly for each target context. + """ + kcsdk = _kcsdk_available() + if kcsdk is not None: + kcsdk.register_toolbar(provider) + return + + # Fallback: extract data and call inject_commands directly + from kindred_sdk import inject_commands + + for ctx_id in provider.context_ids(): + inject_commands(ctx_id, provider.toolbar_name(), provider.commands()) diff --git a/src/Gui/SDK/CMakeLists.txt b/src/Gui/SDK/CMakeLists.txt index 981c4ad7a7..0a6a8b53a2 100644 --- a/src/Gui/SDK/CMakeLists.txt +++ b/src/Gui/SDK/CMakeLists.txt @@ -4,6 +4,7 @@ set(KCSDK_SRCS KCSDKGlobal.h Types.h IPanelProvider.h + IToolbarProvider.h WidgetBridge.h WidgetBridge.cpp ThemeEngine.h diff --git a/src/Gui/SDK/IToolbarProvider.h b/src/Gui/SDK/IToolbarProvider.h new file mode 100644 index 0000000000..d4b2ffd13d --- /dev/null +++ b/src/Gui/SDK/IToolbarProvider.h @@ -0,0 +1,60 @@ +// 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_ITOOLBARPROVIDER_H +#define KCSDK_ITOOLBARPROVIDER_H + +#include +#include + +#include "KCSDKGlobal.h" + +namespace KCSDK +{ + +/// Abstract interface for addon-provided toolbar declarations. +/// +/// Addons implement this interface to declaratively register toolbar +/// configurations. On registration the SDK auto-injects the declared +/// commands into the specified editing contexts. +class KCSDKExport IToolbarProvider +{ +public: + virtual ~IToolbarProvider() = default; + + /// Unique provider identifier (e.g. "ztools.partdesign", "gears.gear"). + virtual std::string id() const = 0; + + /// Toolbar name shown in the UI. + virtual std::string toolbar_name() const = 0; + + /// Editing context IDs this toolbar applies to. + virtual std::vector context_ids() const = 0; + + /// Command names to inject into the toolbar. + virtual std::vector commands() const = 0; +}; + +} // namespace KCSDK + +#endif // KCSDK_ITOOLBARPROVIDER_H diff --git a/src/Gui/SDK/SDKRegistry.cpp b/src/Gui/SDK/SDKRegistry.cpp index 3c051c9d64..6aee06a833 100644 --- a/src/Gui/SDK/SDKRegistry.cpp +++ b/src/Gui/SDK/SDKRegistry.cpp @@ -23,6 +23,7 @@ #include "SDKRegistry.h" #include "IPanelProvider.h" +#include "IToolbarProvider.h" #include #include @@ -91,10 +92,13 @@ std::vector SDKRegistry::available() const { std::lock_guard lock(mutex_); std::vector result; - result.reserve(panels_.size()); + result.reserve(panels_.size() + toolbars_.size()); for (const auto& [id, _] : panels_) { result.push_back(id); } + for (const auto& [id, _] : toolbars_) { + result.push_back(id); + } return result; } @@ -252,4 +256,53 @@ std::vector SDKRegistry::registeredPanels() const return result; } +// -- Toolbar provider API --------------------------------------------------- + +void SDKRegistry::registerToolbar(std::unique_ptr provider) +{ + if (!provider) { + return; + } + std::string id = provider->id(); + std::string toolbarName = provider->toolbar_name(); + std::vector contextIds = provider->context_ids(); + std::vector cmds = provider->commands(); + + { + std::lock_guard lock(mutex_); + toolbars_[id] = std::move(provider); + } + + // Auto-inject commands into each target context. + for (const auto& ctxId : contextIds) { + injectCommands(ctxId, toolbarName, cmds); + } + + Base::Console().log("KCSDK: registered toolbar provider '%s'\n", id.c_str()); +} + +void SDKRegistry::unregisterToolbar(const std::string& id) +{ + std::lock_guard lock(mutex_); + + auto it = toolbars_.find(id); + if (it == toolbars_.end()) { + return; + } + + toolbars_.erase(it); + Base::Console().log("KCSDK: unregistered toolbar provider '%s'\n", id.c_str()); +} + +std::vector SDKRegistry::registeredToolbars() const +{ + std::lock_guard lock(mutex_); + std::vector result; + result.reserve(toolbars_.size()); + for (const auto& [id, _] : toolbars_) { + result.push_back(id); + } + return result; +} + } // namespace KCSDK diff --git a/src/Gui/SDK/SDKRegistry.h b/src/Gui/SDK/SDKRegistry.h index c002e3ca8b..eeada713b1 100644 --- a/src/Gui/SDK/SDKRegistry.h +++ b/src/Gui/SDK/SDKRegistry.h @@ -37,6 +37,7 @@ namespace KCSDK { class IPanelProvider; +class IToolbarProvider; /// Current KCSDK API major version. Addons should check this at load time. constexpr int API_VERSION_MAJOR = 1; @@ -102,6 +103,18 @@ public: /// Return IDs of all registered panel providers. std::vector registeredPanels() const; + // -- Toolbar provider API ---------------------------------------------- + + /// Register a toolbar provider. Ownership transfers to the registry. + /// Auto-injects the declared commands into the target editing contexts. + void registerToolbar(std::unique_ptr provider); + + /// Remove a registered toolbar provider. + void unregisterToolbar(const std::string& id); + + /// Return IDs of all registered toolbar providers. + std::vector registeredToolbars() const; + private: SDKRegistry(); @@ -112,6 +125,7 @@ private: mutable std::mutex mutex_; std::unordered_map> panels_; + std::unordered_map> toolbars_; }; } // namespace KCSDK diff --git a/src/Gui/SDK/bindings/CMakeLists.txt b/src/Gui/SDK/bindings/CMakeLists.txt index 29cf90a1af..8ac1af9043 100644 --- a/src/Gui/SDK/bindings/CMakeLists.txt +++ b/src/Gui/SDK/bindings/CMakeLists.txt @@ -4,6 +4,8 @@ set(KCSDKPy_SRCS kcsdk_py.cpp PyIPanelProvider.h PyProviderHolder.h + PyIToolbarProvider.h + PyToolbarHolder.h ) add_library(kcsdk_py SHARED ${KCSDKPy_SRCS}) diff --git a/src/Gui/SDK/bindings/PyIToolbarProvider.h b/src/Gui/SDK/bindings/PyIToolbarProvider.h new file mode 100644 index 0000000000..7c350d4e51 --- /dev/null +++ b/src/Gui/SDK/bindings/PyIToolbarProvider.h @@ -0,0 +1,65 @@ +// 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_PYITOOLBARPROVIDER_H +#define KCSDK_PYITOOLBARPROVIDER_H + +#include +#include + +#include + +namespace KCSDK +{ + +/// pybind11 trampoline class for IToolbarProvider. +/// Enables Python subclasses that override virtual methods. +class PyIToolbarProvider : public IToolbarProvider +{ +public: + using IToolbarProvider::IToolbarProvider; + + std::string id() const override + { + PYBIND11_OVERRIDE_PURE(std::string, IToolbarProvider, id); + } + + std::string toolbar_name() const override + { + PYBIND11_OVERRIDE_PURE(std::string, IToolbarProvider, toolbar_name); + } + + std::vector context_ids() const override + { + PYBIND11_OVERRIDE_PURE(std::vector, IToolbarProvider, context_ids); + } + + std::vector commands() const override + { + PYBIND11_OVERRIDE_PURE(std::vector, IToolbarProvider, commands); + } +}; + +} // namespace KCSDK + +#endif // KCSDK_PYITOOLBARPROVIDER_H diff --git a/src/Gui/SDK/bindings/PyToolbarHolder.h b/src/Gui/SDK/bindings/PyToolbarHolder.h new file mode 100644 index 0000000000..c0fd0bbb62 --- /dev/null +++ b/src/Gui/SDK/bindings/PyToolbarHolder.h @@ -0,0 +1,81 @@ +// 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_PYTOOLBARHOLDER_H +#define KCSDK_PYTOOLBARHOLDER_H + +#include +#include + +#include + +namespace py = pybind11; + +namespace KCSDK +{ + +/// GIL-safe forwarding wrapper that holds a Python IToolbarProvider instance. +/// +/// Stores the py::object to prevent garbage collection. Acquires the GIL +/// before every call into Python. All methods return std types so no +/// Qt/PySide bridging is needed. +class PyToolbarHolder : public IToolbarProvider +{ +public: + explicit PyToolbarHolder(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 toolbar_name() const override + { + py::gil_scoped_acquire gil; + return provider_->toolbar_name(); + } + + std::vector context_ids() const override + { + py::gil_scoped_acquire gil; + return provider_->context_ids(); + } + + std::vector commands() const override + { + py::gil_scoped_acquire gil; + return provider_->commands(); + } + +private: + py::object obj_; ///< Prevents Python GC — keeps reference alive. + IToolbarProvider* provider_; ///< Raw pointer into trampoline inside obj_. +}; + +} // namespace KCSDK + +#endif // KCSDK_PYTOOLBARHOLDER_H diff --git a/src/Gui/SDK/bindings/kcsdk_py.cpp b/src/Gui/SDK/bindings/kcsdk_py.cpp index fa19879e59..160e3ae277 100644 --- a/src/Gui/SDK/bindings/kcsdk_py.cpp +++ b/src/Gui/SDK/bindings/kcsdk_py.cpp @@ -26,12 +26,15 @@ #include #include +#include #include #include #include #include "PyIPanelProvider.h" +#include "PyIToolbarProvider.h" #include "PyProviderHolder.h" +#include "PyToolbarHolder.h" namespace py = pybind11; using namespace KCSDK; @@ -247,6 +250,40 @@ PYBIND11_MODULE(kcsdk, m) }, "Return IDs of all registered panel providers."); + // -- Toolbar provider API ----------------------------------------------- + + py::class_(m, "IToolbarProvider") + .def(py::init<>()) + .def("id", &IToolbarProvider::id) + .def("toolbar_name", &IToolbarProvider::toolbar_name) + .def("context_ids", &IToolbarProvider::context_ids) + .def("commands", &IToolbarProvider::commands); + + m.def("register_toolbar", + [](py::object provider) { + auto holder = std::make_unique(std::move(provider)); + SDKRegistry::instance().registerToolbar(std::move(holder)); + }, + py::arg("provider"), + "Register a toolbar provider for automatic context injection.\n\n" + "Parameters\n" + "----------\n" + "provider : IToolbarProvider\n" + " Toolbar provider implementing id(), toolbar_name(), context_ids(), commands()."); + + m.def("unregister_toolbar", + [](const std::string& id) { + SDKRegistry::instance().unregisterToolbar(id); + }, + py::arg("id"), + "Remove a registered toolbar provider."); + + m.def("registered_toolbars", + []() { + return SDKRegistry::instance().registeredToolbars(); + }, + "Return IDs of all registered toolbar providers."); + // -- Theme engine API --------------------------------------------------- m.def("theme_color", From 747c458e236fc2d4042b3f233a4893725aa98fb8 Mon Sep 17 00:00:00 2001 From: forbes Date: Sun, 1 Mar 2026 13:13:44 -0600 Subject: [PATCH 7/9] feat(sdk): add IMenuProvider interface and register_command wrapper (#355) IMenuProvider: declarative menu placement with optional context awareness. C++ interface with pybind11 bindings + GIL-safe holder. SDKMenuManipulator (shared WorkbenchManipulator) injects menu items on workbench switch, filtered by editing context when context_ids() is non-empty. register_command(): thin Python wrapper around FreeCADGui.addCommand() that standardizes the calling convention within the SDK contract. Python wrappers (kindred_sdk.register_menu, kindred_sdk.register_command) use kcsdk-first routing with FreeCADGui fallback. --- mods/sdk/kindred_sdk/__init__.py | 4 + mods/sdk/kindred_sdk/command.py | 58 +++++++++ mods/sdk/kindred_sdk/menu.py | 53 ++++++++ src/Gui/SDK/CMakeLists.txt | 1 + src/Gui/SDK/IMenuProvider.h | 67 ++++++++++ src/Gui/SDK/SDKRegistry.cpp | 162 ++++++++++++++++++++++++- src/Gui/SDK/SDKRegistry.h | 24 ++++ src/Gui/SDK/bindings/CMakeLists.txt | 2 + src/Gui/SDK/bindings/PyIMenuProvider.h | 69 +++++++++++ src/Gui/SDK/bindings/PyMenuHolder.h | 81 +++++++++++++ src/Gui/SDK/bindings/kcsdk_py.cpp | 38 ++++++ 11 files changed, 558 insertions(+), 1 deletion(-) create mode 100644 mods/sdk/kindred_sdk/command.py create mode 100644 mods/sdk/kindred_sdk/menu.py create mode 100644 src/Gui/SDK/IMenuProvider.h create mode 100644 src/Gui/SDK/bindings/PyIMenuProvider.h create mode 100644 src/Gui/SDK/bindings/PyMenuHolder.h diff --git a/mods/sdk/kindred_sdk/__init__.py b/mods/sdk/kindred_sdk/__init__.py index d889224ff5..43b678b371 100644 --- a/mods/sdk/kindred_sdk/__init__.py +++ b/mods/sdk/kindred_sdk/__init__.py @@ -1,5 +1,6 @@ # kindred-addon-sdk — stable API for Kindred Create addon integration +from kindred_sdk.command import register_command from kindred_sdk.compat import create_version, freecad_version from kindred_sdk.context import ( current_context, @@ -11,6 +12,7 @@ from kindred_sdk.context import ( unregister_overlay, ) from kindred_sdk.dock import register_dock_panel +from kindred_sdk.menu import register_menu from kindred_sdk.origin import register_origin, unregister_origin from kindred_sdk.theme import get_theme_tokens, load_palette from kindred_sdk.toolbar import register_toolbar @@ -18,6 +20,7 @@ from kindred_sdk.version import SDK_VERSION __all__ = [ "SDK_VERSION", + "register_command", "register_context", "unregister_context", "register_overlay", @@ -27,6 +30,7 @@ __all__ = [ "refresh_context", "get_theme_tokens", "load_palette", + "register_menu", "register_toolbar", "register_origin", "unregister_origin", diff --git a/mods/sdk/kindred_sdk/command.py b/mods/sdk/kindred_sdk/command.py new file mode 100644 index 0000000000..9c0742b7ed --- /dev/null +++ b/mods/sdk/kindred_sdk/command.py @@ -0,0 +1,58 @@ +"""Command registration wrapper. + +Provides a standardized SDK entry point for registering FreeCAD commands. +This is a thin wrapper around ``FreeCADGui.addCommand()`` — no C++ interface +is needed since FreeCAD's command system is already stable and well-known. +""" + +import FreeCAD + + +def register_command(name, activated, resources, is_active=None): + """Register a FreeCAD command through the SDK. + + Parameters + ---------- + name : str + Command name (e.g. ``"MyAddon_DoThing"``). + activated : callable + Called when command is triggered. Receives an optional ``int`` + index argument (for group commands). + resources : dict + Command resources passed to ``GetResources()``. Common keys: + ``MenuText``, ``ToolTip``, ``Pixmap``, ``Accel``. + is_active : callable, optional + Zero-arg callable returning ``True`` when the command should be + enabled. Default: always active. + """ + if not callable(activated): + raise TypeError("activated must be callable") + if not isinstance(resources, dict): + raise TypeError("resources must be a dict") + if is_active is not None and not callable(is_active): + raise TypeError("is_active must be callable or None") + + _resources = dict(resources) + _activated = activated + _is_active = is_active + + class _SDKCommand: + def GetResources(self): + return _resources + + def Activated(self, index=0): + _activated() + + def IsActive(self): + if _is_active is not None: + return bool(_is_active()) + return True + + try: + import FreeCADGui + + FreeCADGui.addCommand(name, _SDKCommand()) + except Exception as e: + FreeCAD.Console.PrintWarning( + f"kindred_sdk: Failed to register command '{name}': {e}\n" + ) diff --git a/mods/sdk/kindred_sdk/menu.py b/mods/sdk/kindred_sdk/menu.py new file mode 100644 index 0000000000..e6f95164eb --- /dev/null +++ b/mods/sdk/kindred_sdk/menu.py @@ -0,0 +1,53 @@ +"""Menu provider registration. + +Wraps the C++ ``kcsdk.register_menu()`` API with a Python fallback +that installs a WorkbenchManipulator to inject menu items. +""" + +import FreeCAD + + +def _kcsdk_available(): + """Return the kcsdk module if available, else None.""" + try: + import kcsdk + + return kcsdk + except ImportError: + return None + + +def register_menu(provider): + """Register a menu provider for declarative menu placement. + + When the C++ ``kcsdk`` module is available, delegates to its + ``register_menu()`` which installs a shared WorkbenchManipulator + that injects items at the specified menu path. + + Falls back to installing a Python WorkbenchManipulator directly. + """ + kcsdk = _kcsdk_available() + if kcsdk is not None: + kcsdk.register_menu(provider) + return + + # Fallback: extract data and install a Python manipulator. + menu_path = provider.menu_path() + items = provider.items() + + try: + import FreeCADGui + + class _SDKMenuManipulator: + def modifyMenuBar(self, menuBar): + menu_items = [ + "Separator" if item == "separator" else item for item in items + ] + FreeCADGui.addCommand # Just to verify we're in GUI mode + menuBar.appendMenu(menu_path, menu_items) + + FreeCADGui.addWorkbenchManipulator(_SDKMenuManipulator()) + except Exception as e: + FreeCAD.Console.PrintWarning( + f"kindred_sdk: Failed to register menu '{provider.id()}': {e}\n" + ) diff --git a/src/Gui/SDK/CMakeLists.txt b/src/Gui/SDK/CMakeLists.txt index 0a6a8b53a2..92fbdb923b 100644 --- a/src/Gui/SDK/CMakeLists.txt +++ b/src/Gui/SDK/CMakeLists.txt @@ -3,6 +3,7 @@ set(KCSDK_SRCS KCSDKGlobal.h Types.h + IMenuProvider.h IPanelProvider.h IToolbarProvider.h WidgetBridge.h diff --git a/src/Gui/SDK/IMenuProvider.h b/src/Gui/SDK/IMenuProvider.h new file mode 100644 index 0000000000..9ebe81d948 --- /dev/null +++ b/src/Gui/SDK/IMenuProvider.h @@ -0,0 +1,67 @@ +// 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_IMENUPROVIDER_H +#define KCSDK_IMENUPROVIDER_H + +#include +#include + +#include "KCSDKGlobal.h" + +namespace KCSDK +{ + +/// Abstract interface for addon-provided menu declarations. +/// +/// Addons implement this interface to declaratively register menu items. +/// On workbench switch the SDK's shared WorkbenchManipulator injects the +/// declared items at the specified menu path. If context_ids() is +/// non-empty, items are only injected when the current editing context +/// matches. +class KCSDKExport IMenuProvider +{ +public: + virtual ~IMenuProvider() = default; + + /// Unique provider identifier (e.g. "silo.menu", "gears.tools"). + virtual std::string id() const = 0; + + /// Menu path where items are inserted. + /// Use "/" to separate levels (e.g. "&Tools" or "&Edit/Custom"). + virtual std::string menu_path() const = 0; + + /// Command names to add as menu items. Use "separator" for dividers. + virtual std::vector items() const = 0; + + /// Editing context IDs this menu applies to. + /// If empty (default), items are shown in all contexts. + virtual std::vector context_ids() const + { + return {}; + } +}; + +} // namespace KCSDK + +#endif // KCSDK_IMENUPROVIDER_H diff --git a/src/Gui/SDK/SDKRegistry.cpp b/src/Gui/SDK/SDKRegistry.cpp index 6aee06a833..456ff09bf5 100644 --- a/src/Gui/SDK/SDKRegistry.cpp +++ b/src/Gui/SDK/SDKRegistry.cpp @@ -22,6 +22,7 @@ ***************************************************************************/ #include "SDKRegistry.h" +#include "IMenuProvider.h" #include "IPanelProvider.h" #include "IToolbarProvider.h" @@ -31,6 +32,8 @@ #include #include #include +#include +#include namespace KCSDK { @@ -92,13 +95,16 @@ std::vector SDKRegistry::available() const { std::lock_guard lock(mutex_); std::vector result; - result.reserve(panels_.size() + toolbars_.size()); + result.reserve(panels_.size() + toolbars_.size() + menus_.size()); for (const auto& [id, _] : panels_) { result.push_back(id); } for (const auto& [id, _] : toolbars_) { result.push_back(id); } + for (const auto& [id, _] : menus_) { + result.push_back(id); + } return result; } @@ -305,4 +311,158 @@ std::vector SDKRegistry::registeredToolbars() const return result; } +// -- Menu provider API ------------------------------------------------------ + +namespace +{ + +/// Resolve a menu path like "&Tools" or "&Edit/Custom" into a MenuItem, +/// creating intermediate nodes as needed. +Gui::MenuItem* findOrCreateMenuPath(Gui::MenuItem* root, const std::string& path) +{ + Gui::MenuItem* current = root; + + // Split on '/' + std::string remaining = path; + while (!remaining.empty()) { + std::string segment; + auto pos = remaining.find('/'); + if (pos != std::string::npos) { + segment = remaining.substr(0, pos); + remaining = remaining.substr(pos + 1); + } + else { + segment = remaining; + remaining.clear(); + } + + if (segment.empty()) { + continue; + } + + Gui::MenuItem* child = current->findItem(segment); + if (!child) { + child = new Gui::MenuItem(current); + child->setCommand(segment); + } + current = child; + } + + return current; +} + +} // anonymous namespace + +/// Shared WorkbenchManipulator that injects menu items from all +/// registered IMenuProvider instances. +class SDKMenuManipulator : public Gui::WorkbenchManipulator +{ +public: + explicit SDKMenuManipulator(SDKRegistry& registry) + : registry_(registry) + {} + +protected: + void modifyMenuBar(Gui::MenuItem* menuBar) override + { + // Snapshot providers under lock. + std::vector providers; + { + std::lock_guard lock(registry_.mutex_); + providers.reserve(registry_.menus_.size()); + for (const auto& [_, p] : registry_.menus_) { + providers.push_back(p.get()); + } + } + + // Get current editing context for filtering. + std::string currentCtxId; + auto* resolver = Gui::EditingContextResolver::instance(); + if (resolver) { + currentCtxId = fromQString(resolver->currentContext().id); + } + + for (auto* provider : providers) { + // Check context filter. + auto ctxIds = provider->context_ids(); + if (!ctxIds.empty()) { + bool matches = false; + for (const auto& cid : ctxIds) { + if (cid == currentCtxId) { + matches = true; + break; + } + } + if (!matches) { + continue; + } + } + + // Resolve menu path and append items. + std::string path = provider->menu_path(); + Gui::MenuItem* target = findOrCreateMenuPath(menuBar, path); + if (!target) { + continue; + } + + for (const auto& item : provider->items()) { + auto* mi = new Gui::MenuItem(target); + mi->setCommand(item == "separator" ? "Separator" : item); + } + } + } + +private: + SDKRegistry& registry_; +}; + +void SDKRegistry::ensureMenuManipulator() +{ + if (!menuManipulator_) { + menuManipulator_ = std::make_shared(*this); + Gui::WorkbenchManipulator::installManipulator(menuManipulator_); + } +} + +void SDKRegistry::registerMenu(std::unique_ptr provider) +{ + if (!provider) { + return; + } + std::string id = provider->id(); + + { + std::lock_guard lock(mutex_); + menus_[id] = std::move(provider); + } + + ensureMenuManipulator(); + + Base::Console().log("KCSDK: registered menu provider '%s'\n", id.c_str()); +} + +void SDKRegistry::unregisterMenu(const std::string& id) +{ + std::lock_guard lock(mutex_); + + auto it = menus_.find(id); + if (it == menus_.end()) { + return; + } + + menus_.erase(it); + Base::Console().log("KCSDK: unregistered menu provider '%s'\n", id.c_str()); +} + +std::vector SDKRegistry::registeredMenus() const +{ + std::lock_guard lock(mutex_); + std::vector result; + result.reserve(menus_.size()); + for (const auto& [id, _] : menus_) { + result.push_back(id); + } + return result; +} + } // namespace KCSDK diff --git a/src/Gui/SDK/SDKRegistry.h b/src/Gui/SDK/SDKRegistry.h index eeada713b1..fc5d20d408 100644 --- a/src/Gui/SDK/SDKRegistry.h +++ b/src/Gui/SDK/SDKRegistry.h @@ -33,11 +33,17 @@ #include "KCSDKGlobal.h" #include "Types.h" +namespace Gui +{ +class WorkbenchManipulator; +} + namespace KCSDK { class IPanelProvider; class IToolbarProvider; +class IMenuProvider; /// Current KCSDK API major version. Addons should check this at load time. constexpr int API_VERSION_MAJOR = 1; @@ -115,6 +121,18 @@ public: /// Return IDs of all registered toolbar providers. std::vector registeredToolbars() const; + // -- Menu provider API ------------------------------------------------- + + /// Register a menu provider. Ownership transfers to the registry. + /// Items are injected into the menu bar on each workbench switch. + void registerMenu(std::unique_ptr provider); + + /// Remove a registered menu provider. + void unregisterMenu(const std::string& id); + + /// Return IDs of all registered menu providers. + std::vector registeredMenus() const; + private: SDKRegistry(); @@ -123,9 +141,15 @@ private: SDKRegistry(SDKRegistry&&) = delete; SDKRegistry& operator=(SDKRegistry&&) = delete; + friend class SDKMenuManipulator; + + void ensureMenuManipulator(); + mutable std::mutex mutex_; std::unordered_map> panels_; std::unordered_map> toolbars_; + std::unordered_map> menus_; + std::shared_ptr menuManipulator_; }; } // namespace KCSDK diff --git a/src/Gui/SDK/bindings/CMakeLists.txt b/src/Gui/SDK/bindings/CMakeLists.txt index 8ac1af9043..7916a76025 100644 --- a/src/Gui/SDK/bindings/CMakeLists.txt +++ b/src/Gui/SDK/bindings/CMakeLists.txt @@ -4,6 +4,8 @@ set(KCSDKPy_SRCS kcsdk_py.cpp PyIPanelProvider.h PyProviderHolder.h + PyIMenuProvider.h + PyMenuHolder.h PyIToolbarProvider.h PyToolbarHolder.h ) diff --git a/src/Gui/SDK/bindings/PyIMenuProvider.h b/src/Gui/SDK/bindings/PyIMenuProvider.h new file mode 100644 index 0000000000..311eddacb5 --- /dev/null +++ b/src/Gui/SDK/bindings/PyIMenuProvider.h @@ -0,0 +1,69 @@ +// 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_PYIMENUPROVIDER_H +#define KCSDK_PYIMENUPROVIDER_H + +#include +#include + +#include + +namespace KCSDK +{ + +/// pybind11 trampoline class for IMenuProvider. +/// Enables Python subclasses that override virtual methods. +class PyIMenuProvider : public IMenuProvider +{ +public: + using IMenuProvider::IMenuProvider; + + // ── Pure virtuals ────────────────────────────────────────────── + + std::string id() const override + { + PYBIND11_OVERRIDE_PURE(std::string, IMenuProvider, id); + } + + std::string menu_path() const override + { + PYBIND11_OVERRIDE_PURE(std::string, IMenuProvider, menu_path); + } + + std::vector items() const override + { + PYBIND11_OVERRIDE_PURE(std::vector, IMenuProvider, items); + } + + // ── Virtuals with defaults ───────────────────────────────────── + + std::vector context_ids() const override + { + PYBIND11_OVERRIDE(std::vector, IMenuProvider, context_ids); + } +}; + +} // namespace KCSDK + +#endif // KCSDK_PYIMENUPROVIDER_H diff --git a/src/Gui/SDK/bindings/PyMenuHolder.h b/src/Gui/SDK/bindings/PyMenuHolder.h new file mode 100644 index 0000000000..9e9c17c4f6 --- /dev/null +++ b/src/Gui/SDK/bindings/PyMenuHolder.h @@ -0,0 +1,81 @@ +// 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_PYMENUHOLDER_H +#define KCSDK_PYMENUHOLDER_H + +#include +#include + +#include + +namespace py = pybind11; + +namespace KCSDK +{ + +/// GIL-safe forwarding wrapper that holds a Python IMenuProvider instance. +/// +/// Stores the py::object to prevent garbage collection. Acquires the GIL +/// before every call into Python. All methods return std types so no +/// Qt/PySide bridging is needed. +class PyMenuHolder : public IMenuProvider +{ +public: + explicit PyMenuHolder(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 menu_path() const override + { + py::gil_scoped_acquire gil; + return provider_->menu_path(); + } + + std::vector items() const override + { + py::gil_scoped_acquire gil; + return provider_->items(); + } + + std::vector context_ids() const override + { + py::gil_scoped_acquire gil; + return provider_->context_ids(); + } + +private: + py::object obj_; ///< Prevents Python GC — keeps reference alive. + IMenuProvider* provider_; ///< Raw pointer into trampoline inside obj_. +}; + +} // namespace KCSDK + +#endif // KCSDK_PYMENUHOLDER_H diff --git a/src/Gui/SDK/bindings/kcsdk_py.cpp b/src/Gui/SDK/bindings/kcsdk_py.cpp index 160e3ae277..8bd556abc7 100644 --- a/src/Gui/SDK/bindings/kcsdk_py.cpp +++ b/src/Gui/SDK/bindings/kcsdk_py.cpp @@ -25,14 +25,17 @@ #include #include +#include #include #include #include #include #include +#include "PyIMenuProvider.h" #include "PyIPanelProvider.h" #include "PyIToolbarProvider.h" +#include "PyMenuHolder.h" #include "PyProviderHolder.h" #include "PyToolbarHolder.h" @@ -284,6 +287,41 @@ PYBIND11_MODULE(kcsdk, m) }, "Return IDs of all registered toolbar providers."); + // -- Menu provider API -------------------------------------------------- + + py::class_(m, "IMenuProvider") + .def(py::init<>()) + .def("id", &IMenuProvider::id) + .def("menu_path", &IMenuProvider::menu_path) + .def("items", &IMenuProvider::items) + .def("context_ids", &IMenuProvider::context_ids); + + m.def("register_menu", + [](py::object provider) { + auto holder = std::make_unique(std::move(provider)); + SDKRegistry::instance().registerMenu(std::move(holder)); + }, + py::arg("provider"), + "Register a menu provider for declarative menu placement.\n\n" + "Parameters\n" + "----------\n" + "provider : IMenuProvider\n" + " Menu provider implementing id(), menu_path(), items().\n" + " Optionally override context_ids() to limit to specific contexts."); + + m.def("unregister_menu", + [](const std::string& id) { + SDKRegistry::instance().unregisterMenu(id); + }, + py::arg("id"), + "Remove a registered menu provider."); + + m.def("registered_menus", + []() { + return SDKRegistry::instance().registeredMenus(); + }, + "Return IDs of all registered menu providers."); + // -- Theme engine API --------------------------------------------------- m.def("theme_color", From a6a6cefc1675b3eb54ee05aba362a39abf5444d5 Mon Sep 17 00:00:00 2001 From: forbes Date: Sun, 1 Mar 2026 14:13:31 -0600 Subject: [PATCH 8/9] feat(sdk): add status bar widget wrapper and origin query bindings (#356) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit register_status_widget(): pure Python wrapper that adds a live widget to the main window status bar with context menu discoverability. Origin query bindings (kcsdk.list_origins, active_origin, get_origin, set_active_origin): thin C++ forwarding to OriginManager with Python wrappers using kcsdk-first routing. IOriginProvider and IStatusBarProvider C++ interfaces dropped — existing FileOrigin stack is already complete, and status bar widgets don't need C++ lifecycle management. --- mods/sdk/kindred_sdk/__init__.py | 43 ++++++++---- mods/sdk/kindred_sdk/origin.py | 105 +++++++++++++++++++++++++++- mods/sdk/kindred_sdk/statusbar.py | 74 ++++++++++++++++++++ src/Gui/SDK/bindings/CMakeLists.txt | 1 + src/Gui/SDK/bindings/kcsdk_py.cpp | 69 ++++++++++++++++++ 5 files changed, 276 insertions(+), 16 deletions(-) create mode 100644 mods/sdk/kindred_sdk/statusbar.py diff --git a/mods/sdk/kindred_sdk/__init__.py b/mods/sdk/kindred_sdk/__init__.py index 43b678b371..410e18b31d 100644 --- a/mods/sdk/kindred_sdk/__init__.py +++ b/mods/sdk/kindred_sdk/__init__.py @@ -13,28 +13,41 @@ from kindred_sdk.context import ( ) from kindred_sdk.dock import register_dock_panel from kindred_sdk.menu import register_menu -from kindred_sdk.origin import register_origin, unregister_origin +from kindred_sdk.origin import ( + active_origin, + get_origin, + list_origins, + register_origin, + set_active_origin, + unregister_origin, +) +from kindred_sdk.statusbar import register_status_widget from kindred_sdk.theme import get_theme_tokens, load_palette from kindred_sdk.toolbar import register_toolbar from kindred_sdk.version import SDK_VERSION __all__ = [ "SDK_VERSION", + "active_origin", + "create_version", + "current_context", + "freecad_version", + "get_origin", + "get_theme_tokens", + "inject_commands", + "list_origins", + "load_palette", + "refresh_context", "register_command", "register_context", - "unregister_context", - "register_overlay", - "unregister_overlay", - "inject_commands", - "current_context", - "refresh_context", - "get_theme_tokens", - "load_palette", - "register_menu", - "register_toolbar", - "register_origin", - "unregister_origin", "register_dock_panel", - "create_version", - "freecad_version", + "register_menu", + "register_origin", + "register_status_widget", + "register_toolbar", + "set_active_origin", + "unregister_context", + "unregister_origin", + "unregister_overlay", + "register_overlay", ] diff --git a/mods/sdk/kindred_sdk/origin.py b/mods/sdk/kindred_sdk/origin.py index 02bb953c3b..8d19eda1d4 100644 --- a/mods/sdk/kindred_sdk/origin.py +++ b/mods/sdk/kindred_sdk/origin.py @@ -1,12 +1,22 @@ -"""FileOrigin registration wrappers. +"""FileOrigin registration and query wrappers. Wraps ``FreeCADGui.addOrigin()`` / ``removeOrigin()`` with validation and error handling. Addons implement the FileOrigin duck-typed interface directly (see Silo's ``SiloOrigin`` for the full contract). + +Query functions (``list_origins``, ``active_origin``, etc.) route +through the ``kcsdk`` C++ module when available, falling back to +``FreeCADGui.*`` 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 + _REQUIRED_METHODS = ("id", "name", "type", "ownsDocument") @@ -40,3 +50,96 @@ def unregister_origin(origin): FreeCAD.Console.PrintLog(f"kindred_sdk: Unregistered origin '{origin.id()}'\n") except Exception as e: FreeCAD.Console.PrintWarning(f"kindred_sdk: Failed to unregister origin: {e}\n") + + +def list_origins(): + """Return IDs of all registered origins. + + Returns + ------- + list[str] + Origin IDs (always includes ``"local"``). + """ + if _kcsdk is not None: + try: + return _kcsdk.list_origins() + except Exception: + pass + try: + return _gui().listOrigins() + except Exception as e: + FreeCAD.Console.PrintWarning(f"kindred_sdk: list_origins failed: {e}\n") + return [] + + +def active_origin(): + """Return info about the currently active origin. + + Returns + ------- + dict or None + Origin info dict with keys: ``id``, ``name``, ``nickname``, + ``type``, ``tracksExternally``, ``requiresAuthentication``, + ``supportsRevisions``, ``supportsBOM``, ``supportsPartNumbers``, + ``connectionState``. + """ + if _kcsdk is not None: + try: + return _kcsdk.active_origin() + except Exception: + pass + try: + return _gui().activeOrigin() + except Exception as e: + FreeCAD.Console.PrintWarning(f"kindred_sdk: active_origin failed: {e}\n") + return None + + +def set_active_origin(origin_id): + """Set the active origin by ID. + + Parameters + ---------- + origin_id : str + The origin ID to activate. + + Returns + ------- + bool + True if the origin was found and activated. + """ + if _kcsdk is not None: + try: + return _kcsdk.set_active_origin(origin_id) + except Exception: + pass + try: + return _gui().setActiveOrigin(origin_id) + except Exception as e: + FreeCAD.Console.PrintWarning(f"kindred_sdk: set_active_origin failed: {e}\n") + return False + + +def get_origin(origin_id): + """Get info about a specific origin by ID. + + Parameters + ---------- + origin_id : str + The origin ID to look up. + + Returns + ------- + dict or None + Origin info dict, or None if not found. + """ + if _kcsdk is not None: + try: + return _kcsdk.get_origin(origin_id) + except Exception: + pass + try: + return _gui().getOrigin(origin_id) + except Exception as e: + FreeCAD.Console.PrintWarning(f"kindred_sdk: get_origin failed: {e}\n") + return None diff --git a/mods/sdk/kindred_sdk/statusbar.py b/mods/sdk/kindred_sdk/statusbar.py new file mode 100644 index 0000000000..6ce153e8f6 --- /dev/null +++ b/mods/sdk/kindred_sdk/statusbar.py @@ -0,0 +1,74 @@ +"""Status bar widget registration wrapper. + +Provides a standardized SDK entry point for adding widgets to the main +window's status bar. This is a pure Python wrapper — no C++ interface +is needed since the widget is created once and Qt manages its lifecycle. +""" + +import FreeCAD + + +def register_status_widget(object_name, label, widget, position="right"): + """Add a widget to the main window status bar. + + Parameters + ---------- + object_name : str + Qt object name for duplicate prevention. + label : str + Display name shown in the status bar's right-click context menu + (becomes the widget's ``windowTitle``). + widget : QWidget + The widget instance to add. The caller keeps its own reference + for live updates (e.g. connecting signals, updating text). + position : str, optional + ``"left"`` (stretches) or ``"right"`` (permanent, fixed width). + Default ``"right"``. + """ + if not isinstance(object_name, str) or not object_name: + raise TypeError("object_name must be a non-empty string") + if not isinstance(label, str) or not label: + raise TypeError("label must be a non-empty string") + if position not in ("left", "right"): + raise ValueError(f"position must be 'left' or 'right', got {position!r}") + + try: + import FreeCADGui + from PySide import QtWidgets + + mw = FreeCADGui.getMainWindow() + if mw is None: + FreeCAD.Console.PrintWarning( + "kindred_sdk: Main window not available, " + f"cannot register status widget '{object_name}'\n" + ) + return + + sb = mw.statusBar() + + # Duplicate check + for child in sb.children(): + if ( + isinstance(child, QtWidgets.QWidget) + and child.objectName() == object_name + ): + FreeCAD.Console.PrintLog( + f"kindred_sdk: Status widget '{object_name}' already exists, skipping\n" + ) + return + + widget.setObjectName(object_name) + widget.setWindowTitle(label) + + if position == "left": + sb.addWidget(widget) + else: + sb.addPermanentWidget(widget) + + FreeCAD.Console.PrintLog( + f"kindred_sdk: Registered status widget '{object_name}'\n" + ) + except Exception as e: + FreeCAD.Console.PrintWarning( + f"kindred_sdk: Failed to register status widget '{object_name}': {e}\n" + ) diff --git a/src/Gui/SDK/bindings/CMakeLists.txt b/src/Gui/SDK/bindings/CMakeLists.txt index 7916a76025..98c917eaeb 100644 --- a/src/Gui/SDK/bindings/CMakeLists.txt +++ b/src/Gui/SDK/bindings/CMakeLists.txt @@ -24,6 +24,7 @@ target_link_libraries(kcsdk_py pybind11::module Python3::Python KCSDK + FreeCADGui ) if(FREECAD_WARN_ERROR) diff --git a/src/Gui/SDK/bindings/kcsdk_py.cpp b/src/Gui/SDK/bindings/kcsdk_py.cpp index 8bd556abc7..c4de50f23c 100644 --- a/src/Gui/SDK/bindings/kcsdk_py.cpp +++ b/src/Gui/SDK/bindings/kcsdk_py.cpp @@ -32,6 +32,9 @@ #include #include +#include +#include + #include "PyIMenuProvider.h" #include "PyIPanelProvider.h" #include "PyIToolbarProvider.h" @@ -47,6 +50,23 @@ using namespace KCSDK; namespace { +/// Build a Python dict from a FileOrigin* (same keys as ApplicationPy). +py::dict originToDict(Gui::FileOrigin* origin) +{ + py::dict d; + d["id"] = origin->id(); + d["name"] = origin->name(); + d["nickname"] = origin->nickname(); + d["type"] = static_cast(origin->type()); + d["tracksExternally"] = origin->tracksExternally(); + d["requiresAuthentication"] = origin->requiresAuthentication(); + d["supportsRevisions"] = origin->supportsRevisions(); + d["supportsBOM"] = origin->supportsBOM(); + d["supportsPartNumbers"] = origin->supportsPartNumbers(); + d["connectionState"] = static_cast(origin->connectionState()); + return d; +} + /// Convert a ContextSnapshot to a Python dict (same keys as ApplicationPy). py::dict contextSnapshotToDict(const ContextSnapshot& snap) { @@ -369,4 +389,53 @@ PYBIND11_MODULE(kcsdk, m) }, py::arg("name") = "catppuccin-mocha", "Load a named palette. Returns True on success."); + + // -- Origin query API --------------------------------------------------- + + m.def("list_origins", + []() { + auto* mgr = Gui::OriginManager::instance(); + return mgr ? mgr->originIds() : std::vector{}; + }, + "Return IDs of all registered origins."); + + m.def("active_origin", + []() -> py::object { + auto* mgr = Gui::OriginManager::instance(); + if (!mgr) { + return py::none(); + } + Gui::FileOrigin* origin = mgr->currentOrigin(); + if (!origin) { + return py::none(); + } + return originToDict(origin); + }, + "Return the active origin as a dict, or None."); + + m.def("set_active_origin", + [](const std::string& id) { + auto* mgr = Gui::OriginManager::instance(); + if (!mgr) { + return false; + } + return mgr->setCurrentOrigin(id); + }, + py::arg("id"), + "Set the active origin by ID. Returns True on success."); + + m.def("get_origin", + [](const std::string& id) -> py::object { + auto* mgr = Gui::OriginManager::instance(); + if (!mgr) { + return py::none(); + } + Gui::FileOrigin* origin = mgr->getOrigin(id); + if (!origin) { + return py::none(); + } + return originToDict(origin); + }, + py::arg("id"), + "Get origin info by ID as a dict, or None if not found."); } From 79f69e28565f67a1aa740fad8b40b973422e6afa Mon Sep 17 00:00:00 2001 From: forbes Date: Sun, 1 Mar 2026 14:32:08 -0600 Subject: [PATCH 9/9] feat(sdk): remove fallbacks, add deprecation warnings, bump v1.0.0 (#357) - Bump SDK_VERSION to 1.0.0 in version.py and package.xml - Remove tag from package.xml (kcsdk is C++) - Remove all FreeCADGui.* fallback paths in context.py, dock.py, toolbar.py, menu.py; require kcsdk module - Remove query fallbacks in origin.py (keep register/unregister which still need FreeCADGui.addOrigin/removeOrigin) - Add deprecation warnings to 11 superseded FreeCADGui methods in ApplicationPy.cpp (not addOrigin/removeOrigin) - All 39 Tier 1 tests pass --- mods/sdk/kindred_sdk/context.py | 59 +++++++++--------------- mods/sdk/kindred_sdk/dock.py | 79 ++++++++------------------------- mods/sdk/kindred_sdk/menu.py | 52 +++++----------------- mods/sdk/kindred_sdk/origin.py | 51 +++++++++------------ mods/sdk/kindred_sdk/toolbar.py | 38 +++++----------- mods/sdk/kindred_sdk/version.py | 2 +- mods/sdk/package.xml | 3 +- src/Gui/ApplicationPy.cpp | 11 +++++ 8 files changed, 95 insertions(+), 200 deletions(-) diff --git a/mods/sdk/kindred_sdk/context.py b/mods/sdk/kindred_sdk/context.py index 659105f041..fd61884fa9 100644 --- a/mods/sdk/kindred_sdk/context.py +++ b/mods/sdk/kindred_sdk/context.py @@ -1,23 +1,23 @@ """Editing context and overlay registration wrappers. -Routes through the ``kcsdk`` C++ module when available, falling back to -the legacy ``FreeCADGui`` Python bindings for backwards compatibility. +Routes through the ``kcsdk`` C++ module. The legacy ``FreeCADGui`` +Python bindings are deprecated — kcsdk is required. """ import FreeCAD -# Try to import the C++ SDK module; None if not yet built/installed. try: import kcsdk as _kcsdk except ImportError: _kcsdk = None -def _gui(): - """Lazy import of FreeCADGui (not available in console mode).""" - import FreeCADGui - - return FreeCADGui +def _require_kcsdk(): + if _kcsdk is None: + raise RuntimeError( + "kcsdk module not available. " + "The kindred_sdk requires the kcsdk C++ module (libKCSDK)." + ) def register_context(context_id, label, color, toolbars, match, priority=50): @@ -45,13 +45,9 @@ def register_context(context_id, label, color, toolbars, match, priority=50): if not callable(match): raise TypeError("match must be callable") + _require_kcsdk() try: - if _kcsdk is not None: - _kcsdk.register_context(context_id, label, color, toolbars, match, priority) - else: - _gui().registerEditingContext( - context_id, label, color, toolbars, match, priority - ) + _kcsdk.register_context(context_id, label, color, toolbars, match, priority) except Exception as e: FreeCAD.Console.PrintWarning( f"kindred_sdk: Failed to register context '{context_id}': {e}\n" @@ -63,11 +59,9 @@ def unregister_context(context_id): if not isinstance(context_id, str): raise TypeError(f"context_id must be str, got {type(context_id).__name__}") + _require_kcsdk() try: - if _kcsdk is not None: - _kcsdk.unregister_context(context_id) - else: - _gui().unregisterEditingContext(context_id) + _kcsdk.unregister_context(context_id) except Exception as e: FreeCAD.Console.PrintWarning( f"kindred_sdk: Failed to unregister context '{context_id}': {e}\n" @@ -96,11 +90,9 @@ def register_overlay(overlay_id, toolbars, match): if not callable(match): raise TypeError("match must be callable") + _require_kcsdk() try: - if _kcsdk is not None: - _kcsdk.register_overlay(overlay_id, toolbars, match) - else: - _gui().registerEditingOverlay(overlay_id, toolbars, match) + _kcsdk.register_overlay(overlay_id, toolbars, match) except Exception as e: FreeCAD.Console.PrintWarning( f"kindred_sdk: Failed to register overlay '{overlay_id}': {e}\n" @@ -112,11 +104,9 @@ def unregister_overlay(overlay_id): if not isinstance(overlay_id, str): raise TypeError(f"overlay_id must be str, got {type(overlay_id).__name__}") + _require_kcsdk() try: - if _kcsdk is not None: - _kcsdk.unregister_overlay(overlay_id) - else: - _gui().unregisterEditingOverlay(overlay_id) + _kcsdk.unregister_overlay(overlay_id) except Exception as e: FreeCAD.Console.PrintWarning( f"kindred_sdk: Failed to unregister overlay '{overlay_id}': {e}\n" @@ -142,11 +132,9 @@ def inject_commands(context_id, toolbar_name, commands): if not isinstance(commands, list): raise TypeError(f"commands must be list, got {type(commands).__name__}") + _require_kcsdk() try: - if _kcsdk is not None: - _kcsdk.inject_commands(context_id, toolbar_name, commands) - else: - _gui().injectEditingCommands(context_id, toolbar_name, commands) + _kcsdk.inject_commands(context_id, toolbar_name, commands) except Exception as e: FreeCAD.Console.PrintWarning( f"kindred_sdk: Failed to inject commands into '{context_id}': {e}\n" @@ -159,10 +147,9 @@ def current_context(): Keys: ``id``, ``label``, ``color``, ``toolbars``, ``breadcrumb``, ``breadcrumbColors``. Returns ``None`` if no context is active. """ + _require_kcsdk() try: - if _kcsdk is not None: - return _kcsdk.current_context() - return _gui().currentEditingContext() + return _kcsdk.current_context() except Exception as e: FreeCAD.Console.PrintWarning( f"kindred_sdk: Failed to get current context: {e}\n" @@ -172,10 +159,8 @@ def current_context(): def refresh_context(): """Force re-resolution and update of the editing context.""" + _require_kcsdk() try: - if _kcsdk is not None: - _kcsdk.refresh() - else: - _gui().refreshEditingContext() + _kcsdk.refresh() except Exception as e: FreeCAD.Console.PrintWarning(f"kindred_sdk: Failed to refresh context: {e}\n") diff --git a/mods/sdk/kindred_sdk/dock.py b/mods/sdk/kindred_sdk/dock.py index b2940f8229..168b348a9a 100644 --- a/mods/sdk/kindred_sdk/dock.py +++ b/mods/sdk/kindred_sdk/dock.py @@ -1,28 +1,27 @@ """Dock panel registration helper. -Routes through the ``kcsdk`` C++ module (IPanelProvider / DockWindowManager) -when available, falling back to direct PySide QDockWidget creation for -backwards compatibility. +Routes through the ``kcsdk`` C++ module (IPanelProvider / DockWindowManager). +The kcsdk module is required — legacy PySide fallback has been removed. """ 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 / 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 _require_kcsdk(): + if _kcsdk is None: + raise RuntimeError( + "kcsdk module not available. " + "The kindred_sdk requires the kcsdk C++ module (libKCSDK)." + ) + + def _get_dock_area(area_str): """Convert area string to kcsdk.DockArea enum value.""" global _DOCK_AREA_MAP @@ -36,6 +35,9 @@ def _get_dock_area(area_str): return _DOCK_AREA_MAP.get(area_str) +_AREA_NAMES = ("left", "right", "top", "bottom") + + def register_dock_panel(object_name, title, widget_factory, area="right", delay_ms=0): """Register a dock panel, optionally deferred. @@ -59,18 +61,11 @@ def register_dock_panel(object_name, title, widget_factory, area="right", delay_ raise TypeError(f"object_name must be str, got {type(object_name).__name__}") if not callable(widget_factory): raise TypeError("widget_factory must be callable") + if area not in _AREA_NAMES: + raise ValueError(f"area must be one of {list(_AREA_NAMES)}, got {area!r}") - if area not in _AREA_MAP: - raise ValueError(f"area must be one of {list(_AREA_MAP)}, got {area!r}") + _require_kcsdk() - 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): @@ -101,44 +96,6 @@ def _register_via_kcsdk(object_name, title, widget_factory, area, delay_ms): 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: - import FreeCADGui - from PySide import QtCore, QtWidgets - - mw = FreeCADGui.getMainWindow() - if mw is None: - return - - if mw.findChild(QtWidgets.QDockWidget, object_name): - return - - widget = widget_factory() - panel = QtWidgets.QDockWidget(title, mw) - panel.setObjectName(object_name) - 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" - ) - - try: - from PySide.QtCore import QTimer - - QTimer.singleShot(max(0, delay_ms), _create) - except Exception as e: - FreeCAD.Console.PrintLog( - f"kindred_sdk: Could not schedule dock panel '{object_name}': {e}\n" + FreeCAD.Console.PrintWarning( + f"kindred_sdk: Panel registration failed for '{object_name}': {e}\n" ) diff --git a/mods/sdk/kindred_sdk/menu.py b/mods/sdk/kindred_sdk/menu.py index e6f95164eb..04d9791f13 100644 --- a/mods/sdk/kindred_sdk/menu.py +++ b/mods/sdk/kindred_sdk/menu.py @@ -1,53 +1,21 @@ """Menu provider registration. -Wraps the C++ ``kcsdk.register_menu()`` API with a Python fallback -that installs a WorkbenchManipulator to inject menu items. +Routes through the ``kcsdk`` C++ module. The kcsdk module is required. """ -import FreeCAD - - -def _kcsdk_available(): - """Return the kcsdk module if available, else None.""" - try: - import kcsdk - - return kcsdk - except ImportError: - return None - def register_menu(provider): """Register a menu provider for declarative menu placement. - When the C++ ``kcsdk`` module is available, delegates to its - ``register_menu()`` which installs a shared WorkbenchManipulator - that injects items at the specified menu path. - - Falls back to installing a Python WorkbenchManipulator directly. + Delegates to ``kcsdk.register_menu()`` which installs a shared + WorkbenchManipulator that injects items at the specified menu path. """ - kcsdk = _kcsdk_available() - if kcsdk is not None: - kcsdk.register_menu(provider) - return - - # Fallback: extract data and install a Python manipulator. - menu_path = provider.menu_path() - items = provider.items() - try: - import FreeCADGui - - class _SDKMenuManipulator: - def modifyMenuBar(self, menuBar): - menu_items = [ - "Separator" if item == "separator" else item for item in items - ] - FreeCADGui.addCommand # Just to verify we're in GUI mode - menuBar.appendMenu(menu_path, menu_items) - - FreeCADGui.addWorkbenchManipulator(_SDKMenuManipulator()) - except Exception as e: - FreeCAD.Console.PrintWarning( - f"kindred_sdk: Failed to register menu '{provider.id()}': {e}\n" + import kcsdk + except ImportError: + raise RuntimeError( + "kcsdk module not available. " + "The kindred_sdk requires the kcsdk C++ module (libKCSDK)." ) + + kcsdk.register_menu(provider) diff --git a/mods/sdk/kindred_sdk/origin.py b/mods/sdk/kindred_sdk/origin.py index 8d19eda1d4..b3e2a2ded2 100644 --- a/mods/sdk/kindred_sdk/origin.py +++ b/mods/sdk/kindred_sdk/origin.py @@ -1,17 +1,15 @@ """FileOrigin registration and query wrappers. -Wraps ``FreeCADGui.addOrigin()`` / ``removeOrigin()`` with validation -and error handling. Addons implement the FileOrigin duck-typed -interface directly (see Silo's ``SiloOrigin`` for the full contract). +Registration (``register_origin`` / ``unregister_origin``) wraps +``FreeCADGui.addOrigin()`` / ``removeOrigin()`` with validation. Query functions (``list_origins``, ``active_origin``, etc.) route -through the ``kcsdk`` C++ module when available, falling back to -``FreeCADGui.*`` for backwards compatibility. +through the ``kcsdk`` C++ module. The kcsdk module is required for +query operations. """ import FreeCAD -# Try to import the C++ SDK module; None if not yet built/installed. try: import kcsdk as _kcsdk except ImportError: @@ -21,11 +19,20 @@ _REQUIRED_METHODS = ("id", "name", "type", "ownsDocument") def _gui(): + """Lazy import of FreeCADGui (not available in console mode).""" import FreeCADGui return FreeCADGui +def _require_kcsdk(): + if _kcsdk is None: + raise RuntimeError( + "kcsdk module not available. " + "The kindred_sdk requires the kcsdk C++ module (libKCSDK)." + ) + + def register_origin(origin): """Register a FileOrigin with FreeCADGui. @@ -60,13 +67,9 @@ def list_origins(): list[str] Origin IDs (always includes ``"local"``). """ - if _kcsdk is not None: - try: - return _kcsdk.list_origins() - except Exception: - pass + _require_kcsdk() try: - return _gui().listOrigins() + return _kcsdk.list_origins() except Exception as e: FreeCAD.Console.PrintWarning(f"kindred_sdk: list_origins failed: {e}\n") return [] @@ -83,13 +86,9 @@ def active_origin(): ``supportsRevisions``, ``supportsBOM``, ``supportsPartNumbers``, ``connectionState``. """ - if _kcsdk is not None: - try: - return _kcsdk.active_origin() - except Exception: - pass + _require_kcsdk() try: - return _gui().activeOrigin() + return _kcsdk.active_origin() except Exception as e: FreeCAD.Console.PrintWarning(f"kindred_sdk: active_origin failed: {e}\n") return None @@ -108,13 +107,9 @@ def set_active_origin(origin_id): bool True if the origin was found and activated. """ - if _kcsdk is not None: - try: - return _kcsdk.set_active_origin(origin_id) - except Exception: - pass + _require_kcsdk() try: - return _gui().setActiveOrigin(origin_id) + return _kcsdk.set_active_origin(origin_id) except Exception as e: FreeCAD.Console.PrintWarning(f"kindred_sdk: set_active_origin failed: {e}\n") return False @@ -133,13 +128,9 @@ def get_origin(origin_id): dict or None Origin info dict, or None if not found. """ - if _kcsdk is not None: - try: - return _kcsdk.get_origin(origin_id) - except Exception: - pass + _require_kcsdk() try: - return _gui().getOrigin(origin_id) + return _kcsdk.get_origin(origin_id) except Exception as e: FreeCAD.Console.PrintWarning(f"kindred_sdk: get_origin failed: {e}\n") return None diff --git a/mods/sdk/kindred_sdk/toolbar.py b/mods/sdk/kindred_sdk/toolbar.py index c14a295626..8774a59669 100644 --- a/mods/sdk/kindred_sdk/toolbar.py +++ b/mods/sdk/kindred_sdk/toolbar.py @@ -1,37 +1,21 @@ """Toolbar provider registration. -Wraps the C++ ``kcsdk.register_toolbar()`` API with a Python fallback -that extracts toolbar data and calls ``inject_commands()`` directly. +Routes through the ``kcsdk`` C++ module. The kcsdk module is required. """ -def _kcsdk_available(): - """Return the kcsdk module if available, else None.""" - try: - import kcsdk - - return kcsdk - except ImportError: - return None - - def register_toolbar(provider): """Register a toolbar provider for automatic context injection. - When the C++ ``kcsdk`` module is available, delegates to its - ``register_toolbar()`` which stores the provider and auto-injects - commands into the target editing contexts. - - Falls back to extracting data from the provider and calling - ``inject_commands()`` directly for each target context. + Delegates to ``kcsdk.register_toolbar()`` which stores the provider + and auto-injects commands into the target editing contexts. """ - kcsdk = _kcsdk_available() - if kcsdk is not None: - kcsdk.register_toolbar(provider) - return + try: + import kcsdk + except ImportError: + raise RuntimeError( + "kcsdk module not available. " + "The kindred_sdk requires the kcsdk C++ module (libKCSDK)." + ) - # Fallback: extract data and call inject_commands directly - from kindred_sdk import inject_commands - - for ctx_id in provider.context_ids(): - inject_commands(ctx_id, provider.toolbar_name(), provider.commands()) + kcsdk.register_toolbar(provider) diff --git a/mods/sdk/kindred_sdk/version.py b/mods/sdk/kindred_sdk/version.py index 597e21ba21..983d42c865 100644 --- a/mods/sdk/kindred_sdk/version.py +++ b/mods/sdk/kindred_sdk/version.py @@ -1 +1 @@ -SDK_VERSION = "0.1.0" +SDK_VERSION = "1.0.0" diff --git a/mods/sdk/package.xml b/mods/sdk/package.xml index 1577f8702b..da067f02cf 100644 --- a/mods/sdk/package.xml +++ b/mods/sdk/package.xml @@ -3,7 +3,7 @@ sdk Kindred Create addon SDK - stable API for addon integration - 0.1.0 + 1.0.0 Kindred Systems LGPL-2.1-or-later @@ -17,7 +17,6 @@ 0.1.0 0 - true diff --git a/src/Gui/ApplicationPy.cpp b/src/Gui/ApplicationPy.cpp index 2472264f84..5dd9229ceb 100644 --- a/src/Gui/ApplicationPy.cpp +++ b/src/Gui/ApplicationPy.cpp @@ -2110,6 +2110,7 @@ PyObject* ApplicationPy::sRemoveOrigin(PyObject* /*self*/, PyObject* args) PyObject* ApplicationPy::sGetOrigin(PyObject* /*self*/, PyObject* args) { + Base::Console().warning("FreeCADGui.getOrigin() is deprecated. Use kindred_sdk.get_origin() instead.\n"); const char* originId = nullptr; if (!PyArg_ParseTuple(args, "s", &originId)) { return nullptr; @@ -2142,6 +2143,7 @@ PyObject* ApplicationPy::sGetOrigin(PyObject* /*self*/, PyObject* args) PyObject* ApplicationPy::sListOrigins(PyObject* /*self*/, PyObject* args) { + Base::Console().warning("FreeCADGui.listOrigins() is deprecated. Use kindred_sdk.list_origins() instead.\n"); if (!PyArg_ParseTuple(args, "")) { return nullptr; } @@ -2159,6 +2161,7 @@ PyObject* ApplicationPy::sListOrigins(PyObject* /*self*/, PyObject* args) PyObject* ApplicationPy::sActiveOrigin(PyObject* /*self*/, PyObject* args) { + Base::Console().warning("FreeCADGui.activeOrigin() is deprecated. Use kindred_sdk.active_origin() instead.\n"); if (!PyArg_ParseTuple(args, "")) { return nullptr; } @@ -2190,6 +2193,7 @@ PyObject* ApplicationPy::sActiveOrigin(PyObject* /*self*/, PyObject* args) PyObject* ApplicationPy::sSetActiveOrigin(PyObject* /*self*/, PyObject* args) { + Base::Console().warning("FreeCADGui.setActiveOrigin() is deprecated. Use kindred_sdk.set_active_origin() instead.\n"); const char* originId = nullptr; if (!PyArg_ParseTuple(args, "s", &originId)) { return nullptr; @@ -2234,6 +2238,7 @@ static PyObject* qStringListToPyList(const QStringList& list) PyObject* ApplicationPy::sRegisterEditingContext(PyObject* /*self*/, PyObject* args) { + Base::Console().warning("FreeCADGui.registerEditingContext() is deprecated. Use kindred_sdk.register_context() instead.\n"); const char* id = nullptr; const char* label = nullptr; const char* color = nullptr; @@ -2282,6 +2287,7 @@ PyObject* ApplicationPy::sRegisterEditingContext(PyObject* /*self*/, PyObject* a PyObject* ApplicationPy::sUnregisterEditingContext(PyObject* /*self*/, PyObject* args) { + Base::Console().warning("FreeCADGui.unregisterEditingContext() is deprecated. Use kindred_sdk.unregister_context() instead.\n"); const char* id = nullptr; if (!PyArg_ParseTuple(args, "s", &id)) { return nullptr; @@ -2292,6 +2298,7 @@ PyObject* ApplicationPy::sUnregisterEditingContext(PyObject* /*self*/, PyObject* PyObject* ApplicationPy::sRegisterEditingOverlay(PyObject* /*self*/, PyObject* args) { + Base::Console().warning("FreeCADGui.registerEditingOverlay() is deprecated. Use kindred_sdk.register_overlay() instead.\n"); const char* id = nullptr; PyObject* toolbarsList = nullptr; PyObject* matchCallable = nullptr; @@ -2333,6 +2340,7 @@ PyObject* ApplicationPy::sRegisterEditingOverlay(PyObject* /*self*/, PyObject* a PyObject* ApplicationPy::sUnregisterEditingOverlay(PyObject* /*self*/, PyObject* args) { + Base::Console().warning("FreeCADGui.unregisterEditingOverlay() is deprecated. Use kindred_sdk.unregister_overlay() instead.\n"); const char* id = nullptr; if (!PyArg_ParseTuple(args, "s", &id)) { return nullptr; @@ -2343,6 +2351,7 @@ PyObject* ApplicationPy::sUnregisterEditingOverlay(PyObject* /*self*/, PyObject* PyObject* ApplicationPy::sInjectEditingCommands(PyObject* /*self*/, PyObject* args) { + Base::Console().warning("FreeCADGui.injectEditingCommands() is deprecated. Use kindred_sdk.inject_commands() instead.\n"); const char* contextId = nullptr; const char* toolbarName = nullptr; PyObject* commandsList = nullptr; @@ -2359,6 +2368,7 @@ PyObject* ApplicationPy::sInjectEditingCommands(PyObject* /*self*/, PyObject* ar PyObject* ApplicationPy::sCurrentEditingContext(PyObject* /*self*/, PyObject* args) { + Base::Console().warning("FreeCADGui.currentEditingContext() is deprecated. Use kindred_sdk.current_context() instead.\n"); if (!PyArg_ParseTuple(args, "")) { return nullptr; } @@ -2378,6 +2388,7 @@ PyObject* ApplicationPy::sCurrentEditingContext(PyObject* /*self*/, PyObject* ar PyObject* ApplicationPy::sRefreshEditingContext(PyObject* /*self*/, PyObject* args) { + Base::Console().warning("FreeCADGui.refreshEditingContext() is deprecated. Use kindred_sdk.refresh_context() instead.\n"); if (!PyArg_ParseTuple(args, "")) { return nullptr; }