mirror of
https://github.com/rbreu/beeref.git
synced 2026-03-11 08:54:28 +00:00
Missing lib directory
This commit is contained in:
parent
3e76f86822
commit
b3a322ebd7
14 changed files with 903 additions and 1 deletions
|
|
@ -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')
|
||||
|
|
|
|||
0
beeref/__init__.py
Normal file
0
beeref/__init__.py
Normal file
38
beeref/bee_json.py
Normal file
38
beeref/bee_json.py
Normal file
|
|
@ -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)
|
||||
24
beeref/gui.py
Normal file
24
beeref/gui.py
Normal file
|
|
@ -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 = """<p>Paste or drop images here.</p>
|
||||
<p>Right-click for more options.</p>"""
|
||||
|
||||
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)
|
||||
109
beeref/items.py
Normal file
109
beeref/items.py
Normal file
|
|
@ -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)
|
||||
71
beeref/scene.py
Normal file
71
beeref/scene.py
Normal file
|
|
@ -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())))
|
||||
92
beeref/selection.py
Normal file
92
beeref/selection.py
Normal file
|
|
@ -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')
|
||||
0
beeref/tests/__init__.py
Normal file
0
beeref/tests/__init__.py
Normal file
BIN
beeref/tests/assets/test3x3.png
Normal file
BIN
beeref/tests/assets/test3x3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 158 B |
14
beeref/tests/base.py
Normal file
14
beeref/tests/base.py
Normal file
|
|
@ -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()
|
||||
30
beeref/tests/test_bee_json.py
Normal file
30
beeref/tests/test_bee_json.py
Normal file
|
|
@ -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
|
||||
80
beeref/tests/test_items.py
Normal file
80
beeref/tests/test_items.py
Normal file
|
|
@ -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'
|
||||
47
beeref/tests/test_scene.py
Normal file
47
beeref/tests/test_scene.py
Normal file
|
|
@ -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))
|
||||
397
beeref/view.py
Normal file
397
beeref/view.py
Normal file
|
|
@ -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'<li>{fn}</li>' for fn in errors]
|
||||
errornames = '<ul>%s</ul>' % '\n'.join(errornames)
|
||||
msg = ('{errors} image(s) out of {total} '
|
||||
'could not be opened:'.format(
|
||||
errors=len(errors), total=len(filenames)))
|
||||
QtWidgets.QMessageBox.warning(
|
||||
self,
|
||||
'Problem loading images',
|
||||
msg + errornames)
|
||||
|
||||
def on_action_paste_images(self):
|
||||
logging.info('Pasting image from clipboard...')
|
||||
clipboard = QtWidgets.QApplication.clipboard()
|
||||
img = clipboard.image()
|
||||
if img.isNull():
|
||||
logging.info('No image data in clipboard')
|
||||
else:
|
||||
self.scene.clearSelection()
|
||||
pos = self.mapToScene(
|
||||
self.mapFromGlobal(self.cursor().pos()))
|
||||
self.add_image(img, pos)
|
||||
|
||||
def on_selection_changed(self):
|
||||
for action in self.actions_active_when_selection:
|
||||
action.setEnabled(self.scene.has_selection())
|
||||
self.viewport().repaint()
|
||||
|
||||
def recalc_scene_rect(self):
|
||||
"""Resize the scene rectangle so that it is always one view width
|
||||
wider than all items' bounding box at each side and one view
|
||||
width higher on top and bottom. This gives the impression of
|
||||
an infinite canvas."""
|
||||
|
||||
logger.debug('Recalculating scene rectangle...')
|
||||
try:
|
||||
topleft = self.mapFromScene(
|
||||
self.scene.itemsBoundingRect().topLeft())
|
||||
topleft = self.mapToScene(QtCore.QPoint(
|
||||
topleft.x() - self.size().width(),
|
||||
topleft.y() - self.size().height()))
|
||||
bottomright = self.mapFromScene(
|
||||
self.scene.itemsBoundingRect().bottomRight())
|
||||
bottomright = self.mapToScene(QtCore.QPoint(
|
||||
bottomright.x() + self.size().width(),
|
||||
bottomright.y() + self.size().height()))
|
||||
self.setSceneRect(QtCore.QRectF(topleft, bottomright))
|
||||
except OverflowError:
|
||||
logger.info('Maximum scene size reached')
|
||||
|
||||
def get_zoom_size(self, func):
|
||||
"""Calculates the size of the current items' bounding box
|
||||
in the view's coordinates.
|
||||
|
||||
This helps ensure that we never zoom out too much (scene
|
||||
becomes so tiny that they become invisible) or zoom in too
|
||||
much (causing overflow errors).
|
||||
|
||||
`func` is a function witch takes the width and height as
|
||||
arguments and turns it into a number, for ex. `min` or `max`.
|
||||
"""
|
||||
|
||||
topleft = self.mapFromScene(
|
||||
self.scene.itemsBoundingRect().topLeft())
|
||||
bottomright = self.mapFromScene(
|
||||
self.scene.itemsBoundingRect().bottomRight())
|
||||
return func(bottomright.x() - topleft.x(),
|
||||
bottomright.y() - topleft.y())
|
||||
|
||||
def scale(self, *args, **kwargs):
|
||||
super().scale(*args, **kwargs)
|
||||
self.recalc_scene_rect()
|
||||
|
||||
def wheelEvent(self, event):
|
||||
factor = 1.2
|
||||
if event.angleDelta().y() > 0:
|
||||
if self.get_zoom_size(max) < 10000000:
|
||||
self.scale(factor, factor)
|
||||
else:
|
||||
logging.debug('Maximum zoom size reached')
|
||||
else:
|
||||
if self.get_zoom_size(min) > 50:
|
||||
self.scale(1/factor, 1/factor)
|
||||
else:
|
||||
logging.debug('Minimum zoom size reached')
|
||||
event.accept()
|
||||
|
||||
def mouseMoveEvent(self, event):
|
||||
if self.pan_active:
|
||||
point = event.position()
|
||||
hscroll = self.horizontalScrollBar()
|
||||
hscroll.setValue(hscroll.value() + self.pan_start.x() - point.x())
|
||||
vscroll = self.verticalScrollBar()
|
||||
vscroll.setValue(vscroll.value() + self.pan_start.y() - point.y())
|
||||
self.pan_start = point
|
||||
event.accept()
|
||||
return
|
||||
|
||||
super().mouseMoveEvent(event)
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
if (event.button() == Qt.MouseButtons.MiddleButton
|
||||
or (event.button() == Qt.MouseButtons.LeftButton
|
||||
and event.modifiers() == Qt.KeyboardModifiers.AltModifier)):
|
||||
self.pan_active = True
|
||||
self.pan_start = event.position()
|
||||
self.setCursor(Qt.CursorShape.ClosedHandCursor)
|
||||
event.accept()
|
||||
return
|
||||
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def mouseReleaseEvent(self, event):
|
||||
if self.pan_active:
|
||||
self.setCursor(Qt.CursorShape.ArrowCursor)
|
||||
self.pan_active = False
|
||||
event.accept()
|
||||
return
|
||||
|
||||
super().mouseReleaseEvent(event)
|
||||
|
||||
def resizeEvent(self, event):
|
||||
super().resizeEvent(event)
|
||||
self.recalc_scene_rect()
|
||||
self.welcome_overlay.resize(self.size())
|
||||
|
||||
def dragEnterEvent(self, event):
|
||||
logging.debug('Received drag enter event')
|
||||
|
||||
# tbd: always empty???
|
||||
print(event.mimeData().formats())
|
||||
print(dir(event))
|
||||
if event.mimeData().hasImage():
|
||||
event.acceptProposedAction()
|
||||
else:
|
||||
logging.info('Attempted drop not an image')
|
||||
|
||||
def dragMoveEvent(self, event):
|
||||
event.acceptProposedAction()
|
||||
|
||||
def dropEvent(self, event):
|
||||
logging.info('Handling file drop...')
|
||||
print(event.mimeData().formats())
|
||||
# tbd
|
||||
Loading…
Reference in a new issue