mirror of
https://github.com/rbreu/beeref.git
synced 2026-03-11 08:54:28 +00:00
952 lines
34 KiB
Python
952 lines
34 KiB
Python
# This file is part of BeeRef.
|
|
#
|
|
# BeeRef is free software: you can redistribute it and/or modify
|
|
# it under the terms of the GNU General Public License as published by
|
|
# the Free Software Foundation, either version 3 of the License, or
|
|
# (at your option) any later version.
|
|
#
|
|
# BeeRef is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU General Public License
|
|
# along with BeeRef. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
from functools import partial
|
|
import logging
|
|
import os
|
|
import os.path
|
|
|
|
from PyQt6 import QtCore, QtGui, QtWidgets
|
|
from PyQt6.QtCore import Qt
|
|
|
|
from beeref.actions import ActionsMixin, actions
|
|
from beeref import commands
|
|
from beeref.config import CommandlineArgs, BeeSettings, KeyboardSettings
|
|
from beeref import constants
|
|
from beeref import fileio
|
|
from beeref.fileio.errors import IMG_LOADING_ERROR_MSG
|
|
from beeref.fileio.export import exporter_registry, ImagesToDirectoryExporter
|
|
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, qcolor_to_hex
|
|
|
|
|
|
commandline_args = CommandlineArgs()
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
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
|
|
self.parent = parent
|
|
self.settings = BeeSettings()
|
|
self.keyboard_settings = KeyboardSettings()
|
|
self.welcome_overlay = widgets.welcome_overlay.WelcomeOverlay(self)
|
|
|
|
self.setBackgroundBrush(
|
|
QtGui.QBrush(QtGui.QColor(*constants.COLORS['Scene:Canvas'])))
|
|
self.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing)
|
|
self.setFrameShape(QtWidgets.QFrame.Shape.NoFrame)
|
|
|
|
self.undo_stack = QtGui.QUndoStack(self)
|
|
self.undo_stack.setUndoLimit(100)
|
|
self.undo_stack.canRedoChanged.connect(self.on_can_redo_changed)
|
|
self.undo_stack.canUndoChanged.connect(self.on_can_undo_changed)
|
|
self.undo_stack.cleanChanged.connect(self.on_undo_clean_changed)
|
|
|
|
self.filename = None
|
|
self.previous_transform = None
|
|
self.active_mode = None
|
|
|
|
self.scene = BeeGraphicsScene(self.undo_stack)
|
|
self.scene.changed.connect(self.on_scene_changed)
|
|
self.scene.selectionChanged.connect(self.on_selection_changed)
|
|
self.scene.cursor_changed.connect(self.on_cursor_changed)
|
|
self.scene.cursor_cleared.connect(self.on_cursor_cleared)
|
|
self.setScene(self.scene)
|
|
|
|
# Context menu and actions
|
|
self.build_menu_and_actions()
|
|
self.control_target = self
|
|
self.init_main_controls(main_window=parent)
|
|
|
|
# Load file given via command line
|
|
if commandline_args.filename:
|
|
self.open_from_file(commandline_args.filename)
|
|
self.update_window_title()
|
|
|
|
@property
|
|
def filename(self):
|
|
return self._filename
|
|
|
|
@filename.setter
|
|
def filename(self, value):
|
|
self._filename = value
|
|
self.update_window_title()
|
|
if 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
|
|
if self.scene.has_multi_selection():
|
|
self.scene.multi_select_item.bring_to_front()
|
|
|
|
def update_window_title(self):
|
|
clean = self.undo_stack.isClean()
|
|
if clean and not self.filename:
|
|
title = constants.APPNAME
|
|
else:
|
|
name = os.path.basename(self.filename or '[Untitled]')
|
|
clean = '' if clean else '*'
|
|
title = f'{name}{clean} - {constants.APPNAME}'
|
|
self.parent.setWindowTitle(title)
|
|
|
|
def on_scene_changed(self, region):
|
|
if not self.scene.items():
|
|
logger.debug('No items in scene')
|
|
self.setTransform(QtGui.QTransform())
|
|
self.welcome_overlay.setFocus()
|
|
self.clearFocus()
|
|
self.welcome_overlay.show()
|
|
self.actiongroup_set_enabled('active_when_items_in_scene', False)
|
|
else:
|
|
self.setFocus()
|
|
self.welcome_overlay.clearFocus()
|
|
self.welcome_overlay.hide()
|
|
self.actiongroup_set_enabled('active_when_items_in_scene', True)
|
|
self.recalc_scene_rect()
|
|
|
|
def on_can_redo_changed(self, can_redo):
|
|
self.actiongroup_set_enabled('active_when_can_redo', can_redo)
|
|
|
|
def on_can_undo_changed(self, can_undo):
|
|
self.actiongroup_set_enabled('active_when_can_undo', can_undo)
|
|
|
|
def on_undo_clean_changed(self, clean):
|
|
self.update_window_title()
|
|
|
|
def on_context_menu(self, point):
|
|
self.context_menu.exec(self.mapToGlobal(point))
|
|
|
|
def get_supported_image_formats(self, cls):
|
|
formats = []
|
|
|
|
for f in cls.supportedImageFormats():
|
|
string = f'*.{f.data().decode()}'
|
|
formats.extend((string, string.upper()))
|
|
return ' '.join(formats)
|
|
|
|
def get_view_center(self):
|
|
return QtCore.QPoint(round(self.size().width() / 2),
|
|
round(self.size().height() / 2))
|
|
|
|
def clear_scene(self):
|
|
logging.debug('Clearing scene...')
|
|
self.cancel_active_modes()
|
|
self.scene.clear()
|
|
self.undo_stack.clear()
|
|
self.filename = None
|
|
self.setTransform(QtGui.QTransform())
|
|
|
|
def reset_previous_transform(self, toggle_item=None):
|
|
if (self.previous_transform
|
|
and self.previous_transform['toggle_item'] != toggle_item):
|
|
self.previous_transform = None
|
|
|
|
def fit_rect(self, rect, toggle_item=None):
|
|
if toggle_item and self.previous_transform:
|
|
logger.debug('Fit view: Reset to previous')
|
|
self.setTransform(self.previous_transform['transform'])
|
|
self.centerOn(self.previous_transform['center'])
|
|
self.previous_transform = None
|
|
return
|
|
if toggle_item:
|
|
self.previous_transform = {
|
|
'toggle_item': toggle_item,
|
|
'transform': QtGui.QTransform(self.transform()),
|
|
'center': self.mapToScene(self.get_view_center()),
|
|
}
|
|
else:
|
|
self.previous_transform = None
|
|
|
|
logger.debug(f'Fit view: {rect}')
|
|
self.fitInView(rect, Qt.AspectRatioMode.KeepAspectRatio)
|
|
self.recalc_scene_rect()
|
|
# It seems to be more reliable when we fit a second time
|
|
# Sometimes a changing scene rect can mess up the fitting
|
|
self.fitInView(rect, Qt.AspectRatioMode.KeepAspectRatio)
|
|
logger.trace('Fit view done')
|
|
|
|
def get_confirmation_unsaved_changes(self, msg):
|
|
confirm = self.settings.valueOrDefault('Save/confirm_close_unsaved')
|
|
if confirm and not self.undo_stack.isClean():
|
|
answer = QtWidgets.QMessageBox.question(
|
|
self,
|
|
'Discard unsaved changes?',
|
|
msg,
|
|
QtWidgets.QMessageBox.StandardButton.Yes |
|
|
QtWidgets.QMessageBox.StandardButton.Cancel)
|
|
return answer == QtWidgets.QMessageBox.StandardButton.Yes
|
|
|
|
return True
|
|
|
|
def on_action_new_scene(self):
|
|
confirm = self.get_confirmation_unsaved_changes(
|
|
'There are unsaved changes. '
|
|
'Are you sure you want to open a new scene?')
|
|
if confirm:
|
|
self.clear_scene()
|
|
|
|
def on_action_fit_scene(self):
|
|
self.fit_rect(self.scene.itemsBoundingRect())
|
|
|
|
def on_action_fit_selection(self):
|
|
self.fit_rect(self.scene.itemsBoundingRect(selection_only=True))
|
|
|
|
def on_action_fullscreen(self, checked):
|
|
if checked:
|
|
self.parent.showFullScreen()
|
|
else:
|
|
self.parent.showNormal()
|
|
|
|
def on_action_always_on_top(self, checked):
|
|
self.parent.setWindowFlag(
|
|
Qt.WindowType.WindowStaysOnTopHint, on=checked)
|
|
self.parent.destroy()
|
|
self.parent.create()
|
|
self.parent.show()
|
|
|
|
def on_action_show_scrollbars(self, checked):
|
|
if checked:
|
|
self.setHorizontalScrollBarPolicy(
|
|
Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
|
self.setVerticalScrollBarPolicy(
|
|
Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
|
else:
|
|
self.setHorizontalScrollBarPolicy(
|
|
Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
|
self.setVerticalScrollBarPolicy(
|
|
Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
|
|
|
def on_action_show_menubar(self, checked):
|
|
if checked:
|
|
self.parent.setMenuBar(self.create_menubar())
|
|
else:
|
|
self.parent.setMenuBar(None)
|
|
|
|
def on_action_show_titlebar(self, checked):
|
|
self.parent.setWindowFlag(
|
|
Qt.WindowType.FramelessWindowHint, on=not checked)
|
|
self.parent.destroy()
|
|
self.parent.create()
|
|
self.parent.show()
|
|
|
|
def on_action_move_window(self):
|
|
if self.welcome_overlay.isHidden():
|
|
self.on_action_movewin_mode()
|
|
else:
|
|
self.welcome_overlay.on_action_movewin_mode()
|
|
|
|
def on_action_undo(self):
|
|
logger.debug('Undo: %s' % self.undo_stack.undoText())
|
|
self.cancel_active_modes()
|
|
self.undo_stack.undo()
|
|
|
|
def on_action_redo(self):
|
|
logger.debug('Redo: %s' % self.undo_stack.redoText())
|
|
self.cancel_active_modes()
|
|
self.undo_stack.redo()
|
|
|
|
def on_action_select_all(self):
|
|
self.scene.select_all_items()
|
|
|
|
def on_action_deselect_all(self):
|
|
self.scene.deselect_all_items()
|
|
|
|
def on_action_delete_items(self):
|
|
logger.debug('Deleting items...')
|
|
self.cancel_active_modes()
|
|
self.undo_stack.push(
|
|
commands.DeleteItems(
|
|
self.scene, self.scene.selectedItems(user_only=True)))
|
|
|
|
def on_action_cut(self):
|
|
logger.debug('Cutting items...')
|
|
self.on_action_copy()
|
|
self.undo_stack.push(
|
|
commands.DeleteItems(
|
|
self.scene, self.scene.selectedItems(user_only=True)))
|
|
|
|
def on_action_raise_to_top(self):
|
|
self.scene.raise_to_top()
|
|
|
|
def on_action_lower_to_bottom(self):
|
|
self.scene.lower_to_bottom()
|
|
|
|
def on_action_normalize_height(self):
|
|
self.scene.normalize_height()
|
|
|
|
def on_action_normalize_width(self):
|
|
self.scene.normalize_width()
|
|
|
|
def on_action_normalize_size(self):
|
|
self.scene.normalize_size()
|
|
|
|
def on_action_arrange_horizontal(self):
|
|
self.scene.arrange()
|
|
|
|
def on_action_arrange_vertical(self):
|
|
self.scene.arrange(vertical=True)
|
|
|
|
def on_action_arrange_optimal(self):
|
|
self.scene.arrange_optimal()
|
|
|
|
def on_action_change_opacity(self):
|
|
images = list(filter(
|
|
lambda item: item.is_image,
|
|
self.scene.selectedItems(user_only=True)))
|
|
widgets.ChangeOpacityDialog(self, images, self.undo_stack)
|
|
|
|
def on_action_grayscale(self, checked):
|
|
images = list(filter(
|
|
lambda item: item.is_image,
|
|
self.scene.selectedItems(user_only=True)))
|
|
if images:
|
|
self.undo_stack.push(
|
|
commands.ToggleGrayscale(images, checked))
|
|
|
|
def on_action_crop(self):
|
|
self.scene.crop_items()
|
|
|
|
def on_action_flip_horizontally(self):
|
|
self.scene.flip_items(vertical=False)
|
|
|
|
def on_action_flip_vertically(self):
|
|
self.scene.flip_items(vertical=True)
|
|
|
|
def on_action_reset_scale(self):
|
|
self.cancel_active_modes()
|
|
self.undo_stack.push(commands.ResetScale(
|
|
self.scene.selectedItems(user_only=True)))
|
|
|
|
def on_action_reset_rotation(self):
|
|
self.cancel_active_modes()
|
|
self.undo_stack.push(commands.ResetRotation(
|
|
self.scene.selectedItems(user_only=True)))
|
|
|
|
def on_action_reset_flip(self):
|
|
self.cancel_active_modes()
|
|
self.undo_stack.push(commands.ResetFlip(
|
|
self.scene.selectedItems(user_only=True)))
|
|
|
|
def on_action_reset_crop(self):
|
|
self.cancel_active_modes()
|
|
self.undo_stack.push(commands.ResetCrop(
|
|
self.scene.selectedItems(user_only=True)))
|
|
|
|
def on_action_reset_transforms(self):
|
|
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
|
|
|
|
if self.scene.has_multi_selection():
|
|
# We don't want to sample the multi select item, so
|
|
# temporarily send it to the back:
|
|
self.scene.multi_select_item.lower_behind_selection()
|
|
|
|
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()
|
|
|
|
def on_loading_finished(self, filename, errors):
|
|
if errors:
|
|
QtWidgets.QMessageBox.warning(
|
|
self,
|
|
'Problem loading file',
|
|
('<p>Problem loading file %s</p>'
|
|
'<p>Not accessible or not a proper bee file</p>') % filename)
|
|
else:
|
|
self.filename = filename
|
|
self.scene.add_queued_items()
|
|
self.on_action_fit_scene()
|
|
|
|
def on_action_open_recent_file(self, filename):
|
|
confirm = self.get_confirmation_unsaved_changes(
|
|
'There are unsaved changes. '
|
|
'Are you sure you want to open a new scene?')
|
|
if confirm:
|
|
self.open_from_file(filename)
|
|
|
|
def open_from_file(self, filename):
|
|
logger.info(f'Opening file {filename}')
|
|
self.clear_scene()
|
|
self.worker = fileio.ThreadedIO(
|
|
fileio.load_bee, filename, self.scene)
|
|
self.worker.progress.connect(self.on_items_loaded)
|
|
self.worker.finished.connect(self.on_loading_finished)
|
|
self.progress = widgets.BeeProgressDialog(
|
|
f'Loading {filename}',
|
|
worker=self.worker,
|
|
parent=self)
|
|
self.worker.start()
|
|
|
|
def on_action_open(self):
|
|
confirm = self.get_confirmation_unsaved_changes(
|
|
'There are unsaved changes. '
|
|
'Are you sure you want to open a new scene?')
|
|
if not confirm:
|
|
return
|
|
|
|
self.cancel_active_modes()
|
|
filename, f = QtWidgets.QFileDialog.getOpenFileName(
|
|
parent=self,
|
|
caption='Open file',
|
|
filter=f'{constants.APPNAME} File (*.bee)')
|
|
if filename:
|
|
filename = os.path.normpath(filename)
|
|
self.open_from_file(filename)
|
|
self.filename = filename
|
|
|
|
def on_saving_finished(self, filename, errors):
|
|
if errors:
|
|
QtWidgets.QMessageBox.warning(
|
|
self,
|
|
'Problem saving file',
|
|
('<p>Problem saving file %s</p>'
|
|
'<p>File/directory not accessible</p>') % filename)
|
|
else:
|
|
self.filename = filename
|
|
self.undo_stack.setClean()
|
|
|
|
def do_save(self, filename, create_new):
|
|
if not fileio.is_bee_file(filename):
|
|
filename = f'{filename}.bee'
|
|
self.worker = fileio.ThreadedIO(
|
|
fileio.save_bee, filename, self.scene, create_new=create_new)
|
|
self.worker.finished.connect(self.on_saving_finished)
|
|
self.progress = widgets.BeeProgressDialog(
|
|
f'Saving {filename}',
|
|
worker=self.worker,
|
|
parent=self)
|
|
self.worker.start()
|
|
|
|
def on_action_save_as(self):
|
|
self.cancel_active_modes()
|
|
directory = os.path.dirname(self.filename) if self.filename else None
|
|
filename, f = QtWidgets.QFileDialog.getSaveFileName(
|
|
parent=self,
|
|
caption='Save file',
|
|
directory=directory,
|
|
filter=f'{constants.APPNAME} File (*.bee)')
|
|
if filename:
|
|
self.do_save(filename, create_new=True)
|
|
|
|
def on_action_save(self):
|
|
self.cancel_active_modes()
|
|
if not self.filename:
|
|
self.on_action_save_as()
|
|
else:
|
|
self.do_save(self.filename, create_new=False)
|
|
|
|
def on_action_export_scene(self):
|
|
directory = os.path.dirname(self.filename) if self.filename else None
|
|
filename, formatstr = QtWidgets.QFileDialog.getSaveFileName(
|
|
parent=self,
|
|
caption='Export Scene to Image',
|
|
directory=directory,
|
|
filter=';;'.join(('Image Files (*.png *.jpg *.jpeg *.svg)',
|
|
'PNG (*.png)',
|
|
'JPEG (*.jpg *.jpeg)',
|
|
'SVG (*.svg)')))
|
|
|
|
if not filename:
|
|
return
|
|
|
|
name, ext = os.path.splitext(filename)
|
|
if not ext:
|
|
ext = get_file_extension_from_format(formatstr)
|
|
filename = f'{filename}.{ext}'
|
|
logger.debug(f'Got export filename {filename}')
|
|
|
|
exporter_cls = exporter_registry[ext]
|
|
exporter = exporter_cls(self.scene)
|
|
if not exporter.get_user_input(self):
|
|
return
|
|
|
|
self.worker = fileio.ThreadedIO(exporter.export, filename)
|
|
self.worker.finished.connect(self.on_export_finished)
|
|
self.progress = widgets.BeeProgressDialog(
|
|
f'Exporting {filename}',
|
|
worker=self.worker,
|
|
parent=self)
|
|
self.worker.start()
|
|
|
|
def on_export_finished(self, filename, errors):
|
|
if errors:
|
|
err_msg = '</br>'.join(str(errors))
|
|
QtWidgets.QMessageBox.warning(
|
|
self,
|
|
'Problem writing file',
|
|
f'<p>Problem writing file {filename}</p><p>{err_msg}</p>')
|
|
|
|
def on_action_export_images(self):
|
|
directory = os.path.dirname(self.filename) if self.filename else None
|
|
directory = QtWidgets.QFileDialog.getExistingDirectory(
|
|
parent=self,
|
|
caption='Export Images',
|
|
directory=directory)
|
|
|
|
if not directory:
|
|
return
|
|
|
|
logger.debug(f'Got export directory {directory}')
|
|
self.exporter = ImagesToDirectoryExporter(self.scene, directory)
|
|
self.worker = fileio.ThreadedIO(self.exporter.export)
|
|
self.worker.user_input_required.connect(
|
|
self.on_export_images_file_exists)
|
|
self.worker.finished.connect(self.on_export_finished)
|
|
self.progress = widgets.BeeProgressDialog(
|
|
f'Exporting to {directory}',
|
|
worker=self.worker,
|
|
parent=self)
|
|
self.worker.start()
|
|
|
|
def on_export_images_file_exists(self, filename):
|
|
dlg = widgets.ExportImagesFileExistsDialog(self, filename)
|
|
if dlg.exec() == QtWidgets.QDialog.DialogCode.Accepted:
|
|
self.exporter.handle_existing = dlg.get_answer()
|
|
directory = self.exporter.dirname
|
|
self.progress = widgets.BeeProgressDialog(
|
|
f'Exporting to {directory}',
|
|
worker=self.worker,
|
|
parent=self)
|
|
self.worker.start()
|
|
|
|
def on_action_quit(self):
|
|
confirm = self.get_confirmation_unsaved_changes(
|
|
'There are unsaved changes. Are you sure you want to quit?')
|
|
if confirm:
|
|
logger.info('User quit. Exiting...')
|
|
self.app.quit()
|
|
|
|
def on_action_settings(self):
|
|
widgets.settings.SettingsDialog(self)
|
|
|
|
def on_action_keyboard_settings(self):
|
|
widgets.controls.ControlsDialog(self)
|
|
|
|
def on_action_help(self):
|
|
widgets.HelpDialog(self)
|
|
|
|
def on_action_about(self):
|
|
QtWidgets.QMessageBox.about(
|
|
self,
|
|
f'About {constants.APPNAME}',
|
|
(f'<h2>{constants.APPNAME} {constants.VERSION}</h2>'
|
|
f'<p>{constants.APPNAME_FULL}</p>'
|
|
f'<p>{constants.COPYRIGHT}</p>'
|
|
f'<p><a href="{constants.WEBSITE}">'
|
|
f'Visit the {constants.APPNAME} website</a></p>'))
|
|
|
|
def on_action_debuglog(self):
|
|
widgets.DebugLogDialog(self)
|
|
|
|
def on_insert_images_finished(self, new_scene, filename, errors):
|
|
"""Callback for when loading of images is finished.
|
|
|
|
:param new_scene: True if the scene was empty before, else False
|
|
:param filename: Not used, for compatibility only
|
|
:param errors: List of filenames that couldn't be loaded
|
|
"""
|
|
|
|
logger.debug('Insert images finished')
|
|
if errors:
|
|
errornames = [
|
|
f'<li>{fn}</li>' for fn in errors]
|
|
errornames = '<ul>%s</ul>' % '\n'.join(errornames)
|
|
num = len(errors)
|
|
msg = f'{num} image(s) could not be opened.<br/>'
|
|
QtWidgets.QMessageBox.warning(
|
|
self,
|
|
'Problem loading images',
|
|
msg + IMG_LOADING_ERROR_MSG + errornames)
|
|
self.scene.add_queued_items()
|
|
self.scene.arrange_optimal()
|
|
self.undo_stack.endMacro()
|
|
if new_scene:
|
|
self.on_action_fit_scene()
|
|
|
|
def do_insert_images(self, filenames, pos=None):
|
|
if not pos:
|
|
pos = self.get_view_center()
|
|
self.scene.deselect_all_items()
|
|
self.undo_stack.beginMacro('Insert Images')
|
|
self.worker = fileio.ThreadedIO(
|
|
fileio.load_images,
|
|
filenames,
|
|
self.mapToScene(pos),
|
|
self.scene)
|
|
self.worker.progress.connect(self.on_items_loaded)
|
|
self.worker.finished.connect(
|
|
partial(self.on_insert_images_finished,
|
|
not self.scene.items()))
|
|
self.progress = widgets.BeeProgressDialog(
|
|
'Loading images',
|
|
worker=self.worker,
|
|
parent=self)
|
|
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(
|
|
parent=self,
|
|
caption='Select one or more images to open',
|
|
filter=f'Images ({formats})')
|
|
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())
|
|
self.undo_stack.push(commands.InsertItems(self.scene, [item], pos))
|
|
|
|
def on_action_copy(self):
|
|
logger.debug('Copying to clipboard...')
|
|
self.cancel_active_modes()
|
|
clipboard = QtWidgets.QApplication.clipboard()
|
|
items = self.scene.selectedItems(user_only=True)
|
|
|
|
# At the moment, we can only copy one image to the global
|
|
# clipboard. (Later, we might create an image of the whole
|
|
# selection for external copying.)
|
|
items[0].copy_to_clipboard(clipboard)
|
|
|
|
# However, we can copy all items to the internal clipboard:
|
|
self.scene.copy_selection_to_internal_clipboard()
|
|
|
|
# We set a marker for ourselves in the global clipboard so
|
|
# that we know to look up the internal clipboard when pasting:
|
|
clipboard.mimeData().setData(
|
|
'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()))
|
|
|
|
# See if we need to look up the internal clipboard:
|
|
data = clipboard.mimeData().data('beeref/items')
|
|
logger.debug(f'Custom data in clipboard: {data}')
|
|
if data and self.scene.internal_clipboard:
|
|
# Checking that internal clipboard exists since the user
|
|
# may have opened a new scene since copying.
|
|
self.scene.paste_from_internal_clipboard(pos)
|
|
return
|
|
|
|
img = clipboard.image()
|
|
if not img.isNull():
|
|
item = BeePixmapItem(img)
|
|
self.undo_stack.push(commands.InsertItems(self.scene, [item], pos))
|
|
if len(self.scene.items()) == 1:
|
|
# This is the first image in the scene
|
|
self.on_action_fit_scene()
|
|
return
|
|
text = clipboard.text()
|
|
if text:
|
|
item = BeeTextItem(text)
|
|
item.setScale(1 / self.get_scale())
|
|
self.undo_stack.push(commands.InsertItems(self.scene, [item], pos))
|
|
return
|
|
|
|
msg = 'No image data or text in clipboard or image too big'
|
|
logger.info(msg)
|
|
widgets.BeeNotification(self, msg)
|
|
|
|
def on_action_open_settings_dir(self):
|
|
dirname = os.path.dirname(self.settings.fileName())
|
|
QtGui.QDesktopServices.openUrl(
|
|
QtCore.QUrl.fromLocalFile(dirname))
|
|
|
|
def on_selection_changed(self):
|
|
logger.debug('Currently selected items: %s',
|
|
len(self.scene.selectedItems(user_only=True)))
|
|
self.actiongroup_set_enabled('active_when_selection',
|
|
self.scene.has_selection())
|
|
self.actiongroup_set_enabled('active_when_single_image',
|
|
self.scene.has_single_image_selection())
|
|
|
|
if self.scene.has_selection():
|
|
item = self.scene.selectedItems(user_only=True)[0]
|
|
grayscale = getattr(item, 'grayscale', False)
|
|
actions.actions['grayscale'].qaction.setChecked(grayscale)
|
|
self.viewport().repaint()
|
|
|
|
def on_cursor_changed(self, cursor):
|
|
if self.active_mode is None:
|
|
self.viewport().setCursor(cursor)
|
|
|
|
def on_cursor_cleared(self):
|
|
if self.active_mode is None:
|
|
self.viewport().unsetCursor()
|
|
|
|
def recalc_scene_rect(self):
|
|
"""Resize the scene rectangle so that it is always one view width
|
|
wider than all items' bounding box at each side and one view
|
|
width higher on top and bottom. This gives the impression of
|
|
an infinite canvas."""
|
|
|
|
if self.previous_transform:
|
|
return
|
|
logger.trace('Recalculating scene rectangle...')
|
|
try:
|
|
topleft = self.mapFromScene(
|
|
self.scene.itemsBoundingRect().topLeft())
|
|
topleft = self.mapToScene(QtCore.QPoint(
|
|
topleft.x() - self.size().width(),
|
|
topleft.y() - self.size().height()))
|
|
bottomright = self.mapFromScene(
|
|
self.scene.itemsBoundingRect().bottomRight())
|
|
bottomright = self.mapToScene(QtCore.QPoint(
|
|
bottomright.x() + self.size().width(),
|
|
bottomright.y() + self.size().height()))
|
|
self.setSceneRect(QtCore.QRectF(topleft, bottomright))
|
|
except OverflowError:
|
|
logger.info('Maximum scene size reached')
|
|
logger.trace('Done recalculating scene rectangle')
|
|
|
|
def get_zoom_size(self, func):
|
|
"""Calculates the size of all items' bounding box in the view's
|
|
coordinates.
|
|
|
|
This helps ensure that we never zoom out too much (scene
|
|
becomes so tiny that items become invisible) or zoom in too
|
|
much (causing overflow errors).
|
|
|
|
:param func: Function which takes the width and height as
|
|
arguments and turns it into a number, for ex. ``min`` or ``max``.
|
|
"""
|
|
|
|
topleft = self.mapFromScene(
|
|
self.scene.itemsBoundingRect().topLeft())
|
|
bottomright = self.mapFromScene(
|
|
self.scene.itemsBoundingRect().bottomRight())
|
|
return func(bottomright.x() - topleft.x(),
|
|
bottomright.y() - topleft.y())
|
|
|
|
def scale(self, *args, **kwargs):
|
|
super().scale(*args, **kwargs)
|
|
self.scene.on_view_scale_change()
|
|
self.recalc_scene_rect()
|
|
|
|
def get_scale(self):
|
|
return self.transform().m11()
|
|
|
|
def pan(self, delta):
|
|
if not self.scene.items():
|
|
logger.debug('No items in scene; ignore pan')
|
|
return
|
|
|
|
hscroll = self.horizontalScrollBar()
|
|
hscroll.setValue(int(hscroll.value() + delta.x()))
|
|
vscroll = self.verticalScrollBar()
|
|
vscroll.setValue(int(vscroll.value() + delta.y()))
|
|
|
|
def zoom(self, delta, anchor):
|
|
if not self.scene.items():
|
|
logger.debug('No items in scene; ignore zoom')
|
|
return
|
|
|
|
# We calculate where the anchor is before and after the zoom
|
|
# and then move the view accordingly to keep the anchor fixed
|
|
# We can't use QGraphicsView's AnchorUnderMouse since it
|
|
# uses the current cursor position while we need the initial mouse
|
|
# press position for zooming with Ctrl + Middle Drag
|
|
anchor = QtCore.QPoint(round(anchor.x()),
|
|
round(anchor.y()))
|
|
ref_point = self.mapToScene(anchor)
|
|
if delta == 0:
|
|
return
|
|
factor = 1 + abs(delta / 1000)
|
|
if delta > 0:
|
|
if self.get_zoom_size(max) < 10000000:
|
|
self.scale(factor, factor)
|
|
else:
|
|
logger.debug('Maximum zoom size reached')
|
|
return
|
|
else:
|
|
if self.get_zoom_size(min) > 50:
|
|
self.scale(1/factor, 1/factor)
|
|
else:
|
|
logger.debug('Minimum zoom size reached')
|
|
return
|
|
|
|
self.pan(self.mapFromScene(ref_point) - anchor)
|
|
self.reset_previous_transform()
|
|
|
|
def wheelEvent(self, event):
|
|
action, inverted\
|
|
= self.keyboard_settings.mousewheel_action_for_event(event)
|
|
|
|
delta = event.angleDelta().y()
|
|
if inverted:
|
|
delta = delta * -1
|
|
|
|
if action == 'zoom':
|
|
self.zoom(delta, event.position())
|
|
event.accept()
|
|
return
|
|
if action == 'pan_horizontal':
|
|
self.pan(QtCore.QPointF(0, 0.5 * delta))
|
|
event.accept()
|
|
return
|
|
if action == 'pan_vertical':
|
|
self.pan(QtCore.QPointF(0.5 * delta, 0))
|
|
event.accept()
|
|
return
|
|
|
|
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:
|
|
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
|
|
|
|
action, inverted = self.keyboard_settings.mouse_action_for_event(event)
|
|
|
|
if action == 'zoom':
|
|
self.active_mode = self.ZOOM_MODE
|
|
self.event_start = event.position()
|
|
self.event_anchor = event.position()
|
|
self.event_inverted = inverted
|
|
event.accept()
|
|
return
|
|
|
|
if action == 'pan':
|
|
logger.trace('Begin pan')
|
|
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
|
|
# don't know if that's only on my system or a general
|
|
# problem. It works with other cursors.
|
|
event.accept()
|
|
return
|
|
|
|
super().mousePressEvent(event)
|
|
|
|
def mouseMoveEvent(self, event):
|
|
if self.active_mode == self.PAN_MODE:
|
|
self.reset_previous_transform()
|
|
pos = event.position()
|
|
self.pan(self.event_start - pos)
|
|
self.event_start = pos
|
|
event.accept()
|
|
return
|
|
|
|
if self.active_mode == self.ZOOM_MODE:
|
|
self.reset_previous_transform()
|
|
pos = event.position()
|
|
delta = (self.event_start - pos).y()
|
|
if self.event_inverted:
|
|
delta *= -1
|
|
self.event_start = pos
|
|
self.zoom(delta * 20, self.event_anchor)
|
|
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.active_mode == self.PAN_MODE:
|
|
logger.trace('End pan')
|
|
self.viewport().unsetCursor()
|
|
self.active_mode = None
|
|
event.accept()
|
|
return
|
|
if self.active_mode == self.ZOOM_MODE:
|
|
self.active_mode = None
|
|
event.accept()
|
|
return
|
|
if self.mouseReleaseEventMainControls(event):
|
|
return
|
|
super().mouseReleaseEvent(event)
|
|
|
|
def resizeEvent(self, event):
|
|
super().resizeEvent(event)
|
|
self.recalc_scene_rect()
|
|
self.welcome_overlay.resize(self.size())
|
|
|
|
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)
|