Compare commits
5 Commits
38b8ee34ff
...
9312ea544b
| Author | SHA1 | Date | |
|---|---|---|---|
| 9312ea544b | |||
| 3bd6cfb81d | |||
| 810f993830 | |||
| f37cc41ed0 | |||
| 4c3b8b4b6d |
@@ -1,10 +1,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
GUI_PACKAGE = ROOT / "src" / "bedit_gui"
|
GUI_PACKAGE = ROOT / "src" / "bedit_gui"
|
||||||
|
|
||||||
@@ -15,9 +13,15 @@ QRC_FILE = GUI_PACKAGE / "resources" / "resources.qrc"
|
|||||||
GENERATED_RESOURCES = (
|
GENERATED_RESOURCES = (
|
||||||
GUI_PACKAGE
|
GUI_PACKAGE
|
||||||
/ "resources"
|
/ "resources"
|
||||||
|
/ "generated"
|
||||||
/ "resources_rc.py"
|
/ "resources_rc.py"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
RESOURCE_IMPORT = "import bedit_gui.resources.resources_rc"
|
||||||
|
GENERATED_RESOURCE_IMPORT = (
|
||||||
|
"from bedit_gui.resources.generated import resources_rc"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def execute(*command: str) -> None:
|
def execute(*command: str) -> None:
|
||||||
print("+", " ".join(command))
|
print("+", " ".join(command))
|
||||||
@@ -39,6 +43,16 @@ def generate_ui() -> None:
|
|||||||
"-o",
|
"-o",
|
||||||
str(destination),
|
str(destination),
|
||||||
)
|
)
|
||||||
|
generated = destination.read_text(encoding="utf-8")
|
||||||
|
if RESOURCE_IMPORT in generated:
|
||||||
|
destination.write_text(
|
||||||
|
generated.replace(
|
||||||
|
RESOURCE_IMPORT,
|
||||||
|
GENERATED_RESOURCE_IMPORT,
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def generate_resources() -> None:
|
def generate_resources() -> None:
|
||||||
|
|||||||
@@ -1,18 +1,36 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from PySide6.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
from bedit_gui.resources import resources_rc
|
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
|
from bedit_gui.views.main_window import MainWindow
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
app = QApplication(sys.argv)
|
app = QApplication(sys.argv)
|
||||||
|
|
||||||
app.setOrganizationName("BEdit")
|
app.setOrganizationName("BEdit")
|
||||||
app.setApplicationName("BEdit")
|
app.setApplicationName("BEdit")
|
||||||
|
|
||||||
|
settings = ApplicationSettings()
|
||||||
|
document = Document(app)
|
||||||
window = MainWindow()
|
window = MainWindow()
|
||||||
window.show()
|
LogController(window, settings.log_level)
|
||||||
|
DocumentController(document, window)
|
||||||
|
SettingsController(window, settings)
|
||||||
|
UndoController(document, window)
|
||||||
|
ViewMenuController(window)
|
||||||
|
window_state_controller = WindowStateController(app, window)
|
||||||
|
window_state_controller.restore()
|
||||||
|
window.showMaximized()
|
||||||
|
|
||||||
return app.exec()
|
return app.exec()
|
||||||
|
|||||||
124
src/bedit_gui/controllers/document_controller.py
Normal file
124
src/bedit_gui/controllers/document_controller.py
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
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: ...
|
||||||
|
|
||||||
|
def choose_save_path(self, current_path: Path | None) -> Path | None: ...
|
||||||
|
|
||||||
|
def ask_save_changes(self) -> SaveChangesChoice: ...
|
||||||
|
|
||||||
|
def show_file_error(self, title: str, error: Exception) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentController(QObject):
|
||||||
|
"""Coordinates the single-document workflow with the main window."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
document: Document,
|
||||||
|
window: MainWindow,
|
||||||
|
dialogs: DocumentDialogProvider | None = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(window)
|
||||||
|
self.document = document
|
||||||
|
self.window = window
|
||||||
|
self.dialogs = dialogs or DocumentDialogs(window)
|
||||||
|
|
||||||
|
window.ui.actionNew_File.triggered.connect(self.new_document)
|
||||||
|
window.ui.actionOpen_File.triggered.connect(self.open_document)
|
||||||
|
window.ui.actionSave_File.triggered.connect(self.save_document)
|
||||||
|
window.ui.actionSave_File_As.triggered.connect(self.save_document_as)
|
||||||
|
window.ui.actionClose.triggered.connect(window.close)
|
||||||
|
|
||||||
|
document.path_changed.connect(self.update_window_title)
|
||||||
|
document.modified_changed.connect(self.update_window_title)
|
||||||
|
window.installEventFilter(self)
|
||||||
|
self.update_window_title()
|
||||||
|
|
||||||
|
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():
|
||||||
|
return
|
||||||
|
|
||||||
|
path = self.dialogs.choose_open_path(self.document.path)
|
||||||
|
if path is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
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:
|
||||||
|
return self.save_document_as()
|
||||||
|
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:
|
||||||
|
path = self.dialogs.choose_save_path(self.document.path)
|
||||||
|
if path is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
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:
|
||||||
|
if not self.document.modified:
|
||||||
|
return True
|
||||||
|
|
||||||
|
choice = self.dialogs.ask_save_changes()
|
||||||
|
if choice is SaveChangesChoice.SAVE:
|
||||||
|
return self.save_document()
|
||||||
|
return choice is SaveChangesChoice.DISCARD
|
||||||
|
|
||||||
|
def update_window_title(self, *_args: object) -> None:
|
||||||
|
name = self.document.path.name if self.document.path else "Untitled"
|
||||||
|
marker = "*" if self.document.modified else ""
|
||||||
|
self.window.setWindowTitle(f"{name}{marker} — BEdit")
|
||||||
|
|
||||||
|
def eventFilter(self, watched: QObject, event: QEvent) -> bool:
|
||||||
|
window = getattr(self, "window", None)
|
||||||
|
if (
|
||||||
|
watched is window
|
||||||
|
and event.type() == QEvent.Type.Close
|
||||||
|
and not self.maybe_save_changes()
|
||||||
|
):
|
||||||
|
event.ignore()
|
||||||
|
return True
|
||||||
|
return super().eventFilter(watched, event)
|
||||||
51
src/bedit_gui/controllers/log_controller.py
Normal file
51
src/bedit_gui/controllers/log_controller.py
Normal 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)
|
||||||
50
src/bedit_gui/controllers/settings_controller.py
Normal file
50
src/bedit_gui/controllers/settings_controller.py
Normal file
@@ -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")
|
||||||
49
src/bedit_gui/controllers/undo_controller.py
Normal file
49
src/bedit_gui/controllers/undo_controller.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
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:
|
||||||
|
super().__init__(window)
|
||||||
|
|
||||||
|
self.document = document
|
||||||
|
self.window = window
|
||||||
|
|
||||||
|
undo_action = window.ui.actionUndo
|
||||||
|
redo_action = window.ui.actionRedo
|
||||||
|
undo_stack = document.undo_stack
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
undo_stack.undoTextChanged.connect(self._update_undo_text)
|
||||||
|
undo_stack.redoTextChanged.connect(self._update_redo_text)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
def _update_redo_text(self, command: str) -> None:
|
||||||
|
text = f"Redo {command}" if command else "Redo"
|
||||||
|
self.window.ui.actionRedo.setText(text)
|
||||||
49
src/bedit_gui/controllers/view_menu_controller.py
Normal file
49
src/bedit_gui/controllers/view_menu_controller.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from PySide6.QtCore import QObject
|
||||||
|
from PySide6.QtWidgets import QDockWidget, QMenu, QToolBar
|
||||||
|
|
||||||
|
from bedit_gui.views.main_window import MainWindow
|
||||||
|
|
||||||
|
|
||||||
|
class ViewMenuController(QObject):
|
||||||
|
"""Populate View submenus with synchronized visibility actions."""
|
||||||
|
|
||||||
|
def __init__(self, window: MainWindow) -> None:
|
||||||
|
super().__init__(window)
|
||||||
|
|
||||||
|
self.panels_menu = QMenu("Panels", window.ui.menuView)
|
||||||
|
self.toolbars_menu = QMenu("Toolbars", window.ui.menuView)
|
||||||
|
|
||||||
|
window.ui.actionPanels.setMenu(self.panels_menu)
|
||||||
|
window.ui.actionToolbars.setMenu(self.toolbars_menu)
|
||||||
|
|
||||||
|
self._populate(
|
||||||
|
self.panels_menu,
|
||||||
|
window.findChildren(QDockWidget),
|
||||||
|
)
|
||||||
|
self._populate(
|
||||||
|
self.toolbars_menu,
|
||||||
|
window.findChildren(QToolBar),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _populate(
|
||||||
|
self,
|
||||||
|
menu: QMenu,
|
||||||
|
widgets: list[QDockWidget] | list[QToolBar],
|
||||||
|
) -> None:
|
||||||
|
for widget in sorted(widgets, key=self._label):
|
||||||
|
action = widget.toggleViewAction()
|
||||||
|
action.setText(self._label(widget))
|
||||||
|
menu.addAction(action)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _label(widget: QDockWidget | QToolBar) -> str:
|
||||||
|
title = widget.windowTitle().strip()
|
||||||
|
if title and title.lower() != "toolbar":
|
||||||
|
return title
|
||||||
|
|
||||||
|
name = re.sub(r"(DockWidget|Widget|ToolBar)$", "", widget.objectName())
|
||||||
|
return re.sub(r"(?<!^)(?=[A-Z])", " ", name).title()
|
||||||
37
src/bedit_gui/controllers/window_state_controller.py
Normal file
37
src/bedit_gui/controllers/window_state_controller.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
from PySide6.QtCore import QObject, QSettings, QByteArray
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from bedit_gui.views.main_window import MainWindow
|
||||||
|
|
||||||
|
|
||||||
|
class WindowStateController(QObject):
|
||||||
|
def __init__(self,app: QApplication,window: MainWindow) -> None:
|
||||||
|
super().__init__(window)
|
||||||
|
|
||||||
|
self.window = window
|
||||||
|
self.settings = QSettings()
|
||||||
|
|
||||||
|
# Capture the layout created by Designer.
|
||||||
|
self._default_geometry = QByteArray(window.saveGeometry())
|
||||||
|
self._default_state = QByteArray(window.saveState())
|
||||||
|
|
||||||
|
app.aboutToQuit.connect(self.save)
|
||||||
|
window.ui.actionReset_Layout.triggered.connect(self.reset)
|
||||||
|
|
||||||
|
def restore(self) -> None:
|
||||||
|
if self.settings.contains("main_window/geometry"):
|
||||||
|
self.window.restoreGeometry(self.settings.value("main_window/geometry"))
|
||||||
|
|
||||||
|
if self.settings.contains("main_window/state"):
|
||||||
|
self.window.restoreState(self.settings.value("main_window/state"))
|
||||||
|
|
||||||
|
def save(self) -> None:
|
||||||
|
self.settings.setValue("main_window/geometry",self.window.saveGeometry())
|
||||||
|
self.settings.setValue("main_window/state",self.window.saveState())
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
self.settings.remove("main_window/geometry")
|
||||||
|
self.settings.remove("main_window/state")
|
||||||
|
self.window.restoreGeometry(self._default_geometry)
|
||||||
|
self.window.restoreState(self._default_state)
|
||||||
|
self.window.showMaximized()
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from .document import Document
|
||||||
|
|
||||||
|
__all__ = ["Document"]
|
||||||
|
|||||||
80
src/bedit_gui/documents/document.py
Normal file
80
src/bedit_gui/documents/document.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PySide6.QtCore import QObject, Signal
|
||||||
|
from PySide6.QtGui import QUndoStack
|
||||||
|
|
||||||
|
from bedit_core.models import ID
|
||||||
|
from bedit_core.models import Document as CoreDocument
|
||||||
|
from bedit_gui.services import document_files
|
||||||
|
|
||||||
|
|
||||||
|
class Document(QObject):
|
||||||
|
"""The editable document currently owned by the GUI application."""
|
||||||
|
|
||||||
|
model_changed = Signal(object)
|
||||||
|
path_changed = Signal(object)
|
||||||
|
modified_changed = Signal(bool)
|
||||||
|
|
||||||
|
def __init__(self, parent: QObject | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.undo_stack = QUndoStack(self)
|
||||||
|
self.undo_stack.cleanChanged.connect(self._on_clean_changed)
|
||||||
|
self._model = self._new_model()
|
||||||
|
self._path: Path | None = None
|
||||||
|
self.undo_stack.setClean()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def model(self) -> CoreDocument:
|
||||||
|
return self._model
|
||||||
|
|
||||||
|
@property
|
||||||
|
def path(self) -> Path | None:
|
||||||
|
return self._path
|
||||||
|
|
||||||
|
@property
|
||||||
|
def modified(self) -> bool:
|
||||||
|
return not self.undo_stack.isClean()
|
||||||
|
|
||||||
|
def new(self) -> None:
|
||||||
|
self._replace(self._new_model(), None)
|
||||||
|
|
||||||
|
def open(self, path: str | Path) -> None:
|
||||||
|
file_path = Path(path)
|
||||||
|
model = document_files.load(file_path)
|
||||||
|
self._replace(model, file_path)
|
||||||
|
|
||||||
|
def save(self) -> None:
|
||||||
|
if self._path is None:
|
||||||
|
raise ValueError("the document does not have a file path")
|
||||||
|
document_files.save(self._model, self._path)
|
||||||
|
self.undo_stack.setClean()
|
||||||
|
|
||||||
|
def save_as(self, path: str | Path) -> None:
|
||||||
|
file_path = Path(path)
|
||||||
|
document_files.save(self._model, file_path)
|
||||||
|
if file_path != self._path:
|
||||||
|
self._path = file_path
|
||||||
|
self.path_changed.emit(file_path)
|
||||||
|
self.undo_stack.setClean()
|
||||||
|
|
||||||
|
def _replace(self, model: CoreDocument, path: Path | None) -> None:
|
||||||
|
self.undo_stack.clear()
|
||||||
|
self._model = model
|
||||||
|
self._path = path
|
||||||
|
self.model_changed.emit(model)
|
||||||
|
self.path_changed.emit(path)
|
||||||
|
self.undo_stack.setClean()
|
||||||
|
|
||||||
|
def _on_clean_changed(self, clean: bool) -> None:
|
||||||
|
self.modified_changed.emit(not clean)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _new_model() -> CoreDocument:
|
||||||
|
return CoreDocument(
|
||||||
|
format_version=1,
|
||||||
|
id=ID(),
|
||||||
|
name="Untitled",
|
||||||
|
root={},
|
||||||
|
)
|
||||||
32
src/bedit_gui/services/application_logging.py
Normal file
32
src/bedit_gui/services/application_logging.py
Normal 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)
|
||||||
27
src/bedit_gui/services/application_settings.py
Normal file
27
src/bedit_gui/services/application_settings.py
Normal file
@@ -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)
|
||||||
17
src/bedit_gui/services/document_files.py
Normal file
17
src/bedit_gui/services/document_files.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from bedit_core.models import Document
|
||||||
|
from bedit_core.serialization import load as load_document
|
||||||
|
from bedit_core.serialization import save as save_document
|
||||||
|
|
||||||
|
|
||||||
|
def load(path: str | Path) -> Document:
|
||||||
|
"""Load a supported document file into the core model."""
|
||||||
|
return load_document(path)
|
||||||
|
|
||||||
|
|
||||||
|
def save(document: Document, path: str | Path) -> None:
|
||||||
|
"""Save a core model using the format selected by its file extension."""
|
||||||
|
save_document(document, path)
|
||||||
@@ -6,20 +6,30 @@
|
|||||||
<rect>
|
<rect>
|
||||||
<x>0</x>
|
<x>0</x>
|
||||||
<y>0</y>
|
<y>0</y>
|
||||||
<width>800</width>
|
<width>940</width>
|
||||||
<height>600</height>
|
<height>729</height>
|
||||||
</rect>
|
</rect>
|
||||||
</property>
|
</property>
|
||||||
<property name="windowTitle">
|
<property name="windowTitle">
|
||||||
<string>MainWindow</string>
|
<string>MainWindow</string>
|
||||||
</property>
|
</property>
|
||||||
|
<property name="windowIcon">
|
||||||
|
<iconset resource="../../resources/resources.qrc">
|
||||||
|
<normaloff>:/icons/icons/office-chart-line.png</normaloff>:/icons/icons/office-chart-line.png</iconset>
|
||||||
|
</property>
|
||||||
|
<property name="documentMode">
|
||||||
|
<bool>false</bool>
|
||||||
|
</property>
|
||||||
|
<property name="tabShape">
|
||||||
|
<enum>QTabWidget::TabShape::Triangular</enum>
|
||||||
|
</property>
|
||||||
<widget class="QWidget" name="centralwidget"/>
|
<widget class="QWidget" name="centralwidget"/>
|
||||||
<widget class="QMenuBar" name="menubar">
|
<widget class="QMenuBar" name="menubar">
|
||||||
<property name="geometry">
|
<property name="geometry">
|
||||||
<rect>
|
<rect>
|
||||||
<x>0</x>
|
<x>0</x>
|
||||||
<y>0</y>
|
<y>0</y>
|
||||||
<width>800</width>
|
<width>940</width>
|
||||||
<height>22</height>
|
<height>22</height>
|
||||||
</rect>
|
</rect>
|
||||||
</property>
|
</property>
|
||||||
@@ -39,11 +49,18 @@
|
|||||||
<property name="title">
|
<property name="title">
|
||||||
<string>Edit</string>
|
<string>Edit</string>
|
||||||
</property>
|
</property>
|
||||||
|
<addaction name="actionUndo"/>
|
||||||
|
<addaction name="actionRedo"/>
|
||||||
|
<addaction name="separator"/>
|
||||||
|
<addaction name="actionSettings"/>
|
||||||
</widget>
|
</widget>
|
||||||
<widget class="QMenu" name="menuView">
|
<widget class="QMenu" name="menuView">
|
||||||
<property name="title">
|
<property name="title">
|
||||||
<string>View</string>
|
<string>View</string>
|
||||||
</property>
|
</property>
|
||||||
|
<addaction name="actionReset_Layout"/>
|
||||||
|
<addaction name="actionPanels"/>
|
||||||
|
<addaction name="actionToolbars"/>
|
||||||
</widget>
|
</widget>
|
||||||
<widget class="QMenu" name="menuHelp">
|
<widget class="QMenu" name="menuHelp">
|
||||||
<property name="title">
|
<property name="title">
|
||||||
@@ -72,6 +89,61 @@
|
|||||||
<addaction name="actionSave_File"/>
|
<addaction name="actionSave_File"/>
|
||||||
<addaction name="actionSave_File_As"/>
|
<addaction name="actionSave_File_As"/>
|
||||||
</widget>
|
</widget>
|
||||||
|
<widget class="QToolBar" name="undoToolBar">
|
||||||
|
<property name="windowTitle">
|
||||||
|
<string>toolBar</string>
|
||||||
|
</property>
|
||||||
|
<attribute name="toolBarArea">
|
||||||
|
<enum>TopToolBarArea</enum>
|
||||||
|
</attribute>
|
||||||
|
<attribute name="toolBarBreak">
|
||||||
|
<bool>false</bool>
|
||||||
|
</attribute>
|
||||||
|
<addaction name="actionUndo"/>
|
||||||
|
<addaction name="actionRedo"/>
|
||||||
|
</widget>
|
||||||
|
<widget class="QDockWidget" name="documentTreeWidget">
|
||||||
|
<property name="minimumSize">
|
||||||
|
<size>
|
||||||
|
<width>150</width>
|
||||||
|
<height>533</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="windowTitle">
|
||||||
|
<string>Document Tree</string>
|
||||||
|
</property>
|
||||||
|
<attribute name="dockWidgetArea">
|
||||||
|
<number>1</number>
|
||||||
|
</attribute>
|
||||||
|
<widget class="QWidget" name="dockWidgetContents">
|
||||||
|
<layout class="QVBoxLayout" name="verticalLayout">
|
||||||
|
<item>
|
||||||
|
<widget class="QTreeView" name="documentTree"/>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</widget>
|
||||||
|
</widget>
|
||||||
|
<widget class="QDockWidget" name="logWidget">
|
||||||
|
<property name="minimumSize">
|
||||||
|
<size>
|
||||||
|
<width>150</width>
|
||||||
|
<height>107</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="windowTitle">
|
||||||
|
<string>Log</string>
|
||||||
|
</property>
|
||||||
|
<attribute name="dockWidgetArea">
|
||||||
|
<number>8</number>
|
||||||
|
</attribute>
|
||||||
|
<widget class="QWidget" name="dockWidgetContents_5">
|
||||||
|
<layout class="QVBoxLayout" name="verticalLayout_2">
|
||||||
|
<item>
|
||||||
|
<widget class="QListView" name="listView"/>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</widget>
|
||||||
|
</widget>
|
||||||
<action name="actionOpen_File">
|
<action name="actionOpen_File">
|
||||||
<property name="icon">
|
<property name="icon">
|
||||||
<iconset resource="../../resources/resources.qrc">
|
<iconset resource="../../resources/resources.qrc">
|
||||||
@@ -166,6 +238,62 @@
|
|||||||
<enum>QAction::MenuRole::AboutQtRole</enum>
|
<enum>QAction::MenuRole::AboutQtRole</enum>
|
||||||
</property>
|
</property>
|
||||||
</action>
|
</action>
|
||||||
|
<action name="actionUndo">
|
||||||
|
<property name="icon">
|
||||||
|
<iconset resource="../../resources/resources.qrc">
|
||||||
|
<normaloff>:/icons/icons/edit-undo.png</normaloff>:/icons/icons/edit-undo.png</iconset>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>Undo</string>
|
||||||
|
</property>
|
||||||
|
<property name="shortcut">
|
||||||
|
<string>Ctrl+Z</string>
|
||||||
|
</property>
|
||||||
|
<property name="menuRole">
|
||||||
|
<enum>QAction::MenuRole::NoRole</enum>
|
||||||
|
</property>
|
||||||
|
</action>
|
||||||
|
<action name="actionRedo">
|
||||||
|
<property name="icon">
|
||||||
|
<iconset resource="../../resources/resources.qrc">
|
||||||
|
<normaloff>:/icons/icons/edit-redo.png</normaloff>:/icons/icons/edit-redo.png</iconset>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>Redo</string>
|
||||||
|
</property>
|
||||||
|
<property name="shortcut">
|
||||||
|
<string>Ctrl+Y</string>
|
||||||
|
</property>
|
||||||
|
<property name="menuRole">
|
||||||
|
<enum>QAction::MenuRole::NoRole</enum>
|
||||||
|
</property>
|
||||||
|
</action>
|
||||||
|
<action name="actionReset_Layout">
|
||||||
|
<property name="text">
|
||||||
|
<string>Reset Layout</string>
|
||||||
|
</property>
|
||||||
|
<property name="toolTip">
|
||||||
|
<string>Reset window layout to default</string>
|
||||||
|
</property>
|
||||||
|
<property name="menuRole">
|
||||||
|
<enum>QAction::MenuRole::NoRole</enum>
|
||||||
|
</property>
|
||||||
|
</action>
|
||||||
|
<action name="actionPanels">
|
||||||
|
<property name="text">
|
||||||
|
<string>Panels</string>
|
||||||
|
</property>
|
||||||
|
</action>
|
||||||
|
<action name="actionToolbars">
|
||||||
|
<property name="text">
|
||||||
|
<string>Toolbars</string>
|
||||||
|
</property>
|
||||||
|
</action>
|
||||||
|
<action name="actionSettings">
|
||||||
|
<property name="text">
|
||||||
|
<string>Settings</string>
|
||||||
|
</property>
|
||||||
|
</action>
|
||||||
</widget>
|
</widget>
|
||||||
<resources>
|
<resources>
|
||||||
<include location="../../resources/resources.qrc"/>
|
<include location="../../resources/resources.qrc"/>
|
||||||
|
|||||||
122
src/bedit_gui/ui/forms/settings_dialog.ui
Normal file
122
src/bedit_gui/ui/forms/settings_dialog.ui
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<ui version="4.0">
|
||||||
|
<class>Settings</class>
|
||||||
|
<widget class="QDialog" name="Settings">
|
||||||
|
<property name="geometry">
|
||||||
|
<rect>
|
||||||
|
<x>0</x>
|
||||||
|
<y>0</y>
|
||||||
|
<width>400</width>
|
||||||
|
<height>230</height>
|
||||||
|
</rect>
|
||||||
|
</property>
|
||||||
|
<property name="windowTitle">
|
||||||
|
<string>Settings</string>
|
||||||
|
</property>
|
||||||
|
<layout class="QVBoxLayout" name="verticalLayout">
|
||||||
|
<item>
|
||||||
|
<widget class="QTabWidget" name="tabWidget">
|
||||||
|
<property name="currentIndex">
|
||||||
|
<number>0</number>
|
||||||
|
</property>
|
||||||
|
<widget class="QWidget" name="General">
|
||||||
|
<attribute name="title">
|
||||||
|
<string>General</string>
|
||||||
|
</attribute>
|
||||||
|
<layout class="QFormLayout" name="formLayout">
|
||||||
|
<item row="0" column="1">
|
||||||
|
<widget class="QComboBox" name="logLevel">
|
||||||
|
<item>
|
||||||
|
<property name="text">
|
||||||
|
<string>Debug</string>
|
||||||
|
</property>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<property name="text">
|
||||||
|
<string>Info</string>
|
||||||
|
</property>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<property name="text">
|
||||||
|
<string>Warning</string>
|
||||||
|
</property>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<property name="text">
|
||||||
|
<string>Error</string>
|
||||||
|
</property>
|
||||||
|
</item>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item row="0" column="0">
|
||||||
|
<widget class="QLabel" name="lableLogLevel">
|
||||||
|
<property name="text">
|
||||||
|
<string>Log level:</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item row="1" column="1">
|
||||||
|
<spacer name="verticalSpacer">
|
||||||
|
<property name="orientation">
|
||||||
|
<enum>Qt::Orientation::Vertical</enum>
|
||||||
|
</property>
|
||||||
|
<property name="sizeHint" stdset="0">
|
||||||
|
<size>
|
||||||
|
<width>20</width>
|
||||||
|
<height>40</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
</spacer>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</widget>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QDialogButtonBox" name="buttonBox">
|
||||||
|
<property name="orientation">
|
||||||
|
<enum>Qt::Orientation::Horizontal</enum>
|
||||||
|
</property>
|
||||||
|
<property name="standardButtons">
|
||||||
|
<set>QDialogButtonBox::StandardButton::Cancel|QDialogButtonBox::StandardButton::Ok</set>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</widget>
|
||||||
|
<resources/>
|
||||||
|
<connections>
|
||||||
|
<connection>
|
||||||
|
<sender>buttonBox</sender>
|
||||||
|
<signal>accepted()</signal>
|
||||||
|
<receiver>Settings</receiver>
|
||||||
|
<slot>accept()</slot>
|
||||||
|
<hints>
|
||||||
|
<hint type="sourcelabel">
|
||||||
|
<x>248</x>
|
||||||
|
<y>254</y>
|
||||||
|
</hint>
|
||||||
|
<hint type="destinationlabel">
|
||||||
|
<x>157</x>
|
||||||
|
<y>274</y>
|
||||||
|
</hint>
|
||||||
|
</hints>
|
||||||
|
</connection>
|
||||||
|
<connection>
|
||||||
|
<sender>buttonBox</sender>
|
||||||
|
<signal>rejected()</signal>
|
||||||
|
<receiver>Settings</receiver>
|
||||||
|
<slot>reject()</slot>
|
||||||
|
<hints>
|
||||||
|
<hint type="sourcelabel">
|
||||||
|
<x>316</x>
|
||||||
|
<y>260</y>
|
||||||
|
</hint>
|
||||||
|
<hint type="destinationlabel">
|
||||||
|
<x>286</x>
|
||||||
|
<y>274</y>
|
||||||
|
</hint>
|
||||||
|
</hints>
|
||||||
|
</connection>
|
||||||
|
</connections>
|
||||||
|
</ui>
|
||||||
67
src/bedit_gui/views/dialogs/document_dialogs.py
Normal file
67
src/bedit_gui/views/dialogs/document_dialogs.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import Enum, auto
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import QFileDialog, QMessageBox, QWidget
|
||||||
|
|
||||||
|
|
||||||
|
class SaveChangesChoice(Enum):
|
||||||
|
SAVE = auto()
|
||||||
|
DISCARD = auto()
|
||||||
|
CANCEL = auto()
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentDialogs:
|
||||||
|
"""All modal dialogs used by the document workflow."""
|
||||||
|
|
||||||
|
FILE_FILTER = "BEdit documents (*.beb *.json);;BEdit binary (*.beb);;JSON (*.json)"
|
||||||
|
|
||||||
|
def __init__(self, parent: QWidget) -> None:
|
||||||
|
self._parent = parent
|
||||||
|
|
||||||
|
def choose_open_path(self, current_path: Path | None) -> Path | None:
|
||||||
|
directory = str(current_path.parent) if current_path else ""
|
||||||
|
file_name, _ = QFileDialog.getOpenFileName(
|
||||||
|
self._parent,
|
||||||
|
"Open BEdit Document",
|
||||||
|
directory,
|
||||||
|
self.FILE_FILTER,
|
||||||
|
)
|
||||||
|
return Path(file_name) if file_name else None
|
||||||
|
|
||||||
|
def choose_save_path(self, current_path: Path | None) -> Path | None:
|
||||||
|
suggested = str(current_path) if current_path else "Untitled.beb"
|
||||||
|
file_name, selected_filter = QFileDialog.getSaveFileName(
|
||||||
|
self._parent,
|
||||||
|
"Save BEdit Document",
|
||||||
|
suggested,
|
||||||
|
self.FILE_FILTER,
|
||||||
|
)
|
||||||
|
if not file_name:
|
||||||
|
return None
|
||||||
|
|
||||||
|
path = Path(file_name)
|
||||||
|
if not path.suffix:
|
||||||
|
suffix = ".json" if "JSON" in selected_filter else ".beb"
|
||||||
|
path = path.with_suffix(suffix)
|
||||||
|
return path
|
||||||
|
|
||||||
|
def ask_save_changes(self) -> SaveChangesChoice:
|
||||||
|
answer = QMessageBox.warning(
|
||||||
|
self._parent,
|
||||||
|
"Unsaved Changes",
|
||||||
|
"The current document has unsaved changes.",
|
||||||
|
QMessageBox.StandardButton.Save
|
||||||
|
| QMessageBox.StandardButton.Discard
|
||||||
|
| QMessageBox.StandardButton.Cancel,
|
||||||
|
QMessageBox.StandardButton.Save,
|
||||||
|
)
|
||||||
|
if answer == QMessageBox.StandardButton.Save:
|
||||||
|
return SaveChangesChoice.SAVE
|
||||||
|
if answer == QMessageBox.StandardButton.Discard:
|
||||||
|
return SaveChangesChoice.DISCARD
|
||||||
|
return SaveChangesChoice.CANCEL
|
||||||
|
|
||||||
|
def show_file_error(self, title: str, error: Exception) -> None:
|
||||||
|
QMessageBox.critical(self._parent, title, str(error))
|
||||||
41
src/bedit_gui/views/dialogs/settings_dialog.py
Normal file
41
src/bedit_gui/views/dialogs/settings_dialog.py
Normal file
@@ -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())
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
from PySide6.QtWidgets import QMainWindow
|
from PySide6.QtCore import Qt
|
||||||
|
from PySide6.QtWidgets import QMainWindow, QTabWidget
|
||||||
|
|
||||||
from bedit_gui.ui.generated.ui_main_window import Ui_MainWindow
|
from bedit_gui.ui.generated.ui_main_window import Ui_MainWindow
|
||||||
|
|
||||||
@@ -8,4 +9,8 @@ class MainWindow(QMainWindow):
|
|||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
self.ui = Ui_MainWindow()
|
self.ui = Ui_MainWindow()
|
||||||
self.ui.setupUi(self)
|
self.ui.setupUi(self)
|
||||||
|
|
||||||
|
self.setTabPosition(Qt.AllDockWidgetAreas, QTabWidget.North)
|
||||||
|
self.setCorner(Qt.Corner.BottomLeftCorner, Qt.DockWidgetArea.LeftDockWidgetArea)
|
||||||
|
self.setCorner(Qt.Corner.BottomRightCorner, Qt.DockWidgetArea.RightDockWidgetArea)
|
||||||
|
|||||||
3
src/bedit_gui/views/models/__init__.py
Normal file
3
src/bedit_gui/views/models/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from .log_list_model import LogListModel
|
||||||
|
|
||||||
|
__all__ = ["LogListModel"]
|
||||||
48
src/bedit_gui/views/models/log_list_model.py
Normal file
48
src/bedit_gui/views/models/log_list_model.py
Normal 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()
|
||||||
96
tests/unit/test_application_logging.py
Normal file
96
tests/unit/test_application_logging.py
Normal 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)
|
||||||
79
tests/unit/test_document_controller.py
Normal file
79
tests/unit/test_document_controller.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtGui import QUndoCommand
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from bedit_gui.controllers.document_controller import DocumentController
|
||||||
|
from bedit_gui.documents import Document
|
||||||
|
from bedit_gui.views.dialogs.document_dialogs import SaveChangesChoice
|
||||||
|
from bedit_gui.views.main_window import MainWindow
|
||||||
|
|
||||||
|
|
||||||
|
class FakeDialogs:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.open_path: Path | None = None
|
||||||
|
self.save_path: Path | None = None
|
||||||
|
self.save_choice = SaveChangesChoice.CANCEL
|
||||||
|
self.errors: list[tuple[str, Exception]] = []
|
||||||
|
|
||||||
|
def choose_open_path(self, _current_path: Path | None) -> Path | None:
|
||||||
|
return self.open_path
|
||||||
|
|
||||||
|
def choose_save_path(self, _current_path: Path | None) -> Path | None:
|
||||||
|
return self.save_path
|
||||||
|
|
||||||
|
def ask_save_changes(self) -> SaveChangesChoice:
|
||||||
|
return self.save_choice
|
||||||
|
|
||||||
|
def show_file_error(self, title: str, error: Exception) -> None:
|
||||||
|
self.errors.append((title, error))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def qt_app() -> QApplication:
|
||||||
|
return QApplication.instance() or QApplication([])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_new_document_respects_unsaved_changes(qt_app: QApplication) -> None:
|
||||||
|
document = Document(qt_app)
|
||||||
|
window = MainWindow()
|
||||||
|
dialogs = FakeDialogs()
|
||||||
|
controller = DocumentController(document, window, dialogs)
|
||||||
|
original_id = document.model.id
|
||||||
|
document.undo_stack.push(QUndoCommand("change"))
|
||||||
|
|
||||||
|
controller.new_document()
|
||||||
|
assert document.model.id == original_id
|
||||||
|
|
||||||
|
dialogs.save_choice = SaveChangesChoice.DISCARD
|
||||||
|
controller.new_document()
|
||||||
|
assert document.model.id != original_id
|
||||||
|
assert not document.modified
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_save_as_then_open_document(
|
||||||
|
qt_app: QApplication,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
document = Document(qt_app)
|
||||||
|
window = MainWindow()
|
||||||
|
dialogs = FakeDialogs()
|
||||||
|
controller = DocumentController(document, window, dialogs)
|
||||||
|
original_id = document.model.id
|
||||||
|
path = tmp_path / "document.json"
|
||||||
|
|
||||||
|
dialogs.save_path = path
|
||||||
|
assert controller.save_document_as()
|
||||||
|
|
||||||
|
document.new()
|
||||||
|
dialogs.open_path = path
|
||||||
|
controller.open_document()
|
||||||
|
|
||||||
|
assert document.model.id == original_id
|
||||||
|
assert document.path == path
|
||||||
|
assert not dialogs.errors
|
||||||
37
tests/unit/test_gui_document.py
Normal file
37
tests/unit/test_gui_document.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtGui import QUndoCommand
|
||||||
|
|
||||||
|
from bedit_gui.documents import Document
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.parametrize("suffix", [".beb", ".json"])
|
||||||
|
def test_document_save_and_open(tmp_path: Path, suffix: str) -> None:
|
||||||
|
path = tmp_path / f"document{suffix}"
|
||||||
|
document = Document()
|
||||||
|
original_id = document.model.id
|
||||||
|
|
||||||
|
document.save_as(path)
|
||||||
|
document.new()
|
||||||
|
assert document.model.id != original_id
|
||||||
|
|
||||||
|
document.open(path)
|
||||||
|
|
||||||
|
assert document.model.id == original_id
|
||||||
|
assert document.path == path
|
||||||
|
assert not document.modified
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_document_modified_state_uses_undo_stack() -> None:
|
||||||
|
document = Document()
|
||||||
|
|
||||||
|
document.undo_stack.push(QUndoCommand("change"))
|
||||||
|
assert document.modified
|
||||||
|
|
||||||
|
document.undo_stack.setClean()
|
||||||
|
assert not document.modified
|
||||||
72
tests/unit/test_settings_controller.py
Normal file
72
tests/unit/test_settings_controller.py
Normal file
@@ -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)
|
||||||
38
tests/unit/test_view_menu_controller.py
Normal file
38
tests/unit/test_view_menu_controller.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from bedit_gui.controllers.view_menu_controller import ViewMenuController
|
||||||
|
from bedit_gui.views.main_window import MainWindow
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def qt_app() -> QApplication:
|
||||||
|
return QApplication.instance() or QApplication([])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_view_submenus_contain_visibility_actions(
|
||||||
|
qt_app: QApplication,
|
||||||
|
) -> None:
|
||||||
|
window = MainWindow()
|
||||||
|
controller = ViewMenuController(window)
|
||||||
|
|
||||||
|
assert window.ui.actionPanels.menu() is controller.panels_menu
|
||||||
|
assert window.ui.actionToolbars.menu() is controller.toolbars_menu
|
||||||
|
assert [action.text() for action in controller.panels_menu.actions()] == [
|
||||||
|
"Document Tree",
|
||||||
|
"Log",
|
||||||
|
]
|
||||||
|
assert [action.text() for action in controller.toolbars_menu.actions()] == [
|
||||||
|
"File",
|
||||||
|
"Undo",
|
||||||
|
]
|
||||||
|
assert all(
|
||||||
|
action.isCheckable()
|
||||||
|
for action in (
|
||||||
|
controller.panels_menu.actions()
|
||||||
|
+ controller.toolbars_menu.actions()
|
||||||
|
)
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user