diff --git a/src/Model/Ensembles/Ensemble.py b/src/Model/Ensembles/Ensemble.py index b61c810e..24898fa5 100644 --- a/src/Model/Ensembles/Ensemble.py +++ b/src/Model/Ensembles/Ensemble.py @@ -39,7 +39,7 @@ class Ensemble(SQLSubModel): ) self._name = name - self._type = "generic" + self._data_type = "generic" self._function = function self._range = range self._target_data = target_data @@ -102,7 +102,7 @@ class Ensemble(SQLSubModel): id = next(it) deleted = (next(it) == 1) name = next(it) - type = next(it) + data_type = next(it) fid = next(it) fname = next(it) range = next(it) @@ -158,7 +158,7 @@ class Ensemble(SQLSubModel): brange, length = self._encode_range() fid = self._function._pamhyr_id - if self._function._type == "generic": + if self._function._data_type == "generic": fid = -1 data_pid = -1 @@ -172,7 +172,7 @@ class Ensemble(SQLSubModel): " data_pid, scenario) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", self.pamhyr_id, self.is_deleted(), - self._name, self._type, + self._name, self._data_type, fid, self._function._name, brange, length, data_pid, self._status.scenario_id ) @@ -223,6 +223,15 @@ class Ensemble(SQLSubModel): self._range = range self._status.modified() + @property + def data_type(self): + return self._data_type + + @data_type.setter + def data_type(self, data_type): + self._data_type = data_type + self._status.modified() + @property def target_data(self): return self._target_data diff --git a/src/View/Ensembles/Table.py b/src/View/Ensembles/Table.py new file mode 100644 index 00000000..21c23154 --- /dev/null +++ b/src/View/Ensembles/Table.py @@ -0,0 +1,220 @@ +# Table.py -- Pamhyr +# Copyright (C) 2023-2026 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 trace, timer + +from PyQt5.QtCore import ( + Qt, QVariant, QAbstractTableModel, + QCoreApplication, QModelIndex, pyqtSlot, + QRect, +) + +from PyQt5.QtWidgets import ( + QDialogButtonBox, QPushButton, QLineEdit, + QFileDialog, QTableView, QAbstractItemView, + QUndoStack, QShortcut, QAction, QItemDelegate, + QComboBox, +) + +from View.Ensembles.UndoCommand import ( + SetNameCommand, SetTypeCommand, SetDataCommand, + SetFunctionCommand, + AddCommand, DelCommand, +) + +from View.Tools.PamhyrTable import PamhyrTableModel + +from View.Ensembles.Translate import * + +logger = logging.getLogger() + +_translate = QCoreApplication.translate + + +class ComboBoxDelegate(QItemDelegate): + def __init__(self, data=None, study=None, + mode="stricklers", trad=None, parent=None): + super(ComboBoxDelegate, self).__init__(parent) + + self._data = data + self._study = study + self._trad = trad + self._mode = mode + + def createEditor(self, parent, option, index): + self.editor = QComboBox(parent) + + if self._mode == "data_type": + self.editor.addItems( + [ + self._trad["not_defined"], + self._trad["strickler_minor"], + self._trad["strickler_medium"], + ] + ) + elif self._mode == "stricklers": + self.editor.addItems( + [self._trad["not_defined"]] + + list( + map( + lambda s: str(s), + self._study.river.stricklers.stricklers + ) + ) + ) + elif self._mode == "function": + self.editor.addItems( + [self._trad["not_defined"]] + + list( + map( + lambda s: str(s), + self._study._ens_functions + ) + ) + ) + + self.editor.setCurrentText(index.data(Qt.DisplayRole)) + return self.editor + + def setEditorData(self, editor, index): + value = index.data(Qt.DisplayRole) + self.editor.currentTextChanged.connect(self.currentItemChanged) + + def setModelData(self, editor, model, index): + text = str(editor.currentText()) + model.setData(index, text) + editor.close() + editor.deleteLater() + + def updateEditorGeometry(self, editor, option, index): + r = QRect(option.rect) + if self.editor.windowFlags() & Qt.Popup: + if editor.parent() is not None: + r.setTopLeft(self.editor.parent().mapToGlobal(r.topLeft())) + editor.setGeometry(r) + + @pyqtSlot() + def currentItemChanged(self): + self.commitData.emit(self.sender()) + + +class EnsembleTableModel(PamhyrTableModel): + def _setup_lst(self): + self._lst = self._data + self._study = self._opt_data + + def data(self, index, role): + if role != Qt.ItemDataRole.DisplayRole: + return QVariant() + + row = index.row() + column = index.column() + + if self._headers[column] == "name": + return self._lst.get(row).name + elif self._headers[column] == "type": + return self._lst.get(row).data_type + elif self._headers[column] == "target_data": + value = self._lst.get(row).target_data + if value is None: + return self._trad["not_defined"] + return str(value) + elif self._headers[column] == "function": + value = self._lst.get(row).function + if value is None: + return self._trad["not_defined"] + return str(value) + + return QVariant() + + def setData(self, index, value, role=Qt.EditRole): + if not index.isValid() or role != Qt.EditRole: + return False + + if self.is_same_data(index, value): + return False + + row = index.row() + column = index.column() + + try: + if self._headers[column] == "name": + self._undo.push( + SetNameCommand( + self._lst, row, value + ) + ) + elif self._headers[column] == "type": + self._undo.push( + SetTypeCommand( + self._lst, row, value + ) + ) + elif self._headers[column] == "target_data": + self._undo.push( + SetDataCommand( + self._lst, row, value + ) + ) + elif self._headers[column] == "function": + self._undo.push( + SetFunctionCommand( + self._lst, row, value + ) + ) + except Exception as e: + logger.info(e) + logger.debug(traceback.format_exc()) + + self.dataChanged.emit(index, index) + return True + + def add(self, row, parent=QModelIndex()): + self.beginInsertRows(parent, row, row - 1) + + self._undo.push( + AddCommand( + self._lst, row, self._data + ) + ) + + self.endInsertRows() + self.layoutChanged.emit() + + def delete(self, rows, parent=QModelIndex()): + self.beginRemoveRows(parent, rows[0], rows[-1]) + + self._undo.push( + DelCommand( + self._lst, rows + ) + ) + + self.endRemoveRows() + self.layoutChanged.emit() + + def undo(self): + self._undo.undo() + self.layoutChanged.emit() + + def redo(self): + self._undo.redo() + self.layoutChanged.emit() diff --git a/src/View/Ensembles/Translate.py b/src/View/Ensembles/Translate.py new file mode 100644 index 00000000..848135e2 --- /dev/null +++ b/src/View/Ensembles/Translate.py @@ -0,0 +1,49 @@ +# translate.py -- Pamhyr +# Copyright (C) 2023-2026 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 EnsemblesTranslate(MainTranslate): + def __init__(self): + super(EnsemblesTranslate, self).__init__() + + self._dict["Ensembles"] = _translate( + "Ensembles", "Ensembles" + ) + + self._dict["ensemble"] = _translate( + "Ensembles", "Ensembles scenario" + ) + self._dict["strickler_minor"] = _translate( + "Ensembles", "Stricklers minor" + ) + self._dict["strickler_medium"] = _translate( + "Ensembles", "Stricklers medium" + ) + + self._sub_dict["table_headers"] = { + "name": self._dict["name"], + "type": _translate("Ensembles", "Type"), + "function": _translate("Ensembles", "Function"), + "parameters": _translate("Ensembles", "Parameters"), + } diff --git a/src/View/Ensembles/UndoCommand.py b/src/View/Ensembles/UndoCommand.py new file mode 100644 index 00000000..a4bd0f3a --- /dev/null +++ b/src/View/Ensembles/UndoCommand.py @@ -0,0 +1,131 @@ +# UndoCommand.py -- Pamhyr +# Copyright (C) 2023-2026 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 ( + QMessageBox, QUndoCommand, QUndoStack, +) + +from Model.Friction.Friction import Friction +from Model.Friction.FrictionList import FrictionList + + +class SetNameCommand(QUndoCommand): + def __init__(self, ensembles, index, new_value): + QUndoCommand.__init__(self) + + self._ensembles = ensembles + self._index = index + self._old = self._ensembles.get(self._index).name + self._new = str(new_value) + + def undo(self): + self._ensembles.get(self._index).name = self._old + + def redo(self): + self._ensembles.get(self._index).name = self._new + + +class SetTypeCommand(QUndoCommand): + def __init__(self, ensembles, index, new_value): + QUndoCommand.__init__(self) + + self._ensembles = ensembles + self._index = index + self._old = self._ensembles.get(self._index).type + self._new = new_value + + def undo(self): + self._ensembles.get(self._index).type = self._old + + def redo(self): + self._ensembles.get(self._index).type = self._new + + +class SetDataCommand(QUndoCommand): + def __init__(self, ensembles, index, edge): + QUndoCommand.__init__(self) + + self._ensembles = ensembles + self._index = index + self._old = self._ensembles.get(self._index).edge + self._new = edge + + def undo(self): + self._ensembles.get(self._index).target_data = self._old + + def redo(self): + self._ensembles.get(self._index).target_data = self._new + +class SetFunctionCommand(QUndoCommand): + def __init__(self, ensembles, index, edge): + QUndoCommand.__init__(self) + + self._ensembles = ensembles + self._index = index + self._old = self._ensembles.get(self._index).edge + self._new = edge + + def undo(self): + self._ensembles.get(self._index).function = self._old + + def redo(self): + self._ensembles.get(self._index).function = self._new + +class AddCommand(QUndoCommand): + def __init__(self, ensembles, index, reach): + QUndoCommand.__init__(self) + + self._ensembles = ensembles + self._index = index + self._reach = reach + self._new = None + + def undo(self): + self._ensembles.delete_i([self._index]) + + def redo(self): + if self._new is None: + self._new = self._ensembles.new( + self._reach, self._index + ) + self._new.edge = self._reach + else: + self._ensembles.insert(self._index, self._new) + + +class DelCommand(QUndoCommand): + def __init__(self, ensembles, rows): + QUndoCommand.__init__(self) + + self._ensembles = ensembles + self._rows = rows + + self._friction = [] + for row in rows: + self._friction.append((row, self._ensembles.get(row))) + self._friction.sort() + + def undo(self): + for row, el in self._friction: + self._ensembles.insert(row, el) + + def redo(self): + self._ensembles.delete_i(self._rows) diff --git a/src/View/Ensembles/Window.py b/src/View/Ensembles/Window.py new file mode 100644 index 00000000..6725f6ad --- /dev/null +++ b/src/View/Ensembles/Window.py @@ -0,0 +1,198 @@ +# Window.py -- Pamhyr +# Copyright (C) 2023-2026 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 tools import trace, timer + +from View.Tools.PamhyrWindow import PamhyrWindow + +from PyQt5.QtGui import ( + QKeySequence, +) + +from PyQt5 import QtWidgets +from PyQt5.QtCore import ( + Qt, QVariant, QAbstractTableModel, + QCoreApplication, QModelIndex, pyqtSlot, + QRect, QSettings, +) + +from PyQt5.QtWidgets import ( + QDialogButtonBox, QPushButton, QLineEdit, + QFileDialog, QTableView, QAbstractItemView, + QUndoStack, QShortcut, QAction, QItemDelegate, + QComboBox, QVBoxLayout, QHeaderView, QTabWidget, +) + +from Modules import Modules + +from View.Ensembles.Table import ( + EnsembleTableModel, ComboBoxDelegate +) +from View.Ensembles.Translate import EnsemblesTranslate + +logger = logging.getLogger() + + +class EnsemblesWindow(PamhyrWindow): + _pamhyr_ui = "Ensembles" + _pamhyr_name = "Ensembles" + + def __init__(self, ensembles=None, + study=None, config=None, + parent=None): + trad = EnsemblesTranslate() + + self._ensembles = ensembles + + name = ( + trad[self._pamhyr_name] + " - " + + study.name + ) + + super(EnsemblesWindow, self).__init__( + title=name, + study=study, + config=config, + trad=trad, + parent=parent + ) + + self.setup_table() + self.setup_connections() + + def setup_table_delegate(self): + self._delegate_stricklers = ComboBoxDelegate( + data=self._ensembles, + study=self._study, + mode="stricklers", + trad=self._trad, + parent=self + ) + + self._delegate_data_type = ComboBoxDelegate( + data=self._ensembles, + study=self._study, + mode="data_type", + trad=self._trad, + parent=self + ) + + self._delegate_function = ComboBoxDelegate( + data=self._ensembles, + study=self._study, + mode="function", + trad=self._trad, + parent=self + ) + + return { + "type": self._delegate_data_type, + "target_data": self._delegate_stricklers, + "function": self._delegate_function, + } + + def setup_table(self): + self._table = {} + + delegates = self.setup_table_delegate() + + if self._study.is_editable(): + editable_headers = [ + "name", "type", "function", "target_data" + ] + else: + editable_headers = [] + + table = self.find(QTableView, f"tableView") + self._table = EnsembleTableModel( + table_view=table, + table_headers=self._trad.get_dict("table_headers"), + editable_headers=editable_headers, + delegates=delegates, + data=self._ensembles, + trad=self._trad, + undo=self._undo_stack, + opt_data=self._study + ) + + table.setModel(self._table) + table.setSelectionBehavior(QAbstractItemView.SelectRows) + table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) + table.setAlternatingRowColors(True) + + def setup_connections(self): + if self._study.is_editable(): + self.find(QAction, "action_add").triggered.connect(self.add) + self.find(QAction, "action_del").triggered.connect(self.delete) + # self.find(QAction, "action_edit").triggered.connect(self.edit) + + table = self.find(QTableView, f"tableView") + table.selectionModel()\ + .selectionChanged\ + .connect(self.update) + + self._table.dataChanged\ + .connect(self.update) + + def index_selected_rows(self): + table = self.find(QTableView, f"tableView") + return list( + # Delete duplicate + set( + map( + lambda i: i.row(), + table.selectedIndexes() + ) + ) + ) + + def add(self): + rows = self.index_selected_rows() + if len(self._ensembles) == 0 or len(rows) == 0: + self._table.add(0) + else: + self._table.add(rows[0]) + + def delete(self): + rows = self.index_selected_rows() + if len(rows) == 0: + return + + self._table.delete(rows) + + def _undo(self): + self._table.undo() + + def _redo(self): + self._table.redo() + + # def edit(self): + # if self.sub_window_exists( + # EnsemblesWindow, + # data=[self._study, self.parent.conf] + # ): + # return + + # strick = EnsemblesWindow( + # study=self._study, + # config=self.parent.conf, + # parent=self + # ) + # strick.show() diff --git a/src/View/ui/Ensembles.ui b/src/View/ui/Ensembles.ui new file mode 100644 index 00000000..a88796c4 --- /dev/null +++ b/src/View/ui/Ensembles.ui @@ -0,0 +1,85 @@ + + + MainWindow + + + + 0 + 0 + 800 + 600 + + + + MainWindow + + + + + + + + + + + toolBar + + + TopToolBarArea + + + false + + + + + + + + + ressources/add.pngressources/add.png + + + Add + + + Add ensemble + + + QAction::NoRole + + + + + + ressources/del.pngressources/del.png + + + Delete + + + Delete selected ensembles + + + QAction::NoRole + + + + + + ressources/edit.pngressources/edit.png + + + Edit + + + Edit ensembles range + + + QAction::NoRole + + + + + +