Files
create/mods/sdk/kindred_sdk/lifecycle.py
forbes 519d5b4436
All checks were successful
Build and Test / build (pull_request) Successful in 25m9s
feat(sdk): add context introspection — available_contexts and context_history (#383)
C++ layer:
- EditingContextResolver::registeredContexts() returns static metadata
  (id, labelTemplate, color, priority) for all registered contexts
- SDKRegistry::registeredContexts() wraps with Qt-to-std conversion
- kcsdk.available_contexts() pybind11 binding returns list of dicts

Python layer:
- kindred_sdk.available_contexts() wraps kcsdk binding
- kindred_sdk.context_history(limit=10) returns recent transitions
  from a 50-entry ring buffer tracked in lifecycle.py, with timestamps

Closes #383
2026-03-04 13:42:34 -06:00

153 lines
4.7 KiB
Python

# 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 time
from collections import deque
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
_history: deque = deque(maxlen=50)
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 context_history(limit=10):
"""Return recent context transitions, newest first.
Each entry is a dict with keys:
- ``id`` — the context that was entered
- ``timestamp`` — ``time.time()`` when the transition occurred
- ``previous_id`` — the context that was exited (empty string if none)
Parameters
----------
limit : int, optional
Maximum entries to return. Default 10. Pass 0 for all (up to 50).
"""
entries = list(_history)
entries.reverse()
if limit:
entries = entries[:limit]
return entries
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)
# Record transition in history ring buffer.
_history.append(
{
"id": new_id,
"timestamp": time.time(),
"previous_id": old_id,
}
)
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")