diff --git a/mods/sdk/InitGui.py b/mods/sdk/InitGui.py index 67b994c53e..799cebc7ca 100644 --- a/mods/sdk/InitGui.py +++ b/mods/sdk/InitGui.py @@ -1,3 +1,6 @@ import FreeCAD +from kindred_sdk.lifecycle import _init_lifecycle + +_init_lifecycle() FreeCAD.Console.PrintLog("kindred-addon-sdk GUI initialized\n") diff --git a/mods/sdk/kindred_sdk/__init__.py b/mods/sdk/kindred_sdk/__init__.py index 2d2310c7b4..0026ff1705 100644 --- a/mods/sdk/kindred_sdk/__init__.py +++ b/mods/sdk/kindred_sdk/__init__.py @@ -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", diff --git a/mods/sdk/kindred_sdk/lifecycle.py b/mods/sdk/kindred_sdk/lifecycle.py new file mode 100644 index 0000000000..2d2383a808 --- /dev/null +++ b/mods/sdk/kindred_sdk/lifecycle.py @@ -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") diff --git a/src/Gui/SDK/SDKRegistry.cpp b/src/Gui/SDK/SDKRegistry.cpp index 456ff09bf5..a898034def 100644 --- a/src/Gui/SDK/SDKRegistry.cpp +++ b/src/Gui/SDK/SDKRegistry.cpp @@ -26,6 +26,7 @@ #include "IPanelProvider.h" #include "IToolbarProvider.h" +#include #include #include @@ -171,6 +172,57 @@ void SDKRegistry::refresh() Gui::EditingContextResolver::instance()->refresh(); } +// -- Context change callback API -------------------------------------------- + +void SDKRegistry::onContextChanged(ContextChangeCallback cb) +{ + { + std::lock_guard 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 cbs; + { + std::lock_guard 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 provider) diff --git a/src/Gui/SDK/SDKRegistry.h b/src/Gui/SDK/SDKRegistry.h index fc5d20d408..2a9a1cde4a 100644 --- a/src/Gui/SDK/SDKRegistry.h +++ b/src/Gui/SDK/SDKRegistry.h @@ -133,6 +133,15 @@ public: /// Return IDs of all registered menu providers. std::vector registeredMenus() const; + // -- Context change callback API --------------------------------------- + + /// Callback type for context change notifications. + using ContextChangeCallback = std::function; + + /// 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> panels_; std::unordered_map> toolbars_; std::unordered_map> menus_; std::shared_ptr menuManipulator_; + std::vector contextCallbacks_; + bool contextCallbacksConnected_ = false; }; } // namespace KCSDK diff --git a/src/Gui/SDK/bindings/kcsdk_py.cpp b/src/Gui/SDK/bindings/kcsdk_py.cpp index c4de50f23c..f36da35562 100644 --- a/src/Gui/SDK/bindings/kcsdk_py.cpp +++ b/src/Gui/SDK/bindings/kcsdk_py.cpp @@ -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(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_(m, "DockArea")