From 85d3345043c751b676b8d49a9b5e9504f55ae13b Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Fri, 30 Aug 2024 10:06:43 +0200 Subject: [PATCH] Scenarios: Add window scheme (from copy of network window). --- src/Model/Scenario.py | 16 + src/Model/Scenarios.py | 9 + src/Modules.py | 3 + src/View/MainWindow.py | 19 + src/View/Scenarios/ContextMenu.py | 112 ++++ src/View/Scenarios/GraphWidget.py | 813 ++++++++++++++++++++++++++++++ src/View/Scenarios/Table.py | 82 +++ src/View/Scenarios/UndoCommand.py | 68 +++ src/View/Scenarios/Window.py | 131 +++++ src/View/Scenarios/translate.py | 45 ++ src/View/ui/MainWindow.ui | 12 + src/View/ui/Scenarios.ui | 62 +++ 12 files changed, 1372 insertions(+) create mode 100644 src/View/Scenarios/ContextMenu.py create mode 100644 src/View/Scenarios/GraphWidget.py create mode 100644 src/View/Scenarios/Table.py create mode 100644 src/View/Scenarios/UndoCommand.py create mode 100644 src/View/Scenarios/Window.py create mode 100644 src/View/Scenarios/translate.py create mode 100644 src/View/ui/Scenarios.ui diff --git a/src/Model/Scenario.py b/src/Model/Scenario.py index 3c9097f2..f49491c8 100644 --- a/src/Model/Scenario.py +++ b/src/Model/Scenario.py @@ -185,3 +185,19 @@ class Scenario(SQLSubModel): @property def parent(self): return self._parent + + def __setitem__(self, key, value): + if key == "name": + self.name = value + if key == "description": + self.description = description + + def __getitem__(self, key): + if key == "name": + return self.name + if key == "description": + return self.description + if key == "parent": + return self.parent + + return None diff --git a/src/Model/Scenarios.py b/src/Model/Scenarios.py index 254cdc44..1bc8e961 100644 --- a/src/Model/Scenarios.py +++ b/src/Model/Scenarios.py @@ -59,3 +59,12 @@ class Scenarios(PamhyrModelDict): new = Scenario(parent=parent) self.set(new._id, new) return new + + @property + def lst(self): + return list( + map( + lambda k: self.get(k), + self._dict + ) + ) diff --git a/src/Modules.py b/src/Modules.py index f8ed5b65..47389585 100644 --- a/src/Modules.py +++ b/src/Modules.py @@ -42,6 +42,7 @@ class Modules(IterableFlag): # General STUDY = auto() CONFIG = auto() + SCENARIOS = auto() # Modelling NETWORK = auto() @@ -65,6 +66,7 @@ class Modules(IterableFlag): def values(cls): return [ cls.STUDY, cls.CONFIG, + cls.SCENARIOS, cls.NETWORK, cls.GEOMETRY, cls.BOUNDARY_CONDITION, @@ -143,6 +145,7 @@ class Modules(IterableFlag): _impact = { + Modules.SCENARIOS: Modules.values(), Modules.NETWORK: [ Modules.GEOMETRY, Modules.BOUNDARY_CONDITION, Modules.LATERAL_CONTRIBUTION, Modules.FRICTION, diff --git a/src/View/MainWindow.py b/src/View/MainWindow.py index fc16bee4..fdcfcc70 100644 --- a/src/View/MainWindow.py +++ b/src/View/MainWindow.py @@ -57,6 +57,7 @@ from View.MainWindowTabChecker import WidgetChecker from View.Configure.Window import ConfigureWindow from View.Study.Window import NewStudyWindow from View.About.Window import AboutWindow +from View.Scenarios.Window import ScenariosWindow from View.Network.Window import NetworkWindow from View.Geometry.Window import GeometryWindow from View.BoundaryCondition.Window import BoundaryConditionWindow @@ -109,6 +110,7 @@ define_model_action = [ "action_toolBar_boundary_cond", "action_toolBar_lateral_contrib", "action_toolBar_frictions", "action_toolBar_initial_cond", # Menu + "action_menu_edit_scenarios", "action_menu_run_solver", "action_menu_numerical_parameter", "action_menu_edit_network", "action_menu_edit_geometry", "action_menu_boundary_conditions", "action_menu_initial_conditions", @@ -235,6 +237,7 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit): "action_menu_save": self.save_study, "action_menu_save_as": self.save_as_study, "action_menu_numerical_parameter": self.open_solver_parameters, + "action_menu_edit_scenarios": self.open_scenarios, "action_menu_edit_network": self.open_network, "action_menu_edit_geometry": self.open_geometry, "action_menu_boundary_conditions": self.open_boundary_cond, @@ -993,6 +996,22 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit): self.network = NetworkWindow(study=self._study, parent=self) self.network.show() + def open_scenarios(self): + """Open scenarios window + + Returns: + Nothing + """ + if self._study is not None: + if self.sub_window_exists( + ScenariosWindow, + data=[self._study, None] + ): + return + + self.scenarios = ScenariosWindow(study=self._study, parent=self) + self.scenarios.show() + def open_geometry(self): """Open geometry window diff --git a/src/View/Scenarios/ContextMenu.py b/src/View/Scenarios/ContextMenu.py new file mode 100644 index 00000000..bcee3f0f --- /dev/null +++ b/src/View/Scenarios/ContextMenu.py @@ -0,0 +1,112 @@ +# ContextMenu.py -- Pamhyr +# Copyright (C) 2024 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 -*- + +from PyQt5.QtWidgets import ( + QMenu, +) + + +class AbstractMenu(object): + def __init__(self, event=None, pos=None, items=[], + graph=None, trad=None, parent=None): + super(AbstractMenu, self).__init__() + + self._menu = QMenu(parent) + self._event = event + self._pos = pos + self._items = items + + self._graph = graph + self._trad = trad + self._parent = parent + + def map_to_global(self): + return self._parent.mapToGlobal(self._event.pos()) + + def _exec(self): + return self._menu.exec_(self.map_to_global()) + + def run(self): + return + + +class DefaultMenu(AbstractMenu): + def run(self): + add_node = self._menu.addAction(self._trad["menu_add_node"]) + + action = self._exec() + if action == add_node: + self._parent.add_node(self._pos) + + +class NodeMenu(AbstractMenu): + def run(self): + item = self._items[0] + node = item.node + + delete = self._menu.addAction(self._trad["menu_del_node"]) + + is_internal = self._graph.is_internal_node(node) + if is_internal: + res = self._graph.reservoir.get_assoc_to_node(node) + if res is not None: + edt_res = self._menu.addAction( + self._trad["menu_edit_res_node"] + ) + del_res = self._menu.addAction( + self._trad["menu_del_res_node"] + ) + res_edit = True + else: + add_res = self._menu.addAction(self._trad["menu_add_res_node"]) + res_edit = False + + action = self._exec() + if action == delete: + self._parent.del_node(item) + elif is_internal: + if res_edit: + if action == edt_res: + self._parent.edit_node_reservoir(node) + elif action == del_res: + self._parent.del_node_reservoir(node) + else: + if action == add_res: + self._parent.add_node_reservoir(node) + + +class EdgeMenu(AbstractMenu): + def run(self): + it = self._items[0] + + reverse = self._menu.addAction(self._trad["menu_rev_edge"]) + delete = self._menu.addAction(self._trad["menu_del_edge"]) + if self._parent.graph.is_enable_edge(it.edge): + enable = self._menu.addAction(self._trad["menu_dis_edge"]) + is_enable = True + else: + enable = self._menu.addAction(self._trad["menu_ena_edge"]) + is_enable = False + + action = self._exec() + if action == delete: + self._parent.del_edge(it) + elif action == enable: + self._parent.enable_edge(it, is_enable) + elif action == reverse: + self._parent.reverse_edge(it) diff --git a/src/View/Scenarios/GraphWidget.py b/src/View/Scenarios/GraphWidget.py new file mode 100644 index 00000000..6638362a --- /dev/null +++ b/src/View/Scenarios/GraphWidget.py @@ -0,0 +1,813 @@ +# GraphWidget.py -- Pamhyr +# Copyright (C) 2023-2024 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, +) + +from View.Scenarios.UndoCommand import * +from View.Scenarios.ContextMenu import ( + DefaultMenu, NodeMenu, EdgeMenu, +) + +logger = logging.getLogger() + +_translate = QCoreApplication.translate + + +class ScenarioItem(QGraphicsItem): + Type = QGraphicsItem.UserType + 1 + + def __init__(self, node, graph_widget): + super(ScenarioItem, self).__init__() + + self.node = node + self.setPos(QPointF(self.node.pos.x, self.node.pos.y)) + + self.graph = graph_widget + + self.setFlag(QGraphicsItem.ItemIsMovable) + self.setFlag(QGraphicsItem.ItemSendsGeometryChanges) + self.setCacheMode(QGraphicsItem.DeviceCoordinateCache) + self.setZValue(1) + + def type(self): + return NodeItem.Type + + def boundingRect(self): + adjust = 2.0 + return QRectF(-10 - adjust, -10 - adjust, 20 + adjust, 20 + adjust) + + def shape(self): + path = QPainterPath() + if self.graph.graph._reservoir.get_assoc_to_node(self.node) is None: + path.addEllipse(-10, -10, 20, 20) + else: + path.addRect(-10, -10, 20, 20) + return path + + @timer + def paint(self, painter, option, widget): + painter.setPen(Qt.NoPen) + + # Select color in function of node position in graph and + # status + color = Qt.darkBlue + if self.graph.selected_new_edge_src_node() == self: + color = Qt.darkRed + elif self.graph.selected_item() == self: + color = Qt.red + elif not self.graph.graph.is_enable_node(self.node): + color = Qt.darkGray + elif self.graph.graph.is_upstream_node(self.node): + color = Qt.yellow + elif self.graph.graph.is_downstream_node(self.node): + color = Qt.green + + painter.setBrush(QBrush(color)) + if self.graph.graph._reservoir.get_assoc_to_node(self.node) is None: + painter.drawEllipse(-10, -10, 20, 20) + else: + painter.drawRect(-10, -10, 20, 20) + + def itemChange(self, change, value): + if change == QGraphicsItem.ItemPositionHasChanged: + self.graph.node_change_position(value, self) + + self.graph.update_edges(self) + return super(NodeItem, self).itemChange(change, value) + + def mousePressEvent(self, event): + self.update() + if not self.graph._only_display: + super(NodeItem, self).mousePressEvent(event) + + def mouseReleaseEvent(self, event): + self.update() + super(NodeItem, self).mouseReleaseEvent(event) + + def mouseMoveEvent(self, event): + self.update() + if not self.graph._only_display: + super(NodeItem, self).mouseMoveEvent(event) + + +class EdgeItem(QGraphicsItem): + _arc_dist = 0.1 + + Type = QGraphicsItem.UserType + 2 + + def __init__(self, src_node_item, dest_node_item, + edge, graph_widget): + super(EdgeItem, self).__init__() + + self.src_node = src_node_item + self.dest_node = dest_node_item + self.edge = edge + + self.graph = graph_widget + self._ind = 1 + + geometry = self.compute_arc_two_point( + QPointF( + self.src_node.pos().x(), + self.src_node.pos().y() + ), + QPointF( + self.dest_node.pos().x(), + self.dest_node.pos().y() + ), + ) + + self._bound_rect = geometry['bound_rect'] + self._arc = geometry['arc'] + + self.setAcceptedMouseButtons(Qt.NoButton) + + def type(self): + return Edge.Type + + def boundingRect(self): + # Rectangle of edge for display update + pen_width = 2.0 + extra = 0 # (pen_width + 5) / 2.0 + + return self._bound_rect.normalized().adjusted( + -extra, -extra, extra, extra + ) + + def shape(self): + return self._arc + + def paint(self, painter, option, widget): + # self.paint_line(painter, option, widget) + self.paint_arc(painter, option, widget) + + @timer + def paint_line(self, painter, option, widget): + # Draw shape of the edge + # color = QColor(Qt.white) + # color.setAlpha(128) + # painter.setBrush(color) + # painter.drawPath(self.shape()) + + line = QLineF(self.src_node.pos(), self.dest_node.pos()) + if line.length() == 0.0: + return + + # Select color + # color = Qt.darkBlue + color = Qt.black + if self.graph.selected_item() == self: + color = Qt.red + elif self.graph.current_edge() == self: + color = Qt.blue + elif not self.graph.graph.is_enable_edge(self.edge): + color = Qt.darkGray + + painter.setPen( + QPen( + color, 2, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin + ) + ) + + painter.drawLine(line) + + # Draw arrow + center = QPointF( + (self.src_node.pos().x() + self.dest_node.pos().x()) / 2, + (self.src_node.pos().y() + self.dest_node.pos().y()) / 2, + ) + angle = math.acos(line.dx() / line.length()) + if line.dy() >= 0: + angle = (math.pi * 2.0) - angle + + self.paint_arrow( + painter, option, widget, + color, center, angle + ) + + @timer + def paint_arc(self, painter, option, widget): + line = QLineF(self.src_node.pos(), self.dest_node.pos()) + if line.length() == 0.0: + return + + # Select color + # color = Qt.darkBlue + color = Qt.black + if self.graph.selected_item() == self: + color = Qt.red + elif self.graph.current_edge() == self: + color = Qt.blue + elif not self.graph.graph.is_enable_edge(self.edge): + color = Qt.darkGray + + painter.setPen( + QPen( + color, 2, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin + ) + ) + + geometry = self.compute_arc_two_point( + QPointF( + self.src_node.pos().x(), + self.src_node.pos().y() + ), + QPointF( + self.dest_node.pos().x(), + self.dest_node.pos().y() + ), + ) + + self._bound_rect = geometry['bound_rect'] + self._arc = geometry['arc'] + + painter.drawPath(self._arc) + + line = geometry['line'] + angle = math.acos(line.dx() / line.length()) + if line.dy() >= 0: + angle = (math.pi * 2.0) - angle + + self.paint_arrow( + painter, option, widget, + color, geometry['c'], angle, + ) + + def compute_arc_two_point(self, a, b): + # This function is a black magic spell for invoking an arc + # path between two points in Qt! (In fact, it's just cryptic + # trigonometry functions from stackoverflow: + # https://stackoverflow.com/questions/26901540/arc-in-qgraphicsscene) + c = self.compute_arc_p3(a, b) + + line_ac = QLineF(a, c) + line_bc = QLineF(b, c) + line_ab = QLineF(a, b) + + rad = abs( + line_bc.length() / ( + 2 * math.sin( + math.radians( + line_ac.angleTo(line_ab) + ) + ) + ) + ) + + bisector_bc = QLineF(line_bc.pointAt(0.5), line_bc.p2()) + bisector_bc.setAngle(line_bc.normalVector().angle()) + + bisector_ab = QLineF(line_ab.pointAt(0.5), line_ab.p2()) + bisector_ab.setAngle(line_ab.normalVector().angle()) + + center = QPointF(0, 0) + bisector_ab.intersect(bisector_bc, center) + + circle = QRectF( + center.x() - rad, + center.y() - rad, + rad * 2, rad * 2 + ) + + line_oa = QLineF(center, a) + line_ob = QLineF(center, b) + line_oc = QLineF(center, c) + + start = line_ob + end = line_oa + + w = 1 + bounding_rect = circle.adjusted( + -w, -w, w, w + ) + + path = QPainterPath() + path.arcMoveTo(circle, start.angle()) + path.arcTo(circle, start.angle(), start.angleTo(end)) + + return { + 'bound_rect': bounding_rect, + 'arc': path, + 'c': c, + 'line': line_ab, + } + + def compute_arc_p3(self, p1, p2): + dist_p3 = self._ind * self._arc_dist + + center_p1_p2 = QPointF( + (p1.x() + p2.x()) / 2, + (p1.y() + p2.y()) / 2 + ) + + # u = (p2.x() - p1.x(), p2.y() - p1.y()) + v = (p2.y() - p1.y(), p1.x() - p2.x()) + p3 = QPointF( + center_p1_p2.x() + dist_p3 * v[0], + center_p1_p2.y() + dist_p3 * v[1] + ) + + return p3 + + def paint_arrow(self, painter, option, widget, + color, center, angle): + brush = QBrush() + brush.setColor(color) + brush.setStyle(Qt.SolidPattern) + + size = 10.0 + arrow_p1 = center + QPointF( + math.sin(angle - math.pi / 3) * size, + math.cos(angle - math.pi / 3) * size + ) + arrow_p2 = center + QPointF( + math.sin(angle - math.pi + math.pi / 3) * size, + math.cos(angle - math.pi + math.pi / 3) * size + ) + poly = QPolygonF([center, arrow_p1, arrow_p2]) + path = QPainterPath() + path.addPolygon(poly) + + painter.drawPolygon(poly) + painter.fillPath(path, brush) + + +class NodeText(QGraphicsTextItem): + def __init__(self, node_item): + super(NodeText, self).__init__() + + self.item = node_item + self.setPlainText(self.item.node.name) + self.setDefaultTextColor(Qt.black) + self.set_custom_pos(self.item.pos()) + self.setZValue(2) + + def set_custom_pos(self, pos): + x = pos.x() + y = pos.y() - 42 # Dont panic! The answer is 42 + + self.setPos(QPointF(x, y)) + + @timer + def paint(self, painter, option, widget): + color = QColor(Qt.white) + color.setAlpha(128) + + # Draw text background + painter.setBrush(color) + painter.drawRect(self.boundingRect()) + + # Draw text + super(NodeText, self).paint(painter, option, widget) + + def rename(self): + # Update the node text + self.setPlainText(self.item.node.name) + + +class GraphWidget(QGraphicsView): + changeEdge = pyqtSignal(object) + changeNode = pyqtSignal(object) + + def __init__(self, graph, parent=None, + min_size=(400, 400), max_size=None, + size=None, only_display=False, undo=None, + trad=None): + super(GraphWidget, self).__init__(parent=parent) + + self.parent = parent + self._state = "move" + self._only_display = only_display + self._undo = undo + self._trad = trad + + self.graph = graph + + self._selected_item = None + self._selected_new_edge_src_node = None + self._current_edge = None + self._current_moved_node = None + self.tmp_line = None + + self.node_items = [] + self.edge_items = [] + self.texts = {} + + self.m_origin_x = 0.0 + self.m_origin_y = 0.0 + self.clicked = False + + 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.node_items = [] + self.edge_items = [] + self.texts = {} + + for node in self.graph.nodes(): + inode = NodeItem(node, self) + self.texts[inode] = NodeText(inode) + self.scene().addItem(self.texts[inode]) + self.scene().addItem(inode) + self.node_items.append(inode) + + curr_edge = self.graph.current_reach() + + iedges = [] + multiple_edges = {} + for edge in self.graph.edges(): + n1 = next( + filter( + lambda n: n.node.name == edge.node1.name, + self.node_items + ) + ) + + n2 = next( + filter( + lambda n: n.node.name == edge.node2.name, + self.node_items + ) + ) + + # Multiple edges counter + if (n1, n2) not in multiple_edges: + ind = 1 + else: + ind = multiple_edges[(n1, n2)] + 1 + + multiple_edges[(n1, n2)] = ind + + iedge = EdgeItem(n1, n2, edge, self) + iedge._ind = ind + + if edge == curr_edge: + self._current_edge = iedge + + iedges.append(iedge) + + for iedge in reversed(iedges): + self.scene().addItem(iedge) + self.edge_items.append(iedge) + + def state(self, status): + """Set the current status of draw widget + + Set the current status of draw widget, like "move", "add" or + "del" + + Args: + status: String of current status + + Returns: + Nothing + """ + self._state = status + + def add_node(self, pos): + """Create a new node in graph and draw it + + Args: + pos: The position of new node + + Returns: + Nothing + """ + node = self.graph.create_node(pos.x(), pos.y()) + self._undo.push( + AddNodeCommand( + self.graph, node + ) + ) + inode = NodeItem(node, self) + self.scene().addItem(inode) + self.node_items.append(inode) + self.texts[inode] = NodeText(inode) + self.scene().addItem(self.texts[inode]) + + self.changeNode.emit(self.sender()) + + def del_node(self, item): + """Delete a node and update display + + Args: + item: The node item to delete + + Returns: + Nothing + """ + node = item.node + self._undo.push( + DelNodeCommand( + self.graph, node + ) + ) + + self.scene().clear() + self.create_items() + self.changeNode.emit(self.sender()) + self.changeEdge.emit(self.sender()) + + def rename_nodes(self): + """Update all nodes item name in scene + + Returns: + Nothing + """ + for i in self.texts: + if type(i) is NodeItem: + self.texts[i].rename() + + def add_node_reservoir(self, node): + self.parent.add_node_reservoir(node) + self.display_update() + + def del_node_reservoir(self, node): + self.parent.del_node_reservoir(node) + self.changeNode.emit(self.sender()) + self.display_update() + + def edit_node_reservoir(self, node): + self.changeNode.emit(self.sender()) + self.parent.edit_node_reservoir(node) + + def enable_edge(self, edge, prev): + self._undo.push( + EnableEdgeCommand( + edge.edge, not prev + ) + ) + self.changeEdge.emit(self.sender()) + self.changeNode.emit(self.sender()) + self.display_update() + + def reverse_edge(self, edge): + self._undo.push( + ReverseEdgeCommand( + edge.edge + ) + ) + self.changeEdge.emit(self.sender()) + self.display_update() + + def display_update(self): + """Clear the scene and redraw it + + Returns: + Nothing + """ + self.scene().clear() + self.create_items() + + 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) + elif key == Qt.Key_Escape: + self.reset_selection() + else: + super(GraphWidget, self).keyPressEvent(event) + + def drawBackground(self, painter, rect): + sceneRect = self.sceneRect() + painter.fillRect( + rect.intersected(sceneRect), + QBrush(Qt.lightGray) + ) + painter.setBrush(Qt.NoBrush) + painter.drawRect(sceneRect) + + def wheelEvent(self, event): + self.scaleView(math.pow(2.0, -event.angleDelta().y() / 240.0)) + + 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()) + self.clicked = True + + if event.buttons() & Qt.RightButton: + self.update() + super(GraphWidget, self).mousePressEvent(event) + return + + # Move item and select edge item + if self._state == "move": + self._selected_new_edge_src_node = None + + items = self.items(event.pos()) + if items and type(items[0]) is EdgeItem: + edge = items[0] + if edge: + self.set_current_edge(edge) + elif items and type(items[0]) is NodeItem: + self._mouse_origin_x = pos.x() + self._mouse_origin_y = pos.y() + self._current_moved_node = items[0] + + # Add nodes and edges + elif self._state == "add": + items = self.items(event.pos()) + nodes = list(filter(lambda i: type(i) is NodeItem, items)) + if not nodes: + self.add_node(pos) + self.set_selected_new_edge_src_node(None) + else: + if self.selected_new_edge_src_node() is None: + self.set_selected_new_edge_src_node(nodes[0]) + else: + self.add_edge(self.selected_new_edge_src_node(), nodes[0]) + + # Delete nodes and edges + elif self._state == "del": + self._selected_new_edge_src_node = None + items = list( + filter( + lambda i: type(i) is NodeItem or type(i) is EdgeItem, + self.items(event.pos()) + ) + ) + if len(items) > 0: + item = items[0] + if type(item) is NodeItem: + self.del_node(item) + elif type(item) is EdgeItem: + self.del_edge(item) + + self.update() + super(GraphWidget, self).mousePressEvent(event) + + def mouseReleaseEvent(self, event): + self.clicked = False + + if self._only_display: + return + + if self._state == "move": + if self._current_moved_node is not None: + pos = self.mapToScene(event.pos()) + self._undo.push( + SetNodePosCommand( + self._current_moved_node, + (pos.x(), pos.y()), + (self._mouse_origin_x, + self._mouse_origin_y) + ) + ) + + self.update() + super(GraphWidget, self).mouseReleaseEvent(event) + + def mouseMoveEvent(self, event): + pos = self.mapToScene(event.pos()) + + # Selecte item on the fly + items = self.items(event.pos()) + selectable_items = list( + filter( + lambda i: (type(i) is NodeItem or type(i) is EdgeItem), + items + ) + ) + + if selectable_items: + self.set_selected_item(selectable_items[0]) + elif not self.clicked: + self.set_selected_item(None) + + # Update temporary line + if self.selected_new_edge_src_node() is not None: + if self.tmp_line is None: + self.tmp_line = NewEdgeLine( + self.selected_new_edge_src_node().pos(), + pos + ) + self.scene().addItem(self.tmp_line) + else: + self.tmp_line.dest = pos + self.tmp_line.update() + + # If state is "move" + if self._state == "move": + # Move on scene + if not self.selected_item(): + if event.buttons() & Qt.LeftButton: + old_p = self.mapToScene( + 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) + + # Contextual menu + + def contextMenuEvent(self, event): + if self._only_display: + return + + pos = self.mapToScene(event.pos()) + items = self.items(event.pos()) + + # Select current menu + while len(items) > 0: + if type(items[0]) in [NodeItem, EdgeItem]: + break + else: + items = items[1:] + + if len(items) == 0: + m_type = DefaultMenu + elif type(items[0]) is NodeItem: + m_type = NodeMenu + elif type(items[0]) is EdgeItem: + m_type = EdgeMenu + else: + m_type = DefaultMenu + + # Create and exec menu + m = m_type( + event=event, pos=pos, items=items, + graph=self.graph, trad=self._trad, parent=self + ) + m.run() + self.clicked = False diff --git a/src/View/Scenarios/Table.py b/src/View/Scenarios/Table.py new file mode 100644 index 00000000..69c06801 --- /dev/null +++ b/src/View/Scenarios/Table.py @@ -0,0 +1,82 @@ +# Table.py -- Pamhyr +# Copyright (C) 2023-2024 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 logging +import traceback + +from tools import logger_exception + +from View.Scenarios.GraphWidget import GraphWidget +from View.Scenarios.UndoCommand import * +from View.Tools.PamhyrTable import PamhyrTableModel + +from PyQt5.QtCore import ( + Qt, QRect, QVariant, QAbstractTableModel, pyqtSlot, pyqtSignal, + QEvent, +) + +from PyQt5.QtWidgets import ( + QTableView, QItemDelegate, QComboBox, QLineEdit, QHBoxLayout, QSlider, + QPushButton, QCheckBox, QStyledItemDelegate, QStyleOptionButton, QStyle, + QApplication, +) + +logger = logging.getLogger() + + +class ScenariosTableModel(PamhyrTableModel): + def _setup_lst(self): + self._lst = self._data.scenarios.lst + + def data(self, index, role): + if role != Qt.ItemDataRole.DisplayRole: + return QVariant() + + if self._headers[index.column()] == "parent": + parent = self._lst[index.row()][self._headers[index.column()]] + if parent is None: + return "" + + return parent.name + + return self._lst[index.row()][self._headers[index.column()]] + + @pyqtSlot() + def setData(self, index, value, role=Qt.EditRole): + if not index.isValid(): + return False + + if role == Qt.EditRole: + try: + self._undo.push( + SetCommand( + self._lst[index.row()], + self._headers[index.column()], + value + ) + ) + except Exception as e: + logger_exception(e) + + self.layoutChanged.emit() + return True + + self.dataChanged.emit(index, index) + + def update(self): + self.layoutChanged.emit() diff --git a/src/View/Scenarios/UndoCommand.py b/src/View/Scenarios/UndoCommand.py new file mode 100644 index 00000000..e90a5d7e --- /dev/null +++ b/src/View/Scenarios/UndoCommand.py @@ -0,0 +1,68 @@ +# UndoCommand.py -- Pamhyr +# Copyright (C) 2023-2024 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 -*- + +from copy import deepcopy +from tools import trace, timer + +from PyQt5.QtWidgets import ( + QUndoCommand, QUndoStack, +) + + +class AddScenariosCommand(QUndoCommand): + def __init__(self, study, source): + QUndoCommand.__init__(self) + + self._study = study + self._source = source + + def undo(self): + logger.error(f"TODO {type(self)} : undo") + + def redo(self): + self._study.new_scenario_from_current(switch=False) + + +class DelScenariosCommand(QUndoCommand): + def __init__(self, study, scenario): + QUndoCommand.__init__(self) + + self._study = study + self._scenario = scenario + + def undo(self): + logger.error(f"TODO {type(self)} : undo") + + def redo(self): + logger.error(f"TODO {type(self)} : redo") + + +class SetCommand(QUndoCommand): + def __init__(self, scenario, column, new_value): + QUndoCommand.__init__(self) + + self._el = scenario + self._column = column + self._old = self._el[self._column] + self._new = new_value + + def undo(self): + self._el[self._column] = self._old + + def redo(self): + self._el[self._column] = self._new diff --git a/src/View/Scenarios/Window.py b/src/View/Scenarios/Window.py new file mode 100644 index 00000000..0afe5ea7 --- /dev/null +++ b/src/View/Scenarios/Window.py @@ -0,0 +1,131 @@ +# Window.py -- Pamhyr +# Copyright (C) 2023-2024 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 logging + +from PyQt5.QtCore import QCoreApplication + +from PyQt5.QtGui import ( + QKeySequence, +) + +from PyQt5.QtCore import ( + Qt, QRect, QVariant, QAbstractTableModel, pyqtSlot, pyqtSignal, + QEvent, +) + +from PyQt5.QtWidgets import ( + QTableView, QItemDelegate, QComboBox, QLineEdit, QHBoxLayout, QSlider, + QPushButton, QCheckBox, QStyledItemDelegate, QStyleOptionButton, QStyle, + QApplication, QToolBar, QAction, QHeaderView, QAbstractItemView, + QUndoStack, QShortcut, +) + +from Modules import Modules +from Model.River import RiverNode, RiverReach, River + +from View.Tools.PamhyrWindow import PamhyrWindow +from View.Scenarios.GraphWidget import GraphWidget +from View.Scenarios.UndoCommand import * +from View.Scenarios.translate import ScenariosTranslate +from View.Scenarios.Table import ScenariosTableModel + +# Reservoir short cut +from View.Reservoir.Edit.Window import EditReservoirWindow +import View.Reservoir.UndoCommand as ResUndoCommand + + +logger = logging.getLogger() + +_translate = QCoreApplication.translate + + +class ScenariosWindow(PamhyrWindow): + _pamhyr_ui = "Scenarios" + _pamhyr_name = "Scenarios" + + def __init__(self, study=None, config=None, parent=None): + trad = ScenariosTranslate() + name = trad[self._pamhyr_name] + " - " + study.name + + super(ScenariosWindow, self).__init__( + title=name, + study=study, + config=config, + trad=trad, + options=['undo'], + parent=parent, + ) + + self._table_headers_scenarios = self._trad.get_dict( + "table_headers_scenarios" + ) + + self.setup_graph() + self.setup_table() + self.setup_connections() + + def setup_graph(self): + # self._graph_widget = GraphWidget( + # self._study, + # undo=self._undo_stack, + # trad=self._trad, + # parent=self, + # ) + + self._graph_layout = self.find( + QHBoxLayout, "horizontalLayout_graph" + ) + + # self._graph_layout.addWidget(self._graph_widget) + + def setup_table(self): + table = self.find(QTableView, "tableView_scenarios") + self._scenarios_model = ScenariosTableModel( + table_view=table, + table_headers=self._table_headers_scenarios, + editable_headers=["name", "description"], + data=self._study, + undo=self._undo_stack, + ) + table.setModel(self._scenarios_model) + + table.setSelectionBehavior(QAbstractItemView.SelectRows) + table.horizontalHeader()\ + .setSectionResizeMode(QHeaderView.Stretch) + + def setup_connections(self): + self._scenarios_model.dataChanged.connect(self.update) + # self._scenarios_model.dataChanged.connect( + # self._graph_widget.rename_scenarios + # ) + # self._graph_widget.changeScenarios.connect(self.update) + + def _undo(self): + self._undo_stack.undo() + self.update() + + def _redo(self): + self._undo_stack.redo() + self.update() + + def update(self): + self._scenarios_model.update() + self._graph_widget.display_update() + + self._propagate_update(key=Modules.SCENARIOS) diff --git a/src/View/Scenarios/translate.py b/src/View/Scenarios/translate.py new file mode 100644 index 00000000..101cf377 --- /dev/null +++ b/src/View/Scenarios/translate.py @@ -0,0 +1,45 @@ +# translate.py -- Pamhyr +# Copyright (C) 2023-2024 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 -*- + +from PyQt5.QtCore import QCoreApplication + +from View.Translate import MainTranslate + +_translate = QCoreApplication.translate + + +class ScenariosTranslate(MainTranslate): + def __init__(self): + super(ScenariosTranslate, self).__init__() + + self._dict["Scenarios"] = _translate( + "Scenarios", "Scenarios" + ) + + self._dict["menu_del_scenario"] = _translate( + "Scenarios", "Delete the scenario" + ) + self._dict["menu_add_scenario"] = _translate( + "Scenarios", "Create new derived scenario" + ) + + self._sub_dict["table_headers_scenarios"] = { + "name": self._dict['name'], + "description": self._dict['description'], + "parent": _translate("Scenarios", "Parent"), + } diff --git a/src/View/ui/MainWindow.ui b/src/View/ui/MainWindow.ui index 1156fca9..1ab6f1b3 100644 --- a/src/View/ui/MainWindow.ui +++ b/src/View/ui/MainWindow.ui @@ -210,7 +210,14 @@ + + + Scenarios + + + + @@ -730,6 +737,11 @@ REP additional lines + + + Edit scenarios tree + + diff --git a/src/View/ui/Scenarios.ui b/src/View/ui/Scenarios.ui new file mode 100644 index 00000000..19dd347f --- /dev/null +++ b/src/View/ui/Scenarios.ui @@ -0,0 +1,62 @@ + + + MainWindow + + + + 0 + 0 + 990 + 600 + + + + MainWindow + + + + + + + Qt::Vertical + + + + + + + + + + + + 650 + 150 + + + + + 16777215 + 16777215 + + + + + + + + + + + 0 + 0 + 990 + 22 + + + + + + + +