This commit is contained in:
2026-07-26 14:46:18 +02:00
parent 810f993830
commit 3bd6cfb81d
8 changed files with 259 additions and 3 deletions

View File

@@ -5,6 +5,7 @@ import sys
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.undo_controller import UndoController
from bedit_gui.controllers.view_menu_controller import ViewMenuController
from bedit_gui.controllers.window_state_controller import WindowStateController
@@ -20,6 +21,7 @@ def main() -> int:
document = Document(app)
window = MainWindow()
LogController(window)
DocumentController(document, window)
UndoController(document, window)
ViewMenuController(window)

View File

@@ -6,12 +6,15 @@ from typing import Protocol
from PySide6.QtCore import QEvent, QObject
from bedit_gui.documents import Document
from bedit_gui.services.application_logging import get_logger
from bedit_gui.views.dialogs.document_dialogs import (
DocumentDialogs,
SaveChangesChoice,
)
from bedit_gui.views.main_window import MainWindow
logger = get_logger(__name__)
class DocumentDialogProvider(Protocol):
def choose_open_path(self, current_path: Path | None) -> Path | None: ...
@@ -51,6 +54,7 @@ class DocumentController(QObject):
def new_document(self) -> None:
if self.maybe_save_changes():
self.document.new()
logger.info("Created new document")
def open_document(self) -> None:
if not self.maybe_save_changes():
@@ -63,7 +67,10 @@ class DocumentController(QObject):
try:
self.document.open(path)
except (OSError, TypeError, ValueError) as exc:
logger.exception("Could not open document %s", path)
self.dialogs.show_file_error("Could not open document", exc)
else:
logger.info("Opened document: %s", path)
def save_document(self) -> bool:
if self.document.path is None:
@@ -71,8 +78,10 @@ class DocumentController(QObject):
try:
self.document.save()
except (OSError, TypeError, ValueError) as exc:
logger.exception("Could not save document %s", self.document.path)
self.dialogs.show_file_error("Could not save document", exc)
return False
logger.info("Saved document: %s", self.document.path)
return True
def save_document_as(self) -> bool:
@@ -83,8 +92,10 @@ class DocumentController(QObject):
try:
self.document.save_as(path)
except (OSError, TypeError, ValueError) as exc:
logger.exception("Could not save document as %s", path)
self.dialogs.show_file_error("Could not save document", exc)
return False
logger.info("Saved document as: %s", path)
return True
def maybe_save_changes(self) -> bool:

View File

@@ -0,0 +1,51 @@
from __future__ import annotations
import logging
from PySide6.QtCore import QObject, Signal
from bedit_gui.services.application_logging import configure_logging
from bedit_gui.views.main_window import MainWindow
from bedit_gui.views.models import LogListModel
class _LogEmitter(QObject):
message = Signal(str)
class _QtLogHandler(logging.Handler):
def __init__(self, emitter: _LogEmitter) -> None:
super().__init__()
self._emitter = emitter
def emit(self, record: logging.LogRecord) -> None:
try:
self._emitter.message.emit(self.format(record))
except (RuntimeError, TypeError, ValueError):
self.handleError(record)
class LogController(QObject):
"""Routes standard application log records into the log list view."""
def __init__(
self,
window: MainWindow,
level: int | str = logging.INFO,
) -> None:
super().__init__(window)
self.model = LogListModel()
self.emitter = _LogEmitter(self)
self.handler = _QtLogHandler(self.emitter)
self.handler.setFormatter(
logging.Formatter(
"%(asctime)s %(levelname)s %(message)s",
datefmt="%H:%M:%S",
)
)
self.emitter.message.connect(self.model.append)
self.model.rowsInserted.connect(window.ui.listView.scrollToBottom)
window.ui.listView.setModel(self.model)
configure_logging(self.handler, level)

View File

@@ -1,8 +1,11 @@
from PySide6.QtCore import QObject
from bedit_gui.documents import Document
from bedit_gui.services.application_logging import get_logger
from bedit_gui.views.main_window import MainWindow
logger = get_logger(__name__)
class UndoController(QObject):
def __init__(self, document: Document, window: MainWindow) -> None:
@@ -15,8 +18,8 @@ class UndoController(QObject):
redo_action = window.ui.actionRedo
undo_stack = document.undo_stack
undo_action.triggered.connect(undo_stack.undo)
redo_action.triggered.connect(undo_stack.redo)
undo_action.triggered.connect(self.undo)
redo_action.triggered.connect(self.redo)
undo_stack.canUndoChanged.connect(undo_action.setEnabled)
undo_stack.canRedoChanged.connect(redo_action.setEnabled)
@@ -27,6 +30,16 @@ class UndoController(QObject):
undo_action.setEnabled(undo_stack.canUndo())
redo_action.setEnabled(undo_stack.canRedo())
def undo(self) -> None:
command = self.document.undo_stack.undoText()
self.document.undo_stack.undo()
logger.info("Undo: %s", command)
def redo(self) -> None:
command = self.document.undo_stack.redoText()
self.document.undo_stack.redo()
logger.info("Redo: %s", command)
def _update_undo_text(self, command: str) -> None:
text = f"Undo {command}" if command else "Undo"
self.window.ui.actionUndo.setText(text)

View File

@@ -0,0 +1,32 @@
from __future__ import annotations
import logging
LOGGER_NAMESPACE = "bedit"
_application_logger = logging.getLogger(LOGGER_NAMESPACE)
_application_logger.propagate = False
_application_logger.addHandler(logging.NullHandler())
def get_logger(name: str) -> logging.Logger:
"""Return a logger routed to the BEdit application log."""
return logging.getLogger(f"{LOGGER_NAMESPACE}.{name}")
def configure_logging(
handler: logging.Handler,
level: int | str = logging.INFO,
) -> None:
"""Replace the application output handler and set its log level."""
for existing in list(_application_logger.handlers):
_application_logger.removeHandler(existing)
_application_logger.addHandler(handler)
set_log_level(level)
def set_log_level(level: int | str) -> None:
"""Set the minimum severity shown by all BEdit loggers."""
if isinstance(level, str):
level = level.upper()
_application_logger.setLevel(level)

View File

@@ -0,0 +1,3 @@
from .log_list_model import LogListModel
__all__ = ["LogListModel"]

View File

@@ -0,0 +1,48 @@
from __future__ import annotations
from PySide6.QtCore import QAbstractListModel, QModelIndex, Qt
class LogListModel(QAbstractListModel):
"""Bounded list model containing formatted application log messages."""
def __init__(self, max_entries: int = 2_000) -> None:
super().__init__()
self._messages: list[str] = []
self._max_entries = max_entries
def rowCount(self, parent: QModelIndex | None = None) -> int:
return (
0
if parent is not None and parent.isValid()
else len(self._messages)
)
def data(
self,
index: QModelIndex,
role: int = Qt.ItemDataRole.DisplayRole,
) -> str | None:
if (
not index.isValid()
or not 0 <= index.row() < len(self._messages)
):
return None
if role in (
Qt.ItemDataRole.DisplayRole,
Qt.ItemDataRole.ToolTipRole,
):
return self._messages[index.row()]
return None
def append(self, message: str) -> None:
overflow = len(self._messages) - self._max_entries + 1
if overflow > 0:
self.beginRemoveRows(QModelIndex(), 0, overflow - 1)
del self._messages[:overflow]
self.endRemoveRows()
row = len(self._messages)
self.beginInsertRows(QModelIndex(), row, row)
self._messages.append(message)
self.endInsertRows()

View File

@@ -0,0 +1,96 @@
from __future__ import annotations
import logging
from pathlib import Path
import pytest
from PySide6.QtCore import Qt
from PySide6.QtGui import QUndoCommand
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.undo_controller import UndoController
from bedit_gui.documents import Document
from bedit_gui.services.application_logging import get_logger, set_log_level
from bedit_gui.views.dialogs.document_dialogs import SaveChangesChoice
from bedit_gui.views.main_window import MainWindow
class FakeDialogs:
def __init__(self, path: Path) -> None:
self.path = path
def choose_open_path(self, _current_path: Path | None) -> Path:
return self.path
def choose_save_path(self, _current_path: Path | None) -> Path:
return self.path
def ask_save_changes(self) -> SaveChangesChoice:
return SaveChangesChoice.DISCARD
def show_file_error(self, _title: str, error: Exception) -> None:
raise AssertionError("unexpected file error") from error
@pytest.fixture(scope="module")
def qt_app() -> QApplication:
return QApplication.instance() or QApplication([])
@pytest.mark.unit
def test_log_level_and_qt_log_model(qt_app: QApplication) -> None:
window = MainWindow()
controller = LogController(window, logging.WARNING)
logger = get_logger("tests")
logger.info("hidden message")
logger.warning("visible message")
qt_app.processEvents()
assert controller.model.rowCount() == 1
assert "WARNING visible message" in controller.model.data(
controller.model.index(0),
Qt.ItemDataRole.DisplayRole,
)
set_log_level(logging.INFO)
logger.info("now visible")
qt_app.processEvents()
assert controller.model.rowCount() == 2
@pytest.mark.unit
def test_document_and_undo_actions_are_logged(
qt_app: QApplication,
tmp_path: Path,
) -> None:
path = tmp_path / "document.json"
window = MainWindow()
log_controller = LogController(window)
document = Document(qt_app)
dialogs = FakeDialogs(path)
document_controller = DocumentController(document, window, dialogs)
undo_controller = UndoController(document, window)
document_controller.new_document()
document_controller.save_document_as()
document_controller.save_document()
document_controller.open_document()
document.undo_stack.push(QUndoCommand("test change"))
undo_controller.undo()
undo_controller.redo()
qt_app.processEvents()
messages = [
log_controller.model.data(log_controller.model.index(row))
for row in range(log_controller.model.rowCount())
]
assert any("Created new document" in message for message in messages)
assert any("Opened document:" in message for message in messages)
assert any("Saved document:" in message for message in messages)
assert any("Saved document as:" in message for message in messages)
assert any("Undo: test change" in message for message in messages)
assert any("Redo: test change" in message for message in messages)