betty.gui - Betty documentation

Hide navigation sidebar
Hide table of contents sidebar
Skip to content
Back to top
Toggle table of contents sidebar

# Source code for betty.gui

    """Provide the Graphical User Interface (GUI) for Betty Desktop."""

    from __future__ import annotations

    import pickle
    from contextlib import asynccontextmanager
    from typing import Any, TypeVar, Self, TYPE_CHECKING

    from PyQt6.QtCore import pyqtSlot, QObject, QCoreApplication
    from PyQt6.QtGui import QPalette
    from PyQt6.QtWidgets import QApplication, QWidget
    from typing_extensions import override

    from betty.app import App
    from betty.gui.error import ExceptionError, _UnexpectedExceptionError
    from betty.locale import Str
    from betty.serde.format import FormatRepository

    if TYPE_CHECKING:
        from collections.abc import AsyncIterator

    QWidgetT = TypeVar("QWidgetT", bound=QWidget)

    def get_configuration_file_filter() -> Str:
        """
        Get the Qt file filter for project configuration files.
        """
        serde_formats = FormatRepository()
        return Str._(
            "Betty project configuration ({supported_formats})",
            supported_formats=" ".join(
                f"*.{extension}"
                for serde_format in serde_formats.formats
                for extension in serde_format.extensions
            ),
        )

    class GuiBuilder:
        """
        Allow extensions to provide their own Graphical User Interface component.
        """

        def gui_build(self) -> QWidget:
            """
            Build this extension's Graphical User Interface component.
            """
            raise NotImplementedError(repr(self))

    def mark_valid(widget: QWidget) -> None:
        """
        Mark a widget as currently containing valid input.
        """
        widget.setProperty("invalid", "false")
        widget.setStyle(widget.style())
        widget.setToolTip("")

    def mark_invalid(widget: QWidget, reason: str) -> None:
        """
        Mark a widget as currently containing invalid input.
        """
        widget.setProperty("invalid", "true")
        widget.setStyle(widget.style())
        widget.setToolTip(reason)

    class BettyApplication(QApplication):
        """
        A Betty Qt application.
        """

        def __init__(self, *args: Any, **kwargs: Any):
            super().__init__(*args, **kwargs)
            self._app: App | None = None
            self.setApplicationName("Betty")
            self.setStyleSheet(self._stylesheet())

        def _is_dark_mode(self) -> bool:
            palette = self.palette()
            window_lightness = palette.color(QPalette.ColorRole.Window).lightness()
            window_text_lightness = palette.color(QPalette.ColorRole.WindowText).lightness()
            return window_lightness < window_text_lightness

        def _stylesheet(self) -> str:
            caption_color = "#eeeeee" if self._is_dark_mode() else "#333333"
            return f"""
                Caption {{
                    color: {caption_color};
                    font-size: 14px;
                    margin-bottom: 0.3em;
                }}

                Code {{
                    font-family: monospace;
                }}

                QLineEdit[invalid="true"] {{
                    border: 1px solid red;
                    color: red;
                }}

                QPushButton[pane-selector="true"] {{
                    padding: 10px;
                }}

                LogRecord[level="50"],
                LogRecord[level="40"] {{
                    color: red;
                }}

                LogRecord[level="30"] {{
                    color: yellow;
                }}

                LogRecord[level="20"] {{
                    color: green;
                }}

                LogRecord[level="10"],
                LogRecord[level="0"] {{
                    color: white;
                }}

                _WelcomeText {{
                    padding: 10px;
                }}

                _WelcomeTitle {{
                    font-size: 20px;
                    padding: 10px;
                }}

                _WelcomeHeading {{
                    font-size: 16px;
                    margin-top: 50px;
                }}

                _WelcomeAction {{
                    padding: 10px;
                }}
                """

        @pyqtSlot(
            type,
            bytes,
            QObject,
            bool,
        )
        def _show_user_facing_error(
            self,
            error_type: type[Exception],
            pickled_error_message: bytes,
            parent: QObject,
            close_parent: bool,
        ) -> None:
            error_message = pickle.loads(pickled_error_message)
            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()

        @override
        @classmethod
        def instance(cls) -> Self:
            qapp = QCoreApplication.instance()
            assert isinstance(qapp, cls)
            return qapp

        @asynccontextmanager
        async def with_app(self, app: App) -> AsyncIterator[Self]:
            """
            Temporarily set assign a Betty application to this Qt application.
            """
            if self._app is not None:
                raise RuntimeError(f"This {type(self)} already has an {App}.")
            self._app = app
            yield self
            self._app = None

        @property
        def app(self) -> App:
            """
            Get this Qt application's Betty application.
            """
            if self._app is None:
                raise RuntimeError(f"This {type(self)} does not have an {App} yet.")
            return self._app