Pamhyr2/src/View/Scenarios/GraphWidget.py

505 lines
14 KiB
Python

# 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 <https://www.gnu.org/licenses/>.
# -*- 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"]
)