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
+
+
+
+
+ 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