Module soitool.inline_editable_soi_view
Includes functionality for inline editing of SOI.
Expand source code
"""Includes functionality for inline editing of SOI."""
import string
from datetime import datetime
from PySide2.QtCore import Qt, QRectF, QTimer, QMarginsF
from PySide2.QtWidgets import (
QApplication,
QScrollArea,
QLabel,
QGraphicsScene,
QGraphicsView,
QGraphicsRectItem,
QGraphicsProxyWidget,
QMainWindow,
)
from PySide2.QtGui import (
QPixmap,
QBrush,
QPalette,
QPainter,
)
from PySide2.QtPrintSupport import QPrinter
from soitool.soi import ModuleLargerThanBinError
from soitool.dialog_wrappers import exec_warning_dialog
from soitool.serialize_export_import_soi import generate_soi_filename
from soitool.modules.module_base import qfont_with_pixel_size
# How attachment pages should be numbered. The first page should be numbered
# by the value of ATTACHMENT_NUMBERING_SCHEME[0], the second page
# ATTACHMENT_NUMBERING_SCHEME[1], and so on.
ATTACHMENT_NUMBERING_SCHEME = list(string.ascii_uppercase)
ZOOM_LEVEL_MINIMUM = 0.2
ZOOM_LEVEL_MAXIMUM = 5
# Used in InlineEditableSOIView.scroll_by_wheel_event
ABSOLUTE_MAXIMUM_SCROLL_ANGLE = 120
SCROLL_ZOOM_INCREMENT_SCALAR = 0.0005
class QGraphicsViewWithCtrlScrollListener(QGraphicsView):
"""QGraphicsView with support for scroll listener.
Catches wheelEvents and calls listener if CTRL was held.
"""
def __init__(self, *args, **kwargs):
super(QGraphicsViewWithCtrlScrollListener, self).__init__(
*args, **kwargs
)
self.ctrl_scroll_listeners = []
def add_ctrl_scroll_listener(self, function):
"""Add function to be called when user scrolls while holding CTRL.
Parameters
----------
function : Python function that takes one parameter
Function takes a parameter 'event', that is the QWheelEvent
received by the wheelEvent when user scrolls and holds CTRL.
"""
self.ctrl_scroll_listeners.append(function)
def wheelEvent(self, event):
"""Override to call listener when CTRL is also held.
If CTRL is not held the wheelEvent is handled like normal.
Parameters
----------
event : QWheelEvent
Event from Qt.
"""
modifiers = QApplication.keyboardModifiers()
if modifiers == Qt.ControlModifier:
for listener in self.ctrl_scroll_listeners:
listener(event)
else:
super(QGraphicsViewWithCtrlScrollListener, self).wheelEvent(event)
class ProxyLabelWithCustomQPrintText(QGraphicsProxyWidget):
"""QGraphicsItem that prints a custom text when printed onto QPrint.
Useful to have a piece of text be painted differently in a QGraphicsScene
depending on whether it's drawn to QPrint or not.
Note that this class doesn't have to use a QLabel. It was done to KISS.
## How it works
When the QGrahpicsScene wants to render it's items it first uses the
"bounding rects" of it's items to figure out which of them needs to be
redrawn. It then redraws items using their `paint` functions. By overriding
`paint` we can control how our item is drawn. One of the parameters to the
`paint` function is the QPainter that is being used, which we can use to
print custom text onto QPrinter.
Parameters
----------
default_text : str
Text to be drawn for all QPainters except QPrint.
printing_text : str
Text to be drawn if the QPainter is QPrint.
"""
def __init__(self, default_text, printing_text):
super(ProxyLabelWithCustomQPrintText, self).__init__()
self.default_text = default_text
self.printing_text = printing_text
# self.boundingRect is updated at the end of this function, so this
# default value is in practice never used.
self.bounding_rect = QRectF(0, 0, 0, 0)
self.label = QLabel(self.default_text)
self.setWidget(self.label)
self.update_bounding_rect()
def update_bounding_rect(self):
"""Update bounding rect property."""
self.bounding_rect = self.determine_bounding_rect()
def determine_bounding_rect(self):
"""Calculate bounding rect that encapsulates both alternative strings.
From the docs: "Although the item's shape can be arbitrary, the
bounding rect is always rectangular, and it is unaffected by the
items' transformation." Link:
https://doc.qt.io/qt-5/qgraphicsitem.html#boundingRect
Because of this we're returning a rectangle (starting at 0,0) that
encapsulates the self.widget(), seen from this items local coordinate
system. This class purposefully lets self.widget() have different
content depending on the QPainter, so we give a bounding rect that
encapsulates the largest content.
Returns
-------
QRectF
Bounding rect that enpasulates both alternative contents of
self.widget().
"""
bounding_rect_default_text = self.label.fontMetrics().boundingRect(
self.default_text
)
bounding_rect_printing_text = self.label.fontMetrics().boundingRect(
self.printing_text
)
largest_width = max(
bounding_rect_default_text.width(),
bounding_rect_printing_text.width(),
)
largest_height = max(
bounding_rect_default_text.height(),
bounding_rect_printing_text.height(),
)
return QRectF(0, 0, largest_width, largest_height)
def paint(self, painter, option, widget):
"""Overridden to paint text depending on `painter` parameter.
Source: https://doc.qt.io/qt-5/qgraphicsitem.html#paint
Parameter
---------
painter : QPainter
QPainter that is painting the item. Used to determine which text
to draw.
option : QStyleOptionGraphicsItem
Passed on to superclass implementation. See source.
widget : QWidget
Passed on to superclass implementation. See source.
"""
if isinstance(painter.device(), QPrinter):
self.label.setText(self.printing_text)
else:
self.label.setText(self.default_text)
# From Qt docs: "Prepares the item for a geometry change. Call this
# function before changing the bounding rect of an item to keep
# QGraphicsScene's index up to date."
# https://doc.qt.io/qt-5/qgraphicsitem.html#prepareGeometryChange
self.prepareGeometryChange()
# QLabel doesn't adjust it's size automatically, so do it here
# https://stackoverflow.com/a/47037607/3545896
self.label.adjustSize()
# Let super handle the actual painting
super(ProxyLabelWithCustomQPrintText, self).paint(
painter, option, widget
)
def boundingRect(self):
"""Give QRectF that bounds this item. Overridden.
Overridden to provide custom bounding rect. Custom bounding rect is
needed because this item has two different contents depending on where
it is drawn, which Qt does not respect (or understand) out of the box.
Overrides this: https://doc.qt.io/qt-5/qgraphicsitem.html#boundingRect
This function is called by Qt to figure out how much space it needs.
See the docstring of `determine_bounding_rect` for how the bounding
rect is calculated.
If either of self.default_text or self.printing_text changes, or if
font / style is updated on self.label, self.bounding_rect needs to be
updated using `update_bounding_rect`.
Returns
-------
QRectF
Bounding rect.
"""
return self.bounding_rect
class InlineEditableSOIView(QScrollArea):
"""Widget that allows for "inline" editing of an SOI. Also prints to PDF.
## Important note about QScrollArea superclass
This class was originally derived from QScrollArea for scrolling, but it
turns out QGraphicsView has it's own scrolling. We are using
QGraphicsView's scrolling instead. This class should probably inherit
directly from QGraphicsView, but because of time pressure we decided not
to refactor.
Parameters
----------
soi : soitool.soi.SOI
SOI to edit.
"""
def __init__(self, soi):
super().__init__()
self.soi = soi
# The following variables are updated by a call to `update_pages` later
# in this `__init__`. Therefore the values given here are in practice
# never used
self.number_of_pages_total = 1
self.number_of_non_attachment_pages = 1
self.proxies = set()
# NOTE: These variables are only included to support PDF output of the
# widget. When rendering self.scene onto a QPrinter this widget will
# use these variables to indicate that "copy number self.copy_current
# is being printed, out of a total of self.copy_total copies". By
# looping self.copy_total times, updating self.copy_current each time
# and rendering onto a QPrinter it is possible to print multiple
# copies of the SOI, each marked with a copy number.
self.copy_current = 1
self.copy_total = 3
# Necessary to make the scroll area fill the space it's given
self.setWidgetResizable(True)
# Scene and view widgets
self.scene = QGraphicsScene()
self.setup_scene()
self.view = QGraphicsViewWithCtrlScrollListener(self.scene)
# Zoom view when user holds CTRL while scrolling
self.view.add_ctrl_scroll_listener(self.scroll_by_wheel_event)
self.setWidget(self.view)
self.ensure_proxies()
self.update_pages()
# Add listeners to react properly to SOI changes
self.soi.add_reorganization_listener(self.update_pages)
self.soi.add_new_module_listener(self.ensure_proxies)
self.soi.add_update_property_listener(self.update_pages)
# Initial position of scrollbars should be upper-left
self.view.verticalScrollBar().setValue(1)
self.view.horizontalScrollBar().setValue(1)
def scroll_by_wheel_event(self, event):
"""Scroll SOI by given QWheelEvent.
`event.angleDelta().y()` gives the angle the mouse was scrolled. For
the mouse this code was tested with this was always 120, for mousepads
it varies with how hard the pad is scrolled. To support both regular
mice and mousepads we cap the angle at 120. The amount to increment
zoom is calculated from the angle by multiplying with
SCROLL_ZOOM_INCREMENT_SCALAR.
Source:
* https://stackoverflow.com/a/41688654/3545896
Parameters
----------
event : QWheelEvent
"""
vertical_scroll_angle = event.angleDelta().y()
vertical_scroll_angle = max(
vertical_scroll_angle, -1 * ABSOLUTE_MAXIMUM_SCROLL_ANGLE
)
vertical_scroll_angle = min(
vertical_scroll_angle, ABSOLUTE_MAXIMUM_SCROLL_ANGLE
)
zoom_increment = SCROLL_ZOOM_INCREMENT_SCALAR * vertical_scroll_angle
self.zoom(
zoom_increment,
anchor=QGraphicsView.ViewportAnchor.AnchorUnderMouse,
)
self.show_zoom_level_in_statusbar()
def show_zoom_level_in_statusbar(self):
"""Show zoom level in statusbar."""
current_scale = self.get_current_scale()
zoom_level_percentage_string = str(round(current_scale * 100))
message = f"Zoom: {zoom_level_percentage_string}%"
get_main_window().statusBar().showMessage(message)
def is_widget_in_scene(self, widget):
"""Indicate wether given widget already has a proxy in the scene."""
for proxy in self.proxies:
if proxy.widget() == widget:
return True
return False
def ensure_proxies(self):
"""Make sure all modules of the SOI have a proxy inside the scene."""
for module in self.soi.modules + self.soi.attachments:
if not self.is_widget_in_scene(module["widget"]):
proxy = self.scene.addWidget(module["widget"])
self.proxies.add(proxy)
def mousePressEvent(self, _):
"""Reorganize modules when pressed.
This is a temporary way to activate reorganization of widgets. Note
that will not be triggered by clicks on modules in the scene.
"""
try_reorganize(self.soi)
def produce_pdf(self, number_of_copies, resolution, filename=None):
"""Produce PDF using QGraphicsScene.
Renders the QGraphicsScene-representation of the SOI as a PDF. This
PDF is in theory supposed to be searchable [1], but in practice it
seems that each QGraphicsItem in the scene is simply dumped as an
image.
Sources:
[1]: https://doc.qt.io/qt-5/qprinter.html#OutputFormat-enum
Parameters
----------
number_of_copies : int
Total number of copies to produce.
resolution : int
Resolution of PDF. Passed to
https://doc.qt.io/qt-5/qprinter.html#setResolution
filename : str
Name of file to store PDF in. If file exists it will be
overwritten. Note that the filename should contain the extension
'.pdf' to be properly handled by operating systems. If no filename
is supplied one will be generated following the same convention as
for exported JSON and TXT files
Raises
------
ValueError
If filename is invalid.
"""
# Prepare modules for PDF-export
for module in self.soi.modules:
module["widget"].prepare_for_pdf_export()
for attachment in self.soi.attachments:
attachment["widget"].prepare_for_pdf_export()
if filename is None:
filename = generate_soi_filename(self.soi) + ".pdf"
printer = QPrinter(QPrinter.HighResolution)
printer.setResolution(resolution)
printer.setOutputFormat(QPrinter.PdfFormat)
printer.setOutputFileName(filename)
printer.setPageSize(QPrinter.A4)
printer.setOrientation(QPrinter.Landscape)
printer.setPageMargins(QMarginsF(0, 0, 0, 0))
painter = QPainter()
try:
ok = painter.begin(printer)
if not ok:
raise ValueError(
"Not able to begin QPainter using QPrinter "
"based on argument "
"filename '{}'".format(filename)
)
# Update total number of copies from parameter
self.copy_total = number_of_copies
for i in range(self.copy_total):
# Update copy number and redraw pages so that it is reflected
# in the scene
self.copy_current = i + 1
self.draw_pages()
# Render each page to own PDF page
for j in range(self.number_of_pages_total):
x = 0
y = self.soi.HEIGHT * j + self.soi.PADDING * j
self.scene.render(
painter,
source=QRectF(x, y, self.soi.WIDTH, self.soi.HEIGHT),
)
# If there are more pages, newPage
if j + 1 < self.number_of_pages_total:
printer.newPage()
# If there are more copies, newPage
if i + 1 < self.copy_total:
printer.newPage()
finally:
painter.end()
def update_number_of_pages(self):
"""Make sure number of pages excactly fits SOI modules.
The minimum page count is 1.
"""
self.number_of_non_attachment_pages = (
self.soi.get_number_of_non_attachment_pages()
)
# Each attachment module requires it's own page
required_pages = self.number_of_non_attachment_pages + len(
self.soi.attachments
)
self.number_of_pages_total = required_pages
def draw_pages(self):
"""Draw self.number_of_pages_total pages."""
for i in range(self.number_of_pages_total):
x = 0
y = self.soi.HEIGHT * i + self.soi.PADDING * i
# Adjust page size
full_scene_height = y + self.soi.HEIGHT
self.scene.setSceneRect(
QRectF(0, 0, self.soi.WIDTH, full_scene_height)
)
self.draw_page(x, y)
page_number = i + 1
header_x = x + self.soi.PADDING
header_y = y + self.soi.PADDING
# If not an attachment page: draw as normal
# If attachment page: draw with page number starting from 1 again
if page_number <= self.number_of_non_attachment_pages:
self.draw_header(header_x, header_y, page_number, False)
else:
self.draw_header(
header_x,
header_y,
page_number - self.number_of_non_attachment_pages,
True,
)
for proxy in self.proxies:
# Redraw of pages requires modules to be moved to front again
proxy.setZValue(1)
def update_pages(self):
"""Update pages drawn in the scene to reflect the SOI."""
self.update_number_of_pages()
self.draw_pages()
# Ignoring Pylint's errors "Too many local variables" and
# "Too many statements" for this function because it's doing GUI layout
# work, which in nature is tedious and repetitive.
# pylint: disable=R0914,R0915
def draw_header(self, x, y, page_number, is_attachment_page):
"""Draw header staring at given position.
Source for rotation approach:
* https://stackoverflow.com/a/43389394/3545896
Parameters
----------
x : int
y : int
page_number : int
Page number of page to draw. 'is_attachment_page' affects how the
page number is drawn.
is_attachment_page : bool
If True the page will indicate that it is an attachment, and the
page numbering will follow a convention for page numbering defined
in 'ATTACHMENT_NUMBERING_SCHEME'. If False the page will indicate
that the page is part of the main page, and the page numbering will
use 1,2,3,....
"""
# Title
label_title = QLabel(self.soi.title)
label_title.setStyleSheet("background-color: rgba(0,0,0,0%)")
label_title.setFont(qfont_with_pixel_size("Times New Roman", 60))
proxy = self.scene.addWidget(label_title)
proxy.setRotation(-90)
proxy.setPos(x - 5, y + self.soi.HEADER_HEIGHT - 5)
# Description
label_description = QLabel(self.soi.description)
label_description.setStyleSheet("background-color: rgba(0,0,0,0%)")
label_description.setFont(qfont_with_pixel_size("Times New Roman", 28))
proxy = self.scene.addWidget(label_description)
proxy.setRotation(-90)
proxy.setPos(x + 52, y + self.soi.HEADER_HEIGHT - 5)
# Creation date
creation_date = soi_date_string_to_user_friendly_string(self.soi.date)
label_creation_date = QLabel("Opprettet: {}".format(creation_date))
label_creation_date.setStyleSheet("background-color: rgba(0,0,0,0%)")
label_creation_date.setFont(
qfont_with_pixel_size("Times New Roman", 28)
)
# Source: https://stackoverflow.com/a/8638114/3545896
# CAUTION: does not work if font is set through stylesheet
label_width = (
label_creation_date.fontMetrics()
.boundingRect(label_creation_date.text())
.width()
)
proxy = self.scene.addWidget(label_creation_date)
proxy.setRotation(-90)
proxy.setPos(x + 78, y + self.soi.HEADER_HEIGHT - 5)
# Patch
pixmap = QPixmap(self.soi.icon)
patch = QLabel()
patch.setPixmap(
pixmap.scaled(self.soi.HEADER_WIDTH - 2, self.soi.HEADER_WIDTH - 2)
)
proxy = self.scene.addWidget(patch)
proxy.setRotation(-90)
proxy.setPos(
x + 1, y + self.soi.HEADER_HEIGHT / 2 + self.soi.HEADER_WIDTH + 10
)
# Copy number
# Store for usage when placing copy number title and page number
copy_number_y_pos = y + self.soi.HEADER_HEIGHT / 2 - 100
# NOTE: See __init__ for explanation on self.copy_current and
# self.copy_total
proxy = ProxyLabelWithCustomQPrintText(
"1 av N", f"{self.copy_current} av {self.copy_total}"
)
proxy.label.setStyleSheet("background-color: rgba(0,0,0,0%)")
proxy.label.setFont(qfont_with_pixel_size("Times New Roman", 60))
# Need to call this when label of proxy changes. See function docstring
proxy.update_bounding_rect()
# Source: https://stackoverflow.com/a/8638114/3545896
# CAUTION: does not work if font is set through stylesheet
label_width = (
proxy.label.fontMetrics().boundingRect(proxy.label.text()).width()
)
# NOTE that this position is only correct for the default text. During
# PDF printing the ProxyLabelWithCustomQPrintText class will paint
# itself with a different text, and thus not be perfectly centered.
proxy.setPos(x + 22, copy_number_y_pos + label_width / 2)
proxy.setRotation(-90)
self.scene.addItem(proxy)
# Copy number title
label_copy_number_header = QLabel("Eksemplar")
label_copy_number_header.setStyleSheet(
"background-color: rgba(0,0,0,0%)"
)
label_copy_number_header.setFont(
qfont_with_pixel_size("Times New Roman", 28)
)
# Source: https://stackoverflow.com/a/8638114/3545896
# CAUTION: does not work if font is set through stylesheet
label_width = (
label_copy_number_header.fontMetrics()
.boundingRect(label_copy_number_header.text())
.width()
)
proxy = self.scene.addWidget(label_copy_number_header)
proxy.setRotation(-90)
proxy.setPos(x, copy_number_y_pos + label_width / 2)
# Page numbering
if is_attachment_page:
page_number = QLabel(
"Vedlegg {}".format(
ATTACHMENT_NUMBERING_SCHEME[page_number - 1]
)
)
else:
page_number = QLabel(
"Side {} av {}".format(
page_number, self.number_of_non_attachment_pages
)
)
page_number.setStyleSheet("background-color: rgba(0,0,0,0%)")
page_number.setFont(qfont_with_pixel_size("Times New Roman", 28))
# Source: https://stackoverflow.com/a/8638114/3545896
# CAUTION: does not work if font is set through stylesheet
label_width = (
page_number.fontMetrics().boundingRect(page_number.text()).width()
)
proxy = self.scene.addWidget(page_number)
proxy.setRotation(-90)
proxy.setPos(x + 80, copy_number_y_pos + label_width / 2)
# Classification
classification = QLabel(self.soi.classification)
classification.setStyleSheet(
"background-color: rgba(0,0,0,0%); " "color: #eb0000"
)
classification.setFont(qfont_with_pixel_size("Times New Roman", 54))
# Source: https://stackoverflow.com/a/8638114/3545896
# CAUTION: does not work if font is set through stylesheet
label_width = (
classification.fontMetrics()
.boundingRect(classification.text())
.width()
)
proxy = self.scene.addWidget(classification)
proxy.setRotation(-90)
proxy.setPos(x - 2, y + label_width + 10)
# From date
valid_from = soi_date_string_to_user_friendly_string(
self.soi.valid_from
)
label_valid_from = QLabel("Gyldig fra: {}".format(valid_from))
label_valid_from.setStyleSheet("background-color: rgba(0,0,0,0%)")
label_valid_from.setFont(qfont_with_pixel_size("Times New Roman", 28))
# Source: https://stackoverflow.com/a/8638114/3545896
# CAUTION: does not work if font is set through stylesheet
label_width = (
label_valid_from.fontMetrics()
.boundingRect(label_valid_from.text())
.width()
)
proxy = self.scene.addWidget(label_valid_from)
proxy.setRotation(-90)
proxy.setPos(x + 55, y + label_width + 10)
# To date
valid_to = soi_date_string_to_user_friendly_string(self.soi.valid_to)
label_valid_to = QLabel("Gyldig til: {}".format(valid_to))
label_valid_to.setStyleSheet("background-color: rgba(0,0,0,0%)")
label_valid_to.setFont(qfont_with_pixel_size("Times New Roman", 28))
# Source: https://stackoverflow.com/a/8638114/3545896
# CAUTION: does not work if font is set through stylesheet
label_width = (
label_valid_to.fontMetrics()
.boundingRect(label_valid_to.text())
.width()
)
proxy = self.scene.addWidget(label_valid_to)
proxy.setRotation(-90)
proxy.setPos(x + 80, y + label_width + 10)
def draw_page(self, x, y):
"""Draw page starting at given position.
Parameters
----------
x : int
y : int
"""
# Color the page white
page_background = QGraphicsRectItem(
x, y, self.soi.WIDTH, self.soi.HEIGHT
)
page_background.setBrush(QBrush(Qt.white))
self.scene.addItem(page_background)
# Draw borders
self.scene.addRect(x, y, self.soi.WIDTH, self.soi.HEIGHT)
self.scene.addRect(
x + self.soi.PADDING,
y + self.soi.PADDING,
self.soi.WIDTH - self.soi.PADDING * 2,
self.soi.HEIGHT - self.soi.PADDING * 2,
)
self.scene.addRect(
x + self.soi.PADDING,
y + self.soi.PADDING,
self.soi.HEADER_WIDTH,
self.soi.HEADER_HEIGHT,
)
def setup_scene(self):
"""Prepare scene for use.
Draws borders, background, etc.
"""
# Sets the background color of the scene to be the appropriate
# system-specific background color
# source: https://stackoverflow.com/a/23880531/3545896
# source: https://stackoverflow.com/questions/15519749/how-to-get-widget-background-qcolor
app = QApplication.instance()
self.scene.setBackgroundBrush(app.palette().color(QPalette.Window))
self.update_pages()
def launch_auto_zoom(self):
"""Zoom in a regular interval.
Used to demonstrate zooming only, should be removed once the project
matures.
"""
def do_on_timeout():
self.zoom(1 / 1.00005)
timer = QTimer(self)
timer.timeout.connect(do_on_timeout)
timer.start(10)
def zoom(
self,
zoom_increment,
anchor=QGraphicsView.ViewportAnchor.AnchorViewCenter,
):
"""Zoom GraphicsView by zoom_factor.
The zoom level is held within ZOOM_LEVEL_MINIMUM and
ZOOM_LEVEL_MAXIMUM.
Note that because of inaccuracy during floating point operations the
current scale is sometimes off by an insignificant fraction
(e.g. 0.0000000000000001)
source:
* https://stackoverflow.com/a/29026916/3545896
* https://stackoverflow.com/a/41688654/3545896
Parameters
----------
zoom_increment : float
How much to increase zoom by. 1 means increase by 100%, -0.5 means
decrease by -50%, and so on.
anchor : QGraphicsView.ViewportAnchor
Anchor indicates where to aim the zoom. See the documentation for
QGraphicsView.ViewportAnchor.
QGraphicsView.ViewportAnchor.AnchorUnderMouse can be used to target
the mouse.
"""
current_scale = self.get_current_scale()
# Only zoom if we are not breaching the minimum and maximum range
if (
ZOOM_LEVEL_MINIMUM
< current_scale + zoom_increment
< ZOOM_LEVEL_MAXIMUM
):
# We need a scalar such that:
# current_scale * scalar = current_scale + zoom_increment
scalar = (current_scale + zoom_increment) / current_scale
viewport_anchor = self.view.transformationAnchor()
self.view.setTransformationAnchor(anchor)
self.view.scale(
scalar, scalar,
)
self.view.setTransformationAnchor(viewport_anchor)
def get_current_scale(self):
"""Get current scale.
Returns
-------
scale : float
Horizontal and vertical scale. They should always be equal in our
case.
"""
scale_x = self.view.transform().m11()
return scale_x
def soi_date_string_to_user_friendly_string(date_string):
"""Convert SOI date to user-friendly format.
Parameters
----------
date_string : str
String following convention used in SOI, which is 'YYYY-MM-DD'
Returns
-------
str
String following user friendly convention 'DD.MM.YYYY'
"""
parsed_date = datetime.strptime(date_string, "%Y-%m-%d")
return parsed_date.strftime("%d.%m.%Y")
def get_main_window():
"""Get the main window amongst the top level widgets.
Parameters
----------
widget : QMainWindow
The QMainWindow.
Raises
------
Exception
Indicates that there was no QMainWindow amongst the top level widgets.
"""
for widget in QApplication.instance().topLevelWidgets():
if isinstance(widget, QMainWindow):
return widget
raise Exception(
"Could not find main window. This should only happen if "
"used without QMainWindow."
)
def try_reorganize(soi):
"""Try to reorganize SOI and show error if was not able.
If reorganization cannot occur because a module is too large the user
will be informed. It's worth noting that with the current
implementation this is the only way the user will be informed of this,
so reorganization that is not triggered here will not give feedback to
the user.
Pararmeters
-----------
soi : soitool.soi.SOI
The SOI to reorganize.
"""
try:
soi.reorganize()
except ModuleLargerThanBinError:
exec_warning_dialog(
text="Minst én av modulene er for store "
"for å passe på én side!",
informative_text="Programmet kan desverre ikke fikse dette "
"for deg. Se igjennom modulene og sjekk at "
"alle moduler er mindre enn én enkelt side.",
)
Functions
def get_main_window()
-
Get the main window amongst the top level widgets.
Parameters
widget
:QMainWindow
- The QMainWindow.
Raises
Exception
- Indicates that there was no QMainWindow amongst the top level widgets.
Expand source code
def get_main_window(): """Get the main window amongst the top level widgets. Parameters ---------- widget : QMainWindow The QMainWindow. Raises ------ Exception Indicates that there was no QMainWindow amongst the top level widgets. """ for widget in QApplication.instance().topLevelWidgets(): if isinstance(widget, QMainWindow): return widget raise Exception( "Could not find main window. This should only happen if " "used without QMainWindow." )
def soi_date_string_to_user_friendly_string(date_string)
-
Convert SOI date to user-friendly format.
Parameters
date_string
:str
- String following convention used in SOI, which is 'YYYY-MM-DD'
Returns
str
- String following user friendly convention 'DD.MM.YYYY'
Expand source code
def soi_date_string_to_user_friendly_string(date_string): """Convert SOI date to user-friendly format. Parameters ---------- date_string : str String following convention used in SOI, which is 'YYYY-MM-DD' Returns ------- str String following user friendly convention 'DD.MM.YYYY' """ parsed_date = datetime.strptime(date_string, "%Y-%m-%d") return parsed_date.strftime("%d.%m.%Y")
def try_reorganize(soi)
-
Try to reorganize SOI and show error if was not able.
If reorganization cannot occur because a module is too large the user will be informed. It's worth noting that with the current implementation this is the only way the user will be informed of this, so reorganization that is not triggered here will not give feedback to the user.
Pararmeters
soi
:SOI
- The SOI to reorganize.
Expand source code
def try_reorganize(soi): """Try to reorganize SOI and show error if was not able. If reorganization cannot occur because a module is too large the user will be informed. It's worth noting that with the current implementation this is the only way the user will be informed of this, so reorganization that is not triggered here will not give feedback to the user. Pararmeters ----------- soi : soitool.soi.SOI The SOI to reorganize. """ try: soi.reorganize() except ModuleLargerThanBinError: exec_warning_dialog( text="Minst én av modulene er for store " "for å passe på én side!", informative_text="Programmet kan desverre ikke fikse dette " "for deg. Se igjennom modulene og sjekk at " "alle moduler er mindre enn én enkelt side.", )
Classes
class InlineEditableSOIView (soi)
-
Widget that allows for "inline" editing of an SOI. Also prints to PDF.
Important note about QScrollArea superclass
This class was originally derived from QScrollArea for scrolling, but it turns out QGraphicsView has it's own scrolling. We are using QGraphicsView's scrolling instead. This class should probably inherit directly from QGraphicsView, but because of time pressure we decided not to refactor.
Parameters
soi
:SOI
- SOI to edit.
Expand source code
class InlineEditableSOIView(QScrollArea): """Widget that allows for "inline" editing of an SOI. Also prints to PDF. ## Important note about QScrollArea superclass This class was originally derived from QScrollArea for scrolling, but it turns out QGraphicsView has it's own scrolling. We are using QGraphicsView's scrolling instead. This class should probably inherit directly from QGraphicsView, but because of time pressure we decided not to refactor. Parameters ---------- soi : soitool.soi.SOI SOI to edit. """ def __init__(self, soi): super().__init__() self.soi = soi # The following variables are updated by a call to `update_pages` later # in this `__init__`. Therefore the values given here are in practice # never used self.number_of_pages_total = 1 self.number_of_non_attachment_pages = 1 self.proxies = set() # NOTE: These variables are only included to support PDF output of the # widget. When rendering self.scene onto a QPrinter this widget will # use these variables to indicate that "copy number self.copy_current # is being printed, out of a total of self.copy_total copies". By # looping self.copy_total times, updating self.copy_current each time # and rendering onto a QPrinter it is possible to print multiple # copies of the SOI, each marked with a copy number. self.copy_current = 1 self.copy_total = 3 # Necessary to make the scroll area fill the space it's given self.setWidgetResizable(True) # Scene and view widgets self.scene = QGraphicsScene() self.setup_scene() self.view = QGraphicsViewWithCtrlScrollListener(self.scene) # Zoom view when user holds CTRL while scrolling self.view.add_ctrl_scroll_listener(self.scroll_by_wheel_event) self.setWidget(self.view) self.ensure_proxies() self.update_pages() # Add listeners to react properly to SOI changes self.soi.add_reorganization_listener(self.update_pages) self.soi.add_new_module_listener(self.ensure_proxies) self.soi.add_update_property_listener(self.update_pages) # Initial position of scrollbars should be upper-left self.view.verticalScrollBar().setValue(1) self.view.horizontalScrollBar().setValue(1) def scroll_by_wheel_event(self, event): """Scroll SOI by given QWheelEvent. `event.angleDelta().y()` gives the angle the mouse was scrolled. For the mouse this code was tested with this was always 120, for mousepads it varies with how hard the pad is scrolled. To support both regular mice and mousepads we cap the angle at 120. The amount to increment zoom is calculated from the angle by multiplying with SCROLL_ZOOM_INCREMENT_SCALAR. Source: * https://stackoverflow.com/a/41688654/3545896 Parameters ---------- event : QWheelEvent """ vertical_scroll_angle = event.angleDelta().y() vertical_scroll_angle = max( vertical_scroll_angle, -1 * ABSOLUTE_MAXIMUM_SCROLL_ANGLE ) vertical_scroll_angle = min( vertical_scroll_angle, ABSOLUTE_MAXIMUM_SCROLL_ANGLE ) zoom_increment = SCROLL_ZOOM_INCREMENT_SCALAR * vertical_scroll_angle self.zoom( zoom_increment, anchor=QGraphicsView.ViewportAnchor.AnchorUnderMouse, ) self.show_zoom_level_in_statusbar() def show_zoom_level_in_statusbar(self): """Show zoom level in statusbar.""" current_scale = self.get_current_scale() zoom_level_percentage_string = str(round(current_scale * 100)) message = f"Zoom: {zoom_level_percentage_string}%" get_main_window().statusBar().showMessage(message) def is_widget_in_scene(self, widget): """Indicate wether given widget already has a proxy in the scene.""" for proxy in self.proxies: if proxy.widget() == widget: return True return False def ensure_proxies(self): """Make sure all modules of the SOI have a proxy inside the scene.""" for module in self.soi.modules + self.soi.attachments: if not self.is_widget_in_scene(module["widget"]): proxy = self.scene.addWidget(module["widget"]) self.proxies.add(proxy) def mousePressEvent(self, _): """Reorganize modules when pressed. This is a temporary way to activate reorganization of widgets. Note that will not be triggered by clicks on modules in the scene. """ try_reorganize(self.soi) def produce_pdf(self, number_of_copies, resolution, filename=None): """Produce PDF using QGraphicsScene. Renders the QGraphicsScene-representation of the SOI as a PDF. This PDF is in theory supposed to be searchable [1], but in practice it seems that each QGraphicsItem in the scene is simply dumped as an image. Sources: [1]: https://doc.qt.io/qt-5/qprinter.html#OutputFormat-enum Parameters ---------- number_of_copies : int Total number of copies to produce. resolution : int Resolution of PDF. Passed to https://doc.qt.io/qt-5/qprinter.html#setResolution filename : str Name of file to store PDF in. If file exists it will be overwritten. Note that the filename should contain the extension '.pdf' to be properly handled by operating systems. If no filename is supplied one will be generated following the same convention as for exported JSON and TXT files Raises ------ ValueError If filename is invalid. """ # Prepare modules for PDF-export for module in self.soi.modules: module["widget"].prepare_for_pdf_export() for attachment in self.soi.attachments: attachment["widget"].prepare_for_pdf_export() if filename is None: filename = generate_soi_filename(self.soi) + ".pdf" printer = QPrinter(QPrinter.HighResolution) printer.setResolution(resolution) printer.setOutputFormat(QPrinter.PdfFormat) printer.setOutputFileName(filename) printer.setPageSize(QPrinter.A4) printer.setOrientation(QPrinter.Landscape) printer.setPageMargins(QMarginsF(0, 0, 0, 0)) painter = QPainter() try: ok = painter.begin(printer) if not ok: raise ValueError( "Not able to begin QPainter using QPrinter " "based on argument " "filename '{}'".format(filename) ) # Update total number of copies from parameter self.copy_total = number_of_copies for i in range(self.copy_total): # Update copy number and redraw pages so that it is reflected # in the scene self.copy_current = i + 1 self.draw_pages() # Render each page to own PDF page for j in range(self.number_of_pages_total): x = 0 y = self.soi.HEIGHT * j + self.soi.PADDING * j self.scene.render( painter, source=QRectF(x, y, self.soi.WIDTH, self.soi.HEIGHT), ) # If there are more pages, newPage if j + 1 < self.number_of_pages_total: printer.newPage() # If there are more copies, newPage if i + 1 < self.copy_total: printer.newPage() finally: painter.end() def update_number_of_pages(self): """Make sure number of pages excactly fits SOI modules. The minimum page count is 1. """ self.number_of_non_attachment_pages = ( self.soi.get_number_of_non_attachment_pages() ) # Each attachment module requires it's own page required_pages = self.number_of_non_attachment_pages + len( self.soi.attachments ) self.number_of_pages_total = required_pages def draw_pages(self): """Draw self.number_of_pages_total pages.""" for i in range(self.number_of_pages_total): x = 0 y = self.soi.HEIGHT * i + self.soi.PADDING * i # Adjust page size full_scene_height = y + self.soi.HEIGHT self.scene.setSceneRect( QRectF(0, 0, self.soi.WIDTH, full_scene_height) ) self.draw_page(x, y) page_number = i + 1 header_x = x + self.soi.PADDING header_y = y + self.soi.PADDING # If not an attachment page: draw as normal # If attachment page: draw with page number starting from 1 again if page_number <= self.number_of_non_attachment_pages: self.draw_header(header_x, header_y, page_number, False) else: self.draw_header( header_x, header_y, page_number - self.number_of_non_attachment_pages, True, ) for proxy in self.proxies: # Redraw of pages requires modules to be moved to front again proxy.setZValue(1) def update_pages(self): """Update pages drawn in the scene to reflect the SOI.""" self.update_number_of_pages() self.draw_pages() # Ignoring Pylint's errors "Too many local variables" and # "Too many statements" for this function because it's doing GUI layout # work, which in nature is tedious and repetitive. # pylint: disable=R0914,R0915 def draw_header(self, x, y, page_number, is_attachment_page): """Draw header staring at given position. Source for rotation approach: * https://stackoverflow.com/a/43389394/3545896 Parameters ---------- x : int y : int page_number : int Page number of page to draw. 'is_attachment_page' affects how the page number is drawn. is_attachment_page : bool If True the page will indicate that it is an attachment, and the page numbering will follow a convention for page numbering defined in 'ATTACHMENT_NUMBERING_SCHEME'. If False the page will indicate that the page is part of the main page, and the page numbering will use 1,2,3,.... """ # Title label_title = QLabel(self.soi.title) label_title.setStyleSheet("background-color: rgba(0,0,0,0%)") label_title.setFont(qfont_with_pixel_size("Times New Roman", 60)) proxy = self.scene.addWidget(label_title) proxy.setRotation(-90) proxy.setPos(x - 5, y + self.soi.HEADER_HEIGHT - 5) # Description label_description = QLabel(self.soi.description) label_description.setStyleSheet("background-color: rgba(0,0,0,0%)") label_description.setFont(qfont_with_pixel_size("Times New Roman", 28)) proxy = self.scene.addWidget(label_description) proxy.setRotation(-90) proxy.setPos(x + 52, y + self.soi.HEADER_HEIGHT - 5) # Creation date creation_date = soi_date_string_to_user_friendly_string(self.soi.date) label_creation_date = QLabel("Opprettet: {}".format(creation_date)) label_creation_date.setStyleSheet("background-color: rgba(0,0,0,0%)") label_creation_date.setFont( qfont_with_pixel_size("Times New Roman", 28) ) # Source: https://stackoverflow.com/a/8638114/3545896 # CAUTION: does not work if font is set through stylesheet label_width = ( label_creation_date.fontMetrics() .boundingRect(label_creation_date.text()) .width() ) proxy = self.scene.addWidget(label_creation_date) proxy.setRotation(-90) proxy.setPos(x + 78, y + self.soi.HEADER_HEIGHT - 5) # Patch pixmap = QPixmap(self.soi.icon) patch = QLabel() patch.setPixmap( pixmap.scaled(self.soi.HEADER_WIDTH - 2, self.soi.HEADER_WIDTH - 2) ) proxy = self.scene.addWidget(patch) proxy.setRotation(-90) proxy.setPos( x + 1, y + self.soi.HEADER_HEIGHT / 2 + self.soi.HEADER_WIDTH + 10 ) # Copy number # Store for usage when placing copy number title and page number copy_number_y_pos = y + self.soi.HEADER_HEIGHT / 2 - 100 # NOTE: See __init__ for explanation on self.copy_current and # self.copy_total proxy = ProxyLabelWithCustomQPrintText( "1 av N", f"{self.copy_current} av {self.copy_total}" ) proxy.label.setStyleSheet("background-color: rgba(0,0,0,0%)") proxy.label.setFont(qfont_with_pixel_size("Times New Roman", 60)) # Need to call this when label of proxy changes. See function docstring proxy.update_bounding_rect() # Source: https://stackoverflow.com/a/8638114/3545896 # CAUTION: does not work if font is set through stylesheet label_width = ( proxy.label.fontMetrics().boundingRect(proxy.label.text()).width() ) # NOTE that this position is only correct for the default text. During # PDF printing the ProxyLabelWithCustomQPrintText class will paint # itself with a different text, and thus not be perfectly centered. proxy.setPos(x + 22, copy_number_y_pos + label_width / 2) proxy.setRotation(-90) self.scene.addItem(proxy) # Copy number title label_copy_number_header = QLabel("Eksemplar") label_copy_number_header.setStyleSheet( "background-color: rgba(0,0,0,0%)" ) label_copy_number_header.setFont( qfont_with_pixel_size("Times New Roman", 28) ) # Source: https://stackoverflow.com/a/8638114/3545896 # CAUTION: does not work if font is set through stylesheet label_width = ( label_copy_number_header.fontMetrics() .boundingRect(label_copy_number_header.text()) .width() ) proxy = self.scene.addWidget(label_copy_number_header) proxy.setRotation(-90) proxy.setPos(x, copy_number_y_pos + label_width / 2) # Page numbering if is_attachment_page: page_number = QLabel( "Vedlegg {}".format( ATTACHMENT_NUMBERING_SCHEME[page_number - 1] ) ) else: page_number = QLabel( "Side {} av {}".format( page_number, self.number_of_non_attachment_pages ) ) page_number.setStyleSheet("background-color: rgba(0,0,0,0%)") page_number.setFont(qfont_with_pixel_size("Times New Roman", 28)) # Source: https://stackoverflow.com/a/8638114/3545896 # CAUTION: does not work if font is set through stylesheet label_width = ( page_number.fontMetrics().boundingRect(page_number.text()).width() ) proxy = self.scene.addWidget(page_number) proxy.setRotation(-90) proxy.setPos(x + 80, copy_number_y_pos + label_width / 2) # Classification classification = QLabel(self.soi.classification) classification.setStyleSheet( "background-color: rgba(0,0,0,0%); " "color: #eb0000" ) classification.setFont(qfont_with_pixel_size("Times New Roman", 54)) # Source: https://stackoverflow.com/a/8638114/3545896 # CAUTION: does not work if font is set through stylesheet label_width = ( classification.fontMetrics() .boundingRect(classification.text()) .width() ) proxy = self.scene.addWidget(classification) proxy.setRotation(-90) proxy.setPos(x - 2, y + label_width + 10) # From date valid_from = soi_date_string_to_user_friendly_string( self.soi.valid_from ) label_valid_from = QLabel("Gyldig fra: {}".format(valid_from)) label_valid_from.setStyleSheet("background-color: rgba(0,0,0,0%)") label_valid_from.setFont(qfont_with_pixel_size("Times New Roman", 28)) # Source: https://stackoverflow.com/a/8638114/3545896 # CAUTION: does not work if font is set through stylesheet label_width = ( label_valid_from.fontMetrics() .boundingRect(label_valid_from.text()) .width() ) proxy = self.scene.addWidget(label_valid_from) proxy.setRotation(-90) proxy.setPos(x + 55, y + label_width + 10) # To date valid_to = soi_date_string_to_user_friendly_string(self.soi.valid_to) label_valid_to = QLabel("Gyldig til: {}".format(valid_to)) label_valid_to.setStyleSheet("background-color: rgba(0,0,0,0%)") label_valid_to.setFont(qfont_with_pixel_size("Times New Roman", 28)) # Source: https://stackoverflow.com/a/8638114/3545896 # CAUTION: does not work if font is set through stylesheet label_width = ( label_valid_to.fontMetrics() .boundingRect(label_valid_to.text()) .width() ) proxy = self.scene.addWidget(label_valid_to) proxy.setRotation(-90) proxy.setPos(x + 80, y + label_width + 10) def draw_page(self, x, y): """Draw page starting at given position. Parameters ---------- x : int y : int """ # Color the page white page_background = QGraphicsRectItem( x, y, self.soi.WIDTH, self.soi.HEIGHT ) page_background.setBrush(QBrush(Qt.white)) self.scene.addItem(page_background) # Draw borders self.scene.addRect(x, y, self.soi.WIDTH, self.soi.HEIGHT) self.scene.addRect( x + self.soi.PADDING, y + self.soi.PADDING, self.soi.WIDTH - self.soi.PADDING * 2, self.soi.HEIGHT - self.soi.PADDING * 2, ) self.scene.addRect( x + self.soi.PADDING, y + self.soi.PADDING, self.soi.HEADER_WIDTH, self.soi.HEADER_HEIGHT, ) def setup_scene(self): """Prepare scene for use. Draws borders, background, etc. """ # Sets the background color of the scene to be the appropriate # system-specific background color # source: https://stackoverflow.com/a/23880531/3545896 # source: https://stackoverflow.com/questions/15519749/how-to-get-widget-background-qcolor app = QApplication.instance() self.scene.setBackgroundBrush(app.palette().color(QPalette.Window)) self.update_pages() def launch_auto_zoom(self): """Zoom in a regular interval. Used to demonstrate zooming only, should be removed once the project matures. """ def do_on_timeout(): self.zoom(1 / 1.00005) timer = QTimer(self) timer.timeout.connect(do_on_timeout) timer.start(10) def zoom( self, zoom_increment, anchor=QGraphicsView.ViewportAnchor.AnchorViewCenter, ): """Zoom GraphicsView by zoom_factor. The zoom level is held within ZOOM_LEVEL_MINIMUM and ZOOM_LEVEL_MAXIMUM. Note that because of inaccuracy during floating point operations the current scale is sometimes off by an insignificant fraction (e.g. 0.0000000000000001) source: * https://stackoverflow.com/a/29026916/3545896 * https://stackoverflow.com/a/41688654/3545896 Parameters ---------- zoom_increment : float How much to increase zoom by. 1 means increase by 100%, -0.5 means decrease by -50%, and so on. anchor : QGraphicsView.ViewportAnchor Anchor indicates where to aim the zoom. See the documentation for QGraphicsView.ViewportAnchor. QGraphicsView.ViewportAnchor.AnchorUnderMouse can be used to target the mouse. """ current_scale = self.get_current_scale() # Only zoom if we are not breaching the minimum and maximum range if ( ZOOM_LEVEL_MINIMUM < current_scale + zoom_increment < ZOOM_LEVEL_MAXIMUM ): # We need a scalar such that: # current_scale * scalar = current_scale + zoom_increment scalar = (current_scale + zoom_increment) / current_scale viewport_anchor = self.view.transformationAnchor() self.view.setTransformationAnchor(anchor) self.view.scale( scalar, scalar, ) self.view.setTransformationAnchor(viewport_anchor) def get_current_scale(self): """Get current scale. Returns ------- scale : float Horizontal and vertical scale. They should always be equal in our case. """ scale_x = self.view.transform().m11() return scale_x
Ancestors
- PySide2.QtWidgets.QScrollArea
- PySide2.QtWidgets.QAbstractScrollArea
- PySide2.QtWidgets.QFrame
- PySide2.QtWidgets.QWidget
- PySide2.QtCore.QObject
- PySide2.QtGui.QPaintDevice
- Shiboken.Object
Class variables
var staticMetaObject
Methods
def draw_header(self, x, y, page_number, is_attachment_page)
-
Draw header staring at given position.
Source for rotation approach: * https://stackoverflow.com/a/43389394/3545896
Parameters
x
:int
y
:int
page_number
:int
- Page number of page to draw. 'is_attachment_page' affects how the page number is drawn.
is_attachment_page
:bool
- If True the page will indicate that it is an attachment, and the page numbering will follow a convention for page numbering defined in 'ATTACHMENT_NUMBERING_SCHEME'. If False the page will indicate that the page is part of the main page, and the page numbering will use 1,2,3,....
Expand source code
def draw_header(self, x, y, page_number, is_attachment_page): """Draw header staring at given position. Source for rotation approach: * https://stackoverflow.com/a/43389394/3545896 Parameters ---------- x : int y : int page_number : int Page number of page to draw. 'is_attachment_page' affects how the page number is drawn. is_attachment_page : bool If True the page will indicate that it is an attachment, and the page numbering will follow a convention for page numbering defined in 'ATTACHMENT_NUMBERING_SCHEME'. If False the page will indicate that the page is part of the main page, and the page numbering will use 1,2,3,.... """ # Title label_title = QLabel(self.soi.title) label_title.setStyleSheet("background-color: rgba(0,0,0,0%)") label_title.setFont(qfont_with_pixel_size("Times New Roman", 60)) proxy = self.scene.addWidget(label_title) proxy.setRotation(-90) proxy.setPos(x - 5, y + self.soi.HEADER_HEIGHT - 5) # Description label_description = QLabel(self.soi.description) label_description.setStyleSheet("background-color: rgba(0,0,0,0%)") label_description.setFont(qfont_with_pixel_size("Times New Roman", 28)) proxy = self.scene.addWidget(label_description) proxy.setRotation(-90) proxy.setPos(x + 52, y + self.soi.HEADER_HEIGHT - 5) # Creation date creation_date = soi_date_string_to_user_friendly_string(self.soi.date) label_creation_date = QLabel("Opprettet: {}".format(creation_date)) label_creation_date.setStyleSheet("background-color: rgba(0,0,0,0%)") label_creation_date.setFont( qfont_with_pixel_size("Times New Roman", 28) ) # Source: https://stackoverflow.com/a/8638114/3545896 # CAUTION: does not work if font is set through stylesheet label_width = ( label_creation_date.fontMetrics() .boundingRect(label_creation_date.text()) .width() ) proxy = self.scene.addWidget(label_creation_date) proxy.setRotation(-90) proxy.setPos(x + 78, y + self.soi.HEADER_HEIGHT - 5) # Patch pixmap = QPixmap(self.soi.icon) patch = QLabel() patch.setPixmap( pixmap.scaled(self.soi.HEADER_WIDTH - 2, self.soi.HEADER_WIDTH - 2) ) proxy = self.scene.addWidget(patch) proxy.setRotation(-90) proxy.setPos( x + 1, y + self.soi.HEADER_HEIGHT / 2 + self.soi.HEADER_WIDTH + 10 ) # Copy number # Store for usage when placing copy number title and page number copy_number_y_pos = y + self.soi.HEADER_HEIGHT / 2 - 100 # NOTE: See __init__ for explanation on self.copy_current and # self.copy_total proxy = ProxyLabelWithCustomQPrintText( "1 av N", f"{self.copy_current} av {self.copy_total}" ) proxy.label.setStyleSheet("background-color: rgba(0,0,0,0%)") proxy.label.setFont(qfont_with_pixel_size("Times New Roman", 60)) # Need to call this when label of proxy changes. See function docstring proxy.update_bounding_rect() # Source: https://stackoverflow.com/a/8638114/3545896 # CAUTION: does not work if font is set through stylesheet label_width = ( proxy.label.fontMetrics().boundingRect(proxy.label.text()).width() ) # NOTE that this position is only correct for the default text. During # PDF printing the ProxyLabelWithCustomQPrintText class will paint # itself with a different text, and thus not be perfectly centered. proxy.setPos(x + 22, copy_number_y_pos + label_width / 2) proxy.setRotation(-90) self.scene.addItem(proxy) # Copy number title label_copy_number_header = QLabel("Eksemplar") label_copy_number_header.setStyleSheet( "background-color: rgba(0,0,0,0%)" ) label_copy_number_header.setFont( qfont_with_pixel_size("Times New Roman", 28) ) # Source: https://stackoverflow.com/a/8638114/3545896 # CAUTION: does not work if font is set through stylesheet label_width = ( label_copy_number_header.fontMetrics() .boundingRect(label_copy_number_header.text()) .width() ) proxy = self.scene.addWidget(label_copy_number_header) proxy.setRotation(-90) proxy.setPos(x, copy_number_y_pos + label_width / 2) # Page numbering if is_attachment_page: page_number = QLabel( "Vedlegg {}".format( ATTACHMENT_NUMBERING_SCHEME[page_number - 1] ) ) else: page_number = QLabel( "Side {} av {}".format( page_number, self.number_of_non_attachment_pages ) ) page_number.setStyleSheet("background-color: rgba(0,0,0,0%)") page_number.setFont(qfont_with_pixel_size("Times New Roman", 28)) # Source: https://stackoverflow.com/a/8638114/3545896 # CAUTION: does not work if font is set through stylesheet label_width = ( page_number.fontMetrics().boundingRect(page_number.text()).width() ) proxy = self.scene.addWidget(page_number) proxy.setRotation(-90) proxy.setPos(x + 80, copy_number_y_pos + label_width / 2) # Classification classification = QLabel(self.soi.classification) classification.setStyleSheet( "background-color: rgba(0,0,0,0%); " "color: #eb0000" ) classification.setFont(qfont_with_pixel_size("Times New Roman", 54)) # Source: https://stackoverflow.com/a/8638114/3545896 # CAUTION: does not work if font is set through stylesheet label_width = ( classification.fontMetrics() .boundingRect(classification.text()) .width() ) proxy = self.scene.addWidget(classification) proxy.setRotation(-90) proxy.setPos(x - 2, y + label_width + 10) # From date valid_from = soi_date_string_to_user_friendly_string( self.soi.valid_from ) label_valid_from = QLabel("Gyldig fra: {}".format(valid_from)) label_valid_from.setStyleSheet("background-color: rgba(0,0,0,0%)") label_valid_from.setFont(qfont_with_pixel_size("Times New Roman", 28)) # Source: https://stackoverflow.com/a/8638114/3545896 # CAUTION: does not work if font is set through stylesheet label_width = ( label_valid_from.fontMetrics() .boundingRect(label_valid_from.text()) .width() ) proxy = self.scene.addWidget(label_valid_from) proxy.setRotation(-90) proxy.setPos(x + 55, y + label_width + 10) # To date valid_to = soi_date_string_to_user_friendly_string(self.soi.valid_to) label_valid_to = QLabel("Gyldig til: {}".format(valid_to)) label_valid_to.setStyleSheet("background-color: rgba(0,0,0,0%)") label_valid_to.setFont(qfont_with_pixel_size("Times New Roman", 28)) # Source: https://stackoverflow.com/a/8638114/3545896 # CAUTION: does not work if font is set through stylesheet label_width = ( label_valid_to.fontMetrics() .boundingRect(label_valid_to.text()) .width() ) proxy = self.scene.addWidget(label_valid_to) proxy.setRotation(-90) proxy.setPos(x + 80, y + label_width + 10)
def draw_page(self, x, y)
-
Draw page starting at given position.
Parameters
x
:int
y
:int
Expand source code
def draw_page(self, x, y): """Draw page starting at given position. Parameters ---------- x : int y : int """ # Color the page white page_background = QGraphicsRectItem( x, y, self.soi.WIDTH, self.soi.HEIGHT ) page_background.setBrush(QBrush(Qt.white)) self.scene.addItem(page_background) # Draw borders self.scene.addRect(x, y, self.soi.WIDTH, self.soi.HEIGHT) self.scene.addRect( x + self.soi.PADDING, y + self.soi.PADDING, self.soi.WIDTH - self.soi.PADDING * 2, self.soi.HEIGHT - self.soi.PADDING * 2, ) self.scene.addRect( x + self.soi.PADDING, y + self.soi.PADDING, self.soi.HEADER_WIDTH, self.soi.HEADER_HEIGHT, )
def draw_pages(self)
-
Draw self.number_of_pages_total pages.
Expand source code
def draw_pages(self): """Draw self.number_of_pages_total pages.""" for i in range(self.number_of_pages_total): x = 0 y = self.soi.HEIGHT * i + self.soi.PADDING * i # Adjust page size full_scene_height = y + self.soi.HEIGHT self.scene.setSceneRect( QRectF(0, 0, self.soi.WIDTH, full_scene_height) ) self.draw_page(x, y) page_number = i + 1 header_x = x + self.soi.PADDING header_y = y + self.soi.PADDING # If not an attachment page: draw as normal # If attachment page: draw with page number starting from 1 again if page_number <= self.number_of_non_attachment_pages: self.draw_header(header_x, header_y, page_number, False) else: self.draw_header( header_x, header_y, page_number - self.number_of_non_attachment_pages, True, ) for proxy in self.proxies: # Redraw of pages requires modules to be moved to front again proxy.setZValue(1)
def ensure_proxies(self)
-
Make sure all modules of the SOI have a proxy inside the scene.
Expand source code
def ensure_proxies(self): """Make sure all modules of the SOI have a proxy inside the scene.""" for module in self.soi.modules + self.soi.attachments: if not self.is_widget_in_scene(module["widget"]): proxy = self.scene.addWidget(module["widget"]) self.proxies.add(proxy)
def get_current_scale(self)
-
Get current scale.
Returns
scale
:float
- Horizontal and vertical scale. They should always be equal in our case.
Expand source code
def get_current_scale(self): """Get current scale. Returns ------- scale : float Horizontal and vertical scale. They should always be equal in our case. """ scale_x = self.view.transform().m11() return scale_x
def is_widget_in_scene(self, widget)
-
Indicate wether given widget already has a proxy in the scene.
Expand source code
def is_widget_in_scene(self, widget): """Indicate wether given widget already has a proxy in the scene.""" for proxy in self.proxies: if proxy.widget() == widget: return True return False
def launch_auto_zoom(self)
-
Zoom in a regular interval.
Used to demonstrate zooming only, should be removed once the project matures.
Expand source code
def launch_auto_zoom(self): """Zoom in a regular interval. Used to demonstrate zooming only, should be removed once the project matures. """ def do_on_timeout(): self.zoom(1 / 1.00005) timer = QTimer(self) timer.timeout.connect(do_on_timeout) timer.start(10)
def mousePressEvent(self, _)
-
Reorganize modules when pressed.
This is a temporary way to activate reorganization of widgets. Note that will not be triggered by clicks on modules in the scene.
Expand source code
def mousePressEvent(self, _): """Reorganize modules when pressed. This is a temporary way to activate reorganization of widgets. Note that will not be triggered by clicks on modules in the scene. """ try_reorganize(self.soi)
def produce_pdf(self, number_of_copies, resolution, filename=None)
-
Produce PDF using QGraphicsScene.
Renders the QGraphicsScene-representation of the SOI as a PDF. This PDF is in theory supposed to be searchable 1, but in practice it seems that each QGraphicsItem in the scene is simply dumped as an image.
Sources:
Parameters
number_of_copies
:int
- Total number of copies to produce.
resolution
:int
- Resolution of PDF. Passed to https://doc.qt.io/qt-5/qprinter.html#setResolution
filename
:str
- Name of file to store PDF in. If file exists it will be overwritten. Note that the filename should contain the extension '.pdf' to be properly handled by operating systems. If no filename is supplied one will be generated following the same convention as for exported JSON and TXT files
Raises
ValueError
- If filename is invalid.
Expand source code
def produce_pdf(self, number_of_copies, resolution, filename=None): """Produce PDF using QGraphicsScene. Renders the QGraphicsScene-representation of the SOI as a PDF. This PDF is in theory supposed to be searchable [1], but in practice it seems that each QGraphicsItem in the scene is simply dumped as an image. Sources: [1]: https://doc.qt.io/qt-5/qprinter.html#OutputFormat-enum Parameters ---------- number_of_copies : int Total number of copies to produce. resolution : int Resolution of PDF. Passed to https://doc.qt.io/qt-5/qprinter.html#setResolution filename : str Name of file to store PDF in. If file exists it will be overwritten. Note that the filename should contain the extension '.pdf' to be properly handled by operating systems. If no filename is supplied one will be generated following the same convention as for exported JSON and TXT files Raises ------ ValueError If filename is invalid. """ # Prepare modules for PDF-export for module in self.soi.modules: module["widget"].prepare_for_pdf_export() for attachment in self.soi.attachments: attachment["widget"].prepare_for_pdf_export() if filename is None: filename = generate_soi_filename(self.soi) + ".pdf" printer = QPrinter(QPrinter.HighResolution) printer.setResolution(resolution) printer.setOutputFormat(QPrinter.PdfFormat) printer.setOutputFileName(filename) printer.setPageSize(QPrinter.A4) printer.setOrientation(QPrinter.Landscape) printer.setPageMargins(QMarginsF(0, 0, 0, 0)) painter = QPainter() try: ok = painter.begin(printer) if not ok: raise ValueError( "Not able to begin QPainter using QPrinter " "based on argument " "filename '{}'".format(filename) ) # Update total number of copies from parameter self.copy_total = number_of_copies for i in range(self.copy_total): # Update copy number and redraw pages so that it is reflected # in the scene self.copy_current = i + 1 self.draw_pages() # Render each page to own PDF page for j in range(self.number_of_pages_total): x = 0 y = self.soi.HEIGHT * j + self.soi.PADDING * j self.scene.render( painter, source=QRectF(x, y, self.soi.WIDTH, self.soi.HEIGHT), ) # If there are more pages, newPage if j + 1 < self.number_of_pages_total: printer.newPage() # If there are more copies, newPage if i + 1 < self.copy_total: printer.newPage() finally: painter.end()
def scroll_by_wheel_event(self, event)
-
Scroll SOI by given QWheelEvent.
event.angleDelta().y()
gives the angle the mouse was scrolled. For the mouse this code was tested with this was always 120, for mousepads it varies with how hard the pad is scrolled. To support both regular mice and mousepads we cap the angle at 120. The amount to increment zoom is calculated from the angle by multiplying with SCROLL_ZOOM_INCREMENT_SCALAR.Source: * https://stackoverflow.com/a/41688654/3545896
Parameters
event
:QWheelEvent
Expand source code
def scroll_by_wheel_event(self, event): """Scroll SOI by given QWheelEvent. `event.angleDelta().y()` gives the angle the mouse was scrolled. For the mouse this code was tested with this was always 120, for mousepads it varies with how hard the pad is scrolled. To support both regular mice and mousepads we cap the angle at 120. The amount to increment zoom is calculated from the angle by multiplying with SCROLL_ZOOM_INCREMENT_SCALAR. Source: * https://stackoverflow.com/a/41688654/3545896 Parameters ---------- event : QWheelEvent """ vertical_scroll_angle = event.angleDelta().y() vertical_scroll_angle = max( vertical_scroll_angle, -1 * ABSOLUTE_MAXIMUM_SCROLL_ANGLE ) vertical_scroll_angle = min( vertical_scroll_angle, ABSOLUTE_MAXIMUM_SCROLL_ANGLE ) zoom_increment = SCROLL_ZOOM_INCREMENT_SCALAR * vertical_scroll_angle self.zoom( zoom_increment, anchor=QGraphicsView.ViewportAnchor.AnchorUnderMouse, ) self.show_zoom_level_in_statusbar()
def setup_scene(self)
-
Prepare scene for use.
Draws borders, background, etc.
Expand source code
def setup_scene(self): """Prepare scene for use. Draws borders, background, etc. """ # Sets the background color of the scene to be the appropriate # system-specific background color # source: https://stackoverflow.com/a/23880531/3545896 # source: https://stackoverflow.com/questions/15519749/how-to-get-widget-background-qcolor app = QApplication.instance() self.scene.setBackgroundBrush(app.palette().color(QPalette.Window)) self.update_pages()
def show_zoom_level_in_statusbar(self)
-
Show zoom level in statusbar.
Expand source code
def show_zoom_level_in_statusbar(self): """Show zoom level in statusbar.""" current_scale = self.get_current_scale() zoom_level_percentage_string = str(round(current_scale * 100)) message = f"Zoom: {zoom_level_percentage_string}%" get_main_window().statusBar().showMessage(message)
def update_number_of_pages(self)
-
Make sure number of pages excactly fits SOI modules.
The minimum page count is 1.
Expand source code
def update_number_of_pages(self): """Make sure number of pages excactly fits SOI modules. The minimum page count is 1. """ self.number_of_non_attachment_pages = ( self.soi.get_number_of_non_attachment_pages() ) # Each attachment module requires it's own page required_pages = self.number_of_non_attachment_pages + len( self.soi.attachments ) self.number_of_pages_total = required_pages
def update_pages(self)
-
Update pages drawn in the scene to reflect the SOI.
Expand source code
def update_pages(self): """Update pages drawn in the scene to reflect the SOI.""" self.update_number_of_pages() self.draw_pages()
def zoom(self, zoom_increment, anchor=PySide2.QtWidgets.QGraphicsView.ViewportAnchor.AnchorViewCenter)
-
Zoom GraphicsView by zoom_factor.
The zoom level is held within ZOOM_LEVEL_MINIMUM and ZOOM_LEVEL_MAXIMUM.
Note that because of inaccuracy during floating point operations the current scale is sometimes off by an insignificant fraction (e.g. 0.0000000000000001)
source: * https://stackoverflow.com/a/29026916/3545896 * https://stackoverflow.com/a/41688654/3545896
Parameters
zoom_increment
:float
- How much to increase zoom by. 1 means increase by 100%, -0.5 means decrease by -50%, and so on.
anchor
:QGraphicsView.ViewportAnchor
- Anchor indicates where to aim the zoom. See the documentation for QGraphicsView.ViewportAnchor. QGraphicsView.ViewportAnchor.AnchorUnderMouse can be used to target the mouse.
Expand source code
def zoom( self, zoom_increment, anchor=QGraphicsView.ViewportAnchor.AnchorViewCenter, ): """Zoom GraphicsView by zoom_factor. The zoom level is held within ZOOM_LEVEL_MINIMUM and ZOOM_LEVEL_MAXIMUM. Note that because of inaccuracy during floating point operations the current scale is sometimes off by an insignificant fraction (e.g. 0.0000000000000001) source: * https://stackoverflow.com/a/29026916/3545896 * https://stackoverflow.com/a/41688654/3545896 Parameters ---------- zoom_increment : float How much to increase zoom by. 1 means increase by 100%, -0.5 means decrease by -50%, and so on. anchor : QGraphicsView.ViewportAnchor Anchor indicates where to aim the zoom. See the documentation for QGraphicsView.ViewportAnchor. QGraphicsView.ViewportAnchor.AnchorUnderMouse can be used to target the mouse. """ current_scale = self.get_current_scale() # Only zoom if we are not breaching the minimum and maximum range if ( ZOOM_LEVEL_MINIMUM < current_scale + zoom_increment < ZOOM_LEVEL_MAXIMUM ): # We need a scalar such that: # current_scale * scalar = current_scale + zoom_increment scalar = (current_scale + zoom_increment) / current_scale viewport_anchor = self.view.transformationAnchor() self.view.setTransformationAnchor(anchor) self.view.scale( scalar, scalar, ) self.view.setTransformationAnchor(viewport_anchor)
class ProxyLabelWithCustomQPrintText (default_text, printing_text)
-
QGraphicsItem that prints a custom text when printed onto QPrint.
Useful to have a piece of text be painted differently in a QGraphicsScene depending on whether it's drawn to QPrint or not.
Note that this class doesn't have to use a QLabel. It was done to KISS.
How it works
When the QGrahpicsScene wants to render it's items it first uses the "bounding rects" of it's items to figure out which of them needs to be redrawn. It then redraws items using their
paint
functions. By overridingpaint
we can control how our item is drawn. One of the parameters to thepaint
function is the QPainter that is being used, which we can use to print custom text onto QPrinter.Parameters
default_text
:str
- Text to be drawn for all QPainters except QPrint.
printing_text
:str
- Text to be drawn if the QPainter is QPrint.
Expand source code
class ProxyLabelWithCustomQPrintText(QGraphicsProxyWidget): """QGraphicsItem that prints a custom text when printed onto QPrint. Useful to have a piece of text be painted differently in a QGraphicsScene depending on whether it's drawn to QPrint or not. Note that this class doesn't have to use a QLabel. It was done to KISS. ## How it works When the QGrahpicsScene wants to render it's items it first uses the "bounding rects" of it's items to figure out which of them needs to be redrawn. It then redraws items using their `paint` functions. By overriding `paint` we can control how our item is drawn. One of the parameters to the `paint` function is the QPainter that is being used, which we can use to print custom text onto QPrinter. Parameters ---------- default_text : str Text to be drawn for all QPainters except QPrint. printing_text : str Text to be drawn if the QPainter is QPrint. """ def __init__(self, default_text, printing_text): super(ProxyLabelWithCustomQPrintText, self).__init__() self.default_text = default_text self.printing_text = printing_text # self.boundingRect is updated at the end of this function, so this # default value is in practice never used. self.bounding_rect = QRectF(0, 0, 0, 0) self.label = QLabel(self.default_text) self.setWidget(self.label) self.update_bounding_rect() def update_bounding_rect(self): """Update bounding rect property.""" self.bounding_rect = self.determine_bounding_rect() def determine_bounding_rect(self): """Calculate bounding rect that encapsulates both alternative strings. From the docs: "Although the item's shape can be arbitrary, the bounding rect is always rectangular, and it is unaffected by the items' transformation." Link: https://doc.qt.io/qt-5/qgraphicsitem.html#boundingRect Because of this we're returning a rectangle (starting at 0,0) that encapsulates the self.widget(), seen from this items local coordinate system. This class purposefully lets self.widget() have different content depending on the QPainter, so we give a bounding rect that encapsulates the largest content. Returns ------- QRectF Bounding rect that enpasulates both alternative contents of self.widget(). """ bounding_rect_default_text = self.label.fontMetrics().boundingRect( self.default_text ) bounding_rect_printing_text = self.label.fontMetrics().boundingRect( self.printing_text ) largest_width = max( bounding_rect_default_text.width(), bounding_rect_printing_text.width(), ) largest_height = max( bounding_rect_default_text.height(), bounding_rect_printing_text.height(), ) return QRectF(0, 0, largest_width, largest_height) def paint(self, painter, option, widget): """Overridden to paint text depending on `painter` parameter. Source: https://doc.qt.io/qt-5/qgraphicsitem.html#paint Parameter --------- painter : QPainter QPainter that is painting the item. Used to determine which text to draw. option : QStyleOptionGraphicsItem Passed on to superclass implementation. See source. widget : QWidget Passed on to superclass implementation. See source. """ if isinstance(painter.device(), QPrinter): self.label.setText(self.printing_text) else: self.label.setText(self.default_text) # From Qt docs: "Prepares the item for a geometry change. Call this # function before changing the bounding rect of an item to keep # QGraphicsScene's index up to date." # https://doc.qt.io/qt-5/qgraphicsitem.html#prepareGeometryChange self.prepareGeometryChange() # QLabel doesn't adjust it's size automatically, so do it here # https://stackoverflow.com/a/47037607/3545896 self.label.adjustSize() # Let super handle the actual painting super(ProxyLabelWithCustomQPrintText, self).paint( painter, option, widget ) def boundingRect(self): """Give QRectF that bounds this item. Overridden. Overridden to provide custom bounding rect. Custom bounding rect is needed because this item has two different contents depending on where it is drawn, which Qt does not respect (or understand) out of the box. Overrides this: https://doc.qt.io/qt-5/qgraphicsitem.html#boundingRect This function is called by Qt to figure out how much space it needs. See the docstring of `determine_bounding_rect` for how the bounding rect is calculated. If either of self.default_text or self.printing_text changes, or if font / style is updated on self.label, self.bounding_rect needs to be updated using `update_bounding_rect`. Returns ------- QRectF Bounding rect. """ return self.bounding_rect
Ancestors
- PySide2.QtWidgets.QGraphicsProxyWidget
- PySide2.QtWidgets.QGraphicsWidget
- PySide2.QtWidgets.QGraphicsObject
- PySide2.QtWidgets.QGraphicsItem
- PySide2.QtCore.QObject
- PySide2.QtWidgets.QGraphicsLayoutItem
- Shiboken.Object
Class variables
var staticMetaObject
Methods
def boundingRect(self)
-
Give QRectF that bounds this item. Overridden.
Overridden to provide custom bounding rect. Custom bounding rect is needed because this item has two different contents depending on where it is drawn, which Qt does not respect (or understand) out of the box. Overrides this: https://doc.qt.io/qt-5/qgraphicsitem.html#boundingRect
This function is called by Qt to figure out how much space it needs.
See the docstring of
determine_bounding_rect
for how the bounding rect is calculated.If either of self.default_text or self.printing_text changes, or if font / style is updated on self.label, self.bounding_rect needs to be updated using
update_bounding_rect
.Returns
QRectF
- Bounding rect.
Expand source code
def boundingRect(self): """Give QRectF that bounds this item. Overridden. Overridden to provide custom bounding rect. Custom bounding rect is needed because this item has two different contents depending on where it is drawn, which Qt does not respect (or understand) out of the box. Overrides this: https://doc.qt.io/qt-5/qgraphicsitem.html#boundingRect This function is called by Qt to figure out how much space it needs. See the docstring of `determine_bounding_rect` for how the bounding rect is calculated. If either of self.default_text or self.printing_text changes, or if font / style is updated on self.label, self.bounding_rect needs to be updated using `update_bounding_rect`. Returns ------- QRectF Bounding rect. """ return self.bounding_rect
def determine_bounding_rect(self)
-
Calculate bounding rect that encapsulates both alternative strings.
From the docs: "Although the item's shape can be arbitrary, the bounding rect is always rectangular, and it is unaffected by the items' transformation." Link: https://doc.qt.io/qt-5/qgraphicsitem.html#boundingRect Because of this we're returning a rectangle (starting at 0,0) that encapsulates the self.widget(), seen from this items local coordinate system. This class purposefully lets self.widget() have different content depending on the QPainter, so we give a bounding rect that encapsulates the largest content.
Returns
QRectF
- Bounding rect that enpasulates both alternative contents of self.widget().
Expand source code
def determine_bounding_rect(self): """Calculate bounding rect that encapsulates both alternative strings. From the docs: "Although the item's shape can be arbitrary, the bounding rect is always rectangular, and it is unaffected by the items' transformation." Link: https://doc.qt.io/qt-5/qgraphicsitem.html#boundingRect Because of this we're returning a rectangle (starting at 0,0) that encapsulates the self.widget(), seen from this items local coordinate system. This class purposefully lets self.widget() have different content depending on the QPainter, so we give a bounding rect that encapsulates the largest content. Returns ------- QRectF Bounding rect that enpasulates both alternative contents of self.widget(). """ bounding_rect_default_text = self.label.fontMetrics().boundingRect( self.default_text ) bounding_rect_printing_text = self.label.fontMetrics().boundingRect( self.printing_text ) largest_width = max( bounding_rect_default_text.width(), bounding_rect_printing_text.width(), ) largest_height = max( bounding_rect_default_text.height(), bounding_rect_printing_text.height(), ) return QRectF(0, 0, largest_width, largest_height)
def paint(self, painter, option, widget)
-
Overridden to paint text depending on
painter
parameter.Source: https://doc.qt.io/qt-5/qgraphicsitem.html#paint
Parameter
painter
:QPainter
- QPainter that is painting the item. Used to determine which text to draw.
option
:QStyleOptionGraphicsItem
- Passed on to superclass implementation. See source.
widget
:QWidget
- Passed on to superclass implementation. See source.
Expand source code
def paint(self, painter, option, widget): """Overridden to paint text depending on `painter` parameter. Source: https://doc.qt.io/qt-5/qgraphicsitem.html#paint Parameter --------- painter : QPainter QPainter that is painting the item. Used to determine which text to draw. option : QStyleOptionGraphicsItem Passed on to superclass implementation. See source. widget : QWidget Passed on to superclass implementation. See source. """ if isinstance(painter.device(), QPrinter): self.label.setText(self.printing_text) else: self.label.setText(self.default_text) # From Qt docs: "Prepares the item for a geometry change. Call this # function before changing the bounding rect of an item to keep # QGraphicsScene's index up to date." # https://doc.qt.io/qt-5/qgraphicsitem.html#prepareGeometryChange self.prepareGeometryChange() # QLabel doesn't adjust it's size automatically, so do it here # https://stackoverflow.com/a/47037607/3545896 self.label.adjustSize() # Let super handle the actual painting super(ProxyLabelWithCustomQPrintText, self).paint( painter, option, widget )
def update_bounding_rect(self)
-
Update bounding rect property.
Expand source code
def update_bounding_rect(self): """Update bounding rect property.""" self.bounding_rect = self.determine_bounding_rect()
class QGraphicsViewWithCtrlScrollListener (*args, **kwargs)
-
QGraphicsView with support for scroll listener.
Catches wheelEvents and calls listener if CTRL was held.
Expand source code
class QGraphicsViewWithCtrlScrollListener(QGraphicsView): """QGraphicsView with support for scroll listener. Catches wheelEvents and calls listener if CTRL was held. """ def __init__(self, *args, **kwargs): super(QGraphicsViewWithCtrlScrollListener, self).__init__( *args, **kwargs ) self.ctrl_scroll_listeners = [] def add_ctrl_scroll_listener(self, function): """Add function to be called when user scrolls while holding CTRL. Parameters ---------- function : Python function that takes one parameter Function takes a parameter 'event', that is the QWheelEvent received by the wheelEvent when user scrolls and holds CTRL. """ self.ctrl_scroll_listeners.append(function) def wheelEvent(self, event): """Override to call listener when CTRL is also held. If CTRL is not held the wheelEvent is handled like normal. Parameters ---------- event : QWheelEvent Event from Qt. """ modifiers = QApplication.keyboardModifiers() if modifiers == Qt.ControlModifier: for listener in self.ctrl_scroll_listeners: listener(event) else: super(QGraphicsViewWithCtrlScrollListener, self).wheelEvent(event)
Ancestors
- PySide2.QtWidgets.QGraphicsView
- PySide2.QtWidgets.QAbstractScrollArea
- PySide2.QtWidgets.QFrame
- PySide2.QtWidgets.QWidget
- PySide2.QtCore.QObject
- PySide2.QtGui.QPaintDevice
- Shiboken.Object
Class variables
var staticMetaObject
Methods
def add_ctrl_scroll_listener(self, function)
-
Add function to be called when user scrolls while holding CTRL.
Parameters
function
:Python
function
that
takes
one
parameter
- Function takes a parameter 'event', that is the QWheelEvent received by the wheelEvent when user scrolls and holds CTRL.
Expand source code
def add_ctrl_scroll_listener(self, function): """Add function to be called when user scrolls while holding CTRL. Parameters ---------- function : Python function that takes one parameter Function takes a parameter 'event', that is the QWheelEvent received by the wheelEvent when user scrolls and holds CTRL. """ self.ctrl_scroll_listeners.append(function)
def wheelEvent(self, event)
-
Override to call listener when CTRL is also held.
If CTRL is not held the wheelEvent is handled like normal.
Parameters
event
:QWheelEvent
- Event from Qt.
Expand source code
def wheelEvent(self, event): """Override to call listener when CTRL is also held. If CTRL is not held the wheelEvent is handled like normal. Parameters ---------- event : QWheelEvent Event from Qt. """ modifiers = QApplication.keyboardModifiers() if modifiers == Qt.ControlModifier: for listener in self.ctrl_scroll_listeners: listener(event) else: super(QGraphicsViewWithCtrlScrollListener, self).wheelEvent(event)