mirror of https://gitlab.com/pamhyr/pamhyr2
505 lines
14 KiB
Python
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"]
|
|
)
|