feat(sdk): add context lifecycle callbacks (#381) #399

Merged
forbes merged 1 commits from feat/sdk-lifecycle-callbacks into main 2026-03-04 19:34:53 +00:00
6 changed files with 210 additions and 0 deletions

View File

@@ -1,3 +1,6 @@
import FreeCAD
from kindred_sdk.lifecycle import _init_lifecycle
_init_lifecycle()
FreeCAD.Console.PrintLog("kindred-addon-sdk GUI initialized\n")

View File

@@ -13,6 +13,7 @@ from kindred_sdk.context import (
)
from kindred_sdk.dock import register_dock_panel
from kindred_sdk.events import emit, off, on
from kindred_sdk.lifecycle import on_context_enter, on_context_exit
from kindred_sdk.menu import register_menu
from kindred_sdk.origin import (
active_origin,
@@ -45,6 +46,8 @@ __all__ = [
"loaded_addons",
"off",
"on",
"on_context_enter",
"on_context_exit",
"refresh_context",
"register_command",
"register_context",

View File

@@ -0,0 +1,118 @@
# kindred_sdk.lifecycle — context enter/exit callbacks
#
# Provides on_context_enter() and on_context_exit() so addons can react
# to context transitions declaratively. Internally wires up a single
# C++ callback via kcsdk.on_context_changed() and derives enter/exit
# events by tracking the previous context.
#
# Also emits "context.enter" / "context.exit" on the SDK event bus.
import FreeCAD
from kindred_sdk.events import emit as _emit_event
_enter_listeners: dict[str, list] = {}
_exit_listeners: dict[str, list] = {}
_previous_context_id: str = ""
_initialized: bool = False
def on_context_enter(context_id, callback):
"""Subscribe *callback* to fire when *context_id* becomes active.
Use ``"*"`` as a wildcard to receive all enter events.
The callback receives the new context dict (id, label, color, ...).
>>> import kindred_sdk as sdk
>>> sdk.on_context_enter("sketcher.edit", lambda ctx: print("editing sketch"))
"""
if not isinstance(context_id, str):
raise TypeError(f"context_id must be str, got {type(context_id).__name__}")
if not callable(callback):
raise TypeError(f"callback must be callable, got {type(callback).__name__}")
subs = _enter_listeners.setdefault(context_id, [])
if callback not in subs:
subs.append(callback)
def on_context_exit(context_id, callback):
"""Subscribe *callback* to fire when *context_id* is deactivated.
Use ``"*"`` as a wildcard to receive all exit events.
The callback receives the old context dict (id, label, color, ...).
>>> import kindred_sdk as sdk
>>> sdk.on_context_exit("sketcher.edit", lambda ctx: print("left sketch"))
"""
if not isinstance(context_id, str):
raise TypeError(f"context_id must be str, got {type(context_id).__name__}")
if not callable(callback):
raise TypeError(f"callback must be callable, got {type(callback).__name__}")
subs = _exit_listeners.setdefault(context_id, [])
if callback not in subs:
subs.append(callback)
def _fire_callbacks(listeners, context_id, ctx_dict):
"""Dispatch to listeners for a specific id and the wildcard."""
for key in (context_id, "*"):
subs = listeners.get(key)
if not subs:
continue
for cb in list(subs): # snapshot for re-entrancy safety
try:
cb(ctx_dict)
except Exception as e:
FreeCAD.Console.PrintWarning(
f"kindred_sdk: lifecycle callback for '{key}' raised "
f"{type(e).__name__}: {e}\n"
)
def _on_context_changed(new_ctx):
"""Internal callback wired to kcsdk.on_context_changed()."""
global _previous_context_id
new_id = new_ctx.get("id", "")
if new_id == _previous_context_id:
return
old_id = _previous_context_id
_previous_context_id = new_id
# Build a minimal dict for the exited context (we only have the id).
if old_id:
old_ctx = {"id": old_id}
_fire_callbacks(_exit_listeners, old_id, old_ctx)
_emit_event("context.exit", old_ctx)
if new_id:
_fire_callbacks(_enter_listeners, new_id, new_ctx)
_emit_event("context.enter", new_ctx)
def _init_lifecycle():
"""Wire up the C++ context change signal. Called once from InitGui.py."""
global _initialized, _previous_context_id
if _initialized:
return
_initialized = True
try:
import kcsdk
except ImportError:
FreeCAD.Console.PrintWarning(
"kindred_sdk: kcsdk not available, lifecycle callbacks disabled\n"
)
return
# Seed previous context so the first real change fires correctly.
current = kcsdk.current_context()
if current is not None:
_previous_context_id = current.get("id", "")
kcsdk.on_context_changed(_on_context_changed)
FreeCAD.Console.PrintLog("kindred_sdk: lifecycle callbacks initialized\n")

View File

@@ -26,6 +26,7 @@
#include "IPanelProvider.h"
#include "IToolbarProvider.h"
#include <QObject>
#include <QString>
#include <QStringList>
@@ -171,6 +172,57 @@ void SDKRegistry::refresh()
Gui::EditingContextResolver::instance()->refresh();
}
// -- Context change callback API --------------------------------------------
void SDKRegistry::onContextChanged(ContextChangeCallback cb)
{
{
std::lock_guard<std::mutex> lock(mutex_);
contextCallbacks_.push_back(std::move(cb));
}
ensureContextConnection();
}
void SDKRegistry::ensureContextConnection()
{
if (contextCallbacksConnected_) {
return;
}
contextCallbacksConnected_ = true;
auto* resolver = Gui::EditingContextResolver::instance();
QObject::connect(resolver, &Gui::EditingContextResolver::contextChanged,
resolver, [this](const Gui::EditingContext& ctx) {
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);
notifyContextCallbacks(snap);
});
Base::Console().log("KCSDK: connected to context change signal\n");
}
void SDKRegistry::notifyContextCallbacks(const ContextSnapshot& snap)
{
std::vector<ContextChangeCallback> cbs;
{
std::lock_guard<std::mutex> lock(mutex_);
cbs = contextCallbacks_;
}
for (const auto& cb : cbs) {
try {
cb(snap);
}
catch (const std::exception& e) {
Base::Console().warning("KCSDK: context change callback failed: %s\n", e.what());
}
}
}
// -- Panel provider API -----------------------------------------------------
void SDKRegistry::registerPanel(std::unique_ptr<IPanelProvider> provider)

View File

@@ -133,6 +133,15 @@ public:
/// Return IDs of all registered menu providers.
std::vector<std::string> registeredMenus() const;
// -- Context change callback API ---------------------------------------
/// Callback type for context change notifications.
using ContextChangeCallback = std::function<void(const ContextSnapshot&)>;
/// Register a callback invoked whenever the editing context changes.
/// Lazily connects to EditingContextResolver::contextChanged on first call.
void onContextChanged(ContextChangeCallback cb);
private:
SDKRegistry();
@@ -144,12 +153,16 @@ private:
friend class SDKMenuManipulator;
void ensureMenuManipulator();
void ensureContextConnection();
void notifyContextCallbacks(const ContextSnapshot& snap);
mutable std::mutex mutex_;
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<IMenuProvider>> menus_;
std::shared_ptr<Gui::WorkbenchManipulator> menuManipulator_;
std::vector<ContextChangeCallback> contextCallbacks_;
bool contextCallbacksConnected_ = false;
};
} // namespace KCSDK

View File

@@ -209,6 +209,27 @@ PYBIND11_MODULE(kcsdk, m)
},
"Force re-resolution of the editing context.");
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.\n"
"Called synchronously on the Qt main thread whenever the\n"
"editing context changes.");
// -- Enums --------------------------------------------------------------
py::enum_<DockArea>(m, "DockArea")