diff --git a/beeref/__main__.py b/beeref/__main__.py index dfb6a29..8f4908a 100755 --- a/beeref/__main__.py +++ b/beeref/__main__.py @@ -16,11 +16,10 @@ # along with BeeRef. If not, see . import logging -import os.path import signal import sys -from PyQt6 import QtCore, QtGui, QtWidgets +from PyQt6 import QtCore, QtWidgets from beeref.assets import BeeAssets from beeref.config import CommandlineArgs diff --git a/beeref/assets.py b/beeref/assets.py index 745f3ee..2da5b00 100644 --- a/beeref/assets.py +++ b/beeref/assets.py @@ -18,8 +18,7 @@ import logging import os.path -from PyQt6 import QtCore, QtGui, QtWidgets -from PyQt6.QtCore import Qt +from PyQt6 import QtGui logger = logging.getLogger('BeeRef') @@ -27,7 +26,7 @@ logger = logging.getLogger('BeeRef') class BeeAssets: _instance = None - PATH = os.path.join( os.path.dirname(__file__), 'assets') + PATH = os.path.join(os.path.dirname(__file__), 'assets') def __new__(cls, *args, **kwargs): if not cls._instance: @@ -36,7 +35,6 @@ class BeeAssets: return cls._instance def on_new(self): - PATH = os.path.join(os.path.dirname(__file__), 'assets') logger.debug(f'Assets path: {self.PATH}') self.logo = QtGui.QIcon(os.path.join(self.PATH, 'logo.png')) diff --git a/beeref/commands.py b/beeref/commands.py index f89790a..f2a4ea1 100644 --- a/beeref/commands.py +++ b/beeref/commands.py @@ -103,6 +103,35 @@ class ScaleItemsBy(QtGui.QUndoCommand): item.setPos(data['orig_pos']) +class RotateItemsBy(QtGui.QUndoCommand): + """Rotate items by a given deltan around the given anchor.""" + + def __init__(self, items, delta, anchor, ignore_first_redo=False): + super().__init__('Scale items') + self.ignore_first_redo = ignore_first_redo + self.items = items + self.delta = delta + self.anchor = anchor + + def redo(self): + if self.ignore_first_redo: + self.ignore_first_redo = False + return + for item in self.items: + item.setRotation(item.rotation() + self.delta, + item.mapFromScene(self.anchor)) + item.scene().on_selection_change() + + def undo(self): + if self.ignore_first_redo: + self.ignore_first_redo = False + return + for item in self.items: + item.setRotation(item.rotation() - self.delta, + item.mapFromScene(self.anchor)) + item.scene().on_selection_change() + + class NormalizeItems(QtGui.QUndoCommand): def __init__(self, items, scale_factors): diff --git a/beeref/fileio/sql.py b/beeref/fileio/sql.py index 8c6bf59..349651b 100644 --- a/beeref/fileio/sql.py +++ b/beeref/fileio/sql.py @@ -127,20 +127,21 @@ class SQLiteIO: @handle_sqlite_errors def read(self): rows = self.fetchall( - 'SELECT items.id, x, y, z, scale, filename, sqlar.data ' + 'SELECT items.id, x, y, z, scale, rotation, filename, sqlar.data ' 'FROM items ' 'INNER JOIN sqlar on sqlar.item_id = items.id') if self.progress: self.progress.setMaximum(len(rows)) for i, row in enumerate(rows): - item = BeePixmapItem(QtGui.QImage(), filename=row[5]) + item = BeePixmapItem(QtGui.QImage(), filename=row[6]) item.save_id = row[0] - item.pixmap_from_bytes(row[6]) + item.pixmap_from_bytes(row[7]) item.setPos(row[1], row[2]) self.scene.addItem(item) item.setZValue(row[3]) item.setScale(row[4]) + item.setRotation(row[5]) if self.progress: self.progress.setValue(i) if self.progress.wasCanceled(): @@ -187,10 +188,10 @@ class SQLiteIO: def insert_item(self, item): self.ex( - 'INSERT INTO items (type, x, y, z, scale, filename) ' - 'VALUES (?, ?, ?, ?, ?, ?) ', + 'INSERT INTO items (type, x, y, z, scale, rotation, filename) ' + 'VALUES (?, ?, ?, ?, ?, ?, ?) ', ('pixmap', item.pos().x(), item.pos().y(), item.zValue(), - item.scale(), item.filename)) + item.scale(), item.rotation(), item.filename)) item.save_id = self.cursor.lastrowid pixmap = item.pixmap_to_bytes() @@ -213,8 +214,8 @@ class SQLiteIO: data never changes and is also time-consuming to save. """ self.ex( - 'UPDATE items SET x=?, y=?, z=?, scale=?, filename=? ' + 'UPDATE items SET x=?, y=?, z=?, scale=?, rotation=?, filename=? ' 'WHERE id=?', (item.pos().x(), item.pos().y(), item.zValue(), item.scale(), - item.filename, item.save_id)) + item.rotation(), item.filename, item.save_id)) self.connection.commit() diff --git a/beeref/items.py b/beeref/items.py index 2489874..7ad44b4 100644 --- a/beeref/items.py +++ b/beeref/items.py @@ -40,11 +40,11 @@ class BeePixmapItem(SelectableMixin, QtWidgets.QGraphicsPixmapItem): def __str__(self): return (f'Image "{self.filename}" {self.width} x {self.height}') - def set_pos_center(self, x, y): + def set_pos_center(self, pos): """Sets the position using the item's center as the origin point.""" - self.setPos(x - self.width * self.scale() / 2, - y - self.height * self.scale() / 2) + center = self.mapToScene(QtCore.QPointF(self.width/2, self.height/2)) + self.setPos(pos - center) @property def width(self): diff --git a/beeref/selection.py b/beeref/selection.py index ea49190..95506da 100644 --- a/beeref/selection.py +++ b/beeref/selection.py @@ -16,6 +16,7 @@ """Classes for items that draw and handle selection stuff.""" import logging +import math from PyQt6 import QtCore, QtGui, QtWidgets from PyQt6.QtCore import Qt @@ -50,6 +51,16 @@ class BaseItemMixin: def bring_to_front(self): self.setZValue(self.scene().max_z + 0.001) + def setRotation(self, rotation, anchor=None): + anchor = anchor or QtCore.QPointF(0, 0) + prev = self.mapToScene(anchor) + super().setRotation(rotation) + + # Calculate where the anchor moved to so that we can shift the + # item back to keep the anchor fixed + diff = self.mapToScene(anchor) - prev + self.setPos(self.pos() - diff) + class SelectableMixin(BaseItemMixin): """Common code for selectable items: Selection outline, handles etc.""" @@ -66,6 +77,7 @@ class SelectableMixin(BaseItemMixin): | QtWidgets.QGraphicsItem.GraphicsItemFlags.ItemIsSelectable) self.scale_active = False + self.rotate_active = False self.viewport_scale = 1 self.conf_debug_shapes = commandline_args.draw_debug_shapes @@ -223,7 +235,7 @@ class SelectableMixin(BaseItemMixin): if self.get_scale_bounds(corner).contains(event.pos()): # Start scale action for this corner self.scale_active = True - self.scale_start = event.scenePos() + self.event_start = event.scenePos() self.scale_direction = self.get_corner_direction(corner) for item in self.selection_action_items(): item.scale_anchor = self.get_scale_anchor(item, corner) @@ -231,12 +243,25 @@ class SelectableMixin(BaseItemMixin): item.scale_orig_pos = item.pos() event.accept() return + # Check if we are in one of the corner's rotate areas + if self.get_rotate_bounds(corner).contains(event.pos()): + # Start rotate action + self.rotate_active = True + self.rotate_anchor = self.mapToScene( + QtCore.QPointF(self.width/2, self.height/2)) + self.rotate_start_angle = self.get_rotate_angle(event) + for item in self.selection_action_items(): + item.rotate_anchor = self.rotate_anchor + item.rotate_orig_degrees = item.rotation() + event.accept() + return super().mousePressEvent(event) def get_scale_factor(self, event): + """Get the scale factor for the current mouse movement.""" imgsize = self.width + self.height - p = event.scenePos() - self.scale_start + p = event.scenePos() - self.event_start direction = self.scale_direction delta = QtCore.QPointF.dotProduct(direction, p) / imgsize return (self.scale_orig_factor + delta) / self.scale_orig_factor @@ -265,6 +290,17 @@ class SelectableMixin(BaseItemMixin): self.scale_orig_pos.y() - self.scale_anchor.y() * factor, ) + def get_rotate_angle(self, event): + """Get the angle of the current mouse position towards the + scale center.""" + + diff = event.scenePos() - self.rotate_anchor + return -math.degrees(math.atan2(diff.x(), diff.y())) + + def get_rotate_delta(self, event): + """Get the rotate delta for the current mouse movement""" + return self.get_rotate_angle(event) - self.rotate_start_angle + def mouseMoveEvent(self, event): if self.scale_active: factor = self.get_scale_factor(event) @@ -272,6 +308,11 @@ class SelectableMixin(BaseItemMixin): item.setScale(item.scale_orig_factor * factor) item.translate_for_scale_anchor(factor) event.accept() + elif self.rotate_active: + delta = self.get_rotate_delta(event) + for item in self.selection_action_items(): + item.setRotation(item.rotate_orig_degrees + delta, + item.mapFromScene(self.rotate_anchor)) else: super().mouseMoveEvent(event) @@ -284,6 +325,16 @@ class SelectableMixin(BaseItemMixin): ignore_first_redo=True)) self.scale_active = False event.accept() + elif self.rotate_active: + self.scene().on_selection_change() + self.scene().undo_stack.push( + commands.RotateItemsBy( + self.selection_action_items(), + self.get_rotate_delta(event), + self.rotate_anchor, + ignore_first_redo=True)) + self.rotate_active = False + event.accept() else: super().mouseReleaseEvent(event) diff --git a/beeref/view.py b/beeref/view.py index b6baf59..cdee5cc 100644 --- a/beeref/view.py +++ b/beeref/view.py @@ -323,7 +323,7 @@ class BeeGraphicsView(QtWidgets.QGraphicsView): errors.append(filename) continue item = BeePixmapItem(img, filename) - item.set_pos_center(pos.x(), pos.y()) + item.set_pos_center(pos) items.append(item) pos.setX(pos.x() + 50) pos.setY(pos.y() + 50) diff --git a/tests/fileio/test_sql.py b/tests/fileio/test_sql.py index 477d515..acb25f2 100644 --- a/tests/fileio/test_sql.py +++ b/tests/fileio/test_sql.py @@ -92,12 +92,13 @@ class SQLiteIOWriteTestCase(BeeTestCase): item.setScale(1.3) item.setPos(44, 55) item.setZValue(0.22) + item.setRotation(33) item.pixmap_to_bytes = MagicMock(return_value=b'abc') self.io.write() assert item.save_id == 1 result = self.io.fetchone( - 'SELECT x, y, z, scale, filename, type, ' + 'SELECT x, y, z, scale, rotation, filename, type, ' 'sqlar.data, sqlar.name ' 'FROM items ' 'INNER JOIN sqlar on sqlar.item_id = items.id') @@ -105,10 +106,11 @@ class SQLiteIOWriteTestCase(BeeTestCase): assert result[1] == 55.0 assert result[2] == 0.22 assert result[3] == 1.3 - assert result[4] == 'bee.jpg' - assert result[5] == 'pixmap' - assert result[6] == b'abc' - assert result[7] == '0001-bee.png' + assert result[4] == 33 + assert result[5] == 'bee.jpg' + assert result[6] == 'pixmap' + assert result[7] == b'abc' + assert result[8] == '0001-bee.png' def test_inserts_new_item_without_filename(self): item = BeePixmapItem(QtGui.QImage()) @@ -128,12 +130,14 @@ class SQLiteIOWriteTestCase(BeeTestCase): item.setScale(1.3) item.setPos(44, 55) item.setZValue(0.22) + item.setRotation(33) item.save_id = 1 item.pixmap_to_bytes = MagicMock(return_value=b'abc') self.io.write() item.setScale(0.7) item.setPos(20, 30) item.setZValue(0.33) + item.setRotation(100) item.filename = 'new.png' item.pixmap_to_bytes.return_value = b'updated' self.io.create_new = False @@ -141,15 +145,16 @@ class SQLiteIOWriteTestCase(BeeTestCase): assert self.io.fetchone('SELECT COUNT(*) from items') == (1,) result = self.io.fetchone( - 'SELECT x, y, z, scale, filename, sqlar.data ' + 'SELECT x, y, z, scale, rotation, filename, sqlar.data ' 'FROM items ' 'INNER JOIN sqlar on sqlar.item_id = items.id') assert result[0] == 20 assert result[1] == 30 assert result[2] == 0.33 assert result[3] == 0.7 - assert result[4] == 'new.png' - assert result[5] == b'abc' + assert result[4] == 100 + assert result[5] == 'new.png' + assert result[6] == b'abc' def test_removes_nonexisting_item(self): item = BeePixmapItem(QtGui.QImage(), filename='bee.png') @@ -200,9 +205,10 @@ class SQLiteIOReadTestCase(BeeTestCase): fname = os.path.join(dirname, 'test.bee') io = SQLiteIO(fname, self.scene, create_new=True) io.create_schema_on_new() - io.ex('INSERT INTO items (type, x, y, z, scale, filename) ' - 'VALUES (?, ?, ?, ?, ?, ?) ', - ('pixmap', 22.2, 33.3, 0.22, 3.4, 'bee.png')) + io.ex('INSERT INTO items ' + '(type, x, y, z, scale, rotation, filename) ' + 'VALUES (?, ?, ?, ?, ?, ?, ?) ', + ('pixmap', 22.2, 33.3, 0.22, 3.4, 45, 'bee.png')) io.ex('INSERT INTO sqlar (item_id, data) VALUES (?, ?)', (1, self.imgdata3x3)) io.connection.commit() @@ -217,6 +223,7 @@ class SQLiteIOReadTestCase(BeeTestCase): assert item.pos().y() == 33.3 assert item.zValue() == 0.22 assert item.scale() == 3.4 + assert item.rotation() == 45 assert item.filename == 'bee.png' assert item.width == 3 assert item.height == 3 diff --git a/tests/test_config.py b/tests/test_config.py index 56cb80a..5c5fd65 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,5 +1,3 @@ -from unittest.mock import patch - from beeref.config import CommandlineArgs diff --git a/tests/test_items.py b/tests/test_items.py index 00b91c2..6fa5150 100644 --- a/tests/test_items.py +++ b/tests/test_items.py @@ -1,6 +1,6 @@ from unittest.mock import patch, MagicMock, PropertyMock -from PyQt6 import QtGui +from PyQt6 import QtCore, QtGui from beeref.items import BeePixmapItem from beeref.scene import BeeGraphicsScene @@ -29,7 +29,7 @@ class BeePixmapItemTestCase(BeeTestCase): new_callable=PropertyMock, return_value=200): with patch('beeref.items.BeePixmapItem.height', new_callable=PropertyMock, return_value=100): - item.set_pos_center(0, 0) + item.set_pos_center(QtCore.QPointF(0, 0)) assert item.pos().x() == -100 assert item.pos().y() == -50 diff --git a/tests/test_selection.py b/tests/test_selection.py index 72aa3f9..5d3689b 100644 --- a/tests/test_selection.py +++ b/tests/test_selection.py @@ -60,6 +60,27 @@ class BaseItemMixinTestCase(BeeTestCase): assert item2.zValue() > item1.zValue() assert item2.zValue() == self.scene.max_z + def test_set_rotation_anchor_topleft(self): + item = BeePixmapItem(QtGui.QImage()) + item.setRotation(45) + assert item.rotation() == 45 + assert item.pos().x() == 0 + assert item.pos().y() == 0 + + def test_set_rotation_anchor_bottomright_cw(self): + item = BeePixmapItem(QtGui.QImage()) + item.setRotation(90, QtCore.QPointF(100, 100)) + assert item.rotation() == 90 + assert item.pos().x() == 200 + assert item.pos().y() == 0 + + def test_set_rotation_anchor_bottomright_ccw(self): + item = BeePixmapItem(QtGui.QImage()) + item.setRotation(-90, QtCore.QPointF(100, 100)) + assert item.rotation() == -90 + assert item.pos().x() == 0 + assert item.pos().y() == 200 + class SelectableMixinBaseTestCase(BeeTestCase): @@ -307,7 +328,7 @@ class SelectableMixinTestCase(SelectableMixinBaseTestCase): class SelectableMixinScalingTestCase(SelectableMixinBaseTestCase): def test_get_scale_factor_bottomright(self): - self.item.scale_start = QtCore.QPointF(10, 10) + self.item.event_start = QtCore.QPointF(10, 10) self.item.scale_direction = QtCore.QPointF(1, 1) self.item.scale_orig_factor = 1 event = MagicMock() @@ -315,7 +336,7 @@ class SelectableMixinScalingTestCase(SelectableMixinBaseTestCase): assert self.item.get_scale_factor(event) == 1.5 def test_get_scale_factor_topleft(self): - self.item.scale_start = QtCore.QPointF(10, 10) + self.item.event_start = QtCore.QPointF(10, 10) self.item.scale_direction = QtCore.QPointF(-1, -1) self.item.scale_orig_factor = 0.5 event = MagicMock() @@ -373,6 +394,19 @@ class SelectableMixinScalingTestCase(SelectableMixinBaseTestCase): assert self.item.pos().x() == -150 assert self.item.pos().y() == -90 + def test_get_rotate_angle(self): + self.item.rotate_anchor = QtCore.QPointF(10, 20) + event = MagicMock() + event.scenePos = MagicMock(return_value=QtCore.QPointF(15, 25)) + assert self.item.get_rotate_angle(event) == -45 + + def test_get_rotate_delta(self): + self.item.rotate_anchor = QtCore.QPointF(10, 20) + self.item.rotate_start_angle = -3 + event = MagicMock() + event.scenePos = MagicMock(return_value=QtCore.QPointF(15, 25)) + assert self.item.get_rotate_delta(event) == -42 + class SelectableMixinMouseEventsTestCase(SelectableMixinBaseTestCase): @@ -380,6 +414,8 @@ class SelectableMixinMouseEventsTestCase(SelectableMixinBaseTestCase): super().setUp() self.event = MagicMock() self.item.setCursor = MagicMock() + self.item.SELECT_RESIZE_SIZE = 10 + self.item.SELECT_ROTATE_SIZE = 10 def test_hover_move_event_no_selection(self): self.event.pos = MagicMock(return_value=QtCore.QPointF(0, 0)) @@ -409,8 +445,6 @@ class SelectableMixinMouseEventsTestCase(SelectableMixinBaseTestCase): def test_hover_move_event_rotate(self): self.item.setSelected(True) - self.item.SELECT_RESIZE_SIZE = 10 - self.item.SELECT_ROTATE_SIZE = 10 self.event.pos = MagicMock(return_value=QtCore.QPointF(110, 90)) self.item.hoverMoveEvent(self.event) self.item.setCursor.assert_called_once_with(BeeAssets().cursor_rotate) @@ -441,7 +475,7 @@ class SelectableMixinMouseEventsTestCase(SelectableMixinBaseTestCase): return_value=Qt.MouseButtons.LeftButton) self.item.mousePressEvent(self.event) assert self.item.scale_active is True - assert self.item.scale_start == QtCore.QPointF(66, 99) + assert self.item.event_start == QtCore.QPointF(66, 99) assert self.item.scale_direction == QtCore.QPointF(-1, -1) assert self.item.scale_orig_factor == 1 assert self.item.scale_orig_pos == QtCore.QPointF(0, 0) @@ -455,10 +489,21 @@ class SelectableMixinMouseEventsTestCase(SelectableMixinBaseTestCase): self.item.mousePressEvent(self.event) assert self.item.scale_active is True assert self.item.scale_direction == QtCore.QPointF(1, 1) - assert self.item.scale_start == QtCore.QPointF(66, 99) + assert self.item.event_start == QtCore.QPointF(66, 99) assert self.item.scale_orig_factor == 1 assert self.item.scale_orig_pos == QtCore.QPointF(0, 0) + def test_mouse_press_event_rotate(self): + self.item.setSelected(True) + self.event.pos = MagicMock(return_value=QtCore.QPointF(111, 91)) + self.event.scenePos = MagicMock(return_value=QtCore.QPointF(66, 99)) + self.event.button = MagicMock( + return_value=Qt.MouseButtons.LeftButton) + self.item.mousePressEvent(self.event) + assert self.item.rotate_active is True + assert self.item.rotate_anchor == QtCore.QPointF(50, 40) + assert self.item.rotate_orig_degrees == 0 + def test_mouse_press_event_not_selected(self): self.item.setSelected(False) with patch('PyQt6.QtWidgets.QGraphicsPixmapItem.mousePressEvent') as m: @@ -481,18 +526,27 @@ class SelectableMixinMouseEventsTestCase(SelectableMixinBaseTestCase): self.item.mouseMoveEvent(self.event) m.assert_called_once_with(self.event) - def test_move_event_when_scale_action(self): + def test_mouse_move_event_when_scale_action(self): self.event.scenePos = MagicMock(return_value=QtCore.QPointF(20, 90)) self.item.scale_active = True self.item.scale_direction = QtCore.QPointF(1, 1) self.item.scale_anchor = QtCore.QPointF(100, 80) - self.item.scale_start = QtCore.QPointF(10, 10) + self.item.event_start = QtCore.QPointF(10, 10) self.item.scale_orig_factor = 1 self.item.scale_orig_pos = QtCore.QPointF(0, 0) self.item.mouseMoveEvent(self.event) assert self.item.scale() == 1.5 + def test_mouse_move_event_when_rotate_action(self): + self.event.scenePos = MagicMock(return_value=QtCore.QPointF(15, 25)) + self.item.rotate_active = True + self.item.rotate_orig_degrees = 0 + self.item.rotate_start_angle = -3 + self.item.rotate_anchor = QtCore.QPointF(10, 20) + self.item.mouseMoveEvent(self.event) + assert self.item.rotation() == -42 + def test_mouse_release_event_when_no_action(self): with patch('PyQt6.QtWidgets.QGraphicsPixmapItem' '.mouseReleaseEvent') as m: @@ -504,7 +558,7 @@ class SelectableMixinMouseEventsTestCase(SelectableMixinBaseTestCase): self.item.scale_active = True self.item.scale_direction = QtCore.QPointF(1, 1) self.item.scale_anchor = QtCore.QPointF(100, 80) - self.item.scale_start = QtCore.QPointF(10, 10) + self.item.event_start = QtCore.QPointF(10, 10) self.item.scale_orig_factor = 1 self.item.scale_orig_pos = QtCore.QPointF(0, 0) self.scene.undo_stack = MagicMock(push=MagicMock()) @@ -522,6 +576,23 @@ class SelectableMixinMouseEventsTestCase(SelectableMixinBaseTestCase): assert cmd.ignore_first_redo is True assert self.item.scale_active is False + def test_mouse_release_event_when_rotate_action(self): + self.event.scenePos = MagicMock(return_value=QtCore.QPointF(15, 25)) + self.item.rotate_active = True + self.item.rotate_orig_degrees = 0 + self.item.rotate_start_angle = -3 + self.item.rotate_anchor = QtCore.QPointF(10, 20) + self.scene.undo_stack = MagicMock(push=MagicMock()) + + self.item.mouseReleaseEvent(self.event) + args = self.scene.undo_stack.push.call_args_list[0][0] + cmd = args[0] + assert cmd.items == [self.item] + assert cmd.delta == -42 + assert cmd.anchor == QtCore.QPointF(10, 20) + assert cmd.ignore_first_redo is True + assert self.item.rotate_active is False + class MultiSelectItemTestCase(BeeTestCase):