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.
This commit is contained in:
forbes
2026-03-01 13:13:44 -06:00
parent 4eb643a26f
commit 747c458e23
11 changed files with 558 additions and 1 deletions

View File

@@ -1,5 +1,6 @@
# kindred-addon-sdk — stable API for Kindred Create addon integration # 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.compat import create_version, freecad_version
from kindred_sdk.context import ( from kindred_sdk.context import (
current_context, current_context,
@@ -11,6 +12,7 @@ from kindred_sdk.context import (
unregister_overlay, unregister_overlay,
) )
from kindred_sdk.dock import register_dock_panel 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 register_origin, unregister_origin
from kindred_sdk.theme import get_theme_tokens, load_palette from kindred_sdk.theme import get_theme_tokens, load_palette
from kindred_sdk.toolbar import register_toolbar from kindred_sdk.toolbar import register_toolbar
@@ -18,6 +20,7 @@ from kindred_sdk.version import SDK_VERSION
__all__ = [ __all__ = [
"SDK_VERSION", "SDK_VERSION",
"register_command",
"register_context", "register_context",
"unregister_context", "unregister_context",
"register_overlay", "register_overlay",
@@ -27,6 +30,7 @@ __all__ = [
"refresh_context", "refresh_context",
"get_theme_tokens", "get_theme_tokens",
"load_palette", "load_palette",
"register_menu",
"register_toolbar", "register_toolbar",
"register_origin", "register_origin",
"unregister_origin", "unregister_origin",

View File

@@ -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"
)

View File

@@ -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"
)

View File

@@ -3,6 +3,7 @@
set(KCSDK_SRCS set(KCSDK_SRCS
KCSDKGlobal.h KCSDKGlobal.h
Types.h Types.h
IMenuProvider.h
IPanelProvider.h IPanelProvider.h
IToolbarProvider.h IToolbarProvider.h
WidgetBridge.h WidgetBridge.h

View File

@@ -0,0 +1,67 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
/****************************************************************************
* *
* Copyright (c) 2025 Kindred Systems <development@kindred-systems.com> *
* *
* 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 *
* <https://www.gnu.org/licenses/>. *
* *
***************************************************************************/
#ifndef KCSDK_IMENUPROVIDER_H
#define KCSDK_IMENUPROVIDER_H
#include <string>
#include <vector>
#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<std::string> items() const = 0;
/// Editing context IDs this menu applies to.
/// If empty (default), items are shown in all contexts.
virtual std::vector<std::string> context_ids() const
{
return {};
}
};
} // namespace KCSDK
#endif // KCSDK_IMENUPROVIDER_H

View File

@@ -22,6 +22,7 @@
***************************************************************************/ ***************************************************************************/
#include "SDKRegistry.h" #include "SDKRegistry.h"
#include "IMenuProvider.h"
#include "IPanelProvider.h" #include "IPanelProvider.h"
#include "IToolbarProvider.h" #include "IToolbarProvider.h"
@@ -31,6 +32,8 @@
#include <Base/Console.h> #include <Base/Console.h>
#include <Gui/DockWindowManager.h> #include <Gui/DockWindowManager.h>
#include <Gui/EditingContext.h> #include <Gui/EditingContext.h>
#include <Gui/MenuManager.h>
#include <Gui/WorkbenchManipulator.h>
namespace KCSDK namespace KCSDK
{ {
@@ -92,13 +95,16 @@ std::vector<std::string> SDKRegistry::available() const
{ {
std::lock_guard<std::mutex> lock(mutex_); std::lock_guard<std::mutex> lock(mutex_);
std::vector<std::string> result; std::vector<std::string> result;
result.reserve(panels_.size() + toolbars_.size()); result.reserve(panels_.size() + toolbars_.size() + menus_.size());
for (const auto& [id, _] : panels_) { for (const auto& [id, _] : panels_) {
result.push_back(id); result.push_back(id);
} }
for (const auto& [id, _] : toolbars_) { for (const auto& [id, _] : toolbars_) {
result.push_back(id); result.push_back(id);
} }
for (const auto& [id, _] : menus_) {
result.push_back(id);
}
return result; return result;
} }
@@ -305,4 +311,158 @@ std::vector<std::string> SDKRegistry::registeredToolbars() const
return result; 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<IMenuProvider*> providers;
{
std::lock_guard<std::mutex> 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<SDKMenuManipulator>(*this);
Gui::WorkbenchManipulator::installManipulator(menuManipulator_);
}
}
void SDKRegistry::registerMenu(std::unique_ptr<IMenuProvider> provider)
{
if (!provider) {
return;
}
std::string id = provider->id();
{
std::lock_guard<std::mutex> 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<std::mutex> 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<std::string> SDKRegistry::registeredMenus() const
{
std::lock_guard<std::mutex> lock(mutex_);
std::vector<std::string> result;
result.reserve(menus_.size());
for (const auto& [id, _] : menus_) {
result.push_back(id);
}
return result;
}
} // namespace KCSDK } // namespace KCSDK

View File

@@ -33,11 +33,17 @@
#include "KCSDKGlobal.h" #include "KCSDKGlobal.h"
#include "Types.h" #include "Types.h"
namespace Gui
{
class WorkbenchManipulator;
}
namespace KCSDK namespace KCSDK
{ {
class IPanelProvider; class IPanelProvider;
class IToolbarProvider; class IToolbarProvider;
class IMenuProvider;
/// Current KCSDK API major version. Addons should check this at load time. /// Current KCSDK API major version. Addons should check this at load time.
constexpr int API_VERSION_MAJOR = 1; constexpr int API_VERSION_MAJOR = 1;
@@ -115,6 +121,18 @@ public:
/// Return IDs of all registered toolbar providers. /// Return IDs of all registered toolbar providers.
std::vector<std::string> registeredToolbars() const; std::vector<std::string> 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<IMenuProvider> provider);
/// Remove a registered menu provider.
void unregisterMenu(const std::string& id);
/// Return IDs of all registered menu providers.
std::vector<std::string> registeredMenus() const;
private: private:
SDKRegistry(); SDKRegistry();
@@ -123,9 +141,15 @@ private:
SDKRegistry(SDKRegistry&&) = delete; SDKRegistry(SDKRegistry&&) = delete;
SDKRegistry& operator=(SDKRegistry&&) = delete; SDKRegistry& operator=(SDKRegistry&&) = delete;
friend class SDKMenuManipulator;
void ensureMenuManipulator();
mutable std::mutex mutex_; mutable std::mutex mutex_;
std::unordered_map<std::string, std::unique_ptr<IPanelProvider>> panels_; std::unordered_map<std::string, std::unique_ptr<IPanelProvider>> panels_;
std::unordered_map<std::string, std::unique_ptr<IToolbarProvider>> toolbars_; std::unordered_map<std::string, std::unique_ptr<IToolbarProvider>> toolbars_;
std::unordered_map<std::string, std::unique_ptr<IMenuProvider>> menus_;
std::shared_ptr<Gui::WorkbenchManipulator> menuManipulator_;
}; };
} // namespace KCSDK } // namespace KCSDK

View File

@@ -4,6 +4,8 @@ set(KCSDKPy_SRCS
kcsdk_py.cpp kcsdk_py.cpp
PyIPanelProvider.h PyIPanelProvider.h
PyProviderHolder.h PyProviderHolder.h
PyIMenuProvider.h
PyMenuHolder.h
PyIToolbarProvider.h PyIToolbarProvider.h
PyToolbarHolder.h PyToolbarHolder.h
) )

View File

@@ -0,0 +1,69 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
/****************************************************************************
* *
* Copyright (c) 2025 Kindred Systems <development@kindred-systems.com> *
* *
* 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 *
* <https://www.gnu.org/licenses/>. *
* *
***************************************************************************/
#ifndef KCSDK_PYIMENUPROVIDER_H
#define KCSDK_PYIMENUPROVIDER_H
#include <pybind11/pybind11.h>
#include <pybind11/stl.h>
#include <Gui/SDK/IMenuProvider.h>
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<std::string> items() const override
{
PYBIND11_OVERRIDE_PURE(std::vector<std::string>, IMenuProvider, items);
}
// ── Virtuals with defaults ─────────────────────────────────────
std::vector<std::string> context_ids() const override
{
PYBIND11_OVERRIDE(std::vector<std::string>, IMenuProvider, context_ids);
}
};
} // namespace KCSDK
#endif // KCSDK_PYIMENUPROVIDER_H

View File

@@ -0,0 +1,81 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
/****************************************************************************
* *
* Copyright (c) 2025 Kindred Systems <development@kindred-systems.com> *
* *
* 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 *
* <https://www.gnu.org/licenses/>. *
* *
***************************************************************************/
#ifndef KCSDK_PYMENUHOLDER_H
#define KCSDK_PYMENUHOLDER_H
#include <pybind11/pybind11.h>
#include <pybind11/stl.h>
#include <Gui/SDK/IMenuProvider.h>
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<IMenuProvider*>())
{}
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<std::string> items() const override
{
py::gil_scoped_acquire gil;
return provider_->items();
}
std::vector<std::string> 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

View File

@@ -25,14 +25,17 @@
#include <pybind11/stl.h> #include <pybind11/stl.h>
#include <pybind11/functional.h> #include <pybind11/functional.h>
#include <Gui/SDK/IMenuProvider.h>
#include <Gui/SDK/IPanelProvider.h> #include <Gui/SDK/IPanelProvider.h>
#include <Gui/SDK/IToolbarProvider.h> #include <Gui/SDK/IToolbarProvider.h>
#include <Gui/SDK/SDKRegistry.h> #include <Gui/SDK/SDKRegistry.h>
#include <Gui/SDK/ThemeEngine.h> #include <Gui/SDK/ThemeEngine.h>
#include <Gui/SDK/Types.h> #include <Gui/SDK/Types.h>
#include "PyIMenuProvider.h"
#include "PyIPanelProvider.h" #include "PyIPanelProvider.h"
#include "PyIToolbarProvider.h" #include "PyIToolbarProvider.h"
#include "PyMenuHolder.h"
#include "PyProviderHolder.h" #include "PyProviderHolder.h"
#include "PyToolbarHolder.h" #include "PyToolbarHolder.h"
@@ -284,6 +287,41 @@ PYBIND11_MODULE(kcsdk, m)
}, },
"Return IDs of all registered toolbar providers."); "Return IDs of all registered toolbar providers.");
// -- Menu provider API --------------------------------------------------
py::class_<IMenuProvider, PyIMenuProvider>(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<PyMenuHolder>(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 --------------------------------------------------- // -- Theme engine API ---------------------------------------------------
m.def("theme_color", m.def("theme_color",