diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 16b1c97..736d855 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -6,6 +6,8 @@ Added * Moving the window from within BeeRef now changes to a diffent cursor from the default arrow cursor. +* Added a color sampler which can copy colors from images to the + clipboard in hex format (Images -> Sample Color) Fixed diff --git a/beeref/actions/actions.py b/beeref/actions/actions.py index 195eca9..1ce63c5 100644 --- a/beeref/actions/actions.py +++ b/beeref/actions/actions.py @@ -271,6 +271,13 @@ actions = ActionList([ 'callback': 'on_action_show_color_gamut', 'group': 'active_when_single_image', }), + Action({ + 'id': 'sample_color', + 'text': 'Sample Color', + 'shortcuts': ['S'], + 'callback': 'on_action_sample_color', + 'group': 'active_when_items_in_scene', + }), Action({ 'id': 'crop', 'text': '&Crop', diff --git a/beeref/actions/menu_structure.py b/beeref/actions/menu_structure.py index 97d3a65..7c3a35b 100644 --- a/beeref/actions/menu_structure.py +++ b/beeref/actions/menu_structure.py @@ -110,6 +110,7 @@ menu_structure = [ 'grayscale', MENU_SEPARATOR, 'show_color_gamut', + 'sample_color', ], }, { diff --git a/beeref/fileio/export.py b/beeref/fileio/export.py index 30febe1..3afd15c 100644 --- a/beeref/fileio/export.py +++ b/beeref/fileio/export.py @@ -50,7 +50,7 @@ class ExporterBase: def __init__(self, scene): self.scene = scene - self.scene.cancel_crop_mode() + self.scene.cancel_active_modes() self.scene.deselect_all_items() # Selection outlines/handles will be rendered to the exported # image, so deselect first. (Alternatively, pass an attribute diff --git a/beeref/items.py b/beeref/items.py index 1fb2038..6a94bbf 100644 --- a/beeref/items.py +++ b/beeref/items.py @@ -43,6 +43,9 @@ def register_item(cls): class BeeItemMixin(SelectableMixin): """Base for all items added by the user.""" + def sample_color_at(self, pos): + return None + def set_pos_center(self, pos): """Sets the position using the item's center as the origin point.""" @@ -64,7 +67,7 @@ class BeeItemMixin(SelectableMixin): def on_selected_change(self, value): if (value and self.scene() and not self.scene().has_selection() - and not self.scene().rubberband_active): + and not self.scene().active_mode is None): self.bring_to_front() def update_from_data(self, **kwargs): @@ -170,6 +173,18 @@ class BeePixmapItem(BeeItemMixin, QtWidgets.QGraphicsPixmapItem): self.update() + def sample_color_at(self, pos): + ipos = self.mapFromScene(pos) + if self.grayscale: + pm = self._grayscale_pixmap + else: + pm = self.pixmap() + img = pm.toImage() + + color = img.pixelColor(int(ipos.x()), int(ipos.y())) + if color.alpha(): + return color + def bounding_rect_unselected(self): if self.crop_mode: return QtWidgets.QGraphicsPixmapItem.boundingRect(self) @@ -414,9 +429,7 @@ class BeePixmapItem(BeeItemMixin, QtWidgets.QGraphicsPixmapItem): color = QtGui.QColor(0, 0, 0) color.setAlpha(100) painter.setBrush(QtGui.QBrush(color)) - pen = QtGui.QPen() - pen.setWidth(0) - painter.setPen(pen) + painter.setPen(Qt.PenStyle.NoPen) painter.drawPath(path) painter.setBrush(QtGui.QBrush()) diff --git a/beeref/scene.py b/beeref/scene.py index 3fde06b..4e40b33 100644 --- a/beeref/scene.py +++ b/beeref/scene.py @@ -35,10 +35,12 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene): cursor_changed = QtCore.pyqtSignal(QtGui.QCursor) cursor_cleared = QtCore.pyqtSignal() + MOVE_MODE = 1 + RUBBERBAND_MODE = 2 + def __init__(self, undo_stack): super().__init__() - self.move_active = False - self.rubberband_active = False + self.active_mode = None self.undo_stack = undo_stack self.max_z = 0 self.min_z = 0 @@ -66,6 +68,19 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene): logger.debug(f'Removing item {item}') super().removeItem(item) + def cancel_active_modes(self): + """Cancels ongoing crop modes, rubberband modes etc, if there are + any. + """ + self.cancel_crop_mode() + self.end_rubberband_mode() + + def end_rubberband_mode(self): + if self.rubberband_item.scene(): + logger.debug('Ending rubberband selection') + self.removeItem(self.rubberband_item) + self.active_mode = None + def cancel_crop_mode(self): """Cancels an ongoing crop mode, if there is any.""" if self.crop_item: @@ -84,7 +99,7 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene): self.undo_stack.push(commands.InsertItems(self, copies, position)) def raise_to_top(self): - self.cancel_crop_mode() + self.cancel_active_modes() items = self.selectedItems(user_only=True) z_values = map(lambda i: i.zValue(), items) delta = self.max_z + self.Z_STEP - min(z_values) @@ -93,7 +108,7 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene): item.setZValue(item.zValue() + delta) def lower_to_bottom(self): - self.cancel_crop_mode() + self.cancel_active_modes() items = self.selectedItems(user_only=True) z_values = map(lambda i: i.zValue(), items) delta = self.min_z - self.Z_STEP - max(z_values) @@ -109,7 +124,7 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene): :param mode: "width" or "height". """ - self.cancel_crop_mode() + self.cancel_active_modes() values = [] items = self.selectedItems(user_only=True) for item in items: @@ -141,7 +156,7 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene): Size meaning the area = widh * height. """ - self.cancel_crop_mode() + self.cancel_active_modes() sizes = [] items = self.selectedItems(user_only=True) for item in items: @@ -164,7 +179,7 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene): def arrange(self, vertical=False): """Arrange items in a line (horizontally or vertically).""" - self.cancel_crop_mode() + self.cancel_active_modes() items = self.selectedItems(user_only=True) if len(items) < 2: @@ -205,7 +220,7 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene): positions)) def arrange_optimal(self): - self.cancel_crop_mode() + self.cancel_active_modes() items = self.selectedItems(user_only=True) if len(items) < 2: @@ -243,7 +258,7 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene): def flip_items(self, vertical=False): """Flip selected items.""" - self.cancel_crop_mode() + self.cancel_active_modes() self.undo_stack.push( commands.FlipItems(self.selectedItems(user_only=True), self.get_selection_center(), @@ -259,15 +274,20 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene): if item.is_image: item.enter_crop_mode() + def sample_color_at(self, position): + item_at_pos = self.itemAt(position, self.views()[0].transform()) + if item_at_pos: + return item_at_pos.sample_color_at(position) + def select_all_items(self): - self.cancel_crop_mode() + self.cancel_active_modes() path = QtGui.QPainterPath() path.addRect(self.itemsBoundingRect()) # This is faster than looping through all items and calling setSelected self.setSelectionArea(path) def deselect_all_items(self): - self.cancel_crop_mode() + self.cancel_active_modes() self.clearSelection() def has_selection(self): @@ -316,16 +336,16 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene): super().mousePressEvent(event) return if item_at_pos: - self.move_active = True + self.active_mode = self.MOVE_MODE elif self.items(): - self.rubberband_active = True + self.active_mode = self.RUBBERBAND_MODE super().mousePressEvent(event) def mouseDoubleClickEvent(self, event): + self.cancel_active_modes() item = self.itemAt(event.scenePos(), self.views()[0].transform()) if item: - self.move_active = False if not item.isSelected(): item.setSelected(True) if item.is_editable: @@ -339,7 +359,7 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene): super().mouseDoubleClickEvent(event) def mouseMoveEvent(self, event): - if self.rubberband_active: + if self.active_mode == self.RUBBERBAND_MODE: if not self.rubberband_item.scene(): logger.debug('Activating rubberband selection') self.addItem(self.rubberband_item) @@ -350,22 +370,19 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene): super().mouseMoveEvent(event) def mouseReleaseEvent(self, event): - if self.rubberband_active: - if self.rubberband_item.scene(): - logger.debug('Ending rubberband selection') - self.removeItem(self.rubberband_item) - self.rubberband_active = False - if (self.move_active + if self.active_mode == self.RUBBERBAND_MODE: + self.end_rubberband_mode() + if (self.active_mode == self.MOVE_MODE and self.has_selection() - and not self.multi_select_item.is_action_active() - and not self.selectedItems()[0].is_action_active()): + and self.multi_select_item.active_mode is None + and self.selectedItems()[0].active_mode is None): delta = event.scenePos() - self.event_start if not delta.isNull(): self.undo_stack.push( commands.MoveItemsBy(self.selectedItems(), delta, ignore_first_redo=True)) - self.move_active = False + self.active_mode = None super().mouseReleaseEvent(event) def selectedItems(self, user_only=False): @@ -446,8 +463,7 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene): def on_change(self, region): if (self.multi_select_item.scene() - and not self.multi_select_item.scale_active - and not self.multi_select_item.rotate_active): + and self.multi_select_item.active_mode is None): self.multi_select_item.fit_selection_area( self.itemsBoundingRect(selection_only=True)) diff --git a/beeref/selection.py b/beeref/selection.py index a3a72f1..69e0827 100644 --- a/beeref/selection.py +++ b/beeref/selection.py @@ -140,6 +140,10 @@ class SelectableMixin(BaseItemMixin): SELECT_ROTATE_SIZE = 10 # size of hover area for rotating SELECT_FREE_CENTER = 20 # size of handle-free area in the center + SCALE_MODE = 1 + ROTATE_MODE = 2 + FLIP_MODE = 3 + def init_selectable(self): self.setAcceptHoverEvents(True) self.setFlags( @@ -147,19 +151,9 @@ class SelectableMixin(BaseItemMixin): | QtWidgets.QGraphicsItem.GraphicsItemFlag.ItemIsSelectable) self.viewport_scale = 1 - self.reset_actions() + self.active_mode = None self.is_editable = False - def reset_actions(self): - self.scale_active = False - self.rotate_active = False - self.flip_active = False - - def is_action_active(self): - return any((self.scale_active, - self.rotate_active, - self.flip_active)) - def fixed_length_for_viewport(self, value): """The interactable areas need to stay the same size on the screen so we need to adjust the values according to the scale @@ -423,7 +417,7 @@ class SelectableMixin(BaseItemMixin): # Check if we are in one of the corner's scale areas if self.get_scale_bounds(corner).contains(event.pos()): # Start scale action for this corner - self.scale_active = True + self.active_mode = self.SCALE_MODE self.event_direction = self.get_direction_from_center( event.scenePos()) self.event_anchor = self.mapToScene( @@ -435,7 +429,7 @@ class SelectableMixin(BaseItemMixin): # Check if we are in one of the corner's rotate areas if self.get_rotate_bounds(corner).contains(event.pos()): # Start rotate action - self.rotate_active = True + self.active_mode = self.ROTATE_MODE self.event_anchor = self.center_scene_coords self.rotate_start_angle = self.get_rotate_angle( event.scenePos()) @@ -446,7 +440,7 @@ class SelectableMixin(BaseItemMixin): # Check if we are in one of the flip edges: for edge in self.get_flip_bounds(): if edge['rect'].contains(event.pos()): - self.flip_active = True + self.active_mode = self.FLIP_MODE event.accept() self.scene().undo_stack.push( commands.FlipItems( @@ -549,14 +543,14 @@ class SelectableMixin(BaseItemMixin): if (event.scenePos() - self.event_start).manhattanLength() > 5: self.scene().views()[0].reset_previous_transform() - if self.scale_active: + if self.active_mode == self.SCALE_MODE: factor = self.get_scale_factor(event) for item in self.selection_action_items(): item.setScale(item.scale_orig_factor * factor, item.mapFromScene(self.event_anchor)) event.accept() return - if self.rotate_active: + if self.active_mode == self.ROTATE_MODE: snap = (event.modifiers() == Qt.KeyboardModifier.ControlModifier or event.modifiers() == Qt.KeyboardModifier.ShiftModifier) delta = self.get_rotate_delta(event.scenePos(), snap) @@ -566,7 +560,7 @@ class SelectableMixin(BaseItemMixin): item.mapFromScene(self.event_anchor)) event.accept() return - if self.flip_active: + if self.active_mode == self.FLIP_MODE: # We have already flipped on MousePress, but we # still need to accept the event here as to not # initiate an item move @@ -576,7 +570,7 @@ class SelectableMixin(BaseItemMixin): super().mouseMoveEvent(event) def mouseReleaseEvent(self, event): - if self.scale_active: + if self.active_mode == self.SCALE_MODE: if self.get_scale_factor(event) != 1: self.scene().undo_stack.push( commands.ScaleItemsBy( @@ -585,9 +579,9 @@ class SelectableMixin(BaseItemMixin): self.event_anchor, ignore_first_redo=True)) event.accept() - self.reset_actions() + self.active_mode = None return - elif self.rotate_active: + elif self.active_mode == self.ROTATE_MODE: self.scene().on_selection_change() if self.get_rotate_delta(event.scenePos()) != 0: self.scene().undo_stack.push( @@ -597,18 +591,18 @@ class SelectableMixin(BaseItemMixin): self.event_anchor, ignore_first_redo=True)) event.accept() - self.reset_actions() + self.active_mode = None return - elif self.flip_active: + elif self.active_mode == self.FLIP_MODE: for edge in self.get_flip_bounds(): if edge['rect'].contains(event.pos()): # We have already flipped on MousePress, but we # still need to accept the event here as to not # initiate an item move event.accept() - self.reset_actions() + self.active_mode = None return - self.reset_actions() + self.active_mode = None super().mouseReleaseEvent(event) def on_view_scale_change(self): diff --git a/beeref/utils.py b/beeref/utils.py index 1ff8113..46920fd 100644 --- a/beeref/utils.py +++ b/beeref/utils.py @@ -78,3 +78,18 @@ def get_file_extension_from_format(formatstr): extensions = re.match(r'.* \((.*)\)', formatstr).groups()[0] ext = extensions.split()[0] return ext.removeprefix('*.') + + +def qcolor_to_hex(color): + """Returns the QColor as a hex represenation string: + #RRGGBBAA if the color has transparencey, otherwise #RRGGBB. + """ + + if color.alpha() == 255: + return color.name() + + # The name method can only do HexRgb and HexArgb, not HexRgba, so + # we have to do this ourselves: + rgb = color.name() + alpha = hex(color.alpha()).removeprefix('0x') + return f'{rgb}{alpha}' diff --git a/beeref/view.py b/beeref/view.py index 4463cb0..5fcad5e 100644 --- a/beeref/view.py +++ b/beeref/view.py @@ -31,7 +31,7 @@ from beeref import widgets from beeref.items import BeePixmapItem, BeeTextItem from beeref.main_controls import MainControlsMixin from beeref.scene import BeeGraphicsScene -from beeref.utils import get_file_extension_from_format +from beeref.utils import get_file_extension_from_format, qcolor_to_hex commandline_args = CommandlineArgs() @@ -42,6 +42,10 @@ class BeeGraphicsView(MainControlsMixin, QtWidgets.QGraphicsView, ActionsMixin): + PAN_MODE = 1 + ZOOM_MODE = 2 + SAMPLE_COLOR_MODE = 3 + def __init__(self, app, parent=None): super().__init__(parent) self.app = app @@ -62,8 +66,7 @@ class BeeGraphicsView(MainControlsMixin, self.filename = None self.previous_transform = None - self.pan_active = False - self.zoom_active = False + self.active_mode = None self.scene = BeeGraphicsScene(self.undo_stack) self.scene.changed.connect(self.on_scene_changed) @@ -94,6 +97,19 @@ class BeeGraphicsView(MainControlsMixin, self.settings.update_recent_files(value) self.update_menu_and_actions() + def cancel_active_modes(self): + self.scene.cancel_active_modes() + self.cancel_sample_color_mode() + self.active_mode = None + + def cancel_sample_color_mode(self): + logger.debug('Cancel sample color mode') + self.active_mode = None + self.viewport().unsetCursor() + if hasattr(self, 'sample_color_widget'): + self.sample_color_widget.hide() + del self.sample_color_widget + def update_window_title(self): clean = self.undo_stack.isClean() if clean and not self.filename: @@ -145,6 +161,7 @@ class BeeGraphicsView(MainControlsMixin, def clear_scene(self): logging.debug('Clearing scene...') + self.cancel_active_modes() self.scene.clear() self.undo_stack.clear() self.filename = None @@ -231,12 +248,12 @@ class BeeGraphicsView(MainControlsMixin, def on_action_undo(self): logger.debug('Undo: %s' % self.undo_stack.undoText()) - self.scene.cancel_crop_mode() + self.cancel_active_modes() self.undo_stack.undo() def on_action_redo(self): logger.debug('Redo: %s' % self.undo_stack.redoText()) - self.scene.cancel_crop_mode() + self.cancel_active_modes() self.undo_stack.redo() def on_action_select_all(self): @@ -247,7 +264,7 @@ class BeeGraphicsView(MainControlsMixin, def on_action_delete_items(self): logger.debug('Deleting items...') - self.scene.cancel_crop_mode() + self.cancel_active_modes() self.undo_stack.push( commands.DeleteItems( self.scene, self.scene.selectedItems(user_only=True))) @@ -307,33 +324,45 @@ class BeeGraphicsView(MainControlsMixin, self.scene.flip_items(vertical=True) def on_action_reset_scale(self): - self.scene.cancel_crop_mode() + self.cancel_active_modes() self.undo_stack.push(commands.ResetScale( self.scene.selectedItems(user_only=True))) def on_action_reset_rotation(self): - self.scene.cancel_crop_mode() + self.cancel_active_modes() self.undo_stack.push(commands.ResetRotation( self.scene.selectedItems(user_only=True))) def on_action_reset_flip(self): - self.scene.cancel_crop_mode() + self.cancel_active_modes() self.undo_stack.push(commands.ResetFlip( self.scene.selectedItems(user_only=True))) def on_action_reset_crop(self): - self.scene.cancel_crop_mode() + self.cancel_active_modes() self.undo_stack.push(commands.ResetCrop( self.scene.selectedItems(user_only=True))) def on_action_reset_transforms(self): - self.scene.cancel_crop_mode() + self.cancel_active_modes() self.undo_stack.push(commands.ResetTransforms( self.scene.selectedItems(user_only=True))) def on_action_show_color_gamut(self): widgets.color_gamut.GamutDialog(self, self.scene.selectedItems()[0]) + def on_action_sample_color(self): + self.cancel_active_modes() + logger.debug('Entering sample color mode') + self.viewport().setCursor(Qt.CursorShape.CrossCursor) + self.active_mode = self.SAMPLE_COLOR_MODE + + pos = self.mapFromGlobal(self.cursor().pos()) + self.sample_color_widget = widgets.SampleColorWidget( + self, + pos, + self.scene.sample_color_at(self.mapToScene(pos))) + def on_items_loaded(self, value): logger.debug('On items loaded: add queued items') self.scene.add_queued_items() @@ -364,7 +393,7 @@ class BeeGraphicsView(MainControlsMixin, self.worker.start() def on_action_open(self): - self.scene.cancel_crop_mode() + self.cancel_active_modes() filename, f = QtWidgets.QFileDialog.getOpenFileName( parent=self, caption='Open file', @@ -398,7 +427,7 @@ class BeeGraphicsView(MainControlsMixin, self.worker.start() def on_action_save_as(self): - self.scene.cancel_crop_mode() + self.cancel_active_modes() directory = os.path.dirname(self.filename) if self.filename else None filename, f = QtWidgets.QFileDialog.getSaveFileName( parent=self, @@ -409,7 +438,7 @@ class BeeGraphicsView(MainControlsMixin, self.do_save(filename, create_new=True) def on_action_save(self): - self.scene.cancel_crop_mode() + self.cancel_active_modes() if not self.filename: self.on_action_save_as() else: @@ -528,6 +557,7 @@ class BeeGraphicsView(MainControlsMixin, self.worker.start() def on_action_insert_images(self): + self.cancel_active_modes() formats = self.get_supported_image_formats(QtGui.QImageReader) logger.debug(f'Supported image types for reading: {formats}') filenames, f = QtWidgets.QFileDialog.getOpenFileNames( @@ -537,6 +567,7 @@ class BeeGraphicsView(MainControlsMixin, self.do_insert_images(filenames) def on_action_insert_text(self): + self.cancel_active_modes() item = BeeTextItem() pos = self.mapToScene(self.mapFromGlobal(self.cursor().pos())) item.setScale(1 / self.get_scale()) @@ -544,7 +575,7 @@ class BeeGraphicsView(MainControlsMixin, def on_action_copy(self): logger.debug('Copying to clipboard...') - self.scene.cancel_crop_mode() + self.cancel_active_modes() clipboard = QtWidgets.QApplication.clipboard() items = self.scene.selectedItems(user_only=True) @@ -562,6 +593,7 @@ class BeeGraphicsView(MainControlsMixin, 'beeref/items', QtCore.QByteArray.number(len(items))) def on_action_paste(self): + self.cancel_active_modes() logger.debug('Pasting from clipboard...') clipboard = QtWidgets.QApplication.clipboard() pos = self.mapToScene(self.mapFromGlobal(self.cursor().pos())) @@ -611,11 +643,11 @@ class BeeGraphicsView(MainControlsMixin, self.viewport().repaint() def on_cursor_changed(self, cursor): - if not self.pan_active: + if self.active_mode is None: self.viewport().setCursor(cursor) def on_cursor_cleared(self): - if not self.pan_active: + if self.active_mode is None: self.viewport().unsetCursor() def recalc_scene_rect(self): @@ -720,9 +752,27 @@ class BeeGraphicsView(MainControlsMixin, if self.mousePressEventMainControls(event): return + if self.active_mode == self.SAMPLE_COLOR_MODE: + if (event.button() == Qt.MouseButton.LeftButton): + color = self.scene.sample_color_at( + self.mapToScene(event.pos())) + if color: + name = qcolor_to_hex(color) + clipboard = QtWidgets.QApplication.clipboard() + clipboard.setText(name) + self.scene.internal_clipboard = [] + msg = f'Copied color to clipboard: {name}' + logger.debug(msg) + widgets.BeeNotification(self, msg) + else: + logger.debug('No color found') + self.cancel_sample_color_mode() + event.accept() + return + if (event.button() == Qt.MouseButton.MiddleButton and event.modifiers() == Qt.KeyboardModifier.ControlModifier): - self.zoom_active = True + self.active_mode = self.ZOOM_MODE self.event_start = event.position() self.event_anchor = event.position() event.accept() @@ -732,7 +782,7 @@ class BeeGraphicsView(MainControlsMixin, or (event.button() == Qt.MouseButton.LeftButton and event.modifiers() == Qt.KeyboardModifier.AltModifier)): logger.trace('Begin pan') - self.pan_active = True + self.active_mode = self.PAN_MODE self.event_start = event.position() self.viewport().setCursor(Qt.CursorShape.ClosedHandCursor) # ClosedHandCursor and OpenHandCursor don't work, but I @@ -744,7 +794,7 @@ class BeeGraphicsView(MainControlsMixin, super().mousePressEvent(event) def mouseMoveEvent(self, event): - if self.pan_active: + if self.active_mode == self.PAN_MODE: self.reset_previous_transform() pos = event.position() self.pan(self.event_start - pos) @@ -752,7 +802,7 @@ class BeeGraphicsView(MainControlsMixin, event.accept() return - if self.zoom_active: + if self.active_mode == self.ZOOM_MODE: self.reset_previous_transform() pos = event.position() delta = (self.event_start - pos).y() @@ -761,19 +811,26 @@ class BeeGraphicsView(MainControlsMixin, event.accept() return + if self.active_mode == self.SAMPLE_COLOR_MODE: + self.sample_color_widget.update( + event.position(), + self.scene.sample_color_at(self.mapToScene(event.pos()))) + event.accept() + return + if self.mouseMoveEventMainControls(event): return super().mouseMoveEvent(event) def mouseReleaseEvent(self, event): - if self.pan_active: + if self.active_mode == self.PAN_MODE: logger.trace('End pan') self.viewport().unsetCursor() - self.pan_active = False + self.active_mode = None event.accept() return - if self.zoom_active: - self.zoom_active = False + if self.active_mode == self.ZOOM_MODE: + self.active_mode = None event.accept() return if self.mouseReleaseEventMainControls(event): @@ -788,4 +845,8 @@ class BeeGraphicsView(MainControlsMixin, def keyPressEvent(self, event): if self.keyPressEventMainControls(event): return + if self.active_mode == self.SAMPLE_COLOR_MODE: + self.cancel_sample_color_mode() + event.accept() + return super().keyPressEvent(event) diff --git a/beeref/widgets/__init__.py b/beeref/widgets/__init__.py index 4b51c11..bcdf2ca 100644 --- a/beeref/widgets/__init__.py +++ b/beeref/widgets/__init__.py @@ -16,7 +16,7 @@ import logging import os.path -from PyQt6 import QtCore, QtWidgets +from PyQt6 import QtCore, QtWidgets, QtGui from PyQt6.QtCore import Qt from beeref import constants, commands @@ -239,3 +239,57 @@ class ChangeOpacityDialog(QtWidgets.QDialog): def reject(self): self.command.undo() return super().reject() + + +class BeeNotification(QtWidgets.QWidget): + def __init__(self, parent, text): + super().__init__(parent) + self.label = QtWidgets.QLabel(text) + self.setObjectName('BeeNotification') + self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents) + self.setAutoFillBackground(True) + layout = QtWidgets.QVBoxLayout() + layout.addWidget(self.label) + self.setLayout(layout) + color = constants.COLORS['Active:Window'] + self.setStyleSheet( + f'background-color: rgba({color[0]}, {color[1]}, {color[2]}, 0.9);' + 'padding: 0.7em;' + 'border-radius: 5px;') + self.show() + # We only get own width after showing it; + # updateGeometry doesn't work on hidden widgets + x = (parent.width() - self.width()) / 2 + self.move(int(x), 10) + + QtCore.QTimer.singleShot(1000 * 3, self.deleteLater) + + +class SampleColorWidget(QtWidgets.QWidget): + + OFFSET = 10 # Offset from mouse pointer + SIZE = 50 + NONE_COLOR = QtGui.QColor(0, 0, 0, 0) + + def __init__(self, parent, pos, color): + super().__init__(parent) + self.color = color + self.set_pos(pos) + self.show() + + def set_pos(self, pos): + self.setGeometry(int(pos.x() + self.OFFSET), + int(pos.y() + self.OFFSET), + self.SIZE, self.SIZE) + + def paintEvent(self, event): + color = self.color if self.color else self.NONE_COLOR + painter = QtGui.QPainter(self) + painter.setBrush(QtGui.QBrush(color)) + painter.setPen(Qt.PenStyle.NoPen) + painter.drawRect(0, 0, self.SIZE, self.SIZE) + + def update(self, pos, color): + self.set_pos(pos) + self.color = color + self.repaint() diff --git a/beeref/widgets/welcome_overlay.py b/beeref/widgets/welcome_overlay.py index ec5fc4b..e40f772 100644 --- a/beeref/widgets/welcome_overlay.py +++ b/beeref/widgets/welcome_overlay.py @@ -93,7 +93,7 @@ class WelcomeOverlay(MainControlsMixin, QtWidgets.QWidget): def __init__(self, parent): super().__init__(parent) self.control_target = parent - self.setAttribute(Qt.WidgetAttribute.WA_NoSystemBackground) + self.setAutoFillBackground(True) self.init_main_controls(main_window=parent.parent) # Recent files diff --git a/tests/items/test_pixmapitem.py b/tests/items/test_pixmapitem.py index b484474..d07fbf9 100644 --- a/tests/items/test_pixmapitem.py +++ b/tests/items/test_pixmapitem.py @@ -846,3 +846,42 @@ def test_mouse_release_event_when_not_crop_mode(mouse_mock, qapp, item): assert item.crop_mode is False event.accept.assert_not_called() mouse_mock.assert_called_once_with(event) + + +def test_sample_color_at_returns_color(qapp, view): + color = QtGui.QColor(255, 0, 0, 3) + img = QtGui.QImage(10, 10, QtGui.QImage.Format.Format_ARGB32) + img.fill(color) + item = BeePixmapItem(img, 'foo.png') + view.scene.addItem(item) + assert item.sample_color_at(QtCore.QPointF(2, 2)) == color + + +def test_sample_color_at_returns_none_when_fully_transparent(qapp, view): + color = QtGui.QColor(255, 0, 0, 0) + img = QtGui.QImage(10, 10, QtGui.QImage.Format.Format_ARGB32) + img.fill(color) + item = BeePixmapItem(img, 'foo.png') + view.scene.addItem(item) + assert item.sample_color_at(QtCore.QPointF(2, 2)) is None + + +def test_sample_color_in_greyscale_mode(qapp, view): + color = QtGui.QColor(255, 0, 0) + img = QtGui.QImage(10, 10, QtGui.QImage.Format.Format_ARGB32) + img.fill(color) + item = BeePixmapItem(img, 'foo.png') + item.grayscale = True + view.scene.addItem(item) + gray = item.sample_color_at(QtCore.QPointF(2, 2)) + print(gray.red(), gray.green(), gray.blue(), gray.alpha()) + assert gray == QtGui.QColor(130, 130, 130) + + +def test_sample_color_at_returns_none_when_transparent(qapp, view): + color = QtGui.QColor(255, 0, 0, 0) + img = QtGui.QImage(10, 10, QtGui.QImage.Format.Format_ARGB32) + img.fill(color) + item = BeePixmapItem(img, 'foo.png') + view.scene.addItem(item) + assert item.sample_color_at(QtCore.QPointF(2, 2)) is None diff --git a/tests/items/test_textitem.py b/tests/items/test_textitem.py index 9e94e20..740ae7a 100644 --- a/tests/items/test_textitem.py +++ b/tests/items/test_textitem.py @@ -24,6 +24,12 @@ def test_init(selectable_mock, qapp): selectable_mock.assert_called_once() +def test_sample_color_at(qapp, view): + item = BeeTextItem('foo bar') + view.scene.addItem(item) + assert item.sample_color_at(QtCore.QPointF(2.0, 2.0)) is None + + def test_set_pos_center(qapp): item = BeeTextItem('foo bar') with patch.object(item, 'bounding_rect_unselected', diff --git a/tests/selection/test_selectable_mixin.py b/tests/selection/test_selectable_mixin.py index f75e94f..c1d9c8e 100644 --- a/tests/selection/test_selectable_mixin.py +++ b/tests/selection/test_selectable_mixin.py @@ -13,18 +13,7 @@ from beeref.items import BeePixmapItem def test_init_selectable(view): item = BeePixmapItem(QtGui.QImage()) assert item.viewport_scale == 1 - assert item.scale_active is False - assert item.rotate_active is False - assert item.flip_active is False - - -def test_is_action_active_when_no_action(view, item): - assert item.is_action_active() is False - - -def test_is_action_active_when_action(view, item): - item.scale_active = True - assert item.is_action_active() is True + assert item.active_mode is None def test_on_view_scale_change(view, item): @@ -835,7 +824,7 @@ def test_mouse_press_event_topleft_scale(view, item): with patch.object(item, 'bounding_rect_unselected', return_value=QtCore.QRectF(0, 0, 100, 80)): item.mousePressEvent(event) - assert item.scale_active is True + assert item.active_mode == item.SCALE_MODE assert item.event_start == QtCore.QPointF(-1, -1) assert item.event_direction.x() < 0 assert item.event_direction.y() < 0 @@ -853,7 +842,7 @@ def test_mouse_press_event_bottomright_scale(view, item): with patch.object(item, 'bounding_rect_unselected', return_value=QtCore.QRectF(0, 0, 100, 80)): item.mousePressEvent(event) - assert item.scale_active is True + assert item.active_mode == item.SCALE_MODE assert item.event_start == QtCore.QPointF(101, 81) assert item.event_direction.x() > 0 assert item.event_direction.y() > 0 @@ -872,7 +861,7 @@ def test_mouse_press_event_rotate(view, item): return_value=QtCore.QRectF(0, 0, 100, 80)): with patch('PyQt6.QtWidgets.QGraphicsPixmapItem.mousePressEvent'): item.mousePressEvent(event) - assert item.rotate_active is True + assert item.active_mode == item.ROTATE_MODE assert item.event_anchor == QtCore.QPointF(50, 40) assert item.rotate_orig_degrees == 0 event.accept.assert_called_once_with() @@ -895,7 +884,7 @@ def test_mouse_press_event_flip(view, item): assert cmd.items == [item] assert cmd.anchor == QtCore.QPointF(50, 40) assert cmd.vertical is False - assert item.flip_active is True + assert item.active_mode == item.FLIP_MODE event.accept.assert_called_once_with() @@ -909,9 +898,7 @@ def test_mouse_press_event_not_in_handles(view, item): with patch('PyQt6.QtWidgets.QGraphicsPixmapItem.mousePressEvent') as m: item.mousePressEvent(event) m.assert_called_once_with(event) - assert item.scale_active is False - assert item.rotate_active is False - assert item.flip_active is False + assert item.active_mode is None event.accept.assert_not_called() @@ -945,7 +932,7 @@ def test_mouse_move_event_when_scale_action(view, item): view.scene.addItem(item) event = MagicMock() event.scenePos.return_value = QtCore.QPointF(20, 90) - item.scale_active = True + item.active_mode = item.SCALE_MODE item.event_direction = QtCore.QPointF(1, 1) / math.sqrt(2) item.event_anchor = QtCore.QPointF(100, 80) item.event_start = QtCore.QPointF(10, 10) @@ -965,7 +952,7 @@ def test_mouse_move_event_when_rotate_action(view, item): event = MagicMock() event.scenePos.return_value = QtCore.QPointF(15, 25) item.event_start = QtCore.QPointF(10, 10) - item.rotate_active = True + item.active_mode = item.ROTATE_MODE item.rotate_orig_degrees = 0 item.rotate_start_angle = -3 item.event_anchor = QtCore.QPointF(10, 20) @@ -981,7 +968,7 @@ def test_mouse_move_event_when_flip_action(view, item): event = MagicMock() event.scenePos.return_value = QtCore.QPointF(15, 25) item.event_start = QtCore.QPointF(10, 10) - item.flip_active = True + item.active_mode = item.FLIP_MODE with patch('PyQt6.QtWidgets.QGraphicsPixmapItem.mouseMoveEvent') as m: item.mouseMoveEvent(event) m.assert_not_called() @@ -991,13 +978,13 @@ def test_mouse_move_event_when_flip_action(view, item): def test_mouse_release_event_when_no_action(view, item): view.scene.addItem(item) event = MagicMock() - item.flip_active = True + item.active_mode = item.FLIP_MODE event.pos.return_value = QtCore.QPointF(-100, -100) with patch('PyQt6.QtWidgets.QGraphicsPixmapItem' '.mouseReleaseEvent') as m: item.mouseReleaseEvent(event) m.assert_called_once_with(event) - item.flip_active is False + item.active_mode is None event.accept.assert_not_called() @@ -1005,7 +992,7 @@ def test_mouse_release_event_when_scale_action(view, item): view.scene.addItem(item) event = MagicMock() event.scenePos.return_value = QtCore.QPointF(20, 90) - item.scale_active = True + item.active_mode = item.SCALE_MODE item.event_direction = QtCore.QPointF(1, 1) / math.sqrt(2) item.event_anchor = QtCore.QPointF(100, 80) item.event_start = QtCore.QPointF(10, 10) @@ -1023,7 +1010,7 @@ def test_mouse_release_event_when_scale_action(view, item): assert cmd.factor == approx(1.5, 0.01) assert cmd.anchor == QtCore.QPointF(100, 80) assert cmd.ignore_first_redo is True - assert item.scale_active is False + assert item.active_mode is None event.accept.assert_called_once_with() @@ -1031,7 +1018,7 @@ def test_mouse_release_event_when_scale_action_zero(view, item): view.scene.addItem(item) event = MagicMock() event.scenePos.return_value = QtCore.QPointF(20, 90) - item.scale_active = True + item.active_mode = item.SCALE_MODE item.event_direction = QtCore.QPointF(1, 1) / math.sqrt(2) item.event_anchor = QtCore.QPointF(100, 80) item.event_start = QtCore.QPointF(20, 90) @@ -1042,7 +1029,7 @@ def test_mouse_release_event_when_scale_action_zero(view, item): return_value=QtCore.QRectF(0, 0, 100, 80)): item.mouseReleaseEvent(event) view.scene.undo_stack.push.assert_not_called() - assert item.scale_active is False + assert item.active_mode is None event.accept.assert_called_once_with() @@ -1050,7 +1037,7 @@ def test_mouse_release_event_when_rotate_action(view, item): view.scene.addItem(item) event = MagicMock() event.scenePos.return_value = QtCore.QPointF(15, 25) - item.rotate_active = True + item.active_mode = item.ROTATE_MODE item.rotate_orig_degrees = 0 item.rotate_start_angle = -3 item.event_anchor = QtCore.QPointF(10, 20) @@ -1065,7 +1052,7 @@ def test_mouse_release_event_when_rotate_action(view, item): assert cmd.delta == -42 assert cmd.anchor == QtCore.QPointF(10, 20) assert cmd.ignore_first_redo is True - assert item.rotate_active is False + assert item.active_mode is None event.accept.assert_called_once_with() @@ -1073,7 +1060,7 @@ def test_mouse_release_event_when_rotate_action_zero(view, item): view.scene.addItem(item) event = MagicMock() event.scenePos.return_value = QtCore.QPointF(15, 25) - item.rotate_active = True + item.active_mode = item.ROTATE_MODE item.rotate_orig_degrees = 0 item.rotate_start_angle = -45 item.event_anchor = QtCore.QPointF(10, 20) @@ -1081,7 +1068,7 @@ def test_mouse_release_event_when_rotate_action_zero(view, item): item.mouseReleaseEvent(event) view.scene.undo_stack.push.assert_not_called() - assert item.rotate_active is False + assert item.active_mode is None event.accept.assert_called_once_with() @@ -1089,12 +1076,12 @@ def test_mouse_release_event_when_flip_action(view, item): view.scene.addItem(item) event = MagicMock() event.pos.return_value = QtCore.QPointF(0, 40) - item.flip_active = True + item.active_mode = item.FLIP_MODE view.scene.undo_stack = MagicMock(push=MagicMock()) with patch.object(item, 'bounding_rect_unselected', return_value=QtCore.QRectF(0, 0, 100, 80)): item.mouseReleaseEvent(event) view.scene.undo_stack.push.assert_not_called() - assert item.flip_active is False + assert item.active_mode is None event.accept.assert_called_once_with() diff --git a/tests/test_scene.py b/tests/test_scene.py index fd4e000..28583d1 100644 --- a/tests/test_scene.py +++ b/tests/test_scene.py @@ -495,6 +495,25 @@ def test_crop_item_when_not_image(view): item.enter_crop_mode.assert_not_called() +def test_sample_color_at_when_pixmap_item(view): + color = QtGui.QColor(255, 0, 0, 3) + img = QtGui.QImage(10, 10, QtGui.QImage.Format.Format_ARGB32) + img.fill(color) + item = BeePixmapItem(img, 'foo.png') + view.scene.addItem(item) + assert view.scene.sample_color_at(QtCore.QPointF(2, 2)) == color + + +def test_sample_color_at_when_text_item(view): + item = BeeTextItem('foo bar baz') + view.scene.addItem(item) + assert view.scene.sample_color_at(QtCore.QPointF(2, 2)) is None + + +def test_sample_color_at_when_no_item(view): + assert view.scene.sample_color_at(QtCore.QPointF(2, 2)) is None + + def test_select_all_items_when_true(view): item1 = BeeTextItem('foo') view.scene.addItem(item1) @@ -631,8 +650,7 @@ def test_mouse_press_event_when_left_click_over_item(mouse_mock, view, item): view.scene.mousePressEvent(event) event.accept.assert_not_called() mouse_mock.assert_called_once_with(event) - assert view.scene.move_active is True - assert view.scene.rubberband_active is False + assert view.scene.active_mode == view.scene.MOVE_MODE assert view.scene.event_start == QtCore.QPointF(10, 20) @@ -651,8 +669,7 @@ def test_mouse_press_event_when_left_click_over_item_in_edit_mode( event.accept.assert_not_called() mouse_mock.assert_called_once_with(event) item.exit_edit_mode.assert_not_called() - assert view.scene.move_active is False - assert view.scene.rubberband_active is False + assert view.scene.active_mode is None @patch('PyQt6.QtWidgets.QGraphicsScene.mousePressEvent') @@ -670,8 +687,7 @@ def test_mouse_press_event_when_left_click_over_diff_item_in_edit_mode( event.accept.assert_not_called() mouse_mock.assert_called_once_with(event) txtitem.exit_edit_mode.assert_called_once_with() - assert view.scene.move_active is True - assert view.scene.rubberband_active is False + assert view.scene.active_mode == view.scene.MOVE_MODE @patch('PyQt6.QtWidgets.QGraphicsScene.mousePressEvent') @@ -689,8 +705,7 @@ def test_mouse_press_event_when_left_click_over_no_item_in_edit_mode( event.accept.assert_not_called() mouse_mock.assert_called_once_with(event) item.exit_edit_mode.assert_called_once_with() - assert view.scene.move_active is False - assert view.scene.rubberband_active is True + assert view.scene.active_mode == view.scene.RUBBERBAND_MODE @patch('PyQt6.QtWidgets.QGraphicsScene.mousePressEvent') @@ -707,8 +722,7 @@ def test_mouse_press_event_when_left_click_over_item_in_crop_mode( event.accept.assert_not_called() mouse_mock.assert_called_once_with(event) view.scene.cancel_crop_mode.assert_not_called() - assert view.scene.move_active is False - assert view.scene.rubberband_active is False + assert view.scene.active_mode is None @patch('PyQt6.QtWidgets.QGraphicsScene.mousePressEvent') @@ -726,8 +740,7 @@ def test_mouse_press_event_when_left_click_over_diff_item_in_crop_mode( event.accept.assert_not_called() mouse_mock.assert_called_once_with(event) view.scene.cancel_crop_mode.assert_called_once_with() - assert view.scene.move_active is True - assert view.scene.rubberband_active is False + assert view.scene.active_mode is view.scene.MOVE_MODE @patch('PyQt6.QtWidgets.QGraphicsScene.mousePressEvent') @@ -744,8 +757,7 @@ def test_mouse_press_event_when_left_click_over_no_item_in_crop_mode( event.accept.assert_not_called() mouse_mock.assert_called_once_with(event) view.scene.cancel_crop_mode.assert_called_once_with() - assert view.scene.move_active is False - assert view.scene.rubberband_active is True + assert view.scene.active_mode == view.scene.RUBBERBAND_MODE @patch('PyQt6.QtWidgets.QGraphicsScene.mousePressEvent') @@ -760,8 +772,7 @@ def test_mouse_press_event_when_left_click_not_over_item( view.scene.mousePressEvent(event) event.accept.assert_not_called() mouse_mock.assert_called_once_with(event) - assert view.scene.move_active is False - assert view.scene.rubberband_active is True + assert view.scene.active_mode == view.scene.RUBBERBAND_MODE assert view.scene.event_start == QtCore.QPointF(10, 20) @@ -775,15 +786,14 @@ def test_mouse_press_event_when_no_items(mouse_mock, view): view.scene.mousePressEvent(event) event.accept.assert_not_called() mouse_mock.assert_called_once_with(event) - assert view.scene.move_active is False - assert view.scene.rubberband_active is False + assert view.scene.active_mode is None mouse_mock.assert_called_once_with(event) @patch('PyQt6.QtWidgets.QGraphicsScene.mouseDoubleClickEvent') def test_mouse_doubleclick_event_when_over_item(mouse_mock, view, item): event = MagicMock() - view.scene.move_active = True + view.scene.active_mode = view.scene.MOVE_MODE view.scene.addItem(item) item.setPos(30, 40) item.setSelected(True) @@ -794,7 +804,7 @@ def test_mouse_doubleclick_event_when_over_item(mouse_mock, view, item): return_value=QtCore.QRectF(0, 0, 100, 100)): view.scene.mouseDoubleClickEvent(event) - assert view.scene.move_active is False + assert view.scene.active_mode is None view.fit_rect.assert_called_once_with( QtCore.QRectF(30, 40, 100, 100), toggle_item=item) mouse_mock.assert_not_called() @@ -807,7 +817,7 @@ def test_mouse_doubleclick_event_when_over_editable_item( item = BeeTextItem('foo bar') item.enter_edit_mode = MagicMock() event = MagicMock() - view.scene.move_active = True + view.scene.active_mode = view.scene.MOVE_MODE view.scene.addItem(item) item.setPos(30, 40) item.setSelected(True) @@ -818,7 +828,7 @@ def test_mouse_doubleclick_event_when_over_editable_item( return_value=QtCore.QRectF(0, 0, 100, 100)): view.scene.mouseDoubleClickEvent(event) - assert view.scene.move_active is False + assert view.scene.active_mode is None item.enter_edit_mode.assert_called_once_with() double_mock.assert_not_called() press_mock.assert_called_once_with(event) @@ -828,7 +838,7 @@ def test_mouse_doubleclick_event_when_over_editable_item( def test_mouse_doubleclick_event_when_item_not_selected( mouse_mock, view, item): event = MagicMock() - view.scene.move_active = True + view.scene.active_mode = view.scene.MOVE_MODE view.scene.addItem(item) item.setPos(30, 40) item.setSelected(False) @@ -839,7 +849,7 @@ def test_mouse_doubleclick_event_when_item_not_selected( return_value=QtCore.QRectF(0, 0, 100, 100)): view.scene.mouseDoubleClickEvent(event) - assert view.scene.move_active is False + assert view.scene.active_mode is None view.fit_rect.assert_called_once_with( QtCore.QRectF(30, 40, 100, 100), toggle_item=item) mouse_mock.assert_not_called() @@ -861,7 +871,7 @@ def test_mouse_move_event_when_rubberband_new( mouse_mock, view, imgfilename3x3): item = BeePixmapItem(QtGui.QImage(imgfilename3x3)) view.scene.addItem(item) - view.scene.rubberband_active = True + view.scene.active_mode = view.scene.RUBBERBAND_MODE view.scene.addItem = MagicMock() view.scene.event_start = QtCore.QPointF(0, 0) view.scene.rubberband_item.bring_to_front = MagicMock() @@ -885,7 +895,7 @@ def test_mouse_move_event_when_rubberband_not_new( mouse_mock, view, imgfilename3x3): item = BeePixmapItem(QtGui.QImage(imgfilename3x3)) view.scene.addItem(item) - view.scene.rubberband_active = True + view.scene.active_mode = view.scene.RUBBERBAND_MODE view.scene.event_start = QtCore.QPointF(0, 0) view.scene.rubberband_item.bring_to_front = MagicMock() view.scene.addItem(view.scene.rubberband_item) @@ -908,7 +918,7 @@ def test_mouse_move_event_when_rubberband_not_new( def test_mouse_move_event_when_no_rubberband(mouse_mock, view, imgfilename3x3): item = BeePixmapItem(QtGui.QImage(imgfilename3x3)) view.scene.addItem(item) - view.scene.rubberband_active = False + view.scene.active_mode = None view.scene.event_start = QtCore.QPointF(0, 0) view.scene.rubberband_item.bring_to_front = MagicMock() view.scene.addItem = MagicMock() @@ -929,13 +939,13 @@ def test_mouse_move_event_when_no_rubberband(mouse_mock, view, imgfilename3x3): @patch('PyQt6.QtWidgets.QGraphicsScene.mouseReleaseEvent') def test_mouse_release_event_when_rubberband_active(mouse_mock, view): event = MagicMock() - view.scene.rubberband_active = True + view.scene.active_mode = view.scene.RUBBERBAND_MODE view.scene.addItem(view.scene.rubberband_item) view.scene.removeItem = MagicMock() view.scene.mouseReleaseEvent(event) view.scene.removeItem.assert_called_once_with(view.scene.rubberband_item) - view.scene.rubberband_active is False + view.scene.active_mode is None @patch('PyQt6.QtWidgets.QGraphicsScene.mouseReleaseEvent') @@ -943,7 +953,7 @@ def test_mouse_release_event_when_move_active(mouse_mock, view, item): view.scene.addItem(item) item.setSelected(True) event = MagicMock(scenePos=MagicMock(return_value=QtCore.QPoint(10, 20))) - view.scene.move_active = True + view.scene.active_mode = view.scene.MOVE_MODE view.scene.event_start = QtCore.QPoint(0, 0) view.scene.undo_stack = MagicMock(push=MagicMock()) @@ -957,7 +967,7 @@ def test_mouse_release_event_when_move_active(mouse_mock, view, item): assert cmd.delta.x() == 10 assert cmd.delta.y() == 20 mouse_mock.assert_called_once_with(event) - assert view.scene.move_active is False + assert view.scene.active_mode is None @patch('PyQt6.QtWidgets.QGraphicsScene.mouseReleaseEvent') @@ -965,13 +975,13 @@ def test_mouse_release_event_when_move_not_active(mouse_mock, view, item): view.scene.addItem(item) item.setSelected(True) event = MagicMock(scenePos=MagicMock(return_value=QtCore.QPoint(10, 20))) - view.scene.move_active = False + view.scene.active_mode = None view.scene.undo_stack = MagicMock(push=MagicMock()) view.scene.mouseReleaseEvent(event) view.scene.undo_stack.push.assert_not_called() mouse_mock.assert_called_once_with(event) - assert view.scene.move_active is False + assert view.scene.active_mode is None @patch('PyQt6.QtWidgets.QGraphicsScene.mouseReleaseEvent') @@ -979,13 +989,13 @@ def test_mouse_release_event_when_no_selection(mouse_mock, view, item): view.scene.addItem(item) item.setSelected(False) event = MagicMock(scenePos=MagicMock(return_value=QtCore.QPoint(10, 20))) - view.scene.move_active = True + view.scene.active_mode = view.scene.MOVE_MODE view.scene.undo_stack = MagicMock(push=MagicMock()) view.scene.mouseReleaseEvent(event) view.scene.undo_stack.push.assert_not_called() mouse_mock.assert_called_once_with(event) - assert view.scene.move_active is False + assert view.scene.active_mode is None @patch('PyQt6.QtWidgets.QGraphicsScene.mouseReleaseEvent') @@ -993,14 +1003,14 @@ def test_mouse_release_event_when_item_action_active(mouse_mock, view, item): view.scene.addItem(item) item.setSelected(True) event = MagicMock(scenePos=MagicMock(return_value=QtCore.QPoint(10, 20))) - view.scene.move_active = True - item.scale_active = True + item.active_mode = item.SCALE_MODE + view.scene.active_mode = view.scene.MOVE_MODE view.scene.undo_stack = MagicMock(push=MagicMock()) view.scene.mouseReleaseEvent(event) view.scene.undo_stack.push.assert_not_called() mouse_mock.assert_called_once_with(event) - assert view.scene.move_active is False + assert view.scene.active_mode is None @patch('PyQt6.QtWidgets.QGraphicsScene.mouseReleaseEvent') @@ -1012,14 +1022,14 @@ def test_mouse_release_event_when_multiselect_action_active(mouse_mock, view): view.scene.addItem(item2) item2.setSelected(True) event = MagicMock(scenePos=MagicMock(return_value=QtCore.QPoint(10, 20))) - view.scene.move_active = True - view.scene.multi_select_item.scale_active = True + view.scene.active_mode = view.scene.MOVE_MODE + view.scene.multi_select_item.active_mode = BeePixmapItem.SCALE_MODE view.scene.undo_stack = MagicMock(push=MagicMock()) view.scene.mouseReleaseEvent(event) view.scene.undo_stack.push.assert_not_called() mouse_mock.assert_called_once_with(event) - assert view.scene.move_active is False + assert view.scene.active_mode is None def test_selected_items(view): @@ -1229,8 +1239,7 @@ def test_on_selection_change_when_multi_selection_ended(view): def test_on_change_when_multi_select_when_no_scale_no_rotate(view): view.scene.addItem(view.scene.multi_select_item) view.scene.multi_select_item.fit_selection_area = MagicMock() - view.scene.multi_select_item.scale_active = False - view.scene.multi_select_item.rotate_active = False + view.scene.multi_select_item.active_mode = None view.scene.on_change(None) view.scene.multi_select_item.fit_selection_area.assert_called_once() @@ -1238,8 +1247,7 @@ def test_on_change_when_multi_select_when_no_scale_no_rotate(view): def test_on_change_when_multi_select_when_scale_active(view): view.scene.addItem(view.scene.multi_select_item) view.scene.multi_select_item.fit_selection_area = MagicMock() - view.scene.multi_select_item.scale_active = True - view.scene.multi_select_item.rotate_active = False + view.scene.multi_select_item.active_mode = BeePixmapItem.SCALE_MODE view.scene.on_change(None) view.scene.multi_select_item.fit_selection_area.assert_not_called() @@ -1247,16 +1255,14 @@ def test_on_change_when_multi_select_when_scale_active(view): def test_on_change_when_multi_select_when_rotate_active(view): view.scene.addItem(view.scene.multi_select_item) view.scene.multi_select_item.fit_selection_area = MagicMock() - view.scene.multi_select_item.scale_active = False - view.scene.multi_select_item.rotate_active = True + view.scene.multi_select_item.active_mode = BeePixmapItem.ROTATE_MODE view.scene.on_change(None) view.scene.multi_select_item.fit_selection_area.assert_not_called() def test_on_change_when_no_multi_select(view): view.scene.multi_select_item.fit_selection_area = MagicMock() - view.scene.multi_select_item.scale_active = True - view.scene.multi_select_item.rotate_active = True + view.scene.multi_select_item.active_mode = BeePixmapItem.SCALE_MODE view.scene.on_change(None) view.scene.multi_select_item.fit_selection_area.assert_not_called() diff --git a/tests/test_utils.py b/tests/test_utils.py index 48b9845..4d26161 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -71,3 +71,10 @@ def test_round_to(number, base, expected): ('JPEG (*.jpg *.jpeg)', 'jpg')]) def test_get_file_extension_from_format(formatstr, expected): assert utils.get_file_extension_from_format(formatstr) == expected + + +@pytest.mark.parametrize('rgba,expected', + [((255, 0, 0, 255), '#ff0000'), + ((255, 0, 0, 100), '#ff000064')]) +def test_qcolor_to_hex(rgba, expected): + assert utils.qcolor_to_hex(QtGui.QColor(*rgba)) == expected diff --git a/tests/test_view.py b/tests/test_view.py index 2a72d6b..1885ce6 100644 --- a/tests/test_view.py +++ b/tests/test_view.py @@ -6,6 +6,7 @@ from unittest.mock import MagicMock, patch, mock_open from PyQt6 import QtCore, QtGui, QtWidgets from PyQt6.QtCore import Qt +from beeref import widgets from beeref.actions import actions from beeref.config import logfile_name from beeref.items import BeePixmapItem, BeeTextItem @@ -185,7 +186,7 @@ def test_on_action_open(dialog_mock, view, qtbot): filename = os.path.join(root, 'assets', 'test1item.bee') dialog_mock.return_value = (filename, None) view.on_loading_finished = MagicMock() - view.scene.cancel_crop_mode = MagicMock() + view.cancel_active_modes = MagicMock() view.on_action_open() qtbot.waitUntil(lambda: view.on_loading_finished.called is True) @@ -194,31 +195,31 @@ def test_on_action_open(dialog_mock, view, qtbot): assert item.isSelected() is False assert item.pixmap() view.on_loading_finished.assert_called_once_with(filename, []) - view.scene.cancel_crop_mode.assert_called_once_with() + view.cancel_active_modes.assert_called_with() @patch('PyQt6.QtWidgets.QFileDialog.getOpenFileName') @patch('beeref.view.BeeGraphicsView.open_from_file') def test_on_action_open_when_no_filename(open_mock, dialog_mock, view): dialog_mock.return_value = (None, None) - view.scene.cancel_crop_mode = MagicMock() + view.cancel_active_modes = MagicMock() view.on_action_open() open_mock.assert_not_called() - view.scene.cancel_crop_mode.assert_called_once_with() + view.cancel_active_modes.assert_called_once_with() @patch('PyQt6.QtWidgets.QFileDialog.getSaveFileName') def test_on_action_save_as(dialog_mock, view, imgfilename3x3, tmpdir): item = BeePixmapItem(QtGui.QImage(imgfilename3x3)) view.scene.addItem(item) - view.scene.cancel_crop_mode = MagicMock() + view.cancel_active_modes = MagicMock() filename = os.path.join(tmpdir, 'test.bee') assert os.path.exists(filename) is False dialog_mock.return_value = (filename, None) view.on_action_save_as() view.worker.wait() assert os.path.exists(filename) is True - view.scene.cancel_crop_mode.assert_called_once_with() + view.cancel_active_modes.assert_called_once_with() @patch('PyQt6.QtWidgets.QFileDialog.getSaveFileName') @@ -227,11 +228,11 @@ def test_on_action_save_as_when_no_filename( save_mock, dialog_mock, view, imgfilename3x3): item = BeePixmapItem(QtGui.QImage(imgfilename3x3)) view.scene.addItem(item) - view.scene.cancel_crop_mode = MagicMock() + view.cancel_active_modes = MagicMock() dialog_mock.return_value = (None, None) view.on_action_save_as() save_mock.assert_not_called() - view.scene.cancel_crop_mode.assert_called_once_with() + view.cancel_active_modes.assert_called_once_with() @patch('PyQt6.QtWidgets.QFileDialog.getSaveFileName') @@ -239,7 +240,7 @@ def test_on_action_save_as_filename_doesnt_end_with_bee( dialog_mock, view, qtbot, imgfilename3x3, tmpdir): item = BeePixmapItem(QtGui.QImage(imgfilename3x3)) view.scene.addItem(item) - view.scene.cancel_crop_mode = MagicMock() + view.cancel_active_modes = MagicMock() view.on_saving_finished = MagicMock() filename = os.path.join(tmpdir, 'test') assert os.path.exists(filename) is False @@ -248,7 +249,7 @@ def test_on_action_save_as_filename_doesnt_end_with_bee( qtbot.waitUntil(lambda: view.on_saving_finished.called is True) assert os.path.exists(f'{filename}.bee') is True view.on_saving_finished.assert_called_once_with(f'{filename}.bee', []) - view.scene.cancel_crop_mode.assert_called_once_with() + view.cancel_active_modes.assert_called_once_with() @patch('PyQt6.QtWidgets.QFileDialog.getSaveFileName') @@ -258,20 +259,20 @@ def test_on_action_save_as_when_error( item = BeePixmapItem(QtGui.QImage(imgfilename3x3)) view.scene.addItem(item) view.on_saving_finished = MagicMock() - view.scene.cancel_crop_mode = MagicMock() + view.cancel_active_modes = MagicMock() filename = os.path.join(tmpdir, 'test.bee') dialog_mock.return_value = (filename, None) save_mock.side_effect = sqlite3.Error('foo') view.on_action_save_as() qtbot.waitUntil(lambda: view.on_saving_finished.called is True) view.on_saving_finished.assert_called_once_with(filename, ['foo']) - view.scene.cancel_crop_mode.assert_called_once_with() + view.cancel_active_modes.assert_called_once_with() def test_on_action_save(view, qtbot, imgfilename3x3, tmpdir): item = BeePixmapItem(QtGui.QImage(imgfilename3x3)) view.scene.addItem(item) - view.scene.cancel_crop_mode = MagicMock() + view.cancel_active_modes = MagicMock() view.filename = os.path.join(tmpdir, 'test.bee') root = os.path.dirname(__file__) shutil.copyfile(os.path.join(root, 'assets', 'test1item.bee'), @@ -281,18 +282,18 @@ def test_on_action_save(view, qtbot, imgfilename3x3, tmpdir): qtbot.waitUntil(lambda: view.on_saving_finished.called is True) assert os.path.exists(view.filename) is True view.on_saving_finished.assert_called_once_with(view.filename, []) - view.scene.cancel_crop_mode.assert_called_once_with() + view.cancel_active_modes.assert_called_once_with() @patch('beeref.view.BeeGraphicsView.on_action_save_as') def test_on_action_save_when_no_filename(save_as_mock, view, imgfilename3x3): item = BeePixmapItem(QtGui.QImage(imgfilename3x3)) view.scene.addItem(item) - view.scene.cancel_crop_mode = MagicMock() + view.cancel_active_modes = MagicMock() view.filename = None view.on_action_save() save_as_mock.assert_called_once_with() - view.scene.cancel_crop_mode.assert_called_once_with() + view.cancel_active_modes.assert_called_once_with() @patch('beeref.widgets.SceneToPixmapExporterDialog.exec') @@ -401,7 +402,7 @@ def test_on_action_insert_images_new_scene( dialog_mock, clear_mock, view, imgfilename3x3, qtbot): dialog_mock.return_value = ([imgfilename3x3], None) view.on_insert_images_finished = MagicMock() - view.scene.cancel_crop_mode = MagicMock() + view.cancel_active_modes = MagicMock() view.on_action_insert_images() qtbot.waitUntil(lambda: view.on_insert_images_finished.called is True) assert len(view.scene.items()) == 1 @@ -410,7 +411,7 @@ def test_on_action_insert_images_new_scene( assert item.pixmap() clear_mock.assert_called_once_with() view.on_insert_images_finished.assert_called_once_with(True, '', []) - view.scene.cancel_crop_mode.assert_called_once_with() + view.cancel_active_modes.assert_called_once_with() @patch('beeref.scene.BeeGraphicsScene.clearSelection') @@ -420,7 +421,7 @@ def test_on_action_insert_images_existing_scene( view.scene.addItem(item) dialog_mock.return_value = ([imgfilename3x3], None) view.on_insert_images_finished = MagicMock() - view.scene.cancel_crop_mode = MagicMock() + view.cancel_active_modes = MagicMock() view.on_action_insert_images() qtbot.waitUntil(lambda: view.on_insert_images_finished.called is True) assert len(view.scene.items()) == 2 @@ -429,7 +430,7 @@ def test_on_action_insert_images_existing_scene( assert item.pixmap() clear_mock.assert_called_once_with() view.on_insert_images_finished.assert_called_once_with(False, '', []) - view.scene.cancel_crop_mode.assert_called_once_with() + view.cancel_active_modes.assert_called_once_with() @patch('beeref.scene.BeeGraphicsScene.clearSelection') @@ -438,7 +439,7 @@ def test_on_action_insert_images_when_error( dialog_mock, clear_mock, view, imgfilename3x3, qtbot): dialog_mock.return_value = ([imgfilename3x3, 'iaeiae', 'trntrn'], None) view.on_insert_images_finished = MagicMock() - view.scene.cancel_crop_mode = MagicMock() + view.cancel_active_modes = MagicMock() view.on_action_insert_images() qtbot.waitUntil(lambda: view.on_insert_images_finished.called is True) assert len(view.scene.items()) == 1 @@ -448,26 +449,26 @@ def test_on_action_insert_images_when_error( clear_mock.assert_called_once_with() view.on_insert_images_finished.assert_called_once_with( True, '', ['iaeiae', 'trntrn']) - view.scene.cancel_crop_mode.assert_called_once_with() + view.cancel_active_modes.assert_called_once_with() @patch('beeref.scene.BeeGraphicsScene.clearSelection') def test_on_action_insert_text(clear_mock, view): - view.scene.cancel_crop_mode = MagicMock() + view.cancel_active_modes = MagicMock() 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 - view.scene.cancel_crop_mode.assert_called_once_with() + view.cancel_active_modes.assert_called_once_with() @patch('PyQt6.QtWidgets.QApplication.clipboard') def test_on_action_copy_image(clipboard_mock, view, imgfilename3x3): item = BeePixmapItem(QtGui.QImage(imgfilename3x3)) view.scene.addItem(item) - view.scene.cancel_crop_mode = MagicMock() + view.cancel_active_modes = MagicMock() item.setSelected(True) mimedata = QtCore.QMimeData() clipboard_mock.return_value.mimeData.return_value = mimedata @@ -476,14 +477,14 @@ def test_on_action_copy_image(clipboard_mock, view, imgfilename3x3): clipboard_mock.return_value.setPixmap.assert_called_once() view.scene.internal_clipboard == [item] assert mimedata.data('beeref/items') == b'1' - view.scene.cancel_crop_mode.assert_called_once_with() + view.cancel_active_modes.assert_called_once_with() @patch('PyQt6.QtWidgets.QApplication.clipboard') def test_on_action_copy_text(clipboard_mock, view, imgfilename3x3): item = BeeTextItem('foo bar') view.scene.addItem(item) - view.scene.cancel_crop_mode = MagicMock() + view.cancel_active_modes = MagicMock() item.setSelected(True) mimedata = QtCore.QMimeData() clipboard_mock.return_value.mimeData.return_value = mimedata @@ -492,7 +493,7 @@ def test_on_action_copy_text(clipboard_mock, view, imgfilename3x3): clipboard_mock.return_value.setText.assert_called_once_with('foo bar') view.scene.internal_clipboard == [item] assert mimedata.data('beeref/items') == b'1' - view.scene.cancel_crop_mode.assert_called_once_with() + view.cancel_active_modes.assert_called_once_with() @patch('beeref.view.BeeGraphicsView.on_action_fit_scene') @@ -501,12 +502,12 @@ def test_on_action_copy_text(clipboard_mock, view, imgfilename3x3): def test_on_action_paste_external_new_scene( clipboard_mock, clear_mock, fit_mock, view, imgfilename3x3): clipboard_mock.return_value = QtGui.QImage(imgfilename3x3) - view.scene.cancel_crop_mode = MagicMock() + view.cancel_active_modes = MagicMock() view.on_action_paste() assert len(view.scene.items()) == 1 assert view.scene.items()[0].isSelected() is True fit_mock.assert_called_once_with() - view.scene.cancel_crop_mode.assert_called_once_with() + view.cancel_active_modes.assert_called_once_with() @patch('beeref.view.BeeGraphicsView.on_action_fit_scene') @@ -515,14 +516,14 @@ def test_on_action_paste_external_new_scene( def test_on_action_paste_external_existing_scene( clipboard_mock, clear_mock, fit_mock, view, item, imgfilename3x3): view.scene.addItem(item) - view.scene.cancel_crop_mode = MagicMock() + view.cancel_active_modes = MagicMock() clipboard_mock.return_value = QtGui.QImage(imgfilename3x3) view.on_action_paste() assert len(view.scene.items()) == 2 assert view.scene.items()[0].isSelected() is True assert view.scene.items()[1].isSelected() is False fit_mock.assert_not_called() - view.scene.cancel_crop_mode.assert_called_once_with() + view.cancel_active_modes.assert_called_once_with() @patch('beeref.scene.BeeGraphicsScene.clearSelection') @@ -533,12 +534,12 @@ def test_on_action_paste_internal(mimedata_mock, clear_mock, view): mimedata_mock.return_value = mimedata item = BeePixmapItem(QtGui.QImage()) view.scene.internal_clipboard = [item] - view.scene.cancel_crop_mode = MagicMock() + view.cancel_active_modes = MagicMock() view.on_action_paste() assert len(view.scene.items()) == 1 assert view.scene.items()[0].isSelected() is True clear_mock.assert_called_once_with() - view.scene.cancel_crop_mode.assert_called() + view.cancel_active_modes.assert_called() @patch('beeref.scene.BeeGraphicsScene.clearSelection') @@ -547,26 +548,26 @@ def test_on_action_paste_internal(mimedata_mock, clear_mock, view): 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.scene.cancel_crop_mode = MagicMock() + view.cancel_active_modes = MagicMock() 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() - view.scene.cancel_crop_mode.assert_called_once_with() + view.cancel_active_modes.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): - view.scene.cancel_crop_mode = MagicMock() + view.cancel_active_modes = MagicMock() 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() - view.scene.cancel_crop_mode.assert_not_called() + view.cancel_active_modes.assert_called_once_with() @patch('beeref.view.BeeGraphicsView.on_action_copy') @@ -644,6 +645,15 @@ def test_on_action_reset_transforms(view, item): assert item.scale() == 1 +def test_on_action_sample_color(view): + view.cancel_active_modes = MagicMock() + view.on_action_sample_color() + assert view.active_mode == view.SAMPLE_COLOR_MODE + assert isinstance(view.sample_color_widget, widgets.SampleColorWidget) + assert view.viewport().cursor() == Qt.CursorShape.CrossCursor + view.cancel_active_modes.assert_called_once_with() + + @patch('PyQt6.QtWidgets.QWidget.create') @patch('PyQt6.QtWidgets.QWidget.destroy') @patch('PyQt6.QtWidgets.QWidget.show') @@ -742,13 +752,13 @@ def test_on_action_deselect_all(view, item): def test_on_action_delete_items(view, item): - view.scene.cancel_crop_mode = MagicMock() + view.cancel_active_modes = MagicMock() view.scene.addItem(item) item.setSelected(True) view.on_action_delete_items() assert view.scene.items() == [] assert view.undo_stack.isClean() is False - view.scene.cancel_crop_mode.assert_called_once() + view.cancel_active_modes.assert_called_once() @patch('beeref.widgets.ChangeOpacityDialog.__init__', @@ -789,6 +799,18 @@ def test_on_action_grayscale(view): assert pixmapitem2.grayscale is False +def test_cancel_active_modes_when_sample_color_mode(view): + view.active_mode = view.SAMPLE_COLOR_MODE + view.sample_color_widget = widgets.SampleColorWidget( + view, MagicMock(), MagicMock()) + view.viewport().setCursor(Qt.CursorShape.CrossCursor) + view.cancel_active_modes() + + assert view.active_mode is None + assert hasattr(view, 'sample_color_widget') is False + assert view.viewport().cursor() == Qt.CursorShape.ArrowCursor + + @patch('PyQt6.QtGui.QUndoStack.isClean', return_value=True) def test_update_window_title_no_changes_no_filename(clear_mock, view): view.filename = None @@ -920,9 +942,7 @@ def test_mouse_press_zoom(mouse_event_mock, view): event.button.return_value = Qt.MouseButton.MiddleButton event.modifiers.return_value = Qt.KeyboardModifier.ControlModifier view.mousePressEvent(event) - assert view.zoom_active is True - assert view.pan_active is False - assert view.movewin_active is False + assert view.active_mode == view.ZOOM_MODE assert view.event_start == QtCore.QPointF(10.0, 20.0) assert view.event_anchor == QtCore.QPointF(10.0, 20.0) mouse_event_mock.assert_not_called() @@ -936,9 +956,7 @@ def test_mouse_press_pan_middle_drag(mouse_event_mock, view): event.button.return_value = Qt.MouseButton.MiddleButton event.modifiers.return_value = None view.mousePressEvent(event) - assert view.pan_active is True - assert view.zoom_active is False - assert view.movewin_active is False + assert view.active_mode == view.PAN_MODE assert view.event_start == QtCore.QPointF(10.0, 20.0) mouse_event_mock.assert_not_called() view.cursor() == Qt.CursorShape.ClosedHandCursor @@ -952,15 +970,71 @@ def test_mouse_press_pan_alt_left_drag(mouse_event_mock, view): event.button.return_value = Qt.MouseButton.LeftButton event.modifiers.return_value = Qt.KeyboardModifier.AltModifier view.mousePressEvent(event) - assert view.pan_active is True - assert view.zoom_active is False - assert view.movewin_active is False + assert view.active_mode == view.PAN_MODE assert view.event_start == QtCore.QPointF(10.0, 20.0) mouse_event_mock.assert_not_called() view.cursor() == Qt.CursorShape.ClosedHandCursor event.accept.assert_called_once_with() +@patch('beeref.widgets.BeeNotification') +@patch('PyQt6.QtWidgets.QGraphicsView.mousePressEvent') +def test_mouse_press_sample_color_when_color( + mouse_event_mock, notification_mock, view): + view.scene.sample_color_at = MagicMock( + return_value=QtGui.QColor(255, 0, 0, 255)) + view.active_mode = view.SAMPLE_COLOR_MODE + event = MagicMock() + event.pos.return_value = QtCore.QPoint(2, 2) + event.button.return_value = Qt.MouseButton.LeftButton + + view.mousePressEvent(event) + assert QtWidgets.QApplication.clipboard().text() == '#ff0000' + notification_mock.assert_called_once_with( + view, 'Copied color to clipboard: #ff0000') + assert view.active_mode is None + view.scene.sample_color_at.assert_called_once() + mouse_event_mock.assert_not_called() + + +@patch('beeref.widgets.BeeNotification') +@patch('PyQt6.QtWidgets.QGraphicsView.mousePressEvent') +def test_mouse_press_sample_color_when_color_with_alpha( + mouse_event_mock, notification_mock, view): + view.scene.sample_color_at = MagicMock( + return_value=QtGui.QColor(255, 0, 0, 100)) + view.active_mode = view.SAMPLE_COLOR_MODE + event = MagicMock() + event.pos.return_value = QtCore.QPoint(2, 2) + event.button.return_value = Qt.MouseButton.LeftButton + + view.mousePressEvent(event) + assert QtWidgets.QApplication.clipboard().text() == '#ff000064' + notification_mock.assert_called_once_with( + view, 'Copied color to clipboard: #ff000064') + assert view.active_mode is None + view.scene.sample_color_at.assert_called_once() + mouse_event_mock.assert_not_called() + + +@patch('beeref.widgets.BeeNotification') +@patch('PyQt6.QtWidgets.QGraphicsView.mousePressEvent') +def test_mouse_press_sample_color_when_no_color( + mouse_event_mock, notification_mock, view): + view.scene.sample_color_at = MagicMock(return_value=None) + view.active_mode = view.SAMPLE_COLOR_MODE + event = MagicMock() + event.pos.return_value = QtCore.QPoint(2, 2) + event.button.return_value = Qt.MouseButton.LeftButton + + view.mousePressEvent(event) + notification_mock.assert_not_called() + assert view.active_mode is None + view.scene.sample_color_at.assert_called_once() + mouse_event_mock.assert_not_called() + event.accept.assert_called_once_with() + + @patch('PyQt6.QtWidgets.QGraphicsView.mousePressEvent') @patch('beeref.view.BeeGraphicsView.cursor') def test_mouse_press_move_window(cursor_mock, mouse_event_mock, view): @@ -971,8 +1045,7 @@ def test_mouse_press_move_window(cursor_mock, mouse_event_mock, view): event.modifiers.return_value = ( Qt.KeyboardModifier.AltModifier | Qt.KeyboardModifier.ControlModifier) view.mousePressEvent(event) - assert view.pan_active is False - assert view.zoom_active is False + assert view.active_mode is None assert view.movewin_active is True assert view.event_start == view.mapToGlobal(QtCore.QPointF(10.0, 20.0)) mouse_event_mock.assert_not_called() @@ -987,6 +1060,16 @@ def test_mouse_press_when_move_window_active(mouse_event_mock, view): mouse_event_mock.assert_not_called() +@patch('PyQt6.QtWidgets.QGraphicsView.keyPressEvent') +def test_key_press_when_sample_color_mode(key_event_mock, view): + view.active_mode = view.SAMPLE_COLOR_MODE + event = MagicMock() + view.keyPressEvent(event) + assert view.active_mode is None + event.accept.assert_called_once_with() + key_event_mock.assert_not_called() + + @patch('PyQt6.QtWidgets.QGraphicsView.keyPressEvent') def test_key_press_when_move_window_active(key_event_mock, view): view.movewin_active = True @@ -1001,9 +1084,7 @@ def test_mouse_press_unhandled(mouse_event_mock, view): event.button.return_value = Qt.MouseButton.LeftButton event.modifiers.return_value = None view.mousePressEvent(event) - assert view.pan_active is False - assert view.zoom_active is False - assert view.movewin_active is False + assert view.active_mode is None mouse_event_mock.assert_called_once_with(event) event.accept.assert_not_called() @@ -1011,7 +1092,7 @@ def test_mouse_press_unhandled(mouse_event_mock, view): @patch('PyQt6.QtWidgets.QGraphicsView.mouseMoveEvent') @patch('beeref.view.BeeGraphicsView.pan') def test_mouse_move_pan(pan_mock, mouse_event_mock, view): - view.pan_active = True + view.active_mode = view.PAN_MODE view.event_start = QtCore.QPointF(55.0, 66.0) event = MagicMock() event.position.return_value = QtCore.QPointF(10.0, 20.0) @@ -1024,7 +1105,7 @@ def test_mouse_move_pan(pan_mock, mouse_event_mock, view): @patch('PyQt6.QtWidgets.QGraphicsView.mouseMoveEvent') @patch('beeref.view.BeeGraphicsView.zoom') def test_mouse_move_zoom(zoom_mock, mouse_event_mock, view): - view.zoom_active = True + view.active_mode = view.ZOOM_MODE view.event_anchor = QtCore.QPointF(55.0, 66.0) view.event_start = QtCore.QPointF(10.0, 20.0) event = MagicMock() @@ -1035,6 +1116,24 @@ def test_mouse_move_zoom(zoom_mock, mouse_event_mock, view): event.accept.assert_called_once_with() +@patch('PyQt6.QtWidgets.QGraphicsView.mouseMoveEvent') +def test_mouse_move_sample_color(mouse_event_mock, view): + view.active_mode = view.SAMPLE_COLOR_MODE + view.scene.sample_color_at = MagicMock( + return_value=QtGui.QColor(255, 0, 0, 255)) + view.sample_color_widget = MagicMock() + event = MagicMock() + event.pos.return_value = QtCore.QPoint(2, 2) + event.position.return_value = QtCore.QPointF(10.0, 18.0) + view.mouseMoveEvent(event) + view.scene.sample_color_at.assert_called_once() + view.sample_color_widget.update.assert_called_once_with( + QtCore.QPointF(10.0, 18.0), + QtGui.QColor(255, 0, 0, 255)) + mouse_event_mock.assert_not_called() + event.accept.assert_called_once_with() + + @patch('PyQt6.QtWidgets.QGraphicsView.mouseMoveEvent') @patch('PyQt6.QtWidgets.QWidget.move') def test_mouse_move_movewin(move_mock, mouse_event_mock, view): @@ -1060,11 +1159,11 @@ def test_mouse_move_unhandled(mouse_event_mock, view): @patch('PyQt6.QtWidgets.QGraphicsView.mouseReleaseEvent') def test_mouse_release_pan(mouse_event_mock, view): event = MagicMock() - view.pan_active = True + view.active_mode = view.PAN_MODE view.setCursor(Qt.CursorShape.ClosedHandCursor) view.mouseReleaseEvent(event) mouse_event_mock.assert_not_called() - assert view.pan_active is False + assert view.active_mode is None event.accept.assert_called_once_with() view.cursor() == Qt.CursorShape.ArrowCursor @@ -1072,10 +1171,10 @@ def test_mouse_release_pan(mouse_event_mock, view): @patch('PyQt6.QtWidgets.QGraphicsView.mouseReleaseEvent') def test_mouse_release_zoom(mouse_event_mock, view): event = MagicMock() - view.zoom_active = True + view.active_mode = view.ZOOM_MODE view.mouseReleaseEvent(event) mouse_event_mock.assert_not_called() - assert view.zoom_active is False + assert view.active_mode is None event.accept.assert_called_once_with() diff --git a/tests/widgets/test_widgets.py b/tests/widgets/test_widgets.py index bb85d3b..2aff5e3 100644 --- a/tests/widgets/test_widgets.py +++ b/tests/widgets/test_widgets.py @@ -1,10 +1,14 @@ +from unittest.mock import patch, MagicMock + from PyQt6 import QtCore, QtWidgets, QtGui from PyQt6.QtCore import Qt from beeref.config import logfile_name from beeref.widgets import ( + BeeNotification, ChangeOpacityDialog, DebugLogDialog, + SampleColorWidget, SceneToPixmapExporterDialog, ) @@ -94,3 +98,44 @@ def test_change_opacity_dialog_reject(view, item): dlg.reject() assert item.opacity() == 0.6 assert len(stack) == 0 + + +@patch('PyQt6.QtCore.QTimer.singleShot') +def test_bee_notification(single_shot_mock, view): + widget = BeeNotification(view, 'Hello World') + assert widget.label.text() == 'Hello World' + single_shot_mock.assert_called_once_with(1000 * 3, widget.deleteLater) + + +def test_sample_color_widget(view): + widget = SampleColorWidget( + view, QtCore.QPoint(2, 5), QtGui.QColor(255, 0, 0)) + assert widget.color == QtGui.QColor(255, 0, 0) + assert widget.geometry() == QtCore.QRect(12, 15, 50, 50) + + widget.update(QtCore.QPoint(13, 15), QtGui.QColor(0, 255, 0)) + assert widget.color == QtGui.QColor(0, 255, 0) + assert widget.geometry() == QtCore.QRect(23, 25, 50, 50) + + +def test_sample_color_widget_paint_event_when_color(view): + widget = SampleColorWidget( + view, QtCore.QPoint(2, 5), QtGui.QColor(255, 0, 0)) + with patch('PyQt6.QtGui.QPainter') as painter_cls_mock: + painter_mock = MagicMock() + painter_cls_mock.return_value = painter_mock + widget.paintEvent(MagicMock()) + brush = QtGui.QBrush(QtGui.QColor(255, 0, 0)) + painter_mock.setBrush.assert_called_once_with(brush) + painter_mock.drawRect.assert_called_once_with(0, 0, 50, 50) + + +def test_sample_color_widget_paint_event_when_no_color(view): + widget = SampleColorWidget(view, QtCore.QPoint(2, 5), None) + with patch('PyQt6.QtGui.QPainter') as painter_cls_mock: + painter_mock = MagicMock() + painter_cls_mock.return_value = painter_mock + widget.paintEvent(MagicMock()) + brush = QtGui.QBrush(QtGui.QColor(0, 0, 0, 0)) + painter_mock.setBrush.assert_called_once_with(brush) + painter_mock.drawRect.assert_called_once_with(0, 0, 50, 50)