mirror of
https://github.com/rbreu/beeref.git
synced 2026-03-11 08:54:28 +00:00
761 lines
27 KiB
Python
761 lines
27 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
|
|
from beeref import commands
|
|
from beeref.config import CommandlineArgs, BeeSettings
|
|
from beeref import constants
|
|
from beeref import fileio
|
|
from beeref.fileio.export import SceneToPixmapExporter
|
|
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
|
|
|
|
|
|
commandline_args = CommandlineArgs()
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class BeeGraphicsView(MainControlsMixin,
|
|
QtWidgets.QGraphicsView,
|
|
ActionsMixin):
|
|
|
|
def __init__(self, app, parent=None):
|
|
super().__init__(parent)
|
|
self.app = app
|
|
self.parent = parent
|
|
self.settings = BeeSettings()
|
|
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.pan_active = False
|
|
self.zoom_active = False
|
|
|
|
self.scene = BeeGraphicsScene(self.undo_stack)
|
|
self.scene.changed.connect(self.on_scene_changed)
|
|
self.scene.selectionChanged.connect(self.on_selection_changed)
|
|
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 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.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 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.scene.cancel_crop_mode()
|
|
self.undo_stack.undo()
|
|
|
|
def on_action_redo(self):
|
|
logger.debug('Redo: %s' % self.undo_stack.redoText())
|
|
self.scene.cancel_crop_mode()
|
|
self.undo_stack.redo()
|
|
|
|
def on_action_select_all(self):
|
|
self.scene.set_selected_all_items(True)
|
|
|
|
def on_action_deselect_all(self):
|
|
self.scene.set_selected_all_items(False)
|
|
|
|
def on_action_delete_items(self):
|
|
logger.debug('Deleting items...')
|
|
self.scene.cancel_crop_mode()
|
|
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.scene.cancel_crop_mode()
|
|
self.undo_stack.push(commands.ResetScale(
|
|
self.scene.selectedItems(user_only=True)))
|
|
|
|
def on_action_reset_rotation(self):
|
|
self.scene.cancel_crop_mode()
|
|
self.undo_stack.push(commands.ResetRotation(
|
|
self.scene.selectedItems(user_only=True)))
|
|
|
|
def on_action_reset_flip(self):
|
|
self.scene.cancel_crop_mode()
|
|
self.undo_stack.push(commands.ResetFlip(
|
|
self.scene.selectedItems(user_only=True)))
|
|
|
|
def on_action_reset_crop(self):
|
|
self.scene.cancel_crop_mode()
|
|
self.undo_stack.push(commands.ResetCrop(
|
|
self.scene.selectedItems(user_only=True)))
|
|
|
|
def on_action_reset_transforms(self):
|
|
self.scene.cancel_crop_mode()
|
|
self.undo_stack.push(commands.ResetTransforms(
|
|
self.scene.selectedItems(user_only=True)))
|
|
|
|
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 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(
|
|
'Loading %s' % filename,
|
|
worker=self.worker,
|
|
parent=self)
|
|
self.worker.start()
|
|
|
|
def on_action_open(self):
|
|
self.scene.cancel_crop_mode()
|
|
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(
|
|
'Saving %s' % filename,
|
|
worker=self.worker,
|
|
parent=self)
|
|
self.worker.start()
|
|
|
|
def on_action_save_as(self):
|
|
self.scene.cancel_crop_mode()
|
|
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.scene.cancel_crop_mode()
|
|
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)',
|
|
'PNG (*.png)',
|
|
'JPEG (*.jpg *.jpeg)')))
|
|
|
|
if filename:
|
|
name, ext = os.path.splitext(filename)
|
|
if not ext:
|
|
ext = get_file_extension_from_format(formatstr)
|
|
filename = f'{filename}.{ext}'
|
|
print(filename)
|
|
logger.debug(f'Got export filename {filename}')
|
|
exporter = SceneToPixmapExporter(self.scene)
|
|
dialog = widgets.SceneToPixmapExporterDialog(
|
|
parent=self,
|
|
default_size=exporter.default_size,
|
|
)
|
|
if dialog.exec():
|
|
size = dialog.value()
|
|
logger.debug(f'Got export size {size}')
|
|
try:
|
|
exporter.export(filename, size)
|
|
except fileio.BeeFileIOError as e:
|
|
QtWidgets.QMessageBox.warning(
|
|
self,
|
|
'Problem exporting scene',
|
|
str(e))
|
|
|
|
def on_action_quit(self):
|
|
logger.info('User quit. Exiting...')
|
|
self.app.quit()
|
|
|
|
def on_action_settings(self):
|
|
widgets.settings.SettingsDialog(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)
|
|
msg = ('{errors} image(s) out of {total} '
|
|
'could not be opened:'.format(
|
|
errors=len(errors), total=len(errors)))
|
|
QtWidgets.QMessageBox.warning(
|
|
self,
|
|
'Problem loading images',
|
|
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.clearSelection()
|
|
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.scene.cancel_crop_mode()
|
|
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.scene.cancel_crop_mode()
|
|
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.scene.cancel_crop_mode()
|
|
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.scene.cancel_crop_mode()
|
|
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
|
|
logger.info('No image data or text in clipboard')
|
|
|
|
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())
|
|
self.viewport().repaint()
|
|
|
|
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):
|
|
self.zoom(event.angleDelta().y(), event.position())
|
|
event.accept()
|
|
|
|
def mousePressEvent(self, event):
|
|
if self.mousePressEventMainControls(event):
|
|
return
|
|
|
|
if (event.button() == Qt.MouseButton.MiddleButton
|
|
and event.modifiers() == Qt.KeyboardModifier.ControlModifier):
|
|
self.zoom_active = True
|
|
self.event_start = event.position()
|
|
self.event_anchor = event.position()
|
|
event.accept()
|
|
return
|
|
|
|
if (event.button() == Qt.MouseButton.MiddleButton
|
|
or (event.button() == Qt.MouseButton.LeftButton
|
|
and event.modifiers() == Qt.KeyboardModifier.AltModifier)):
|
|
self.pan_active = True
|
|
self.event_start = event.position()
|
|
self.setCursor(Qt.CursorShape.ClosedHandCursor)
|
|
event.accept()
|
|
return
|
|
|
|
super().mousePressEvent(event)
|
|
|
|
def mouseMoveEvent(self, event):
|
|
if self.pan_active:
|
|
self.reset_previous_transform()
|
|
pos = event.position()
|
|
self.pan(self.event_start - pos)
|
|
self.event_start = pos
|
|
event.accept()
|
|
return
|
|
|
|
if self.zoom_active:
|
|
self.reset_previous_transform()
|
|
pos = event.position()
|
|
delta = (self.event_start - pos).y()
|
|
self.event_start = pos
|
|
self.zoom(delta * 20, self.event_anchor)
|
|
event.accept()
|
|
return
|
|
|
|
if self.mouseMoveEventMainControls(event):
|
|
return
|
|
super().mouseMoveEvent(event)
|
|
|
|
def mouseReleaseEvent(self, event):
|
|
if self.pan_active:
|
|
self.setCursor(Qt.CursorShape.ArrowCursor)
|
|
self.pan_active = False
|
|
event.accept()
|
|
return
|
|
if self.zoom_active:
|
|
self.zoom_active = False
|
|
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
|
|
super().keyPressEvent(event)
|