Files
ResInsight/ApplicationLibCode/UserInterface/RiuWorkflowGraphView.cpp
T
Kristian Bendiksen 8baad6f878 Workflow UI: Support collect flattening
Allow list and dict outputs, including mapped task outputs, to be
collected into list inputs, and dicts to be merged into dict inputs,
matching taskmaestro's collect flattening. Label flattened members in
the graph and document the rules.
2026-10-30 16:17:03 +01:00

2058 lines
86 KiB
C++

/////////////////////////////////////////////////////////////////////////////////
//
// Copyright (C) 2026- Equinor ASA
//
// ResInsight is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or
// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
// at <http://www.gnu.org/licenses/gpl.html> for more details.
//
/////////////////////////////////////////////////////////////////////////////////
#include "RiuWorkflowGraphView.h"
#include "RiuWorkflowGraphLayout.h"
#include <QContextMenuEvent>
#include <QDragEnterEvent>
#include <QFontMetrics>
#include <QGraphicsEllipseItem>
#include <QGraphicsPathItem>
#include <QGraphicsPolygonItem>
#include <QGraphicsRectItem>
#include <QGraphicsScene>
#include <QGraphicsTextItem>
#include <QIcon>
#include <QJsonValue>
#include <QKeyEvent>
#include <QMimeData>
#include <QMouseEvent>
#include <QPainter>
#include <QPainterPath>
#include <QPainterPathStroker>
#include <QPen>
#include <QResizeEvent>
#include <QStyleOptionGraphicsItem>
#include <QTextDocument>
#include <QToolTip>
#include <QWheelEvent>
#include <QtMath>
#include <algorithm>
#include <cmath>
#include <limits>
#include <optional>
#include <vector>
namespace
{
constexpr qreal nodeWidth = 240.0;
constexpr qreal configWidth = 190.0;
constexpr qreal portStep = 40.0;
constexpr qreal portTop = 51.0;
constexpr qreal subtitleGap = 16.0;
constexpr qreal stackOffset = 5.0;
constexpr qreal columnStep = 650.0;
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 );
const QColor unknownBackground( 232, 232, 232 );
const QColor configBorder( 140, 105, 45 );
const QColor configBackground( 253, 244, 218 );
const QColor configPortColor( 170, 110, 35 );
const QColor inputPortColor( 75, 145, 95 );
const QColor outputPortColor( 50, 115, 165 );
const QColor missingPortColor( 190, 60, 60 );
const QColor validTargetColor( 40, 170, 70 );
const QColor invalidTargetColor( 175, 175, 175 );
const QColor errorColor( 190, 45, 45 );
const QColor warningColor( 200, 130, 20 );
const QColor edgeColor( 75, 95, 115 );
const QColor selectedEdgeColor( 30, 120, 220 );
const QColor mapColor( 95, 80, 160 );
const QColor conditionColor( 135, 75, 150 );
constexpr qreal diamondMinHeight = 130.0;
constexpr qreal diamondPortStep = 24.0;
QString keyForField( const QString& field )
{
return field.isEmpty() ? "model" : "field:" + field;
}
QString fieldForKey( const QString& key )
{
return key == "model" ? QString() : key.mid( 6 );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
QStringList fieldPorts( const QJsonArray& fields )
{
QStringList keys;
for ( const QJsonValue& field : fields )
{
if ( field.isString() && !field.toString().isEmpty() ) keys.append( "field:" + field.toString() );
}
keys.removeDuplicates();
keys.sort();
return keys;
}
} // namespace
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
namespace RiuWorkflowGraphViewItems
{
class GraphEdge;
//==================================================================================================
/// An icon drawn at the resolution it is shown at, so it stays sharp when the view is zoomed
//==================================================================================================
class IconItem : public QGraphicsItem
{
public:
IconItem( const QIcon& icon, qreal size, QGraphicsItem* parent );
QRectF boundingRect() const override;
void paint( QPainter* painter, const QStyleOptionGraphicsItem* option, QWidget* widget ) override;
private:
QIcon m_icon;
qreal m_size;
};
//==================================================================================================
/// How a port is drawn. Hollow: optional and not used. Missing: required and not provided.
//==================================================================================================
struct PortInfo
{
enum class Style
{
Normal,
Hollow,
Missing
};
QString key;
QString label;
QString toolTip;
QString typeName{};
QString iconResource{};
Style style = Style::Normal;
bool collect = false;
bool firstOf = false;
};
//==================================================================================================
///
//==================================================================================================
class PortItem : public QGraphicsEllipseItem
{
public:
enum class Highlight
{
None,
Valid,
Invalid
};
PortItem( GraphNode* node, const PortInfo& info, bool output, bool config, const QPointF& center );
GraphNode* node() const { return m_node; }
QString key() const { return m_key; }
QString field() const { return fieldForKey( m_key ); }
bool isOutput() const { return m_output; }
bool isConfig() const { return m_config; }
void setHighlight( Highlight highlight, const QString& reason = {} );
private:
GraphNode* m_node;
QString m_key;
bool m_output;
bool m_config;
QPointF m_center;
QBrush m_brush;
QPen m_pen;
QString m_toolTip;
};
//==================================================================================================
///
//==================================================================================================
class GraphNode : public QGraphicsRectItem
{
public:
GraphNode( const QString& name,
const std::vector<PortInfo>& inputs,
const std::vector<PortInfo>& outputs,
bool isConfig = false,
const QString& subtitle = {},
bool diamond = false );
qreal height() const;
QPointF portPosition( const QString& key, bool output ) const;
QPointF controlAnchor( bool outgoing ) const;
void setBranches( const QStringList& branches );
void paint( QPainter* painter, const QStyleOptionGraphicsItem* option, QWidget* widget ) override;
QPainterPath shape() const override;
void addEdge( GraphEdge* edge );
QString name() const;
bool isConfig() const;
std::vector<PortItem*> ports() const;
void setConfigValue( const QString& fieldName, const QString& value );
void setTaskState( const QString& state, const QString& error = {} );
void setItemState( const QString& item, const QString& state, const QString& error = {} );
void setSubtitleToolTip( const QString& toolTip );
void setTitle( const QString& title, const QString& toolTip );
void setIssues( const QJsonArray& issues );
void setUnknownType( bool unknown );
protected:
QVariant itemChange( GraphicsItemChange change, const QVariant& value ) override;
private:
void addPorts( const std::vector<PortInfo>& ports, bool output );
void addDiamondPorts( const std::vector<PortInfo>& ports, bool output );
void applyStyle();
QPolygonF diamondPolygon( const QRectF& bounds ) const;
QString m_name;
bool m_isConfig;
bool m_diamond;
qreal m_width;
QMap<QString, PortItem*> m_inputs;
QMap<QString, PortItem*> m_outputs;
QMap<QString, QGraphicsTextItem*> m_configValues;
QMap<QString, qreal> m_configValueRight; // Where the value must end to stay clear of the type
std::vector<GraphEdge*> m_edges;
QGraphicsTextItem* m_title = nullptr;
QGraphicsTextItem* m_stateLabel = nullptr;
QGraphicsTextItem* m_issueLabel = nullptr;
QGraphicsTextItem* m_subtitle = nullptr;
QGraphicsTextItem* m_branches = nullptr;
std::vector<QAbstractGraphicsShapeItem*> m_stack;
qreal m_portTop = portTop;
QMap<QString, QString> m_itemStates;
QMap<QString, QString> m_itemErrors;
QString m_state;
QString m_stateError;
QString m_baseToolTip;
QString m_issueText;
int m_errorCount = 0;
int m_warningCount = 0;
bool m_unknownType = false;
};
//==================================================================================================
///
//==================================================================================================
class GraphEdge : public QGraphicsPathItem
{
public:
GraphEdge( GraphNode* from, QString outputKey, GraphNode* to, QString inputKey, bool isConfig, bool selectable );
// A control edge: `to` only runs when the condition on the output of `from` holds
GraphEdge( GraphNode* from, GraphNode* to, const QString& condition, bool selectable );
void updatePath();
QPainterPath shape() const override;
bool isConfig() const { return m_isConfig; }
bool isControl() const { return m_isControl; }
GraphNode* fromNode() const { return m_from; }
GraphNode* toNode() const { return m_to; }
QString outputField() const { return fieldForKey( m_outputKey ); }
QString inputField() const { return fieldForKey( m_inputKey ); }
void setLabel( const QString& label, const QString& toolTip );
protected:
QVariant itemChange( GraphicsItemChange change, const QVariant& value ) override;
private:
void applyPen();
GraphNode* m_from;
GraphNode* m_to;
QString m_outputKey;
QString m_inputKey;
bool m_isConfig;
bool m_isControl = false;
QGraphicsPolygonItem* m_arrow;
QGraphicsTextItem* m_label = nullptr;
};
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
IconItem::IconItem( const QIcon& icon, qreal size, QGraphicsItem* parent )
: QGraphicsItem( parent )
, m_icon( icon )
, m_size( size )
{
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
QRectF IconItem::boundingRect() const
{
return QRectF( 0, 0, m_size, m_size );
}
//--------------------------------------------------------------------------------------------------
/// Renders the icon at the device pixel size of the item. SVG icons are rendered at that size,
/// bitmaps are scaled from the largest available size.
//--------------------------------------------------------------------------------------------------
void IconItem::paint( QPainter* painter, const QStyleOptionGraphicsItem* /*option*/, QWidget* /*widget*/ )
{
const qreal zoom = QStyleOptionGraphicsItem::levelOfDetailFromTransform( painter->worldTransform() );
const qreal pixelSize = m_size * zoom * ( painter->device() ? painter->device()->devicePixelRatioF() : 1.0 );
const int extent = std::max( 1, qCeil( pixelSize ) );
const QPixmap pixmap = m_icon.pixmap( QSize( extent, extent ), 1.0 );
painter->setRenderHint( QPainter::SmoothPixmapTransform );
painter->drawPixmap( boundingRect(), pixmap, QRectF( pixmap.rect() ) );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
PortItem::PortItem( GraphNode* node, const PortInfo& info, bool output, bool config, const QPointF& center )
: QGraphicsEllipseItem( center.x() - portRadius, center.y() - portRadius, 2 * portRadius, 2 * portRadius, node )
, m_node( node )
, m_key( info.key )
, m_output( output )
, m_config( config )
, m_center( center )
, m_toolTip( info.toolTip.isEmpty() ? info.label : info.toolTip )
{
const QColor color = config ? configPortColor : ( output ? outputPortColor : inputPortColor );
switch ( info.style )
{
case PortInfo::Style::Normal:
m_brush = color;
m_pen = Qt::NoPen;
break;
case PortInfo::Style::Hollow:
m_brush = QColor( 255, 255, 255 );
m_pen = QPen( color, 1.5 );
break;
case PortInfo::Style::Missing:
m_brush = QColor( 255, 255, 255 );
m_pen = QPen( missingPortColor, 2.0 );
break;
}
setHighlight( Highlight::None );
setAcceptedMouseButtons( Qt::NoButton );
setZValue( 2 );
// An outer ring marks inputs that collect several connections, a dashed one alternatives
if ( info.collect || info.firstOf )
{
const qreal ring = portRadius + 3.0;
auto* outer = new QGraphicsEllipseItem( center.x() - ring, center.y() - ring, 2 * ring, 2 * ring, node );
outer->setPen( QPen( color, 1.2, info.firstOf ? Qt::DotLine : Qt::SolidLine ) );
outer->setBrush( Qt::NoBrush );
outer->setAcceptedMouseButtons( Qt::NoButton );
outer->setZValue( 1.5 );
}
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void PortItem::setHighlight( Highlight highlight, const QString& reason )
{
const qreal radius = highlight == Highlight::Valid ? portRadius * 1.8 : portRadius;
setRect( m_center.x() - radius, m_center.y() - radius, 2 * radius, 2 * radius );
switch ( highlight )
{
case Highlight::None:
setBrush( m_brush );
setPen( m_pen );
setToolTip( m_toolTip );
break;
case Highlight::Valid:
setBrush( validTargetColor );
setPen( QPen( validTargetColor.darker( 130 ), 1.5 ) );
setToolTip( m_toolTip );
break;
case Highlight::Invalid:
setBrush( invalidTargetColor );
setPen( Qt::NoPen );
setToolTip( reason.isEmpty() ? m_toolTip : m_toolTip + "\n\nCannot connect: " + reason );
break;
}
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
GraphNode::GraphNode( const QString& name,
const std::vector<PortInfo>& inputs,
const std::vector<PortInfo>& outputs,
bool isConfig,
const QString& subtitle,
bool diamond )
: m_name( name )
, m_isConfig( isConfig )
, m_diamond( diamond && !isConfig )
, m_width( isConfig ? configWidth : nodeWidth )
, m_portTop( subtitle.isEmpty() ? portTop : portTop + subtitleGap )
, m_baseToolTip( name )
{
const qsizetype rows = std::max( { qsizetype( 1 ), qsizetype( inputs.size() ), qsizetype( outputs.size() ) } );
if ( m_diamond )
setRect( 0, 0, m_width, std::max( diamondMinHeight, ( rows - 1 ) * diamondPortStep + 60.0 ) );
else
setRect( 0, 0, m_width, m_portTop + portStep * rows + 12 );
setFlags( ItemIsMovable | ItemIsSelectable | ItemSendsGeometryChanges );
setZValue( 1 );
m_title = new QGraphicsTextItem( this );
QFont titleFont;
titleFont.setBold( true );
m_title->setFont( titleFont );
m_title->setPos( 10, 4 );
m_title->setAcceptedMouseButtons( Qt::NoButton );
if ( !isConfig )
{
m_stateLabel = new QGraphicsTextItem( this );
QFont font;
font.setPointSize( 8 );
font.setBold( true );
m_stateLabel->setFont( font );
m_stateLabel->setPos( m_width - 91, 5 );
m_stateLabel->setAcceptedMouseButtons( Qt::NoButton );
m_issueLabel = new QGraphicsTextItem( this );
m_issueLabel->setFont( font );
// A diamond has its badge beside the bottom vertex, where control edges leave
m_issueLabel->setPos( m_diamond ? m_width / 2 + 10 : 8, rect().height() - ( m_diamond ? 16 : 2 ) );
m_issueLabel->setAcceptedMouseButtons( Qt::NoButton );
}
// A mapped task runs once per item: drawn as a stack of boxes with a line saying what it maps over
if ( !subtitle.isEmpty() )
{
for ( int depth = 2; depth >= 1; --depth )
{
const QRectF bounds = rect().translated( depth * stackOffset, depth * stackOffset );
QAbstractGraphicsShapeItem* card = nullptr;
if ( m_diamond )
card = new QGraphicsPolygonItem( diamondPolygon( bounds ), this );
else
card = new QGraphicsRectItem( bounds, this );
card->setFlag( ItemStacksBehindParent );
card->setAcceptedMouseButtons( Qt::NoButton );
m_stack.push_back( card );
}
m_subtitle = new QGraphicsTextItem( this );
QFont font;
font.setPointSize( 8 );
font.setItalic( true );
m_subtitle->setFont( font );
m_subtitle->setDefaultTextColor( mapColor );
m_subtitle->setPlainText( QFontMetrics( font ).elidedText( subtitle, Qt::ElideRight, m_diamond ? 100 : m_width - 20 ) );
if ( m_diamond )
m_subtitle->setPos( ( m_width - m_subtitle->boundingRect().width() ) / 2, rect().height() / 2 - 42 );
else
m_subtitle->setPos( 10, 24 );
m_subtitle->setAcceptedMouseButtons( Qt::NoButton );
}
if ( m_diamond )
{
addDiamondPorts( inputs, false );
addDiamondPorts( outputs, true );
}
else
{
addPorts( inputs, false );
addPorts( outputs, true );
}
setTitle( isConfig ? "Config: " + name : name, {} );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
qreal GraphNode::height() const
{
return rect().height();
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void GraphNode::setTitle( const QString& title, const QString& toolTip )
{
const qreal available = m_diamond ? m_width * 0.62 : m_width - ( m_isConfig ? 20 : 100 );
m_title->setPlainText( QFontMetrics( m_title->font() ).elidedText( title, Qt::ElideRight, static_cast<int>( available ) ) );
m_title->setToolTip( title );
if ( m_diamond ) m_title->setPos( ( m_width - m_title->boundingRect().width() ) / 2, rect().height() / 2 - 24 );
if ( !toolTip.isEmpty() ) m_baseToolTip = toolTip;
applyStyle();
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void GraphNode::addPorts( const std::vector<PortInfo>& ports, bool output )
{
auto& portMap = output ? m_outputs : m_inputs;
for ( size_t row = 0; row < ports.size(); ++row )
{
const PortInfo& info = ports[row];
const qreal x = output ? m_width : 0.0;
const qreal y = m_portTop + row * portStep;
auto* port = new PortItem( this, info, output, m_isConfig, QPointF( x, y ) );
portMap.insert( info.key, port );
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,
qreal indent = 0.0,
qreal maxWidth = std::numeric_limits<qreal>::max() )
{
const qreal width = std::min( textWidth, maxWidth ) - indent;
auto* text = new QGraphicsTextItem( QFontMetrics( font ).elidedText( content, Qt::ElideRight, width ), this );
text->setFont( font );
text->setToolTip( toolTip );
text->setDefaultTextColor( color );
text->setPos( output ? m_width - text->boundingRect().width() - 12 : 10 + indent, top );
text->setAcceptedMouseButtons( Qt::NoButton );
return text;
};
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 );
const QIcon icon( info.iconResource );
const bool showIcon = !info.iconResource.isEmpty() && !icon.isNull();
// Config boxes show the value on the same line, so the type gets at most half of it
const qreal maxTypeWidth = m_isConfig ? textWidth / 2 : std::numeric_limits<qreal>::max();
auto* typeText = addText( info.typeName, typeFont, QColor( 110, 120, 135 ), y - 3, showIcon ? iconSize + 2 : 0.0, maxTypeWidth );
qreal typeLeft = typeText->mapRectToParent( typeText->boundingRect() ).left();
// 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* iconItem = new IconItem( icon, iconSize, this );
iconItem->setPos( iconX, textRect.center().y() - iconSize / 2.0 );
iconItem->setToolTip( toolTip );
iconItem->setAcceptedMouseButtons( Qt::NoButton );
typeLeft = iconX;
}
if ( m_isConfig && output ) m_configValueRight.insert( fieldForKey( info.key ), typeLeft );
}
}
}
//--------------------------------------------------------------------------------------------------
/// Ports of a switch task sit on the left and right sides of its diamond, labels go in tooltips
//--------------------------------------------------------------------------------------------------
void GraphNode::addDiamondPorts( const std::vector<PortInfo>& ports, bool output )
{
auto& portMap = output ? m_outputs : m_inputs;
const qreal halfHeight = rect().height() / 2;
for ( size_t row = 0; row < ports.size(); ++row )
{
const qreal offset = ( row - ( ports.size() - 1 ) / 2.0 ) * diamondPortStep;
const qreal inset = m_width / 2 * std::abs( offset ) / halfHeight;
const qreal x = output ? m_width - inset : inset;
portMap.insert( ports[row].key, new PortItem( this, ports[row], output, false, QPointF( x, halfHeight + offset ) ) );
}
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
QPolygonF GraphNode::diamondPolygon( const QRectF& bounds ) const
{
return QPolygonF{ QPointF( bounds.left(), bounds.center().y() ),
QPointF( bounds.center().x(), bounds.top() ),
QPointF( bounds.right(), bounds.center().y() ),
QPointF( bounds.center().x(), bounds.bottom() ),
QPointF( bounds.left(), bounds.center().y() ) };
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void GraphNode::paint( QPainter* painter, const QStyleOptionGraphicsItem* option, QWidget* widget )
{
if ( !m_diamond )
{
QGraphicsRectItem::paint( painter, option, widget );
return;
}
painter->setPen( pen() );
painter->setBrush( brush() );
painter->drawPolygon( diamondPolygon( rect() ) );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
QPainterPath GraphNode::shape() const
{
if ( !m_diamond ) return QGraphicsRectItem::shape();
QPainterPath path;
path.addPolygon( diamondPolygon( rect() ) );
return path;
}
//--------------------------------------------------------------------------------------------------
/// Where control edges leave (the bottom) or enter (the top) the node, in scene coordinates
//--------------------------------------------------------------------------------------------------
QPointF GraphNode::controlAnchor( bool outgoing ) const
{
return mapToScene( QPointF( m_width / 2, outgoing ? rect().height() : 0.0 ) );
}
//--------------------------------------------------------------------------------------------------
/// The branch labels of a switch task, shown below its name
//--------------------------------------------------------------------------------------------------
void GraphNode::setBranches( const QStringList& branches )
{
if ( branches.isEmpty() ) return;
if ( !m_branches )
{
m_branches = new QGraphicsTextItem( this );
QFont font;
font.setPointSize( 8 );
font.setItalic( true );
m_branches->setFont( font );
m_branches->setDefaultTextColor( conditionColor );
m_branches->setAcceptedMouseButtons( Qt::NoButton );
}
const QString text = branches.join( " | " );
m_branches->setPlainText( QFontMetrics( m_branches->font() ).elidedText( text, Qt::ElideRight, static_cast<int>( m_width * 0.5 ) ) );
m_branches->setToolTip( "Branches: " + branches.join( ", " ) );
const qreal x = m_diamond ? ( m_width - m_branches->boundingRect().width() ) / 2 : 10;
m_branches->setPos( x, m_diamond ? rect().height() / 2 - 4 : 24 );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
QPointF GraphNode::portPosition( const QString& key, bool output ) const
{
const auto& ports = output ? m_outputs : m_inputs;
auto* port = ports.value( key, nullptr );
return port ? port->mapToScene( port->rect().center() ) : scenePos();
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void GraphNode::addEdge( GraphEdge* edge )
{
m_edges.push_back( edge );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
QString GraphNode::name() const
{
return m_name;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
bool GraphNode::isConfig() const
{
return m_isConfig;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
std::vector<PortItem*> GraphNode::ports() const
{
std::vector<PortItem*> result;
for ( PortItem* port : m_inputs )
result.push_back( port );
for ( PortItem* port : m_outputs )
result.push_back( port );
return result;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void GraphNode::setTaskState( const QString& state, const QString& error )
{
if ( m_isConfig ) return;
m_state = state;
m_stateError = error;
if ( state.isEmpty() )
{
m_itemStates.clear();
m_itemErrors.clear();
}
applyStyle();
}
//--------------------------------------------------------------------------------------------------
/// The state of one item of a mapped task
//--------------------------------------------------------------------------------------------------
void GraphNode::setItemState( const QString& item, const QString& state, const QString& error )
{
if ( m_isConfig ) return;
m_itemStates[item] = state;
if ( error.isEmpty() )
m_itemErrors.remove( item );
else
m_itemErrors[item] = error;
applyStyle();
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void GraphNode::setSubtitleToolTip( const QString& toolTip )
{
if ( m_subtitle ) m_subtitle->setToolTip( toolTip );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void GraphNode::setIssues( const QJsonArray& issues )
{
if ( m_isConfig ) return;
m_errorCount = 0;
m_warningCount = 0;
QStringList lines;
for ( const QJsonValue& value : issues )
{
const QJsonObject issue = value.toObject();
const bool isError = issue.value( "severity" ).toString() != "warning";
( isError ? m_errorCount : m_warningCount )++;
lines.append( QString( "%1 %2" ).arg( isError ? "Error:" : "Warning:", issue.value( "message" ).toString() ) );
}
m_issueText = lines.join( "\n" );
applyStyle();
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void GraphNode::setUnknownType( bool unknown )
{
m_unknownType = unknown;
applyStyle();
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void GraphNode::applyStyle()
{
if ( m_isConfig )
{
setPen( QPen( configBorder ) );
setBrush( configBackground );
setToolTip( m_baseToolTip );
return;
}
QColor border = taskBorder;
QColor background = m_unknownType ? unknownBackground : taskBackground;
qreal width = 1.0;
auto style = Qt::SolidLine;
if ( m_state == "running" )
{
border = QColor( 158, 112, 16 );
background = QColor( 255, 241, 189 );
}
else if ( m_state == "completed" )
{
border = QColor( 42, 120, 65 );
background = QColor( 215, 242, 222 );
}
else if ( m_state == "failed" )
{
border = QColor( 165, 50, 50 );
background = QColor( 253, 225, 225 );
}
else if ( m_state == "interrupted" )
{
border = QColor( 140, 85, 50 );
background = QColor( 245, 228, 210 );
}
else if ( m_state == "skipped" )
{
border = QColor( 150, 150, 150 );
background = QColor( 240, 240, 240 );
style = Qt::DashLine;
}
else if ( m_errorCount > 0 || m_warningCount > 0 )
{
border = m_errorCount > 0 ? errorColor : warningColor;
style = Qt::DashLine;
}
if ( !m_state.isEmpty() || m_errorCount > 0 || m_warningCount > 0 ) width = 2.0;
if ( isSelected() ) width += 1.0;
QPen pen( border, width );
pen.setStyle( style );
setPen( pen );
setBrush( background );
for ( QAbstractGraphicsShapeItem* card : m_stack )
{
card->setPen( QPen( border, 1.0 ) );
card->setBrush( background.darker( 104 ) );
}
// Item progress of a mapped task, such as "Running 3✓ 1✗"
int done = 0;
int failed = 0;
for ( const QString& itemState : m_itemStates )
{
if ( itemState == "completed" ) ++done;
if ( itemState == "failed" ) ++failed;
}
if ( m_stateLabel )
{
QString text = m_state.isEmpty() ? "" : m_state.at( 0 ).toUpper() + m_state.mid( 1 );
if ( !m_itemStates.isEmpty() )
{
text += QString( " %1/%2" ).arg( done ).arg( m_itemStates.size() );
if ( failed > 0 ) text += QString::fromUtf8( " ✗%1" ).arg( failed );
}
m_stateLabel->setDefaultTextColor( border );
m_stateLabel->setPlainText( text );
if ( m_diamond )
m_stateLabel->setPos( ( m_width - m_stateLabel->boundingRect().width() ) / 2, rect().height() / 2 + 12 );
else
m_stateLabel->setPos( m_width - m_stateLabel->boundingRect().width() - 6, 5 );
}
if ( m_issueLabel )
{
QStringList badge;
if ( m_errorCount > 0 ) badge << QString( "%1 error%2" ).arg( m_errorCount ).arg( m_errorCount > 1 ? "s" : "" );
if ( m_warningCount > 0 ) badge << QString( "%1 warning%2" ).arg( m_warningCount ).arg( m_warningCount > 1 ? "s" : "" );
m_issueLabel->setDefaultTextColor( m_errorCount > 0 ? errorColor : warningColor );
m_issueLabel->setPlainText( badge.isEmpty() ? "" : QString::fromUtf8( "\u26a0 " ) + badge.join( ", " ) );
m_issueLabel->setToolTip( m_issueText );
}
QStringList toolTip{ m_baseToolTip };
if ( !m_stateError.isEmpty() ) toolTip << m_stateError;
if ( !m_itemStates.isEmpty() )
{
QStringList items;
for ( auto it = m_itemStates.cbegin(); it != m_itemStates.cend(); ++it )
{
QString line = QString( "%1: %2" ).arg( it.key(), it.value() );
if ( m_itemErrors.contains( it.key() ) ) line += " - " + m_itemErrors.value( it.key() );
items << line;
}
toolTip << "Items:\n" + items.join( "\n" );
}
if ( !m_issueText.isEmpty() ) toolTip << m_issueText;
setToolTip( toolTip.join( "\n\n" ) );
setOpacity( m_state == "skipped" ? 0.6 : 1.0 );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void GraphNode::setConfigValue( const QString& fieldName, const QString& value )
{
if ( !m_isConfig ) return;
auto* port = m_outputs.value( "field:" + fieldName, nullptr );
if ( !port ) return;
auto* text = m_configValues.value( fieldName, nullptr );
if ( !text )
{
text = new QGraphicsTextItem( this );
QFont font;
font.setPointSize( 8 );
text->setFont( font );
text->setDefaultTextColor( QColor( 125, 80, 30 ) );
text->setPos( 10, port->rect().center().y() - 2 );
text->setAcceptedMouseButtons( Qt::NoButton );
m_configValues.insert( fieldName, text );
}
// The text item adds a margin on both sides of the text
const qreal margin = 2 * text->document()->documentMargin();
const qreal right = m_configValueRight.value( fieldName, m_width - 14 ) - 6;
const qreal available = std::max( 0.0, right - text->pos().x() - margin );
text->setPlainText( QFontMetrics( text->font() ).elidedText( value, Qt::ElideRight, static_cast<int>( available ) ) );
text->setToolTip( fieldName + ": " + value );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
QVariant GraphNode::itemChange( GraphicsItemChange change, const QVariant& value )
{
if ( change == ItemPositionHasChanged )
{
for ( GraphEdge* edge : m_edges )
edge->updatePath();
if ( scene() ) scene()->setSceneRect( scene()->sceneRect().united( scene()->itemsBoundingRect().adjusted( -50, -50, 50, 50 ) ) );
}
else if ( change == ItemSelectedHasChanged && m_title )
{
applyStyle();
}
return QGraphicsRectItem::itemChange( change, value );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
GraphEdge::GraphEdge( GraphNode* from, QString outputKey, GraphNode* to, QString inputKey, bool isConfig, bool selectable )
: m_from( from )
, m_to( to )
, m_outputKey( std::move( outputKey ) )
, m_inputKey( std::move( inputKey ) )
, m_isConfig( isConfig )
, m_arrow( new QGraphicsPolygonItem( this ) )
{
setZValue( -1 );
if ( selectable )
{
setFlag( ItemIsSelectable );
setToolTip( QString( "%1.%2 \u2192 %3.%4" )
.arg( from->name(),
outputField().isEmpty() ? "output" : outputField(),
to->name(),
inputField().isEmpty() ? "input" : inputField() ) );
}
m_arrow->setPen( Qt::NoPen );
applyPen();
m_from->addEdge( this );
m_to->addEdge( this );
updatePath();
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
GraphEdge::GraphEdge( GraphNode* from, GraphNode* to, const QString& condition, bool selectable )
: m_from( from )
, m_to( to )
, m_isConfig( false )
, m_isControl( true )
, m_arrow( new QGraphicsPolygonItem( this ) )
{
setZValue( -1 );
if ( selectable ) setFlag( ItemIsSelectable );
setToolTip( QString( "'%1' runs only when %2 of '%3' matches; otherwise it is skipped" ).arg( to->name(), condition, from->name() ) );
m_arrow->setPen( Qt::NoPen );
applyPen();
m_from->addEdge( this );
m_to->addEdge( this );
updatePath();
setLabel( condition, toolTip() );
}
//--------------------------------------------------------------------------------------------------
/// A label near the target, such as the index or key of a collected member
//--------------------------------------------------------------------------------------------------
void GraphEdge::setLabel( const QString& label, const QString& toolTip )
{
if ( label.isEmpty() ) return;
if ( !m_label )
{
m_label = new QGraphicsTextItem( this );
QFont font;
font.setPointSize( 8 );
m_label->setFont( font );
m_label->setDefaultTextColor( m_isControl ? conditionColor : edgeColor );
m_label->setAcceptedMouseButtons( Qt::NoButton );
}
m_label->setPlainText( label );
if ( !toolTip.isEmpty() ) m_label->setToolTip( toolTip );
updatePath();
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void GraphEdge::applyPen()
{
const QColor color = isSelected() ? selectedEdgeColor : ( m_isConfig ? configPortColor : m_isControl ? conditionColor : edgeColor );
QPen pen( color, isSelected() ? 3.0 : 1.7 );
if ( m_isConfig ) pen.setStyle( Qt::DashLine );
if ( m_isControl ) pen.setStyle( Qt::DashDotLine );
setPen( pen );
m_arrow->setBrush( color );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void GraphEdge::updatePath()
{
if ( m_isControl )
{
const QPointF start = m_from->controlAnchor( true );
const QPointF end = m_to->controlAnchor( false );
const qreal bend = std::max( 40.0, std::abs( end.y() - start.y() ) / 2.0 );
QPainterPath path( start );
path.cubicTo( start + QPointF( 0, bend ), end - QPointF( 0, bend ), end );
setPath( path );
m_arrow->setPolygon( QPolygonF{ end, end + QPointF( -5, -9 ), end + QPointF( 5, -9 ) } );
if ( m_label )
{
const QPointF anchor = path.pointAtPercent( 0.5 );
m_label->setPos( anchor.x() + 4, anchor.y() - m_label->boundingRect().height() / 2 );
}
return;
}
const QPointF start = m_from->portPosition( m_outputKey, true );
const QPointF end = m_to->portPosition( m_inputKey, false );
const qreal bend = std::max( 35.0, std::abs( end.x() - start.x() ) / 2.0 );
QPainterPath path( start );
path.cubicTo( start + QPointF( bend, 0 ), end - QPointF( bend, 0 ), end );
setPath( path );
m_arrow->setPolygon( QPolygonF{ end, end + QPointF( -9, -5 ), end + QPointF( -9, 5 ) } );
// Place the label on the curve shortly before the target
if ( m_label )
{
const QPointF anchor = path.pointAtPercent( 0.85 );
const QRectF bounds = m_label->boundingRect();
m_label->setPos( anchor.x() - bounds.width() / 2.0, anchor.y() - bounds.height() );
}
}
//--------------------------------------------------------------------------------------------------
/// A wide stroke, so the thin curve is easy to click
//--------------------------------------------------------------------------------------------------
QPainterPath GraphEdge::shape() const
{
QPainterPathStroker stroker;
stroker.setWidth( 12 );
return stroker.createStroke( path() );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
QVariant GraphEdge::itemChange( GraphicsItemChange change, const QVariant& value )
{
if ( change == ItemSelectedHasChanged ) applyPen();
return QGraphicsPathItem::itemChange( change, value );
}
} // namespace RiuWorkflowGraphViewItems
using namespace RiuWorkflowGraphViewItems;
namespace
{
//--------------------------------------------------------------------------------------------------
/// A port described by the graph JSON, with the type and description as the first tooltip lines
//--------------------------------------------------------------------------------------------------
PortInfo portFromJson( const QJsonObject& port, const QString& wholeLabel, QStringList* toolTip )
{
const QString field = port.value( "name" ).toString();
PortInfo info{ .key = keyForField( field ),
.label = field.isEmpty() ? wholeLabel : field,
.toolTip = {},
.typeName = port.value( "type" ).toString(),
.iconResource = port.value( "icon" ).toString() };
*toolTip = { QString( "%1: %2" ).arg( info.label, port.value( "type" ).toString( "any" ) ) };
if ( !port.value( "description" ).toString().isEmpty() ) *toolTip << port.value( "description" ).toString();
return info;
}
//--------------------------------------------------------------------------------------------------
/// The port of the whole input or output model of a task
//--------------------------------------------------------------------------------------------------
PortInfo wholeModelPort( const QJsonObject& task, bool output )
{
const QString prefix = output ? "output" : "input";
return PortInfo{ .key = "model",
.label = prefix,
.toolTip = {},
.typeName = task.value( prefix + "_types" ).toObject().value( "" ).toString(),
.iconResource = task.value( prefix + "_icons" ).toObject().value( "" ).toString() };
}
//--------------------------------------------------------------------------------------------------
/// Ports of a task in edit mode: every input and output the task type declares
//--------------------------------------------------------------------------------------------------
std::pair<std::vector<PortInfo>, std::vector<PortInfo>> editModePorts( const QJsonObject& task, const QJsonArray& edges )
{
const QString name = task.value( "name" ).toString();
std::vector<PortInfo> inputs;
bool hasWholeInput = false;
for ( const QJsonValue& value : task.value( "input_ports" ).toArray() )
{
const QJsonObject port = value.toObject();
const QString field = port.value( "name" ).toString();
const bool required = port.value( "required" ).toBool();
const bool provided = port.value( "wired" ).toBool() || port.value( "configured" ).toBool() || port.value( "covered" ).toBool();
// The whole input model is only shown when it is wired, or when it is the only way into the task
if ( field.isEmpty() )
{
if ( !port.value( "wired" ).toBool() && !task.value( "whole_input" ).toBool() ) continue;
hasWholeInput = true;
}
QStringList toolTip;
PortInfo info = portFromJson( port, "input", &toolTip );
toolTip << ( required ? "Required" : "Optional" );
if ( port.value( "configured" ).toBool() ) toolTip << "Set in the job configuration";
if ( port.value( "covered" ).toBool() ) toolTip << "Provided by a whole-output connection";
if ( port.value( "first_of" ).toBool() )
{
info.firstOf = true;
toolTip << "Alternatives: the output of the first connected task that ran is used";
}
if ( !port.value( "collect" ).toString().isEmpty() )
{
info.collect = true;
toolTip << ( port.value( "collect" ).toString() == "dict"
? "Connect several outputs to collect them, each under its own key; a dict output is merged by its own keys"
: "Connect several outputs to collect them into a list; list and dict outputs are flattened" );
}
info.toolTip = toolTip.join( "\n" );
if ( port.value( "map_over" ).toBool() )
{
info.toolTip = QString( "%1: %2\n%3\nThe task runs once per item; set the items in the job configuration" )
.arg( info.label, info.typeName, port.value( "description" ).toString() );
}
if ( !provided && !field.isEmpty() ) info.style = required ? PortInfo::Style::Missing : PortInfo::Style::Hollow;
inputs.push_back( info );
}
for ( const QJsonValue& value : edges )
{
const QJsonObject edge = value.toObject();
if ( edge.value( "kind" ).toString() == "when" ) continue;
if ( edge.value( "to" ).toString() == name && edge.value( "input" ).toString().isEmpty() && !hasWholeInput )
{
inputs.insert( inputs.begin(), wholeModelPort( task, false ) );
hasWholeInput = true;
}
}
std::vector<PortInfo> outputs;
for ( const QJsonValue& value : task.value( "output_ports" ).toArray() )
{
QStringList toolTip;
PortInfo info = portFromJson( value.toObject(), "output", &toolTip );
info.toolTip = toolTip.join( "\n" );
outputs.push_back( info );
}
if ( outputs.empty() ) outputs.push_back( wholeModelPort( task, true ) );
return { inputs, outputs };
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
std::vector<PortInfo> portInfos( const QStringList& keys, bool output, const QJsonObject& types, const QJsonObject& icons )
{
std::vector<PortInfo> 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,
.iconResource = icons.value( fieldForKey( key ) ).toString() } );
}
return result;
}
} // namespace
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
RiuWorkflowGraphView::RiuWorkflowGraphView( QWidget* parent )
: QGraphicsView( parent )
, m_scene( new QGraphicsScene( this ) )
{
setScene( m_scene );
setRenderHint( QPainter::Antialiasing );
setDragMode( QGraphicsView::ScrollHandDrag );
setTransformationAnchor( QGraphicsView::AnchorUnderMouse );
setAcceptDrops( true );
setFocusPolicy( Qt::StrongFocus );
connect( m_scene, &QGraphicsScene::selectionChanged, this, &RiuWorkflowGraphView::onSelectionChanged );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
RiuWorkflowGraphView::~RiuWorkflowGraphView() = default;
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RiuWorkflowGraphView::setEditable( bool editable )
{
m_editable = editable;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
bool RiuWorkflowGraphView::isEditable() const
{
return m_editable;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RiuWorkflowGraphView::setConnectionValidator( const ConnectionValidator& validator )
{
m_validator = validator;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RiuWorkflowGraphView::setPendingNodePosition( const QString& taskName, const QPointF& scenePos )
{
m_pendingPositions[taskName] = scenePos;
}
//--------------------------------------------------------------------------------------------------
/// Keep the position of a task the user has placed when it is renamed
//--------------------------------------------------------------------------------------------------
void RiuWorkflowGraphView::renameNodePosition( const QString& oldName, const QString& newName )
{
rememberPositions();
for ( const QString& prefix : { QString( "task:" ), QString( "config:" ) } )
{
if ( m_positions.contains( prefix + oldName ) ) m_positions[prefix + newName] = m_positions.take( prefix + oldName );
}
if ( m_lastSelectedTask == oldName ) m_lastSelectedTask = newName;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RiuWorkflowGraphView::rememberPositions()
{
for ( auto it = m_taskNodes.cbegin(); it != m_taskNodes.cend(); ++it )
m_positions["task:" + it.key()] = it.value()->pos();
for ( auto it = m_configNodes.cbegin(); it != m_configNodes.cend(); ++it )
m_positions["config:" + it.key()] = it.value()->pos();
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RiuWorkflowGraphView::fitGraph()
{
m_fitOnResize = true;
if ( !m_scene->sceneRect().isEmpty() ) fitInView( m_scene->sceneRect(), Qt::KeepAspectRatio );
}
//--------------------------------------------------------------------------------------------------
/// Rebuild the scene. With ViewportPolicy::Keep, tasks keep their positions and the view keeps
/// its zoom and scroll position, so edits do not make the graph jump.
//--------------------------------------------------------------------------------------------------
void RiuWorkflowGraphView::showGraph( const QJsonObject& graph, const QString& error, ViewportPolicy policy )
{
finishConnectionDrag( false );
const QTransform oldTransform = transform();
const QPointF oldCenter = mapToScene( viewport()->rect().center() );
const bool keepViewport = policy == ViewportPolicy::Keep && !m_taskNodes.isEmpty() && !m_fitOnResize;
const QString selectedTask = m_lastSelectedTask;
if ( policy == ViewportPolicy::Keep )
rememberPositions();
else
m_positions.clear();
m_rebuilding = true;
m_taskNodes.clear();
m_configNodes.clear();
m_runStatusLabel = nullptr;
m_scene->clear();
if ( policy == ViewportPolicy::Fit ) m_fitOnResize = true;
if ( !error.isEmpty() || graph.value( "tasks" ).toArray().isEmpty() )
{
QString message = error;
if ( message.isEmpty() )
message = m_editable ? "No tasks yet.\nDrag a task from the palette, or right-click to add one." : "No tasks in this workflow";
m_scene->addText( message );
m_scene->setSceneRect( m_scene->itemsBoundingRect().adjusted( -30, -30, 30, 30 ) );
fitInView( m_scene->sceneRect(), Qt::KeepAspectRatio );
m_rebuilding = false;
m_pendingPositions.clear();
return;
}
const bool editMode = m_editable && graph.value( "editable" ).toBool();
const QJsonArray tasks = graph.value( "tasks" ).toArray();
const QJsonArray edges = graph.value( "edges" ).toArray();
const QString result = graph.value( "result_task" ).toString();
QStringList taskNames;
std::vector<std::pair<QString, QString>> layoutEdges;
QMap<QString, QJsonObject> taskData;
QMap<QString, QStringList> configFields;
for ( const QJsonValue& task : tasks )
{
const QJsonObject data = task.toObject();
const QString name = data.value( "name" ).toString();
if ( name.isEmpty() || taskData.contains( name ) ) continue;
taskNames.append( name );
taskData.insert( name, data );
QStringList configured;
for ( const QJsonValue& field : data.value( "config_fields" ).toArray() )
{
const QString fieldName = field.toObject().value( "name" ).toString();
if ( !fieldName.isEmpty() ) configured.append( "field:" + fieldName );
}
configured.removeDuplicates();
configFields.insert( name, configured );
}
// Read-only graphs only show the ports in use, plus the declared outputs of end tasks
QMap<QString, QStringList> usedInputs;
QMap<QString, QStringList> usedOutputs;
for ( const QJsonValue& value : edges )
{
const QJsonObject edge = value.toObject();
const QString from = edge.value( "from" ).toString();
const QString to = edge.value( "to" ).toString();
layoutEdges.emplace_back( from, to );
if ( !taskData.contains( from ) || !taskData.contains( to ) || from == to || edge.value( "kind" ).toString() == "when" ) continue;
usedInputs[to].append( keyForField( edge.value( "input" ).toString() ) );
usedOutputs[from].append( keyForField( edge.value( "output" ).toString() ) );
}
for ( const QString& name : taskNames )
{
const QJsonObject data = taskData.value( name );
std::vector<PortInfo> inputs;
std::vector<PortInfo> outputs;
if ( editMode )
{
std::tie( inputs, outputs ) = editModePorts( data, edges );
}
else
{
QStringList in = fieldPorts( data.value( "inputs" ).toArray() ) + usedInputs.value( name );
in.append( configFields.value( name ) );
QStringList out = usedOutputs.value( name );
if ( out.isEmpty() ) out = fieldPorts( data.value( "outputs" ).toArray() );
if ( out.isEmpty() ) out.append( "model" );
in.removeDuplicates();
out.removeDuplicates();
in.sort();
out.sort();
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() );
}
// Config fields follow the order of the task's inputs, so their connections do not cross
QStringList& configured = configFields[name];
auto inputIndex = [&inputs]( const QString& key )
{
const auto it = std::find_if( inputs.begin(), inputs.end(), [&key]( const PortInfo& port ) { return port.key == key; } );
return std::distance( inputs.begin(), it );
};
std::stable_sort( configured.begin(),
configured.end(),
[&inputIndex]( const QString& a, const QString& b ) { return inputIndex( a ) < inputIndex( b ); } );
const QJsonObject map = data.value( "map" ).toObject();
QString subtitle;
if ( !map.isEmpty() ) subtitle = QString::fromUtf8( "⟳ map over %1" ).arg( map.value( "over" ).toString() );
const bool isSwitch = data.value( "switch" ).toBool();
auto* node = new GraphNode( name, inputs, outputs, false, subtitle, isSwitch );
if ( isSwitch )
{
QStringList branches;
for ( const QJsonValue& branch : data.value( "branches" ).toArray() )
branches.append( branch.toVariant().toString() );
node->setBranches( branches );
}
if ( !map.isEmpty() )
{
QStringList mapToolTip{
QString( "Runs once per item of '%1' (%2)" ).arg( map.value( "over" ).toString(), map.value( "type" ).toString() ) };
if ( !map.value( "key_as" ).toString().isEmpty() ) mapToolTip << QString( "Key → %1" ).arg( map.value( "key_as" ).toString() );
if ( !map.value( "value_as" ).toString().isEmpty() )
mapToolTip << QString( "Value → %1" ).arg( map.value( "value_as" ).toString() );
mapToolTip << ( map.value( "error_mode" ).toString() == "collect_all"
? "Every item runs; the task fails afterwards if any item failed"
: "The first failed item stops the task" );
mapToolTip << "The output is a dict with one entry per item";
node->setSubtitleToolTip( mapToolTip.join( "\n" ) );
}
const QString taskId = data.value( "task_id" ).toString();
const QString description = data.value( "description" ).toString();
QStringList toolTip{ name };
if ( !taskId.isEmpty() ) toolTip << taskId;
if ( !description.isEmpty() ) toolTip << description;
const bool unknownType = data.contains( "known_type" ) && !data.value( "known_type" ).toBool();
if ( name == result ) toolTip << "Result task: its output is the result of the workflow";
if ( isSwitch ) toolTip << "Switch: picks a branch; tasks with a run condition on it run only for their branches";
if ( unknownType ) toolTip << "Unknown task type: the package providing it is not installed";
node->setTitle( name == result ? QString::fromUtf8( "\u2605 " ) + name : name, toolTip.join( "\n" ) );
node->setUnknownType( unknownType );
node->setIssues( data.value( "issues" ).toArray() );
m_scene->addItem( node );
m_taskNodes.insert( name, node );
if ( !configFields.value( name ).isEmpty() )
{
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 );
}
}
auto slotHeight = [this]( const QString& name )
{
qreal height = m_taskNodes.value( name )->height();
if ( auto* config = m_configNodes.value( name, nullptr ) ) height = std::max( height, config->height() );
return height;
};
auto layerHeight = [&slotHeight]( const QStringList& layer )
{
qreal height = 0;
for ( const QString& name : layer )
height += slotHeight( name ) + rowGap;
return height - rowGap;
};
const auto layout = RiuWorkflowGraphLayout::computeLayers( taskNames, layoutEdges );
qreal totalHeight = 0;
for ( const QStringList& layer : layout.layers )
totalHeight = std::max( totalHeight, layerHeight( layer ) );
for ( size_t column = 0; column < layout.layers.size(); ++column )
{
const QStringList& layer = layout.layers[column];
qreal y = ( totalHeight - layerHeight( layer ) ) / 2.0;
for ( const QString& name : layer )
{
auto* node = m_taskNodes.value( name );
const qreal x = column * columnStep + configWidth + configGap;
node->setPos( x, y + ( slotHeight( name ) - node->height() ) / 2.0 );
if ( auto* config = m_configNodes.value( name, nullptr ) )
{
config->setPos( x - configWidth - configGap, y + ( slotHeight( name ) - config->height() ) / 2.0 );
}
y += slotHeight( name ) + rowGap;
}
}
// Positions the user (or a drop) has chosen override the layout
for ( auto it = m_taskNodes.cbegin(); it != m_taskNodes.cend(); ++it )
{
const QString name = it.key();
const bool pending = m_pendingPositions.contains( name );
std::optional<QPointF> position;
if ( pending )
position = m_pendingPositions.value( name );
else if ( m_positions.contains( "task:" + name ) )
position = m_positions.value( "task:" + name );
if ( position ) it.value()->setPos( *position );
if ( GraphNode* config = m_configNodes.value( name, nullptr ) )
{
if ( m_positions.contains( "config:" + name ) && !pending )
config->setPos( m_positions.value( "config:" + name ) );
else if ( position )
config->setPos( *position + QPointF( -configWidth - configGap, 0 ) );
}
}
m_pendingPositions.clear();
const bool selectableEdges = editMode;
for ( const QJsonValue& value : edges )
{
const QJsonObject edge = value.toObject();
const QString from = edge.value( "from" ).toString();
const QString to = edge.value( "to" ).toString();
if ( !m_taskNodes.contains( from ) || !m_taskNodes.contains( to ) || from == to ) continue;
if ( edge.value( "kind" ).toString() == "when" )
{
m_scene->addItem(
new GraphEdge( m_taskNodes.value( from ), m_taskNodes.value( to ), edge.value( "label" ).toString(), selectableEdges ) );
continue;
}
auto* graphEdge = new GraphEdge( m_taskNodes.value( from ),
keyForField( edge.value( "output" ).toString() ),
m_taskNodes.value( to ),
keyForField( edge.value( "input" ).toString() ),
false,
selectableEdges );
if ( edge.value( "collect" ).toString() == "first_of" )
{
graphEdge->setLabel( edge.value( "label" ).toString(),
QString( "Alternative %1: used when no earlier alternative ran" ).arg( edge.value( "label" ).toString() ) );
}
else if ( edge.contains( "collect" ) )
{
const bool keyed = edge.value( "collect" ).toString() == "dict";
const bool flattened = edge.value( "flatten" ).toBool();
QString toolTip = keyed ? QString( "Collected under the key '%1'" ).arg( edge.value( "label" ).toString() )
: QString( "Collected as item %1 of the list" ).arg( edge.value( "label" ).toString() );
if ( flattened )
{
toolTip = keyed ? "A dict merged into the collection by its own keys"
: QString( "Collected at position %1: the items of a list, or the values of a dict, are added in order" )
.arg( edge.value( "label" ).toString() );
}
graphEdge->setLabel( edge.value( "label" ).toString(), toolTip );
}
m_scene->addItem( graphEdge );
}
for ( auto it = m_configNodes.cbegin(); it != m_configNodes.cend(); ++it )
{
for ( const QString& key : configFields.value( it.key() ) )
{
m_scene->addItem( new GraphEdge( it.value(), key, m_taskNodes.value( it.key() ), key, true, false ) );
}
}
m_runStatusLabel = m_scene->addText( {} );
m_runStatusLabel->setDefaultTextColor( QColor( 45, 65, 80 ) );
m_runStatusLabel->setPos( 0, -45 );
m_scene->setSceneRect( m_scene->itemsBoundingRect().adjusted( -50, -50, 50, 50 ) );
if ( keepViewport )
{
setTransform( oldTransform );
centerOn( oldCenter );
}
else
{
fitInView( m_scene->sceneRect(), Qt::KeepAspectRatio );
}
m_lastSelectedTask.clear();
if ( auto* node = m_taskNodes.value( selectedTask, nullptr ) )
{
node->setSelected( true );
m_lastSelectedTask = selectedTask;
}
m_rebuilding = false;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RiuWorkflowGraphView::setTaskIssues( const QString& taskName, const QJsonArray& issues )
{
if ( auto* node = m_taskNodes.value( taskName, nullptr ) ) node->setIssues( issues );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RiuWorkflowGraphView::selectTask( const QString& taskName )
{
auto* node = m_taskNodes.value( taskName, nullptr );
if ( !node ) return;
m_rebuilding = true;
m_scene->clearSelection();
node->setSelected( true );
m_lastSelectedTask = taskName;
m_rebuilding = false;
ensureVisible( node );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
RiuWorkflowGraphView::ItemRef RiuWorkflowGraphView::ItemRef::forTask( const QString& taskName )
{
ItemRef item;
item.kind = Kind::Task;
item.task = taskName;
return item;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
RiuWorkflowGraphView::ItemRef
RiuWorkflowGraphView::ItemRef::forEdge( const QString& from, const QString& output, const QString& to, const QString& input )
{
ItemRef item;
item.kind = Kind::Edge;
item.from = from;
item.output = output;
item.to = to;
item.input = input;
return item;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
RiuWorkflowGraphView::ItemRef RiuWorkflowGraphView::ItemRef::forCondition( const QString& from, const QString& to )
{
ItemRef item;
item.kind = Kind::Condition;
item.from = from;
item.to = to;
return item;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
RiuWorkflowGraphView::ItemRef RiuWorkflowGraphView::selectedItem() const
{
for ( QGraphicsItem* item : m_scene->selectedItems() )
{
if ( auto* node = dynamic_cast<GraphNode*>( item ) ) return ItemRef::forTask( node->name() );
if ( auto* edge = dynamic_cast<GraphEdge*>( item ); edge && !edge->isConfig() )
{
if ( edge->isControl() ) return ItemRef::forCondition( edge->fromNode()->name(), edge->toNode()->name() );
return ItemRef::forEdge( edge->fromNode()->name(), edge->outputField(), edge->toNode()->name(), edge->inputField() );
}
}
return {};
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RiuWorkflowGraphView::onSelectionChanged()
{
if ( m_rebuilding ) return;
const ItemRef selected = selectedItem();
if ( selected.kind != ItemRef::Kind::Task || selected.task == m_lastSelectedTask ) return;
m_lastSelectedTask = selected.task;
emit nodeSelected( selected.task );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RiuWorkflowGraphView::setTaskInputValue( const QString& taskName, const QString& fieldName, const QString& value )
{
if ( auto* node = m_configNodes.value( taskName, nullptr ) ) node->setConfigValue( fieldName, value );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RiuWorkflowGraphView::resetTaskStates()
{
for ( GraphNode* node : m_taskNodes )
node->setTaskState( {} );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RiuWorkflowGraphView::setTaskState( const QString& taskName, const QString& state, const QString& error )
{
if ( auto* node = m_taskNodes.value( taskName, nullptr ) ) node->setTaskState( state, error );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RiuWorkflowGraphView::setTaskItemState( const QString& taskName, const QString& item, const QString& state, const QString& error )
{
if ( auto* node = m_taskNodes.value( taskName, nullptr ) ) node->setItemState( item, state, error );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RiuWorkflowGraphView::setRunStatus( const QString& status )
{
if ( !m_runStatusLabel ) return;
m_runStatusLabel->setPlainText( QFontMetrics( m_runStatusLabel->font() ).elidedText( status, Qt::ElideRight, 580 ) );
m_runStatusLabel->setToolTip( status );
}
//--------------------------------------------------------------------------------------------------
/// The task port nearest to the cursor, within a few pixels
//--------------------------------------------------------------------------------------------------
PortItem* RiuWorkflowGraphView::portAt( const QPoint& viewPos ) const
{
const QPointF scenePos = mapToScene( viewPos );
const qreal distance = portHitDist / std::max( 0.05, transform().m11() );
PortItem* nearest = nullptr;
qreal best = distance;
for ( QGraphicsItem* item : m_scene->items( QRectF( scenePos - QPointF( distance, distance ), QSizeF( 2 * distance, 2 * distance ) ) ) )
{
auto* port = dynamic_cast<PortItem*>( item );
if ( !port || port->isConfig() ) continue;
const QPointF delta = port->mapToScene( port->rect().center() ) - scenePos;
const qreal d = std::hypot( delta.x(), delta.y() );
if ( d <= best )
{
best = d;
nearest = port;
}
}
return nearest;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
RiuWorkflowGraphView::ItemRef RiuWorkflowGraphView::itemRefAt( const QPoint& viewPos ) const
{
for ( QGraphicsItem* item : items( viewPos ) )
{
QGraphicsItem* current = item;
while ( current )
{
if ( auto* node = dynamic_cast<GraphNode*>( current ) ) return ItemRef::forTask( node->name() );
if ( auto* edge = dynamic_cast<GraphEdge*>( current ) )
{
if ( edge->isConfig() ) break;
if ( edge->isControl() ) return ItemRef::forCondition( edge->fromNode()->name(), edge->toNode()->name() );
return ItemRef::forEdge( edge->fromNode()->name(), edge->outputField(), edge->toNode()->name(), edge->inputField() );
}
current = current->parentItem();
}
}
return {};
}
//--------------------------------------------------------------------------------------------------
/// Highlight the ports the dragged port can connect to
//--------------------------------------------------------------------------------------------------
void RiuWorkflowGraphView::startConnectionDrag( PortItem* port )
{
m_dragPort = port;
m_dragInvalidReasons.clear();
for ( GraphNode* node : m_taskNodes )
{
for ( PortItem* candidate : node->ports() )
{
if ( candidate->isOutput() == port->isOutput() ) continue;
PortItem* outputPort = port->isOutput() ? port : candidate;
PortItem* inputPort = port->isOutput() ? candidate : port;
std::expected<void, QString> valid;
if ( outputPort->node() == inputPort->node() )
valid = std::unexpected( QString( "A task cannot be connected to itself" ) );
else if ( m_validator )
valid = m_validator( outputPort->node()->name(), outputPort->field(), inputPort->node()->name(), inputPort->field() );
if ( valid )
candidate->setHighlight( PortItem::Highlight::Valid );
else
{
candidate->setHighlight( PortItem::Highlight::Invalid, valid.error() );
m_dragInvalidReasons.insert( candidate, valid.error() );
}
}
}
m_dragPath = m_scene->addPath( QPainterPath(), QPen( validTargetColor.darker( 120 ), 2.0, Qt::DashLine ) );
m_dragPath->setZValue( 3 );
updateConnectionDrag( port->mapToScene( port->rect().center() ) );
setCursor( Qt::CrossCursor );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RiuWorkflowGraphView::updateConnectionDrag( const QPointF& scenePos )
{
if ( !m_dragPort || !m_dragPath ) return;
m_dragTarget = nullptr;
QPointF end = scenePos;
if ( PortItem* target = portAt( mapFromScene( scenePos ) );
target && target->isOutput() != m_dragPort->isOutput() && target->node() != m_dragPort->node() )
{
if ( !m_dragInvalidReasons.contains( target ) )
{
m_dragTarget = target;
end = target->mapToScene( target->rect().center() );
}
else
{
QToolTip::showText( mapToGlobal( mapFromScene( scenePos ) ), m_dragInvalidReasons.value( target ), this );
}
}
const QPointF start = m_dragPort->mapToScene( m_dragPort->rect().center() );
QPointF from = m_dragPort->isOutput() ? start : end;
QPointF to = m_dragPort->isOutput() ? end : start;
const qreal bend = std::max( 35.0, std::abs( to.x() - from.x() ) / 2.0 );
QPainterPath path( from );
path.cubicTo( from + QPointF( bend, 0 ), to - QPointF( bend, 0 ), to );
m_dragPath->setPath( path );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RiuWorkflowGraphView::finishConnectionDrag( bool connect )
{
if ( !m_dragPort ) return;
PortItem* source = m_dragPort;
PortItem* target = m_dragTarget;
m_dragPort = nullptr;
m_dragTarget = nullptr;
m_dragInvalidReasons.clear();
if ( m_dragPath )
{
m_scene->removeItem( m_dragPath );
delete m_dragPath;
m_dragPath = nullptr;
}
for ( GraphNode* node : m_taskNodes )
{
for ( PortItem* port : node->ports() )
port->setHighlight( PortItem::Highlight::None );
}
unsetCursor();
QToolTip::hideText();
if ( !connect || !target ) return;
PortItem* outputPort = source->isOutput() ? source : target;
PortItem* inputPort = source->isOutput() ? target : source;
emit connectRequested( outputPort->node()->name(), outputPort->field(), inputPort->node()->name(), inputPort->field() );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RiuWorkflowGraphView::resizeEvent( QResizeEvent* event )
{
QGraphicsView::resizeEvent( event );
if ( m_fitOnResize && !m_scene->sceneRect().isEmpty() ) fitInView( m_scene->sceneRect(), Qt::KeepAspectRatio );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RiuWorkflowGraphView::mousePressEvent( QMouseEvent* event )
{
if ( event->button() == Qt::LeftButton ) m_fitOnResize = false;
if ( m_editable && event->button() == Qt::LeftButton )
{
if ( PortItem* port = portAt( event->pos() ) )
{
startConnectionDrag( port );
event->accept();
return;
}
}
QGraphicsView::mousePressEvent( event );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RiuWorkflowGraphView::mouseMoveEvent( QMouseEvent* event )
{
if ( m_dragPort )
{
updateConnectionDrag( mapToScene( event->pos() ) );
event->accept();
return;
}
QGraphicsView::mouseMoveEvent( event );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RiuWorkflowGraphView::mouseReleaseEvent( QMouseEvent* event )
{
if ( m_dragPort )
{
updateConnectionDrag( mapToScene( event->pos() ) );
finishConnectionDrag( true );
event->accept();
return;
}
QGraphicsView::mouseReleaseEvent( event );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RiuWorkflowGraphView::wheelEvent( QWheelEvent* event )
{
if ( event->modifiers() & Qt::ControlModifier )
{
m_fitOnResize = false;
const qreal factor = event->angleDelta().y() > 0 ? 1.15 : 1.0 / 1.15;
scale( factor, factor );
event->accept();
return;
}
QGraphicsView::wheelEvent( event );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RiuWorkflowGraphView::keyPressEvent( QKeyEvent* event )
{
if ( event->key() == Qt::Key_Escape && m_dragPort )
{
finishConnectionDrag( false );
event->accept();
return;
}
if ( m_editable )
{
const ItemRef selected = selectedItem();
// A config box carries the name of its task; deleting it must not delete the task
const auto selectedItems = m_scene->selectedItems();
const bool configSelected = std::any_of( selectedItems.begin(),
selectedItems.end(),
[]( QGraphicsItem* item )
{
auto* node = dynamic_cast<GraphNode*>( item );
return node && node->isConfig();
} );
if ( ( event->key() == Qt::Key_Delete || event->key() == Qt::Key_Backspace ) && selected.kind != ItemRef::Kind::None && !configSelected )
{
emit deleteRequested( selected );
event->accept();
return;
}
if ( event->key() == Qt::Key_F2 && selected.kind == ItemRef::Kind::Task )
{
emit renameRequested( selected.task );
event->accept();
return;
}
}
QGraphicsView::keyPressEvent( event );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RiuWorkflowGraphView::contextMenuEvent( QContextMenuEvent* event )
{
const ItemRef item = itemRefAt( event->pos() );
m_rebuilding = true;
m_scene->clearSelection();
for ( QGraphicsItem* graphicsItem : items( event->pos() ) )
{
QGraphicsItem* current = graphicsItem;
while ( current && !( current->flags() & QGraphicsItem::ItemIsSelectable ) )
current = current->parentItem();
if ( current )
{
current->setSelected( true );
break;
}
}
m_rebuilding = false;
onSelectionChanged();
emit contextMenuRequested( item, event->globalPos(), mapToScene( event->pos() ) );
event->accept();
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RiuWorkflowGraphView::dragEnterEvent( QDragEnterEvent* event )
{
if ( m_editable && event->mimeData()->hasFormat( taskMimeType ) )
event->acceptProposedAction();
else
event->ignore();
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RiuWorkflowGraphView::dragMoveEvent( QDragMoveEvent* event )
{
if ( m_editable && event->mimeData()->hasFormat( taskMimeType ) )
event->acceptProposedAction();
else
event->ignore();
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RiuWorkflowGraphView::dropEvent( QDropEvent* event )
{
if ( !m_editable || !event->mimeData()->hasFormat( taskMimeType ) )
{
event->ignore();
return;
}
const QString taskId = QString::fromUtf8( event->mimeData()->data( taskMimeType ) );
event->acceptProposedAction();
m_fitOnResize = false;
emit addTaskRequested( taskId, mapToScene( event->position().toPoint() ) );
}