mirror of
https://gitlab.com/pamhyr/pamhyr2.git
synced 2026-10-29 19:10:42 +01:00
377 lines
16 KiB
Python
377 lines
16 KiB
Python
"""Grouped Rubar parameter editor, backed by the existing parameter list."""
|
||
|
||
from functools import partial
|
||
|
||
from PyQt5.QtCore import QCoreApplication, QEvent, Qt
|
||
from PyQt5.QtGui import QKeySequence
|
||
from PyQt5.QtWidgets import (
|
||
QComboBox, QFormLayout, QLabel, QLineEdit, QScrollArea,
|
||
QSizePolicy, QTabWidget, QVBoxLayout, QWidget,
|
||
)
|
||
|
||
from View.SolverParameters.RubarFields import (
|
||
FIELDS, GROUPS, OPTION_CHOICES, UNSUPPORTED_CHOICES, Field, active,
|
||
canonical, validate,
|
||
)
|
||
from View.SolverParameters.UndoCommand import SetCommand
|
||
|
||
|
||
def tr(text):
|
||
return QCoreApplication.translate('RubarParameters', text)
|
||
|
||
|
||
class RubarForm(QWidget):
|
||
def __init__(self, parameters, undo, editable=True, parent=None):
|
||
super().__init__(parent)
|
||
self.parameters = parameters
|
||
self.stack = undo
|
||
self.editable = editable
|
||
self.editors = {}
|
||
self.labels = {}
|
||
self.errors = {}
|
||
self.descriptions = dict(FIELDS)
|
||
self.indices = {p.name: i for i, p in enumerate(parameters.parameters)}
|
||
self._refreshing = False
|
||
self._options_pending = False
|
||
layout = QVBoxLayout(self)
|
||
intro = QLabel(tr('Les valeurs valides sont appliquées à l’étude. '
|
||
'Pensez à enregistrer l’étude pour les conserver.'))
|
||
intro.setWordWrap(True)
|
||
layout.addWidget(intro)
|
||
self.notice = QLabel()
|
||
self.notice.setWordWrap(True)
|
||
self.notice.setTextFormat(Qt.PlainText)
|
||
layout.addWidget(self.notice)
|
||
self.tabs = QTabWidget()
|
||
layout.addWidget(self.tabs)
|
||
forms = []
|
||
for group in GROUPS:
|
||
page = QWidget()
|
||
form = QFormLayout(page)
|
||
form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
|
||
form.setRowWrapPolicy(QFormLayout.WrapLongRows)
|
||
form.setVerticalSpacing(12)
|
||
scroll = QScrollArea()
|
||
scroll.setWidgetResizable(True)
|
||
scroll.setWidget(page)
|
||
self.tabs.addTab(scroll, tr(group))
|
||
forms.append(form)
|
||
# Metadata order is stable even when stored parameter order changes.
|
||
keys = [key for key in FIELDS if key in self.indices]
|
||
keys += [key for key in self.indices if key not in FIELDS]
|
||
for key in keys:
|
||
field = self.descriptions.setdefault(key, Field(
|
||
key, 0, key, 'Paramètre supplémentaire conservé tel quel.',
|
||
kind='text'))
|
||
self._add_field(field, forms[field.group])
|
||
self.help = QLabel(tr(
|
||
'Sélectionnez un réglage pour afficher son aide.'))
|
||
self.help.setWordWrap(True)
|
||
self.help.setTextFormat(Qt.PlainText)
|
||
self.help.setMinimumHeight(60)
|
||
layout.addWidget(self.help)
|
||
self.stack.indexChanged.connect(self.refresh)
|
||
self.refresh()
|
||
|
||
def _add_field(self, field, form):
|
||
key = field.key
|
||
container = QWidget()
|
||
box = QVBoxLayout(container)
|
||
box.setContentsMargins(0, 0, 0, 0)
|
||
if field.kind == 'options':
|
||
editor = QWidget()
|
||
subform = QFormLayout(editor)
|
||
subform.setContentsMargins(0, 0, 0, 0)
|
||
self.option_editors = []
|
||
for title, choices, tooltip in OPTION_CHOICES:
|
||
combo = QComboBox()
|
||
combo.setSizeAdjustPolicy(
|
||
QComboBox.AdjustToMinimumContentsLengthWithIcon)
|
||
combo.addItem(tr('À renseigner'), None)
|
||
for value, label in choices.items():
|
||
combo.addItem(tr(label), value)
|
||
combo.currentIndexChanged.connect(self._commit_options)
|
||
combo.setProperty('parameter', key)
|
||
combo.installEventFilter(self)
|
||
combo.setToolTip(tr(tooltip) + '\n' + key)
|
||
subform.addRow(tr(title), combo)
|
||
self.option_editors.append(combo)
|
||
elif field.kind == 'choice':
|
||
editor = QComboBox()
|
||
editor.setSizeAdjustPolicy(
|
||
QComboBox.AdjustToMinimumContentsLengthWithIcon)
|
||
for value, label in field.choices.items():
|
||
editor.addItem(tr(label), value)
|
||
editor.currentIndexChanged.connect(
|
||
partial(self._choice_changed, key))
|
||
else:
|
||
editor = QLineEdit()
|
||
editor.setPlaceholderText(tr('Valeur du solveur') if field.optional
|
||
else tr('Saisir une valeur'))
|
||
editor.textEdited.connect(partial(self._check_draft, key))
|
||
editor.editingFinished.connect(partial(self._finish_text, key))
|
||
editor.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
|
||
editor.setObjectName(key)
|
||
editor.setProperty('parameter', key)
|
||
editor.installEventFilter(self)
|
||
editor.setToolTip(
|
||
tr(field.help) + '\n' + key + '\nNotice §3.1 / §3.10')
|
||
box.addWidget(editor)
|
||
error = QLabel()
|
||
error.setWordWrap(True)
|
||
error.setTextFormat(Qt.PlainText)
|
||
error.setStyleSheet('color: #b42318;')
|
||
box.addWidget(error)
|
||
label = QLabel(tr(field.label))
|
||
label.setWordWrap(True)
|
||
label.setFixedWidth(310)
|
||
label.setMinimumHeight(label.heightForWidth(310))
|
||
label.setBuddy(editor)
|
||
label.setToolTip(editor.toolTip())
|
||
form.addRow(label, container)
|
||
self.editors[key] = editor
|
||
self.labels[key] = label
|
||
self.errors[key] = error
|
||
|
||
def eventFilter(self, watched, event):
|
||
if event.type() in (QEvent.FocusIn, QEvent.Enter):
|
||
key = watched.property('parameter')
|
||
if key:
|
||
field = self.descriptions[key]
|
||
self.help.setText(tr(field.help) + '\n' + key)
|
||
# Use the model undo stack even inside a line editor.
|
||
if event.type() in (QEvent.ShortcutOverride, QEvent.KeyPress):
|
||
if (event.matches(QKeySequence.Undo) or
|
||
event.matches(QKeySequence.Redo)):
|
||
if event.type() == QEvent.KeyPress:
|
||
if event.matches(QKeySequence.Undo):
|
||
self.undo()
|
||
else:
|
||
self.redo()
|
||
event.accept()
|
||
return True
|
||
return super().eventFilter(watched, event)
|
||
|
||
def values(self):
|
||
return {p.name: str(p.value) for p in self.parameters.parameters}
|
||
|
||
def _error(self, key, message):
|
||
self.errors[key].setText(message)
|
||
self.errors[key].setVisible(bool(message))
|
||
self.editors[key].setStyleSheet(
|
||
'QLineEdit { border: 1px solid #b42318; }' if message else '')
|
||
|
||
def _validated(self, key, value):
|
||
try:
|
||
value = validate(self.descriptions[key], value)
|
||
except ValueError as error:
|
||
self._error(key, tr(str(error)) + tr(' Valeur non enregistrée.'))
|
||
return None
|
||
self._error(key, '')
|
||
return value
|
||
|
||
def _check_draft(self, key, value):
|
||
self._validated(key, value)
|
||
|
||
def _finish_text(self, key):
|
||
editor = self.editors[key]
|
||
if editor.isModified():
|
||
if self.commit(key, editor.text()):
|
||
editor.setModified(False)
|
||
|
||
def _choice_changed(self, key, index):
|
||
if not self._refreshing:
|
||
value = self.editors[key].itemData(index)
|
||
if value is not None:
|
||
self.commit(key, value)
|
||
|
||
def _commit_options(self):
|
||
if self._refreshing:
|
||
return
|
||
values = [combo.currentData() for combo in self.option_editors]
|
||
key = 'rubarbe_sediment_option'
|
||
if any(value is None for value in values):
|
||
self._options_pending = True
|
||
self._error(key, tr(
|
||
'Renseignez les trois choix. Valeur non enregistrée.'))
|
||
else:
|
||
self._options_pending = False
|
||
self.commit(key, ''.join(values))
|
||
|
||
def apply_pending(self):
|
||
"""Commit focused text before Ctrl+S; leave invalid drafts visible."""
|
||
values = self.values()
|
||
if (values.get('rubarbe_condam') == '0' and
|
||
values.get('rubarbe_regime') == '1'):
|
||
self.tabs.setCurrentIndex(1)
|
||
self.editors['rubarbe_condam'].setFocus()
|
||
return False
|
||
for key, editor in self.editors.items():
|
||
if (isinstance(editor, QLineEdit) and editor.isModified() and
|
||
active(self.descriptions[key], self.values())):
|
||
if not self.commit(key, editor.text()):
|
||
self.tabs.setCurrentIndex(self.descriptions[key].group)
|
||
editor.setFocus()
|
||
return False
|
||
if (self._options_pending and
|
||
active(FIELDS['rubarbe_sediment_option'], self.values())):
|
||
self.tabs.setCurrentIndex(2)
|
||
self.option_editors[0].setFocus()
|
||
return False
|
||
return True
|
||
|
||
def commit(self, key, value):
|
||
field = self.descriptions[key]
|
||
if not self.editable or not active(field, self.values()):
|
||
return False
|
||
value = self._validated(key, value)
|
||
if value is None:
|
||
return False
|
||
values = self.values()
|
||
values[key] = value
|
||
if (key in ('rubarbe_condam', 'rubarbe_regime') and
|
||
values.get('rubarbe_condam') == '0' and
|
||
values.get('rubarbe_regime') == '1'):
|
||
self.refresh()
|
||
return False
|
||
if isinstance(self.editors[key], QLineEdit):
|
||
self.editors[key].setModified(False)
|
||
if self.values()[key] != value:
|
||
self.stack.push(SetCommand(self.parameters, self.indices[key],
|
||
'value', value))
|
||
elif isinstance(self.editors[key], QLineEdit):
|
||
self.editors[key].setText(value)
|
||
return True
|
||
|
||
def refresh(self, *args):
|
||
self._refreshing = True
|
||
values = self.values()
|
||
for key, editor in self.editors.items():
|
||
field = self.descriptions[key]
|
||
value = canonical(field, values[key])
|
||
enabled = active(field, values)
|
||
editor.setEnabled(self.editable and enabled)
|
||
self.labels[key].setEnabled(enabled)
|
||
if isinstance(editor, QLineEdit) and editor.isModified():
|
||
self._validated(key, editor.text())
|
||
continue
|
||
if field.kind == 'options' and self._options_pending:
|
||
self.errors[key].setVisible(enabled)
|
||
continue
|
||
if field.kind == 'options':
|
||
valid = len(value) == 3 and all(c in '12' for c in value)
|
||
for i, combo in enumerate(self.option_editors):
|
||
index = combo.findData(value[i]) if valid else 0
|
||
combo.setCurrentIndex(index)
|
||
elif isinstance(editor, QComboBox):
|
||
# Display unknown historical values without replacing them.
|
||
for i in reversed(range(editor.count())):
|
||
if editor.itemData(i) is None:
|
||
editor.removeItem(i)
|
||
index = editor.findData(value)
|
||
if index < 0:
|
||
editor.addItem(tr('Valeur non reconnue : ') + value, None)
|
||
index = editor.count() - 1
|
||
editor.setCurrentIndex(index)
|
||
else:
|
||
editor.setText(values[key])
|
||
try:
|
||
validate(field, values[key])
|
||
self._error(key, '')
|
||
except ValueError as error:
|
||
self._error(key, tr('Valeur enregistrée à vérifier : ') +
|
||
values[key] + ' — ' + tr(str(error)))
|
||
self.errors[key].setVisible(
|
||
enabled and bool(self.errors[key].text()))
|
||
self._update_context(values)
|
||
for key, forbidden, blocked in (
|
||
('rubarbe_regime', '1', values.get('rubarbe_condam') == '0'),
|
||
('rubarbe_condam', '0', values.get('rubarbe_regime') == '1'),
|
||
):
|
||
editor = self.editors.get(key)
|
||
if editor is None:
|
||
continue
|
||
index = editor.findData(forbidden)
|
||
if index >= 0:
|
||
editor.model().item(index).setEnabled(not blocked)
|
||
# Unsupported choices stay visible, so a stored value is still
|
||
# displayed, but cannot be selected anymore
|
||
for (key, value), reason in UNSUPPORTED_CHOICES.items():
|
||
editor = self.editors.get(key)
|
||
index = editor.findData(value) if editor is not None else -1
|
||
if index >= 0:
|
||
item = editor.model().item(index)
|
||
item.setEnabled(False)
|
||
item.setToolTip(tr(reason))
|
||
self._refreshing = False
|
||
|
||
def _update_context(self, values):
|
||
warnings = []
|
||
if not self.editable:
|
||
warnings.append(tr('Étude en lecture seule.'))
|
||
transport = values.get('rubarbe_trased', '').lower() in ('y', 'o')
|
||
if not transport:
|
||
warnings.append(tr('Transport solide désactivé : les réglages '
|
||
'sédimentaires sont conservés mais inactifs.'))
|
||
elif (values.get('rubarbe_sediment_capsol') in ('2', '3') and
|
||
values.get('rubarbe_sediment_choixc') == '2'):
|
||
warnings.append(tr(
|
||
'La capacité solide choisie nécessite une méthode MPC ; '
|
||
'la répartition uniforme est incompatible.'))
|
||
if (values.get('rubarbe_condam') == '0' and
|
||
values.get('rubarbe_regime') == '1'):
|
||
warnings.append(tr('Une limite amont réfléchissante ne permet pas '
|
||
'd’imposer aussi le tirant d’eau.'))
|
||
for (key, value), reason in UNSUPPORTED_CHOICES.items():
|
||
if values.get(key) == value:
|
||
warnings.append(tr(reason) + tr(
|
||
' Le calcul sera refusé : choisissez un autre réglage '
|
||
'de la limite amont.'))
|
||
try:
|
||
from tools import old_pamhyr_date_to_timestamp
|
||
|
||
def seconds(value):
|
||
return (old_pamhyr_date_to_timestamp(value) if ':' in value
|
||
else float(value.replace(',', '.')))
|
||
|
||
start = seconds(values['rubarbe_tinit'])
|
||
if seconds(values['rubarbe_tmax']) <= start:
|
||
warnings.append(tr('La fin du calcul doit suivre son début.'))
|
||
except (KeyError, ValueError):
|
||
pass
|
||
if transport:
|
||
try:
|
||
if float(values.get('rubarbe_sediment_visc', '0')) > 0.001:
|
||
warnings.append(tr(
|
||
'Viscosité élevée pour de l’eau : vérifiez la valeur. '
|
||
'Le seuil critique adimensionnel se saisit dans son '
|
||
'propre champ.'))
|
||
except ValueError:
|
||
pass
|
||
if (FIELDS['rubarbe_sediment_tcadim'].key in self.editors and
|
||
active(FIELDS['rubarbe_sediment_tcadim'], values) and
|
||
not values.get('rubarbe_sediment_tcadim', '').strip()):
|
||
warnings.append(tr(
|
||
'La loi et les options choisies utilisent un seuil '
|
||
'critique adimensionnel : renseignez-le.'))
|
||
self.notice.setText('\n'.join(warnings))
|
||
self.notice.setVisible(bool(warnings))
|
||
|
||
def undo(self):
|
||
if self.editable:
|
||
self._options_pending = False
|
||
for editor in self.editors.values():
|
||
if isinstance(editor, QLineEdit):
|
||
editor.setModified(False)
|
||
self.stack.undo()
|
||
self.refresh()
|
||
|
||
def redo(self):
|
||
if self.editable:
|
||
self._options_pending = False
|
||
for editor in self.editors.values():
|
||
if isinstance(editor, QLineEdit):
|
||
editor.setModified(False)
|
||
self.stack.redo()
|
||
self.refresh()
|