Document handling

New/Open/Save/Save as and Undo/Redo
This commit is contained in:
2026-07-26 13:41:23 +02:00
parent 38b8ee34ff
commit 4c3b8b4b6d
12 changed files with 506 additions and 4 deletions

View File

@@ -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:

View File

@@ -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()

View File

@@ -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)

View File

@@ -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)

View File

@@ -0,0 +1,3 @@
from .document import Document
__all__ = ["Document"]

View 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={},
)

View 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)

View File

@@ -39,6 +39,8 @@
<property name="title">
<string>Edit</string>
</property>
<addaction name="actionUndo"/>
<addaction name="actionRedo"/>
</widget>
<widget class="QMenu" name="menuView">
<property name="title">
@@ -72,6 +74,19 @@
<addaction name="actionSave_File"/>
<addaction name="actionSave_File_As"/>
</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>
<action name="actionOpen_File">
<property name="icon">
<iconset resource="../../resources/resources.qrc">
@@ -166,6 +181,36 @@
<enum>QAction::MenuRole::AboutQtRole</enum>
</property>
</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>
</widget>
<resources>
<include location="../../resources/resources.qrc"/>

View 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))

View File

@@ -8,4 +8,4 @@ class MainWindow(QMainWindow):
super().__init__()
self.ui = Ui_MainWindow()
self.ui.setupUi(self)
self.ui.setupUi(self)

View 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

View 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