mirror of
https://github.com/rbreu/beeref.git
synced 2026-03-11 08:54:28 +00:00
Add undo/redo for everything
This commit is contained in:
parent
39ee17f06d
commit
5c4de56eaf
8 changed files with 195 additions and 50 deletions
29
CONTRIBUTING.rst
Normal file
29
CONTRIBUTING.rst
Normal 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``.
|
||||
20
README.rst
20
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 <https://github.com/rbreu/beeref/blob/main/CONTRIBUTING.rst>`_.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,2 +1,3 @@
|
|||
flake8==3.9.0
|
||||
pytest==6.2.2
|
||||
flake8
|
||||
pytest
|
||||
coverage
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue