"""Provide the Graphical User Interface (GUI) for Betty Desktop."""
from __future__ import annotations
import pickle
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Any, TypeVar, Self
from PyQt6.QtCore import pyqtSlot, QObject, QCoreApplication
from PyQt6.QtGui import QPalette
from PyQt6.QtWidgets import QApplication, QWidget
from betty.app import App
from betty.gui.error import ExceptionError, _UnexpectedExceptionError
from betty.locale import Str
from betty.serde.format import FormatRepository
QWidgetT = TypeVar("QWidgetT", bound=QWidget)
[docs]
def get_configuration_file_filter() -> Str:
"""
Get the Qt file filter for project configuration files.
"""
formats = FormatRepository()
return Str._(
"Betty project configuration ({supported_formats})",
supported_formats=" ".join(
f"*.{extension}"
for format in formats.formats
for extension in format.extensions
),
)
[docs]
class GuiBuilder:
[docs]
def gui_build(self) -> QWidget:
raise NotImplementedError(repr(self))
[docs]
def mark_valid(widget: QWidget) -> None:
"""
Mark a widget as currently containing valid input.
"""
widget.setProperty("invalid", "false")
widget.setStyle(widget.style())
widget.setToolTip("")
[docs]
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)
[docs]
class BettyApplication(QApplication):
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:
if self._is_dark_mode():
caption_color = "#eeeeee"
else:
caption_color = "#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()
[docs]
@classmethod
def instance(cls) -> Self:
qapp = QCoreApplication.instance()
assert isinstance(qapp, cls)
return qapp
[docs]
@asynccontextmanager
async def with_app(self, app: App) -> AsyncIterator[Self]:
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:
if self._app is None:
raise RuntimeError(f"This {type(self)} does not have an {App} yet.")
return self._app