# GraphWidget.py -- Pamhyr # Copyright (C) 2023-2025 INRAE # # This program 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. # # This program 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 for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # -*- coding: utf-8 -*- import math import logging from tools import timer from PyQt5.QtCore import ( Qt, QPoint, QPointF, QSizeF, QLineF, QRectF, pyqtSlot, pyqtSignal, QCoreApplication, ) from PyQt5.QtGui import ( QPainter, QColor, QBrush, QPen, QPainterPath, QPolygonF, ) from PyQt5.QtWidgets import ( QApplication, QGraphicsScene, QGraphicsView, QGraphicsItem, QGraphicsTextItem, QMenu, QMessageBox, ) from View.WaitingDialog import WaitingDialog from View.Scenarios.UndoCommand import * from View.Scenarios.ContextMenu import ( DefaultMenu, ScenarioMenu, ) logger = logging.getLogger() _translate = QCoreApplication.translate class ScenarioItem(QGraphicsTextItem): Type = QGraphicsItem.UserType + 10 def __init__(self, scenario, graph_widget): super(ScenarioItem, self).__init__() self.graph = graph_widget self.scenario = scenario self.setPos(QPointF(self.scenario.x, self.scenario.y)) tag = " " if self.graph.scenario_has_child(self.scenario): tag = "⛔ " self.setHtml(tag + self.scenario.name) self.setDefaultTextColor(Qt.black) self.setFlag(QGraphicsItem.ItemIsMovable) self.setFlag(QGraphicsItem.ItemSendsGeometryChanges) self.setCacheMode(QGraphicsItem.DeviceCoordinateCache) self.setZValue(1) def type(self): return ScenarioItem.Type def boundingRect(self): extra = 6 return super(ScenarioItem, self)\ .boundingRect()\ .normalized()\ .adjusted( -extra, -extra, extra, extra ) @timer def paint(self, painter, option, widget): extra = 2 pcolor = QColor(Qt.black) color = QColor("#aac3d7") if self.graph._study.status.scenario is self.scenario: color = QColor("#eeaba5") elif self.scenario.id == 0: color = QColor("#d3c1d8") elif self.graph.scenario_has_child(self.scenario): color = QColor("#e6e6e6") painter.setPen( QPen( pcolor, 2, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin ) ) painter.setBrush(QBrush(color)) painter.drawEllipse(self.boundingRect()) super(ScenarioItem, self).paint(painter, option, widget) def itemChange(self, change, value): if change == QGraphicsItem.ItemPositionHasChanged: self.scenario.set_pos(value.x(), value.y()) self.graph.edges_display_update() return super(ScenarioItem, self).itemChange(change, value) class EdgeItem(QGraphicsItem): Type = QGraphicsItem.UserType + 11 def __init__(self, scenario_item, parent_item, graph_widget): super(EdgeItem, self).__init__() self.item = scenario_item self.parent_item = parent_item self.graph = graph_widget self.setAcceptedMouseButtons(Qt.NoButton) def type(self): return EdgeItem.Type def boundingRect(self): extra = 0.0 for item in [self.item, self.parent_item]: rect = item.boundingRect() extra = max( [rect.width(), rect.height(), extra] ) extra /= 2 return QRectF( self.parent_item.pos(), QSizeF( self.item.pos().x() - self.parent_item.pos().x(), self.item.pos().y() - self.parent_item.pos().y() ) ).normalized().adjusted(-extra, -extra, extra, extra) @timer def paint(self, painter, option, widget): line = QLineF( self.get_line_item_point(self.parent_item), self.get_line_item_point(self.item) ) if line.length() == 0.0: return color = Qt.black arrow = self.get_arrow_path(line, color) brush = QBrush() path = QPainterPath() brush.setColor(color) brush.setStyle(Qt.SolidPattern) painter.setPen( QPen( color, 2, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin ) ) painter.drawLine(line) path.addPolygon(arrow) painter.drawPolygon(arrow) painter.fillPath(path, brush) def get_arrow_path(self, line, color): pp = self.get_line_item_point(self.parent_item) p = self.get_line_item_point(self.item) line_center = QPointF( (pp.x() + p.x()) / 2, (pp.y() + p.y()) / 2, ) angle = math.acos(line.dx() / line.length()) if line.dy() >= 0: angle = (math.pi * 2.0) - angle size = 10.0 arrow_p1 = line_center + QPointF( math.sin(angle - math.pi / 3) * size, math.cos(angle - math.pi / 3) * size ) arrow_p2 = line_center + QPointF( math.sin(angle - math.pi + math.pi / 3) * size, math.cos(angle - math.pi + math.pi / 3) * size ) poly = QPolygonF([line_center, arrow_p1, arrow_p2]) return poly def get_line_item_point(self, item): item_extra = 6 rect = item.boundingRect() return QPointF( item.pos().x() + ((rect.width() - item_extra) / 2.0), item.pos().y() + ((rect.height() - item_extra) / 2.0) ) class GraphWidget(QGraphicsView): changeScenario = pyqtSignal(object) def __init__(self, study, parent=None, min_size=(400, 400), max_size=None, size=None, undo=None, trad=None): super(GraphWidget, self).__init__(parent=parent) self._study = study self.parent = parent self._undo = undo self._trad = trad self.m_origin_x = 0.0 self.m_origin_y = 0.0 self.scenarios_items = {} self.edge_items = [] self.setup_scene(min_size, max_size, size) def setup_scene(self, min_size, max_size, size): scene = QGraphicsScene(self) scene.setItemIndexMethod(QGraphicsScene.NoIndex) scene.setSceneRect(0, 0, 2000, 2000) self.setScene(scene) self.setCacheMode(QGraphicsView.CacheBackground) self.setViewportUpdateMode(QGraphicsView.BoundingRectViewportUpdate) self.setRenderHint(QPainter.Antialiasing) self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse) self.setResizeAnchor(QGraphicsView.AnchorViewCenter) self.scale(1, 1) self.previousScale = 1 if min_size: self.setMinimumSize(*min_size) if max_size: self.setMaximumSize(*max_size) if size: self.resize(*size) self.create_items() @timer def create_items(self): """Create all items and draw its Returns: Nothing """ self.scenarios_items = {} self.edge_items = [] for scenar in self._study.scenarios.lst: if scenar.is_deleted(): continue iscenar = ScenarioItem(scenar, self) self.scene().addItem(iscenar) self.scenarios_items[scenar] = iscenar if scenar.parent is not None: iedge = EdgeItem( self.scenarios_items[scenar], self.scenarios_items[scenar.parent], self ) self.scene().addItem(iedge) self.edge_items.append(iedge) def edges_display_update(self): for e in self.edge_items: e.update() def display_update(self): """Clear the scene and redraw it Returns: Nothing """ self.scene().clear() self.create_items() def scenario_has_child(self, scenario): for scenar in self._study.scenarios.lst: if scenar.parent is scenario: if not scenar.is_deleted(): return True return False def drawBackground(self, painter, rect): sceneRect = self.sceneRect() painter.fillRect( rect.intersected(sceneRect), QBrush(QColor("#e6e6e6")) ) painter.setBrush(Qt.NoBrush) painter.drawRect(sceneRect) def wheelEvent(self, event): self.scaleView(math.pow(2.0, -event.angleDelta().y() / 240.0)) def keyPressEvent(self, event): key = event.key() if key == Qt.Key_Plus: self.scaleView(1.2) elif key == Qt.Key_Minus: self.scaleView(1 / 1.2) else: super(GraphWidget, self).keyPressEvent(event) def scaleView(self, scaleFactor): factor = self.transform().scale( scaleFactor, scaleFactor ).mapRect(QRectF(0, 0, 1, 1)).width() if factor < 0.05 or factor > 1.5: return self.scale(scaleFactor, scaleFactor) def mousePressEvent(self, event): # pos = self.mapToScene(event.pos()) # items = self.items(event.pos()) self.update() super(GraphWidget, self).mousePressEvent(event) def mouseMoveEvent(self, event): pos = self.mapToScene(event.pos()) items = self.items(event.pos()) selectable_items = list( filter( lambda i: (type(i) is ScenarioItem or type(i) is EdgeItem), items ) ) # Move on scene if len(selectable_items) == 0: if event.buttons() & Qt.LeftButton: old_p = self.mapToScene( QPoint( int(self.m_origin_x), int(self.m_origin_y) ) ) new_p = self.mapToScene(event.pos()) translation = new_p - old_p self.translate(translation.x(), translation.y()) self.m_origin_x = event.x() self.m_origin_y = event.y() # Propagate event self.update() super(GraphWidget, self).mouseMoveEvent(event) def exec_with_waiting_window(self, fn, action_str): dlg = WaitingDialog( payload_fn=fn, title=action_str, trad=self._trad, parent=self.parent ) dlg.exec_() # Contextual menu def contextMenuEvent(self, event): pos = self.mapToScene(event.pos()) items = self.items(event.pos()) items = list( filter( lambda i: (type(i) is ScenarioItem or type(i) is EdgeItem), items ) ) m_type = DefaultMenu if len(items) != 0: if type(items[0]) is ScenarioItem: m_type = ScenarioMenu # Create and exec menu m = m_type( event=event, pos=pos, items=items, graph=self._study, trad=self._trad, parent=self ) m.run() def dialog_save(self): dlg = QMessageBox(self) dlg.setWindowTitle(self._trad["mb_save_title"]) dlg.setText(self._trad["mb_save_msg"]) opt = QMessageBox.Save | QMessageBox.Cancel dlg.setStandardButtons(opt) dlg.setIcon(QMessageBox.Warning) dlg.button(QMessageBox.Save).setText(self._trad["Save"]) dlg.button(QMessageBox.Cancel).setText(self._trad["Cancel"]) res = dlg.exec() if res == QMessageBox.Save: return True elif res == QMessageBox.Cancel: return False def select_scenario(self, item): if type(item) is not ScenarioItem: return must_saved = self.dialog_save() def fn(): self._close_other_window() if must_saved: self._study.save() self._undo.push( SelectScenariosCommand( self._study, item.scenario ) ) # self._study.reload_from_scenario(item.scenario) self.exec_with_waiting_window(fn, "select_scenario") self.changeScenario.emit(self.sender()) def new_scenario(self, pos): def fn(): self._close_other_window() self._study.save() self._undo.push( AddScenariosCommand( self._study, pos ) ) # scenario = self._study.new_scenario_from_current() # scenario.set_pos(pos.x(), pos.y()) self.exec_with_waiting_window(fn, "new_scenario") self.changeScenario.emit(self.sender()) def delete_scenario(self, item): def fn(): self._close_other_window() # self._study.save() self._undo.push( DeleteScenariosCommand( self._study, item.scenario ) ) self.exec_with_waiting_window(fn, "delete_scenario") self.changeScenario.emit(self.sender()) def duplicate_scenario(self, item): def fn(): self._close_other_window() # self._study.save() self._undo.push( DuplicateScenariosCommand( self._study, ) ) self.exec_with_waiting_window(fn, "duplicate_scenario") self.changeScenario.emit(self.sender()) def _close_other_window(self): self.parent\ .parent\ ._close_sub_window( exceptions=["Scenarios", "Debug REPL"] )