Workflows: Show port types in the graph view

Each input and output port shows its type below the name, for editable and
read-only workflows. The graph JSON carries input_types/output_types per
task, built from the task schemas.
This commit is contained in:
Kristian Bendiksen committed 2026-10-21 11:19:35 +02:00
1 parent 6e2fbde65f
commit ec388ba34b
8 files changed
+87 -24

No files matched your search

@@ -286,6 +286,8 @@ QJsonObject graphTask( const RimWorkflowDefinition& definition,
{ "whole_input", inputPorts.size() == 1 && !inputSchema.value( "properties" ).toObject().isEmpty() },
{ "input_ports", inputPortsJson( definition, node, inputPorts, definition.incomingEdges( node.name ) ) },
{ "output_ports", outputPortsJson( outputPorts, definition.outgoingEdges( node.name ) ) },
{ "input_types", RimWorkflowPortCompatibility::portTypes( inputPorts ) },
{ "output_types", RimWorkflowPortCompatibility::portTypes( outputPorts ) },
{ "is_result", node.name == resultTask },
{ "issues", taskIssues } };
}
@@ -18,6 +18,7 @@
#include "RimWorkflowDescribeTools.h"
#include "RimWorkflowPortCompatibility.h"
#include "RimWorkflowSchemaTools.h"
#include <QJsonArray>
@@ -117,7 +118,9 @@ QJsonObject taskFromDescribe( const QJsonObject& task )
return QJsonObject{ { "name", task.value( "name" ).toString() },
{ "inputs", inputs },
{ "outputs", outputs },
{ "config_fields", configFields } };
{ "config_fields", configFields },
{ "input_types", RimWorkflowPortCompatibility::portTypes( RimWorkflowPortCompatibility::inputPorts( task ) ) },
{ "output_types", RimWorkflowPortCompatibility::portTypes( RimWorkflowPortCompatibility::outputPorts( task ) ) } };
}
} // namespace
@@ -346,6 +346,17 @@ QString RimWorkflowPortCompatibility::typeName( const QJsonObject& schema, const
return pythonNames.value( type, type );
}
//--------------------------------------------------------------------------------------------------
/// Port name -> type name, with an empty name for the whole model. Used to label ports in the graph.
//--------------------------------------------------------------------------------------------------
QJsonObject RimWorkflowPortCompatibility::portTypes( const std::vector<RimWorkflowPort>& ports )
{
QJsonObject types;
for ( const auto& port : ports )
types[port.name] = port.typeName;
return types;
}
//--------------------------------------------------------------------------------------------------
/// Input fields of the downstream task supplied when it takes the whole upstream output
//--------------------------------------------------------------------------------------------------
@@ -55,9 +55,10 @@ std::optional<RimWorkflowPort> inputPort( const QJsonObject& taskType, const QSt
std::optional<RimWorkflowPort> outputPort( const QJsonObject& taskType, const QString& name );
bool isCompatible( const QJsonObject& produced, const QJsonObject& producedRoot, const QJsonObject& expected, const QJsonObject& expectedRoot );
bool isCompatible( const RimWorkflowPort& produced, const RimWorkflowPort& expected );
bool isAny( const QJsonObject& schema, const QJsonObject& rootSchema );
QString typeName( const QJsonObject& schema, const QJsonObject& rootSchema );
bool isCompatible( const RimWorkflowPort& produced, const RimWorkflowPort& expected );
bool isAny( const QJsonObject& schema, const QJsonObject& rootSchema );
QString typeName( const QJsonObject& schema, const QJsonObject& rootSchema );
QJsonObject portTypes( const std::vector<RimWorkflowPort>& ports );
QStringList coveredByWholeOutput( const QJsonObject& upstreamTaskType, const QJsonObject& downstreamTaskType );
bool wouldCreateCycle( const RimWorkflowDefinition& definition, const QString& from, const QString& to );
@@ -159,6 +159,10 @@ TEST( RimWorkflowDefinition, graphFromDefinition )
EXPECT_EQ( "resinsight", ports[1].toObject().value( "name" ).toString() );
EXPECT_TRUE( ports[1].toObject().value( "wired" ).toBool() );
// Type names for labelling the ports
EXPECT_EQ( "RipsInstance", exportTask.value( "input_types" ).toObject().value( "resinsight" ).toString() );
EXPECT_EQ( "str", exportTask.value( "output_types" ).toObject().value( "export_file" ).toString() );
// 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() );
@@ -82,6 +82,8 @@ TEST( RimWorkflowDescribeTools, configFieldTypesAndValues )
EXPECT_EQ( "Person to greet", name.value( "description" ).toString() );
EXPECT_FALSE( name.value( "required" ).toBool() );
EXPECT_EQ( "world", fieldByName( greetB, "name" ).value( "default" ).toString() );
EXPECT_EQ( "str", greetA.value( "input_types" ).toObject().value( "name" ).toString() );
EXPECT_EQ( "int", greetA.value( "input_types" ).toObject().value( "times" ).toString() );
const QJsonObject times = fieldByName( greetA, "times" );
EXPECT_EQ( "integer", times.value( "type" ).toString() );
@@ -122,6 +122,7 @@ struct PortInfo
QString key;
QString label;
QString toolTip;
QString typeName{};
Style style = Style::Normal;
};
@@ -369,15 +370,32 @@ void GraphNode::addPorts( const std::vector<PortInfo>& ports, bool output )
auto* port = new PortItem( this, info, output, m_isConfig, QPointF( x, y ) );
portMap.insert( info.key, port );
QFont font;
font.setPointSize( 9 );
const QString display = QFontMetrics( font ).elidedText( info.label, Qt::ElideRight, m_isConfig ? m_width - 24 : m_width / 2 - 18 );
auto* text = new QGraphicsTextItem( display, this );
text->setFont( font );
text->setToolTip( info.toolTip.isEmpty() ? info.label : info.toolTip );
text->setDefaultTextColor( info.style == PortInfo::Style::Missing ? missingPortColor : QColor( 45, 65, 80 ) );
text->setPos( output ? m_width - text->boundingRect().width() - 12 : 10, y - ( m_isConfig || !output ? 20 : 13 ) );
text->setAcceptedMouseButtons( Qt::NoButton );
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* text = new QGraphicsTextItem( QFontMetrics( font ).elidedText( content, Qt::ElideRight, textWidth ), this );
text->setFont( font );
text->setToolTip( toolTip );
text->setDefaultTextColor( color );
text->setPos( output ? m_width - text->boundingRect().width() - 12 : 10, top );
text->setAcceptedMouseButtons( Qt::NoButton );
};
QFont labelFont;
labelFont.setPointSize( 9 );
const QColor labelColor = info.style == PortInfo::Style::Missing ? missingPortColor : QColor( 45, 65, 80 );
addText( info.label, labelFont, labelColor, y - ( m_isConfig || !output || showType ? 20 : 13 ) );
// The type goes on a second, smaller line below the label
if ( showType )
{
QFont typeFont;
typeFont.setPointSize( 7 );
typeFont.setItalic( true );
addText( info.typeName, typeFont, QColor( 110, 120, 135 ), y - 3 );
}
}
}
@@ -681,8 +699,9 @@ std::pair<std::vector<PortInfo>, std::vector<PortInfo>> editModePorts( const QJs
if ( field.isEmpty() ) hasWholeInput = true;
PortInfo info;
info.key = keyForField( field );
info.label = field.isEmpty() ? "input" : field;
info.key = keyForField( field );
info.label = field.isEmpty() ? "input" : field;
info.typeName = port.value( "type" ).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" );
@@ -697,7 +716,11 @@ std::pair<std::vector<PortInfo>, std::vector<PortInfo>> editModePorts( const QJs
const QJsonObject edge = value.toObject();
if ( edge.value( "to" ).toString() == name && edge.value( "input" ).toString().isEmpty() && !hasWholeInput )
{
inputs.insert( inputs.begin(), PortInfo{ .key = "model", .label = "input", .toolTip = {} } );
inputs.insert( inputs.begin(),
PortInfo{ .key = "model",
.label = "input",
.toolTip = {},
.typeName = task.value( "input_types" ).toObject().value( "" ).toString() } );
hasWholeInput = true;
}
}
@@ -708,25 +731,37 @@ std::pair<std::vector<PortInfo>, std::vector<PortInfo>> 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.key = keyForField( field );
info.label = field.isEmpty() ? "output" : field;
info.typeName = port.value( "type" ).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 = {} } );
if ( outputs.empty() )
outputs.push_back( PortInfo{ .key = "model",
.label = "output",
.toolTip = {},
.typeName = task.value( "output_types" ).toObject().value( "" ).toString() } );
return { inputs, outputs };
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
std::vector<PortInfo> portInfos( const QStringList& keys, bool output )
std::vector<PortInfo> portInfos( const QStringList& keys, bool output, const QJsonObject& types )
{
std::vector<PortInfo> result;
for ( const QString& key : keys )
result.push_back( PortInfo{ .key = key, .label = key == "model" ? ( output ? "output" : "input" ) : key.mid( 6 ), .toolTip = {} } );
{
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 } );
}
return result;
}
} // namespace
@@ -922,8 +957,8 @@ void RiuWorkflowGraphView::showGraph( const QJsonObject& graph, const QString& e
out.removeDuplicates();
in.sort();
out.sort();
inputs = portInfos( in, false );
outputs = portInfos( out, true );
inputs = portInfos( in, false, data.value( "input_types" ).toObject() );
outputs = portInfos( out, true, data.value( "output_types" ).toObject() );
}
auto* node = new GraphNode( name, inputs, outputs );
@@ -943,7 +978,8 @@ void RiuWorkflowGraphView::showGraph( const QJsonObject& graph, const QString& e
if ( !configFields.value( name ).isEmpty() )
{
auto* configNode = new GraphNode( name, {}, portInfos( configFields.value( name ), true ), true );
auto* configNode =
new GraphNode( name, {}, portInfos( configFields.value( name ), true, data.value( "input_types" ).toObject() ), true );
m_scene->addItem( configNode );
m_configNodes.insert( name, configNode );
}
+4
View File
@@ -154,6 +154,10 @@ in a central dock tab:
- a status line for load errors, read-only reasons, validation results and
unsaved changes.
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.
`RiuWorkflowGraphLayout` computes the layout: topological ranks with a Kahn
sort and longest-path ranks, then one barycenter pass to order nodes. It
tolerates cycles. The layout is always automatic; nodes moved by hand keep