From d994e2c95e9a92bd01773d0e8ae75e2fa0c17bfa Mon Sep 17 00:00:00 2001 From: Kristian Bendiksen Date: Fri, 2 Oct 2026 15:43:09 +0200 Subject: [PATCH] Workflows: Show ResInsight object icons on graph ports Ports holding a ResInsight object (instance, case, view, well path, surface) show the project tree icon of that object in front of the type. The rips class is taken from the schema's Python bases, directly or via ObjectModel[Class]. --- .../Workflow/RimWorkflowDefinitionTools.cpp | 10 +- .../Workflow/RimWorkflowDescribeTools.cpp | 4 +- .../Workflow/RimWorkflowPortCompatibility.cpp | 94 +++++++++++++++---- .../Workflow/RimWorkflowPortCompatibility.h | 3 + .../UnitTests/RimWorkflowDefinition-Test.cpp | 19 ++++ .../UserInterface/RiuWorkflowGraphView.cpp | 83 ++++++++++------ docs/workflow-ui-design.md | 6 +- 7 files changed, 172 insertions(+), 47 deletions(-) diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowDefinitionTools.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowDefinitionTools.cpp index b2c14ca3ca..977acdb6a4 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowDefinitionTools.cpp +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowDefinitionTools.cpp @@ -218,6 +218,7 @@ QJsonArray inputPortsJson( const RimWorkflowDefinition& definit wired = wired || edge.input == port.name; result.append( QJsonObject{ { "name", port.name }, { "type", port.typeName }, + { "icon", port.iconResource }, { "description", port.description }, { "required", port.required }, { "configurable", port.configurable }, @@ -236,8 +237,11 @@ QJsonArray outputPortsJson( const std::vector& ports, const std bool wired = false; for ( const auto& edge : outgoing ) wired = wired || edge.output == port.name; - result.append( - QJsonObject{ { "name", port.name }, { "type", port.typeName }, { "description", port.description }, { "wired", wired } } ); + result.append( QJsonObject{ { "name", port.name }, + { "type", port.typeName }, + { "icon", port.iconResource }, + { "description", port.description }, + { "wired", wired } } ); } return result; } @@ -288,6 +292,8 @@ QJsonObject graphTask( const RimWorkflowDefinition& definition, { "output_ports", outputPortsJson( outputPorts, definition.outgoingEdges( node.name ) ) }, { "input_types", RimWorkflowPortCompatibility::portTypes( inputPorts ) }, { "output_types", RimWorkflowPortCompatibility::portTypes( outputPorts ) }, + { "input_icons", RimWorkflowPortCompatibility::portIcons( inputPorts ) }, + { "output_icons", RimWorkflowPortCompatibility::portIcons( outputPorts ) }, { "is_result", node.name == resultTask }, { "issues", taskIssues } }; } diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowDescribeTools.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowDescribeTools.cpp index 41a3b9bc25..aabd42c497 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowDescribeTools.cpp +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowDescribeTools.cpp @@ -120,7 +120,9 @@ QJsonObject taskFromDescribe( const QJsonObject& task ) { "outputs", outputs }, { "config_fields", configFields }, { "input_types", RimWorkflowPortCompatibility::portTypes( RimWorkflowPortCompatibility::inputPorts( task ) ) }, - { "output_types", RimWorkflowPortCompatibility::portTypes( RimWorkflowPortCompatibility::outputPorts( task ) ) } }; + { "output_types", RimWorkflowPortCompatibility::portTypes( RimWorkflowPortCompatibility::outputPorts( task ) ) }, + { "input_icons", RimWorkflowPortCompatibility::portIcons( RimWorkflowPortCompatibility::inputPorts( task ) ) }, + { "output_icons", RimWorkflowPortCompatibility::portIcons( RimWorkflowPortCompatibility::outputPorts( task ) ) } }; } } // namespace diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowPortCompatibility.cpp b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowPortCompatibility.cpp index 7eaf1ebdfe..32cfc52cfd 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowPortCompatibility.cpp +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowPortCompatibility.cpp @@ -23,6 +23,7 @@ #include #include +#include #include #include @@ -155,12 +156,13 @@ std::vector RimWorkflowPortCompatibility::inputPorts( const QJs const QJsonObject inputSchema = taskType.value( "input_schema" ).toObject(); std::vector ports; - ports.push_back( { .name = "", - .schema = inputSchema, - .rootSchema = inputSchema, - .description = inputSchema.value( "description" ).toString(), - .typeName = typeName( inputSchema, inputSchema ), - .required = true } ); + ports.push_back( { .name = "", + .schema = inputSchema, + .rootSchema = inputSchema, + .description = inputSchema.value( "description" ).toString(), + .typeName = typeName( inputSchema, inputSchema ), + .iconResource = iconResource( inputSchema, inputSchema ), + .required = true } ); const QStringList required = RimWorkflowSchemaTools::requiredFields( inputSchema ); const QJsonObject properties = inputSchema.value( "properties" ).toObject(); @@ -173,6 +175,7 @@ std::vector RimWorkflowPortCompatibility::inputPorts( const QJs .rootSchema = inputSchema, .description = property.value( "description" ).toString(), .typeName = typeName( property, inputSchema ), + .iconResource = iconResource( property, inputSchema ), .required = required.contains( name ), .configurable = RimWorkflowSchemaTools::isConfigurable( property, inputSchema ) } ); } @@ -187,22 +190,24 @@ std::vector RimWorkflowPortCompatibility::outputPorts( const QJ const QJsonObject outputSchema = taskType.value( "output_schema" ).toObject(); std::vector ports; - ports.push_back( { .name = "", - .schema = outputSchema, - .rootSchema = outputSchema, - .description = outputSchema.value( "description" ).toString(), - .typeName = typeName( outputSchema, outputSchema ) } ); + ports.push_back( { .name = "", + .schema = outputSchema, + .rootSchema = outputSchema, + .description = outputSchema.value( "description" ).toString(), + .typeName = typeName( outputSchema, outputSchema ), + .iconResource = iconResource( outputSchema, outputSchema ) } ); const QJsonObject properties = outputSchema.value( "properties" ).toObject(); for ( const QString& name : RimWorkflowSchemaTools::propertyNames( outputSchema ) ) { const QJsonObject property = properties.value( name ).toObject(); if ( isHiddenObjectValue( name, property, outputSchema ) ) continue; - ports.push_back( { .name = name, - .schema = property, - .rootSchema = outputSchema, - .description = property.value( "description" ).toString(), - .typeName = typeName( property, outputSchema ) } ); + ports.push_back( { .name = name, + .schema = property, + .rootSchema = outputSchema, + .description = property.value( "description" ).toString(), + .typeName = typeName( property, outputSchema ), + .iconResource = iconResource( property, outputSchema ) } ); } return ports; } @@ -346,6 +351,63 @@ QString RimWorkflowPortCompatibility::typeName( const QJsonObject& schema, const return pythonNames.value( type, type ); } +//-------------------------------------------------------------------------------------------------- +/// Icon of the ResInsight object a schema holds, or an empty string. The rips class is found in the +/// Python class bases: directly for opaque rips objects, or as `ObjectModel[Class]` for wrappers. +/// The icons are the ones the matching ResInsight project classes use. +//-------------------------------------------------------------------------------------------------- +QString RimWorkflowPortCompatibility::iconResource( const QJsonObject& schema, const QJsonObject& rootSchema ) +{ + const QJsonObject resolved = RimWorkflowSchemaTools::resolveReferences( schema, rootSchema ); + for ( const char* unionKey : { "anyOf", "oneOf" } ) + { + for ( const QJsonValue& option : resolved.value( unionKey ).toArray() ) + { + const QString icon = iconResource( option.toObject(), rootSchema ); + if ( !icon.isEmpty() ) return icon; + } + } + if ( resolved.value( "type" ).toString() == "array" ) return iconResource( resolved.value( "items" ).toObject(), rootSchema ); + + static const QMap icons = { { "Instance", ":/AppLogo48x48.png" }, + { "Case", ":/Case48x48.png" }, + { "Reservoir", ":/Case48x48.png" }, + { "EclipseCase", ":/Case48x48.png" }, + { "View", ":/3DView16x16.png" }, + { "EclipseView", ":/3DView16x16.png" }, + { "WellPath", ":/Well.svg" }, + { "Surface", ":/ReservoirSurface16x16.png" } }; + + QJsonArray bases = resolved.value( "x-ri-python-bases" ).toArray(); + if ( bases.isEmpty() && resolved.contains( "x-ri-python-type" ) ) bases.append( resolved.value( "x-ri-python-type" ) ); + + static const QRegularExpression objectModel( R"(ObjectModel\[(\w+)\]$)" ); + for ( const QJsonValue& value : bases ) + { + const QString base = value.toString(); + QString className; + if ( const auto match = objectModel.match( base ); match.hasMatch() ) + className = match.captured( 1 ); + else if ( base.startsWith( "rips." ) ) + className = base.section( '.', -1 ); + if ( icons.contains( className ) ) return icons.value( className ); + } + return {}; +} + +//-------------------------------------------------------------------------------------------------- +/// Port name -> icon resource, for the ports that hold a ResInsight object +//-------------------------------------------------------------------------------------------------- +QJsonObject RimWorkflowPortCompatibility::portIcons( const std::vector& ports ) +{ + QJsonObject icons; + for ( const auto& port : ports ) + { + if ( !port.iconResource.isEmpty() ) icons[port.name] = port.iconResource; + } + return icons; +} + //-------------------------------------------------------------------------------------------------- /// Port name -> type name, with an empty name for the whole model. Used to label ports in the graph. //-------------------------------------------------------------------------------------------------- diff --git a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowPortCompatibility.h b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowPortCompatibility.h index 3fc253cf28..97a3dd37ca 100644 --- a/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowPortCompatibility.h +++ b/ApplicationLibCode/ProjectDataModel/Workflow/RimWorkflowPortCompatibility.h @@ -37,6 +37,7 @@ struct RimWorkflowPort QJsonObject rootSchema{}; QString description{}; QString typeName{}; + QString iconResource{}; bool required = true; bool configurable = false; @@ -58,7 +59,9 @@ bool isCompatible( const QJsonObject& produced, const QJsonObject& producedRoot, bool isCompatible( const RimWorkflowPort& produced, const RimWorkflowPort& expected ); bool isAny( const QJsonObject& schema, const QJsonObject& rootSchema ); QString typeName( const QJsonObject& schema, const QJsonObject& rootSchema ); +QString iconResource( const QJsonObject& schema, const QJsonObject& rootSchema ); QJsonObject portTypes( const std::vector& ports ); +QJsonObject portIcons( const std::vector& ports ); QStringList coveredByWholeOutput( const QJsonObject& upstreamTaskType, const QJsonObject& downstreamTaskType ); bool wouldCreateCycle( const RimWorkflowDefinition& definition, const QString& from, const QString& to ); diff --git a/ApplicationLibCode/UnitTests/RimWorkflowDefinition-Test.cpp b/ApplicationLibCode/UnitTests/RimWorkflowDefinition-Test.cpp index 81d9918452..b00585994e 100644 --- a/ApplicationLibCode/UnitTests/RimWorkflowDefinition-Test.cpp +++ b/ApplicationLibCode/UnitTests/RimWorkflowDefinition-Test.cpp @@ -163,6 +163,25 @@ TEST( RimWorkflowDefinition, graphFromDefinition ) EXPECT_EQ( "RipsInstance", exportTask.value( "input_types" ).toObject().value( "resinsight" ).toString() ); EXPECT_EQ( "str", exportTask.value( "output_types" ).toObject().value( "export_file" ).toString() ); + // Icons of ResInsight objects, found through `ObjectModel[Class]` and rips bases + const QJsonObject exportIcons = exportTask.value( "input_icons" ).toObject(); + EXPECT_EQ( ":/AppLogo48x48.png", exportIcons.value( "resinsight" ).toString() ); + EXPECT_FALSE( exportTask.value( "output_icons" ).toObject().contains( "export_file" ) ); + EXPECT_EQ( ":/Case48x48.png", taskByName( graph, "select_eclipse_case" ).value( "output_icons" ).toObject().value( "" ).toString() ); + EXPECT_EQ( ":/Well.svg", taskByName( graph, "select_well_path_1" ).value( "output_icons" ).toObject().value( "" ).toString() ); + EXPECT_EQ( ":/Case48x48.png", + RimWorkflowPortCompatibility::iconResource( QJsonObject{ { "x-ri-python-type", "rips.generated.generated_classes.Reservoir" } }, + {} ) ); + EXPECT_EQ( ":/3DView16x16.png", + RimWorkflowPortCompatibility::iconResource( QJsonObject{ { "anyOf", + QJsonArray{ QJsonObject{ { "type", "null" } }, + QJsonObject{ { "x-ri-python-bases", + QJsonArray{ "pkg.GridView", + "taskmaestro.object_model." + "ObjectModel[" + "View]" } } } } } }, + {} ) ); + // The connect task's opaque `value` is not a port of its own const QJsonObject connect = taskByName( graph, "connect_to_resinsight" ); EXPECT_TRUE( connect.value( "outputs" ).toArray().isEmpty() ); diff --git a/ApplicationLibCode/UserInterface/RiuWorkflowGraphView.cpp b/ApplicationLibCode/UserInterface/RiuWorkflowGraphView.cpp index e0204466a6..30a262bb4c 100644 --- a/ApplicationLibCode/UserInterface/RiuWorkflowGraphView.cpp +++ b/ApplicationLibCode/UserInterface/RiuWorkflowGraphView.cpp @@ -23,10 +23,12 @@ #include #include #include +#include #include #include #include #include +#include #include #include #include @@ -54,6 +56,7 @@ constexpr qreal configGap = 110.0; constexpr qreal rowGap = 55.0; constexpr qreal portRadius = 5.0; constexpr qreal portHitDist = 14.0; +constexpr int iconSize = 12; const QColor taskBorder( 60, 85, 115 ); const QColor taskBackground( 226, 238, 250 ); @@ -123,6 +126,7 @@ struct PortInfo QString label; QString toolTip; QString typeName{}; + QString iconResource{}; Style style = Style::Normal; }; @@ -373,14 +377,15 @@ void GraphNode::addPorts( const std::vector& ports, bool output ) const QString toolTip = info.toolTip.isEmpty() ? info.label : info.toolTip; const qreal textWidth = m_isConfig ? m_width - 24 : m_width / 2 - 18; const bool showType = !info.typeName.isEmpty(); - auto addText = [&]( const QString& content, const QFont& font, const QColor& color, qreal top ) + auto addText = [&]( const QString& content, const QFont& font, const QColor& color, qreal top, qreal indent = 0.0 ) { - auto* text = new QGraphicsTextItem( QFontMetrics( font ).elidedText( content, Qt::ElideRight, textWidth ), this ); + auto* text = new QGraphicsTextItem( QFontMetrics( font ).elidedText( content, Qt::ElideRight, textWidth - indent ), this ); text->setFont( font ); text->setToolTip( toolTip ); text->setDefaultTextColor( color ); - text->setPos( output ? m_width - text->boundingRect().width() - 12 : 10, top ); + text->setPos( output ? m_width - text->boundingRect().width() - 12 : 10 + indent, top ); text->setAcceptedMouseButtons( Qt::NoButton ); + return text; }; QFont labelFont; @@ -394,7 +399,21 @@ void GraphNode::addPorts( const std::vector& ports, bool output ) QFont typeFont; typeFont.setPointSize( 7 ); typeFont.setItalic( true ); - addText( info.typeName, typeFont, QColor( 110, 120, 135 ), y - 3 ); + const QIcon icon( info.iconResource ); + const bool showIcon = !info.iconResource.isEmpty() && !icon.isNull(); + auto* typeText = addText( info.typeName, typeFont, QColor( 110, 120, 135 ), y - 3, showIcon ? iconSize + 2 : 0.0 ); + + // The icon of the ResInsight object goes in front of the type + if ( showIcon ) + { + const QRectF textRect = typeText->mapRectToParent( typeText->boundingRect() ); + const qreal iconX = output ? textRect.left() - iconSize - 1 : 10 + 3; + auto* pixmap = new QGraphicsPixmapItem( icon.pixmap( iconSize, iconSize ), this ); + pixmap->setPos( iconX, textRect.center().y() - iconSize / 2.0 ); + pixmap->setTransformationMode( Qt::SmoothTransformation ); + pixmap->setToolTip( toolTip ); + pixmap->setAcceptedMouseButtons( Qt::NoButton ); + } } } } @@ -705,9 +724,10 @@ std::pair, std::vector> editModePorts( const QJs } PortInfo info; - info.key = keyForField( field ); - info.label = field.isEmpty() ? "input" : field; - info.typeName = port.value( "type" ).toString(); + info.key = keyForField( field ); + info.label = field.isEmpty() ? "input" : field; + info.typeName = port.value( "type" ).toString(); + info.iconResource = port.value( "icon" ).toString(); QStringList toolTip{ QString( "%1: %2" ).arg( info.label, port.value( "type" ).toString( "any" ) ) }; if ( !port.value( "description" ).toString().isEmpty() ) toolTip << port.value( "description" ).toString(); toolTip << ( required ? "Required" : "Optional" ); @@ -723,10 +743,11 @@ std::pair, std::vector> editModePorts( const QJs if ( edge.value( "to" ).toString() == name && edge.value( "input" ).toString().isEmpty() && !hasWholeInput ) { inputs.insert( inputs.begin(), - PortInfo{ .key = "model", - .label = "input", - .toolTip = {}, - .typeName = task.value( "input_types" ).toObject().value( "" ).toString() } ); + PortInfo{ .key = "model", + .label = "input", + .toolTip = {}, + .typeName = task.value( "input_types" ).toObject().value( "" ).toString(), + .iconResource = task.value( "input_icons" ).toObject().value( "" ).toString() } ); hasWholeInput = true; } } @@ -737,36 +758,39 @@ std::pair, std::vector> editModePorts( const QJs const QJsonObject port = value.toObject(); const QString field = port.value( "name" ).toString(); PortInfo info; - info.key = keyForField( field ); - info.label = field.isEmpty() ? "output" : field; - info.typeName = port.value( "type" ).toString(); + info.key = keyForField( field ); + info.label = field.isEmpty() ? "output" : field; + info.typeName = port.value( "type" ).toString(); + info.iconResource = port.value( "icon" ).toString(); QStringList toolTip{ QString( "%1: %2" ).arg( info.label, port.value( "type" ).toString( "any" ) ) }; if ( !port.value( "description" ).toString().isEmpty() ) toolTip << port.value( "description" ).toString(); info.toolTip = toolTip.join( "\n" ); outputs.push_back( info ); } if ( outputs.empty() ) - outputs.push_back( PortInfo{ .key = "model", - .label = "output", - .toolTip = {}, - .typeName = task.value( "output_types" ).toObject().value( "" ).toString() } ); + outputs.push_back( PortInfo{ .key = "model", + .label = "output", + .toolTip = {}, + .typeName = task.value( "output_types" ).toObject().value( "" ).toString(), + .iconResource = task.value( "output_icons" ).toObject().value( "" ).toString() } ); return { inputs, outputs }; } //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- -std::vector portInfos( const QStringList& keys, bool output, const QJsonObject& types ) +std::vector portInfos( const QStringList& keys, bool output, const QJsonObject& types, const QJsonObject& icons ) { std::vector result; for ( const QString& key : keys ) { const QString typeName = types.value( fieldForKey( key ) ).toString(); const QString label = key == "model" ? ( output ? "output" : "input" ) : fieldForKey( key ); - result.push_back( PortInfo{ .key = key, - .label = label, - .toolTip = typeName.isEmpty() ? label : QString( "%1: %2" ).arg( label, typeName ), - .typeName = typeName } ); + result.push_back( PortInfo{ .key = key, + .label = label, + .toolTip = typeName.isEmpty() ? label : QString( "%1: %2" ).arg( label, typeName ), + .typeName = typeName, + .iconResource = icons.value( fieldForKey( key ) ).toString() } ); } return result; } @@ -963,8 +987,8 @@ void RiuWorkflowGraphView::showGraph( const QJsonObject& graph, const QString& e out.removeDuplicates(); in.sort(); out.sort(); - inputs = portInfos( in, false, data.value( "input_types" ).toObject() ); - outputs = portInfos( out, true, data.value( "output_types" ).toObject() ); + inputs = portInfos( in, false, data.value( "input_types" ).toObject(), data.value( "input_icons" ).toObject() ); + outputs = portInfos( out, true, data.value( "output_types" ).toObject(), data.value( "output_icons" ).toObject() ); } auto* node = new GraphNode( name, inputs, outputs ); @@ -984,8 +1008,13 @@ void RiuWorkflowGraphView::showGraph( const QJsonObject& graph, const QString& e if ( !configFields.value( name ).isEmpty() ) { - auto* configNode = - new GraphNode( name, {}, portInfos( configFields.value( name ), true, data.value( "input_types" ).toObject() ), true ); + auto* configNode = new GraphNode( name, + {}, + portInfos( configFields.value( name ), + true, + data.value( "input_types" ).toObject(), + data.value( "input_icons" ).toObject() ), + true ); m_scene->addItem( configNode ); m_configNodes.insert( name, configNode ); } diff --git a/docs/workflow-ui-design.md b/docs/workflow-ui-design.md index a63faccacc..ac2f5a46e2 100644 --- a/docs/workflow-ui-design.md +++ b/docs/workflow-ui-design.md @@ -156,7 +156,11 @@ in a central dock tab: Each port shows its name with its type below it in smaller text (from the task schemas, as `input_types`/`output_types` in the graph JSON). The -tooltip also shows the description. +tooltip also shows the description. Ports that hold a ResInsight object +(instance, case, view, well path, surface) show that object's project tree +icon in front of the type (`input_icons`/`output_icons`). The rips class is +found in the `x-ri-python-bases`, either directly or as `ObjectModel[Class]` +(`RimWorkflowPortCompatibility::iconResource`). `RiuWorkflowGraphLayout` computes the layout: topological ranks with a Kahn sort and longest-path ranks, then one barycenter pass to order nodes. It