From 5c4de56eaf1ba5b508e43c9f3c922d426531901b Mon Sep 17 00:00:00 2001 From: Rebecca Breu Date: Thu, 25 Mar 2021 16:44:39 +0100 Subject: [PATCH] Add undo/redo for everything --- CONTRIBUTING.rst | 29 ++++++++++++++++++++++ README.rst | 20 +-------------- beeref/commands.py | 59 ++++++++++++++++++++++++++++++++++++++++++++ beeref/scene.py | 35 +++++++++++++++++++++++--- beeref/selection.py | 18 ++++++++++---- beeref/view.py | 24 +++++++++++++++--- requirements/dev.txt | 5 ++-- tests/test_scene.py | 55 ++++++++++++++++++++++++++++------------- 8 files changed, 195 insertions(+), 50 deletions(-) create mode 100644 CONTRIBUTING.rst diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst new file mode 100644 index 0000000..624ea35 --- /dev/null +++ b/CONTRIBUTING.rst @@ -0,0 +1,29 @@ +BeeRef — Notes For Developers +============================= + +BeeRef is written in Python and PyQt6. + +Clone the repository and install beeref and its dependencies:: + + git clone https://github.com/rbreu/beeref.git + pip install -e beeref + +Install additional development requirements:: + + cd beeref + pip install -r requirements/dev.txt + +Run unittests with:: + + pytest . + +Run codechecks with:: + + flake8 . + +Run unittests with coverage report: + + coverage run --source=beeref -m pytest; + coverage html + +If your browser doesn't open automatically, view ``htmlcov/index.html``. diff --git a/README.rst b/README.rst index c597b08..04ad6fb 100644 --- a/README.rst +++ b/README.rst @@ -26,22 +26,4 @@ Then run ``beeref`` or ``beeref filename.bee``. Notes for developers -------------------- -BeeRef is written in Python and PyQt6. - -Clone the repository and install beeref and its dependencies:: - - git clone https://github.com/rbreu/beeref.git - pip install -e beeref - -Install additional development requirements:: - - cd beeref - pip install -r requirements/dev.txt - -Run unittests with:: - - pytest . - -Run codechecks with:: - - flake8 . +BeeRef is written in Python and PyQt6. For more info, see `CONTRIBUTING.rst `_. diff --git a/beeref/commands.py b/beeref/commands.py index ae53b7b..7fd8a50 100644 --- a/beeref/commands.py +++ b/beeref/commands.py @@ -52,3 +52,62 @@ class DeleteSelectedItems(QtGui.QUndoCommand): for item in self.items: item.setSelected(True) self.scene.addItem(item) + + +class MoveItemsBy(QtGui.QUndoCommand): + + def __init__(self, items, x, y, ignore_first_redo=False): + super().__init__('Move items') + self.items = items + self.delta_x = x + self.delta_y = y + self.ignore_first_redo = ignore_first_redo + + def redo(self): + if self.ignore_first_redo: + self.ignore_first_redo = False + return + for item in self.items: + item.moveBy(self.delta_x, self.delta_y) + + def undo(self): + for item in self.items: + item.moveBy(-self.delta_x, -self.delta_y) + + +class ScaleItemsBy(QtGui.QUndoCommand): + + def __init__(self, items, factor, ignore_first_redo=False): + super().__init__('Scale items') + self.items = items + self.factor = factor + self.ignore_first_redo = ignore_first_redo + + def redo(self): + if self.ignore_first_redo: + self.ignore_first_redo = False + return + for item in self.items: + item.setScale(item.scale_factor + self.factor) + + def undo(self): + for item in self.items: + item.setScale(item.scale_factor - self.factor) + + +class NormalizeItems(QtGui.QUndoCommand): + + def __init__(self, items, scale_factors): + super().__init__('Normalize items') + self.items = items + self.scale_factors = scale_factors + + def redo(self): + self.old_scale_factors = [] + for item, factor in zip(self.items, self.scale_factors): + self.old_scale_factors.append(item.scale_factor) + item.setScale(factor) + + def undo(self): + for item, factor in zip(self.items, self.old_scale_factors): + item.setScale(factor) diff --git a/beeref/scene.py b/beeref/scene.py index 1c16090..6f28d7a 100644 --- a/beeref/scene.py +++ b/beeref/scene.py @@ -19,12 +19,18 @@ import math from PyQt6 import QtWidgets from PyQt6.QtCore import Qt +from beeref import commands logger = logging.getLogger('BeeRef') class BeeGraphicsScene(QtWidgets.QGraphicsScene): + def __init__(self, undo_stack): + super().__init__() + self.move_active = False + self.undo_stack = undo_stack + def normalize_width_or_height(self, mode): """Scale the selected images to have the same width or height, as specified by ``mode``. @@ -39,9 +45,11 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene): logger.debug(f'Calculated average {mode} {avg}') + scale_factors = [] for item in self.selectedItems(): - factor = avg / getattr(item, mode) - item.setScale(factor) + scale_factors.append(avg / getattr(item, mode)) + self.undo_stack.push( + commands.NormalizeItems(self.selectedItems(), scale_factors)) def normalize_height(self): """Scale selected images to the same height.""" @@ -64,9 +72,11 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene): avg = sum(sizes) / len(sizes) logger.debug(f'Calculated average size {avg}') + scale_factors = [] for item in self.selectedItems(): - factor = math.sqrt(avg / item.width / item.height) - item.setScale(factor) + scale_factors.append(math.sqrt(avg / item.width / item.height)) + self.undo_stack.push( + commands.NormalizeItems(self.selectedItems(), scale_factors)) def has_selection(self): """Checks whether there are currently items selected.""" @@ -83,8 +93,25 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene): # Right-click invokes the context menu on the # GraphicsView. We don't need it here. return + + if event.button() == Qt.MouseButtons.LeftButton: + self.move_active = True + self.move_start = event.scenePos() + super().mousePressEvent(event) + def mouseReleaseEvent(self, event): + + if self.move_active and self.has_selection(): + delta = event.scenePos() - self.move_start + if not delta.isNull(): + self.undo_stack.push( + commands.MoveItemsBy(self.selectedItems(), + delta.x(), delta.y(), + ignore_first_redo=True)) + self.move_active = False + super().mouseReleaseEvent(event) + def items_for_export(self): """Returns the items that are to be exported. diff --git a/beeref/selection.py b/beeref/selection.py index 372cafc..5fdab82 100644 --- a/beeref/selection.py +++ b/beeref/selection.py @@ -18,6 +18,8 @@ import logging from PyQt6 import QtCore, QtGui, QtWidgets from PyQt6.QtCore import Qt +from beeref import commands + logger = logging.getLogger('BeeRef') @@ -106,15 +108,21 @@ class SelectionItem(QtWidgets.QGraphicsItem): self.orig_scale_factor = self.parentItem().scale() self.scale_start = event.scenePos() + def get_scale_delta(self, event): + imgsize = self.parentItem().width + self.parentItem().height + p = event.scenePos() - self.scale_start + return (p.x() + p.y()) / imgsize + def mouseMoveEvent(self, event): if self.scale_active: - imgsize = self.parentItem().width + self.parentItem().height - p = event.scenePos() - self.scale_start - mousemove = p.x() + p.y() - scale = self.orig_scale_factor + mousemove / imgsize - self.parentItem().setScale(scale) + delta = self.get_scale_delta(event) + self.parentItem().setScale(self.orig_scale_factor + delta) def mouseReleaseEvent(self, event): + self.parentItem().scene().undo_stack.push( + commands.ScaleItemsBy(self.scene().selectedItems(), + self.get_scale_delta(event), + ignore_first_redo=True)) self.scale_active = False @classmethod diff --git a/beeref/view.py b/beeref/view.py index 8f5a617..5baa59a 100644 --- a/beeref/view.py +++ b/beeref/view.py @@ -34,7 +34,12 @@ class BeeGraphicsView(QtWidgets.QGraphicsView): super().__init__(parent) self.app = app - self.scene = BeeGraphicsScene() + self.undo_stack = QtGui.QUndoStack(self) + self.undo_stack.setUndoLimit(10) + self.undo_stack.canRedoChanged.connect(self.on_can_redo_changed) + self.undo_stack.canUndoChanged.connect(self.on_can_undo_changed) + + self.scene = BeeGraphicsScene(self.undo_stack) self.filename = None # TBD: make scrollbar configurable @@ -63,8 +68,6 @@ class BeeGraphicsView(QtWidgets.QGraphicsView): self.build_actions() self.welcome_overlay = WelcomeOverlay(self) - self.undo_stack = QtGui.QUndoStack(self) - self.undo_stack.setUndoLimit(100) # Load file given via command line if filename: @@ -81,6 +84,8 @@ class BeeGraphicsView(QtWidgets.QGraphicsView): def build_actions(self): self.actions_active_when_selection = [] + self.actions_active_when_can_undo = [] + self.actions_active_when_can_redo = [] def add_to_menu(menu, actions): for action in actions: @@ -136,11 +141,15 @@ class BeeGraphicsView(QtWidgets.QGraphicsView): 'text': '&Undo', 'shortcuts': ['Ctrl+Z'], 'callback': self.on_action_undo, + 'group': self.actions_active_when_can_undo, + 'enabled': False, }, { 'text': '&Redo', 'shortcuts': ['Ctrl+Shift+Z'], 'callback': self.on_action_redo, + 'group': self.actions_active_when_can_redo, + 'enabled': False, }, { 'text': '&Paste', @@ -183,6 +192,14 @@ class BeeGraphicsView(QtWidgets.QGraphicsView): ] add_to_menu(items_menu.addMenu('&Normalize'), actions) + def on_can_redo_changed(self, can_redo): + for action in self.actions_active_when_can_redo: + action.setEnabled(can_redo) + + def on_can_undo_changed(self, can_undo): + for action in self.actions_active_when_can_undo: + action.setEnabled(can_undo) + def on_context_menu(self, point): self.context_menu.exec(self.mapToGlobal(point)) @@ -238,6 +255,7 @@ class BeeGraphicsView(QtWidgets.QGraphicsView): with open(filename, 'r') as f: items = bee_json.loads(f.read())['items'] self.scene.clear() + self.undo_stack.clear() for item in items: self.scene.addItem(item) self.filename = filename diff --git a/requirements/dev.txt b/requirements/dev.txt index 42dbe4d..092a599 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -1,2 +1,3 @@ -flake8==3.9.0 -pytest==6.2.2 +flake8 +pytest +coverage diff --git a/tests/test_scene.py b/tests/test_scene.py index 1e6b3d7..5c62c22 100644 --- a/tests/test_scene.py +++ b/tests/test_scene.py @@ -1,44 +1,65 @@ import math from unittest.mock import patch, MagicMock +from PyQt6 import QtGui + from beeref.scene import BeeGraphicsScene from .base import BeeTestCase class BeeGraphicsSceneNormalizeTestCase(BeeTestCase): - def test_normalize_height(self): - item1 = MagicMock(height=100) - item2 = MagicMock(height=200) - scene = BeeGraphicsScene() + def setUp(self): + self.undo_stack = QtGui.QUndoStack() + self.scene = BeeGraphicsScene(self.undo_stack) - with patch.object(scene, 'selectedItems', + def test_normalize_height(self): + item1 = MagicMock(height=100, scale_factor=1) + item2 = MagicMock(height=200, scale_factor=3) + + with patch.object(self.scene, 'selectedItems', return_value=[item1, item2]): - scene.normalize_height() + self.scene.normalize_height() item1.setScale.assert_called_once_with(1.5) item2.setScale.assert_called_once_with(0.75) + item1.setScale.reset_mock() + item2.setScale.reset_mock() + self.undo_stack.undo() + item1.setScale.assert_called_once_with(1) + item2.setScale.assert_called_once_with(3) + def test_normalize_width(self): - item1 = MagicMock(width=100) - item2 = MagicMock(width=200) - scene = BeeGraphicsScene() + item1 = MagicMock(width=100, scale_factor=1) + item2 = MagicMock(width=200, scale_factor=3) - with patch.object(scene, 'selectedItems', + with patch.object(self.scene, 'selectedItems', return_value=[item1, item2]): - scene.normalize_width() + self.scene.normalize_width() item1.setScale.assert_called_once_with(1.5) item2.setScale.assert_called_once_with(0.75) - def test_normalize_size(self): - item1 = MagicMock(width=100, height=200) - item2 = MagicMock(width=400, height=100) - scene = BeeGraphicsScene() + item1.setScale.reset_mock() + item2.setScale.reset_mock() + self.undo_stack.undo() + item1.setScale.assert_called_once_with(1) + item2.setScale.assert_called_once_with(3) - with patch.object(scene, 'selectedItems', + def test_normalize_size(self): + item1 = MagicMock(width=100, height=200, scale_factor=1) + item2 = MagicMock(width=400, height=100, scale_factor=3) + + with patch.object(self.scene, 'selectedItems', return_value=[item1, item2]): - scene.normalize_size() + self.scene.normalize_size() item1.setScale.assert_called_once_with(math.sqrt(1.5)) item2.setScale.assert_called_once_with(math.sqrt(0.75)) + + item1.setScale.reset_mock() + item2.setScale.reset_mock() + self.undo_stack.undo() + item1.setScale.assert_called_once_with(1) + item2.setScale.assert_called_once_with(3)