Exemplo n.º 1
0
class View(Subject, QMainWindow):
    """Main window class, and the View of the MVC pattern.

    Attributes:
        model: The model of the MVC pattern.
        menu_bar: A menu bar derived from QMenuBar.
        tool_bar: A tool bar derived from QToolBar.
        exif_area: The widget containing any available EXIF data.
        image_area: The widget containing the displayed image.
        status_bar: A status bar derived from QStatusBar.
        about: The classic "About" informative widget (actually a new, separate window).
    """
    def __init__(self, model):
        """Inits the class."""
        Subject.__init__(self)
        QMainWindow.__init__(self)

        # Set model
        self.model = model

        # Create interface elements
        self.menu_bar = MenuBar(self)
        self.tool_bar = ToolBar(self)
        self.exif_area = ExifWidget(self)
        self.image_area = ImageWidget(self)
        self.status_bar = StatusBar()
        about_text = 'IEViewer 1.0' \
                     '<br><br>' \
                     'Copyright © 2021 by' \
                     '<br>' \
                     'Paula Mihalcea' \
                     '<br>' \
                     '<a href="mailto:[email protected]">[email protected]</a>' \
                     '<br><br>' \
                     'This program uses PyQt5, a comprehensive set of Python bindings for Qt v5. Qt is a set of cross-platform C++ libraries that implement high-level APIs for accessing many aspects of modern desktop and mobile systems.\n' \
                     '<br><br>' \
                     'PyQt5 is copyright © Riverbank Computing Limited. Its homepage is <a href="https://www.riverbankcomputing.com/software/pyqt/">https://www.riverbankcomputing.com/software/pyqt/</a>.' \
                     '<br><br>' \
                     'No genasi were harmed in the making of this application. <a href="https://www.dndbeyond.com/races/genasi#WaterGenasi">#GenasiLivesMatter#NereisThalian</a>'
        self.about = AboutWidget('About IEViewer',
                                 about_text,
                                 image_path='icons/about_img.png')

        # Disable GUI elements that are unavailable when no image is opened
        self.menu_bar.disable_widgets()
        self.exif_area.hide()

        # Set layout
        self.setCentralWidget(Layout(self).central_widget)

        # Set window properties
        self.set_window_properties()

        # Install additional event filters
        self.image_area.installEventFilter(self)

    def set_window_properties(self):
        """Sets some main window properties."""
        self.statusBar()
        self.setStatusTip('Ready.')
        self.setWindowTitle(
            'IEViewer')  # Window title (the one in the title bar)
        self.resize(
            512,
            256)  # These default dimensions should be fine for most displays

    def get_open_file_dialog(self, caption, filter):
        """Opens an "Open File" dialog and returns a file path. If no file has been selected and the user has pressed "Cancel" or closed the dialog, then it returns None."""
        file_path, _ = QFileDialog.getOpenFileName(caption=caption,
                                                   directory='',
                                                   filter=filter)
        if file_path == '':
            return None
        else:
            return file_path

    def get_save_file_dialog(self, caption, filter):
        """Opens a "Save As" dialog and returns a file path and format. If no name has been entered and the user has pressed "Cancel" or closed the dialog, then it returns None."""
        file_path, format = QFileDialog.getSaveFileName(
            caption=caption, directory='', filter=filter
        )  # By omitting the directory argument (empty string, ''), the dialog should remember the last directory (depends on operating system)
        if file_path == '':
            return None, None
        else:
            format = format.split(' ')
            return file_path, format[0]

    def show_message_box(self, title, text, icon=QMessageBox.Information):
        """Opens a simple message box window, with some text and a customizable icon."""
        info_box = QMessageBox(self)

        if icon is not None:
            info_box.setIcon(icon)
        info_box.setWindowTitle(title)
        info_box.setText(text)

        info_box.show()

    def event(self, e):
        """Defines the default status bar message (when nothing else is displayed)."""
        if e.type() == QEvent.StatusTip:
            if e.tip() == '':
                e = QStatusTipEvent('Ready.')
        return super().event(e)

    def open(self):
        """Only needed to notify observers (i.e. the controller) that they should load an image."""
        self.set_state('open')

    def load_image(self):
        """Displays the image contained in the model."""
        # Get original image dimensions
        width = self.model.image.width()
        height = self.model.image.height()

        # Recalculate image dimensions so as to have a maximum dimension (height or width) of 512 pixels
        if width >= height and width > 512:
            w = 512
            h = int(512 * height / width)
        elif height >= width and height > 512:
            w = int(512 * width / height)
            h = 512
        else:
            w = width
            h = height

        # Generate and set a pixmap from the image
        self.image_area.set_image(self.model.image, w, h)

        # Resize the window and pixmap (generated from the image),
        # so as to display correctly the window title, menu bar and image (actually, its pixmap)
        #
        # The diff parameter is another piece of PyQt5 magic.
        # Without it, an image larger than 512 pixels might be displayed smaller
        # than the maximum 512 that it has been resized to
        # (despite having been resized to 512 pixels).

        diff = 130  # This value is good for both Windows 10 and Ubuntu 20.04
        if w < 280:  # Again, this should suffice for both OSs
            self.resize(280, h + diff)
        else:
            self.resize(w, h + diff)
        self.image_area.pixmap.scaled(w, h)

        # Add EXIF data from the model
        self.exif_area.load_exif()

        # Update window title
        self.setWindowTitle('IEViewer - ' + self.model.filename)

        # Enable menus
        self.menu_bar.enable_widgets()

    def save(self):
        """Saves the modified image. Uses the stored original image, not the modified version currently displayed in the window."""
        self.model.set_image(
            self.model.image.transformed(
                QTransform().rotate(self.image_area.rot),
                Qt.SmoothTransformation))
        self.set_state('save')

    def saveas(self):
        """Saves the modified image with another name. Uses the stored original image, not the modified version currently displayed in the window."""
        self.model.set_image(
            self.model.image.transformed(
                QTransform().rotate(self.image_area.rot),
                Qt.SmoothTransformation))
        self.set_state('saveas')

    def eventFilter(self, widget, event):
        """Adds new behavior for certain events."""

        # Resize event
        # Or what should happen to the image widget when the user resizes the main window
        if event.type(
        ) == QEvent.Resize and widget is self.image_area and self.image_area.pixmap is not None:
            self.image_area.setPixmap(
                QPixmap.fromImage(self.model.modified_image).scaled(
                    self.image_area.width(),
                    self.image_area.height(),
                    aspectRatioMode=Qt.KeepAspectRatio,
                    transformMode=Qt.SmoothTransformation))
            # Update new dimensions for later use
            self.image_area.w = self.image_area.width()
            self.image_area.h = self.image_area.height()
            return True

        return QMainWindow.eventFilter(self, widget, event)

    def close(self):
        """Closes an image."""
        self.set_state('close')

        # Clear image area widget and adjust layout
        self.image_area.clear_image()
        self.exif_area = ExifWidget(self)
        self.setCentralWidget(Layout(self).central_widget)

        # Disable unavailable menus
        self.menu_bar.disable_widgets()

        # Hide EXIF area (if visible) and show blank image widget
        self.exif_area.hide()
        self.image_area.show()

        # Update window title
        self.setWindowTitle('IEViewer')

    def exit(self):
        """Only needed to notify observers (i.e. the controller) that they should exit the application."""
        self.set_state('exit')

    def rotate(self, degree):
        """Base function for rotation.

        Subsequent rotation functions are only needed as hooks for menu actions; actual rotations happen here.
        Here, the View directly changes the Model. Arguably, this is a violation of the MVC pattern. It happens that, for some reason, returning a reference to the rotated image to the Controller in order to have the Controller itself change the Model does not work, probably because it only returns a reference and not a copy of the rotated image. PyQt5 does not allow deep copies of its objects, so the following seems to be the only way to make it work.
        """
        self.model.set_modified_image(
            self.model.image.transformed(
                QTransform().rotate(self.image_area.rot + degree),
                Qt.SmoothTransformation))  # Rotate image
        self.image_area.set_image(self.model.modified_image, self.image_area.w,
                                  self.image_area.h)  # Display rotated image
        self.image_area.rot += degree  # Update rotation history

    def rotate180(self):
        """Rotate the displayed image by 180 degrees."""
        self.rotate(180)

    def rotate90c(self):
        """Rotate the displayed image by 90 degrees clock wise."""
        self.rotate(90)

    def rotate90cc(self):
        """Rotate the displayed image by 90 degrees clock wise."""
        self.rotate(-90)

    def reset_image(self):
        """Reset the displayed image to its original orientation."""
        self.rotate(-self.image_area.rot)

    def show_exif(self):
        """Display EXIF data (and at the same time hide the image)."""
        if self.exif_area.isHidden():
            self.image_area.hide()
            self.exif_area.show()
        else:
            self.image_area.show()
            self.exif_area.hide()

    def about(self):
        """Display info about the program."""
        self.about.show()