Files
ResInsight/ApplicationLibCode/UserInterface/RiuWorkflowGraphView.cpp
T

2843 lines
124 KiB
C++
Raw Normal View History

/////////////////////////////////////////////////////////////////////////////////
//
// 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 rankGap = 140.0;
constexpr qreal siblingGap = 100.0;
// Vertical layouts: the ports are columns along the top and bottom edges of a task
constexpr qreal portColumnWidth = 120.0;
constexpr qreal columnMargin = 10.0;
constexpr qreal columnLineHeight = 16.0;
constexpr qreal columnSectionGap = 12.0;
constexpr qreal configGapVertical = 60.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 );
2026-10-26 14:19:29 +01:00
const QColor conditionColor( 135, 75, 150 );
const QColor nestedColor( 30, 125, 120 );
const QColor nestedHeaderColor( 196, 231, 226 );
2026-10-26 14:19:29 +01:00
constexpr qreal diamondMinHeight = 130.0;
constexpr qreal diamondPortStep = 24.0;
constexpr qreal collapsedHeight = 30.0;
constexpr qreal nestedHeaderHeight = 24.0;
constexpr qreal nestedIconWidth = 22.0;
constexpr qreal headerHeight = 28.0;
// Marks the item that collapses and expands its task when clicked
constexpr int toggleDataKey = 0;
const QLatin1String collapseToggle( "collapse-toggle" );
QString keyForField( const QString& field )
{
return field.isEmpty() ? "model" : "field:" + field;
}
QString fieldForKey( const QString& key )
{
return key == "model" ? QString() : key.mid( 6 );
}
2026-10-05 16:12:01 +02:00
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
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;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
QString hiddenSourcesToolTip( const QStringList& hiddenSources )
{
return QString( "Provided by hidden task%1: %2\nRight-click the canvas to show hidden tasks" )
.arg( hiddenSources.size() > 1 ? "s" : "", hiddenSources.join( ", " ) );
}
//--------------------------------------------------------------------------------------------------
/// A curve leaving `from` and entering `to` along the x axis, or along the y axis if `alongY`. With
/// `loopAround`, a target that is not ahead of the source makes the curve bend out further, so it
/// loops around the boxes instead of doubling back over them.
//--------------------------------------------------------------------------------------------------
QPainterPath flowPath( const QPointF& from, const QPointF& to, bool alongY, qreal minBend, bool loopAround )
{
const QPointF delta = to - from;
const qreal ahead = alongY ? delta.y() : delta.x();
const qreal across = alongY ? delta.x() : delta.y();
qreal bend = std::max( minBend, std::abs( ahead ) / 2.0 );
if ( loopAround && ahead < 2 * minBend ) bend = std::max( bend, std::abs( across ) / 2.0 );
const QPointF direction = alongY ? QPointF( 0, bend ) : QPointF( bend, 0 );
QPainterPath path( from );
path.cubicTo( from + direction, to - direction, to );
return path;
}
//--------------------------------------------------------------------------------------------------
/// A data connection from an output port to an input port: to the right, or downwards if vertical
//--------------------------------------------------------------------------------------------------
QPainterPath connectionPath( const QPointF& from, const QPointF& to, bool vertical )
{
return flowPath( from, to, vertical, 35.0, true );
}
//--------------------------------------------------------------------------------------------------
/// An arrow head with its tip at `tip`, pointing down or to the right
//--------------------------------------------------------------------------------------------------
QPolygonF arrowHead( const QPointF& tip, bool down )
{
if ( down ) return QPolygonF{ tip, tip + QPointF( -5, -9 ), tip + QPointF( 5, -9 ) };
return QPolygonF{ tip, tip + QPointF( -9, -5 ), tip + QPointF( -9, 5 ) };
}
} // namespace
2026-10-05 16:12:01 +02:00
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
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;
bool configured = false; // An input whose value is set in the job configuration, shown in the task box
QStringList hiddenSources{}; // Hidden tasks connected to this input
};
//==================================================================================================
2026-10-05 16:12:01 +02:00
///
//==================================================================================================
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 = {} );
void setValueToolTip( const QString& value );
private:
GraphNode* m_node;
QString m_key;
bool m_output;
bool m_config;
QPointF m_center;
QBrush m_brush;
QPen m_pen;
QString m_baseToolTip;
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,
bool collapsed = false,
bool mapped = false,
bool vertical = false );
qreal width() const;
qreal height() const;
bool isVertical() const;
bool isHeaderAt( const QPointF& scenePos ) const;
QPointF portPosition( const QString& key, bool output ) const;
2026-10-26 14:19:29 +01:00
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 );
void setNested( bool nested );
bool isNested() const;
protected:
QVariant itemChange( GraphicsItemChange change, const QVariant& value ) override;
private:
// Where the config value of a field is drawn: the x position of the text item, where the item
// must end to stay clear of other text, and what goes in front of the value
struct ValueSlot
{
qreal left;
qreal right;
QString prefix;
std::optional<qreal> top{}; // Defaults to the line of the port
bool centered{ false };
};
2026-10-26 14:19:29 +01:00
void addPorts( const std::vector<PortInfo>& ports, bool output );
void addColumnPorts( const std::vector<PortInfo>& inputs, const std::vector<PortInfo>& outputs );
int columnLines( const std::vector<PortInfo>& ports, bool output ) const;
bool showsValue( const PortInfo& info, bool output ) const;
void addCollapsedPorts( const std::vector<PortInfo>& ports, bool output );
void addHiddenSourceMarkers( const std::vector<PortInfo>& inputs );
qreal freeRight( const QRectF& line, qreal right, const QList<QGraphicsItem*>& ignored ) const;
2026-10-26 14:19:29 +01:00
void addDiamondPorts( const std::vector<PortInfo>& ports, bool output );
void applyStyle();
void updateStateLabelPosition();
void paintNestedHeader( QPainter* painter ) const;
2026-10-26 14:19:29 +01:00
QPolygonF diamondPolygon( const QRectF& bounds ) const;
QString m_name;
bool m_isConfig;
bool m_diamond;
bool m_collapsed;
bool m_vertical;
2026-10-26 14:19:29 +01:00
qreal m_width;
QMap<QString, PortItem*> m_inputs;
QMap<QString, PortItem*> m_outputs;
QMap<QString, QGraphicsTextItem*> m_configValues;
QMap<QString, ValueSlot> m_valueSlots;
2026-10-26 14:19:29 +01:00
std::vector<GraphEdge*> m_edges;
QGraphicsTextItem* m_title = nullptr;
QGraphicsTextItem* m_chevron = nullptr;
2026-10-26 14:19:29 +01:00
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;
QStringList m_collapsedInfo; // Shown in the tooltip, as the box does not show it
QMap<QString, QString> m_collapsedValues;
2026-10-26 14:19:29 +01:00
int m_errorCount = 0;
int m_warningCount = 0;
bool m_unknownType = false;
bool m_nested = false;
};
//==================================================================================================
///
//==================================================================================================
class GraphEdge : public QGraphicsPathItem
{
public:
GraphEdge( GraphNode* from, QString outputKey, GraphNode* to, QString inputKey, bool isConfig, bool selectable );
2026-10-26 14:19:29 +01:00
// 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; }
2026-10-26 14:19:29 +01:00
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;
2026-10-26 14:19:29 +01:00
bool m_isControl = false;
QGraphicsPolygonItem* m_arrow;
QGraphicsTextItem* m_label = nullptr;
};
2026-10-05 16:12:01 +02:00
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
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_baseToolTip( info.toolTip.isEmpty() ? info.label : info.toolTip )
, m_toolTip( m_baseToolTip )
{
const QColor color = ( config || info.configured ) ? 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 );
2026-10-26 14:19:29 +01:00
// 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 );
2026-10-26 14:19:29 +01:00
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;
}
}
//--------------------------------------------------------------------------------------------------
/// The value set in the job configuration, shown in the tooltip
//--------------------------------------------------------------------------------------------------
void PortItem::setValueToolTip( const QString& value )
{
m_toolTip = m_baseToolTip + "\nValue: " + value;
setToolTip( m_toolTip );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
GraphNode::GraphNode( const QString& name,
const std::vector<PortInfo>& inputs,
const std::vector<PortInfo>& outputs,
bool isConfig,
2026-10-26 14:19:29 +01:00
const QString& subtitle,
bool diamond,
bool collapsed,
bool mapped,
bool vertical )
: m_name( name )
, m_isConfig( isConfig )
, m_diamond( diamond && !isConfig && !collapsed )
, m_collapsed( collapsed && !isConfig )
, m_vertical( vertical )
, 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_vertical && m_diamond )
m_width = std::max( m_width, ( rows - 1 ) * diamondPortStep + 80.0 );
else if ( m_vertical && !m_collapsed )
m_width = std::max( m_width, rows * portColumnWidth + 2 * columnMargin );
if ( m_collapsed )
setRect( 0, 0, m_width, collapsedHeight );
else if ( m_diamond && m_vertical )
setRect( 0, 0, m_width, diamondMinHeight );
else if ( m_diamond )
2026-10-26 14:19:29 +01:00
setRect( 0, 0, m_width, std::max( diamondMinHeight, ( rows - 1 ) * diamondPortStep + 60.0 ) );
else if ( m_vertical )
{
const int inputLines = columnLines( inputs, false );
const int outputLines = columnLines( outputs, true );
const qreal sectionGap = inputLines > 0 && outputLines > 0 ? columnSectionGap : 0.0;
setRect( 0, 0, m_width, m_portTop - 20 + ( inputLines + outputLines ) * columnLineHeight + sectionGap + 12 );
}
2026-10-26 14:19:29 +01:00
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_diamond )
{
m_chevron = new QGraphicsTextItem( QString::fromUtf8( m_collapsed ? "\u25b8" : "\u25be" ), this );
m_chevron->setDefaultTextColor( taskBorder );
m_chevron->setPos( 3, 4 );
m_chevron->setCursor( Qt::PointingHandCursor );
m_chevron->setToolTip( m_collapsed ? "Expand task" : "Collapse task" );
m_chevron->setData( toggleDataKey, collapseToggle );
m_title->setPos( 17, 4 );
}
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. In vertical layouts, edges leave the
// bottom of a box, so the badge goes beside the title instead.
if ( m_vertical && !m_diamond )
m_issueLabel->setPos( m_width + 2, 2 );
else
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 ( mapped )
{
for ( int depth = 2; depth >= 1; --depth )
{
2026-10-26 14:19:29 +01:00
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 );
}
}
if ( !subtitle.isEmpty() && m_collapsed )
{
m_collapsedInfo << subtitle;
}
else if ( !subtitle.isEmpty() )
{
m_subtitle = new QGraphicsTextItem( this );
QFont font;
font.setPointSize( 8 );
font.setItalic( true );
m_subtitle->setFont( font );
m_subtitle->setDefaultTextColor( mapColor );
2026-10-26 14:19:29 +01:00
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_collapsed )
{
addCollapsedPorts( inputs, false );
addCollapsedPorts( outputs, true );
}
else if ( m_diamond )
2026-10-26 14:19:29 +01:00
{
addDiamondPorts( inputs, false );
addDiamondPorts( outputs, true );
}
else if ( m_vertical )
{
addColumnPorts( inputs, outputs );
}
2026-10-26 14:19:29 +01:00
else
{
addPorts( inputs, false );
addPorts( outputs, true );
addHiddenSourceMarkers( inputs );
2026-10-26 14:19:29 +01:00
}
setTitle( isConfig ? "Config: " + name : name, {} );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
qreal GraphNode::width() const
{
return m_width;
}
2026-10-05 16:12:01 +02:00
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
qreal GraphNode::height() const
{
return rect().height();
}
//--------------------------------------------------------------------------------------------------
/// Whether data flows from top to bottom through the node: inputs on the top edge, outputs on the
/// bottom edge, and control edges on the sides
//--------------------------------------------------------------------------------------------------
bool GraphNode::isVertical() const
{
return m_vertical;
}
//--------------------------------------------------------------------------------------------------
/// The title row of a task, where a double-click collapses or expands it
//--------------------------------------------------------------------------------------------------
bool GraphNode::isHeaderAt( const QPointF& scenePos ) const
{
if ( m_isConfig ) return false;
const QPointF local = mapFromScene( scenePos );
if ( m_diamond ) return m_title->mapRectToParent( m_title->boundingRect() ).contains( local );
return QRectF( 0, 0, m_width, headerHeight ).contains( local );
}
2026-10-05 16:12:01 +02:00
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void GraphNode::setTitle( const QString& title, const QString& toolTip )
{
qreal available = m_diamond ? m_width * 0.62 : m_width - ( m_isConfig ? 20 : ( m_chevron ? 107 : 100 ) );
if ( m_nested && !m_diamond ) available -= nestedIconWidth;
2026-10-26 14:19:29 +01:00
m_title->setPlainText( QFontMetrics( m_title->font() ).elidedText( title, Qt::ElideRight, static_cast<int>( available ) ) );
m_title->setToolTip( title );
2026-10-26 14:19:29 +01:00
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
const bool inlineValue = !output && info.configured && !m_isConfig;
if ( showType )
{
QFont typeFont;
typeFont.setPointSize( 7 );
typeFont.setItalic( true );
const QIcon icon( info.iconResource );
const bool showIcon = !info.iconResource.isEmpty() && !icon.isNull();
// Config values go on the same line, so the type gets at most half of it
const qreal maxTypeWidth = ( m_isConfig || inlineValue ) ? 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 );
const QRectF typeRect = typeText->mapRectToParent( typeText->boundingRect() );
qreal typeLeft = typeRect.left();
// The icon of the ResInsight object goes in front of the type
if ( showIcon )
{
const qreal iconX = output ? typeRect.left() - iconSize - 1 : 10 + 3;
auto* iconItem = new IconItem( icon, iconSize, this );
iconItem->setPos( iconX, typeRect.center().y() - iconSize / 2.0 );
iconItem->setToolTip( toolTip );
iconItem->setAcceptedMouseButtons( Qt::NoButton );
typeLeft = iconX;
}
if ( m_isConfig && output ) m_valueSlots.insert( fieldForKey( info.key ), ValueSlot{ 10, typeLeft - 6, {} } );
if ( inlineValue )
{
// The value follows the type, as in "type = value"
const qreal margin = 2 * typeText->document()->documentMargin();
m_valueSlots.insert( fieldForKey( info.key ), ValueSlot{ typeRect.right() - margin, m_width - 12, " = " } );
}
}
else if ( m_isConfig && output )
{
m_valueSlots.insert( fieldForKey( info.key ), ValueSlot{ 10, m_width - 20, {} } );
}
else if ( inlineValue )
{
m_valueSlots.insert( fieldForKey( info.key ), ValueSlot{ 10, m_width - 12, {} } );
}
}
}
//--------------------------------------------------------------------------------------------------
/// Whether a port shows a config value: the fields of a config box, and inlined values of a task
//--------------------------------------------------------------------------------------------------
bool GraphNode::showsValue( const PortInfo& info, bool output ) const
{
return output ? m_isConfig : ( info.configured && !m_isConfig );
}
//--------------------------------------------------------------------------------------------------
/// The number of text lines of the port columns along one edge. Each kind of line is shown in all
/// columns or none, so the lines of neighbouring columns line up.
//--------------------------------------------------------------------------------------------------
int GraphNode::columnLines( const std::vector<PortInfo>& ports, bool output ) const
{
if ( ports.empty() ) return 0;
auto any = [&ports]( const auto& predicate ) { return std::any_of( ports.begin(), ports.end(), predicate ); };
int lines = 1;
if ( any( []( const PortInfo& info ) { return !info.typeName.isEmpty(); } ) ) ++lines;
if ( any( [this, output]( const PortInfo& info ) { return showsValue( info, output ); } ) ) ++lines;
if ( !output && any( []( const PortInfo& info ) { return !info.hiddenSources.isEmpty(); } ) ) ++lines;
return lines;
}
//--------------------------------------------------------------------------------------------------
/// Vertical layouts: each input is a column hanging from the top edge, with its port on the edge
/// and its label, type, value and hidden sources below. Outputs stand on the bottom edge.
//--------------------------------------------------------------------------------------------------
void GraphNode::addColumnPorts( const std::vector<PortInfo>& inputs, const std::vector<PortInfo>& outputs )
{
for ( bool output : { false, true } )
{
const auto& ports = output ? outputs : inputs;
auto& portMap = output ? m_outputs : m_inputs;
const qreal edgeY = output ? rect().height() : 0.0;
const qreal firstTop = output ? rect().height() - 12 - columnLines( ports, true ) * columnLineHeight : m_portTop - 20;
const qreal left = ( m_width - ports.size() * portColumnWidth ) / 2.0;
const bool anyType = std::any_of( ports.begin(), ports.end(), []( const PortInfo& info ) { return !info.typeName.isEmpty(); } );
const bool anyValue = std::any_of( ports.begin(), ports.end(), [&]( const PortInfo& info ) { return showsValue( info, output ); } );
const bool anyHidden = std::any_of( ports.begin(), ports.end(), []( const PortInfo& info ) { return !info.hiddenSources.isEmpty(); } );
for ( size_t column = 0; column < ports.size(); ++column )
{
const PortInfo& info = ports[column];
const qreal columnLeft = left + column * portColumnWidth;
const qreal center = columnLeft + portColumnWidth / 2.0;
const qreal columnRight = columnLeft + portColumnWidth;
portMap.insert( info.key, new PortItem( this, info, output, m_isConfig, QPointF( center, edgeY ) ) );
const QString toolTip = info.toolTip.isEmpty() ? info.label : info.toolTip;
qreal top = firstTop;
// A line of text centered in the column, leaving `indent` free in front of it
auto addText = [&]( const QString& content, const QFont& font, const QColor& color, qreal indent = 0.0 )
{
const qreal available = portColumnWidth - 8 - indent;
auto* text =
new QGraphicsTextItem( QFontMetrics( font ).elidedText( content, Qt::ElideRight, static_cast<int>( available ) ), this );
text->setFont( font );
text->setToolTip( toolTip );
text->setDefaultTextColor( color );
text->setPos( center - ( text->boundingRect().width() + indent ) / 2.0 + indent, top );
text->setAcceptedMouseButtons( Qt::NoButton );
return text;
};
QFont labelFont;
labelFont.setPointSize( 9 );
addText( info.label, labelFont, info.style == PortInfo::Style::Missing ? missingPortColor : QColor( 45, 65, 80 ) );
top += columnLineHeight;
if ( !info.typeName.isEmpty() )
{
QFont typeFont;
typeFont.setPointSize( 7 );
typeFont.setItalic( true );
const QIcon icon( info.iconResource );
const bool showIcon = !info.iconResource.isEmpty() && !icon.isNull();
const qreal indent = showIcon ? iconSize + 2 : 0.0;
auto* typeText = addText( info.typeName, typeFont, QColor( 110, 120, 135 ), indent );
if ( showIcon )
{
const QRectF typeRect = typeText->mapRectToParent( typeText->boundingRect() );
auto* iconItem = new IconItem( icon, iconSize, this );
iconItem->setPos( typeRect.left() + typeText->document()->documentMargin() - indent,
typeRect.center().y() - iconSize / 2.0 );
iconItem->setToolTip( toolTip );
iconItem->setAcceptedMouseButtons( Qt::NoButton );
}
}
if ( anyType ) top += columnLineHeight;
if ( showsValue( info, output ) )
{
m_valueSlots.insert( fieldForKey( info.key ),
ValueSlot{ columnLeft + 4, columnRight - 4, m_isConfig ? QString() : QString( "= " ), top, true } );
}
if ( anyValue ) top += columnLineHeight;
if ( !output && !info.hiddenSources.isEmpty() )
{
QFont font;
font.setPointSize( 7 );
auto* marker = addText( QString::fromUtf8( "\u25b4 " ) + info.hiddenSources.join( ", " ), font, QColor( 130, 135, 145 ) );
marker->setToolTip( hiddenSourcesToolTip( info.hiddenSources ) );
}
if ( anyHidden ) top += columnLineHeight;
}
}
}
//--------------------------------------------------------------------------------------------------
/// A collapsed task has one dot on each side where all its connections meet (top and bottom in
/// vertical layouts). The ports are kept,
/// hidden, so the edges can find them.
//--------------------------------------------------------------------------------------------------
void GraphNode::addCollapsedPorts( const std::vector<PortInfo>& ports, bool output )
{
auto& portMap = output ? m_outputs : m_inputs;
const QPointF center = m_vertical ? QPointF( m_width / 2, output ? rect().height() : 0.0 )
: QPointF( output ? m_width : 0.0, rect().height() / 2 );
QStringList labels;
bool missing = false;
for ( PortInfo info : ports )
{
missing = missing || info.style == PortInfo::Style::Missing;
info.collect = false;
info.firstOf = false;
auto* port = new PortItem( this, info, output, false, center );
port->setVisible( false );
portMap.insert( info.key, port );
labels << ( info.typeName.isEmpty() ? info.label : QString( "%1 (%2)" ).arg( info.label, info.typeName ) );
}
if ( ports.empty() ) return;
const QString text = ( output ? "Outputs: " : "Inputs: " ) + labels.join( ", " );
auto* dot = new QGraphicsEllipseItem( center.x() - portRadius, center.y() - portRadius, 2 * portRadius, 2 * portRadius, this );
if ( missing )
{
dot->setBrush( QColor( 255, 255, 255 ) );
dot->setPen( QPen( missingPortColor, 2.0 ) );
}
else
{
dot->setBrush( output ? outputPortColor : inputPortColor );
dot->setPen( Qt::NoPen );
}
dot->setToolTip( text );
dot->setAcceptedMouseButtons( Qt::NoButton );
dot->setZValue( 2 );
m_collapsedInfo << text;
}
2026-10-26 14:19:29 +01:00
//--------------------------------------------------------------------------------------------------
/// Ports of a switch task sit on the left and right sides of its diamond (the top and bottom sides
/// in vertical layouts), labels go in tooltips
2026-10-26 14:19:29 +01:00
//--------------------------------------------------------------------------------------------------
void GraphNode::addDiamondPorts( const std::vector<PortInfo>& ports, bool output )
{
auto& portMap = output ? m_outputs : m_inputs;
const qreal halfWidth = m_width / 2;
2026-10-26 14:19:29 +01:00
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;
QPointF center;
if ( m_vertical )
{
const qreal inset = halfHeight * std::abs( offset ) / halfWidth;
center = QPointF( halfWidth + offset, output ? rect().height() - inset : inset );
}
else
{
const qreal inset = halfWidth * std::abs( offset ) / halfHeight;
center = QPointF( output ? m_width - inset : inset, halfHeight + offset );
}
portMap.insert( ports[row].key, new PortItem( this, ports[row], output, false, center ) );
2026-10-26 14:19:29 +01:00
}
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
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 );
if ( m_nested ) paintNestedHeader( painter );
2026-10-26 14:19:29 +01:00
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. In
/// vertical layouts, where data enters at the top, they leave at the right and enter at the left.
2026-10-26 14:19:29 +01:00
//--------------------------------------------------------------------------------------------------
QPointF GraphNode::controlAnchor( bool outgoing ) const
{
if ( m_vertical ) return mapToScene( QPointF( outgoing ? m_width : 0.0, rect().height() / 2 ) );
2026-10-26 14:19:29 +01:00
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 );
}
2026-10-05 16:12:01 +02:00
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
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();
}
2026-10-05 16:12:01 +02:00
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void GraphNode::addEdge( GraphEdge* edge )
{
m_edges.push_back( edge );
}
2026-10-05 16:12:01 +02:00
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
QString GraphNode::name() const
{
return m_name;
}
2026-10-05 16:12:01 +02:00
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
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;
}
2026-10-05 16:12:01 +02:00
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
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();
}
//--------------------------------------------------------------------------------------------------
/// The run state is shown at the right end of the title, left of the sub-workflow icon
//--------------------------------------------------------------------------------------------------
void GraphNode::updateStateLabelPosition()
{
if ( !m_stateLabel || m_diamond ) return;
m_stateLabel->setPos( m_width - m_stateLabel->boundingRect().width() - ( m_nested ? nestedIconWidth + 4 : 6 ), 5 );
}
//--------------------------------------------------------------------------------------------------
/// A task holding a whole workflow has a coloured title band with a sub-workflow icon (two linked
/// boxes) at its right end
//--------------------------------------------------------------------------------------------------
void GraphNode::paintNestedHeader( QPainter* painter ) const
{
const qreal borderWidth = pen().widthF();
const QRectF bounds = rect();
const qreal bandHeight = m_collapsed ? bounds.height() : std::min( nestedHeaderHeight, bounds.height() );
const QRectF band( bounds.left() + borderWidth / 2,
bounds.top() + borderWidth / 2,
bounds.width() - borderWidth,
bandHeight - borderWidth / 2 );
painter->save();
painter->setPen( Qt::NoPen );
painter->setBrush( nestedHeaderColor );
painter->drawRect( band );
if ( !m_collapsed )
{
painter->setPen( QPen( nestedColor, 1.0 ) );
painter->drawLine( QPointF( bounds.left(), bounds.top() + bandHeight ), QPointF( bounds.right(), bounds.top() + bandHeight ) );
}
const qreal right = bounds.right() - 6;
const qreal top = bounds.top() + ( std::min( nestedHeaderHeight, bounds.height() ) - 13 ) / 2;
const QRectF first( right - 18, top + 6, 8, 7 );
const QRectF second( right - 8, top, 8, 7 );
painter->setPen( QPen( nestedColor, 1.2 ) );
painter->setBrush( Qt::white );
painter->drawPolyline( QPolygonF{ QPointF( first.center().x(), first.top() ),
QPointF( first.center().x(), second.center().y() ),
QPointF( second.left(), second.center().y() ) } );
painter->drawRect( first );
painter->drawRect( second );
painter->restore();
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void GraphNode::setNested( bool nested )
{
m_nested = nested;
// A mapped task keeps the map colour, as its subtitle also says what it maps over
if ( m_subtitle && m_stack.empty() ) m_subtitle->setDefaultTextColor( nested ? nestedColor : mapColor );
updateStateLabelPosition();
update();
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
bool GraphNode::isNested() const
{
return m_nested;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
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 );
}
2026-10-26 14:19:29 +01:00
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 );
2026-10-26 14:19:29 +01:00
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 );
2026-10-26 14:19:29 +01:00
if ( m_diamond )
m_stateLabel->setPos( ( m_width - m_stateLabel->boundingRect().width() ) / 2, rect().height() / 2 + 12 );
else
updateStateLabelPosition();
}
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;
if ( !m_collapsedInfo.isEmpty() ) toolTip << m_collapsedInfo.join( "\n" );
if ( !m_collapsedValues.isEmpty() )
{
QStringList values;
for ( auto it = m_collapsedValues.cbegin(); it != m_collapsedValues.cend(); ++it )
values << QString( "%1 = %2" ).arg( it.key(), it.value() );
toolTip << "Config values:\n" + values.join( "\n" );
}
setToolTip( toolTip.join( "\n\n" ) );
2026-10-26 14:19:29 +01:00
setOpacity( m_state == "skipped" ? 0.6 : 1.0 );
}
//--------------------------------------------------------------------------------------------------
/// Where text starting in `line` must end to stay clear of the other text and icons of the node
//--------------------------------------------------------------------------------------------------
qreal GraphNode::freeRight( const QRectF& line, qreal right, const QList<QGraphicsItem*>& ignored ) const
{
// Text items have empty space above and below the text, so only the middle of the line counts
const QRectF middle = line.adjusted( 0, 4, 0, -4 );
for ( QGraphicsItem* child : childItems() )
{
if ( ignored.contains( child ) ) continue;
if ( !dynamic_cast<QGraphicsTextItem*>( child ) && !dynamic_cast<IconItem*>( child ) ) continue;
const QRectF bounds = child->mapRectToParent( child->boundingRect() );
if ( bounds.left() > line.left() && bounds.intersects( middle ) ) right = std::min( right, bounds.left() );
}
return right;
}
//--------------------------------------------------------------------------------------------------
/// Inputs connected to hidden tasks show the names of those tasks after the label
//--------------------------------------------------------------------------------------------------
void GraphNode::addHiddenSourceMarkers( const std::vector<PortInfo>& inputs )
{
QFont font;
font.setPointSize( 7 );
for ( const PortInfo& info : inputs )
{
if ( info.hiddenSources.isEmpty() ) continue;
auto* port = m_inputs.value( info.key, nullptr );
if ( !port ) continue;
// The label is the text item at the left on the label line of the port
const qreal labelTop = port->rect().center().y() - 20;
qreal left = 10;
for ( QGraphicsItem* child : childItems() )
{
auto* text = dynamic_cast<QGraphicsTextItem*>( child );
if ( !text || text->pos().x() > 12 || std::abs( text->pos().y() - labelTop ) > 1 ) continue;
const QRectF bounds = text->mapRectToParent( text->boundingRect() );
left = std::max( left, bounds.right() - text->document()->documentMargin() );
}
auto* marker = new QGraphicsTextItem( this );
marker->setFont( font );
marker->setDefaultTextColor( QColor( 130, 135, 145 ) );
marker->setPos( left, labelTop + 2 );
marker->setAcceptedMouseButtons( Qt::NoButton );
const QString text = QString::fromUtf8( "\u25c2 " ) + info.hiddenSources.join( ", " );
const qreal margin = 2 * marker->document()->documentMargin();
const QRectF line( left, labelTop, m_width - left, marker->boundingRect().height() );
const qreal right = freeRight( line, m_width - 12, { marker } ) - 4;
marker->setPlainText(
QFontMetrics( font ).elidedText( text, Qt::ElideRight, static_cast<int>( std::max( 0.0, right - left - margin ) ) ) );
marker->setToolTip( hiddenSourcesToolTip( info.hiddenSources ) );
}
}
2026-10-05 16:12:01 +02:00
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void GraphNode::setConfigValue( const QString& fieldName, const QString& value )
{
if ( m_collapsed )
{
m_collapsedValues[fieldName] = value;
applyStyle();
}
auto* port = m_isConfig ? m_outputs.value( "field:" + fieldName, nullptr ) : m_inputs.value( "field:" + fieldName, nullptr );
if ( !port ) return;
if ( !m_isConfig ) port->setValueToolTip( value );
if ( !m_valueSlots.contains( fieldName ) ) return;
const ValueSlot slot = m_valueSlots.value( fieldName );
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( slot.left, slot.top.value_or( 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();
qreal right = slot.right;
if ( !m_isConfig && !slot.centered )
{
// In a task box the value may use the width up to the output text on the same line
const QRectF line( slot.left, text->pos().y(), m_width - slot.left, text->boundingRect().height() );
const QList<QGraphicsItem*> values( m_configValues.cbegin(), m_configValues.cend() );
right = freeRight( line, right, values );
}
const qreal available = std::max( 0.0, right - slot.left - margin );
text->setPlainText( QFontMetrics( text->font() ).elidedText( slot.prefix + value, Qt::ElideRight, static_cast<int>( available ) ) );
if ( slot.centered ) text->setX( ( slot.left + slot.right - text->boundingRect().width() ) / 2.0 );
text->setToolTip( fieldName + ": " + value );
}
2026-10-05 16:12:01 +02:00
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
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 );
}
2026-10-05 16:12:01 +02:00
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
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();
}
2026-10-26 14:19:29 +01:00
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
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 );
2026-10-26 14:19:29 +01:00
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()
{
2026-10-26 14:19:29 +01:00
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 );
2026-10-26 14:19:29 +01:00
if ( m_isControl ) pen.setStyle( Qt::DashDotLine );
setPen( pen );
m_arrow->setBrush( color );
}
2026-10-05 16:12:01 +02:00
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void GraphEdge::updatePath()
{
// Control edges run across the data flow: downwards, or to the right in vertical layouts
const bool vertical = m_from->isVertical();
2026-10-26 14:19:29 +01:00
if ( m_isControl )
{
const QPointF start = m_from->controlAnchor( true );
const QPointF end = m_to->controlAnchor( false );
const QPainterPath path = flowPath( start, end, !vertical, 40.0, false );
2026-10-26 14:19:29 +01:00
setPath( path );
m_arrow->setPolygon( arrowHead( end, !vertical ) );
2026-10-26 14:19:29 +01:00
if ( m_label )
{
const QPointF anchor = path.pointAtPercent( 0.5 );
const QRectF bounds = m_label->boundingRect();
if ( vertical )
m_label->setPos( anchor.x() - bounds.width() / 2.0, anchor.y() - bounds.height() );
else
m_label->setPos( anchor.x() + 4, anchor.y() - bounds.height() / 2 );
2026-10-26 14:19:29 +01:00
}
return;
}
const QPointF start = m_from->portPosition( m_outputKey, true );
const QPointF end = m_to->portPosition( m_inputKey, false );
const QPainterPath path = connectionPath( start, end, vertical );
setPath( path );
m_arrow->setPolygon( arrowHead( end, vertical ) );
// Place the label beside the curve shortly before the target
if ( m_label )
{
const QPointF anchor = path.pointAtPercent( 0.85 );
const QRectF bounds = m_label->boundingRect();
if ( vertical )
m_label->setPos( anchor.x() + 4, anchor.y() - bounds.height() / 2.0 );
else
m_label->setPos( anchor.x() - bounds.width() / 2.0, anchor.y() - bounds.height() );
}
}
2026-10-05 16:12:01 +02:00
//--------------------------------------------------------------------------------------------------
/// A wide stroke, so the thin curve is easy to click
2026-10-05 16:12:01 +02:00
//--------------------------------------------------------------------------------------------------
QPainterPath GraphEdge::shape() const
{
QPainterPathStroker stroker;
stroker.setWidth( 12 );
return stroker.createStroke( path() );
}
2026-10-05 16:12:01 +02:00
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
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";
2026-10-26 14:19:29 +01:00
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;
2026-10-30 12:55:50 +01:00
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();
2026-10-26 14:19:29 +01:00
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_taskNodes.isEmpty() )
{
placeLabelsAboveGraph();
m_scene->setSceneRect( m_scene->itemsBoundingRect().adjusted( -50, -50, 50, 50 ) );
}
if ( !m_scene->sceneRect().isEmpty() ) fitInView( m_scene->sceneRect(), Qt::KeepAspectRatio );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
RiuWorkflowGraphView::ViewState RiuWorkflowGraphView::viewState()
{
rememberPositions();
return { m_positions, transform(), mapToScene( viewport()->rect().center() ), m_fitOnResize, m_lastSelectedTask };
}
//--------------------------------------------------------------------------------------------------
/// Use a state from viewState() for the next showGraph() with ViewportPolicy::Keep. The tasks shown
/// now are forgotten.
//--------------------------------------------------------------------------------------------------
void RiuWorkflowGraphView::setViewState( const ViewState& state )
{
finishConnectionDrag( false );
m_rebuilding = true;
m_taskNodes.clear();
m_configNodes.clear();
m_runStatusLabel = nullptr;
m_hiddenTasksLabel = nullptr;
m_scene->clear();
m_rebuilding = false;
m_positions = state.positions;
m_fitOnResize = state.fit;
m_lastSelectedTask = state.selectedTask;
m_restoredViewport = std::make_pair( state.transform, state.center );
}
//--------------------------------------------------------------------------------------------------
/// Keep the status labels at the top-left corner of the boxes, so they don't stretch the scene
/// when tasks are hidden or moved
//--------------------------------------------------------------------------------------------------
void RiuWorkflowGraphView::placeLabelsAboveGraph()
{
QRectF boxes;
for ( auto* node : m_taskNodes )
boxes = boxes.united( node->sceneBoundingRect() );
for ( auto* node : m_configNodes )
boxes = boxes.united( node->sceneBoundingRect() );
if ( boxes.isNull() ) return;
if ( m_runStatusLabel ) m_runStatusLabel->setPos( boxes.left(), boxes.top() - 45 );
if ( m_hiddenTasksLabel ) m_hiddenTasksLabel->setPos( boxes.left(), boxes.top() - 65 );
}
//--------------------------------------------------------------------------------------------------
/// 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 auto restored = std::exchange( m_restoredViewport, std::nullopt );
const QTransform oldTransform = restored ? restored->first : transform();
const QPointF oldCenter = restored ? restored->second : mapToScene( viewport()->rect().center() );
const bool keepViewport = policy == ViewportPolicy::Keep && ( restored || !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_hiddenTasksLabel = 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 bool inlined = m_inlineConfigValues;
if ( inlined )
{
// Positions of config boxes are forgotten, so the boxes return beside their tasks when shown again
for ( auto it = m_positions.begin(); it != m_positions.end(); )
it = it.key().startsWith( "config:" ) ? m_positions.erase( it ) : std::next( it );
}
const bool vertical = m_orientation == RiuWorkflowGraphLayout::Orientation::Vertical;
const qreal configOffset = inlined ? 0.0 : configWidth + configGap;
const QJsonArray tasks = graph.value( "tasks" ).toArray();
const QJsonArray edges = graph.value( "edges" ).toArray();
const QString result = graph.value( "result_task" ).toString();
QStringList hiddenNames;
for ( const QJsonValue& task : tasks )
{
const QString name = task.toObject().value( "name" ).toString();
if ( m_hiddenTasks.contains( name ) && !hiddenNames.contains( name ) ) hiddenNames.append( name );
}
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 ) || hiddenNames.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 );
}
if ( taskNames.isEmpty() )
{
m_scene->addText( "All tasks are hidden.\nRight-click and use Show Hidden Tasks to show them." );
m_scene->setSceneRect( m_scene->itemsBoundingRect().adjusted( -30, -30, 30, 30 ) );
fitInView( m_scene->sceneRect(), Qt::KeepAspectRatio );
m_rebuilding = false;
m_pendingPositions.clear();
return;
}
// Read-only graphs only show the ports in use, plus the declared outputs of end tasks
QMap<QString, QStringList> usedInputs;
QMap<QString, QStringList> usedOutputs;
QMap<QString, QMap<QString, QStringList>> hiddenSources; // Task -> input key -> hidden tasks connected to it
QMap<QString, QStringList> hiddenConditions; // Task -> hidden tasks its run condition depends on
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 ( hiddenNames.contains( from ) && taskData.contains( to ) )
{
if ( edge.value( "kind" ).toString() == "when" )
hiddenConditions[to].append( from );
else
{
const QString key = keyForField( edge.value( "input" ).toString() );
usedInputs[to].append( key );
if ( !hiddenSources[to][key].contains( from ) ) hiddenSources[to][key].append( from );
}
}
if ( !taskData.contains( from ) || !taskData.contains( to ) ) continue;
layoutEdges.emplace_back( from, to );
if ( 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 ); } );
// Inlined config values are shown on the task's own inputs
if ( inlined )
{
for ( PortInfo& port : inputs )
port.configured = configured.contains( port.key );
}
for ( PortInfo& port : inputs )
{
port.hiddenSources = hiddenSources.value( name ).value( port.key );
if ( !port.hiddenSources.isEmpty() && port.style == PortInfo::Style::Missing ) port.style = PortInfo::Style::Normal;
}
const QJsonObject map = data.value( "map" ).toObject();
const bool nested = data.value( "nested_workflow" ).isObject();
const QString nestedFile = data.value( "nested_workflow" ).toObject().value( "file" ).toString();
QStringList subtitles;
if ( nested ) subtitles << ( nestedFile.isEmpty() ? QString( "nested workflow" ) : QString( "workflow: %1" ).arg( nestedFile ) );
if ( !map.isEmpty() ) subtitles << QString::fromUtf8( "⟳ map over %1" ).arg( map.value( "over" ).toString() );
const QString subtitle = subtitles.join( " " );
const bool isSwitch = data.value( "switch" ).toBool();
const bool collapsed = m_collapsedTasks.contains( name );
auto* node = new GraphNode( name, inputs, outputs, false, subtitle, isSwitch, collapsed, !map.isEmpty(), vertical );
node->setNested( nested );
2026-10-26 14:19:29 +01:00
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";
2026-10-26 14:19:29 +01:00
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";
if ( nested )
toolTip << QString( "Nested workflow%1: runs a whole workflow as one task. Double-click to open it." )
.arg( nestedFile.isEmpty() ? QString() : QString( " (%1)" ).arg( nestedFile ) );
if ( hiddenConditions.contains( name ) )
toolTip << QString( "Run condition on hidden task: %1" ).arg( hiddenConditions.value( name ).join( ", " ) );
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 );
// The config values of a collapsed task are shown in its tooltip
if ( !inlined && !collapsed && !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,
{},
false,
false,
false,
vertical );
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;
};
const auto layout = RiuWorkflowGraphLayout::computeLayers( taskNames, layoutEdges );
// Vertical layouts place config boxes above their tasks. The tallest config box of a row sets
// the space above all tasks of the row, so the tasks line up.
QMap<QString, qreal> configSpace;
if ( vertical )
{
for ( const QStringList& rank : layout.layers )
{
qreal space = 0.0;
for ( const QString& name : rank )
{
if ( auto* config = m_configNodes.value( name, nullptr ) ) space = std::max( space, config->height() + configGapVertical );
}
for ( const QString& name : rank )
configSpace[name] = space;
}
}
QMap<QString, QSizeF> slotSizes;
for ( const QString& name : taskNames )
{
const GraphNode* node = m_taskNodes.value( name );
const GraphNode* config = m_configNodes.value( name, nullptr );
if ( vertical )
slotSizes[name] = QSizeF( std::max( node->width(), config ? config->width() : 0.0 ), configSpace.value( name ) + node->height() );
else
slotSizes[name] = QSizeF( configOffset + node->width(), slotHeight( name ) );
}
const RiuWorkflowGraphLayout::Spacing spacing{ .columnStep = columnStep, .rowGap = rowGap, .rankGap = rankGap, .siblingGap = siblingGap };
const auto slotPositions = RiuWorkflowGraphLayout::computePositions( layout, slotSizes, m_orientation, spacing );
for ( auto it = slotPositions.cbegin(); it != slotPositions.cend(); ++it )
{
const QString& name = it.key();
const QPointF slot = it.value();
auto* node = m_taskNodes.value( name );
auto* config = m_configNodes.value( name, nullptr );
if ( vertical )
{
const qreal slotWidth = slotSizes.value( name ).width();
const QPointF taskPos( slot.x() + ( slotWidth - node->width() ) / 2.0, slot.y() + configSpace.value( name ) );
node->setPos( taskPos );
if ( config )
config->setPos( taskPos + QPointF( ( node->width() - config->width() ) / 2.0, -config->height() - configGapVertical ) );
}
else
{
node->setPos( slot.x() + configOffset, slot.y() + ( slotHeight( name ) - node->height() ) / 2.0 );
if ( config ) config->setPos( slot.x(), slot.y() + ( slotHeight( name ) - config->height() ) / 2.0 );
}
}
// 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 && vertical )
config->setPos( *position + QPointF( ( it.value()->width() - config->width() ) / 2.0, -config->height() - configGapVertical ) );
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;
2026-10-26 14:19:29 +01:00
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 );
2026-10-26 14:19:29 +01:00
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" ) )
{
2026-10-30 12:55:50 +01:00
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 ) );
if ( !hiddenNames.isEmpty() )
{
m_hiddenTasksLabel =
m_scene->addText( QString( "%1 hidden task%2" ).arg( hiddenNames.size() ).arg( hiddenNames.size() > 1 ? "s" : "" ) );
QFont font;
font.setPointSize( 8 );
font.setItalic( true );
m_hiddenTasksLabel->setFont( font );
m_hiddenTasksLabel->setDefaultTextColor( QColor( 130, 135, 145 ) );
m_hiddenTasksLabel->setToolTip( "Hidden: " + hiddenNames.join( ", " ) + "\nRight-click the canvas to show hidden tasks" );
}
placeLabelsAboveGraph();
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;
}
2026-10-05 16:12:01 +02:00
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
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;
}
2026-10-26 14:19:29 +01:00
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
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() )
{
2026-10-26 14:19:29 +01:00
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 );
else if ( auto* task = m_taskNodes.value( taskName, nullptr ) )
task->setConfigValue( fieldName, value );
}
//--------------------------------------------------------------------------------------------------
/// Show config values in the task boxes instead of in separate config boxes. Takes effect when the
/// graph is shown next time.
//--------------------------------------------------------------------------------------------------
void RiuWorkflowGraphView::setInlineConfigValues( bool inlineValues )
{
m_inlineConfigValues = inlineValues;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
bool RiuWorkflowGraphView::inlineConfigValues() const
{
return m_inlineConfigValues;
}
//--------------------------------------------------------------------------------------------------
/// Takes effect when the graph is shown next time. Positions the user has chosen are kept with
/// ViewportPolicy::Keep, so use ViewportPolicy::Fit to lay out every task in the new orientation.
//--------------------------------------------------------------------------------------------------
void RiuWorkflowGraphView::setOrientation( RiuWorkflowGraphLayout::Orientation orientation )
{
m_orientation = orientation;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
RiuWorkflowGraphLayout::Orientation RiuWorkflowGraphView::orientation() const
{
return m_orientation;
}
//--------------------------------------------------------------------------------------------------
/// Tasks left out of the graph. Takes effect when the graph is shown next time.
//--------------------------------------------------------------------------------------------------
void RiuWorkflowGraphView::setHiddenTasks( const QStringList& taskNames )
{
m_hiddenTasks = taskNames;
}
//--------------------------------------------------------------------------------------------------
/// Tasks shown with their title only. Takes effect when the graph is shown next time.
//--------------------------------------------------------------------------------------------------
void RiuWorkflowGraphView::setCollapsedTasks( const QStringList& taskNames )
{
m_collapsedTasks = taskNames;
}
2026-10-05 16:12:01 +02:00
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
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 );
// The ports of collapsed tasks are hidden, and can't be connected
if ( !port || port->isConfig() || !port->isVisible() ) 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;
}
2026-10-05 16:12:01 +02:00
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
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;
2026-10-26 14:19:29 +01:00
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 );
}
2026-10-05 16:12:01 +02:00
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
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;
m_dragPath->setPath( connectionPath( from, to, m_orientation == RiuWorkflowGraphLayout::Orientation::Vertical ) );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
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() );
}
2026-10-05 16:12:01 +02:00
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RiuWorkflowGraphView::resizeEvent( QResizeEvent* event )
{
QGraphicsView::resizeEvent( event );
if ( m_fitOnResize && !m_scene->sceneRect().isEmpty() ) fitInView( m_scene->sceneRect(), Qt::KeepAspectRatio );
}
//--------------------------------------------------------------------------------------------------
/// A double-click on the title row of a task collapses or expands it
//--------------------------------------------------------------------------------------------------
void RiuWorkflowGraphView::mouseDoubleClickEvent( QMouseEvent* event )
{
if ( event->button() == Qt::LeftButton )
{
QGraphicsItem* item = itemAt( event->pos() );
// The first click on the chevron already toggled the task
if ( item && item->data( toggleDataKey ).toString() == collapseToggle )
{
event->accept();
return;
}
for ( QGraphicsItem* current = item; current; current = current->parentItem() )
{
auto* node = dynamic_cast<GraphNode*>( current );
if ( !node ) continue;
// A nested workflow opens anywhere in its box; the chevron collapses it
if ( m_taskNodes.value( node->name() ) == node && node->isNested() )
{
emit openNestedRequested( node->name() );
event->accept();
return;
}
if ( m_taskNodes.value( node->name() ) == node && node->isHeaderAt( mapToScene( event->pos() ) ) )
{
emit collapseToggled( node->name() );
event->accept();
return;
}
break;
}
}
QGraphicsView::mouseDoubleClickEvent( event );
}
2026-10-05 16:12:01 +02:00
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RiuWorkflowGraphView::mousePressEvent( QMouseEvent* event )
{
if ( event->button() == Qt::LeftButton ) m_fitOnResize = false;
if ( event->button() == Qt::LeftButton )
{
if ( QGraphicsItem* item = itemAt( event->pos() ); item && item->data( toggleDataKey ).toString() == collapseToggle )
{
if ( auto* node = dynamic_cast<GraphNode*>( item->parentItem() ) ) emit collapseToggled( node->name() );
event->accept();
return;
}
}
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 );
}
2026-10-05 16:12:01 +02:00
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
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() ) );
}