diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 0101b39..e91fc9b 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -1,6 +1,11 @@
0.2.0 - unreleased
==================
+Added
+-----
+
+* You can now add plain text notes
+
Changed
-------
diff --git a/beeref/actions/actions.py b/beeref/actions/actions.py
index d39f1cf..5327fac 100644
--- a/beeref/actions/actions.py
+++ b/beeref/actions/actions.py
@@ -34,7 +34,7 @@ actions = [
},
{
'id': 'quit',
- 'text': '&Quit...',
+ 'text': '&Quit',
'shortcuts': ['Ctrl+Q'],
'callback': 'on_action_quit',
},
@@ -44,6 +44,12 @@ actions = [
'shortcuts': ['Ctrl+I'],
'callback': 'on_action_insert_images',
},
+ {
+ 'id': 'insert_text',
+ 'text': '&Text',
+ 'shortcuts': ['Ctrl+T'],
+ 'callback': 'on_action_insert_text',
+ },
{
'id': 'undo',
'text': '&Undo',
diff --git a/beeref/actions/menu_structure.py b/beeref/actions/menu_structure.py
index a25405b..870c69d 100644
--- a/beeref/actions/menu_structure.py
+++ b/beeref/actions/menu_structure.py
@@ -67,6 +67,7 @@ menu_structure = [
'menu': '&Insert',
'items': [
'insert_images',
+ 'insert_text',
],
},
{
diff --git a/beeref/constants.py b/beeref/constants.py
index 6fc9d6c..d1be05b 100644
--- a/beeref/constants.py
+++ b/beeref/constants.py
@@ -36,4 +36,5 @@ COLORS = {
# BeeRef specific:
'Scene:Selection': (116, 234, 231),
'Scene:Canvas': (60, 60, 60),
+ 'Scene:Text': (200, 200, 200)
}
diff --git a/beeref/documentation/controls.html b/beeref/documentation/controls.html
index c8473f9..0740be6 100644
--- a/beeref/documentation/controls.html
+++ b/beeref/documentation/controls.html
@@ -42,5 +42,14 @@
Move Window
- Ctrl + Alt + Left Cick + Drag
+ Ctrl + Alt + Left Click + Drag
+
+
+
+Edit Text
+
+
+ Double Click on Text
+ Press Enter or click elsewhere to stop editing
+ Shift + Enter for newline
diff --git a/beeref/fileio/__init__.py b/beeref/fileio/__init__.py
index 69bfd74..b6f908c 100644
--- a/beeref/fileio/__init__.py
+++ b/beeref/fileio/__init__.py
@@ -65,12 +65,15 @@ def load_images(filenames, pos, scene, worker):
logger.info(f'Could not load file {filename}')
errors.append(filename)
continue
+
item = BeePixmapItem(img, filename)
item.set_pos_center(pos)
- scene.add_item_later(item, selected=True)
+ scene.add_item_later({'item': item, 'type': 'pixmap'}, selected=True)
items.append(item)
if worker.canceled:
break
+ # Give main thread time to process items:
+ worker.msleep(10)
scene.undo_stack.push(
commands.InsertItems(scene, items, ignore_first_redo=True))
diff --git a/beeref/fileio/image.py b/beeref/fileio/image.py
index 239c369..91c4116 100644
--- a/beeref/fileio/image.py
+++ b/beeref/fileio/image.py
@@ -40,8 +40,8 @@ def exif_rotated_image(path=None):
with open(path, 'rb') as f:
try:
exifimg = exif.Image(f)
- except plum.exceptions.UnpackError:
- logger.info(f'Exif parser failed on image: {path}')
+ except (plum.exceptions.UnpackError, NotImplementedError):
+ logger.exception(f'Exif parser failed on image: {path}')
return img
if 'orientation' in exifimg.list_all():
diff --git a/beeref/fileio/schema.py b/beeref/fileio/schema.py
index d486b33..5ea3852 100644
--- a/beeref/fileio/schema.py
+++ b/beeref/fileio/schema.py
@@ -1,3 +1,7 @@
+USER_VERSION = 2
+APPLICATION_ID = 2060242126
+
+
SCHEMA = [
"""
CREATE TABLE items (
@@ -9,7 +13,7 @@ SCHEMA = [
scale REAL DEFAULT 1,
rotation REAL DEFAULT 0,
flip INTEGER DEFAULT 1,
- filename TEXT
+ data JSON
)
""",
"""
@@ -27,3 +31,11 @@ SCHEMA = [
)
""",
]
+
+
+MIGRATIONS = {
+ 2: [
+ "ALTER TABLE items ADD COLUMN data JSON",
+ "UPDATE items SET data = json_object('filename', filename)",
+ ],
+}
diff --git a/beeref/fileio/sql.py b/beeref/fileio/sql.py
index f81d456..e78eb98 100644
--- a/beeref/fileio/sql.py
+++ b/beeref/fileio/sql.py
@@ -23,16 +23,20 @@ https://www.sqlite.org/appfileformat.html
https://www.sqlite.org/sqlar.html
"""
+import json
import logging
import os
import pathlib
+import shutil
import sqlite3
+import tempfile
from PyQt6 import QtGui
+from beeref import constants
from beeref.items import BeePixmapItem
from .errors import BeeFileIOError
-from .schema import SCHEMA
+from .schema import SCHEMA, USER_VERSION, MIGRATIONS, APPLICATION_ID
logger = logging.getLogger(__name__)
@@ -54,8 +58,6 @@ def handle_sqlite_errors(func):
class SQLiteIO:
- USER_VERSION = 1
- APPLICATION_ID = 2060242126
def __init__(self, filename, scene, create_new=False, readonly=False,
worker=None):
@@ -74,6 +76,9 @@ class SQLiteIO:
delattr(self, '_connection')
if hasattr(self, '_cursor'):
delattr(self, '_cursor')
+ if hasattr(self, '_tmpdir'):
+ self._tmpdir.cleanup()
+ delattr(self, '_tmpdir')
def _establish_connection(self):
if (self.create_new
@@ -86,9 +91,42 @@ class SQLiteIO:
uri = pathlib.Path(self.filename).resolve().as_uri()
if self.readonly:
- uri = f'{uri}?mode=ro'
+ uri = f'{uri}?mode=rw'
self._connection = sqlite3.connect(uri, uri=True)
self._cursor = self.connection.cursor()
+ if not self.create_new:
+ self._migrate()
+
+ def _migrate(self):
+ """Migrate database if necessary."""
+
+ version = self.fetchone('PRAGMA user_version')[0]
+ logger.debug(f'Found bee file version: {version}')
+ if version == USER_VERSION:
+ logger.debug('Version ok; no migrations necessary')
+ return
+
+ if self.readonly:
+ try:
+ # See whether file is writable so we can migrate it directly
+ self.ex('PRAGMA application_id=%s' % APPLICATION_ID)
+ except sqlite3.Error:
+ logger.debug('File not writable; use temporary copy instead')
+ self._connection.close()
+ self._tmpdir = tempfile.TemporaryDirectory(
+ prefix=constants.APPNAME)
+ tmpname = os.path.join(self._tmpdir.name, 'mig.bee')
+ shutil.copyfile(self.filename, tmpname)
+ self._connection = sqlite3.connect(tmpname)
+ self._cursor = self.connection.cursor()
+
+ for i in range(version, USER_VERSION):
+ logger.debug(f'Migrating from version {i} to {i + 1}...')
+ for migration in MIGRATIONS[i + 1]:
+ self.ex(migration)
+ self.write_meta()
+ self.connection.commit()
+ logger.debug('Migration finished')
@property
def connection(self):
@@ -117,47 +155,61 @@ class SQLiteIO:
return self.cursor.fetchall()
def write_meta(self):
- self.ex('PRAGMA application_id=%s' % self.APPLICATION_ID)
- self.ex('PRAGMA user_version=%s' % self.USER_VERSION)
+ self.ex('PRAGMA application_id=%s' % APPLICATION_ID)
+ self.ex('PRAGMA user_version=%s' % USER_VERSION)
self.ex('PRAGMA foreign_keys=1')
def create_schema_on_new(self):
if self.create_new:
+ self.write_meta()
for schema in SCHEMA:
self.ex(schema)
@handle_sqlite_errors
def read(self):
rows = self.fetchall(
- 'SELECT items.id, x, y, z, scale, rotation, flip, filename, '
- 'sqlar.data '
- 'FROM items INNER JOIN sqlar on sqlar.item_id = items.id')
+ 'SELECT items.id, type, x, y, z, scale, rotation, flip, '
+ 'items.data, sqlar.data '
+ 'FROM items LEFT OUTER JOIN sqlar on sqlar.item_id = items.id')
if self.worker:
self.worker.begin_processing.emit(len(rows))
for i, row in enumerate(rows):
- item = BeePixmapItem(QtGui.QImage(), filename=row[7])
- item.save_id = row[0]
- item.pixmap_from_bytes(row[8])
- item.setPos(row[1], row[2])
- item.setZValue(row[3])
- item.setScale(row[4])
- item.setRotation(row[5])
- if row[6] == -1:
- item.do_flip()
- self.scene.add_item_later(item)
+ data = {
+ 'save_id': row[0],
+ 'type': row[1],
+ 'x': row[2],
+ 'y': row[3],
+ 'z': row[4],
+ 'scale': row[5],
+ 'rotation': row[6],
+ 'flip': row[7],
+ 'data': json.loads(row[8]),
+ }
+
+ if data['type'] == 'pixmap':
+ data['item'] = BeePixmapItem(QtGui.QImage(), **data['data'])
+ data['item'].pixmap_from_bytes(row[9])
+
+ self.scene.add_item_later(data)
+
if self.worker:
+ logger.trace(f'Emit progress: {i}')
self.worker.progress.emit(i)
if self.worker.canceled:
self.worker.finished.emit('', [])
return
+ # Give main thread time to process items:
+ self.worker.msleep(10)
if self.worker:
self.worker.finished.emit(self.filename, [])
@handle_sqlite_errors
def write(self):
+ if self.readonly:
+ raise sqlite3.OperationalError(
+ 'attempt to write a readonly database')
try:
- self.write_meta()
self.create_schema_on_new()
self.write_data()
except sqlite3.Error:
@@ -198,23 +250,26 @@ class SQLiteIO:
def insert_item(self, item):
self.ex(
'INSERT INTO items (type, x, y, z, scale, rotation, flip, '
- 'filename) '
+ 'data) '
'VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
- ('pixmap', item.pos().x(), item.pos().y(), item.zValue(),
- item.scale(), item.rotation(), item.flip(), item.filename))
+ (item.TYPE, item.pos().x(), item.pos().y(), item.zValue(),
+ item.scale(), item.rotation(), item.flip(),
+ json.dumps(item.get_extra_save_data())))
item.save_id = self.cursor.lastrowid
- pixmap = item.pixmap_to_bytes()
- if item.filename:
- basename = os.path.splitext(os.path.basename(item.filename))[0]
- name = '%04d-%s.png' % (item.save_id, basename)
- else:
- name = '%04d.png' % item.save_id
+ if hasattr(item, 'pixmap_to_bytes'):
+ pixmap = item.pixmap_to_bytes()
- self.ex(
- 'INSERT INTO sqlar (item_id, name, mode, sz, data) '
- 'VALUES (?, ?, ?, ?, ?)',
- (item.save_id, name, 0o644, len(pixmap), pixmap))
+ if item.filename:
+ basename = os.path.splitext(os.path.basename(item.filename))[0]
+ name = '%04d-%s.png' % (item.save_id, basename)
+ else:
+ name = '%04d.png' % item.save_id
+
+ self.ex(
+ 'INSERT INTO sqlar (item_id, name, mode, sz, data) '
+ 'VALUES (?, ?, ?, ?, ?)',
+ (item.save_id, name, 0o644, len(pixmap), pixmap))
self.connection.commit()
def update_item(self, item):
@@ -225,8 +280,10 @@ class SQLiteIO:
"""
self.ex(
'UPDATE items SET x=?, y=?, z=?, scale=?, rotation=?, flip=?, '
- 'filename=? '
+ 'data=? '
'WHERE id=?',
(item.pos().x(), item.pos().y(), item.zValue(), item.scale(),
- item.rotation(), item.flip(), item.filename, item.save_id))
+ item.rotation(), item.flip(),
+ json.dumps(item.get_extra_save_data()),
+ item.save_id))
self.connection.commit()
diff --git a/beeref/items.py b/beeref/items.py
index 81c26d5..2cd0c28 100644
--- a/beeref/items.py
+++ b/beeref/items.py
@@ -20,16 +20,64 @@ text).
import logging
from PyQt6 import QtCore, QtGui, QtWidgets
+from PyQt6.QtCore import Qt
+from beeref.constants import COLORS
from beeref.selection import SelectableMixin
logger = logging.getLogger(__name__)
+item_registry = {}
-class BeePixmapItem(SelectableMixin, QtWidgets.QGraphicsPixmapItem):
+
+def register_item(cls):
+ item_registry[cls.TYPE] = cls
+ return cls
+
+
+class BeeItemMixin(SelectableMixin):
+ """Base for all items added by the user."""
+
+ def set_pos_center(self, pos):
+ """Sets the position using the item's center as the origin point."""
+
+ self.setPos(pos - self.center_scene_coords)
+
+ def has_selection_outline(self):
+ return self.isSelected()
+
+ def has_selection_handles(self):
+ return self.isSelected() and self.scene().has_single_selection()
+
+ def selection_action_items(self):
+ """The items affected by selection actions like scaling and rotating.
+ """
+ return [self]
+
+ def on_selected_change(self, value):
+ if (value and self.scene()
+ and not self.scene().has_selection()
+ and not self.scene().rubberband_active):
+ self.bring_to_front()
+
+ def update_from_data(self, **kwargs):
+ self.save_id = kwargs.get('save_id', self.save_id)
+ self.setPos(kwargs.get('x', self.pos().x()),
+ kwargs.get('y', self.pos().y()))
+ self.setZValue(kwargs.get('z', self.zValue()))
+ self.setScale(kwargs.get('scale', self.scale()))
+ self.setRotation(kwargs.get('rotation', self.rotation()))
+ if kwargs.get('flip') != self.flip():
+ self.do_flip()
+
+
+@register_item
+class BeePixmapItem(BeeItemMixin, QtWidgets.QGraphicsPixmapItem):
"""Class for images added by the user."""
+ TYPE = 'pixmap'
+
def __init__(self, image, filename=None):
super().__init__(QtGui.QPixmap.fromImage(image))
self.save_id = None
@@ -37,14 +85,16 @@ class BeePixmapItem(SelectableMixin, QtWidgets.QGraphicsPixmapItem):
logger.debug(f'Initialized {self}')
self.init_selectable()
+ @classmethod
+ def create_from_data(self, **kwargs):
+ item = kwargs.pop('item')
+ data = kwargs.pop('data', {})
+ item.filename = item.filename or data.get('filename')
+ return item
+
def __str__(self):
return (f'Image "{self.filename}" {self.width} x {self.height}')
- def set_pos_center(self, pos):
- """Sets the position using the item's center as the origin point."""
-
- self.setPos(pos - self.center_scene_coords)
-
@property
def width(self):
return self.pixmap().size().width()
@@ -53,6 +103,9 @@ class BeePixmapItem(SelectableMixin, QtWidgets.QGraphicsPixmapItem):
def height(self):
return self.pixmap().size().height()
+ def get_extra_save_data(self):
+ return {'filename': self.filename}
+
def pixmap_to_bytes(self):
"""Convert the pixmap data to PNG bytestring."""
barray = QtCore.QByteArray()
@@ -72,23 +125,6 @@ class BeePixmapItem(SelectableMixin, QtWidgets.QGraphicsPixmapItem):
painter.drawPixmap(0, 0, self.pixmap())
self.paint_selectable(painter, option, widget)
- def has_selection_outline(self):
- return self.isSelected()
-
- def has_selection_handles(self):
- return self.isSelected() and self.scene().has_single_selection()
-
- def selection_action_items(self):
- """The items affected by selection actions like scaling and rotating.
- """
- return [self]
-
- def on_selected_change(self, value):
- if (value and self.scene()
- and not self.scene().has_selection()
- and not self.scene().rubberband_active):
- self.bring_to_front()
-
def create_copy(self):
item = BeePixmapItem(QtGui.QImage(), self.filename)
item.setPixmap(self.pixmap())
@@ -99,3 +135,87 @@ class BeePixmapItem(SelectableMixin, QtWidgets.QGraphicsPixmapItem):
if self.flip() == -1:
item.do_flip()
return item
+
+
+@register_item
+class BeeTextItem(BeeItemMixin, QtWidgets.QGraphicsTextItem):
+ """Class for text added by the user."""
+
+ TYPE = 'text'
+
+ def __init__(self, text=None):
+ super().__init__(text or "Text")
+ self.save_id = None
+ logger.debug(f'Initialized {self}')
+ self.init_selectable()
+ self.is_editable = True
+ self.edit_mode = False
+ self.setDefaultTextColor(QtGui.QColor(*COLORS['Scene:Text']))
+
+ @classmethod
+ def create_from_data(cls, **kwargs):
+ data = kwargs.get('data', {})
+ item = cls(**data)
+ return item
+
+ def __str__(self):
+ txt = self.toPlainText()[:40]
+ return (f'Text "{txt}"')
+
+ @property
+ def width(self):
+ return QtWidgets.QGraphicsTextItem.boundingRect(self).width()
+
+ @property
+ def height(self):
+ return QtWidgets.QGraphicsTextItem.boundingRect(self).height()
+
+ def get_extra_save_data(self):
+ return {'text': self.toPlainText()}
+
+ def contains(self, point):
+ return self.boundingRect().contains(point)
+
+ def paint(self, painter, option, widget):
+ painter.setPen(Qt.PenStyle.NoPen)
+ color = QtGui.QColor(0, 0, 0)
+ color.setAlpha(40)
+ brush = QtGui.QBrush(color)
+ painter.setBrush(brush)
+ painter.drawRect(QtWidgets.QGraphicsTextItem.boundingRect(self))
+ option.state = QtWidgets.QStyle.StateFlag.State_Enabled
+ super().paint(painter, option, widget)
+ self.paint_selectable(painter, option, widget)
+
+ def create_copy(self):
+ item = BeeTextItem(self.toPlainText())
+ item.setPos(self.pos())
+ item.setZValue(self.zValue())
+ item.setScale(self.scale())
+ item.setRotation(self.rotation())
+ if self.flip() == -1:
+ item.do_flip()
+ return item
+
+ def enter_edit_mode(self):
+ self.edit_mode = True
+ self.setTextInteractionFlags(
+ Qt.TextInteractionFlag.TextEditorInteraction)
+
+ def exit_edit_mode(self):
+ self.edit_mode = False
+ # reset selection:
+ self.setTextCursor(QtGui.QTextCursor(self.document()))
+ self.setTextInteractionFlags(Qt.TextInteractionFlag.NoTextInteraction)
+
+ def has_selection_handles(self):
+ return super().has_selection_handles() and not self.edit_mode
+
+ def keyPressEvent(self, event):
+ if (event.key() in (Qt.Key.Key_Enter, Qt.Key.Key_Return)
+ and event.modifiers() == Qt.KeyboardModifier.NoModifier):
+ self.exit_edit_mode()
+ self.scene().edit_item = None
+ event.accept()
+ return
+ super().keyPressEvent(event)
diff --git a/beeref/scene.py b/beeref/scene.py
index 3de5eec..c0aa694 100644
--- a/beeref/scene.py
+++ b/beeref/scene.py
@@ -23,6 +23,7 @@ from PyQt6.QtCore import Qt
import rpack
from beeref import commands
+from beeref.items import item_registry
from beeref.selection import MultiSelectItem, RubberbandItem
@@ -45,6 +46,7 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene):
self.changed.connect(self.on_change)
self.items_to_add = Queue()
self.internal_clipboard = []
+ self.edit_item = None
def addItem(self, item):
logger.debug(f'Adding item {item}')
@@ -249,7 +251,17 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene):
if event.button() == Qt.MouseButton.LeftButton:
self.event_start = event.scenePos()
- if self.itemAt(event.scenePos(), self.views()[0].transform()):
+ item_at_pos = self.itemAt(
+ event.scenePos(), self.views()[0].transform())
+
+ if self.edit_item:
+ if item_at_pos != self.edit_item:
+ self.edit_item.exit_edit_mode()
+ self.edit_item = None
+ else:
+ super().mousePressEvent(event)
+ return
+ if item_at_pos:
self.move_active = True
elif self.items():
self.rubberband_active = True
@@ -262,9 +274,14 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene):
self.move_active = False
if not item.isSelected():
item.setSelected(True)
- self.views()[0].fit_rect(
- self.itemsBoundingRect(items=[item]),
- toggle_item=item)
+ if item.is_editable:
+ self.edit_item = item
+ item.enter_edit_mode()
+ self.mousePressEvent(event)
+ else:
+ self.views()[0].fit_rect(
+ self.itemsBoundingRect(items=[item]),
+ toggle_item=item)
return
super().mouseDoubleClickEvent(event)
@@ -381,19 +398,26 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene):
self.multi_select_item.fit_selection_area(
self.itemsBoundingRect(selection_only=True))
- def add_item_later(self, item, selected=False):
- """Keep an item for adding later via ``add_queued_items``"""
+ def add_item_later(self, itemdata, selected=False):
+ """Keep an item for adding later via ``add_queued_items``
- self.items_to_add.put((item, selected))
+ :param dict itemdata: Defines the item's data
+ :param bool selected: Whether the item is initialised as selected
+ """
+
+ self.items_to_add.put((itemdata, selected))
def add_queued_items(self):
"""Adds items added via ``add_items_later``"""
while not self.items_to_add.empty():
- item, selected = self.items_to_add.get()
+ data, selected = self.items_to_add.get()
+ cls = item_registry[data.pop('type')]
+ item = cls.create_from_data(**data)
+ item.update_from_data(**data)
self.addItem(item)
+ # Force recalculation of min/max z values:
+ item.setZValue(item.zValue())
if selected:
item.setSelected(True)
item.bring_to_front()
- self.max_z = max(self.max_z, item.zValue())
- self.min_z = min(self.min_z, item.zValue())
diff --git a/beeref/selection.py b/beeref/selection.py
index 657b4d6..2d9ab8e 100644
--- a/beeref/selection.py
+++ b/beeref/selection.py
@@ -124,6 +124,7 @@ class SelectableMixin(BaseItemMixin):
self.viewport_scale = 1
self.reset_actions()
+ self.is_editable = False
def reset_actions(self):
self.scale_active = False
diff --git a/beeref/utils.py b/beeref/utils.py
index 389e38f..67c5a34 100644
--- a/beeref/utils.py
+++ b/beeref/utils.py
@@ -13,10 +13,6 @@
# You should have received a copy of the GNU General Public License
# along with BeeRef. If not, see .
-import logging.handlers
-import os
-import os.path
-
from PyQt6 import QtCore, QtGui
diff --git a/beeref/view.py b/beeref/view.py
index 62f3af8..8563e5f 100644
--- a/beeref/view.py
+++ b/beeref/view.py
@@ -26,7 +26,7 @@ from beeref.config import CommandlineArgs, BeeSettings
from beeref import constants
from beeref import fileio
from beeref import gui
-from beeref.items import BeePixmapItem
+from beeref.items import BeePixmapItem, BeeTextItem
from beeref.scene import BeeGraphicsScene
@@ -120,8 +120,12 @@ class BeeGraphicsView(QtWidgets.QGraphicsView, ActionsMixin):
self.context_menu.exec(self.mapToGlobal(point))
def get_supported_image_formats(self, cls):
- formats = map(lambda f: f'*.{f.data().decode()}',
- cls.supportedImageFormats())
+ formats = []
+
+ for f in cls.supportedImageFormats():
+ string = f'*.{f.data().decode()}'
+ formats.extend((string, string.upper()))
+ print(formats)
return ' '.join(formats)
def get_view_center(self):
@@ -162,6 +166,7 @@ class BeeGraphicsView(QtWidgets.QGraphicsView, ActionsMixin):
# It seems to be more reliable when we fit a second time
# Sometimes a changing scene rect can mess up the fitting
self.fitInView(rect, Qt.AspectRatioMode.KeepAspectRatio)
+ logger.trace('Fit view done')
def on_action_fit_scene(self):
self.fit_rect(self.scene.itemsBoundingRect())
@@ -281,7 +286,7 @@ class BeeGraphicsView(QtWidgets.QGraphicsView, ActionsMixin):
self.scene.selectedItems(user_only=True)))
def on_items_loaded(self, value):
- logger.debug('On items loded: add queued images')
+ logger.debug('On items loaded: add queued items')
self.scene.add_queued_items()
def on_loading_finished(self, filename, errors):
@@ -413,12 +418,19 @@ class BeeGraphicsView(QtWidgets.QGraphicsView, ActionsMixin):
def on_action_insert_images(self):
formats = self.get_supported_image_formats(QtGui.QImageReader)
+ logger.debug(f'Supported image types for reading: {formats}')
filenames, f = QtWidgets.QFileDialog.getOpenFileNames(
parent=self,
caption='Select one ore more images to open',
filter=f'Images ({formats})')
self.do_insert_images(filenames)
+ def on_action_insert_text(self):
+ item = BeeTextItem()
+ pos = self.mapToScene(self.mapFromGlobal(self.cursor().pos()))
+ item.setScale(1 / self.get_scale())
+ self.undo_stack.push(commands.InsertItems(self.scene, [item], pos))
+
def on_action_copy(self):
logger.debug('Copying to clipboard...')
clipboard = QtWidgets.QApplication.clipboard()
@@ -454,7 +466,13 @@ class BeeGraphicsView(QtWidgets.QGraphicsView, ActionsMixin):
item = BeePixmapItem(img)
self.undo_stack.push(commands.InsertItems(self.scene, [item], pos))
return
- logger.info('No image data in clipboard')
+ text = clipboard.text()
+ if text:
+ item = BeeTextItem(text)
+ item.setScale(1 / self.get_scale())
+ self.undo_stack.push(commands.InsertItems(self.scene, [item], pos))
+ return
+ logger.info('No image data or text in clipboard')
def on_selection_changed(self):
logger.debug('Currently selected items: %s',
@@ -497,7 +515,7 @@ class BeeGraphicsView(QtWidgets.QGraphicsView, ActionsMixin):
much (causing overflow errors).
:param func: Function which takes the width and height as
- arguments and turns it into a number, for ex. ``min`` or ``max``.
+ arguments and turns it into a number, for ex. ``min`` or ``max``.
"""
topleft = self.mapFromScene(
diff --git a/tests/assets/test1item.bee b/tests/assets/test1item.bee
index affb943..7687f6e 100644
Binary files a/tests/assets/test1item.bee and b/tests/assets/test1item.bee differ
diff --git a/tests/conftest.py b/tests/conftest.py
index f71f6ae..6b814d7 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,5 +1,7 @@
import os.path
import pytest
+import uuid
+
from unittest.mock import MagicMock, patch
from PyQt6 import QtGui, QtWidgets
@@ -66,7 +68,7 @@ def imgdata3x3(imgfilename3x3):
@pytest.fixture
def tmpfile(tmpdir):
- yield os.path.join(tmpdir, 'test')
+ yield os.path.join(tmpdir, str(uuid.uuid4()))
@pytest.fixture
diff --git a/tests/fileio/test_image.py b/tests/fileio/test_image.py
index 7754bc7..b39399f 100644
--- a/tests/fileio/test_image.py
+++ b/tests/fileio/test_image.py
@@ -23,8 +23,8 @@ def test_exif_rotated_image_not_a_file(qapp):
def test_exif_rotated_image_exif_unpack_error(qapp, imgfilename3x3):
- with patch('beeref.fileio.image.exif.Image') as exif_mock:
- exif_mock.raise_error = plum.exceptions.UnpackError()
+ with patch('beeref.fileio.image.exif.Image',
+ side_effect=plum.exceptions.UnpackError()):
img = exif_rotated_image(imgfilename3x3)
assert img.isNull() is False
diff --git a/tests/fileio/test_init.py b/tests/fileio/test_init.py
index d3514d2..1d2acb3 100644
--- a/tests/fileio/test_init.py
+++ b/tests/fileio/test_init.py
@@ -33,9 +33,9 @@ def test_load_images_loads(view, imgfilename3x3):
worker.begin_processing.emit.assert_called_once_with(1)
worker.progress.emit.assert_called_once_with(0)
worker.finished.emit.assert_called_once_with('', [])
- items = queue2list(view.scene.items_to_add)
- assert len(items) == 1
- item = items[0][0]
+ itemdata = queue2list(view.scene.items_to_add)
+ assert len(itemdata) == 1
+ item = itemdata[0][0]['item']
args = view.scene.undo_stack.push.call_args_list[0][0]
cmd = args[0]
assert isinstance(cmd, commands.InsertItems)
@@ -53,9 +53,9 @@ def test_load_images_canceled(view, imgfilename3x3):
worker.begin_processing.emit.assert_called_once_with(2)
worker.progress.emit.assert_called_once_with(0)
worker.finished.emit.assert_called_once_with('', [])
- items = queue2list(view.scene.items_to_add)
- assert len(items) == 1
- item = items[0][0]
+ itemdata = queue2list(view.scene.items_to_add)
+ assert len(itemdata) == 1
+ item = itemdata[0][0]['item']
args = view.scene.undo_stack.push.call_args_list[0][0]
cmd = args[0]
assert isinstance(cmd, commands.InsertItems)
@@ -74,9 +74,9 @@ def test_load_images_error(view, imgfilename3x3):
worker.progress.emit.assert_any_call(0)
worker.progress.emit.assert_any_call(1)
worker.finished.emit.assert_called_once_with('', ['foo.jpg'])
- items = queue2list(view.scene.items_to_add)
- assert len(items) == 1
- item = items[0][0]
+ itemdata = queue2list(view.scene.items_to_add)
+ assert len(itemdata) == 1
+ item = itemdata[0][0]['item']
args = view.scene.undo_stack.push.call_args_list[0][0]
cmd = args[0]
assert isinstance(cmd, commands.InsertItems)
diff --git a/tests/fileio/test_sql.py b/tests/fileio/test_sql.py
index 8584a35..77235c7 100644
--- a/tests/fileio/test_sql.py
+++ b/tests/fileio/test_sql.py
@@ -1,26 +1,80 @@
+import json
+import os
import os.path
+import stat
from unittest.mock import MagicMock, patch
from PyQt6 import QtGui
import pytest
+from beeref.fileio import schema
from beeref.fileio.errors import BeeFileIOError
from beeref.fileio.sql import SQLiteIO
-from beeref.items import BeePixmapItem
+from beeref.items import BeePixmapItem, BeeTextItem
+
+
+def test_sqliteio_migrate_does_nothing_when_version_ok(tmpfile):
+ io = SQLiteIO(tmpfile, MagicMock(), create_new=True)
+ io.ex('PRAGMA user_version=%s' % schema.USER_VERSION)
+ io.connection.commit()
+ del(io)
+ with patch('beeref.fileio.sql.SQLiteIO.ex') as ex_mock:
+ SQLiteIO(tmpfile, MagicMock())
+ ex_mock.assert_not_called()
+
+
+@patch('beeref.fileio.sql.USER_VERSION', 3)
+@patch('beeref.fileio.sql.MIGRATIONS', {
+ 2: ['CREATE TABLE foo (col1 INT)',
+ 'CREATE TABLE bar (baz INT)'],
+ 3: ['ALTER TABLE foo ADD COLUMN col2 TEXT']})
+def test_sqliteio_migrate_migrates(tmpfile):
+ io = SQLiteIO(tmpfile, MagicMock(), create_new=True)
+ io.ex('PRAGMA user_version=1')
+ io.connection.commit()
+ del(io)
+ io = SQLiteIO(tmpfile, MagicMock())
+ io.ex('INSERT INTO foo (col1, col2) VALUES (22, "hello world")')
+ io.ex('INSERT INTO bar (baz) VALUES (55)')
+ result = io.fetchone('PRAGMA user_version')
+ assert result[0] == 3
+
+
+@patch('beeref.fileio.sql.USER_VERSION', 3)
+@patch('beeref.fileio.sql.MIGRATIONS', {
+ 2: ['CREATE TABLE foo (col1 INT)',
+ 'CREATE TABLE bar (baz INT)'],
+ 3: ['ALTER TABLE foo ADD COLUMN col2 TEXT']})
+def test_sqliteio_migrate_migrates_when_file_not_writable(tmpfile):
+ io = SQLiteIO(tmpfile, MagicMock(), create_new=True)
+ io.ex('PRAGMA user_version=1')
+ io.connection.commit()
+ del(io)
+ os.chmod(tmpfile, stat.S_IREAD)
+ with pytest.raises(PermissionError):
+ open(tmpfile, 'w')
+ io = SQLiteIO(tmpfile, MagicMock(), readonly=True)
+ io.ex('INSERT INTO foo (col1, col2) VALUES (22, "hello world")')
+ io.ex('INSERT INTO bar (baz) VALUES (55)')
+ result = io.fetchone('PRAGMA user_version')
+ assert result[0] == 3
+ newdir = io._tmpdir.name
+ del(io)
+ assert os.path.exists(newdir) is False
def test_sqliteio_ẁrite_meta_application_id(tmpfile):
io = SQLiteIO(tmpfile, MagicMock(), create_new=True)
io.write_meta()
result = io.fetchone('PRAGMA application_id')
- assert result[0] == SQLiteIO.APPLICATION_ID
+ assert result[0] == schema.APPLICATION_ID
def test_sqliteio_ẁrite_meta_user_version(tmpfile):
io = SQLiteIO(tmpfile, MagicMock(), create_new=True)
io.write_meta()
result = io.fetchone('PRAGMA user_version')
- assert result[0] == SQLiteIO.USER_VERSION
+ assert result[0] == schema.USER_VERSION
def test_sqliteio_ẁrite_meta_foreign_keys(tmpfile):
@@ -41,7 +95,9 @@ def test_sqliteio_create_schema_on_new_when_create_new(tmpfile):
scene_mock.clear_save_ids.assert_called_once()
-def test_sqliteio_create_schema_on_new_when_not_create_new(tmpfile):
+@patch('beeref.fileio.sql.SQLiteIO._migrate')
+def test_sqliteio_create_schema_on_new_when_not_create_new(
+ migrate_mock, tmpfile):
scene_mock = MagicMock()
io = SQLiteIO(tmpfile, scene_mock, create_new=False)
io.create_schema_on_new()
@@ -83,7 +139,36 @@ def test_sqliteio_write_calls_write_meta(tmpfile, view):
metamock.assert_called_once()
-def test_sqliteio_write_inserts_new_item(tmpfile, view):
+def test_sqliteio_write_inserts_new_text_item(tmpfile, view):
+ item = BeeTextItem(text='foo bar')
+ view.scene.addItem(item)
+ item.setScale(1.3)
+ item.setPos(44, 55)
+ item.setZValue(0.22)
+ item.setRotation(33)
+ item.do_flip()
+ io = SQLiteIO(tmpfile, view.scene, create_new=True)
+ io.write()
+
+ assert item.save_id == 1
+ result = io.fetchone(
+ 'SELECT x, y, z, scale, rotation, flip, items.data, type, '
+ 'sqlar.data, sqlar.name '
+ 'FROM items '
+ 'LEFT OUTER JOIN sqlar on sqlar.item_id = items.id')
+ assert result[0] == 44.0
+ assert result[1] == 55.0
+ assert result[2] == 0.22
+ assert result[3] == 1.3
+ assert result[4] == 33
+ assert result[5] == -1
+ assert json.loads(result[6]) == {'text': 'foo bar'}
+ assert result[7] == 'text'
+ assert result[8] is None
+ assert result[9] is None
+
+
+def test_sqliteio_write_inserts_new_pixmap_item(tmpfile, view):
item = BeePixmapItem(QtGui.QImage(), filename='bee.jpg')
view.scene.addItem(item)
item.setScale(1.3)
@@ -97,7 +182,7 @@ def test_sqliteio_write_inserts_new_item(tmpfile, view):
assert item.save_id == 1
result = io.fetchone(
- 'SELECT x, y, z, scale, rotation, flip, filename, type, '
+ 'SELECT x, y, z, scale, rotation, flip, items.data, type, '
'sqlar.data, sqlar.name '
'FROM items '
'INNER JOIN sqlar on sqlar.item_id = items.id')
@@ -107,26 +192,61 @@ def test_sqliteio_write_inserts_new_item(tmpfile, view):
assert result[3] == 1.3
assert result[4] == 33
assert result[5] == -1
- assert result[6] == 'bee.jpg'
+ assert json.loads(result[6]) == {'filename': 'bee.jpg'}
assert result[7] == 'pixmap'
assert result[8] == b'abc'
assert result[9] == '0001-bee.png'
-def test_sqliteio_write_inserts_new_item_without_filename(tmpfile, view, item):
+def test_sqliteio_write_inserts_new_pixmap_item_without_filename(
+ tmpfile, view, item):
view.scene.addItem(item)
io = SQLiteIO(tmpfile, view.scene, create_new=True)
io.write()
assert item.save_id == 1
result = io.fetchone(
- 'SELECT filename, sqlar.name FROM items '
+ 'SELECT items.data, sqlar.name FROM items '
'INNER JOIN sqlar on sqlar.item_id = items.id')
- assert result[0] is None
+ assert json.loads(result[0]) == {'filename': None}
assert result[1] == '0001.png'
-def test_sqliteio_write_updates_existing_item(tmpfile, view):
+def test_sqliteio_write_updates_existing_text_item(tmpfile, view):
+ item = BeeTextItem(text='foo bar')
+ view.scene.addItem(item)
+ item.setScale(1.3)
+ item.setPos(44, 55)
+ item.setZValue(0.22)
+ item.setRotation(33)
+ item.save_id = 1
+ io = SQLiteIO(tmpfile, view.scene, create_new=True)
+ io.write()
+ item.setScale(0.7)
+ item.setPos(20, 30)
+ item.setZValue(0.33)
+ item.setRotation(100)
+ item.do_flip()
+ item.setPlainText('updated')
+ io.create_new = False
+ io.write()
+
+ assert io.fetchone('SELECT COUNT(*) from items') == (1,)
+ result = io.fetchone(
+ 'SELECT x, y, z, scale, rotation, flip, items.data, sqlar.data '
+ 'FROM items '
+ 'LEFT OUTER 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] == 100
+ assert result[5] == -1
+ assert json.loads(result[6]) == {'text': 'updated'}
+ assert result[7] is None
+
+
+def test_sqliteio_write_updates_existing_pixmap_item(tmpfile, view):
item = BeePixmapItem(QtGui.QImage(), filename='bee.png')
view.scene.addItem(item)
item.setScale(1.3)
@@ -149,7 +269,7 @@ def test_sqliteio_write_updates_existing_item(tmpfile, view):
assert io.fetchone('SELECT COUNT(*) from items') == (1,)
result = io.fetchone(
- 'SELECT x, y, z, scale, rotation, flip, filename, sqlar.data '
+ 'SELECT x, y, z, scale, rotation, flip, items.data, sqlar.data '
'FROM items '
'INNER JOIN sqlar on sqlar.item_id = items.id')
assert result[0] == 20
@@ -158,11 +278,27 @@ def test_sqliteio_write_updates_existing_item(tmpfile, view):
assert result[3] == 0.7
assert result[4] == 100
assert result[5] == -1
- assert result[6] == 'new.png'
+ assert json.loads(result[6]) == {'filename': 'new.png'}
assert result[7] == b'abc'
-def test_sqliteio_write_removes_nonexisting_item(tmpfile, view):
+def test_sqliteio_write_removes_nonexisting_text_item(tmpfile, view):
+ item = BeeTextItem('foo bar')
+ item.setScale(1.3)
+ item.setPos(44, 55)
+ view.scene.addItem(item)
+ io = SQLiteIO(tmpfile, view.scene, create_new=True)
+ io.write()
+
+ view.scene.removeItem(item)
+ io.create_new = False
+ io.write()
+
+ assert io.fetchone('SELECT COUNT(*) from items') == (0,)
+ assert io.fetchone('SELECT COUNT(*) from sqlar') == (0,)
+
+
+def test_sqliteio_write_removes_nonexisting_pixmap_item(tmpfile, view):
item = BeePixmapItem(QtGui.QImage(), filename='bee.png')
item.setScale(1.3)
item.setPos(44, 55)
@@ -215,13 +351,42 @@ def test_sqliteio_write_canceled(tmpfile, view):
worker.finished.emit.assert_called_once_with(tmpfile, [])
-def test_sqliteio_read_reads_readonly(tmpfile, view, imgdata3x3):
+def test_sqliteio_read_reads_readonly_text_item(tmpfile, view):
io = SQLiteIO(tmpfile, view.scene, create_new=True)
io.create_schema_on_new()
io.ex('INSERT INTO items '
- '(type, x, y, z, scale, rotation, flip, filename) '
+ '(type, x, y, z, scale, rotation, flip, data) '
'VALUES (?, ?, ?, ?, ?, ?, ?, ?) ',
- ('pixmap', 22.2, 33.3, 0.22, 3.4, 45, -1, 'bee.png'))
+ ('text', 22.2, 33.3, 0.22, 3.4, 45, -1,
+ json.dumps({'text': 'foo bar'})))
+ io.connection.commit()
+ del(io)
+
+ io = SQLiteIO(tmpfile, view.scene, readonly=True)
+ io.read()
+ view.scene.add_queued_items()
+ assert len(view.scene.items()) == 1
+ item = view.scene.items()[0]
+ assert item.isSelected() is False
+ assert item.save_id == 1
+ assert item.pos().x() == 22.2
+ assert item.pos().y() == 33.3
+ assert item.zValue() == 0.22
+ assert item.scale() == 3.4
+ assert item.rotation() == 45
+ assert item.flip() == -1
+ assert item.toPlainText() == 'foo bar'
+ assert view.scene.items_to_add.empty() is True
+
+
+def test_sqliteio_read_reads_readonly_pixmap_item(tmpfile, view, imgdata3x3):
+ io = SQLiteIO(tmpfile, view.scene, create_new=True)
+ io.create_schema_on_new()
+ io.ex('INSERT INTO items '
+ '(type, x, y, z, scale, rotation, flip, data) '
+ 'VALUES (?, ?, ?, ?, ?, ?, ?, ?) ',
+ ('pixmap', 22.2, 33.3, 0.22, 3.4, 45, -1,
+ json.dumps({'filename': 'bee.png'})))
io.ex('INSERT INTO sqlar (item_id, data) VALUES (?, ?)',
(1, imgdata3x3))
io.connection.commit()
@@ -229,8 +394,10 @@ def test_sqliteio_read_reads_readonly(tmpfile, view, imgdata3x3):
io = SQLiteIO(tmpfile, view.scene, readonly=True)
io.read()
- item, selected = view.scene.items_to_add.get()
- assert selected is False
+ view.scene.add_queued_items()
+ assert len(view.scene.items()) == 1
+ item = view.scene.items()[0]
+ assert item.isSelected() is False
assert item.save_id == 1
assert item.pos().x() == 22.2
assert item.pos().y() == 33.3
@@ -250,11 +417,12 @@ def test_sqliteio_read_updates_progress(tmpfile, view):
worker=worker)
io.create_schema_on_new()
- io.ex('INSERT INTO items (type, x, y, z, scale, filename) '
+ io.ex('INSERT INTO items (type, x, y, z, scale, data) '
'VALUES (?, ?, ?, ?, ?, ?) ',
- ('pixmap', 0, 0, 0, 1, 'bee.png'))
+ ('pixmap', 0, 0, 0, 1, json.dumps({'filename': 'bee.png'})))
io.ex('INSERT INTO sqlar (item_id, data) VALUES (?, ?)', (1, b''))
io.connection.commit()
+
io.read()
worker.begin_processing.emit.assert_called_once_with(1)
worker.progress.emit.assert_called_once_with(0)
@@ -264,17 +432,17 @@ def test_sqliteio_read_updates_progress(tmpfile, view):
def test_sqliteio_read_canceled(tmpfile, view):
worker = MagicMock(canceled=True)
io = SQLiteIO(tmpfile, view.scene, create_new=True, worker=worker)
-
io.create_schema_on_new()
- io.ex('INSERT INTO items (type, x, y, z, scale, filename) '
+ io.ex('INSERT INTO items (type, x, y, z, scale, data) '
'VALUES (?, ?, ?, ?, ?, ?) ',
- ('pixmap', 0, 0, 0, 1, 'bee.png'))
+ ('pixmap', 0, 0, 0, 1, json.dumps({'filename': 'bee.png'})))
io.ex('INSERT INTO sqlar (item_id, data) VALUES (?, ?)', (1, b''))
- io.ex('INSERT INTO items (type, x, y, z, scale, filename) '
+ io.ex('INSERT INTO items (type, x, y, z, scale, data) '
'VALUES (?, ?, ?, ?, ?, ?) ',
- ('pixmap', 50, 50, 0, 1, 'bee2.png'))
- io.ex('INSERT INTO sqlar (item_id, data) VALUES (?, ?)', (1, b''))
+ ('pixmap', 50, 50, 0, 1, json.dumps({'filename': 'bee2.png'})))
+ io.ex('INSERT INTO sqlar (item_id, data) VALUES (?, ?)', (2, b''))
io.connection.commit()
+
io.read()
worker.begin_processing.emit.assert_called_once_with(2)
worker.progress.emit.assert_called_once_with(0)
diff --git a/tests/test_items.py b/tests/items/test_pixmapitem.py
similarity index 79%
rename from tests/test_items.py
rename to tests/items/test_pixmapitem.py
index 07e9cd3..ee95b87 100644
--- a/tests/test_items.py
+++ b/tests/items/test_pixmapitem.py
@@ -2,7 +2,11 @@ from unittest.mock import patch, MagicMock, PropertyMock
from PyQt6 import QtCore, QtGui
-from beeref.items import BeePixmapItem
+from beeref.items import BeePixmapItem, item_registry
+
+
+def test_in_item_registry():
+ assert item_registry['pixmap'] == BeePixmapItem
@patch('beeref.selection.SelectableMixin.init_selectable')
@@ -48,6 +52,11 @@ def test_set_pos_center_when_rotated(qapp, item):
assert item.pos().y() == -100
+def test_get_extra_save_data(item):
+ item.filename = 'foobar.png'
+ assert item.get_extra_save_data() == {'filename': 'foobar.png'}
+
+
def test_pixmap_to_bytes(qapp, imgfilename3x3):
item = BeePixmapItem(QtGui.QImage(imgfilename3x3))
assert item.pixmap_to_bytes().startswith(b'\x89PNG')
@@ -114,6 +123,41 @@ def test_selection_action_items(qapp):
assert item.selection_action_items() == [item]
+def test_update_from_data(item):
+ item.update_from_data(
+ save_id=3,
+ x=11,
+ y=22,
+ z=1.2,
+ scale=2.5,
+ rotation=45,
+ flip=-1)
+ assert item.save_id == 3
+ assert item.pos() == QtCore.QPointF(11, 22)
+ assert item.zValue() == 1.2
+ assert item.rotation() == 45
+ assert item.flip() == -1
+
+
+def test_update_from_data_keeps_flip(item):
+ item.do_flip()
+ item.update_from_data(flip=-1)
+ assert item.flip() == -1
+
+
+def test_update_from_data_keeps_unset_values(item):
+ item.setScale(3)
+ item.update_from_data(rotation=45)
+ assert item.scale() == 3
+
+
+def test_create_from_data(item):
+ new_item = BeePixmapItem.create_from_data(
+ item=item, data={'filename': 'foobar.png'})
+ assert new_item is item
+ assert item.filename == 'foobar.png'
+
+
def test_create_copy(qapp, imgfilename3x3):
item = BeePixmapItem(QtGui.QImage(imgfilename3x3), 'foo.png')
item.setPos(20, 30)
diff --git a/tests/items/test_textitem.py b/tests/items/test_textitem.py
new file mode 100644
index 0000000..32bfad7
--- /dev/null
+++ b/tests/items/test_textitem.py
@@ -0,0 +1,301 @@
+from unittest.mock import patch, MagicMock, PropertyMock
+
+from PyQt6 import QtCore, QtWidgets
+from PyQt6.QtCore import Qt
+
+from beeref.items import BeeTextItem, item_registry
+
+
+def test_in_items_registry():
+ assert item_registry['text'] == BeeTextItem
+
+
+@patch('beeref.selection.SelectableMixin.init_selectable')
+def test_init(selectable_mock, qapp):
+ item = BeeTextItem('foo bar')
+ assert item.save_id is None
+ assert item.width
+ assert item.height
+ assert item.scale() == 1
+ assert item.toPlainText() == 'foo bar'
+ assert item.is_editable is True
+ assert item.edit_mode is False
+ selectable_mock.assert_called_once()
+
+
+def test_set_pos_center(qapp):
+ item = BeeTextItem('foo bar')
+ with patch('beeref.items.BeeTextItem.width',
+ new_callable=PropertyMock, return_value=200):
+ with patch('beeref.items.BeeTextItem.height',
+ new_callable=PropertyMock, return_value=100):
+ item.set_pos_center(QtCore.QPointF(0, 0))
+ assert item.pos().x() == -100
+ assert item.pos().y() == -50
+
+
+def test_set_pos_center_when_scaled(qapp):
+ item = BeeTextItem('foo bar')
+ item.setScale(2)
+ with patch('beeref.items.BeeTextItem.width',
+ new_callable=PropertyMock, return_value=200):
+ with patch('beeref.items.BeeTextItem.height',
+ new_callable=PropertyMock, return_value=100):
+ item.set_pos_center(QtCore.QPointF(0, 0))
+ assert item.pos().x() == -200
+ assert item.pos().y() == -100
+
+
+def test_set_pos_center_when_rotated(qapp):
+ item = BeeTextItem('foo bar')
+ item.setRotation(90)
+ with patch('beeref.items.BeeTextItem.width',
+ new_callable=PropertyMock, return_value=200):
+ with patch('beeref.items.BeeTextItem.height',
+ new_callable=PropertyMock, return_value=100):
+ item.set_pos_center(QtCore.QPointF(0, 0))
+ assert item.pos().x() == 50
+ assert item.pos().y() == -100
+
+
+def test_get_extra_save_data(qapp):
+ item = BeeTextItem('foo bar')
+ assert item.get_extra_save_data() == {'text': 'foo bar'}
+
+
+@patch('beeref.items.BeeTextItem.boundingRect')
+def test_contains_when_inside_bounds(brect_mock, qapp):
+ brect_mock.return_value = QtCore.QRectF(20, 30, 50, 50)
+ item = BeeTextItem('foo bar')
+ item.contains(QtCore.QPointF(33, 45)) is True
+ brect_mock.assert_called_once_with()
+
+
+@patch('beeref.items.BeeTextItem.boundingRect')
+def test_contains_when_outside_bounds(brect_mock, qapp):
+ brect_mock.return_value = QtCore.QRectF(20, 30, 50, 50)
+ item = BeeTextItem('foo bar')
+ item.contains(QtCore.QPointF(19, 29)) is False
+ brect_mock.assert_called_once_with()
+
+
+@patch('PyQt6.QtWidgets.QGraphicsTextItem.paint')
+def test_paint(paint_mock, qapp):
+ item = BeeTextItem('foo bar')
+ item.paint_selectable = MagicMock()
+ painter = MagicMock()
+ option = MagicMock()
+ item.paint(painter, option, 'widget')
+ item.paint_selectable.assert_called_once()
+ painter.drawRect.assert_called_once()
+ assert option.state == QtWidgets.QStyle.StateFlag.State_Enabled
+ paint_mock.assert_called_once_with(painter, option, 'widget')
+
+
+def test_has_selection_outline_when_not_selected(view):
+ item = BeeTextItem('foo bar')
+ view.scene.addItem(item)
+ item.setSelected(False)
+ item.has_selection_outline() is False
+
+
+def test_has_selection_outline_when_selected(view):
+ item = BeeTextItem('foo bar')
+ view.scene.addItem(item)
+ item.setSelected(True)
+ item.has_selection_outline() is True
+
+
+def test_has_selection_handles_when_not_selected(view):
+ item = BeeTextItem('foo bar')
+ view.scene.addItem(item)
+ item.setSelected(False)
+ item2 = BeeTextItem('baz')
+ view.scene.addItem(item2)
+ item2.setSelected(False)
+ item.has_selection_handles() is False
+
+
+def test_has_selection_handles_when_selected_single(view):
+ item = BeeTextItem('foo bar')
+ view.scene.addItem(item)
+ item.setSelected(True)
+ item2 = BeeTextItem('baz')
+ view.scene.addItem(item2)
+ item2.setSelected(False)
+ item.has_selection_handles() is True
+
+
+def test_has_selection_handles_when_selected_multi(view):
+ item = BeeTextItem('foo bar')
+ view.scene.addItem(item)
+ item.setSelected(True)
+ item2 = BeeTextItem('baz')
+ view.scene.addItem(item2)
+ item2.setSelected(True)
+ item.has_selection_handles() is False
+
+
+def test_has_selection_handles_when_selected_single_and_edit_mode(view):
+ item = BeeTextItem('foo bar')
+ item.edit_mode = False
+ view.scene.addItem(item)
+ item.setSelected(True)
+ item2 = BeeTextItem('baz')
+ view.scene.addItem(item2)
+ item2.setSelected(False)
+ item.has_selection_handles() is False
+
+
+def test_selection_action_items(qapp):
+ item = BeeTextItem('foo bar')
+ assert item.selection_action_items() == [item]
+
+
+def test_update_from_data(qapp):
+ item = BeeTextItem('foo bar')
+ item.update_from_data(
+ save_id=3,
+ x=11,
+ y=22,
+ z=1.2,
+ scale=2.5,
+ rotation=45,
+ flip=-1)
+ assert item.save_id == 3
+ assert item.pos() == QtCore.QPointF(11, 22)
+ assert item.zValue() == 1.2
+ assert item.rotation() == 45
+ assert item.flip() == -1
+
+
+def test_update_from_data_keeps_flip(qapp):
+ item = BeeTextItem('foo bar')
+ item.do_flip()
+ item.update_from_data(flip=-1)
+ assert item.flip() == -1
+
+
+def test_update_from_data_keeps_unset_values(qapp):
+ item = BeeTextItem('foo bar')
+ item.setScale(3)
+ item.update_from_data(rotation=45)
+ assert item.scale() == 3
+
+
+def test_create_from_data(qapp):
+ item = BeeTextItem.create_from_data(data={'text': 'hello world'})
+ item.toPlainText() == 'hello world'
+
+
+def test_create_copy(qapp):
+ item = BeeTextItem('foo bar')
+ item.setPos(20, 30)
+ item.setRotation(33)
+ item.do_flip()
+ item.setZValue(0.5)
+ item.setScale(2.2)
+
+ copy = item.create_copy()
+ assert copy.toPlainText() == 'foo bar'
+ assert copy.pos() == QtCore.QPointF(20, 30)
+ assert copy.rotation() == 33
+ assert item.flip() == -1
+ assert item.zValue() == 0.5
+ assert item.scale() == 2.2
+
+
+def test_enter_edit_mode(qapp):
+ item = BeeTextItem('foo bar')
+ item.enter_edit_mode()
+ assert item.edit_mode is True
+ flags = item.textInteractionFlags()
+ assert flags == Qt.TextInteractionFlag.TextEditorInteraction
+
+
+@patch('PyQt6.QtGui.QTextCursor')
+@patch('beeref.items.BeeTextItem.setTextCursor')
+def test_exit_edit_mode(setcursor_mock, cursor_mock, qapp):
+ item = BeeTextItem('foo bar')
+ item.edit_mode = True
+ item.exit_edit_mode()
+ assert item.edit_mode is False
+ flags = item.textInteractionFlags()
+ assert flags == Qt.TextInteractionFlag.NoTextInteraction
+ cursor_mock.assert_called_once_with(item.document())
+ setcursor_mock.assert_called_once_with(cursor_mock.return_value)
+
+
+@patch('PyQt6.QtWidgets.QGraphicsTextItem.keyPressEvent')
+@patch('beeref.items.BeeTextItem.exit_edit_mode')
+def test_key_press_event_any_key(exit_mock, key_press_mock, view):
+ item = BeeTextItem('foo bar')
+ view.scene.addItem(item)
+ view.scene.edit_item = item
+ event = MagicMock()
+ event.key.return_value = Qt.Key.Key_T
+ event.modifiers.return_value = Qt.KeyboardModifier.NoModifier
+ item.keyPressEvent(event)
+ key_press_mock.assert_called_once_with(event)
+ exit_mock.assert_not_called()
+ assert view.scene.edit_item == item
+
+
+@patch('PyQt6.QtWidgets.QGraphicsTextItem.keyPressEvent')
+@patch('beeref.items.BeeTextItem.exit_edit_mode')
+def test_key_press_event_shift_return(exit_mock, key_press_mock, view):
+ item = BeeTextItem('foo bar')
+ view.scene.addItem(item)
+ view.scene.edit_item = item
+ event = MagicMock()
+ event.key.return_value = Qt.Key.Key_Return
+ event.modifiers.return_value = Qt.KeyboardModifier.ShiftModifier
+ item.keyPressEvent(event)
+ key_press_mock.assert_called_once_with(event)
+ exit_mock.assert_not_called()
+ assert view.scene.edit_item == item
+
+
+@patch('PyQt6.QtWidgets.QGraphicsTextItem.keyPressEvent')
+@patch('beeref.items.BeeTextItem.exit_edit_mode')
+def test_key_press_event_shift_enter(exit_mock, key_press_mock, view):
+ item = BeeTextItem('foo bar')
+ view.scene.addItem(item)
+ view.scene.edit_item = item
+ event = MagicMock()
+ event.key.return_value = Qt.Key.Key_Enter
+ event.modifiers.return_value = Qt.KeyboardModifier.ShiftModifier
+ item.keyPressEvent(event)
+ key_press_mock.assert_called_once_with(event)
+ exit_mock.assert_not_called()
+ assert view.scene.edit_item == item
+
+
+@patch('PyQt6.QtWidgets.QGraphicsTextItem.keyPressEvent')
+@patch('beeref.items.BeeTextItem.exit_edit_mode')
+def test_key_press_event_return(exit_mock, key_press_mock, view):
+ item = BeeTextItem('foo bar')
+ view.scene.addItem(item)
+ view.scene.edit_item = item
+ event = MagicMock()
+ event.key.return_value = Qt.Key.Key_Return
+ event.modifiers.return_value = Qt.KeyboardModifier.NoModifier
+ item.keyPressEvent(event)
+ key_press_mock.assert_not_called()
+ exit_mock.assert_called_once_with()
+ assert view.scene.edit_item is None
+
+
+@patch('PyQt6.QtWidgets.QGraphicsTextItem.keyPressEvent')
+@patch('beeref.items.BeeTextItem.exit_edit_mode')
+def test_key_press_event_enter(exit_mock, key_press_mock, view):
+ item = BeeTextItem('foo bar')
+ view.scene.addItem(item)
+ view.scene.edit_item = item
+ event = MagicMock()
+ event.key.return_value = Qt.Key.Key_Enter
+ event.modifiers.return_value = Qt.KeyboardModifier.NoModifier
+ item.keyPressEvent(event)
+ key_press_mock.assert_not_called()
+ exit_mock.assert_called_once_with()
+ assert view.scene.edit_item is None
diff --git a/tests/test_scene.py b/tests/test_scene.py
index 7fb32cc..a49d5b0 100644
--- a/tests/test_scene.py
+++ b/tests/test_scene.py
@@ -7,7 +7,7 @@ from PyQt6 import QtCore, QtGui, QtWidgets
from PyQt6.QtCore import Qt
from beeref import commands
-from beeref.items import BeePixmapItem
+from beeref.items import BeePixmapItem, BeeTextItem
def test_add_remove_item(view, item):
@@ -447,6 +447,66 @@ def test_mouse_press_event_when_left_click_over_item(mouse_mock, view, item):
assert view.scene.event_start == QtCore.QPointF(10, 20)
+@patch('PyQt6.QtWidgets.QGraphicsScene.mousePressEvent')
+def test_mouse_press_event_when_left_click_over_item_in_edit_mode(
+ mouse_mock, view):
+ item = BeeTextItem('foo bar')
+ item.exit_edit_mode = MagicMock()
+ view.scene.addItem(item)
+ view.scene.edit_item = item
+ view.scene.itemAt = MagicMock(return_value=item)
+ event = MagicMock(
+ button=MagicMock(return_value=Qt.MouseButton.LeftButton),
+ )
+ view.scene.mousePressEvent(event)
+ event.accept.assert_not_called()
+ mouse_mock.assert_called_once_with(event)
+ assert view.scene.edit_item == item
+ item.exit_edit_mode.assert_not_called()
+ assert view.scene.move_active is False
+ assert view.scene.rubberband_active is False
+
+
+@patch('PyQt6.QtWidgets.QGraphicsScene.mousePressEvent')
+def test_mouse_press_event_when_left_click_over_diff_item_in_edit_mode(
+ mouse_mock, view, item):
+ txtitem = BeeTextItem('foo bar')
+ txtitem.exit_edit_mode = MagicMock()
+ view.scene.addItem(txtitem)
+ view.scene.edit_item = txtitem
+ view.scene.itemAt = MagicMock(return_value=item)
+ event = MagicMock(
+ button=MagicMock(return_value=Qt.MouseButton.LeftButton),
+ )
+ view.scene.mousePressEvent(event)
+ event.accept.assert_not_called()
+ mouse_mock.assert_called_once_with(event)
+ assert view.scene.edit_item is None
+ txtitem.exit_edit_mode.assert_called_once_with()
+ assert view.scene.move_active is True
+ assert view.scene.rubberband_active is False
+
+
+@patch('PyQt6.QtWidgets.QGraphicsScene.mousePressEvent')
+def test_mouse_press_event_when_left_click_over_no_item_in_edit_mode(
+ mouse_mock, view):
+ item = BeeTextItem('foo bar')
+ item.exit_edit_mode = MagicMock()
+ view.scene.addItem(item)
+ view.scene.edit_item = item
+ view.scene.itemAt = MagicMock(return_value=None)
+ event = MagicMock(
+ button=MagicMock(return_value=Qt.MouseButton.LeftButton),
+ )
+ view.scene.mousePressEvent(event)
+ event.accept.assert_not_called()
+ mouse_mock.assert_called_once_with(event)
+ assert view.scene.edit_item is None
+ item.exit_edit_mode.assert_called_once_with()
+ assert view.scene.move_active is False
+ assert view.scene.rubberband_active is True
+
+
@patch('PyQt6.QtWidgets.QGraphicsScene.mousePressEvent')
def test_mouse_press_event_when_left_click_not_over_item(
mouse_mock, view, item):
@@ -501,6 +561,33 @@ def test_mouse_doubleclick_event_when_over_item(mouse_mock, view, item):
mouse_mock.assert_not_called()
+@patch('PyQt6.QtWidgets.QGraphicsScene.mousePressEvent')
+@patch('PyQt6.QtWidgets.QGraphicsScene.mouseDoubleClickEvent')
+def test_mouse_doubleclick_event_when_over_editable_item(
+ double_mock, press_mock, view):
+ item = BeeTextItem('foo bar')
+ item.enter_edit_mode = MagicMock()
+ event = MagicMock()
+ view.scene.move_active = True
+ view.scene.addItem(item)
+ item.setPos(30, 40)
+ item.setSelected(True)
+ view.scene.itemAt = MagicMock(return_value=item)
+ view.fit_rect = MagicMock()
+
+ with patch('beeref.items.BeePixmapItem.width',
+ new_callable=PropertyMock, return_value=100):
+ with patch('beeref.items.BeePixmapItem.height',
+ new_callable=PropertyMock, return_value=100):
+ view.scene.mouseDoubleClickEvent(event)
+
+ assert view.scene.move_active is False
+ item.enter_edit_mode.assert_called_once_with()
+ view.scene.edit_item == item
+ double_mock.assert_not_called()
+ press_mock.assert_called_once_with(event)
+
+
@patch('PyQt6.QtWidgets.QGraphicsScene.mouseDoubleClickEvent')
def test_mouse_doubleclick_event_when_item_not_selected(
mouse_mock, view, item):
@@ -944,21 +1031,24 @@ def test_on_change_when_no_multi_select(view):
view.scene.multi_select_item.fit_selection_area.assert_not_called()
-def test_add_queued_items_unselected(view, item):
- item.setZValue(0.33)
- view.scene.add_item_later(item, selected=False)
+def test_add_queued_items_unselected(view):
+ data = {'type': 'text', 'z': 0.33, 'data': {'text': 'foo'}}
+ view.scene.add_item_later(data, selected=False)
view.scene.add_queued_items()
- assert view.scene.items() == [item]
+ assert len(view.scene.items()) == 1
+ item = view.scene.items()[0]
assert item.isSelected() is False
assert view.scene.max_z == 0.33
+ assert item.toPlainText() == 'foo'
-def test_add_queued_items_selected(view, item):
+def test_add_queued_items_selected(view):
view.scene.max_z = 0.6
- item.setZValue(0.33)
- view.scene.add_item_later(item, selected=True)
+ data = {'type': 'text', 'z': 0.33, 'data': {'text': 'foo'}}
+ view.scene.add_item_later(data, selected=True)
view.scene.add_queued_items()
- assert view.scene.items() == [item]
+ assert len(view.scene.items()) == 1
+ item = view.scene.items()[0]
assert item.isSelected() is True
assert item.zValue() > 0.6
diff --git a/tests/test_view.py b/tests/test_view.py
index 7dd932a..68dc98a 100644
--- a/tests/test_view.py
+++ b/tests/test_view.py
@@ -1,4 +1,5 @@
import os.path
+import shutil
import sqlite3
from unittest.mock import MagicMock, patch, mock_open
@@ -244,7 +245,6 @@ def test_on_action_save_as_when_error(
view.scene.addItem(item)
view.on_saving_finished = MagicMock()
filename = os.path.join(tmpdir, 'test.bee')
- assert os.path.exists(filename) is False
dialog_mock.return_value = (filename, None)
save_mock.side_effect = sqlite3.Error('foo')
view.on_action_save_as()
@@ -256,8 +256,10 @@ def test_on_action_save(view, qtbot, imgfilename3x3, tmpdir):
item = BeePixmapItem(QtGui.QImage(imgfilename3x3))
view.scene.addItem(item)
view.filename = os.path.join(tmpdir, 'test.bee')
+ root = os.path.dirname(__file__)
+ shutil.copyfile(os.path.join(root, 'assets', 'test1item.bee'),
+ view.filename)
view.on_saving_finished = MagicMock()
- assert os.path.exists(view.filename) is False
view.on_action_save()
qtbot.waitUntil(lambda: view.on_saving_finished.called is True)
assert os.path.exists(view.filename) is True
@@ -320,6 +322,16 @@ def test_on_action_insert_images_when_error(
'', ['iaeiae', 'trntrn'])
+@patch('beeref.scene.BeeGraphicsScene.clearSelection')
+def test_on_action_insert_text(clear_mock, view):
+ view.on_action_insert_text()
+ clear_mock.assert_called_once_with()
+ assert len(view.scene.items()) == 1
+ item = view.scene.items()[0]
+ assert item.toPlainText() == 'Text'
+ assert item.isSelected() is True
+
+
@patch('PyQt6.QtWidgets.QApplication.clipboard')
def test_on_action_copy(clipboard_mock, view, imgfilename3x3):
item = BeePixmapItem(QtGui.QImage(imgfilename3x3))
@@ -359,9 +371,24 @@ def test_on_action_paste_internal(mimedata_mock, clear_mock, view):
@patch('beeref.scene.BeeGraphicsScene.clearSelection')
+@patch('PyQt6.QtGui.QClipboard.text')
@patch('PyQt6.QtGui.QClipboard.image')
-def test_on_action_paste_when_empty(clipboard_mock, clear_mock, view):
- clipboard_mock.return_value = QtGui.QImage()
+def test_on_action_paste_when_text(img_mock, text_mock, clear_mock, view):
+ img_mock.return_value = QtGui.QImage()
+ text_mock.return_value = 'foo bar'
+ view.on_action_paste()
+ assert len(view.scene.items()) == 1
+ assert view.scene.items()[0].isSelected() is True
+ assert view.scene.items()[0].toPlainText() == 'foo bar'
+ clear_mock.assert_called_once_with()
+
+
+@patch('beeref.scene.BeeGraphicsScene.clearSelection')
+@patch('PyQt6.QtGui.QClipboard.text')
+@patch('PyQt6.QtGui.QClipboard.image')
+def test_on_action_paste_when_empty(img_mock, text_mock, clear_mock, view):
+ img_mock.return_value = QtGui.QImage()
+ text_mock.return_value = ''
view.on_action_paste()
assert len(view.scene.items()) == 0
clear_mock.assert_not_called()