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

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:
forbes
2026-03-04 14:23:45 -06:00
parent 6ac0c4af54
commit 74b0073327
8 changed files with 481 additions and 149 deletions

View File

@@ -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",
]

View File

@@ -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()

View File

@@ -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
// ---------------------------------------------------------------------------

View File

@@ -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);

View File

@@ -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");

View File

@@ -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.

View File

@@ -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.

View File

@@ -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")