Add undo/redo for everything

This commit is contained in:
Rebecca Breu 2021-03-25 16:44:39 +01:00
parent 39ee17f06d
commit 5c4de56eaf
8 changed files with 195 additions and 50 deletions

29
CONTRIBUTING.rst Normal file
View file

@ -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``.

View file

@ -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 <https://github.com/rbreu/beeref/blob/main/CONTRIBUTING.rst>`_.

View file

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

View file

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

View file

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

View file

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

View file

@ -1,2 +1,3 @@
flake8==3.9.0
pytest==6.2.2
flake8
pytest
coverage

View file

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