diff --git a/src/bedit_gui/application.py b/src/bedit_gui/application.py index 9158891..2866083 100644 --- a/src/bedit_gui/application.py +++ b/src/bedit_gui/application.py @@ -6,10 +6,12 @@ from PySide6.QtWidgets import QApplication from bedit_gui.controllers.document_controller import DocumentController from bedit_gui.controllers.log_controller import LogController +from bedit_gui.controllers.settings_controller import SettingsController from bedit_gui.controllers.undo_controller import UndoController from bedit_gui.controllers.view_menu_controller import ViewMenuController from bedit_gui.controllers.window_state_controller import WindowStateController from bedit_gui.documents import Document +from bedit_gui.services.application_settings import ApplicationSettings from bedit_gui.views.main_window import MainWindow @@ -19,10 +21,12 @@ def main() -> int: app.setOrganizationName("BEdit") app.setApplicationName("BEdit") + settings = ApplicationSettings() document = Document(app) window = MainWindow() - LogController(window) + LogController(window, settings.log_level) DocumentController(document, window) + SettingsController(window, settings) UndoController(document, window) ViewMenuController(window) window_state_controller = WindowStateController(app, window) diff --git a/src/bedit_gui/controllers/settings_controller.py b/src/bedit_gui/controllers/settings_controller.py new file mode 100644 index 0000000..637cea6 --- /dev/null +++ b/src/bedit_gui/controllers/settings_controller.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import Protocol + +from PySide6.QtCore import QObject +from PySide6.QtWidgets import QDialog + +from bedit_gui.services.application_logging import get_logger, set_log_level +from bedit_gui.services.application_settings import ApplicationSettings +from bedit_gui.views.dialogs.settings_dialog import SettingsDialog +from bedit_gui.views.main_window import MainWindow + +logger = get_logger(__name__) + + +class SettingsDialogLike(Protocol): + @property + def log_level(self) -> int: ... + + def exec(self) -> int: ... + + +SettingsDialogFactory = Callable[[int, MainWindow], SettingsDialogLike] + + +class SettingsController(QObject): + """Opens the settings dialog and applies accepted preferences.""" + + def __init__( + self, + window: MainWindow, + settings: ApplicationSettings, + dialog_factory: SettingsDialogFactory = SettingsDialog, + ) -> None: + super().__init__(window) + self.window = window + self.settings = settings + self.dialog_factory = dialog_factory + + window.ui.actionSettings.triggered.connect(self.open_settings) + + def open_settings(self) -> None: + dialog = self.dialog_factory(self.settings.log_level, self.window) + if dialog.exec() != QDialog.DialogCode.Accepted: + return + + self.settings.log_level = dialog.log_level + set_log_level(dialog.log_level) + logger.info("Application settings updated") diff --git a/src/bedit_gui/services/application_settings.py b/src/bedit_gui/services/application_settings.py new file mode 100644 index 0000000..fe823f3 --- /dev/null +++ b/src/bedit_gui/services/application_settings.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +import logging + +from PySide6.QtCore import QSettings + + +class ApplicationSettings: + """Typed access to persistent BEdit application settings.""" + + LOG_LEVEL_KEY = "logging/level" + DEFAULT_LOG_LEVEL = logging.INFO + + def __init__(self, settings: QSettings | None = None) -> None: + self._settings = settings if settings is not None else QSettings() + + @property + def log_level(self) -> int: + return self._settings.value( + self.LOG_LEVEL_KEY, + self.DEFAULT_LOG_LEVEL, + type=int, + ) + + @log_level.setter + def log_level(self, level: int) -> None: + self._settings.setValue(self.LOG_LEVEL_KEY, level) diff --git a/src/bedit_gui/ui/forms/main_window.ui b/src/bedit_gui/ui/forms/main_window.ui index 6f08e37..9b7f3e7 100644 --- a/src/bedit_gui/ui/forms/main_window.ui +++ b/src/bedit_gui/ui/forms/main_window.ui @@ -51,6 +51,8 @@ + + @@ -287,6 +289,11 @@ Toolbars + + + Settings + + diff --git a/src/bedit_gui/ui/forms/settings_dialog.ui b/src/bedit_gui/ui/forms/settings_dialog.ui new file mode 100644 index 0000000..8b54520 --- /dev/null +++ b/src/bedit_gui/ui/forms/settings_dialog.ui @@ -0,0 +1,122 @@ + + + Settings + + + + 0 + 0 + 400 + 230 + + + + Settings + + + + + + 0 + + + + General + + + + + + + Debug + + + + + Info + + + + + Warning + + + + + Error + + + + + + + + Log level: + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + + + + + Qt::Orientation::Horizontal + + + QDialogButtonBox::StandardButton::Cancel|QDialogButtonBox::StandardButton::Ok + + + + + + + + + buttonBox + accepted() + Settings + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + Settings + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/src/bedit_gui/views/dialogs/settings_dialog.py b/src/bedit_gui/views/dialogs/settings_dialog.py new file mode 100644 index 0000000..33fd9df --- /dev/null +++ b/src/bedit_gui/views/dialogs/settings_dialog.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import logging + +from PySide6.QtWidgets import QDialog, QWidget + +from bedit_gui.ui.generated.ui_settings_dialog import Ui_Settings + + +class SettingsDialog(QDialog): + """Handwritten behavior for the generated settings form.""" + + LOG_LEVELS = ( + logging.DEBUG, + logging.INFO, + logging.WARNING, + logging.ERROR, + ) + + def __init__( + self, + log_level: int, + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + + self.ui = Ui_Settings() + self.ui.setupUi(self) + self.setWindowTitle("Settings") + + for index, level in enumerate(self.LOG_LEVELS): + self.ui.logLevel.setItemData(index, level) + + selected = self.ui.logLevel.findData(log_level) + self.ui.logLevel.setCurrentIndex( + selected if selected >= 0 else self.LOG_LEVELS.index(logging.INFO) + ) + + @property + def log_level(self) -> int: + return int(self.ui.logLevel.currentData()) diff --git a/tests/unit/test_settings_controller.py b/tests/unit/test_settings_controller.py new file mode 100644 index 0000000..bf5a479 --- /dev/null +++ b/tests/unit/test_settings_controller.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import logging +from pathlib import Path + +import pytest +from PySide6.QtCore import QSettings +from PySide6.QtWidgets import QApplication, QDialog + +from bedit_gui.controllers.settings_controller import SettingsController +from bedit_gui.services.application_logging import get_logger +from bedit_gui.services.application_settings import ApplicationSettings +from bedit_gui.views.main_window import MainWindow + + +class FakeSettingsDialog: + def __init__(self, log_level: int) -> None: + self.initial_log_level = log_level + self.log_level = logging.ERROR + + def exec(self) -> QDialog.DialogCode: + return QDialog.DialogCode.Accepted + + +@pytest.fixture(scope="module") +def qt_app() -> QApplication: + return QApplication.instance() or QApplication([]) + + +@pytest.mark.unit +def test_log_level_is_persisted(tmp_path: Path) -> None: + path = tmp_path / "settings.ini" + backend = QSettings(str(path), QSettings.Format.IniFormat) + settings = ApplicationSettings(backend) + + settings.log_level = logging.DEBUG + backend.sync() + + reloaded = ApplicationSettings( + QSettings(str(path), QSettings.Format.IniFormat) + ) + assert reloaded.log_level == logging.DEBUG + + +@pytest.mark.unit +def test_settings_action_applies_log_level( + qt_app: QApplication, + tmp_path: Path, +) -> None: + backend = QSettings( + str(tmp_path / "settings.ini"), + QSettings.Format.IniFormat, + ) + settings = ApplicationSettings(backend) + window = MainWindow() + dialogs: list[FakeSettingsDialog] = [] + + def make_dialog( + log_level: int, + _window: MainWindow, + ) -> FakeSettingsDialog: + dialog = FakeSettingsDialog(log_level) + dialogs.append(dialog) + return dialog + + SettingsController(window, settings, make_dialog) + window.ui.actionSettings.trigger() + + assert dialogs[0].initial_log_level == logging.INFO + assert settings.log_level == logging.ERROR + assert get_logger("tests").isEnabledFor(logging.ERROR) + assert not get_logger("tests").isEnabledFor(logging.WARNING)