diff --git a/mods/sdk/kindred_sdk/__init__.py b/mods/sdk/kindred_sdk/__init__.py index afe75b959f..f972e8e040 100644 --- a/mods/sdk/kindred_sdk/__init__.py +++ b/mods/sdk/kindred_sdk/__init__.py @@ -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", ] diff --git a/mods/sdk/kindred_sdk/context.py b/mods/sdk/kindred_sdk/context.py index 5c79fcb516..c7420452de 100644 --- a/mods/sdk/kindred_sdk/context.py +++ b/mods/sdk/kindred_sdk/context.py @@ -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() diff --git a/src/Gui/EditingContext.cpp b/src/Gui/EditingContext.cpp index c1220467a1..5d9dd4d9a9 100644 --- a/src/Gui/EditingContext.cpp +++ b/src/Gui/EditingContext.cpp @@ -25,9 +25,11 @@ #include "EditingContext.h" #include +#include #include #include +#include #include #include "Application.h" @@ -70,6 +72,13 @@ struct EditingContextResolver::Private // Key: contextId -> toolbarName -> list of command names QMap> injections; + // Transition guards: id -> guard callback + QMap guards; + int nextGuardId = 1; + + // Breadcrumb injections: contextId -> (segments, colors) + QMap> 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::registeredCon QList 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(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(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 // --------------------------------------------------------------------------- diff --git a/src/Gui/EditingContext.h b/src/Gui/EditingContext.h index 4dda0c1d9b..ecf5ab97cd 100644 --- a/src/Gui/EditingContext.h +++ b/src/Gui/EditingContext.h @@ -26,7 +26,9 @@ #define GUI_EDITINGCONTEXT_H #include +#include #include +#include #include #include @@ -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( + 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); diff --git a/src/Gui/SDK/SDKRegistry.cpp b/src/Gui/SDK/SDKRegistry.cpp index 2a533c0ada..9c30183509 100644 --- a/src/Gui/SDK/SDKRegistry.cpp +++ b/src/Gui/SDK/SDKRegistry.cpp @@ -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::registeredContexts() const { auto guiContexts = @@ -176,6 +180,7 @@ std::vector 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 + { + 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& segments, + const std::vector& 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"); diff --git a/src/Gui/SDK/SDKRegistry.h b/src/Gui/SDK/SDKRegistry.h index 59dba86ca8..a65f428bf9 100644 --- a/src/Gui/SDK/SDKRegistry.h +++ b/src/Gui/SDK/SDKRegistry.h @@ -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( + 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& segments, + const std::vector& 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. diff --git a/src/Gui/SDK/Types.h b/src/Gui/SDK/Types.h index a8d0109a5f..66fe94a94f 100644 --- a/src/Gui/SDK/Types.h +++ b/src/Gui/SDK/Types.h @@ -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 toolbars; @@ -61,6 +62,7 @@ struct KCSDKExport ContextSnapshot std::vector toolbars; std::vector breadcrumb; std::vector breadcrumbColors; + std::vector stack; // context IDs from root to leaf }; /// Dock widget area. Values match Qt::DockWidgetArea for direct casting. diff --git a/src/Gui/SDK/bindings/kcsdk_py.cpp b/src/Gui/SDK/bindings/kcsdk_py.cpp index 0e288a3d84..882694683c 100644 --- a/src/Gui/SDK/bindings/kcsdk_py.cpp +++ b/src/Gui/SDK/bindings/kcsdk_py.cpp @@ -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& toolbars, py::object match, - int priority) { + int priority, + const std::string& parentId) { if (!py::isinstance(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(std::move(callback)); + SDKRegistry::TransitionGuard guard = + [held](const ContextSnapshot& from, const ContextSnapshot& to) + -> std::pair + { + py::gil_scoped_acquire gil; + try { + py::object result = (*held)( + contextSnapshotToDict(from), + contextSnapshotToDict(to)); + if (py::isinstance(result)) { + auto tup = result.cast(); + bool allowed = tup[0].cast(); + std::string reason = tup.size() > 1 + ? tup[1].cast() : ""; + return {allowed, reason}; + } + return {result.cast(), ""}; + } + 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& segments, + const std::vector& colors) { + SDKRegistry::instance().injectBreadcrumb(contextId, segments, colors); + }, + py::arg("context_id"), + py::arg("segments"), + py::arg("colors") = std::vector{}, + "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_(m, "DockArea")