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()