diff --git a/betty/assets/betty.pot b/betty/assets/betty.pot index 1f69fb0ed..9ba01e64b 100644 --- a/betty/assets/betty.pot +++ b/betty/assets/betty.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Betty VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2024-02-17 18:26+0000\n" +"POT-Creation-Date: 2024-02-20 23:54+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -243,6 +243,9 @@ msgstr "" msgid "Clear all caches" msgstr "" +msgid "Close" +msgstr "" + msgid "Conference" msgstr "" diff --git a/betty/assets/locale/de-DE/betty.po b/betty/assets/locale/de-DE/betty.po index 7f80ec55b..ed474aa5a 100644 --- a/betty/assets/locale/de-DE/betty.po +++ b/betty/assets/locale/de-DE/betty.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: Betty VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2024-02-17 18:26+0000\n" +"POT-Creation-Date: 2024-02-20 23:54+0000\n" "PO-Revision-Date: 2024-02-08 13:24+0000\n" "Last-Translator: Bart Feenstra \n" "Language: de\n" @@ -357,6 +357,9 @@ msgstr "Saubere URLs" msgid "Clear all caches" msgstr "Alle Caches löschen" +msgid "Close" +msgstr "Schließen" + msgid "Conference" msgstr "Konferenz" diff --git a/betty/assets/locale/fr-FR/betty.po b/betty/assets/locale/fr-FR/betty.po index 897c1e2dc..62019f285 100644 --- a/betty/assets/locale/fr-FR/betty.po +++ b/betty/assets/locale/fr-FR/betty.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2024-02-17 18:26+0000\n" +"POT-Creation-Date: 2024-02-20 23:54+0000\n" "PO-Revision-Date: 2024-02-08 13:24+0000\n" "Last-Translator: Bart Feenstra \n" "Language: fr\n" @@ -297,6 +297,9 @@ msgstr "" msgid "Clear all caches" msgstr "" +msgid "Close" +msgstr "" + msgid "Conference" msgstr "" diff --git a/betty/assets/locale/nl-NL/betty.po b/betty/assets/locale/nl-NL/betty.po index 49b3bad4b..e4bc383a2 100644 --- a/betty/assets/locale/nl-NL/betty.po +++ b/betty/assets/locale/nl-NL/betty.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2024-02-17 18:26+0000\n" +"POT-Creation-Date: 2024-02-20 23:54+0000\n" "PO-Revision-Date: 2024-02-11 15:31+0000\n" "Last-Translator: Bart Feenstra \n" "Language: nl\n" @@ -350,6 +350,9 @@ msgstr "Nette URL's" msgid "Clear all caches" msgstr "Leeg alle caches" +msgid "Close" +msgstr "Sluiten" + msgid "Conference" msgstr "Conferentie" diff --git a/betty/assets/locale/uk/betty.po b/betty/assets/locale/uk/betty.po index 3dba03c23..cd4dbd828 100644 --- a/betty/assets/locale/uk/betty.po +++ b/betty/assets/locale/uk/betty.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: Betty VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2024-02-17 18:26+0000\n" +"POT-Creation-Date: 2024-02-20 23:54+0000\n" "PO-Revision-Date: 2024-02-08 13:08+0000\n" "Last-Translator: Rainer Thieringer \n" "Language: uk\n" @@ -297,6 +297,9 @@ msgstr "" msgid "Clear all caches" msgstr "" +msgid "Close" +msgstr "" + #, fuzzy msgid "Conference" msgstr "Референції" diff --git a/betty/gui/__init__.py b/betty/gui/__init__.py index 69667fb17..04a626f10 100644 --- a/betty/gui/__init__.py +++ b/betty/gui/__init__.py @@ -9,8 +9,7 @@ from PyQt6.QtWidgets import QApplication, QWidget from betty.app import App -from betty.error import UserFacingError -from betty.gui.error import ExceptionError, UnexpectedExceptionError +from betty.gui.error import ExceptionError, _UnexpectedExceptionError from betty.locale import Str from betty.serde.format import FormatRepository @@ -75,6 +74,10 @@ def _stylesheet(self) -> str: margin-bottom: 0.3em; }} + Code {{ + font-family: monospace; + }} + QLineEdit[invalid="true"] {{ border: 1px solid red; color: red; @@ -130,21 +133,34 @@ def __init__(self, *args: Any, app: App, **kwargs: Any): @pyqtSlot( type, bytes, - str, QObject, bool, ) - def _catch_error( + def _show_user_facing_error( self, error_type: type[Exception], pickled_error_message: bytes, - error_traceback: str | None, - parent: QWidget, + parent: QObject, close_parent: bool, ) -> None: error_message = pickle.loads(pickled_error_message) - if issubclass(error_type, UserFacingError): - window = ExceptionError(parent, self._app, error_type, error_message, close_parent=close_parent) - else: - window = UnexpectedExceptionError(parent, self._app, error_type, error_message, error_traceback, close_parent=close_parent) + window = ExceptionError(self._app, error_message, error_type, parent=parent, close_parent=close_parent) + window.show() + + @pyqtSlot( + type, + str, + str, + QObject, + bool, + ) + def _show_unexpected_exception( + self, + error_type: type[Exception], + error_message: str, + error_traceback: str, + parent: QObject, + close_parent: bool, + ) -> None: + window = _UnexpectedExceptionError(self._app, error_type, error_message, error_traceback, parent=parent, close_parent=close_parent) window.show() diff --git a/betty/gui/app.py b/betty/gui/app.py index b863118f4..f63a58c77 100644 --- a/betty/gui/app.py +++ b/betty/gui/app.py @@ -29,10 +29,9 @@ class BettyPrimaryWindow(BettyMainWindow): def __init__( self, app: App, - *, - parent: QObject | None = None, + /, ): - super().__init__(app, parent=parent) + super().__init__(app) self.setWindowIcon(QIcon(path.join(path.dirname(__file__), 'assets', 'public', 'static', 'betty-512x512.png'))) menu_bar = self.menuBar() diff --git a/betty/gui/error.py b/betty/gui/error.py index e7f30eeef..9045fb870 100644 --- a/betty/gui/error.py +++ b/betty/gui/error.py @@ -4,18 +4,20 @@ from __future__ import annotations import pickle -import traceback from asyncio import CancelledError +from logging import getLogger +from traceback import format_exception from types import TracebackType from typing import TypeVar, Generic, ParamSpec from PyQt6.QtCore import QMetaObject, Qt, Q_ARG, QObject -from PyQt6.QtGui import QCloseEvent, QIcon -from PyQt6.QtWidgets import QMessageBox, QWidget +from PyQt6.QtGui import QCloseEvent +from PyQt6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QScrollArea, QFrame from betty.app import App from betty.error import UserFacingError -from betty.gui.locale import LocalizedObject +from betty.gui.text import Code, Text +from betty.gui.window import BettyMainWindow from betty.locale import Str, Localizable T = TypeVar('T') @@ -63,42 +65,73 @@ def _catch(self, exception_type: type[BaseExceptionT] | None, exception: BaseExc if isinstance(exception, self._SUPPRESS_EXCEPTION_TYPES): return None - QMetaObject.invokeMethod( - BettyApplication.instance(), - '_catch_error', - Qt.ConnectionType.QueuedConnection, - Q_ARG(type, type(exception)), - Q_ARG(bytes, pickle.dumps(exception if isinstance(exception, UserFacingError) else Str.plain(str(exception)))), - Q_ARG(str, ''.join(traceback.format_exception(exception))), - Q_ARG(QObject, self._parent), - Q_ARG(bool, self._close_parent), - ) + if isinstance(exception, UserFacingError): + QMetaObject.invokeMethod( + BettyApplication.instance(), + '_show_user_facing_error', + Qt.ConnectionType.QueuedConnection, + Q_ARG(type, exception_type), + Q_ARG(bytes, pickle.dumps(exception)), + Q_ARG(QObject, self._parent), + Q_ARG(bool, self._close_parent), + ) + else: + getLogger(__name__).exception(exception) + QMetaObject.invokeMethod( + BettyApplication.instance(), + '_show_unexpected_exception', + Qt.ConnectionType.QueuedConnection, + Q_ARG(type, exception_type), + Q_ARG(str, str(exception)), + Q_ARG(str, ''.join(format_exception(exception))), + Q_ARG(QObject, self._parent), + Q_ARG(bool, self._close_parent), + ) return True -class Error(LocalizedObject, QMessageBox): +class Error(BettyMainWindow): + window_height = 300 + window_width = 500 + def __init__( self, - parent: QObject, app: App, message: Localizable, *, + parent: QObject, close_parent: bool = False, ): - super().__init__(app, parent) - self._message = message + super().__init__(app, parent=parent) + self._message_localizable = message if close_parent and not isinstance(parent, QWidget): raise ValueError('If `close_parent` is true, `parent` must be `QWidget`.') self._close_parent = close_parent + self.setWindowModality(Qt.WindowModality.WindowModal) + + central_widget = QWidget() + self._central_layout = QVBoxLayout() + central_widget.setLayout(self._central_layout) + self.setCentralWidget(central_widget) + + self._message = Text() + self._central_layout.addWidget(self._message) - standard_button_type = QMessageBox.StandardButton.Close - self.setStandardButtons(standard_button_type) - close_button = self.button(standard_button_type) - assert close_button is not None - close_button.setIcon(QIcon()) - self.setDefaultButton(standard_button_type) - self.setEscapeButton(standard_button_type) - close_button.clicked.connect(self.close) + self._controls = QHBoxLayout() + self._central_layout.addLayout(self._controls) + + self._dismiss = QPushButton() + self._dismiss.released.connect(self.close) + self._controls.addWidget(self._dismiss) + + @property + def window_title(self) -> Localizable: + return Str.plain('{error} - Betty', error=Str._('Error')) + + def _set_translatables(self) -> None: + super()._set_translatables() + self._message.setText(self._message_localizable.localize(self._app.localizer)) + self._dismiss.setText(self._app.localizer._('Close')) def closeEvent(self, a0: QCloseEvent | None) -> None: if self._close_parent: @@ -107,11 +140,6 @@ def closeEvent(self, a0: QCloseEvent | None) -> None: parent.close() super().closeEvent(a0) - def _set_translatables(self) -> None: - super()._set_translatables() - self.setWindowTitle('{error} - Betty'.format(error=self._app.localizer._("Error"))) - self.setText(self._message.localize(self._app.localizer)) - ErrorT = TypeVar('ErrorT', bound=Error) @@ -119,32 +147,45 @@ def _set_translatables(self) -> None: class ExceptionError(Error): def __init__( self, - parent: QWidget, app: App, + message: Localizable, error_type: type[BaseException], - error_message: Localizable, *, + parent: QObject, close_parent: bool = False, ): - super().__init__(parent, app, error_message, close_parent=close_parent) + super().__init__(app, message, parent=parent, close_parent=close_parent) self.error_type = error_type -class UnexpectedExceptionError(ExceptionError): +class _UnexpectedExceptionError(ExceptionError): def __init__( self, - parent: QWidget, app: App, - error_type: type[BaseException], - error_message: Localizable, - error_traceback: str | None, + error_type: type[Exception], + error_message: str, + error_traceback: str, *, + parent: QObject, close_parent: bool = False, ): - super().__init__(parent, app, error_type, error_message, close_parent=close_parent) - self.setText(self._app.localizer._('An unexpected error occurred and Betty could not complete the task. Please report this problem and include the following details, so the team behind Betty can address it.').format( - report_url='https://github.com/bartfeenstra/betty/issues', - )) - self.setTextFormat(Qt.TextFormat.RichText) - if error_traceback: - self.setDetailedText(error_traceback) + super().__init__( + app, + Str._( + 'An unexpected error occurred and Betty could not complete the task. Please report this problem and include the following details, so the team behind Betty can address it.', + report_url='https://github.com/bartfeenstra/betty/issues', + ), + error_type, + parent=parent, + close_parent=close_parent, + ) + + if error_message: + self._exception_message = Code(error_message) + self._central_layout.addWidget(self._exception_message) + + self._exception_details = QScrollArea() + self._exception_details.setFrameShape(QFrame.Shape.NoFrame) + self._exception_details.setWidget(Code(error_traceback + error_traceback + error_traceback + error_traceback)) + self._exception_details.setWidgetResizable(True) + self._central_layout.addWidget(self._exception_details) diff --git a/betty/gui/project.py b/betty/gui/project.py index a9e851005..e8b9acb67 100644 --- a/betty/gui/project.py +++ b/betty/gui/project.py @@ -647,6 +647,9 @@ def __init__( self._serve_button.released.connect(self._serve) button_layout.addWidget(self._serve_button) + self._log_record_viewer = LogRecordViewer() + central_layout.addWidget(self._log_record_viewer) + self._logging_handler = LogRecordViewerHandler(self._log_record_viewer) load.getLogger().addHandler(self._logging_handler) generate.getLogger().addHandler(self._logging_handler) diff --git a/betty/gui/text.py b/betty/gui/text.py index 78d91b7bc..ab45db05a 100644 --- a/betty/gui/text.py +++ b/betty/gui/text.py @@ -23,3 +23,13 @@ def __init__(self, text: str | None = None): super().__init__(text) font = QFont() self.setFont(font) + + +class Code(Text): + def __init__(self, text: str | None = None): + super().__init__(text) + font = QFont() + self.setFont(font) + + def setText(self, a0: str | None) -> None: + super().setText(f'
{a0}
' if a0 else a0) diff --git a/betty/tests/conftest.py b/betty/tests/conftest.py index 8cf71189b..3d4027b24 100644 --- a/betty/tests/conftest.py +++ b/betty/tests/conftest.py @@ -16,6 +16,7 @@ from betty.app import AppConfiguration, App from betty.gui import BettyApplication +from betty.gui.app import BettyPrimaryWindow from betty.gui.error import ErrorT _qapp_instance: BettyApplication | None = None @@ -117,7 +118,8 @@ def _assert_window() -> None: assert len(windows) == 1 self.qtbot.waitUntil(_assert_window) window = windows[0] - self.qtbot.addWidget(window) + if isinstance(window, BettyPrimaryWindow): + self.qtbot.addWidget(window) return cast(QMainWindowT, window) def assert_not_window(self, window_type: type[QMainWindow] | QMainWindow) -> None: @@ -147,7 +149,6 @@ def _assert_error_modal() -> None: widget = self.qapp.activeModalWidget() assert isinstance(widget, error_type) self.qtbot.waitUntil(_assert_error_modal) - self.qtbot.addWidget(widget) return cast(ErrorT, widget) def assert_valid(self, widget: QWidget) -> None: