feat(sdk): add context lifecycle callbacks (#381) #399
@@ -1,3 +1,6 @@
|
||||
import FreeCAD
|
||||
from kindred_sdk.lifecycle import _init_lifecycle
|
||||
|
||||
_init_lifecycle()
|
||||
|
||||
FreeCAD.Console.PrintLog("kindred-addon-sdk GUI initialized\n")
|
||||
|
||||
@@ -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",
|
||||
|
||||
118
mods/sdk/kindred_sdk/lifecycle.py
Normal file
118
mods/sdk/kindred_sdk/lifecycle.py
Normal 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")
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user