Some checks failed
Build and Test / build (pull_request) Has been cancelled
Replaces the flat context model with a tree-structured hierarchy: - ContextDefinition gains parentId field for declaring parent-child relationships between contexts - Resolver builds a context stack by walking parentId links from leaf to root, verifying each ancestor matches current state - Breadcrumb is now auto-built from the stack — each level contributes its expanded label and color, replacing all hardcoded special cases - EditingContext gains stack field (QStringList, root to leaf) Transition guards (#386): - addTransitionGuard() / removeTransitionGuard() on resolver - Guards run synchronously before applyContext(); first rejection cancels the transition and emits contextTransitionBlocked signal - Full SDK/pybind11/Python bindings Breadcrumb injection (#387): - injectBreadcrumb() / removeBreadcrumbInjection() on resolver - Addons can append segments to any context's breadcrumb display - Active only when the target context is in the current stack - Full SDK/pybind11/Python bindings Built-in parent assignments: - partdesign.body → partdesign.workbench - partdesign.feature → partdesign.body - partdesign.in_assembly → assembly.edit - sketcher.edit → partdesign.body - assembly.idle → assembly.workbench - assembly.edit → assembly.idle - Workbench-level and root contexts have no parent SDK surface: - Types.h: parentId on ContextDef, stack on ContextSnapshot - SDKRegistry: guard/injection delegation, snapshotFromGui helper - kcsdk_py: parent_id param, context_stack(), guard/injection bindings - kindred_sdk: context_stack(), add/remove_transition_guard(), inject/remove_breadcrumb_injection(), parent_id on register_context() Closes #385, closes #386, closes #387
562 lines
21 KiB
C++
562 lines
21 KiB
C++
// 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/>. *
|
|
* *
|
|
***************************************************************************/
|
|
|
|
#include <pybind11/pybind11.h>
|
|
#include <pybind11/stl.h>
|
|
#include <pybind11/functional.h>
|
|
|
|
#include <Gui/SDK/IMenuProvider.h>
|
|
#include <Gui/SDK/IPanelProvider.h>
|
|
#include <Gui/SDK/IToolbarProvider.h>
|
|
#include <Gui/SDK/SDKRegistry.h>
|
|
#include <Gui/SDK/ThemeEngine.h>
|
|
#include <Gui/SDK/Types.h>
|
|
|
|
#include <Gui/FileOrigin.h>
|
|
#include <Gui/OriginManager.h>
|
|
|
|
#include "PyIMenuProvider.h"
|
|
#include "PyIPanelProvider.h"
|
|
#include "PyIToolbarProvider.h"
|
|
#include "PyMenuHolder.h"
|
|
#include "PyProviderHolder.h"
|
|
#include "PyToolbarHolder.h"
|
|
|
|
namespace py = pybind11;
|
|
using namespace KCSDK;
|
|
|
|
// -- Dict conversion helpers ------------------------------------------------
|
|
|
|
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<int>(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<int>(origin->connectionState());
|
|
return d;
|
|
}
|
|
|
|
/// 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;
|
|
d["stack"] = snap.stack;
|
|
return d;
|
|
}
|
|
|
|
/// Wrap a Python callable as a GIL-safe std::function<bool()>.
|
|
/// The py::object is copied (reference counted) so it survives beyond the
|
|
/// calling scope. The GIL is acquired before every invocation.
|
|
std::function<bool()> wrapMatchCallable(py::object pyCallable)
|
|
{
|
|
// Copy the py::object to prevent GC.
|
|
auto held = std::make_shared<py::object>(std::move(pyCallable));
|
|
return [held]() -> bool {
|
|
py::gil_scoped_acquire gil;
|
|
try {
|
|
py::object result = (*held)();
|
|
return result.cast<bool>();
|
|
}
|
|
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";
|
|
m.attr("API_VERSION_MAJOR") = API_VERSION_MAJOR;
|
|
|
|
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<std::string>& toolbars,
|
|
py::object match,
|
|
int priority,
|
|
const std::string& parentId) {
|
|
if (!py::isinstance<py::function>(match) && !py::hasattr(match, "__call__")) {
|
|
throw py::type_error("match must be callable");
|
|
}
|
|
ContextDef def;
|
|
def.id = id;
|
|
def.parentId = parentId;
|
|
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,
|
|
py::arg("parent_id") = "",
|
|
"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).\n"
|
|
"parent_id : str\n Optional parent context ID for hierarchy.");
|
|
|
|
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<std::string>& toolbars,
|
|
py::object match) {
|
|
if (!py::isinstance<py::function>(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<std::string>& 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.");
|
|
|
|
m.def("available_contexts",
|
|
[]() {
|
|
auto contexts = SDKRegistry::instance().registeredContexts();
|
|
py::list result;
|
|
for (const auto& c : contexts) {
|
|
py::dict d;
|
|
d["id"] = c.id;
|
|
d["parent_id"] = c.parentId;
|
|
d["label_template"] = c.labelTemplate;
|
|
d["color"] = c.color;
|
|
d["priority"] = c.priority;
|
|
result.append(d);
|
|
}
|
|
return result;
|
|
},
|
|
"Return metadata for all registered editing contexts.\n\n"
|
|
"Each entry is a dict with keys: id, parent_id, label_template, color, priority.\n"
|
|
"Sorted by descending priority (highest first).");
|
|
|
|
m.def("on_context_changed",
|
|
[](py::function callback) {
|
|
auto held = std::make_shared<py::object>(std::move(callback));
|
|
SDKRegistry::instance().onContextChanged(
|
|
[held](const ContextSnapshot& snap) {
|
|
py::gil_scoped_acquire gil;
|
|
try {
|
|
(*held)(contextSnapshotToDict(snap));
|
|
}
|
|
catch (py::error_already_set& e) {
|
|
e.discard_as_unraisable(__func__);
|
|
}
|
|
});
|
|
},
|
|
py::arg("callback"),
|
|
"Register a callback for context changes.\n\n"
|
|
"The callback receives a context dict with keys:\n"
|
|
"id, label, color, toolbars, breadcrumb, breadcrumbColors, stack.\n"
|
|
"Called synchronously on the Qt main thread whenever the\n"
|
|
"editing context changes.");
|
|
|
|
m.def("context_stack",
|
|
[]() -> py::object {
|
|
ContextSnapshot snap = SDKRegistry::instance().currentContext();
|
|
if (snap.id.empty()) {
|
|
return py::list();
|
|
}
|
|
return py::cast(snap.stack);
|
|
},
|
|
"Return the current context stack (root to leaf) as a list of IDs.");
|
|
|
|
// -- Transition guard API -----------------------------------------------
|
|
|
|
m.def("add_transition_guard",
|
|
[](py::function callback) -> int {
|
|
auto held = std::make_shared<py::object>(std::move(callback));
|
|
SDKRegistry::TransitionGuard guard =
|
|
[held](const ContextSnapshot& from, const ContextSnapshot& to)
|
|
-> std::pair<bool, std::string>
|
|
{
|
|
py::gil_scoped_acquire gil;
|
|
try {
|
|
py::object result = (*held)(
|
|
contextSnapshotToDict(from),
|
|
contextSnapshotToDict(to));
|
|
if (py::isinstance<py::tuple>(result)) {
|
|
auto tup = result.cast<py::tuple>();
|
|
bool allowed = tup[0].cast<bool>();
|
|
std::string reason = tup.size() > 1
|
|
? tup[1].cast<std::string>() : "";
|
|
return {allowed, reason};
|
|
}
|
|
return {result.cast<bool>(), ""};
|
|
}
|
|
catch (py::error_already_set& e) {
|
|
e.discard_as_unraisable(__func__);
|
|
return {true, ""}; // allow on error
|
|
}
|
|
};
|
|
return SDKRegistry::instance().addTransitionGuard(std::move(guard));
|
|
},
|
|
py::arg("callback"),
|
|
"Register a transition guard.\n\n"
|
|
"The callback receives (from_ctx, to_ctx) dicts and must return\n"
|
|
"either a bool or a (bool, reason_str) tuple. Returns a guard ID\n"
|
|
"for later removal.");
|
|
|
|
m.def("remove_transition_guard",
|
|
[](int guardId) {
|
|
SDKRegistry::instance().removeTransitionGuard(guardId);
|
|
},
|
|
py::arg("guard_id"),
|
|
"Remove a previously registered transition guard.");
|
|
|
|
// -- Breadcrumb injection API -------------------------------------------
|
|
|
|
m.def("inject_breadcrumb",
|
|
[](const std::string& contextId,
|
|
const std::vector<std::string>& segments,
|
|
const std::vector<std::string>& colors) {
|
|
SDKRegistry::instance().injectBreadcrumb(contextId, segments, colors);
|
|
},
|
|
py::arg("context_id"),
|
|
py::arg("segments"),
|
|
py::arg("colors") = std::vector<std::string>{},
|
|
"Inject additional breadcrumb segments into a context.\n\n"
|
|
"Segments are appended after the context's own label in the breadcrumb.\n"
|
|
"Active only when the target context is in the current stack.");
|
|
|
|
m.def("remove_breadcrumb_injection",
|
|
[](const std::string& contextId) {
|
|
SDKRegistry::instance().removeBreadcrumbInjection(contextId);
|
|
},
|
|
py::arg("context_id"),
|
|
"Remove a previously injected breadcrumb for a context.");
|
|
|
|
// -- Enums --------------------------------------------------------------
|
|
|
|
py::enum_<DockArea>(m, "DockArea")
|
|
.value("Left", DockArea::Left)
|
|
.value("Right", DockArea::Right)
|
|
.value("Top", DockArea::Top)
|
|
.value("Bottom", DockArea::Bottom);
|
|
|
|
py::enum_<PanelPersistence>(m, "PanelPersistence")
|
|
.value("Session", PanelPersistence::Session)
|
|
.value("Persistent", PanelPersistence::Persistent);
|
|
|
|
// -- Panel provider API -------------------------------------------------
|
|
|
|
py::class_<IPanelProvider, PyIPanelProvider>(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<PyProviderHolder>(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.");
|
|
|
|
// -- Toolbar provider API -----------------------------------------------
|
|
|
|
py::class_<IToolbarProvider, PyIToolbarProvider>(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<PyToolbarHolder>(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.");
|
|
|
|
// -- 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 ---------------------------------------------------
|
|
|
|
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.");
|
|
|
|
// -- Origin query API ---------------------------------------------------
|
|
|
|
m.def("list_origins",
|
|
[]() {
|
|
auto* mgr = Gui::OriginManager::instance();
|
|
return mgr ? mgr->originIds() : std::vector<std::string>{};
|
|
},
|
|
"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.");
|
|
}
|