feat(editing-context): hierarchical context system with stack, guards, and breadcrumb injection (#385, #386, #387)
Some checks failed
Build and Test / build (pull_request) Has been cancelled
Some checks failed
Build and Test / build (pull_request) Has been cancelled
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
This commit is contained in:
@@ -3,12 +3,17 @@
|
||||
from kindred_sdk.command import register_command
|
||||
from kindred_sdk.compat import create_version, freecad_version
|
||||
from kindred_sdk.context import (
|
||||
add_transition_guard,
|
||||
available_contexts,
|
||||
context_stack,
|
||||
current_context,
|
||||
inject_breadcrumb,
|
||||
inject_commands,
|
||||
refresh_context,
|
||||
register_context,
|
||||
register_overlay,
|
||||
remove_breadcrumb_injection,
|
||||
remove_transition_guard,
|
||||
unregister_context,
|
||||
unregister_overlay,
|
||||
)
|
||||
@@ -39,17 +44,20 @@ from kindred_sdk.version import SDK_VERSION
|
||||
__all__ = [
|
||||
"SDK_VERSION",
|
||||
"active_origin",
|
||||
"add_transition_guard",
|
||||
"addon_diagnostics",
|
||||
"addon_resource",
|
||||
"addon_version",
|
||||
"available_contexts",
|
||||
"context_history",
|
||||
"context_stack",
|
||||
"create_version",
|
||||
"current_context",
|
||||
"emit",
|
||||
"freecad_version",
|
||||
"get_origin",
|
||||
"get_theme_tokens",
|
||||
"inject_breadcrumb",
|
||||
"inject_commands",
|
||||
"is_addon_loaded",
|
||||
"list_origins",
|
||||
@@ -65,11 +73,13 @@ __all__ = [
|
||||
"register_dock_panel",
|
||||
"register_menu",
|
||||
"register_origin",
|
||||
"register_overlay",
|
||||
"register_status_widget",
|
||||
"register_toolbar",
|
||||
"remove_breadcrumb_injection",
|
||||
"remove_transition_guard",
|
||||
"set_active_origin",
|
||||
"unregister_context",
|
||||
"unregister_origin",
|
||||
"unregister_overlay",
|
||||
"register_overlay",
|
||||
]
|
||||
|
||||
@@ -20,7 +20,9 @@ def _require_kcsdk():
|
||||
)
|
||||
|
||||
|
||||
def register_context(context_id, label, color, toolbars, match, priority=50):
|
||||
def register_context(
|
||||
context_id, label, color, toolbars, match, priority=50, parent_id=""
|
||||
):
|
||||
"""Register an editing context.
|
||||
|
||||
Parameters
|
||||
@@ -37,6 +39,8 @@ def register_context(context_id, label, color, toolbars, match, priority=50):
|
||||
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__}")
|
||||
@@ -47,7 +51,9 @@ def register_context(context_id, label, color, toolbars, match, priority=50):
|
||||
|
||||
_require_kcsdk()
|
||||
try:
|
||||
_kcsdk.register_context(context_id, label, color, toolbars, match, priority)
|
||||
_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"
|
||||
@@ -145,7 +151,7 @@ def current_context():
|
||||
"""Return the current editing context as a dict.
|
||||
|
||||
Keys: ``id``, ``label``, ``color``, ``toolbars``, ``breadcrumb``,
|
||||
``breadcrumbColors``. Returns ``None`` if no context is active.
|
||||
``breadcrumbColors``, ``stack``. Returns ``None`` if no context is active.
|
||||
"""
|
||||
_require_kcsdk()
|
||||
try:
|
||||
@@ -160,8 +166,9 @@ def current_context():
|
||||
def available_contexts():
|
||||
"""Return metadata for all registered editing contexts.
|
||||
|
||||
Returns a list of dicts with keys: ``id``, ``label_template``,
|
||||
``color``, ``priority``. Sorted by descending priority (highest first).
|
||||
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()
|
||||
@@ -174,6 +181,76 @@ def available_contexts():
|
||||
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()
|
||||
|
||||
@@ -25,9 +25,11 @@
|
||||
#include "EditingContext.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <utility>
|
||||
|
||||
#include <App/Document.h>
|
||||
#include <App/DocumentObject.h>
|
||||
#include <Base/Console.h>
|
||||
#include <Base/Type.h>
|
||||
|
||||
#include "Application.h"
|
||||
@@ -70,6 +72,13 @@ struct EditingContextResolver::Private
|
||||
// Key: contextId -> toolbarName -> list of command names
|
||||
QMap<QString, QMap<QString, QStringList>> injections;
|
||||
|
||||
// Transition guards: id -> guard callback
|
||||
QMap<int, EditingContextResolver::TransitionGuard> guards;
|
||||
int nextGuardId = 1;
|
||||
|
||||
// Breadcrumb injections: contextId -> (segments, colors)
|
||||
QMap<QString, QPair<QStringList, QStringList>> breadcrumbInjections;
|
||||
|
||||
void sortContexts()
|
||||
{
|
||||
std::sort(
|
||||
@@ -80,6 +89,17 @@ struct EditingContextResolver::Private
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// Find a context definition by ID, or nullptr if not found.
|
||||
const ContextDefinition* findContext(const QString& id) const
|
||||
{
|
||||
for (const auto& def : contexts) {
|
||||
if (def.id == id) {
|
||||
return &def;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -240,6 +260,7 @@ void EditingContextResolver::registerBuiltinContexts()
|
||||
// --- PartDesign body active inside an assembly (supersedes assembly.edit) ---
|
||||
registerContext({
|
||||
/*.id =*/QStringLiteral("partdesign.in_assembly"),
|
||||
/*.parentId =*/QStringLiteral("assembly.edit"),
|
||||
/*.labelTemplate =*/QStringLiteral("Body: {name}"),
|
||||
/*.color =*/QLatin1String(CatppuccinMocha::Mauve),
|
||||
/*.toolbars =*/
|
||||
@@ -255,7 +276,6 @@ void EditingContextResolver::registerBuiltinContexts()
|
||||
if (!body || !objectIsDerivedFrom(body, "PartDesign::Body")) {
|
||||
return false;
|
||||
}
|
||||
// Only match when we're inside an assembly edit session
|
||||
auto* doc = Application::Instance->activeDocument();
|
||||
if (!doc) {
|
||||
return false;
|
||||
@@ -268,6 +288,7 @@ void EditingContextResolver::registerBuiltinContexts()
|
||||
// --- Sketcher edit (highest priority — VP in edit) ---
|
||||
registerContext({
|
||||
/*.id =*/QStringLiteral("sketcher.edit"),
|
||||
/*.parentId =*/QStringLiteral("partdesign.body"),
|
||||
/*.labelTemplate =*/QStringLiteral("Sketcher: {name}"),
|
||||
/*.color =*/QLatin1String(CatppuccinMocha::Green),
|
||||
/*.toolbars =*/
|
||||
@@ -292,6 +313,7 @@ void EditingContextResolver::registerBuiltinContexts()
|
||||
// --- Assembly edit (VP in edit) ---
|
||||
registerContext({
|
||||
/*.id =*/QStringLiteral("assembly.edit"),
|
||||
/*.parentId =*/QStringLiteral("assembly.idle"),
|
||||
/*.labelTemplate =*/QStringLiteral("Assembly: {name}"),
|
||||
/*.color =*/QLatin1String(CatppuccinMocha::Blue),
|
||||
/*.toolbars =*/
|
||||
@@ -313,7 +335,8 @@ void EditingContextResolver::registerBuiltinContexts()
|
||||
// --- PartDesign with features (active body has children) ---
|
||||
registerContext({
|
||||
/*.id =*/QStringLiteral("partdesign.feature"),
|
||||
/*.labelTemplate =*/QStringLiteral("Body: {name}"),
|
||||
/*.parentId =*/QStringLiteral("partdesign.body"),
|
||||
/*.labelTemplate =*/QStringLiteral("{name}"),
|
||||
/*.color =*/QLatin1String(CatppuccinMocha::Mauve),
|
||||
/*.toolbars =*/
|
||||
{QStringLiteral("Part Design Helper Features"),
|
||||
@@ -331,7 +354,6 @@ void EditingContextResolver::registerBuiltinContexts()
|
||||
if (!obj || !objectIsDerivedFrom(obj, "PartDesign::Body")) {
|
||||
return false;
|
||||
}
|
||||
// Body has at least one child beyond the origin
|
||||
auto children = obj->getOutList();
|
||||
for (auto* child : children) {
|
||||
if (child && !objectIsDerivedFrom(child, "App::Origin")) {
|
||||
@@ -345,6 +367,7 @@ void EditingContextResolver::registerBuiltinContexts()
|
||||
// --- PartDesign body (active body, empty or origin-only) ---
|
||||
registerContext({
|
||||
/*.id =*/QStringLiteral("partdesign.body"),
|
||||
/*.parentId =*/QStringLiteral("partdesign.workbench"),
|
||||
/*.labelTemplate =*/QStringLiteral("Body: {name}"),
|
||||
/*.color =*/QLatin1String(CatppuccinMocha::Mauve),
|
||||
/*.toolbars =*/
|
||||
@@ -367,6 +390,7 @@ void EditingContextResolver::registerBuiltinContexts()
|
||||
// --- Assembly idle (assembly exists, active, but not in edit) ---
|
||||
registerContext({
|
||||
/*.id =*/QStringLiteral("assembly.idle"),
|
||||
/*.parentId =*/QStringLiteral("assembly.workbench"),
|
||||
/*.labelTemplate =*/QStringLiteral("Assembly: {name}"),
|
||||
/*.color =*/QLatin1String(CatppuccinMocha::Blue),
|
||||
/*.toolbars =*/
|
||||
@@ -384,6 +408,7 @@ void EditingContextResolver::registerBuiltinContexts()
|
||||
// --- Spreadsheet ---
|
||||
registerContext({
|
||||
/*.id =*/QStringLiteral("spreadsheet"),
|
||||
/*.parentId =*/{},
|
||||
/*.labelTemplate =*/QStringLiteral("Spreadsheet: {name}"),
|
||||
/*.color =*/QLatin1String(CatppuccinMocha::Yellow),
|
||||
/*.toolbars =*/
|
||||
@@ -396,12 +421,12 @@ void EditingContextResolver::registerBuiltinContexts()
|
||||
},
|
||||
});
|
||||
|
||||
// --- Workbench-level fallbacks (priority 20) ---
|
||||
// Show basic workbench toolbars when the workbench is active but no
|
||||
// specific editing context matches (e.g. no Body selected in PartDesign).
|
||||
// --- Workbench-level contexts (priority 20) ---
|
||||
// These represent the active workbench tier — what tools are available.
|
||||
|
||||
registerContext({
|
||||
/*.id =*/QStringLiteral("partdesign.workbench"),
|
||||
/*.parentId =*/{},
|
||||
/*.labelTemplate =*/QStringLiteral("Part Design"),
|
||||
/*.color =*/QLatin1String(CatppuccinMocha::Mauve),
|
||||
/*.toolbars =*/
|
||||
@@ -419,6 +444,7 @@ void EditingContextResolver::registerBuiltinContexts()
|
||||
|
||||
registerContext({
|
||||
/*.id =*/QStringLiteral("sketcher.workbench"),
|
||||
/*.parentId =*/{},
|
||||
/*.labelTemplate =*/QStringLiteral("Sketcher"),
|
||||
/*.color =*/QLatin1String(CatppuccinMocha::Green),
|
||||
/*.toolbars =*/
|
||||
@@ -437,6 +463,7 @@ void EditingContextResolver::registerBuiltinContexts()
|
||||
|
||||
registerContext({
|
||||
/*.id =*/QStringLiteral("assembly.workbench"),
|
||||
/*.parentId =*/{},
|
||||
/*.labelTemplate =*/QStringLiteral("Assembly"),
|
||||
/*.color =*/QLatin1String(CatppuccinMocha::Blue),
|
||||
/*.toolbars =*/
|
||||
@@ -453,6 +480,7 @@ void EditingContextResolver::registerBuiltinContexts()
|
||||
// --- Empty document ---
|
||||
registerContext({
|
||||
/*.id =*/QStringLiteral("empty_document"),
|
||||
/*.parentId =*/{},
|
||||
/*.labelTemplate =*/QStringLiteral("New Document"),
|
||||
/*.color =*/QLatin1String(CatppuccinMocha::Surface1),
|
||||
/*.toolbars =*/
|
||||
@@ -465,6 +493,7 @@ void EditingContextResolver::registerBuiltinContexts()
|
||||
// --- No document ---
|
||||
registerContext({
|
||||
/*.id =*/QStringLiteral("no_document"),
|
||||
/*.parentId =*/{},
|
||||
/*.labelTemplate =*/QStringLiteral("Kindred Create"),
|
||||
/*.color =*/QLatin1String(CatppuccinMocha::Surface0),
|
||||
/*.toolbars =*/ {},
|
||||
@@ -534,39 +563,29 @@ EditingContext EditingContextResolver::resolve() const
|
||||
{
|
||||
EditingContext ctx;
|
||||
|
||||
// Find the first matching primary context
|
||||
// 1. Find the leaf: first matching context by descending priority (unchanged)
|
||||
const ContextDefinition* leaf = nullptr;
|
||||
for (const auto& def : d->contexts) {
|
||||
if (def.match && def.match()) {
|
||||
ctx.id = def.id;
|
||||
ctx.color = def.color;
|
||||
ctx.toolbars = def.toolbars;
|
||||
|
||||
// Expand label template
|
||||
QString label = def.labelTemplate;
|
||||
if (label.contains(QStringLiteral("{name}"))) {
|
||||
// For edit-mode contexts, use the in-edit object name
|
||||
QString name = getInEditLabel();
|
||||
if (name.isEmpty()) {
|
||||
// Try pdbody first for PartDesign contexts
|
||||
auto* bodyObj = getActivePdBodyObject();
|
||||
if (bodyObj) {
|
||||
name = QString::fromUtf8(bodyObj->Label.getValue());
|
||||
}
|
||||
}
|
||||
if (name.isEmpty()) {
|
||||
name = getActivePartLabel();
|
||||
}
|
||||
if (name.isEmpty()) {
|
||||
name = QStringLiteral("?");
|
||||
}
|
||||
label.replace(QStringLiteral("{name}"), name);
|
||||
}
|
||||
ctx.label = label;
|
||||
leaf = &def;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Append overlay toolbars
|
||||
if (!leaf) {
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// Populate from the leaf context
|
||||
ctx.id = leaf->id;
|
||||
ctx.color = leaf->color;
|
||||
ctx.toolbars = leaf->toolbars;
|
||||
ctx.label = expandLabel(*leaf);
|
||||
|
||||
// 2. Build the context stack (root to leaf) via parentId walk
|
||||
buildStack(ctx, *leaf);
|
||||
|
||||
// 3. Append overlay toolbars
|
||||
for (const auto& overlay : d->overlays) {
|
||||
if (overlay.match && overlay.match()) {
|
||||
for (const auto& tb : overlay.toolbars) {
|
||||
@@ -577,7 +596,7 @@ EditingContext EditingContextResolver::resolve() const
|
||||
}
|
||||
}
|
||||
|
||||
// Append any injected toolbar names that aren't already in the context
|
||||
// 4. Append any injected toolbar names that aren't already in the context
|
||||
auto injIt = d->injections.find(ctx.id);
|
||||
if (injIt != d->injections.end()) {
|
||||
for (auto it = injIt->begin(); it != injIt->end(); ++it) {
|
||||
@@ -587,9 +606,8 @@ EditingContext EditingContextResolver::resolve() const
|
||||
}
|
||||
}
|
||||
|
||||
// Build breadcrumb
|
||||
ctx.breadcrumb = buildBreadcrumb(ctx);
|
||||
ctx.breadcrumbColors = buildBreadcrumbColors(ctx);
|
||||
// 5. Build breadcrumb from the stack
|
||||
buildBreadcrumbFromStack(ctx);
|
||||
|
||||
return ctx;
|
||||
}
|
||||
@@ -604,115 +622,112 @@ QList<EditingContextResolver::ContextInfo> EditingContextResolver::registeredCon
|
||||
QList<ContextInfo> result;
|
||||
result.reserve(d->contexts.size());
|
||||
for (const auto& def : d->contexts) {
|
||||
result.append({def.id, def.labelTemplate, def.color, def.priority});
|
||||
result.append({def.id, def.parentId, def.labelTemplate, def.color, def.priority});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Breadcrumb building
|
||||
// Stack and breadcrumb building
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
QStringList EditingContextResolver::buildBreadcrumb(const EditingContext& ctx) const
|
||||
QString EditingContextResolver::expandLabel(const ContextDefinition& def) const
|
||||
{
|
||||
QStringList crumbs;
|
||||
|
||||
if (ctx.id == QStringLiteral("no_document") || ctx.id == QStringLiteral("empty_document")) {
|
||||
crumbs << ctx.label;
|
||||
return crumbs;
|
||||
QString label = def.labelTemplate;
|
||||
if (!label.contains(QStringLiteral("{name}"))) {
|
||||
return label;
|
||||
}
|
||||
|
||||
// Assembly > Body breadcrumb for in-assembly part editing
|
||||
if (ctx.id == QStringLiteral("partdesign.in_assembly")) {
|
||||
auto* guiDoc = Application::Instance->activeDocument();
|
||||
if (guiDoc) {
|
||||
auto* vp = guiDoc->getInEdit();
|
||||
if (vp) {
|
||||
auto* vpd = dynamic_cast<ViewProviderDocumentObject*>(vp);
|
||||
if (vpd && vpd->getObject()) {
|
||||
crumbs << QString::fromUtf8(vpd->getObject()->Label.getValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
auto* body = getActivePdBodyObject();
|
||||
if (body) {
|
||||
crumbs << QString::fromUtf8(body->Label.getValue());
|
||||
}
|
||||
return crumbs;
|
||||
}
|
||||
|
||||
// Always start with the active part/body/assembly label
|
||||
QString partLabel = getActivePartLabel();
|
||||
if (!partLabel.isEmpty()) {
|
||||
crumbs << partLabel;
|
||||
}
|
||||
|
||||
// If in edit mode, add the edited object
|
||||
auto* guiDoc = Application::Instance->activeDocument();
|
||||
if (guiDoc) {
|
||||
auto* vp = guiDoc->getInEdit();
|
||||
if (vp) {
|
||||
auto* vpd = dynamic_cast<ViewProviderDocumentObject*>(vp);
|
||||
if (vpd && vpd->getObject()) {
|
||||
QString editLabel = QString::fromUtf8(vpd->getObject()->Label.getValue());
|
||||
// Don't duplicate if the part label IS the edited object
|
||||
if (editLabel != partLabel) {
|
||||
crumbs << editLabel;
|
||||
}
|
||||
crumbs << QStringLiteral("[editing]");
|
||||
}
|
||||
// For edit-mode contexts, use the in-edit object name
|
||||
QString name = getInEditLabel();
|
||||
if (name.isEmpty()) {
|
||||
auto* bodyObj = getActivePdBodyObject();
|
||||
if (bodyObj) {
|
||||
name = QString::fromUtf8(bodyObj->Label.getValue());
|
||||
}
|
||||
}
|
||||
|
||||
if (crumbs.isEmpty()) {
|
||||
crumbs << ctx.label;
|
||||
if (name.isEmpty()) {
|
||||
name = getActivePartLabel();
|
||||
}
|
||||
|
||||
return crumbs;
|
||||
if (name.isEmpty()) {
|
||||
name = QStringLiteral("?");
|
||||
}
|
||||
label.replace(QStringLiteral("{name}"), name);
|
||||
return label;
|
||||
}
|
||||
|
||||
QStringList EditingContextResolver::buildBreadcrumbColors(const EditingContext& ctx) const
|
||||
void EditingContextResolver::buildStack(EditingContext& ctx,
|
||||
const ContextDefinition& leaf) const
|
||||
{
|
||||
QStringList colors;
|
||||
// Walk from leaf up through parentId chain, verifying each ancestor matches
|
||||
QStringList reverseStack;
|
||||
reverseStack.append(leaf.id);
|
||||
|
||||
if (ctx.id == QStringLiteral("partdesign.in_assembly")) {
|
||||
for (int i = 0; i < ctx.breadcrumb.size(); ++i) {
|
||||
colors << (i == 0 ? QLatin1String(CatppuccinMocha::Blue)
|
||||
: QLatin1String(CatppuccinMocha::Mauve));
|
||||
QString currentParent = leaf.parentId;
|
||||
int maxDepth = 20; // safety limit against cycles
|
||||
while (!currentParent.isEmpty() && maxDepth-- > 0) {
|
||||
const auto* parentDef = d->findContext(currentParent);
|
||||
if (!parentDef) {
|
||||
Base::Console().warning(
|
||||
"EditingContext: parent '%s' not found for context '%s'\n",
|
||||
currentParent.toUtf8().constData(),
|
||||
reverseStack.last().toUtf8().constData());
|
||||
break;
|
||||
}
|
||||
return colors;
|
||||
}
|
||||
|
||||
if (ctx.breadcrumb.size() <= 1) {
|
||||
colors << ctx.color;
|
||||
return colors;
|
||||
}
|
||||
|
||||
// For multi-segment breadcrumbs:
|
||||
// - First segments (parent) use the parent context color
|
||||
// - Last segments (active edit) use the current context color
|
||||
// e.g., Body (mauve) > Sketch001 (green) > [editing] (green)
|
||||
|
||||
// Determine parent color
|
||||
QString parentColor = QLatin1String(CatppuccinMocha::Mauve); // default for Body
|
||||
auto* partObj = getActivePartObject();
|
||||
if (partObj) {
|
||||
if (objectIsDerivedFrom(partObj, "Assembly::AssemblyObject")) {
|
||||
parentColor = QLatin1String(CatppuccinMocha::Blue);
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < ctx.breadcrumb.size(); ++i) {
|
||||
if (i == 0 && ctx.breadcrumb.size() > 1) {
|
||||
colors << parentColor;
|
||||
// Only include parent if it matches current state
|
||||
if (parentDef->match && parentDef->match()) {
|
||||
reverseStack.append(parentDef->id);
|
||||
currentParent = parentDef->parentId;
|
||||
}
|
||||
else {
|
||||
colors << ctx.color;
|
||||
// Parent doesn't match — stop climbing (partial stack)
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return colors;
|
||||
// Reverse to get root-to-leaf order
|
||||
ctx.stack.reserve(reverseStack.size());
|
||||
for (int i = reverseStack.size() - 1; i >= 0; --i) {
|
||||
ctx.stack.append(reverseStack[i]);
|
||||
}
|
||||
}
|
||||
|
||||
void EditingContextResolver::buildBreadcrumbFromStack(EditingContext& ctx) const
|
||||
{
|
||||
QStringList crumbs;
|
||||
QStringList colors;
|
||||
|
||||
// Build one breadcrumb segment per stack entry
|
||||
for (const auto& contextId : ctx.stack) {
|
||||
const auto* def = d->findContext(contextId);
|
||||
if (!def) {
|
||||
continue;
|
||||
}
|
||||
|
||||
crumbs.append(expandLabel(*def));
|
||||
colors.append(def->color);
|
||||
|
||||
// Append any breadcrumb injections for this context
|
||||
auto injIt = d->breadcrumbInjections.find(contextId);
|
||||
if (injIt != d->breadcrumbInjections.end()) {
|
||||
const auto& injSegments = injIt->first;
|
||||
const auto& injColors = injIt->second;
|
||||
for (int i = 0; i < injSegments.size(); ++i) {
|
||||
crumbs.append(injSegments[i]);
|
||||
colors.append(i < injColors.size() ? injColors[i] : def->color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: if the stack produced nothing, use the leaf label
|
||||
if (crumbs.isEmpty()) {
|
||||
crumbs.append(ctx.label);
|
||||
colors.append(ctx.color);
|
||||
}
|
||||
|
||||
ctx.breadcrumb = crumbs;
|
||||
ctx.breadcrumbColors = colors;
|
||||
}
|
||||
|
||||
|
||||
@@ -720,12 +735,33 @@ QStringList EditingContextResolver::buildBreadcrumbColors(const EditingContext&
|
||||
// Apply context → toolbar state changes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
bool EditingContextResolver::checkGuards(const EditingContext& from,
|
||||
const EditingContext& to)
|
||||
{
|
||||
for (auto it = d->guards.constBegin(); it != d->guards.constEnd(); ++it) {
|
||||
auto result = it.value()(from, to);
|
||||
if (!result.first) {
|
||||
Q_EMIT contextTransitionBlocked(from, to, result.second);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void EditingContextResolver::applyContext(const EditingContext& ctx)
|
||||
{
|
||||
if (ctx.id == d->current.id && ctx.toolbars == d->current.toolbars) {
|
||||
if (ctx.id == d->current.id && ctx.toolbars == d->current.toolbars
|
||||
&& ctx.stack == d->current.stack) {
|
||||
return; // No change
|
||||
}
|
||||
|
||||
// Check transition guards before applying
|
||||
if (!d->current.id.isEmpty() && !d->guards.isEmpty()) {
|
||||
if (!checkGuards(d->current, ctx)) {
|
||||
return; // Guard rejected the transition
|
||||
}
|
||||
}
|
||||
|
||||
auto* tbm = ToolBarManager::getInstance();
|
||||
if (!tbm) {
|
||||
return;
|
||||
@@ -746,6 +782,40 @@ void EditingContextResolver::applyContext(const EditingContext& ctx)
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transition guard API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
int EditingContextResolver::addTransitionGuard(TransitionGuard guard)
|
||||
{
|
||||
int id = d->nextGuardId++;
|
||||
d->guards.insert(id, std::move(guard));
|
||||
return id;
|
||||
}
|
||||
|
||||
void EditingContextResolver::removeTransitionGuard(int guardId)
|
||||
{
|
||||
d->guards.remove(guardId);
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Breadcrumb injection API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void EditingContextResolver::injectBreadcrumb(const QString& contextId,
|
||||
const QStringList& segments,
|
||||
const QStringList& colors)
|
||||
{
|
||||
d->breadcrumbInjections[contextId] = qMakePair(segments, colors);
|
||||
}
|
||||
|
||||
void EditingContextResolver::removeBreadcrumbInjection(const QString& contextId)
|
||||
{
|
||||
d->breadcrumbInjections.remove(contextId);
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Signal handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -26,7 +26,9 @@
|
||||
#define GUI_EDITINGCONTEXT_H
|
||||
|
||||
#include <functional>
|
||||
#include <utility>
|
||||
#include <QObject>
|
||||
#include <QPair>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
|
||||
@@ -42,18 +44,20 @@ class ViewProviderDocumentObject;
|
||||
/// Snapshot of the resolved editing context.
|
||||
struct GuiExport EditingContext
|
||||
{
|
||||
QString id; // e.g. "sketcher.edit", "partdesign.body"
|
||||
QString id; // leaf context, e.g. "sketcher.edit"
|
||||
QString label; // e.g. "Sketcher: Sketch001"
|
||||
QString color; // Catppuccin Mocha hex, e.g. "#a6e3a1"
|
||||
QStringList toolbars; // toolbar names to ForceAvailable
|
||||
QStringList breadcrumb; // e.g. ["Body", "Sketch001", "[editing]"]
|
||||
QStringList breadcrumb; // e.g. ["Part Design", "Body", "Sketcher: Sketch001"]
|
||||
QStringList breadcrumbColors; // per-segment color hex values
|
||||
QStringList stack; // context IDs from root to leaf, e.g. ["partdesign.workbench", "partdesign.body", "sketcher.edit"]
|
||||
};
|
||||
|
||||
/// Definition used when registering a context.
|
||||
struct GuiExport ContextDefinition
|
||||
{
|
||||
QString id;
|
||||
QString parentId; // optional parent context ID (empty = root-level)
|
||||
QString labelTemplate; // supports {name} placeholder
|
||||
QString color;
|
||||
QStringList toolbars;
|
||||
@@ -98,6 +102,28 @@ public:
|
||||
/// Inject additional commands into a context's toolbar.
|
||||
void injectCommands(const QString& contextId, const QString& toolbarName, const QStringList& commands);
|
||||
|
||||
// -- Transition guards --------------------------------------------------
|
||||
|
||||
/// Guard callback: returns (allowed, reason). Reason is shown on rejection.
|
||||
using TransitionGuard = std::function<std::pair<bool, QString>(
|
||||
const EditingContext& from, const EditingContext& to)>;
|
||||
|
||||
/// Register a transition guard. Returns an ID for later removal.
|
||||
int addTransitionGuard(TransitionGuard guard);
|
||||
|
||||
/// Remove a previously registered transition guard by ID.
|
||||
void removeTransitionGuard(int guardId);
|
||||
|
||||
// -- Breadcrumb injection -----------------------------------------------
|
||||
|
||||
/// Inject additional segments into a context's breadcrumb display.
|
||||
void injectBreadcrumb(const QString& contextId,
|
||||
const QStringList& segments,
|
||||
const QStringList& colors);
|
||||
|
||||
/// Remove a previously injected breadcrumb for a context.
|
||||
void removeBreadcrumbInjection(const QString& contextId);
|
||||
|
||||
/// Force a re-resolve (e.g. after workbench switch).
|
||||
void refresh();
|
||||
|
||||
@@ -107,6 +133,7 @@ public:
|
||||
struct ContextInfo
|
||||
{
|
||||
QString id;
|
||||
QString parentId;
|
||||
QString labelTemplate;
|
||||
QString color;
|
||||
int priority;
|
||||
@@ -118,6 +145,9 @@ public:
|
||||
|
||||
Q_SIGNALS:
|
||||
void contextChanged(const EditingContext& ctx);
|
||||
void contextTransitionBlocked(const EditingContext& from,
|
||||
const EditingContext& to,
|
||||
const QString& reason);
|
||||
|
||||
private:
|
||||
EditingContextResolver();
|
||||
@@ -125,8 +155,10 @@ private:
|
||||
|
||||
EditingContext resolve() const;
|
||||
void applyContext(const EditingContext& ctx);
|
||||
QStringList buildBreadcrumb(const EditingContext& ctx) const;
|
||||
QStringList buildBreadcrumbColors(const EditingContext& ctx) const;
|
||||
bool checkGuards(const EditingContext& from, const EditingContext& to);
|
||||
void buildStack(EditingContext& ctx, const ContextDefinition& leaf) const;
|
||||
void buildBreadcrumbFromStack(EditingContext& ctx) const;
|
||||
QString expandLabel(const ContextDefinition& def) const;
|
||||
|
||||
// Signal handlers
|
||||
void onInEdit(const ViewProviderDocumentObject& vp);
|
||||
|
||||
@@ -115,6 +115,7 @@ void SDKRegistry::registerContext(const ContextDef& def)
|
||||
{
|
||||
Gui::ContextDefinition guiDef;
|
||||
guiDef.id = toQString(def.id);
|
||||
guiDef.parentId = toQString(def.parentId);
|
||||
guiDef.labelTemplate = toQString(def.labelTemplate);
|
||||
guiDef.color = toQString(def.color);
|
||||
guiDef.toolbars = toQStringList(def.toolbars);
|
||||
@@ -152,11 +153,8 @@ void SDKRegistry::injectCommands(const std::string& contextId,
|
||||
toQString(contextId), toQString(toolbarName), toQStringList(commands));
|
||||
}
|
||||
|
||||
ContextSnapshot SDKRegistry::currentContext() const
|
||||
static ContextSnapshot snapshotFromGui(const Gui::EditingContext& ctx)
|
||||
{
|
||||
Gui::EditingContext ctx =
|
||||
Gui::EditingContextResolver::instance()->currentContext();
|
||||
|
||||
ContextSnapshot snap;
|
||||
snap.id = fromQString(ctx.id);
|
||||
snap.label = fromQString(ctx.label);
|
||||
@@ -164,9 +162,15 @@ ContextSnapshot SDKRegistry::currentContext() const
|
||||
snap.toolbars = fromQStringList(ctx.toolbars);
|
||||
snap.breadcrumb = fromQStringList(ctx.breadcrumb);
|
||||
snap.breadcrumbColors = fromQStringList(ctx.breadcrumbColors);
|
||||
snap.stack = fromQStringList(ctx.stack);
|
||||
return snap;
|
||||
}
|
||||
|
||||
ContextSnapshot SDKRegistry::currentContext() const
|
||||
{
|
||||
return snapshotFromGui(Gui::EditingContextResolver::instance()->currentContext());
|
||||
}
|
||||
|
||||
std::vector<SDKRegistry::ContextInfo> SDKRegistry::registeredContexts() const
|
||||
{
|
||||
auto guiContexts =
|
||||
@@ -176,6 +180,7 @@ std::vector<SDKRegistry::ContextInfo> SDKRegistry::registeredContexts() const
|
||||
result.reserve(guiContexts.size());
|
||||
for (const auto& c : guiContexts) {
|
||||
result.push_back({fromQString(c.id),
|
||||
fromQString(c.parentId),
|
||||
fromQString(c.labelTemplate),
|
||||
fromQString(c.color),
|
||||
c.priority});
|
||||
@@ -188,6 +193,45 @@ void SDKRegistry::refresh()
|
||||
Gui::EditingContextResolver::instance()->refresh();
|
||||
}
|
||||
|
||||
// -- Transition guard API ---------------------------------------------------
|
||||
|
||||
int SDKRegistry::addTransitionGuard(TransitionGuard guard)
|
||||
{
|
||||
// Wrap the SDK-level guard (which uses ContextSnapshot) into a Gui-level
|
||||
// guard (which uses EditingContext).
|
||||
auto wrappedGuard = [g = std::move(guard)](
|
||||
const Gui::EditingContext& from, const Gui::EditingContext& to)
|
||||
-> std::pair<bool, QString>
|
||||
{
|
||||
auto result = g(snapshotFromGui(from), snapshotFromGui(to));
|
||||
return {result.first, toQString(result.second)};
|
||||
};
|
||||
|
||||
return Gui::EditingContextResolver::instance()->addTransitionGuard(
|
||||
std::move(wrappedGuard));
|
||||
}
|
||||
|
||||
void SDKRegistry::removeTransitionGuard(int guardId)
|
||||
{
|
||||
Gui::EditingContextResolver::instance()->removeTransitionGuard(guardId);
|
||||
}
|
||||
|
||||
// -- Breadcrumb injection API -----------------------------------------------
|
||||
|
||||
void SDKRegistry::injectBreadcrumb(const std::string& contextId,
|
||||
const std::vector<std::string>& segments,
|
||||
const std::vector<std::string>& colors)
|
||||
{
|
||||
Gui::EditingContextResolver::instance()->injectBreadcrumb(
|
||||
toQString(contextId), toQStringList(segments), toQStringList(colors));
|
||||
}
|
||||
|
||||
void SDKRegistry::removeBreadcrumbInjection(const std::string& contextId)
|
||||
{
|
||||
Gui::EditingContextResolver::instance()->removeBreadcrumbInjection(
|
||||
toQString(contextId));
|
||||
}
|
||||
|
||||
// -- Context change callback API --------------------------------------------
|
||||
|
||||
void SDKRegistry::onContextChanged(ContextChangeCallback cb)
|
||||
@@ -209,14 +253,7 @@ void SDKRegistry::ensureContextConnection()
|
||||
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);
|
||||
notifyContextCallbacks(snapshotFromGui(ctx));
|
||||
});
|
||||
|
||||
Base::Console().log("KCSDK: connected to context change signal\n");
|
||||
|
||||
@@ -93,6 +93,7 @@ public:
|
||||
struct ContextInfo
|
||||
{
|
||||
std::string id;
|
||||
std::string parentId;
|
||||
std::string labelTemplate;
|
||||
std::string color;
|
||||
int priority;
|
||||
@@ -105,6 +106,28 @@ public:
|
||||
/// Force re-resolution of the editing context.
|
||||
void refresh();
|
||||
|
||||
// -- Transition guards --------------------------------------------------
|
||||
|
||||
/// Guard callback type (mirrors EditingContextResolver::TransitionGuard).
|
||||
using TransitionGuard = std::function<std::pair<bool, std::string>(
|
||||
const ContextSnapshot& from, const ContextSnapshot& to)>;
|
||||
|
||||
/// Register a transition guard. Returns an ID for later removal.
|
||||
int addTransitionGuard(TransitionGuard guard);
|
||||
|
||||
/// Remove a previously registered transition guard by ID.
|
||||
void removeTransitionGuard(int guardId);
|
||||
|
||||
// -- Breadcrumb injection -----------------------------------------------
|
||||
|
||||
/// Inject additional segments into a context's breadcrumb display.
|
||||
void injectBreadcrumb(const std::string& contextId,
|
||||
const std::vector<std::string>& segments,
|
||||
const std::vector<std::string>& colors);
|
||||
|
||||
/// Remove a previously injected breadcrumb for a context.
|
||||
void removeBreadcrumbInjection(const std::string& contextId);
|
||||
|
||||
// -- Panel provider API ------------------------------------------------
|
||||
|
||||
/// Register a dock panel provider. Ownership transfers to the registry.
|
||||
|
||||
@@ -37,6 +37,7 @@ namespace KCSDK
|
||||
struct KCSDKExport ContextDef
|
||||
{
|
||||
std::string id;
|
||||
std::string parentId; // optional parent context ID (empty = root-level)
|
||||
std::string labelTemplate;
|
||||
std::string color;
|
||||
std::vector<std::string> toolbars;
|
||||
@@ -61,6 +62,7 @@ struct KCSDKExport ContextSnapshot
|
||||
std::vector<std::string> toolbars;
|
||||
std::vector<std::string> breadcrumb;
|
||||
std::vector<std::string> breadcrumbColors;
|
||||
std::vector<std::string> stack; // context IDs from root to leaf
|
||||
};
|
||||
|
||||
/// Dock widget area. Values match Qt::DockWidgetArea for direct casting.
|
||||
|
||||
@@ -77,6 +77,7 @@ py::dict contextSnapshotToDict(const ContextSnapshot& snap)
|
||||
d["toolbars"] = snap.toolbars;
|
||||
d["breadcrumb"] = snap.breadcrumb;
|
||||
d["breadcrumbColors"] = snap.breadcrumbColors;
|
||||
d["stack"] = snap.stack;
|
||||
return d;
|
||||
}
|
||||
|
||||
@@ -121,12 +122,14 @@ PYBIND11_MODULE(kcsdk, m)
|
||||
const std::string& color,
|
||||
const std::vector<std::string>& toolbars,
|
||||
py::object match,
|
||||
int priority) {
|
||||
int priority,
|
||||
const std::string& parentId) {
|
||||
if (!py::isinstance<py::function>(match) && !py::hasattr(match, "__call__")) {
|
||||
throw py::type_error("match must be callable");
|
||||
}
|
||||
ContextDef def;
|
||||
def.id = id;
|
||||
def.parentId = parentId;
|
||||
def.labelTemplate = label;
|
||||
def.color = color;
|
||||
def.toolbars = toolbars;
|
||||
@@ -140,6 +143,7 @@ PYBIND11_MODULE(kcsdk, m)
|
||||
py::arg("toolbars"),
|
||||
py::arg("match"),
|
||||
py::arg("priority") = 50,
|
||||
py::arg("parent_id") = "",
|
||||
"Register an editing context.\n\n"
|
||||
"Parameters\n"
|
||||
"----------\n"
|
||||
@@ -148,7 +152,8 @@ PYBIND11_MODULE(kcsdk, m)
|
||||
"color : str\n Hex color for breadcrumb.\n"
|
||||
"toolbars : list[str]\n Toolbar names to show when active.\n"
|
||||
"match : callable\n Zero-arg callable returning True when active.\n"
|
||||
"priority : int\n Higher values checked first (default 50).");
|
||||
"priority : int\n Higher values checked first (default 50).\n"
|
||||
"parent_id : str\n Optional parent context ID for hierarchy.");
|
||||
|
||||
m.def("unregister_context",
|
||||
[](const std::string& id) {
|
||||
@@ -216,6 +221,7 @@ PYBIND11_MODULE(kcsdk, m)
|
||||
for (const auto& c : contexts) {
|
||||
py::dict d;
|
||||
d["id"] = c.id;
|
||||
d["parent_id"] = c.parentId;
|
||||
d["label_template"] = c.labelTemplate;
|
||||
d["color"] = c.color;
|
||||
d["priority"] = c.priority;
|
||||
@@ -224,7 +230,7 @@ PYBIND11_MODULE(kcsdk, m)
|
||||
return result;
|
||||
},
|
||||
"Return metadata for all registered editing contexts.\n\n"
|
||||
"Each entry is a dict with keys: id, label_template, color, priority.\n"
|
||||
"Each entry is a dict with keys: id, parent_id, label_template, color, priority.\n"
|
||||
"Sorted by descending priority (highest first).");
|
||||
|
||||
m.def("on_context_changed",
|
||||
@@ -244,10 +250,85 @@ PYBIND11_MODULE(kcsdk, m)
|
||||
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"
|
||||
"id, label, color, toolbars, breadcrumb, breadcrumbColors, stack.\n"
|
||||
"Called synchronously on the Qt main thread whenever the\n"
|
||||
"editing context changes.");
|
||||
|
||||
m.def("context_stack",
|
||||
[]() -> py::object {
|
||||
ContextSnapshot snap = SDKRegistry::instance().currentContext();
|
||||
if (snap.id.empty()) {
|
||||
return py::list();
|
||||
}
|
||||
return py::cast(snap.stack);
|
||||
},
|
||||
"Return the current context stack (root to leaf) as a list of IDs.");
|
||||
|
||||
// -- Transition guard API -----------------------------------------------
|
||||
|
||||
m.def("add_transition_guard",
|
||||
[](py::function callback) -> int {
|
||||
auto held = std::make_shared<py::object>(std::move(callback));
|
||||
SDKRegistry::TransitionGuard guard =
|
||||
[held](const ContextSnapshot& from, const ContextSnapshot& to)
|
||||
-> std::pair<bool, std::string>
|
||||
{
|
||||
py::gil_scoped_acquire gil;
|
||||
try {
|
||||
py::object result = (*held)(
|
||||
contextSnapshotToDict(from),
|
||||
contextSnapshotToDict(to));
|
||||
if (py::isinstance<py::tuple>(result)) {
|
||||
auto tup = result.cast<py::tuple>();
|
||||
bool allowed = tup[0].cast<bool>();
|
||||
std::string reason = tup.size() > 1
|
||||
? tup[1].cast<std::string>() : "";
|
||||
return {allowed, reason};
|
||||
}
|
||||
return {result.cast<bool>(), ""};
|
||||
}
|
||||
catch (py::error_already_set& e) {
|
||||
e.discard_as_unraisable(__func__);
|
||||
return {true, ""}; // allow on error
|
||||
}
|
||||
};
|
||||
return SDKRegistry::instance().addTransitionGuard(std::move(guard));
|
||||
},
|
||||
py::arg("callback"),
|
||||
"Register a transition guard.\n\n"
|
||||
"The callback receives (from_ctx, to_ctx) dicts and must return\n"
|
||||
"either a bool or a (bool, reason_str) tuple. Returns a guard ID\n"
|
||||
"for later removal.");
|
||||
|
||||
m.def("remove_transition_guard",
|
||||
[](int guardId) {
|
||||
SDKRegistry::instance().removeTransitionGuard(guardId);
|
||||
},
|
||||
py::arg("guard_id"),
|
||||
"Remove a previously registered transition guard.");
|
||||
|
||||
// -- Breadcrumb injection API -------------------------------------------
|
||||
|
||||
m.def("inject_breadcrumb",
|
||||
[](const std::string& contextId,
|
||||
const std::vector<std::string>& segments,
|
||||
const std::vector<std::string>& colors) {
|
||||
SDKRegistry::instance().injectBreadcrumb(contextId, segments, colors);
|
||||
},
|
||||
py::arg("context_id"),
|
||||
py::arg("segments"),
|
||||
py::arg("colors") = std::vector<std::string>{},
|
||||
"Inject additional breadcrumb segments into a context.\n\n"
|
||||
"Segments are appended after the context's own label in the breadcrumb.\n"
|
||||
"Active only when the target context is in the current stack.");
|
||||
|
||||
m.def("remove_breadcrumb_injection",
|
||||
[](const std::string& contextId) {
|
||||
SDKRegistry::instance().removeBreadcrumbInjection(contextId);
|
||||
},
|
||||
py::arg("context_id"),
|
||||
"Remove a previously injected breadcrumb for a context.");
|
||||
|
||||
// -- Enums --------------------------------------------------------------
|
||||
|
||||
py::enum_<DockArea>(m, "DockArea")
|
||||
|
||||
Reference in New Issue
Block a user