diff --git a/scripts/generate_qt_files.py b/scripts/generate_qt_files.py index e091acf..19f2783 100644 --- a/scripts/generate_qt_files.py +++ b/scripts/generate_qt_files.py @@ -1,10 +1,8 @@ - from __future__ import annotations import subprocess from pathlib import Path - ROOT = Path(__file__).resolve().parents[1] GUI_PACKAGE = ROOT / "src" / "bedit_gui" @@ -15,9 +13,15 @@ QRC_FILE = GUI_PACKAGE / "resources" / "resources.qrc" GENERATED_RESOURCES = ( GUI_PACKAGE / "resources" + / "generated" / "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: print("+", " ".join(command)) @@ -39,6 +43,19 @@ def generate_ui() -> None: "-o", str(destination), ) + generated = destination.read_text(encoding="utf-8") + if RESOURCE_IMPORT not in generated: + raise RuntimeError( + f"could not find the generated resource import in {destination}" + ) + destination.write_text( + generated.replace( + RESOURCE_IMPORT, + GENERATED_RESOURCE_IMPORT, + 1, + ), + encoding="utf-8", + ) def generate_resources() -> None: diff --git a/src/bedit_gui/application.py b/src/bedit_gui/application.py index b5689fc..337683c 100644 --- a/src/bedit_gui/application.py +++ b/src/bedit_gui/application.py @@ -1,18 +1,26 @@ from __future__ import annotations + import sys from PySide6.QtWidgets import QApplication -from bedit_gui.resources import resources_rc +from bedit_gui.controllers.document_controller import DocumentController +from bedit_gui.controllers.undo_controller import UndoController +from bedit_gui.documents import Document +from bedit_gui.resources.generated import resources_rc # noqa: F401 from bedit_gui.views.main_window import MainWindow + def main() -> int: app = QApplication(sys.argv) app.setOrganizationName("BEdit") app.setApplicationName("BEdit") + document = Document(app) window = MainWindow() + DocumentController(document, window) + UndoController(document, window) window.show() return app.exec() diff --git a/src/bedit_gui/controllers/document_controller.py b/src/bedit_gui/controllers/document_controller.py new file mode 100644 index 0000000..7ca377a --- /dev/null +++ b/src/bedit_gui/controllers/document_controller.py @@ -0,0 +1,113 @@ +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.views.dialogs.document_dialogs import ( + DocumentDialogs, + SaveChangesChoice, +) +from bedit_gui.views.main_window import MainWindow + + +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() + + 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: + self.dialogs.show_file_error("Could not open document", exc) + + 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: + self.dialogs.show_file_error("Could not save document", exc) + return False + 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: + self.dialogs.show_file_error("Could not save document", exc) + return False + 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) diff --git a/src/bedit_gui/controllers/undo_controller.py b/src/bedit_gui/controllers/undo_controller.py new file mode 100644 index 0000000..033d12f --- /dev/null +++ b/src/bedit_gui/controllers/undo_controller.py @@ -0,0 +1,36 @@ +from PySide6.QtCore import QObject + +from bedit_gui.documents import Document +from bedit_gui.views.main_window import MainWindow + + +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(undo_stack.undo) + redo_action.triggered.connect(undo_stack.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 _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) \ No newline at end of file diff --git a/src/bedit_gui/documents/__init__.py b/src/bedit_gui/documents/__init__.py index e69de29..fd289e2 100644 --- a/src/bedit_gui/documents/__init__.py +++ b/src/bedit_gui/documents/__init__.py @@ -0,0 +1,3 @@ +from .document import Document + +__all__ = ["Document"] diff --git a/src/bedit_gui/documents/document.py b/src/bedit_gui/documents/document.py new file mode 100644 index 0000000..4376bf0 --- /dev/null +++ b/src/bedit_gui/documents/document.py @@ -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={}, + ) diff --git a/src/bedit_gui/services/document_files.py b/src/bedit_gui/services/document_files.py new file mode 100644 index 0000000..73b4715 --- /dev/null +++ b/src/bedit_gui/services/document_files.py @@ -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) diff --git a/src/bedit_gui/ui/forms/main_window.ui b/src/bedit_gui/ui/forms/main_window.ui index 54f1cca..3292e8c 100644 --- a/src/bedit_gui/ui/forms/main_window.ui +++ b/src/bedit_gui/ui/forms/main_window.ui @@ -39,6 +39,8 @@ Edit + + @@ -72,6 +74,19 @@ + + + toolBar + + + TopToolBarArea + + + false + + + + @@ -166,6 +181,36 @@ QAction::MenuRole::AboutQtRole + + + + :/icons/icons/edit-undo.png:/icons/icons/edit-undo.png + + + Undo + + + Ctrl+Z + + + QAction::MenuRole::NoRole + + + + + + :/icons/icons/edit-redo.png:/icons/icons/edit-redo.png + + + Redo + + + Ctrl+Y + + + QAction::MenuRole::NoRole + + diff --git a/src/bedit_gui/views/dialogs/document_dialogs.py b/src/bedit_gui/views/dialogs/document_dialogs.py new file mode 100644 index 0000000..0305aa7 --- /dev/null +++ b/src/bedit_gui/views/dialogs/document_dialogs.py @@ -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)) diff --git a/src/bedit_gui/views/main_window.py b/src/bedit_gui/views/main_window.py index 8da233e..4748bb2 100644 --- a/src/bedit_gui/views/main_window.py +++ b/src/bedit_gui/views/main_window.py @@ -8,4 +8,4 @@ class MainWindow(QMainWindow): super().__init__() self.ui = Ui_MainWindow() - self.ui.setupUi(self) \ No newline at end of file + self.ui.setupUi(self) diff --git a/tests/unit/test_document_controller.py b/tests/unit/test_document_controller.py new file mode 100644 index 0000000..17e17e0 --- /dev/null +++ b/tests/unit/test_document_controller.py @@ -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 diff --git a/tests/unit/test_gui_document.py b/tests/unit/test_gui_document.py new file mode 100644 index 0000000..17e65ef --- /dev/null +++ b/tests/unit/test_gui_document.py @@ -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