Files
create/mods/sdk/kindred_sdk/context.py
forbes 74b0073327
Some checks failed
Build and Test / build (pull_request) Has been cancelled
feat(editing-context): hierarchical context system with stack, guards, and breadcrumb injection (#385, #386, #387)
Replaces the flat context model with a tree-structured hierarchy:

- ContextDefinition gains parentId field for declaring parent-child
  relationships between contexts
- Resolver builds a context stack by walking parentId links from
  leaf to root, verifying each ancestor matches current state
- Breadcrumb is now auto-built from the stack — each level
  contributes its expanded label and color, replacing all hardcoded
  special cases
- EditingContext gains stack field (QStringList, root to leaf)

Transition guards (#386):
- addTransitionGuard() / removeTransitionGuard() on resolver
- Guards run synchronously before applyContext(); first rejection
  cancels the transition and emits contextTransitionBlocked signal
- Full SDK/pybind11/Python bindings

Breadcrumb injection (#387):
- injectBreadcrumb() / removeBreadcrumbInjection() on resolver
- Addons can append segments to any context's breadcrumb display
- Active only when the target context is in the current stack
- Full SDK/pybind11/Python bindings

Built-in parent assignments:
- partdesign.body → partdesign.workbench
- partdesign.feature → partdesign.body
- partdesign.in_assembly → assembly.edit
- sketcher.edit → partdesign.body
- assembly.idle → assembly.workbench
- assembly.edit → assembly.idle
- Workbench-level and root contexts have no parent

SDK surface:
- Types.h: parentId on ContextDef, stack on ContextSnapshot
- SDKRegistry: guard/injection delegation, snapshotFromGui helper
- kcsdk_py: parent_id param, context_stack(), guard/injection bindings
- kindred_sdk: context_stack(), add/remove_transition_guard(),
  inject/remove_breadcrumb_injection(), parent_id on register_context()

Closes #385, closes #386, closes #387
2026-03-04 14:23:45 -06:00

261 lines
8.0 KiB
Python

"""Editing context and overlay registration wrappers.
Routes through the ``kcsdk`` C++ module. The legacy ``FreeCADGui``
Python bindings are deprecated — kcsdk is required.
"""
import FreeCAD
try:
import kcsdk as _kcsdk
except ImportError:
_kcsdk = None
def _require_kcsdk():
if _kcsdk is None:
raise RuntimeError(
"kcsdk module not available. "
"The kindred_sdk requires the kcsdk C++ module (libKCSDK)."
)
def register_context(
context_id, label, color, toolbars, match, priority=50, parent_id=""
):
"""Register an editing context.
Parameters
----------
context_id : str
Unique identifier (e.g. ``"myaddon.edit"``).
label : str
Display label template. Supports ``{name}`` placeholder.
color : str
Hex color for the breadcrumb (e.g. ``"#f38ba8"``).
toolbars : list[str]
Toolbar names to show when this context is active.
match : callable
Zero-argument callable returning *True* when this context is active.
priority : int, optional
Higher values are checked first. Default 50.
parent_id : str, optional
Parent context ID for hierarchy. Empty string for root-level.
"""
if not isinstance(context_id, str):
raise TypeError(f"context_id must be str, got {type(context_id).__name__}")
if not isinstance(toolbars, list):
raise TypeError(f"toolbars must be list, got {type(toolbars).__name__}")
if not callable(match):
raise TypeError("match must be callable")
_require_kcsdk()
try:
_kcsdk.register_context(
context_id, label, color, toolbars, match, priority, parent_id=parent_id
)
except Exception as e:
FreeCAD.Console.PrintWarning(
f"kindred_sdk: Failed to register context '{context_id}': {e}\n"
)
def unregister_context(context_id):
"""Remove a previously registered editing context."""
if not isinstance(context_id, str):
raise TypeError(f"context_id must be str, got {type(context_id).__name__}")
_require_kcsdk()
try:
_kcsdk.unregister_context(context_id)
except Exception as e:
FreeCAD.Console.PrintWarning(
f"kindred_sdk: Failed to unregister context '{context_id}': {e}\n"
)
def register_overlay(overlay_id, toolbars, match):
"""Register an editing overlay.
Overlays add toolbars to whatever context is currently active when
*match* returns True.
Parameters
----------
overlay_id : str
Unique overlay identifier.
toolbars : list[str]
Toolbar names to append.
match : callable
Zero-argument callable returning *True* when the overlay applies.
"""
if not isinstance(overlay_id, str):
raise TypeError(f"overlay_id must be str, got {type(overlay_id).__name__}")
if not isinstance(toolbars, list):
raise TypeError(f"toolbars must be list, got {type(toolbars).__name__}")
if not callable(match):
raise TypeError("match must be callable")
_require_kcsdk()
try:
_kcsdk.register_overlay(overlay_id, toolbars, match)
except Exception as e:
FreeCAD.Console.PrintWarning(
f"kindred_sdk: Failed to register overlay '{overlay_id}': {e}\n"
)
def unregister_overlay(overlay_id):
"""Remove a previously registered editing overlay."""
if not isinstance(overlay_id, str):
raise TypeError(f"overlay_id must be str, got {type(overlay_id).__name__}")
_require_kcsdk()
try:
_kcsdk.unregister_overlay(overlay_id)
except Exception as e:
FreeCAD.Console.PrintWarning(
f"kindred_sdk: Failed to unregister overlay '{overlay_id}': {e}\n"
)
def inject_commands(context_id, toolbar_name, commands):
"""Inject commands into a context's toolbar.
Parameters
----------
context_id : str
Target context identifier.
toolbar_name : str
Toolbar within that context.
commands : list[str]
Command names to add.
"""
if not isinstance(context_id, str):
raise TypeError(f"context_id must be str, got {type(context_id).__name__}")
if not isinstance(toolbar_name, str):
raise TypeError(f"toolbar_name must be str, got {type(toolbar_name).__name__}")
if not isinstance(commands, list):
raise TypeError(f"commands must be list, got {type(commands).__name__}")
_require_kcsdk()
try:
_kcsdk.inject_commands(context_id, toolbar_name, commands)
except Exception as e:
FreeCAD.Console.PrintWarning(
f"kindred_sdk: Failed to inject commands into '{context_id}': {e}\n"
)
def current_context():
"""Return the current editing context as a dict.
Keys: ``id``, ``label``, ``color``, ``toolbars``, ``breadcrumb``,
``breadcrumbColors``, ``stack``. Returns ``None`` if no context is active.
"""
_require_kcsdk()
try:
return _kcsdk.current_context()
except Exception as e:
FreeCAD.Console.PrintWarning(
f"kindred_sdk: Failed to get current context: {e}\n"
)
return None
def available_contexts():
"""Return metadata for all registered editing contexts.
Returns a list of dicts with keys: ``id``, ``parent_id``,
``label_template``, ``color``, ``priority``. Sorted by descending
priority (highest first).
Returns an empty list on failure.
"""
_require_kcsdk()
try:
return _kcsdk.available_contexts()
except Exception as e:
FreeCAD.Console.PrintWarning(
f"kindred_sdk: Failed to get available contexts: {e}\n"
)
return []
def context_stack():
"""Return the current context stack as a list of context IDs.
The list is ordered root-to-leaf (e.g.
``["partdesign.workbench", "partdesign.body", "sketcher.edit"]``).
Returns an empty list if no context is active.
"""
_require_kcsdk()
try:
return _kcsdk.context_stack()
except Exception as e:
FreeCAD.Console.PrintWarning(f"kindred_sdk: Failed to get context stack: {e}\n")
return []
def add_transition_guard(callback):
"""Register a transition guard.
The *callback* receives ``(from_ctx, to_ctx)`` dicts and must return
either a ``bool`` or a ``(bool, reason_str)`` tuple.
Returns an integer guard ID for later removal via
:func:`remove_transition_guard`.
"""
if not callable(callback):
raise TypeError("callback must be callable")
_require_kcsdk()
return _kcsdk.add_transition_guard(callback)
def remove_transition_guard(guard_id):
"""Remove a previously registered transition guard by ID."""
_require_kcsdk()
_kcsdk.remove_transition_guard(guard_id)
def inject_breadcrumb(context_id, segments, colors=None):
"""Inject additional breadcrumb segments into a context.
Segments are appended after the context's own label when it appears
in the current stack.
Parameters
----------
context_id : str
Target context identifier.
segments : list[str]
Additional breadcrumb segment labels.
colors : list[str], optional
Per-segment hex colors. Defaults to the context's own color.
"""
if not isinstance(context_id, str):
raise TypeError(f"context_id must be str, got {type(context_id).__name__}")
if not isinstance(segments, list):
raise TypeError(f"segments must be list, got {type(segments).__name__}")
_require_kcsdk()
_kcsdk.inject_breadcrumb(context_id, segments, colors or [])
def remove_breadcrumb_injection(context_id):
"""Remove a previously injected breadcrumb for a context."""
if not isinstance(context_id, str):
raise TypeError(f"context_id must be str, got {type(context_id).__name__}")
_require_kcsdk()
_kcsdk.remove_breadcrumb_injection(context_id)
def refresh_context():
"""Force re-resolution and update of the editing context."""
_require_kcsdk()
try:
_kcsdk.refresh()
except Exception as e:
FreeCAD.Console.PrintWarning(f"kindred_sdk: Failed to refresh context: {e}\n")