mirror of
https://github.com/OPM/ResInsight.git
synced 2026-11-13 09:37:29 -06:00
Add a per-workflow Vertical Layout toggle to the workflow editor toolbar and canvas context menu. In vertical mode ranks are stacked top to bottom, task inputs sit on the top edge and outputs on the bottom edge, config boxes are placed above their task and control edges run between the sides.
2844 lines
124 KiB
C++
2844 lines
124 KiB
C++
/////////////////////////////////////////////////////////////////////////////////
|
|
//
|
|
// Copyright (C) 2026- Equinor ASA
|
|
//
|
|
// ResInsight is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY
|
|
// WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
|
// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
|
// at <http://www.gnu.org/licenses/gpl.html> for more details.
|
|
//
|
|
/////////////////////////////////////////////////////////////////////////////////
|
|
|
|
#include "RiuWorkflowGraphView.h"
|
|
|
|
#include "RiuWorkflowGraphLayout.h"
|
|
|
|
#include <QContextMenuEvent>
|
|
#include <QDragEnterEvent>
|
|
#include <QFontMetrics>
|
|
#include <QGraphicsEllipseItem>
|
|
#include <QGraphicsPathItem>
|
|
#include <QGraphicsPolygonItem>
|
|
#include <QGraphicsRectItem>
|
|
#include <QGraphicsScene>
|
|
#include <QGraphicsTextItem>
|
|
#include <QIcon>
|
|
#include <QJsonValue>
|
|
#include <QKeyEvent>
|
|
#include <QMimeData>
|
|
#include <QMouseEvent>
|
|
#include <QPainter>
|
|
#include <QPainterPath>
|
|
#include <QPainterPathStroker>
|
|
#include <QPen>
|
|
#include <QResizeEvent>
|
|
#include <QStyleOptionGraphicsItem>
|
|
#include <QTextDocument>
|
|
#include <QToolTip>
|
|
#include <QWheelEvent>
|
|
#include <QtMath>
|
|
|
|
#include <algorithm>
|
|
#include <cmath>
|
|
#include <limits>
|
|
#include <optional>
|
|
#include <vector>
|
|
|
|
namespace
|
|
{
|
|
constexpr qreal nodeWidth = 240.0;
|
|
constexpr qreal configWidth = 190.0;
|
|
constexpr qreal portStep = 40.0;
|
|
constexpr qreal portTop = 51.0;
|
|
constexpr qreal subtitleGap = 16.0;
|
|
constexpr qreal stackOffset = 5.0;
|
|
constexpr qreal columnStep = 650.0;
|
|
constexpr qreal configGap = 110.0;
|
|
constexpr qreal rowGap = 55.0;
|
|
constexpr qreal 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 );
|
|
const QColor conditionColor( 135, 75, 150 );
|
|
const QColor nestedColor( 30, 125, 120 );
|
|
const QColor nestedHeaderColor( 196, 231, 226 );
|
|
|
|
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 );
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
///
|
|
//--------------------------------------------------------------------------------------------------
|
|
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
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
///
|
|
//--------------------------------------------------------------------------------------------------
|
|
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
|
|
};
|
|
|
|
//==================================================================================================
|
|
///
|
|
//==================================================================================================
|
|
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;
|
|
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 };
|
|
};
|
|
|
|
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;
|
|
void addDiamondPorts( const std::vector<PortInfo>& ports, bool output );
|
|
void applyStyle();
|
|
void updateStateLabelPosition();
|
|
void paintNestedHeader( QPainter* painter ) const;
|
|
QPolygonF diamondPolygon( const QRectF& bounds ) const;
|
|
|
|
QString m_name;
|
|
bool m_isConfig;
|
|
bool m_diamond;
|
|
bool m_collapsed;
|
|
bool m_vertical;
|
|
qreal m_width;
|
|
QMap<QString, PortItem*> m_inputs;
|
|
QMap<QString, PortItem*> m_outputs;
|
|
QMap<QString, QGraphicsTextItem*> m_configValues;
|
|
QMap<QString, ValueSlot> m_valueSlots;
|
|
std::vector<GraphEdge*> m_edges;
|
|
QGraphicsTextItem* m_title = nullptr;
|
|
QGraphicsTextItem* m_chevron = nullptr;
|
|
QGraphicsTextItem* m_stateLabel = nullptr;
|
|
QGraphicsTextItem* m_issueLabel = nullptr;
|
|
QGraphicsTextItem* m_subtitle = nullptr;
|
|
QGraphicsTextItem* m_branches = nullptr;
|
|
std::vector<QAbstractGraphicsShapeItem*> m_stack;
|
|
qreal m_portTop = portTop;
|
|
QMap<QString, QString> m_itemStates;
|
|
QMap<QString, QString> m_itemErrors;
|
|
QString m_state;
|
|
QString m_stateError;
|
|
QString m_baseToolTip;
|
|
QString m_issueText;
|
|
QStringList m_collapsedInfo; // Shown in the tooltip, as the box does not show it
|
|
QMap<QString, QString> m_collapsedValues;
|
|
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 );
|
|
// A control edge: `to` only runs when the condition on the output of `from` holds
|
|
GraphEdge( GraphNode* from, GraphNode* to, const QString& condition, bool selectable );
|
|
|
|
void updatePath();
|
|
QPainterPath shape() const override;
|
|
bool isConfig() const { return m_isConfig; }
|
|
bool isControl() const { return m_isControl; }
|
|
GraphNode* fromNode() const { return m_from; }
|
|
GraphNode* toNode() const { return m_to; }
|
|
QString outputField() const { return fieldForKey( m_outputKey ); }
|
|
QString inputField() const { return fieldForKey( m_inputKey ); }
|
|
void setLabel( const QString& label, const QString& toolTip );
|
|
|
|
protected:
|
|
QVariant itemChange( GraphicsItemChange change, const QVariant& value ) override;
|
|
|
|
private:
|
|
void applyPen();
|
|
|
|
GraphNode* m_from;
|
|
GraphNode* m_to;
|
|
QString m_outputKey;
|
|
QString m_inputKey;
|
|
bool m_isConfig;
|
|
bool m_isControl = false;
|
|
QGraphicsPolygonItem* m_arrow;
|
|
QGraphicsTextItem* m_label = nullptr;
|
|
};
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
///
|
|
//--------------------------------------------------------------------------------------------------
|
|
IconItem::IconItem( const QIcon& icon, qreal size, QGraphicsItem* parent )
|
|
: QGraphicsItem( parent )
|
|
, m_icon( icon )
|
|
, m_size( size )
|
|
{
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
///
|
|
//--------------------------------------------------------------------------------------------------
|
|
QRectF IconItem::boundingRect() const
|
|
{
|
|
return QRectF( 0, 0, m_size, m_size );
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
/// Renders the icon at the device pixel size of the item. SVG icons are rendered at that size,
|
|
/// bitmaps are scaled from the largest available size.
|
|
//--------------------------------------------------------------------------------------------------
|
|
void IconItem::paint( QPainter* painter, const QStyleOptionGraphicsItem* /*option*/, QWidget* /*widget*/ )
|
|
{
|
|
const qreal zoom = QStyleOptionGraphicsItem::levelOfDetailFromTransform( painter->worldTransform() );
|
|
const qreal pixelSize = m_size * zoom * ( painter->device() ? painter->device()->devicePixelRatioF() : 1.0 );
|
|
const int extent = std::max( 1, qCeil( pixelSize ) );
|
|
|
|
const QPixmap pixmap = m_icon.pixmap( QSize( extent, extent ), 1.0 );
|
|
painter->setRenderHint( QPainter::SmoothPixmapTransform );
|
|
painter->drawPixmap( boundingRect(), pixmap, QRectF( pixmap.rect() ) );
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
///
|
|
//--------------------------------------------------------------------------------------------------
|
|
PortItem::PortItem( GraphNode* node, const PortInfo& info, bool output, bool config, const QPointF& center )
|
|
: QGraphicsEllipseItem( center.x() - portRadius, center.y() - portRadius, 2 * portRadius, 2 * portRadius, node )
|
|
, m_node( node )
|
|
, m_key( info.key )
|
|
, m_output( output )
|
|
, m_config( config )
|
|
, m_center( center )
|
|
, m_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 );
|
|
|
|
// An outer ring marks inputs that collect several connections, a dashed one alternatives
|
|
if ( info.collect || info.firstOf )
|
|
{
|
|
const qreal ring = portRadius + 3.0;
|
|
auto* outer = new QGraphicsEllipseItem( center.x() - ring, center.y() - ring, 2 * ring, 2 * ring, node );
|
|
outer->setPen( QPen( color, 1.2, info.firstOf ? Qt::DotLine : Qt::SolidLine ) );
|
|
outer->setBrush( Qt::NoBrush );
|
|
outer->setAcceptedMouseButtons( Qt::NoButton );
|
|
outer->setZValue( 1.5 );
|
|
}
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
///
|
|
//--------------------------------------------------------------------------------------------------
|
|
void PortItem::setHighlight( Highlight highlight, const QString& reason )
|
|
{
|
|
const qreal radius = highlight == Highlight::Valid ? portRadius * 1.8 : portRadius;
|
|
setRect( m_center.x() - radius, m_center.y() - radius, 2 * radius, 2 * radius );
|
|
switch ( highlight )
|
|
{
|
|
case Highlight::None:
|
|
setBrush( m_brush );
|
|
setPen( m_pen );
|
|
setToolTip( m_toolTip );
|
|
break;
|
|
case Highlight::Valid:
|
|
setBrush( validTargetColor );
|
|
setPen( QPen( validTargetColor.darker( 130 ), 1.5 ) );
|
|
setToolTip( m_toolTip );
|
|
break;
|
|
case Highlight::Invalid:
|
|
setBrush( invalidTargetColor );
|
|
setPen( Qt::NoPen );
|
|
setToolTip( reason.isEmpty() ? m_toolTip : m_toolTip + "\n\nCannot connect: " + reason );
|
|
break;
|
|
}
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
/// 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,
|
|
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 )
|
|
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 );
|
|
}
|
|
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 )
|
|
{
|
|
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 );
|
|
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 )
|
|
{
|
|
addDiamondPorts( inputs, false );
|
|
addDiamondPorts( outputs, true );
|
|
}
|
|
else if ( m_vertical )
|
|
{
|
|
addColumnPorts( inputs, outputs );
|
|
}
|
|
else
|
|
{
|
|
addPorts( inputs, false );
|
|
addPorts( outputs, true );
|
|
addHiddenSourceMarkers( inputs );
|
|
}
|
|
setTitle( isConfig ? "Config: " + name : name, {} );
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
///
|
|
//--------------------------------------------------------------------------------------------------
|
|
qreal GraphNode::width() const
|
|
{
|
|
return m_width;
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
///
|
|
//--------------------------------------------------------------------------------------------------
|
|
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 );
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
///
|
|
//--------------------------------------------------------------------------------------------------
|
|
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;
|
|
m_title->setPlainText( QFontMetrics( m_title->font() ).elidedText( title, Qt::ElideRight, static_cast<int>( available ) ) );
|
|
m_title->setToolTip( title );
|
|
if ( m_diamond ) m_title->setPos( ( m_width - m_title->boundingRect().width() ) / 2, rect().height() / 2 - 24 );
|
|
if ( !toolTip.isEmpty() ) m_baseToolTip = toolTip;
|
|
applyStyle();
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
///
|
|
//--------------------------------------------------------------------------------------------------
|
|
void GraphNode::addPorts( const std::vector<PortInfo>& ports, bool output )
|
|
{
|
|
auto& portMap = output ? m_outputs : m_inputs;
|
|
for ( size_t row = 0; row < ports.size(); ++row )
|
|
{
|
|
const PortInfo& info = ports[row];
|
|
const qreal x = output ? m_width : 0.0;
|
|
const qreal y = m_portTop + row * portStep;
|
|
auto* port = new PortItem( this, info, output, m_isConfig, QPointF( x, y ) );
|
|
portMap.insert( info.key, port );
|
|
|
|
const QString toolTip = info.toolTip.isEmpty() ? info.label : info.toolTip;
|
|
const qreal textWidth = m_isConfig ? m_width - 24 : m_width / 2 - 18;
|
|
const bool showType = !info.typeName.isEmpty();
|
|
auto addText = [&]( const QString& content,
|
|
const QFont& font,
|
|
const QColor& color,
|
|
qreal top,
|
|
qreal indent = 0.0,
|
|
qreal maxWidth = std::numeric_limits<qreal>::max() )
|
|
{
|
|
const qreal width = std::min( textWidth, maxWidth ) - indent;
|
|
auto* text = new QGraphicsTextItem( QFontMetrics( font ).elidedText( content, Qt::ElideRight, width ), this );
|
|
text->setFont( font );
|
|
text->setToolTip( toolTip );
|
|
text->setDefaultTextColor( color );
|
|
text->setPos( output ? m_width - text->boundingRect().width() - 12 : 10 + indent, top );
|
|
text->setAcceptedMouseButtons( Qt::NoButton );
|
|
return text;
|
|
};
|
|
|
|
QFont labelFont;
|
|
labelFont.setPointSize( 9 );
|
|
const QColor labelColor = info.style == PortInfo::Style::Missing ? missingPortColor : QColor( 45, 65, 80 );
|
|
addText( info.label, labelFont, labelColor, y - ( m_isConfig || !output || showType ? 20 : 13 ) );
|
|
|
|
// The type goes on a second, smaller line below the label
|
|
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;
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
/// 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
|
|
//--------------------------------------------------------------------------------------------------
|
|
void GraphNode::addDiamondPorts( const std::vector<PortInfo>& ports, bool output )
|
|
{
|
|
auto& portMap = output ? m_outputs : m_inputs;
|
|
const qreal halfWidth = m_width / 2;
|
|
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 ) );
|
|
}
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
///
|
|
//--------------------------------------------------------------------------------------------------
|
|
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 );
|
|
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.
|
|
//--------------------------------------------------------------------------------------------------
|
|
QPointF GraphNode::controlAnchor( bool outgoing ) const
|
|
{
|
|
if ( m_vertical ) return mapToScene( QPointF( outgoing ? m_width : 0.0, rect().height() / 2 ) );
|
|
return mapToScene( QPointF( m_width / 2, outgoing ? rect().height() : 0.0 ) );
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
/// The branch labels of a switch task, shown below its name
|
|
//--------------------------------------------------------------------------------------------------
|
|
void GraphNode::setBranches( const QStringList& branches )
|
|
{
|
|
if ( branches.isEmpty() ) return;
|
|
if ( !m_branches )
|
|
{
|
|
m_branches = new QGraphicsTextItem( this );
|
|
QFont font;
|
|
font.setPointSize( 8 );
|
|
font.setItalic( true );
|
|
m_branches->setFont( font );
|
|
m_branches->setDefaultTextColor( conditionColor );
|
|
m_branches->setAcceptedMouseButtons( Qt::NoButton );
|
|
}
|
|
const QString text = branches.join( " | " );
|
|
m_branches->setPlainText( QFontMetrics( m_branches->font() ).elidedText( text, Qt::ElideRight, static_cast<int>( m_width * 0.5 ) ) );
|
|
m_branches->setToolTip( "Branches: " + branches.join( ", " ) );
|
|
const qreal x = m_diamond ? ( m_width - m_branches->boundingRect().width() ) / 2 : 10;
|
|
m_branches->setPos( x, m_diamond ? rect().height() / 2 - 4 : 24 );
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
///
|
|
//--------------------------------------------------------------------------------------------------
|
|
QPointF GraphNode::portPosition( const QString& key, bool output ) const
|
|
{
|
|
const auto& ports = output ? m_outputs : m_inputs;
|
|
auto* port = ports.value( key, nullptr );
|
|
return port ? port->mapToScene( port->rect().center() ) : scenePos();
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
///
|
|
//--------------------------------------------------------------------------------------------------
|
|
void GraphNode::addEdge( GraphEdge* edge )
|
|
{
|
|
m_edges.push_back( edge );
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
///
|
|
//--------------------------------------------------------------------------------------------------
|
|
QString GraphNode::name() const
|
|
{
|
|
return m_name;
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
///
|
|
//--------------------------------------------------------------------------------------------------
|
|
bool GraphNode::isConfig() const
|
|
{
|
|
return m_isConfig;
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
///
|
|
//--------------------------------------------------------------------------------------------------
|
|
std::vector<PortItem*> GraphNode::ports() const
|
|
{
|
|
std::vector<PortItem*> result;
|
|
for ( PortItem* port : m_inputs )
|
|
result.push_back( port );
|
|
for ( PortItem* port : m_outputs )
|
|
result.push_back( port );
|
|
return result;
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
///
|
|
//--------------------------------------------------------------------------------------------------
|
|
void GraphNode::setTaskState( const QString& state, const QString& error )
|
|
{
|
|
if ( m_isConfig ) return;
|
|
m_state = state;
|
|
m_stateError = error;
|
|
if ( state.isEmpty() )
|
|
{
|
|
m_itemStates.clear();
|
|
m_itemErrors.clear();
|
|
}
|
|
applyStyle();
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
/// The state of one item of a mapped task
|
|
//--------------------------------------------------------------------------------------------------
|
|
void GraphNode::setItemState( const QString& item, const QString& state, const QString& error )
|
|
{
|
|
if ( m_isConfig ) return;
|
|
m_itemStates[item] = state;
|
|
if ( error.isEmpty() )
|
|
m_itemErrors.remove( item );
|
|
else
|
|
m_itemErrors[item] = error;
|
|
applyStyle();
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
///
|
|
//--------------------------------------------------------------------------------------------------
|
|
void GraphNode::setSubtitleToolTip( const QString& toolTip )
|
|
{
|
|
if ( m_subtitle ) m_subtitle->setToolTip( toolTip );
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
///
|
|
//--------------------------------------------------------------------------------------------------
|
|
void GraphNode::setIssues( const QJsonArray& issues )
|
|
{
|
|
if ( m_isConfig ) return;
|
|
|
|
m_errorCount = 0;
|
|
m_warningCount = 0;
|
|
QStringList lines;
|
|
for ( const QJsonValue& value : issues )
|
|
{
|
|
const QJsonObject issue = value.toObject();
|
|
const bool isError = issue.value( "severity" ).toString() != "warning";
|
|
( isError ? m_errorCount : m_warningCount )++;
|
|
lines.append( QString( "%1 %2" ).arg( isError ? "Error:" : "Warning:", issue.value( "message" ).toString() ) );
|
|
}
|
|
m_issueText = lines.join( "\n" );
|
|
applyStyle();
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
///
|
|
//--------------------------------------------------------------------------------------------------
|
|
void GraphNode::setUnknownType( bool unknown )
|
|
{
|
|
m_unknownType = unknown;
|
|
applyStyle();
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
/// 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 );
|
|
}
|
|
else if ( m_state == "skipped" )
|
|
{
|
|
border = QColor( 150, 150, 150 );
|
|
background = QColor( 240, 240, 240 );
|
|
style = Qt::DashLine;
|
|
}
|
|
else if ( m_errorCount > 0 || m_warningCount > 0 )
|
|
{
|
|
border = m_errorCount > 0 ? errorColor : warningColor;
|
|
style = Qt::DashLine;
|
|
}
|
|
if ( !m_state.isEmpty() || m_errorCount > 0 || m_warningCount > 0 ) width = 2.0;
|
|
if ( isSelected() ) width += 1.0;
|
|
|
|
QPen pen( border, width );
|
|
pen.setStyle( style );
|
|
setPen( pen );
|
|
setBrush( background );
|
|
for ( QAbstractGraphicsShapeItem* card : m_stack )
|
|
{
|
|
card->setPen( QPen( border, 1.0 ) );
|
|
card->setBrush( background.darker( 104 ) );
|
|
}
|
|
|
|
// Item progress of a mapped task, such as "Running 3✓ 1✗"
|
|
int done = 0;
|
|
int failed = 0;
|
|
for ( const QString& itemState : m_itemStates )
|
|
{
|
|
if ( itemState == "completed" ) ++done;
|
|
if ( itemState == "failed" ) ++failed;
|
|
}
|
|
if ( m_stateLabel )
|
|
{
|
|
QString text = m_state.isEmpty() ? "" : m_state.at( 0 ).toUpper() + m_state.mid( 1 );
|
|
if ( !m_itemStates.isEmpty() )
|
|
{
|
|
text += QString( " %1/%2" ).arg( done ).arg( m_itemStates.size() );
|
|
if ( failed > 0 ) text += QString::fromUtf8( " ✗%1" ).arg( failed );
|
|
}
|
|
m_stateLabel->setDefaultTextColor( border );
|
|
m_stateLabel->setPlainText( text );
|
|
if ( m_diamond )
|
|
m_stateLabel->setPos( ( m_width - m_stateLabel->boundingRect().width() ) / 2, rect().height() / 2 + 12 );
|
|
else
|
|
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" ) );
|
|
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 ) );
|
|
}
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
///
|
|
//--------------------------------------------------------------------------------------------------
|
|
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 );
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
///
|
|
//--------------------------------------------------------------------------------------------------
|
|
QVariant GraphNode::itemChange( GraphicsItemChange change, const QVariant& value )
|
|
{
|
|
if ( change == ItemPositionHasChanged )
|
|
{
|
|
for ( GraphEdge* edge : m_edges )
|
|
edge->updatePath();
|
|
if ( scene() ) scene()->setSceneRect( scene()->sceneRect().united( scene()->itemsBoundingRect().adjusted( -50, -50, 50, 50 ) ) );
|
|
}
|
|
else if ( change == ItemSelectedHasChanged && m_title )
|
|
{
|
|
applyStyle();
|
|
}
|
|
return QGraphicsRectItem::itemChange( change, value );
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
///
|
|
//--------------------------------------------------------------------------------------------------
|
|
GraphEdge::GraphEdge( GraphNode* from, QString outputKey, GraphNode* to, QString inputKey, bool isConfig, bool selectable )
|
|
: m_from( from )
|
|
, m_to( to )
|
|
, m_outputKey( std::move( outputKey ) )
|
|
, m_inputKey( std::move( inputKey ) )
|
|
, m_isConfig( isConfig )
|
|
, m_arrow( new QGraphicsPolygonItem( this ) )
|
|
{
|
|
setZValue( -1 );
|
|
if ( selectable )
|
|
{
|
|
setFlag( ItemIsSelectable );
|
|
setToolTip( QString( "%1.%2 \u2192 %3.%4" )
|
|
.arg( from->name(),
|
|
outputField().isEmpty() ? "output" : outputField(),
|
|
to->name(),
|
|
inputField().isEmpty() ? "input" : inputField() ) );
|
|
}
|
|
m_arrow->setPen( Qt::NoPen );
|
|
applyPen();
|
|
m_from->addEdge( this );
|
|
m_to->addEdge( this );
|
|
updatePath();
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
///
|
|
//--------------------------------------------------------------------------------------------------
|
|
GraphEdge::GraphEdge( GraphNode* from, GraphNode* to, const QString& condition, bool selectable )
|
|
: m_from( from )
|
|
, m_to( to )
|
|
, m_isConfig( false )
|
|
, m_isControl( true )
|
|
, m_arrow( new QGraphicsPolygonItem( this ) )
|
|
{
|
|
setZValue( -1 );
|
|
if ( selectable ) setFlag( ItemIsSelectable );
|
|
setToolTip( QString( "'%1' runs only when %2 of '%3' matches; otherwise it is skipped" ).arg( to->name(), condition, from->name() ) );
|
|
m_arrow->setPen( Qt::NoPen );
|
|
applyPen();
|
|
m_from->addEdge( this );
|
|
m_to->addEdge( this );
|
|
updatePath();
|
|
setLabel( condition, toolTip() );
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
/// A label near the target, such as the index or key of a collected member
|
|
//--------------------------------------------------------------------------------------------------
|
|
void GraphEdge::setLabel( const QString& label, const QString& toolTip )
|
|
{
|
|
if ( label.isEmpty() ) return;
|
|
if ( !m_label )
|
|
{
|
|
m_label = new QGraphicsTextItem( this );
|
|
QFont font;
|
|
font.setPointSize( 8 );
|
|
m_label->setFont( font );
|
|
m_label->setDefaultTextColor( m_isControl ? conditionColor : edgeColor );
|
|
m_label->setAcceptedMouseButtons( Qt::NoButton );
|
|
}
|
|
m_label->setPlainText( label );
|
|
if ( !toolTip.isEmpty() ) m_label->setToolTip( toolTip );
|
|
updatePath();
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
///
|
|
//--------------------------------------------------------------------------------------------------
|
|
void GraphEdge::applyPen()
|
|
{
|
|
const QColor color = isSelected() ? selectedEdgeColor : ( m_isConfig ? configPortColor : m_isControl ? conditionColor : edgeColor );
|
|
QPen pen( color, isSelected() ? 3.0 : 1.7 );
|
|
if ( m_isConfig ) pen.setStyle( Qt::DashLine );
|
|
if ( m_isControl ) pen.setStyle( Qt::DashDotLine );
|
|
setPen( pen );
|
|
m_arrow->setBrush( color );
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
///
|
|
//--------------------------------------------------------------------------------------------------
|
|
void GraphEdge::updatePath()
|
|
{
|
|
// Control edges run across the data flow: downwards, or to the right in vertical layouts
|
|
const bool vertical = m_from->isVertical();
|
|
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 );
|
|
setPath( path );
|
|
m_arrow->setPolygon( arrowHead( end, !vertical ) );
|
|
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 );
|
|
}
|
|
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() );
|
|
}
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
/// A wide stroke, so the thin curve is easy to click
|
|
//--------------------------------------------------------------------------------------------------
|
|
QPainterPath GraphEdge::shape() const
|
|
{
|
|
QPainterPathStroker stroker;
|
|
stroker.setWidth( 12 );
|
|
return stroker.createStroke( path() );
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
///
|
|
//--------------------------------------------------------------------------------------------------
|
|
QVariant GraphEdge::itemChange( GraphicsItemChange change, const QVariant& value )
|
|
{
|
|
if ( change == ItemSelectedHasChanged ) applyPen();
|
|
return QGraphicsPathItem::itemChange( change, value );
|
|
}
|
|
} // namespace RiuWorkflowGraphViewItems
|
|
|
|
using namespace RiuWorkflowGraphViewItems;
|
|
|
|
namespace
|
|
{
|
|
//--------------------------------------------------------------------------------------------------
|
|
/// A port described by the graph JSON, with the type and description as the first tooltip lines
|
|
//--------------------------------------------------------------------------------------------------
|
|
PortInfo portFromJson( const QJsonObject& port, const QString& wholeLabel, QStringList* toolTip )
|
|
{
|
|
const QString field = port.value( "name" ).toString();
|
|
PortInfo info{ .key = keyForField( field ),
|
|
.label = field.isEmpty() ? wholeLabel : field,
|
|
.toolTip = {},
|
|
.typeName = port.value( "type" ).toString(),
|
|
.iconResource = port.value( "icon" ).toString() };
|
|
*toolTip = { QString( "%1: %2" ).arg( info.label, port.value( "type" ).toString( "any" ) ) };
|
|
if ( !port.value( "description" ).toString().isEmpty() ) *toolTip << port.value( "description" ).toString();
|
|
return info;
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
/// The port of the whole input or output model of a task
|
|
//--------------------------------------------------------------------------------------------------
|
|
PortInfo wholeModelPort( const QJsonObject& task, bool output )
|
|
{
|
|
const QString prefix = output ? "output" : "input";
|
|
return PortInfo{ .key = "model",
|
|
.label = prefix,
|
|
.toolTip = {},
|
|
.typeName = task.value( prefix + "_types" ).toObject().value( "" ).toString(),
|
|
.iconResource = task.value( prefix + "_icons" ).toObject().value( "" ).toString() };
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
/// Ports of a task in edit mode: every input and output the task type declares
|
|
//--------------------------------------------------------------------------------------------------
|
|
std::pair<std::vector<PortInfo>, std::vector<PortInfo>> editModePorts( const QJsonObject& task, const QJsonArray& edges )
|
|
{
|
|
const QString name = task.value( "name" ).toString();
|
|
|
|
std::vector<PortInfo> inputs;
|
|
bool hasWholeInput = false;
|
|
for ( const QJsonValue& value : task.value( "input_ports" ).toArray() )
|
|
{
|
|
const QJsonObject port = value.toObject();
|
|
const QString field = port.value( "name" ).toString();
|
|
const bool required = port.value( "required" ).toBool();
|
|
const bool provided = port.value( "wired" ).toBool() || port.value( "configured" ).toBool() || port.value( "covered" ).toBool();
|
|
|
|
// The whole input model is only shown when it is wired, or when it is the only way into the task
|
|
if ( field.isEmpty() )
|
|
{
|
|
if ( !port.value( "wired" ).toBool() && !task.value( "whole_input" ).toBool() ) continue;
|
|
hasWholeInput = true;
|
|
}
|
|
|
|
QStringList toolTip;
|
|
PortInfo info = portFromJson( port, "input", &toolTip );
|
|
toolTip << ( required ? "Required" : "Optional" );
|
|
if ( port.value( "configured" ).toBool() ) toolTip << "Set in the job configuration";
|
|
if ( port.value( "covered" ).toBool() ) toolTip << "Provided by a whole-output connection";
|
|
if ( port.value( "first_of" ).toBool() )
|
|
{
|
|
info.firstOf = true;
|
|
toolTip << "Alternatives: the output of the first connected task that ran is used";
|
|
}
|
|
if ( !port.value( "collect" ).toString().isEmpty() )
|
|
{
|
|
info.collect = true;
|
|
toolTip << ( port.value( "collect" ).toString() == "dict"
|
|
? "Connect several outputs to collect them, each under its own key; a dict output is merged by its own keys"
|
|
: "Connect several outputs to collect them into a list; list and dict outputs are flattened" );
|
|
}
|
|
info.toolTip = toolTip.join( "\n" );
|
|
if ( port.value( "map_over" ).toBool() )
|
|
{
|
|
info.toolTip = QString( "%1: %2\n%3\nThe task runs once per item; set the items in the job configuration" )
|
|
.arg( info.label, info.typeName, port.value( "description" ).toString() );
|
|
}
|
|
if ( !provided && !field.isEmpty() ) info.style = required ? PortInfo::Style::Missing : PortInfo::Style::Hollow;
|
|
inputs.push_back( info );
|
|
}
|
|
for ( const QJsonValue& value : edges )
|
|
{
|
|
const QJsonObject edge = value.toObject();
|
|
if ( edge.value( "kind" ).toString() == "when" ) continue;
|
|
if ( edge.value( "to" ).toString() == name && edge.value( "input" ).toString().isEmpty() && !hasWholeInput )
|
|
{
|
|
inputs.insert( inputs.begin(), wholeModelPort( task, false ) );
|
|
hasWholeInput = true;
|
|
}
|
|
}
|
|
|
|
std::vector<PortInfo> outputs;
|
|
for ( const QJsonValue& value : task.value( "output_ports" ).toArray() )
|
|
{
|
|
QStringList toolTip;
|
|
PortInfo info = portFromJson( value.toObject(), "output", &toolTip );
|
|
info.toolTip = toolTip.join( "\n" );
|
|
outputs.push_back( info );
|
|
}
|
|
if ( outputs.empty() ) outputs.push_back( wholeModelPort( task, true ) );
|
|
return { inputs, outputs };
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
///
|
|
//--------------------------------------------------------------------------------------------------
|
|
std::vector<PortInfo> portInfos( const QStringList& keys, bool output, const QJsonObject& types, const QJsonObject& icons )
|
|
{
|
|
std::vector<PortInfo> result;
|
|
for ( const QString& key : keys )
|
|
{
|
|
const QString typeName = types.value( fieldForKey( key ) ).toString();
|
|
const QString label = key == "model" ? ( output ? "output" : "input" ) : fieldForKey( key );
|
|
result.push_back( PortInfo{ .key = key,
|
|
.label = label,
|
|
.toolTip = typeName.isEmpty() ? label : QString( "%1: %2" ).arg( label, typeName ),
|
|
.typeName = typeName,
|
|
.iconResource = icons.value( fieldForKey( key ) ).toString() } );
|
|
}
|
|
return result;
|
|
}
|
|
} // namespace
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
///
|
|
//--------------------------------------------------------------------------------------------------
|
|
RiuWorkflowGraphView::RiuWorkflowGraphView( QWidget* parent )
|
|
: QGraphicsView( parent )
|
|
, m_scene( new QGraphicsScene( this ) )
|
|
{
|
|
setScene( m_scene );
|
|
setRenderHint( QPainter::Antialiasing );
|
|
setDragMode( QGraphicsView::ScrollHandDrag );
|
|
setTransformationAnchor( QGraphicsView::AnchorUnderMouse );
|
|
setAcceptDrops( true );
|
|
setFocusPolicy( Qt::StrongFocus );
|
|
|
|
connect( m_scene, &QGraphicsScene::selectionChanged, this, &RiuWorkflowGraphView::onSelectionChanged );
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
///
|
|
//--------------------------------------------------------------------------------------------------
|
|
RiuWorkflowGraphView::~RiuWorkflowGraphView() = default;
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
///
|
|
//--------------------------------------------------------------------------------------------------
|
|
void RiuWorkflowGraphView::setEditable( bool editable )
|
|
{
|
|
m_editable = editable;
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
///
|
|
//--------------------------------------------------------------------------------------------------
|
|
bool RiuWorkflowGraphView::isEditable() const
|
|
{
|
|
return m_editable;
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
///
|
|
//--------------------------------------------------------------------------------------------------
|
|
void RiuWorkflowGraphView::setConnectionValidator( const ConnectionValidator& validator )
|
|
{
|
|
m_validator = validator;
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
///
|
|
//--------------------------------------------------------------------------------------------------
|
|
void RiuWorkflowGraphView::setPendingNodePosition( const QString& taskName, const QPointF& scenePos )
|
|
{
|
|
m_pendingPositions[taskName] = scenePos;
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
/// Keep the position of a task the user has placed when it is renamed
|
|
//--------------------------------------------------------------------------------------------------
|
|
void RiuWorkflowGraphView::renameNodePosition( const QString& oldName, const QString& newName )
|
|
{
|
|
rememberPositions();
|
|
for ( const QString& prefix : { QString( "task:" ), QString( "config:" ) } )
|
|
{
|
|
if ( m_positions.contains( prefix + oldName ) ) m_positions[prefix + newName] = m_positions.take( prefix + oldName );
|
|
}
|
|
if ( m_lastSelectedTask == oldName ) m_lastSelectedTask = newName;
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
///
|
|
//--------------------------------------------------------------------------------------------------
|
|
void RiuWorkflowGraphView::rememberPositions()
|
|
{
|
|
for ( auto it = m_taskNodes.cbegin(); it != m_taskNodes.cend(); ++it )
|
|
m_positions["task:" + it.key()] = it.value()->pos();
|
|
for ( auto it = m_configNodes.cbegin(); it != m_configNodes.cend(); ++it )
|
|
m_positions["config:" + it.key()] = it.value()->pos();
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
///
|
|
//--------------------------------------------------------------------------------------------------
|
|
void RiuWorkflowGraphView::fitGraph()
|
|
{
|
|
m_fitOnResize = true;
|
|
if ( !m_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 );
|
|
if ( isSwitch )
|
|
{
|
|
QStringList branches;
|
|
for ( const QJsonValue& branch : data.value( "branches" ).toArray() )
|
|
branches.append( branch.toVariant().toString() );
|
|
node->setBranches( branches );
|
|
}
|
|
if ( !map.isEmpty() )
|
|
{
|
|
QStringList mapToolTip{
|
|
QString( "Runs once per item of '%1' (%2)" ).arg( map.value( "over" ).toString(), map.value( "type" ).toString() ) };
|
|
if ( !map.value( "key_as" ).toString().isEmpty() ) mapToolTip << QString( "Key → %1" ).arg( map.value( "key_as" ).toString() );
|
|
if ( !map.value( "value_as" ).toString().isEmpty() )
|
|
mapToolTip << QString( "Value → %1" ).arg( map.value( "value_as" ).toString() );
|
|
mapToolTip << ( map.value( "error_mode" ).toString() == "collect_all"
|
|
? "Every item runs; the task fails afterwards if any item failed"
|
|
: "The first failed item stops the task" );
|
|
mapToolTip << "The output is a dict with one entry per item";
|
|
node->setSubtitleToolTip( mapToolTip.join( "\n" ) );
|
|
}
|
|
const QString taskId = data.value( "task_id" ).toString();
|
|
const QString description = data.value( "description" ).toString();
|
|
QStringList toolTip{ name };
|
|
if ( !taskId.isEmpty() ) toolTip << taskId;
|
|
if ( !description.isEmpty() ) toolTip << description;
|
|
const bool unknownType = data.contains( "known_type" ) && !data.value( "known_type" ).toBool();
|
|
if ( name == result ) toolTip << "Result task: its output is the result of the workflow";
|
|
if ( isSwitch ) toolTip << "Switch: picks a branch; tasks with a run condition on it run only for their branches";
|
|
if ( unknownType ) toolTip << "Unknown task type: the package providing it is not installed";
|
|
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;
|
|
if ( edge.value( "kind" ).toString() == "when" )
|
|
{
|
|
m_scene->addItem(
|
|
new GraphEdge( m_taskNodes.value( from ), m_taskNodes.value( to ), edge.value( "label" ).toString(), selectableEdges ) );
|
|
continue;
|
|
}
|
|
auto* graphEdge = new GraphEdge( m_taskNodes.value( from ),
|
|
keyForField( edge.value( "output" ).toString() ),
|
|
m_taskNodes.value( to ),
|
|
keyForField( edge.value( "input" ).toString() ),
|
|
false,
|
|
selectableEdges );
|
|
if ( edge.value( "collect" ).toString() == "first_of" )
|
|
{
|
|
graphEdge->setLabel( edge.value( "label" ).toString(),
|
|
QString( "Alternative %1: used when no earlier alternative ran" ).arg( edge.value( "label" ).toString() ) );
|
|
}
|
|
else if ( edge.contains( "collect" ) )
|
|
{
|
|
const bool keyed = edge.value( "collect" ).toString() == "dict";
|
|
const bool flattened = edge.value( "flatten" ).toBool();
|
|
QString toolTip = keyed ? QString( "Collected under the key '%1'" ).arg( edge.value( "label" ).toString() )
|
|
: QString( "Collected as item %1 of the list" ).arg( edge.value( "label" ).toString() );
|
|
if ( flattened )
|
|
{
|
|
toolTip = keyed ? "A dict merged into the collection by its own keys"
|
|
: QString( "Collected at position %1: the items of a list, or the values of a dict, are added in order" )
|
|
.arg( edge.value( "label" ).toString() );
|
|
}
|
|
graphEdge->setLabel( edge.value( "label" ).toString(), toolTip );
|
|
}
|
|
m_scene->addItem( graphEdge );
|
|
}
|
|
|
|
for ( auto it = m_configNodes.cbegin(); it != m_configNodes.cend(); ++it )
|
|
{
|
|
for ( const QString& key : configFields.value( it.key() ) )
|
|
{
|
|
m_scene->addItem( new GraphEdge( it.value(), key, m_taskNodes.value( it.key() ), key, true, false ) );
|
|
}
|
|
}
|
|
|
|
m_runStatusLabel = m_scene->addText( {} );
|
|
m_runStatusLabel->setDefaultTextColor( QColor( 45, 65, 80 ) );
|
|
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;
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
///
|
|
//--------------------------------------------------------------------------------------------------
|
|
void RiuWorkflowGraphView::setTaskIssues( const QString& taskName, const QJsonArray& issues )
|
|
{
|
|
if ( auto* node = m_taskNodes.value( taskName, nullptr ) ) node->setIssues( issues );
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
///
|
|
//--------------------------------------------------------------------------------------------------
|
|
void RiuWorkflowGraphView::selectTask( const QString& taskName )
|
|
{
|
|
auto* node = m_taskNodes.value( taskName, nullptr );
|
|
if ( !node ) return;
|
|
|
|
m_rebuilding = true;
|
|
m_scene->clearSelection();
|
|
node->setSelected( true );
|
|
m_lastSelectedTask = taskName;
|
|
m_rebuilding = false;
|
|
ensureVisible( node );
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
///
|
|
//--------------------------------------------------------------------------------------------------
|
|
RiuWorkflowGraphView::ItemRef RiuWorkflowGraphView::ItemRef::forTask( const QString& taskName )
|
|
{
|
|
ItemRef item;
|
|
item.kind = Kind::Task;
|
|
item.task = taskName;
|
|
return item;
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
///
|
|
//--------------------------------------------------------------------------------------------------
|
|
RiuWorkflowGraphView::ItemRef
|
|
RiuWorkflowGraphView::ItemRef::forEdge( const QString& from, const QString& output, const QString& to, const QString& input )
|
|
{
|
|
ItemRef item;
|
|
item.kind = Kind::Edge;
|
|
item.from = from;
|
|
item.output = output;
|
|
item.to = to;
|
|
item.input = input;
|
|
return item;
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
///
|
|
//--------------------------------------------------------------------------------------------------
|
|
RiuWorkflowGraphView::ItemRef RiuWorkflowGraphView::ItemRef::forCondition( const QString& from, const QString& to )
|
|
{
|
|
ItemRef item;
|
|
item.kind = Kind::Condition;
|
|
item.from = from;
|
|
item.to = to;
|
|
return item;
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
///
|
|
//--------------------------------------------------------------------------------------------------
|
|
RiuWorkflowGraphView::ItemRef RiuWorkflowGraphView::selectedItem() const
|
|
{
|
|
for ( QGraphicsItem* item : m_scene->selectedItems() )
|
|
{
|
|
if ( auto* node = dynamic_cast<GraphNode*>( item ) ) return ItemRef::forTask( node->name() );
|
|
if ( auto* edge = dynamic_cast<GraphEdge*>( item ); edge && !edge->isConfig() )
|
|
{
|
|
if ( edge->isControl() ) return ItemRef::forCondition( edge->fromNode()->name(), edge->toNode()->name() );
|
|
return ItemRef::forEdge( edge->fromNode()->name(), edge->outputField(), edge->toNode()->name(), edge->inputField() );
|
|
}
|
|
}
|
|
return {};
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
///
|
|
//--------------------------------------------------------------------------------------------------
|
|
void RiuWorkflowGraphView::onSelectionChanged()
|
|
{
|
|
if ( m_rebuilding ) return;
|
|
|
|
const ItemRef selected = selectedItem();
|
|
if ( selected.kind != ItemRef::Kind::Task || selected.task == m_lastSelectedTask ) return;
|
|
|
|
m_lastSelectedTask = selected.task;
|
|
emit nodeSelected( selected.task );
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
///
|
|
//--------------------------------------------------------------------------------------------------
|
|
void RiuWorkflowGraphView::setTaskInputValue( const QString& taskName, const QString& fieldName, const QString& value )
|
|
{
|
|
if ( auto* node = m_configNodes.value( taskName, nullptr ) )
|
|
node->setConfigValue( fieldName, value );
|
|
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;
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
///
|
|
//--------------------------------------------------------------------------------------------------
|
|
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;
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
///
|
|
//--------------------------------------------------------------------------------------------------
|
|
RiuWorkflowGraphView::ItemRef RiuWorkflowGraphView::itemRefAt( const QPoint& viewPos ) const
|
|
{
|
|
for ( QGraphicsItem* item : items( viewPos ) )
|
|
{
|
|
QGraphicsItem* current = item;
|
|
while ( current )
|
|
{
|
|
if ( auto* node = dynamic_cast<GraphNode*>( current ) ) return ItemRef::forTask( node->name() );
|
|
if ( auto* edge = dynamic_cast<GraphEdge*>( current ) )
|
|
{
|
|
if ( edge->isConfig() ) break;
|
|
if ( edge->isControl() ) return ItemRef::forCondition( edge->fromNode()->name(), edge->toNode()->name() );
|
|
return ItemRef::forEdge( edge->fromNode()->name(), edge->outputField(), edge->toNode()->name(), edge->inputField() );
|
|
}
|
|
current = current->parentItem();
|
|
}
|
|
}
|
|
return {};
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
/// Highlight the ports the dragged port can connect to
|
|
//--------------------------------------------------------------------------------------------------
|
|
void RiuWorkflowGraphView::startConnectionDrag( PortItem* port )
|
|
{
|
|
m_dragPort = port;
|
|
m_dragInvalidReasons.clear();
|
|
|
|
for ( GraphNode* node : m_taskNodes )
|
|
{
|
|
for ( PortItem* candidate : node->ports() )
|
|
{
|
|
if ( candidate->isOutput() == port->isOutput() ) continue;
|
|
|
|
PortItem* outputPort = port->isOutput() ? port : candidate;
|
|
PortItem* inputPort = port->isOutput() ? candidate : port;
|
|
|
|
std::expected<void, QString> valid;
|
|
if ( outputPort->node() == inputPort->node() )
|
|
valid = std::unexpected( QString( "A task cannot be connected to itself" ) );
|
|
else if ( m_validator )
|
|
valid = m_validator( outputPort->node()->name(), outputPort->field(), inputPort->node()->name(), inputPort->field() );
|
|
|
|
if ( valid )
|
|
candidate->setHighlight( PortItem::Highlight::Valid );
|
|
else
|
|
{
|
|
candidate->setHighlight( PortItem::Highlight::Invalid, valid.error() );
|
|
m_dragInvalidReasons.insert( candidate, valid.error() );
|
|
}
|
|
}
|
|
}
|
|
|
|
m_dragPath = m_scene->addPath( QPainterPath(), QPen( validTargetColor.darker( 120 ), 2.0, Qt::DashLine ) );
|
|
m_dragPath->setZValue( 3 );
|
|
updateConnectionDrag( port->mapToScene( port->rect().center() ) );
|
|
setCursor( Qt::CrossCursor );
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
///
|
|
//--------------------------------------------------------------------------------------------------
|
|
void RiuWorkflowGraphView::updateConnectionDrag( const QPointF& scenePos )
|
|
{
|
|
if ( !m_dragPort || !m_dragPath ) return;
|
|
|
|
m_dragTarget = nullptr;
|
|
QPointF end = scenePos;
|
|
if ( PortItem* target = portAt( mapFromScene( scenePos ) );
|
|
target && target->isOutput() != m_dragPort->isOutput() && target->node() != m_dragPort->node() )
|
|
{
|
|
if ( !m_dragInvalidReasons.contains( target ) )
|
|
{
|
|
m_dragTarget = target;
|
|
end = target->mapToScene( target->rect().center() );
|
|
}
|
|
else
|
|
{
|
|
QToolTip::showText( mapToGlobal( mapFromScene( scenePos ) ), m_dragInvalidReasons.value( target ), this );
|
|
}
|
|
}
|
|
|
|
const QPointF start = m_dragPort->mapToScene( m_dragPort->rect().center() );
|
|
QPointF from = m_dragPort->isOutput() ? start : end;
|
|
QPointF to = m_dragPort->isOutput() ? end : start;
|
|
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() );
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
///
|
|
//--------------------------------------------------------------------------------------------------
|
|
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 );
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
///
|
|
//--------------------------------------------------------------------------------------------------
|
|
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 );
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------------------
|
|
///
|
|
//--------------------------------------------------------------------------------------------------
|
|
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() ) );
|
|
}
|