///////////////////////////////////////////////////////////////////////////////// // // 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 for more details. // ///////////////////////////////////////////////////////////////////////////////// #include "RiuWorkflowGraphView.h" #include "RiuWorkflowGraphLayout.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include 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& inputs, const std::vector& 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 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 top{}; // Defaults to the line of the port bool centered{ false }; }; void addPorts( const std::vector& ports, bool output ); void addColumnPorts( const std::vector& inputs, const std::vector& outputs ); int columnLines( const std::vector& ports, bool output ) const; bool showsValue( const PortInfo& info, bool output ) const; void addCollapsedPorts( const std::vector& ports, bool output ); void addHiddenSourceMarkers( const std::vector& inputs ); qreal freeRight( const QRectF& line, qreal right, const QList& ignored ) const; void addDiamondPorts( const std::vector& 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 m_inputs; QMap m_outputs; QMap m_configValues; QMap m_valueSlots; std::vector 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 m_stack; qreal m_portTop = portTop; QMap m_itemStates; QMap 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 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& inputs, const std::vector& 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( 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& 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::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::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& 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& inputs, const std::vector& 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( 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& 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& 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( 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 GraphNode::ports() const { std::vector 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& 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( child ) && !dynamic_cast( 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& 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( 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( 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 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( 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> editModePorts( const QJsonObject& task, const QJsonArray& edges ) { const QString name = task.value( "name" ).toString(); std::vector 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 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 portInfos( const QStringList& keys, bool output, const QJsonObject& types, const QJsonObject& icons ) { std::vector result; for ( const QString& key : keys ) { const QString typeName = types.value( fieldForKey( key ) ).toString(); const QString label = key == "model" ? ( output ? "output" : "input" ) : fieldForKey( key ); result.push_back( PortInfo{ .key = key, .label = label, .toolTip = typeName.isEmpty() ? label : QString( "%1: %2" ).arg( label, typeName ), .typeName = typeName, .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> layoutEdges; QMap taskData; QMap 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 usedInputs; QMap usedOutputs; QMap> hiddenSources; // Task -> input key -> hidden tasks connected to it QMap 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 inputs; std::vector 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 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 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 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( item ) ) return ItemRef::forTask( node->name() ); if ( auto* edge = dynamic_cast( 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( 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( current ) ) return ItemRef::forTask( node->name() ); if ( auto* edge = dynamic_cast( 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 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( 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( 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( 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() ) ); }