Document handling
New/Open/Save/Save as and Undo/Redo
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
113
src/bedit_gui/controllers/document_controller.py
Normal file
113
src/bedit_gui/controllers/document_controller.py
Normal 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)
|
||||
36
src/bedit_gui/controllers/undo_controller.py
Normal file
36
src/bedit_gui/controllers/undo_controller.py
Normal 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)
|
||||
@@ -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={},
|
||||
)
|
||||
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)
|
||||
@@ -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"/>
|
||||
|
||||
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))
|
||||
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
|
||||
Reference in New Issue
Block a user