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 @@ def __getitem__(self, key): '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 @@ '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 207184b..0d5f14a 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 @@ def selection_action_items(self): 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): @@ -140,6 +143,18 @@ def grayscale(self, value): 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) diff --git a/beeref/scene.py b/beeref/scene.py index 3fde06b..773d3b1 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 @@ def removeItem(self, item): 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): + logger.debug('Ending rubberband selection') + if self.rubberband_item.scene(): + 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 @@ def paste_from_internal_clipboard(self, position): 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 @@ def raise_to_top(self): 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 @@ def normalize_width_or_height(self, mode): :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 @@ def normalize_size(self): 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 @@ def normalize_size(self): 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 @@ def arrange(self, vertical=False): 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 @@ def arrange_optimal(self): 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 @@ def crop_items(self): 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 @@ def mousePressEvent(self, event): 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 @@ def mouseDoubleClickEvent(self, event): 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 @@ def mouseMoveEvent(self, event): 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 @@ def on_selection_change(self): 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 @@ def init_selectable(self): | 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 @@ def mousePressEvent(self, event): # 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 @@ def mousePressEvent(self, event): # 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 @@ def mousePressEvent(self, event): # 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 @@ def mouseMoveEvent(self, event): 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 @@ def mouseMoveEvent(self, event): 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 @@ def mouseMoveEvent(self, event): 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 @@ def mouseReleaseEvent(self, event): 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 @@ def mouseReleaseEvent(self, event): 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/view.py b/beeref/view.py index a1bd242..1d24768 100644 --- a/beeref/view.py +++ b/beeref/view.py @@ -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 @@ def __init__(self, app, parent=None): 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 @@ def filename(self, value): 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 @@ def get_view_center(self): 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 @@ def on_action_move_window(self): 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 @@ def on_action_deselect_all(self): 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 @@ def on_action_flip_vertically(self): 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 @@ def open_from_file(self, filename): 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 @@ def do_save(self, filename, create_new): 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 @@ def on_action_save_as(self): 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: @@ -544,7 +573,7 @@ def on_action_insert_text(self): 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) @@ -606,11 +635,11 @@ def on_selection_changed(self): 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): @@ -715,9 +744,26 @@ def mousePressEvent(self, event): 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: + clipboard = QtWidgets.QApplication.clipboard() + clipboard.setText(color.name()) + self.scene.internal_clipboard = [] + msg = f'Copied color to clipboard: {color.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() @@ -727,7 +773,7 @@ def mousePressEvent(self, event): 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 @@ -739,7 +785,7 @@ def mousePressEvent(self, event): 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) @@ -747,7 +793,7 @@ def mouseMoveEvent(self, event): 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() @@ -756,19 +802,24 @@ def mouseMoveEvent(self, event): 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()))) + 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): @@ -783,4 +834,6 @@ def resizeEvent(self, event): def keyPressEvent(self, event): if self.keyPressEventMainControls(event): return + if self.active_mode == self.SAMPLE_COLOR_MODE: + self.cancel_sample_color_mode() super().keyPressEvent(event) diff --git a/beeref/widgets/__init__.py b/beeref/widgets/__init__.py index 4b51c11..70d654a 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,47 @@ def accept(self): def reject(self): self.command.undo() return super().reject() + + +class BeeNotification(QtWidgets.QWidget): + def __init__(self, parent, text): + super().__init__(parent) + label = QtWidgets.QLabel(text) + self.setObjectName('BeeNotification') + self.setAutoFillBackground(True) + layout = QtWidgets.QVBoxLayout() + layout.addWidget(label) + self.setLayout(layout) + self.move(50, 10) + self.show() + + + +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()