From ebd26aa2495872173d3b186fd7a1993c01ae8f95 Mon Sep 17 00:00:00 2001 From: Fabien Roger Date: Sat, 8 Jan 2022 19:32:50 +0100 Subject: [PATCH] :beetle: Fix history lost by manual history creation --- pyflow/core/history.py | 68 +++++++++++++++++++++++ pyflow/core/pyeditor/__init__.py | 6 +++ pyflow/core/pyeditor/history.py | 75 ++++++++++++++++++++++++++ pyflow/core/{ => pyeditor}/pyeditor.py | 42 +++++++++++++-- pyflow/scene/history.py | 57 ++++++-------------- 5 files changed, 203 insertions(+), 45 deletions(-) create mode 100644 pyflow/core/history.py create mode 100644 pyflow/core/pyeditor/__init__.py create mode 100644 pyflow/core/pyeditor/history.py rename pyflow/core/{ => pyeditor}/pyeditor.py (73%) diff --git a/pyflow/core/history.py b/pyflow/core/history.py new file mode 100644 index 00000000..22b48d0b --- /dev/null +++ b/pyflow/core/history.py @@ -0,0 +1,68 @@ +# Pyflow an open-source tool for modular visual programing in python +# Copyright (C) 2021-2022 Bycelium + +""" Module for the handling an history of operations. """ + +from typing import Any, List + + +class History: + """Helper object to handle undo/redo operations. + + Args: + max_stack: Maximum size of the history stack (number of available undo). + + """ + + def __init__(self, max_stack: int = 50): + self.history_stack: List[Any] = [] + self.current: int = -1 + self.max_stack: int = max_stack + + def undo(self) -> None: + """Undo the last action by moving the current stamp backward and restoring.""" + if len(self.history_stack) > 0 and self.current > 0: + self.current -= 1 + self.restore() + + def redo(self) -> None: + """Redo the last undone action by moving the current stamp forward and restoring.""" + if len(self.history_stack) > 0 and self.current + 1 < len(self.history_stack): + self.current += 1 + self.restore() + + def store(self, data: Any) -> None: + """Store new data in the history stack, updating current checkpoint. + Remove data that would be forward in the history stack. + + Args: + data: Data to store in the history stack. + + """ + if self.current + 1 < len(self.history_stack): + self.history_stack = self.history_stack[0 : self.current + 1] + + self.history_stack.append(data) + + if len(self.history_stack) > self.max_stack: + self.history_stack.pop(0) + + self.current = min(self.current + 1, len(self.history_stack) - 1) + + def restored_data(self) -> Any: + """ + Return the snapshot pointed by current in the history stack. + Return None if there is nothing pointed to. + """ + if len(self.history_stack) >= 0 and self.current >= 0: + data = self.history_stack[self.current] + return data + return None + + def restore(self) -> None: + """ + Empty function to be overriden + Contains the behavior to be adopted when a state is restored + """ + # data: Any = self.restored_data() + return diff --git a/pyflow/core/pyeditor/__init__.py b/pyflow/core/pyeditor/__init__.py new file mode 100644 index 00000000..d7e312f0 --- /dev/null +++ b/pyflow/core/pyeditor/__init__.py @@ -0,0 +1,6 @@ +# Pyflow an open-source tool for modular visual programing in python +# Copyright (C) 2021-2022 Bycelium + +""" Module for the Python Editor. """ + +from pyflow.core.pyeditor.pyeditor import PythonEditor, POINT_SIZE diff --git a/pyflow/core/pyeditor/history.py b/pyflow/core/pyeditor/history.py new file mode 100644 index 00000000..a4bc0cf1 --- /dev/null +++ b/pyflow/core/pyeditor/history.py @@ -0,0 +1,75 @@ +# Pyflow an open-source tool for modular visual programing in python +# Copyright (C) 2021-2022 Bycelium + +""" Module for the handling a PythonEditor history. """ + +from typing import TYPE_CHECKING, Optional, OrderedDict, Tuple + +from pyflow.core.history import History + +if TYPE_CHECKING: + from pyflow.core.pyeditor import PythonEditor + + +class EditorHistory(History): + """ + Helper object to handle undo/redo operations on a PythonEditor. + + Args: + editor: PythonEditor reference. + max_stack: Maximum size of the history stack (number of available undo). + + """ + + def __init__(self, editor: "PythonEditor", max_stack: int = 50): + self.editor = editor + self.is_writing = False + super().__init__(max_stack) + + def start_sequence(self): + """ + Start a new writing sequence if it was not already the case, and save the current state. + """ + if not self.is_writing: + self.is_writing = True + self.checkpoint() + + def end_sequence(self): + """ + End the writing sequence if it was not already the case. + Do not save at this point because the writing parameters to be saved (cursor pos, etc) + are the one of the beginning of the next sequence. + """ + self.is_writing = False + + def checkpoint(self): + """ + Store a snapshot of the editor's text and parameters in the history stack + (only if the text has changed). + """ + text: str = self.editor.text() + old_data = self.restored_data() + if old_data is not None and old_data["text"] == text: + return + + cursor_pos: Tuple[int, int] = self.editor.getCursorPosition() + scroll_pos: int = self.editor.verticalScrollBar().value() + self.store( + { + "text": text, + "cursor_pos": cursor_pos, + "scroll_pos": scroll_pos, + } + ) + + def restore(self): + """ + Restore the editor's text and parameters + using the snapshot pointed by current in the history stack. + """ + data: Optional[OrderedDict] = self.restored_data() + + if data is not None: + self.editor.setText(data["text"]) + self.editor.setCursorPosition(*data["cursor_pos"]) + self.editor.verticalScrollBar().setValue(data["scroll_pos"]) diff --git a/pyflow/core/pyeditor.py b/pyflow/core/pyeditor/pyeditor.py similarity index 73% rename from pyflow/core/pyeditor.py rename to pyflow/core/pyeditor/pyeditor.py index a3ccd676..de56adf3 100644 --- a/pyflow/core/pyeditor.py +++ b/pyflow/core/pyeditor/pyeditor.py @@ -10,10 +10,12 @@ QFont, QFontMetrics, QColor, + QKeyEvent, QMouseEvent, QWheelEvent, ) from PyQt5.Qsci import QsciScintilla, QsciLexerPython +from pyflow.core.pyeditor.history import EditorHistory from pyflow.graphics.theme_manager import theme_manager from pyflow.blocks.block import Block @@ -39,6 +41,10 @@ def __init__(self, block: Block): self._mode = "NOOP" self.block = block + self.history = EditorHistory(self) + self.pressingControl = False + # self.startOfSequencePos + self.update_theme() theme_manager().themeChanged.connect(self.update_theme) @@ -67,7 +73,7 @@ def __init__(self, block: Block): self.setWindowFlags(Qt.WindowType.FramelessWindowHint) def update_theme(self): - """Change the font and colors of the editor to match the current theme.""" + """Change the font and colors of the editor to match the current theme""" font = QFont() font.setFamily(theme_manager().recommended_font_family) font.setFixedPitch(True) @@ -94,14 +100,14 @@ def views(self) -> List["View"]: return self.block.scene().views() def wheelEvent(self, event: QWheelEvent) -> None: - """How PythonEditor handles wheel events.""" + """How PythonEditor handles wheel events""" if self.mode == "EDITING" and event.angleDelta().x() == 0: event.accept() return super().wheelEvent(event) @property def mode(self) -> int: - """PythonEditor current mode.""" + """PythonEditor current mode""" return self._mode @mode.setter @@ -114,10 +120,38 @@ def mousePressEvent(self, event: QMouseEvent) -> None: """PythonEditor reaction to PyQt mousePressEvent events.""" if event.buttons() & Qt.MouseButton.LeftButton: self.mode = "EDITING" - return super().mousePressEvent(event) + + super().mousePressEvent(event) + self.history.end_sequence() def focusOutEvent(self, event: QFocusEvent): """PythonEditor reaction to PyQt focusOut events.""" + self.history.end_sequence() self.mode = "NOOP" self.block.source = self.text() return super().focusOutEvent(event) + + def keyPressEvent(self, e: QKeyEvent) -> None: + """PythonEditor reaction to PyQt keyPressed events.""" + + # Disable QsciScintilla undo + self.SendScintilla(QsciScintilla.SCI_EMPTYUNDOBUFFER, 1) + + # Manualy check if Ctrl+Z or Ctrl+Y is pressed + if self.pressingControl and e.key() == Qt.Key.Key_Z: + # The sequence ends and a new one starts when pressing Ctrl+Z + self.history.end_sequence() + self.history.start_sequence() + self.history.undo() + elif self.pressingControl and e.key() == Qt.Key.Key_Y: + self.history.redo() + elif e.key() == Qt.Key.Key_Control: + self.pressingControl = True + else: + self.pressingControl = False + self.history.start_sequence() + + if e.key() in {Qt.Key.Key_Return, Qt.Key.Key_Enter}: + self.history.end_sequence() + + super().keyPressEvent(e) diff --git a/pyflow/scene/history.py b/pyflow/scene/history.py index 6f26ce7d..541dc92c 100644 --- a/pyflow/scene/history.py +++ b/pyflow/scene/history.py @@ -1,15 +1,17 @@ -# Pyflow an open-source tool for modular visual programing in python -# Copyright (C) 2021-2022 Bycelium +# OpenCodeBlock an open-source tool for modular visual programing in python +# Copyright (C) 2021 Mathïs FEDERICO -""" Module for the handling a scene history. """ +""" Module for the handling an OCBScene history. """ -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING + +from pyflow.core.history import History if TYPE_CHECKING: from pyflow.scene import Scene -class SceneHistory: +class SceneHistory(History): """Helper object to handle undo/redo operations on an Scene. Args: @@ -20,21 +22,7 @@ class SceneHistory: def __init__(self, scene: "Scene", max_stack: int = 50): self.scene = scene - self.history_stack = [] - self.current = -1 - self.max_stack = max_stack - - def undo(self): - """Undo the last action by moving the current stamp backward and restoring.""" - if len(self.history_stack) > 0 and self.current > 0: - self.current -= 1 - self.restore() - - def redo(self): - """Redo the last undone action by moving the current stamp forward and restoring.""" - if len(self.history_stack) > 0 and self.current + 1 < len(self.history_stack): - self.current += 1 - self.restore() + super().__init__(max_stack) def checkpoint(self, description: str, set_modified=True): """Store a snapshot of the scene in the history stack. @@ -44,32 +32,19 @@ def checkpoint(self, description: str, set_modified=True): set_modified: Whether the scene should be considered modified. """ - history_stamp = {"description": description, "snapshot": self.scene.serialize()} + history_stamp = { + "description": description, + "snapshot": self.scene.serialize(), + } self.store(history_stamp) if set_modified: self.scene.has_been_modified = True - def store(self, data: Any): - """Store new data in the history stack, updating current checkpoint. - Remove data that would be forward in the history stack. - - Args: - data: Data to store in the history stack. - - """ - if self.current + 1 < len(self.history_stack): - self.history_stack = self.history_stack[0 : self.current + 1] - - self.history_stack.append(data) - - if len(self.history_stack) > self.max_stack: - self.history_stack.pop(0) - - self.current = min(self.current + 1, len(self.history_stack) - 1) - def restore(self): """Restore the scene using the snapshot pointed by current in the history stack.""" - if len(self.history_stack) >= 0 and self.current >= 0: - stamp = self.history_stack[self.current] + + stamp = self.restored_data() + + if stamp is not None: snapshot = stamp["snapshot"] self.scene.deserialize(snapshot)