From 2bf083609d30153e3c342f38398da783e1f242e9 Mon Sep 17 00:00:00 2001 From: forbes Date: Sun, 1 Mar 2026 13:13:44 -0600 Subject: [PATCH] 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",