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 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.
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)