diff --git a/src/ert/gui/suggestor/_suggestor_message.py b/src/ert/gui/suggestor/_suggestor_message.py index 5037d30f22b..126c58dab5d 100644 --- a/src/ert/gui/suggestor/_suggestor_message.py +++ b/src/ert/gui/suggestor/_suggestor_message.py @@ -1,7 +1,5 @@ from __future__ import annotations -from typing import TYPE_CHECKING - from PyQt5 import QtSvg from PyQt5.QtCore import Qt from PyQt5.QtGui import QColor @@ -10,9 +8,10 @@ QHBoxLayout, QLabel, QSizePolicy, + QVBoxLayout, QWidget, ) -from typing_extensions import Self +from typing_extensions import Any, Self from ._colors import ( BLUE_BACKGROUND, @@ -23,9 +22,6 @@ YELLOW_TEXT, ) -if TYPE_CHECKING: - from ert.config import ErrorInfo, WarningInfo - def _svg_icon(image_name: str) -> QtSvg.QSvgWidget: widget = QtSvg.QSvgWidget(f"img:{image_name}.svg") @@ -40,7 +36,8 @@ def __init__( text_color: str, bg_color: str, icon: QWidget, - info: ErrorInfo, + message: str, + locations: list[str], ) -> None: super().__init__() self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground) @@ -58,39 +55,111 @@ def __init__( self.setGraphicsEffect(shadowEffect) self.setContentsMargins(0, 0, 0, 0) - self.icon = icon - info.message = info.message.replace("<", "<").replace(">", ">") - self.lbl = QLabel( - '
' - + f'' - + header - + "" - + info.message - + "

" - + info.location() - + "

" - + "
" - ) + self._icon = icon + self._message = message.replace("<", "<").replace(">", ">") + self._locations = locations + self._header = header + self._text_color = text_color + + self._hbox = QHBoxLayout() + self._hbox.setContentsMargins(16, 16, 16, 16) + self._hbox.addWidget(self._icon, alignment=Qt.AlignmentFlag.AlignTop) + self.setLayout(self._hbox) + + self.lbl = QLabel(self._collapsed_text()) + self.lbl.setOpenExternalLinks(False) self.lbl.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.lbl.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) self.lbl.setWordWrap(True) + self._expanded = False + if len(self._locations) > 1: + self._expand_collapse_label = QLabel(self._expand_link()) + self._expand_collapse_label.setOpenExternalLinks(False) + self._expand_collapse_label.linkActivated.connect(self._toggle_expand) + + self._vbox = QWidget() + layout = QVBoxLayout() + self._vbox.setLayout(layout) + layout.addWidget(self.lbl) + layout.addWidget(self._expand_collapse_label) + self._hbox.addWidget(self._vbox, alignment=Qt.AlignmentFlag.AlignTop) + else: + self._expand_collapse_label = QLabel() + self._hbox.addWidget(self.lbl, alignment=Qt.AlignmentFlag.AlignTop) + + def _toggle_expand(self, _link: Any) -> None: + if self._expanded: + self.lbl.setText(self._collapsed_text()) + self._expand_collapse_label.setText(self._expand_link()) + else: + self.lbl.setText(self._expanded_text()) + self._expand_collapse_label.setText(self._hide_link()) + self._expanded = not self._expanded + + def _hide_link(self) -> str: + return " show less" + + def _expand_link(self) -> str: + return f" and {len(self._locations) - 1} more" + + def _collapsed_text(self) -> str: + location_paragraph = "" + if self._locations: + location_paragraph = self._locations[0] + location_paragraph = ( + "

" + self._color_bold("location: ") + location_paragraph + "

" + ) - self.hbox = QHBoxLayout() - self.hbox.setContentsMargins(16, 16, 16, 16) - self.hbox.addWidget(self.icon, alignment=Qt.AlignmentFlag.AlignTop) - self.hbox.addWidget(self.lbl, alignment=Qt.AlignmentFlag.AlignTop) - self.setLayout(self.hbox) + return self._text(location_paragraph) + + def _expanded_text(self) -> str: + location_paragraphs = "" + first = True + for loc in self._locations: + if first: + location_paragraphs += f'

{self._color_bold("location:")}{loc}

' + first = False + else: + location_paragraphs += f"

{loc}

" + + return self._text(location_paragraphs) + + def _text(self, location: str) -> str: + return ( + '
' + + self._color_bold(self._header) + + self._message + + location + + "
" + ) + + def _color_bold(self, text: str) -> str: + return f'{text}' @classmethod - def error_msg(cls, info: ErrorInfo) -> Self: - return cls("Error: ", RED_TEXT, RED_BACKGROUND, _svg_icon("error"), info) + def error_msg(cls, message: str, locations: list[str]) -> Self: + return cls( + "Error: ", RED_TEXT, RED_BACKGROUND, _svg_icon("error"), message, locations + ) @classmethod - def warning_msg(cls, info: WarningInfo) -> Self: + def warning_msg(cls, message: str, locations: list[str]) -> Self: return cls( - "Warning: ", YELLOW_TEXT, YELLOW_BACKGROUND, _svg_icon("warning"), info + "Warning: ", + YELLOW_TEXT, + YELLOW_BACKGROUND, + _svg_icon("warning"), + message, + locations, ) @classmethod - def deprecation_msg(cls, info: WarningInfo) -> Self: - return cls("Deprecation: ", BLUE_TEXT, BLUE_BACKGROUND, _svg_icon("bell"), info) + def deprecation_msg(cls, message: str, locations: list[str]) -> Self: + return cls( + "Deprecation: ", + BLUE_TEXT, + BLUE_BACKGROUND, + _svg_icon("bell"), + message, + locations, + ) diff --git a/src/ert/gui/suggestor/suggestor.py b/src/ert/gui/suggestor/suggestor.py index df62218bcbb..5f8f397f3dc 100644 --- a/src/ert/gui/suggestor/suggestor.py +++ b/src/ert/gui/suggestor/suggestor.py @@ -3,7 +3,8 @@ import functools import logging import webbrowser -from typing import TYPE_CHECKING, Callable, Dict, List, Optional +from collections import defaultdict +from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Sequence from PyQt5.QtGui import QCursor from qtpy.QtCore import Qt @@ -33,6 +34,13 @@ def _clicked_help_button(menu_label: str, link: str) -> None: webbrowser.open(link) +def _combine_messages(infos: Sequence[ErrorInfo]) -> list[tuple[str, list[str]]]: + combined = defaultdict(list) + for info in infos: + combined[info.message].append(info.location()) + return list(combined.items()) + + LIGHT_GREY = "#f7f7f7" MEDIUM_GREY = "#eaeaea" HEAVY_GREY = "#dcdcdc" @@ -232,6 +240,7 @@ def _messages( NUM_COLUMNS = 2 suggest_msgs = QWidget(parent=self) + suggest_msgs.setObjectName("suggestor_messages") suggest_msgs.setContentsMargins(0, 0, 16, 0) suggest_layout = QGridLayout() suggest_layout.setContentsMargins(0, 0, 0, 0) @@ -241,20 +250,24 @@ def _messages( column = 0 row = 0 num = 0 - for msg in errors: - suggest_layout.addWidget(SuggestorMessage.error_msg(msg), row, column) + for combined in _combine_messages(errors): + suggest_layout.addWidget(SuggestorMessage.error_msg(*combined), row, column) if column: row += 1 column = (column + 1) % NUM_COLUMNS num += 1 - for msg in warnings: - suggest_layout.addWidget(SuggestorMessage.warning_msg(msg), row, column) + for combined in _combine_messages(warnings): + suggest_layout.addWidget( + SuggestorMessage.warning_msg(*combined), row, column + ) if column: row += 1 column = (column + 1) % NUM_COLUMNS num += 1 - for msg in deprecations: - suggest_layout.addWidget(SuggestorMessage.deprecation_msg(msg), row, column) + for combined in _combine_messages(deprecations): + suggest_layout.addWidget( + SuggestorMessage.deprecation_msg(*combined), row, column + ) if column: row += 1 column = (column + 1) % NUM_COLUMNS diff --git a/tests/unit_tests/gui/test_suggestor.py b/tests/unit_tests/gui/test_suggestor.py new file mode 100644 index 00000000000..a04366e07cc --- /dev/null +++ b/tests/unit_tests/gui/test_suggestor.py @@ -0,0 +1,23 @@ +import pytest +from qtpy.QtWidgets import QWidget + +from ert.config import ErrorInfo +from ert.gui.suggestor import Suggestor + + +@pytest.mark.parametrize( + "errors, expected_num", + [ + ([ErrorInfo("msg_1")], 1), + ([ErrorInfo("msg_1"), ErrorInfo("msg_2")], 2), + ([ErrorInfo("msg_1"), ErrorInfo("msg_1"), ErrorInfo("msg_2")], 2), + ([ErrorInfo("msg_1"), ErrorInfo("msg_2"), ErrorInfo("msg_3")], 3), + ], +) +def test_suggestor_combines_errors_with_the_same_message(qtbot, errors, expected_num): + suggestor = Suggestor(errors, [], [], lambda: None) + msgs = suggestor.findChild(QWidget, name="suggestor_messages") + assert msgs is not None + msg_layout = msgs.layout() + assert msg_layout is not None + assert msg_layout.count() == expected_num