// 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 #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" 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(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) { 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. /// 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"; 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& toolbars, py::object match, int priority, const std::string& parentId) { if (!py::isinstance(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& 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."); 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(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(std::move(callback)); SDKRegistry::TransitionGuard guard = [held](const ContextSnapshot& from, const ContextSnapshot& to) -> std::pair { py::gil_scoped_acquire gil; try { py::object result = (*held)( contextSnapshotToDict(from), contextSnapshotToDict(to)); if (py::isinstance(result)) { auto tup = result.cast(); bool allowed = tup[0].cast(); std::string reason = tup.size() > 1 ? tup[1].cast() : ""; return {allowed, reason}; } return {result.cast(), ""}; } 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& segments, const std::vector& colors) { SDKRegistry::instance().injectBreadcrumb(contextId, segments, colors); }, py::arg("context_id"), py::arg("segments"), py::arg("colors") = std::vector{}, "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_(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."); // -- 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."); // -- 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", [](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{}; }, "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."); }