Skip to content

Commit

Permalink
🎉 Add custom undo system (#118)
Browse files Browse the repository at this point in the history
🎉 Add custom undo system (#118)
  • Loading branch information
MathisFederico authored Feb 2, 2022
2 parents 7461912 + b0bf7d2 commit 88473c7
Show file tree
Hide file tree
Showing 17 changed files with 501 additions and 153 deletions.
2 changes: 1 addition & 1 deletion pyflow/blocks/block.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ class Block(QGraphicsItem, Serializable):

def __init__(
self,
block_type: str = "base",
block_type: str = "Block",
position: tuple = (0, 0),
width: int = DEFAULT_DATA["width"],
height: int = DEFAULT_DATA["height"],
Expand Down
15 changes: 7 additions & 8 deletions pyflow/blocks/codeblock.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,17 @@
""" Module for the base Code Block."""

from typing import OrderedDict
from PyQt5.QtWidgets import QPushButton, QTextEdit
from PyQt5.QtGui import QPen, QColor

from ansi2html import Ansi2HTMLConverter

from pyflow.blocks.block import Block
from PyQt5.QtWidgets import QPushButton, QTextEdit
from PyQt5.QtGui import QPen, QColor

from pyflow.blocks.block import Block
from pyflow.blocks.executableblock import ExecutableBlock
from pyflow.blocks.pyeditor import PythonEditor

conv = Ansi2HTMLConverter()
ansi2html_converter = Ansi2HTMLConverter()


class CodeBlock(ExecutableBlock):
Expand Down Expand Up @@ -43,7 +43,7 @@ def __init__(self, source: str = "", **kwargs):
"""

super().__init__(**kwargs)
super().__init__(block_type="CodeBlock", **kwargs)
self.source_editor = PythonEditor(self)

self._source = ""
Expand Down Expand Up @@ -171,7 +171,7 @@ def update_all(self):
@property
def source(self) -> str:
"""Source code."""
return self._source
return self.source_editor.text()

@source.setter
def source(self, value: str):
Expand Down Expand Up @@ -222,7 +222,7 @@ def str_to_html(text: str) -> str:
text = text.replace("\x08", "")
text = text.replace("\r", "")
# Convert ANSI escape codes to HTML
text = conv.convert(text)
text = ansi2html_converter.convert(text)
# Replace background color
text = text.replace(
"background-color: #000000", "background-color: transparent"
Expand Down Expand Up @@ -256,7 +256,6 @@ def serialize(self):
base_dict = super().serialize()
base_dict["source"] = self.source
base_dict["stdout"] = self.stdout

return base_dict

def deserialize(
Expand Down
2 changes: 1 addition & 1 deletion pyflow/blocks/containerblock.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ class ContainerBlock(Block):
"""

def __init__(self, **kwargs):
super().__init__(**kwargs)
super().__init__(block_type="ContainerBlock", **kwargs)

# Defer import to prevent circular dependency.
# Due to the overall structure of the code, this cannot be removed, as the
Expand Down
2 changes: 1 addition & 1 deletion pyflow/blocks/drawingblock.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ class DrawingBlock(ExecutableBlock):

def __init__(self, **kwargs):
"""Create a new Block."""
super().__init__(**kwargs)
super().__init__(block_type="DrawingBlock", **kwargs)

self.draw_area = DrawableWidget(self.root)
self.draw_area.on_value_changed.connect(self.valueChanged)
Expand Down
2 changes: 1 addition & 1 deletion pyflow/blocks/markdownblock.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ def __init__(self, **kwargs):
"""
Create a new MarkdownBlock, a block that renders markdown
"""
super().__init__(**kwargs)
super().__init__(block_type="MarkdownBlock", **kwargs)

self.editor = MarkdownEditor(self)

Expand Down
112 changes: 106 additions & 6 deletions pyflow/blocks/pyeditor.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,27 @@

""" Module for the PyFlow python editor."""

from typing import TYPE_CHECKING, Optional, OrderedDict, Tuple

from PyQt5.QtCore import Qt
from PyQt5.QtGui import (
QFocusEvent,
QFont,
QFontMetrics,
QColor,
QKeyEvent,
QWheelEvent,
)

from PyQt5.Qsci import QsciScintilla, QsciLexerPython

from pyflow.core.editor import Editor
from pyflow.core.history import History
from pyflow.graphics.theme_manager import theme_manager

from pyflow.blocks.block import Block

if TYPE_CHECKING:
from pyflow.blocks.codeblock import CodeBlock

POINT_SIZE = 11

Expand All @@ -24,17 +32,19 @@ class PythonEditor(Editor):

"""In-block python editor for Pyflow."""

def __init__(self, block: Block):
def __init__(self, block: "CodeBlock"):
"""In-block python editor for Pyflow.
Args:
block: Block in which to add the python editor widget.
"""
super().__init__(block)
self.foreground_color = QColor("#dddddd")
self.background_color = QColor("#212121")

self.history = EditorHistory(self)
self.pressingControl = False
# self.startOfSequencePos

self.update_theme()
theme_manager().themeChanged.connect(self.update_theme)

Expand Down Expand Up @@ -65,7 +75,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)
Expand All @@ -86,12 +96,102 @@ def update_theme(self):
self.setLexer(lexer)

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)

def focusOutEvent(self, event: QFocusEvent):
"""PythonEditor reaction to PyQt focusOut events."""
self.block.source = self.text()
self.block.scene().history.checkpoint(
"A codeblock source was updated", set_modified=True
)
return super().focusOutEvent(event)

def keyPressEvent(self, event: 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 event.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 event.key() == Qt.Key.Key_Y:
self.history.redo()
elif event.key() == Qt.Key.Key_Control:
self.pressingControl = True
else:
self.pressingControl = False
self.history.start_sequence()

if event.key() in {Qt.Key.Key_Return, Qt.Key.Key_Enter}:
self.history.end_sequence()

super().keyPressEvent(event)


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: "PythonEditor" = 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"])
2 changes: 1 addition & 1 deletion pyflow/blocks/sliderblock.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ class SliderBlock(ExecutableBlock):
"""

def __init__(self, **kwargs):
super().__init__(**kwargs)
super().__init__(block_type="SliderBlock", **kwargs)

self.layout = QVBoxLayout(self.root)

Expand Down
72 changes: 72 additions & 0 deletions pyflow/core/history.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Pyflow an open-source tool for modular visual programing in python
# Copyright (C) 2021-2022 Bycelium <https://www.gnu.org/licenses/>

""" Module for the handling an history of operations. """

from typing import Any, List
import logging

logger = logging.getLogger(__name__)


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 self.history_stack 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 self.history_stack 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)
if "description" in data:
logger.debug("Stored [%s]: %s", self.current, data["description"])

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
"""
raise NotImplementedError
Loading

0 comments on commit 88473c7

Please sign in to comment.