diff --git a/beeref.py b/beeref.py index b59151d..d83c98e 100755 --- a/beeref.py +++ b/beeref.py @@ -7,7 +7,7 @@ import sys from PyQt6 import QtCore, QtGui, QtWidgets -from lib.view import BeeGraphicsView +from beeref.view import BeeGraphicsView logger = logging.getLogger('BeeRef') diff --git a/beeref/__init__.py b/beeref/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/beeref/bee_json.py b/beeref/bee_json.py new file mode 100644 index 0000000..330b389 --- /dev/null +++ b/beeref/bee_json.py @@ -0,0 +1,38 @@ +import json + +from beeref.items import BeePixmapItem + + +class BeeJSONEncoder(json.JSONEncoder): + + def default(self, obj): + if hasattr(obj, 'to_bee_json'): + return obj.to_bee_json() + return json.JSONEncoder.default(self, obj) + + +def dumps(obj): + return json.dumps(obj, cls=BeeJSONEncoder) + + +class BeeJSONDecoder(json.JSONDecoder): + + bee_classes = [BeePixmapItem] + + def __init__(self, *args, **kwargs): + super().__init__(object_hook=self.object_hook, *args, **kwargs) + + def get_bee_class(self, cls): + for bee_class in self.bee_classes: + if bee_class.__name__ == cls: + return bee_class + + def object_hook(self, obj): + if 'cls' in obj: + bee_class = self.get_bee_class(obj['cls']) + return bee_class.from_bee_json(obj) + return obj + + +def loads(obj): + return json.loads(obj, cls=BeeJSONDecoder) diff --git a/beeref/gui.py b/beeref/gui.py new file mode 100644 index 0000000..fbaf4f9 --- /dev/null +++ b/beeref/gui.py @@ -0,0 +1,24 @@ +import logging + +from PyQt6 import QtCore, QtGui, QtWidgets +from PyQt6.QtCore import Qt + + +logger = logging.getLogger('BeeRef') + + +class WelcomeOverlay(QtWidgets.QWidget): + + txt = """
Paste or drop images here.
+Right-click for more options.
""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setAttribute(Qt.WidgetAttribute.WA_NoSystemBackground) + self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents) + label = QtWidgets.QLabel(self) + label.setText(self.txt) + label.setAlignment(Qt.Alignment.AlignVCenter | Qt.Alignment.AlignCenter) + layout = QtWidgets.QVBoxLayout() + layout.addWidget(label) + self.setLayout(layout) diff --git a/beeref/items.py b/beeref/items.py new file mode 100644 index 0000000..a5b923c --- /dev/null +++ b/beeref/items.py @@ -0,0 +1,109 @@ +import base64 +import logging + +from PyQt6 import QtCore, QtGui, QtWidgets + +from beeref.selection import SelectionItem + + +logger = logging.getLogger('BeeRef') + + +class BeePixmapItem(QtWidgets.QGraphicsPixmapItem): + + def __init__(self, image, filename=None): + super().__init__(QtGui.QPixmap.fromImage(image)) + logger.debug(f'Initialized image "{filename}" with dimensions: ' + f'{self.width} x {self.height} at index {self.zValue()}') + + self.filename = filename + self.scale_factor = 1 + self.setFlags( + QtWidgets.QGraphicsItem.GraphicsItemFlags.ItemIsMovable + | QtWidgets.QGraphicsItem.GraphicsItemFlags.ItemIsSelectable) + + def setScale(self, factor): + self.scale_factor = factor + logger.debug(f'Setting scale for image "{self.filename}" to {factor}') + super().setScale(factor) + + def set_pos_center(self, x, y): + """Sets the position using the item's center as the origin point.""" + + self.setPos(x - self.width * self.scale_factor / 2, + y - self.height * self.scale_factor / 2) + + @property + def width(self): + return self.pixmap().size().width() + + @property + def height(self): + return self.pixmap().size().height() + + def pixmap_to_str(self): + barray = QtCore.QByteArray() + buffer = QtCore.QBuffer(barray) + buffer.open(QtCore.QIODevice.OpenMode.WriteOnly) + img = self.pixmap().toImage() + img.save(buffer, 'PNG') + data = base64.b64encode(barray.data()) + return data.decode('ascii') + + @classmethod + def qimage_from_str(self, data): + img = QtGui.QImage() + img.loadFromData(base64.b64decode(data)) + return img + + def to_bee_json(self): + return { + 'cls': self.__class__.__name__, + 'scale': self.scale_factor, + 'pixmap': self.pixmap_to_str(), + 'pos': [self.pos().x(), self.pos().y()], + 'z': self.zValue(), + 'filename': self.filename, + } + + @classmethod + def from_bee_json(cls, obj): + img = cls.qimage_from_str(obj['pixmap']) + item = cls(img, filename=obj.get('filename')) + if 'scale' in obj: + item.setScale(obj['scale']) + if 'pos' in obj: + item.setPos(*obj['pos']) + if 'z' in obj: + item.setZValue(obj['z']) + + return item + + def create_selection(self): + SelectionItem(self) + + def clear_selection(self): + for child in self.childItems(): + logger.debug('Removing child item...') + self.scene().removeItem(child) + logger.debug('Child item removed') + + def itemChange(self, change, value): + ret = super().itemChange(change, value) + + if change == self.GraphicsItemChange.ItemSelectedChange: + if value: + logger.debug('Item selected') + self.create_selection() + else: + logger.debug('Item deselected') + self.clear_selection() + + return ret + + def mousePressEvent(self, event): + print('????') + + def sceneEvent(self, event): + print('********', event) + return super().sceneEvent(event) diff --git a/beeref/scene.py b/beeref/scene.py new file mode 100644 index 0000000..1697fa0 --- /dev/null +++ b/beeref/scene.py @@ -0,0 +1,71 @@ +import logging +import math + +from PyQt6 import QtCore, QtWidgets +from PyQt6.QtCore import Qt + + +logger = logging.getLogger('BeeRef') + + +class BeeGraphicsScene(QtWidgets.QGraphicsScene): + + def delete_selected_items(self): + for item in self.selectedItems(): + logger.debug('Deleting item...') + self.removeItem(item) + + def normalize_width_or_height(self, mode): + values = [getattr(i, mode) for i in self.selectedItems()] + if not values: + return + avg = sum(values) / len(values) + + logger.debug(f'Calculated average {mode} {avg}') + + for item in self.selectedItems(): + factor = avg / getattr(item, mode) + item.setScale(factor) + + def normalize_height(self): + return self.normalize_width_or_height('height') + + def normalize_width(self): + return self.normalize_width_or_height('width') + + def normalize_size(self): + sizes = [i.width * i.height for i in self.selectedItems()] + + if not sizes: + return + + avg = sum(sizes) / len(sizes) + logger.debug(f'Calculated average size {avg}') + + for item in self.selectedItems(): + factor = math.sqrt(avg / item.width / item.height) + item.setScale(factor) + + def has_selection(self): + """Checks whether there are currently items selected.""" + + return bool(self.selectedItems()) + + def has_single_selection(self): + """Checks whether there's currently exactly one item selected.""" + + return len(self.selectedItems()) == 1 + + def mousePressEvent(self, event): + if event.button() == Qt.MouseButtons.RightButton: + # Right-click invokes the context menu on the + # GraphicsView. We don't need it here. + return + super().mousePressEvent(event) + + def items_for_export(self): + """Returns the items that are to be exported.""" + + # self.items() holds items in reverse order of addition + return list(filter(lambda i: hasattr(i, 'to_bee_json'), + reversed(self.items()))) diff --git a/beeref/selection.py b/beeref/selection.py new file mode 100644 index 0000000..fafc534 --- /dev/null +++ b/beeref/selection.py @@ -0,0 +1,92 @@ +import logging + +from PyQt6 import QtCore, QtGui, QtWidgets +from PyQt6.QtCore import Qt + + +logger = logging.getLogger('BeeRef') + + +class SelectionItem(QtWidgets.QGraphicsItem): + + color = QtGui.QColor(116, 234, 231, 255) + handle_size = 30 # scaling handles + resize_size = 50 # area for scaling hover events + margin = 200 # margin for bounding box etc + + debug = False + + def __init__(self, item): + super().__init__(parent=item) + self.single_select_mode = False + + bounds = self.parentItem().boundingRect() + pos = bounds.bottomRight() + self.bottom_right_scale_bounds = QtCore.QRectF( + pos.x() - self.resize_size/2, + pos.y() - self.resize_size/2, + self.resize_size, + self.resize_size) + + def boundingRect(self): + bounds = self.parentItem().boundingRect() + return QtCore.QRectF( + bounds.topLeft().x() - self.margin, + bounds.topLeft().y() - self.margin, + bounds.bottomRight().x() + self.margin * 2, + bounds.bottomRight().y() + self.margin * 2) + + def shape(self): + path = QtGui.QPainterPath() + path.addRect(self.boundingRect()) + return path + + def draw_debug_rect(self, painter, rect): + pen = QtGui.QPen(QtGui.QColor('red')) + pen.setWidth(3) + painter.setPen(pen) + painter.drawRect(rect) + + def paint(self, painter, option, widget): + pen = QtGui.QPen(self.color) + pen.setWidth(10) + painter.setPen(pen) + + # Draw the main selection rectangle + bounds = self.parentItem().boundingRect() + painter.drawRect(bounds) + + self.single_select_mode = self.parentItem()\ + .scene().has_single_selection() + + self.setAcceptHoverEvents(self.single_select_mode) + self.setAcceptDrops(self.single_select_mode) + + + # If it's a single selection, draw the handles: + if self.single_select_mode: + self.setFlags( + QtWidgets.QGraphicsItem.GraphicsItemFlags.ItemIsSelectable) + pos = bounds.bottomRight() + painter.fillRect(pos.x() - self.handle_size/2, + pos.y() - self.handle_size/2, + self.handle_size, + self.handle_size, + self.color) + + if self.debug: + self.draw_debug_rect(painter, self.boundingRect()) + self.draw_debug_rect(painter, self.bottom_right_scale_bounds) + + def hoverMoveEvent(self, event): + if not self.single_select_mode: + return + + # In bottomright scale area? + if self.bottom_right_scale_bounds.contains(event.pos()): + self.setCursor(Qt.CursorShape.SizeFDiagCursor) + else: + self.setCursor(Qt.CursorShape.ArrowCursor) + + def dragEnterEvent(self, event): + print('OOOOOOOOOOOOOOOdrag enter') diff --git a/beeref/tests/__init__.py b/beeref/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/beeref/tests/assets/test3x3.png b/beeref/tests/assets/test3x3.png new file mode 100644 index 0000000..0009dac Binary files /dev/null and b/beeref/tests/assets/test3x3.png differ diff --git a/beeref/tests/base.py b/beeref/tests/base.py new file mode 100644 index 0000000..e852e9e --- /dev/null +++ b/beeref/tests/base.py @@ -0,0 +1,14 @@ +from unittest import TestCase + +from PyQt6 import QtWidgets + + +class BeeTestCase(TestCase): + + @classmethod + def setUpClass(cls): + cls.app = QtWidgets.QApplication([]) + + @classmethod + def tearDownClass(cls): + cls.app.quit() diff --git a/beeref/tests/test_bee_json.py b/beeref/tests/test_bee_json.py new file mode 100644 index 0000000..9496972 --- /dev/null +++ b/beeref/tests/test_bee_json.py @@ -0,0 +1,30 @@ +import os.path + +from PyQt6 import QtGui + +from beeref import bee_json +from beeref.items import BeePixmapItem +from beeref.scene import BeeGraphicsScene +from beeref.tests.base import BeeTestCase + + +class BeeJsonTestCase(BeeTestCase): + + def test_dumps_loads(self): + filename = os.path.join('lib', 'tests', 'assets', 'test3x3.png') + item = BeePixmapItem(QtGui.QImage(filename), filename) + item.setScale(2) + item.setPos(100, 200) + item.setZValue(3) + dump = bee_json.dumps({'items': [item]}) + + obj = bee_json.loads(dump) + assert len(obj['items']) == 1 + obj_item = obj['items'][0] + assert obj_item.scale_factor == 2 + assert obj_item.zValue() == 3 + assert obj_item.pos().x() == 100 + assert obj_item.pos().y() == 200 + assert obj_item.width == item.width + assert obj_item.height == item.height + assert obj_item.filename == filename diff --git a/beeref/tests/test_items.py b/beeref/tests/test_items.py new file mode 100644 index 0000000..f4594f7 --- /dev/null +++ b/beeref/tests/test_items.py @@ -0,0 +1,80 @@ +import os.path + +from PyQt6 import QtGui, QtWidgets + +from beeref.items import BeePixmapItem +from beeref.tests.base import BeeTestCase + + +class BeePixmapItemTestCase(BeeTestCase): + + def test_init(self): + filename = os.path.join('beeref', 'tests', 'assets', 'test3x3.png') + item = BeePixmapItem(QtGui.QImage(filename), filename) + assert item.width == 3 + assert item.height == 3 + assert item.scale_factor == 1 + assert item.flags() == ( + QtWidgets.QGraphicsItem.GraphicsItemFlags.ItemIsMovable + | QtWidgets.QGraphicsItem.GraphicsItemFlags.ItemIsSelectable) + assert item.filename == filename + + +class BeePixmapItemToBeeJsonTestCase(BeeTestCase): + + def test_basic(self): + item = BeePixmapItem(QtGui.QImage(), 'bee.png') + assert item.to_bee_json() == { + 'cls': 'BeePixmapItem', + 'scale': 1, + 'pos': [0.0, 0.0], + 'z': 0.0, + 'pixmap': '', + 'filename': 'bee.png', + } + + def test_scale(self): + item = BeePixmapItem(QtGui.QImage()) + item.setScale(2) + beejson = item.to_bee_json() + assert beejson['scale'] == 2 + + def test_position(self): + item = BeePixmapItem(QtGui.QImage()) + item.setPos(100, 200) + beejson = item.to_bee_json() + assert beejson['pos'] == [100, 200] + + def test_z_value(self): + item = BeePixmapItem(QtGui.QImage()) + item.setZValue(3) + beejson = item.to_bee_json() + assert beejson['z'] == 3.0 + + def test_pixmap(self): + filename = os.path.join('beeref', 'tests', 'assets', 'test3x3.png') + item = BeePixmapItem(QtGui.QImage(filename)) + beejson = item.to_bee_json() + assert len(beejson['pixmap']) > 0 + + +class BeePixmapItemFromBeeJsonTestCase(BeeTestCase): + + def test_basic(self): + bee_json = { + 'cls': 'BeePixmapItem', + 'scale': 2, + 'pos': [100.0, 200.0], + 'z': 3.0, + 'pixmap': '', + 'filename': 'bee.png', + } + + item = BeePixmapItem.from_bee_json(bee_json) + + assert isinstance(item, BeePixmapItem) + assert item.scale_factor == 2 + assert item.pos().x() == 100.0 + assert item.pos().y() == 200.0 + assert item.zValue() == 3.0 + assert item.filename == 'bee.png' diff --git a/beeref/tests/test_scene.py b/beeref/tests/test_scene.py new file mode 100644 index 0000000..4be81de --- /dev/null +++ b/beeref/tests/test_scene.py @@ -0,0 +1,47 @@ +import math +from unittest.mock import patch, MagicMock + +from PyQt6 import QtGui + +from beeref.items import BeePixmapItem +from beeref.scene import BeeGraphicsScene +from beeref.tests.base import BeeTestCase + + +class BeeGraphicsSceneNormalizeTestCase(BeeTestCase): + + def test_normalize_height(self): + item1 = MagicMock(height=100) + item2 = MagicMock(height=200) + scene = BeeGraphicsScene() + + with patch.object(scene, 'selectedItems', + return_value=[item1, item2]): + scene.normalize_height() + + item1.setScale.assert_called_once_with(1.5) + item2.setScale.assert_called_once_with(0.75) + + def test_normalize_width(self): + item1 = MagicMock(width=100) + item2 = MagicMock(width=200) + scene = BeeGraphicsScene() + + with patch.object(scene, 'selectedItems', + return_value=[item1, item2]): + 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() + + with patch.object(scene, 'selectedItems', + return_value=[item1, item2]): + scene.normalize_size() + + item1.setScale.assert_called_once_with(math.sqrt(1.5)) + item2.setScale.assert_called_once_with(math.sqrt(0.75)) diff --git a/beeref/view.py b/beeref/view.py new file mode 100644 index 0000000..5725ffe --- /dev/null +++ b/beeref/view.py @@ -0,0 +1,397 @@ +import logging + +from PyQt6 import QtCore, QtGui, QtWidgets +from PyQt6.QtCore import Qt + +from beeref import bee_json +from beeref.gui import WelcomeOverlay +from beeref.items import BeePixmapItem +from beeref.scene import BeeGraphicsScene + + +logger = logging.getLogger('BeeRef') + + +class BeeGraphicsView(QtWidgets.QGraphicsView): + + def __init__(self, app, parent=None, filename=None): + super().__init__(parent) + self.app = app + + self.scene = BeeGraphicsScene() + self.filename = None + + # TBD: make scrollbar configurable + self.setHorizontalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.setVerticalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.setFrameShape(QtWidgets.QFrame.Shape.NoFrame) + self.setAcceptDrops(True) + + # TBD: fix zoom anchor - why does this not work? + # self.setTransformationAnchor( + # QtWidgets.QGraphicsView.ViewportAnchor.AnchorUnderMouse) + + self.pan_active = False + self.scene.changed.connect(self.on_scene_changed) + self.scene.selectionChanged.connect(self.on_selection_changed) + + self.setScene(self.scene) + + # Context menu and actions + self.setContextMenuPolicy( + Qt.ContextMenuPolicy.CustomContextMenu) + self.customContextMenuRequested.connect(self.on_context_menu) + self.context_menu = QtWidgets.QMenu(self) + self.build_actions() + + self.welcome_overlay = WelcomeOverlay(self) + + # Load file given via command line + if filename: + self.open_from_file(filename) + + def on_scene_changed(self, region): + if not self.scene.items(): + logger.info('No items in scene') + self.welcome_overlay.show() + else: + self.welcome_overlay.hide() + self.recalc_scene_rect() + + def build_actions(self): + + self.actions_active_when_selection = [] + + def add_to_menu(menu, actions): + for action in actions: + qaction = QtGui.QAction(action['text'], self) + if 'shortcuts' in action: + qaction.setShortcuts(action['shortcuts']) + qaction.triggered.connect(action['callback']) + self.addAction(qaction) + menu.addAction(qaction) + if 'group' in action: + action['group'].append(qaction) + qaction.setEnabled(action.get('enabled', True)) + + # File menu + actions = [ + { + 'text': '&Open', + 'shortcuts': ['Ctrl+O'], + 'callback': self.on_action_open, + }, + { + 'text': '&Save', + 'shortcuts': ['Ctrl+S'], + 'callback': self.on_action_save, + }, + { + 'text': 'Save &As...', + 'shortcuts': ['Ctrl+Shift+S'], + 'callback': self.on_action_save_as, + }, + { + 'text': '&Quit...', + 'shortcuts': ['Ctrl+Q'], + 'callback': self.on_action_quit, + }, + ] + add_to_menu(self.context_menu.addMenu('&File'), actions) + + # Add menu + actions = [ + { + 'text': 'Images from &files...', + 'shortcuts': ['Ctrl+I'], + 'callback': self.on_action_load_images, + }, + { + 'text': 'Image from &clipboard...', + 'shortcuts': ['Ctrl+V'], + 'callback': self.on_action_paste_images, + }, + + ] + add_to_menu(self.context_menu.addMenu('&Add'), actions) + + # Edit menu + actions = [ + { + 'text': '&Delete', + 'shortcuts': ['Del'], + 'callback': self.on_action_delete_images, + 'group': self.actions_active_when_selection, + 'enabled': False, + }, + ] + items_menu = self.context_menu.addMenu('&Items') + add_to_menu(items_menu, actions) + + actions = [ + { + 'text': '&Height', + 'shortcuts': ['Shift+H'], + 'callback': self.on_action_normalize_height, + 'group': self.actions_active_when_selection, + 'enabled': False, + }, + { + 'text': '&Width', + 'shortcuts': ['Shift+W'], + 'callback': self.on_action_normalize_width, + 'group': self.actions_active_when_selection, + 'enabled': False, + }, + { + 'text': '&Size', + 'shortcuts': ['Shift+S'], + 'callback': self.on_action_normalize_size, + 'group': self.actions_active_when_selection, + 'enabled': False, + }, + ] + add_to_menu(items_menu.addMenu('&Normalize'), actions) + + def on_context_menu(self, point): + self.context_menu.exec(self.mapToGlobal(point)) + + def get_supported_image_formats(self, cls): + formats = map(lambda f: f'*.{f.data().decode()}', + cls.supportedImageFormats()) + return ' '.join(formats) + + def get_view_center(self): + return QtCore.QPoint(self.size().width() / 2, + self.size().height() / 2) + + def on_action_delete_images(self): + self.scene.delete_selected_items() + + def on_action_normalize_height(self): + self.scene.normalize_height() + + def on_action_normalize_width(self): + self.scene.normalize_width() + + def on_action_normalize_size(self): + self.scene.normalize_size() + + def add_image(self, img, pos, filename=None): + item = BeePixmapItem(img, filename) + item.set_pos_center(pos.x(), pos.y()) + item.setSelected(True) + self.scene.addItem(item) + + def save_to_file(self, filename): + logging.info(f'Saving to file {filename}') + with open(filename, 'w') as f: + f.write(bee_json.dumps({'items': self.scene.items_for_export()})) + self.filename = filename + + def open_from_file(self, filename): + logging.info(f'Opening file {filename}') + with open(filename, 'r') as f: + items = bee_json.loads(f.read())['items'] + self.scene.clear() + for item in items: + self.scene.addItem(item) + self.filename = filename + + def on_action_open(self): + filename, f = QtWidgets.QFileDialog.getOpenFileName( + parent=self, + caption='Open file', + filter='BeeRef File (*.bee)') + if filename: + self.open_from_file(filename) + + def on_action_save_as(self): + filename, f = QtWidgets.QFileDialog.getSaveFileName( + parent=self, + caption='Save file', + filter='BeeRef File (*.bee)') + if filename: + if not filename.endswith('.bee'): + filename = f'{filename}.bee' + self.save_to_file(filename) + + def on_action_save(self): + if not self.filename: + self.on_action_save_as() + else: + self.save_to_file(self.filename) + + def on_action_quit(self): + logger.info('User quit. Exiting...') + self.app.quit() + + def on_action_load_images(self): + formats = self.get_supported_image_formats(QtGui.QImageReader) + filenames, f = QtWidgets.QFileDialog.getOpenFileNames( + parent=self, + caption='Select one ore more images to open', + filter=f'Images ({formats})') + + if filenames: + self.scene.clearSelection() + + pos = self.mapToScene(self.get_view_center()) + errors = [] + for filename in filenames: + logger.info(f'Loading image from file {filename}') + img = QtGui.QImage(filename) + if img.isNull(): + errors.append(filename) + continue + self.add_image(img, pos, filename) + pos.setX(pos.x() + 50) + pos.setY(pos.y() + 50) + + if errors: + errornames = [ + f'