7Pez | Unzip your files with a cat, because why not.

This is a functionally terrible unzip application, saved only by the fact that you get to look at a cat while using it.

The original idea reflected in the name 7Pez was actually worse — to rig it up so you had to push on the head to unzip each file from the zipfile, so press 5 times to get 5 files. Opening a zipfile with 1000s of items in it soon put a stop to that idea.

Cat.

It also fails on the Pez front, since you press the head instead of lifting it to release the file. I haven't seen a Pez in years, so I forgot how they work.

But look, cat.

Cat.

Setting up

The UI of the app was built in Qt Designer, and consists entirely of QLabel objects. Two seperate QLabel objects with pixmaps were used for the head and body. The progress Pez bar is made from a stack of QLabel objects inside a QVBoxLayout.

The widget for the progress bar is placed behind the cat image, which is transparent with a central heart-shaped cutout. This means the progress bar shows through nicely.

The completed .ui file was built with pyuic5 to produce an importable Python module. This, together with the standard PyQt5 modules are imported as usual. We also make use of some standard library modules, including the zipfile module to handle the loading and unpacking of .zip files.

from PyQt5.QtGui import *
from PyQt5.QtWidgets import *
from PyQt5.QtCore import *

from MainWindow import Ui_MainWindow

import os
import types
import random
import sys
import traceback
import zipfile

The state of the Pez bar is controlled by switching CSS styles on QLabel elements between on — pink background with dark pink writing and border — and off — no background and transparent text.

The colour used for OFF is rgba(0,0,0,0). In RGBA format the fourth value indicates transparency, with 0 meaning fully transparent. So our OFF state is fully transparent and not at all black.

PROGRESS_ON = """
QLabel {
    background-color: rgb(233,30,99);
    border: 2px solid rgb(194,24,91);
    color: rgb(136,14,79);
}
"""

PROGRESS_OFF = """
QLabel {
    color: rgba(0,0,0,0);
}
"""

We also define a bunch of paths to exclude while unzipping. In this case we're just excluding the special __MACOSX folder which is added on macOS to extended attributes. We don't want to unpack this.

EXCLUDE_PATHS = [
    '__MACOSX/',
]

The MainWindow

The app uses a few advanced features for Qt window appearance, including translucency, frameless/decoration-less windows and a custom window PNG overlay.

To enable transparent windows in Qt we need to set the Qt.WA_TranslucentBackground attribute on the window. Without this, the window manager will automatically draw a background window colour behind the window. To turn off the window decorations (open, restore, close buttons and the titlebar) we set the Qt.FramelessWindowHint flag.

We also enable our window to accept drag-drops, so we can drop zip files on it. The actual code to handle the dropping files is implemented later.

class MainWindow(QMainWindow, Ui_MainWindow):

    def __init__(self, *args, **kwargs):
        super(MainWindow, self).__init__(*args, **kwargs)
        self.setupUi(self)

        self.setAttribute(Qt.WA_TranslucentBackground )
        self.setWindowFlags(Qt.FramelessWindowHint)
        self.setAcceptDrops(True)

We set the initial unset state for previous drag position and active unzip workers.

        self.prev_pos = None
        self.worker = None

        # Reset progress bar to complete (empty)
        self.update_progress(1)

Unzipping is handled by a single threadpool which we create at startup.

        # Create a threadpool to run our unzip worker in.
        self.threadpool = QThreadPool()

To put the head on top of the body we must .raise_ it.

        self.head.raise_()

Head animation

The head is disconnected and bobbles around when pressed. The progress bar fills up when a zip file is dropped onto the application and empties when the files are unzipped. Unzipping is a one-off operation, and a new zip file must be dropped to repeat the process.

To start the animation we need to be able to detect clicks on the head from our Python code. When subclassing a widget we can do this by implementing our own mousePressEvent.

In this case the UI has been implemented in Qt Designer, and we cannot subclass before adding to the layout. To override methods on the existing object, we need to patch it.

The first patch is for the mousePressEvent. This captures clicks on the cat head and triggers the extraction process — if one isn't already underway — and the animation.

The head animation is implemented as a random rotation, between -15 and +15 degrees, and lifting the head to 30 pixels up. This is gradually returned back to 0 rotation and 0 offset by the animation timer interrupt.

You could add an x offset as well to make the head movement more erratic.

        def patch_mousePressEvent(self_, e):
            if e.button() == Qt.LeftButton and self.worker is not None:
                # Start the head animation.
                self_.current_rotation = random.randint(-15, +15)
                self_.current_y = 30

                # Redraw the mainwindow
                self.update()

                # Start the extraction.
                self.threadpool.start(self.worker)
                self.worker = None  # Remove the worker so it is not double-triggere.

            elif e.button() == Qt.RightButton:
                pass # Open a new zip?

Notice our event patch also checks for a right button, here you could show a dialog to open a file directly rather than dropping.

The second patch covers the paintEvent where the widget is drawn.

        def patch_paintEvent(self, event):

            p = QPainter(self)
            rect = event.rect()

            # Translate
            transform = QTransform()
            transform.translate(rect.width()/2, rect.height()/2)
            transform.rotate(self.current_rotation)
            transform.translate(-rect.width()/2, -rect.height()/2)
            p.setTransform(transform)

            # Calculate rect to center the pixmap on the QLabel.
            prect = self.pixmap().rect()
            rect.adjust(
                (rect.width() - prect.width()) / 2,
                self.current_y + (rect.height() - prect.height()) / 2,
                -(rect.width() - prect.width()) / 2,
                self.current_y + -(rect.height() - prect.height()) / 2,
            )
            p.drawPixmap(rect, self.pixmap())

To patch an object method in Python we assign the function to an attribute on the object, wrapping it with types.MethodType and passing in the parent object (in this case self.head).

        self.head.mousePressEvent = types.MethodType(patch_mousePressEvent, self.head)
        self.head.paintEvent = types.MethodType(patch_paintEvent, self.head)

With the patches in place, we set up a timer — firing every 5 milliseconds (or thereabouts) — to handle the animation update. We also set the initial states for rotation and y position of the head.

        # Initialize
        self.head.current_rotation = 0
        self.head.current_y = 0
        self.head.locked = True

        self.timer = QTimer()
        self.timer.timeout.connect(self.timer_triggered)
        self.timer.start(5)

That is the end of the window __init__ block. The handler for the timer is implemented as a window method, timer_triggered. This is called each time the timer times out.

The logic here is pretty self explanatory. If the head position is raised >0 reduce it. If the rotation is clockwise >0 rotate it back, if the rotation is anticlockwise <0 rotate it forward. The result is to gradually bring the head back to it's default position.

The .update() is triggered to redraw the head. Finally, if after this update we are back at 0 rotation and 0 offset, we unlock the head, allowing it to be clicked again.

    def timer_triggered(self):
        if self.head.current_y > 0:
            self.head.current_y -= 1

        if self.head.current_rotation > 0:
            self.head.current_rotation -= 1

        elif self.head.current_rotation < 0:
            self.head.current_rotation += 1

        self.head.update()

        if self.head.current_y == 0 and self.head.current_rotation == 0:
            self.head.locked = False

Ready to build your own apps?

Then you might enjoy this book! Create Simple GUI Applications with Python & Qt is my guide to building cross-platform GUI applications with Python. Work step by step from displaying your first window to building fully functional desktop software.

Drag & drop

To accept zip files dropped onto our cat we need to define the standard Qt dragEnterEvent and dropEvent handlers.

The dragEnterEvent is triggered when an object (such as a file) is dragged over the window. It receives a QDragEnterEvent which contains information about the object being dragged. In our event handler we can either respond by accepting or rejecting the event (or ignoring it).

Depending on the accept or reject response the desktop will provide feedback to the user whether the drop can be performed. In our case we're checking that we're receiving URLs — all files are URLs in Qt — and that the first of these has a .zip file extension.

We only check the first since we can only accept a single file. You could change this to iterate all the files added and find the first .zip instead.

    def dragEnterEvent(self, e):
        data = e.mimeData()
        if data.hasUrls():
            # We are passed urls as a list, but only accept one.
            url = data.urls()[0].toLocalFile()
            if os.path.splitext(url)[1].lower() == '.zip':
                e.accept()

The dropEvent is only triggered where the dragEnterEvent accepted the drop, and the user dropped the object. In this case we receive a QDropEvent containing the same data as before.

Here we retrieve the zip file from the first URL in the list, as before, and then pass this into a new UnzipWorker which will handle the unzip process. This worker is not yet started, so will not unzip the file until we start it.

    def dropEvent(self, e):
        data = e.mimeData()
        path = data.urls()[0].toLocalFile()

        # Load the zipfile and pass to the worker which will extract.
        self.worker = UnzipWorker(path)
        self.worker.signals.progress.connect(self.update_progress)
        self.worker.signals.finished.connect(self.unzip_finished)
        self.worker.signals.error.connect(self.unzip_error)
        self.update_progress(0)

Moving around

In a normal windowed application you're able to drag and drop it around your desktop using the window bar. For this app we've turned that off, and so we need to implement the logic for repositioning the window ourselves.

To do this we define two custom event handlers. The first mousePressEvent fires when the user clicks in the window (on the cat) and simply records the global location of this click.

The second mouseMoveEvent is triggered any time the mouse moves over our window while it is focused. We check for self.prev_pos to be set since we require this for our calculation, however it should not be possible for the mouseMoveEvent event to occur without first registering a mousePressEvent to select the window.

This is why we don't need to check for the release event. Once the mouse is released mouseMoveEvent will stop firing.

The movement is calculated relative to the previous position, again using the global position. We use the delta between these positions to update the window using move().

    def mousePressEvent(self, e):
        self.prev_pos = e.globalPos()

    def mouseMoveEvent(self, e):
        if self.prev_pos:
            delta = e.globalPos() - self.prev_pos
            self.move(self.x() + delta.x(), self.y() + delta.y())
            self.prev_pos = e.globalPos()

Unzip handlers

As we unzip a file we want to update our Pez bar with the progress in realtime. The update_progress callback method is defined below. When called with a float in the range 0-1 — representing 0-100% —this will update the current state.

The update itself is handled by iterating over each progress bar indicator QLabel, numbered 1-10, and setting their style sheet to PROGRESS_ON or PROGRESS_OFF.

    def update_progress(self, pc):
        """
        Accepts progress as float in
        :param pc: float 0-1 of completion.
        :return:
        """
        current_n = int(pc * 10)
        for n in range(1, 11):
            getattr(self, 'progress_%d' % n).setStyleSheet(
                PROGRESS_ON if n > current_n else PROGRESS_OFF
            )

An unzip_finished callback is set, although it doesn't do anything. You coudl add some custom notifications here if you like.

    def unzip_finished(self):
        pass

We also define an error callback, which will receive any tracebacks from the unzip progress and display them as a critical error message.

This isn't particularly user friendly, so you could extend it to display nicer customized messages for different exception types.

    def unzip_error(self, err):
        exctype, value, traceback = err

        self.update_progress(1)  # Reset the Pez bar.

        dlg = QMessageBox(self)
        dlg.setText(traceback)
        dlg.setIcon(QMessageBox.Critical)
        dlg.show()

The unzipper

The unzipping of files is handled in a separate thread, so it doesn't lock the application. We use the QThreadPool approach described here. Unzipping occurs in standalone QRunnable jobs, which communicate with the application using Qt signals.

We define 3 signals our unzipper can return — finished, error and progress. These are emitted from the worker run() slot below, and connected up to the callback handlers we've just defined.

class WorkerSignals(QObject):
    '''
    Defines the signals available from a running worker thread.
    '''
    finished = pyqtSignal()
    error = pyqtSignal(tuple)
    progress = pyqtSignal(float)

The worker itself accepts a path to the zip file which we intend to unzip. Creating the worker does not start the unzip process, just passes it to zipfile.ZipFile to create a new zip objects.

When the worker is started, the run() method is executed. Here we get a complete list of the files in the zipfile, and then proceed to unzip each file in turn. This is a little slower than doing them in one go but it allows us to update the Pez bar with progress.

Progress is updated by dividing the current iteration number by the total number of items in the list. By enumerate() provides a 0-based index, which would mean our final state would be (total_n-1)/total_n — not quite 100%. By passing 1 as a second parameter we start the counting from 1, giving a final loop state of total_n/total_n which gives 1.

class UnzipWorker(QRunnable):
    '''
    Worker thread for unzipping.
    '''
    signals = WorkerSignals()

    def __init__(self, path):
        super(UnzipWorker, self).__init__()
        os.chdir(os.path.dirname(path))
        self.zipfile = zipfile.ZipFile(path)

    @pyqtSlot()
    def run(self):
        try:
            items = self.zipfile.infolist()
            total_n = len(items)

            for n, item in enumerate(items, 1):
                if not any(item.filename.startswith(p) for p in EXCLUDE_PATHS):
                    self.zipfile.extract(item)

                self.signals.progress.emit(n / total_n)

        except Exception as e:
            exctype, value = sys.exc_info()[:2]
            self.signals.error.emit((exctype, value, traceback.format_exc()))
            return

        self.signals.finished.emit()

The finished signal is emitted if the unzip completes successfully or unsuccessfully, and is used to reset the application state. An error is emitted on if unzipping fails, passing the exception and traceback as a tuple. Our run() slot emits progress as a float 0..1.

Improvements

You could make this even more awesome by —

  1. Animate the cat head some more, so it bobbles around more amusingly when pressed.
  2. Making the cat meow when you press the head. Take a look at the Qt Multimedia components, which make it simple to play audio cross-platform.
  3. Add support for unzipping multiple files simultaneously.

Continue reading

QtWebEngineWidgets, the new browser API in PyQt 5.6  gui

With the release of Qt 5.5 the Qt WebKit API was deprecated and replaced with the new QtWebEngine API, based on Chromium. The WebKit API was subsequently removed from Qt entirely with the release of Qt 5.6 in mid-2016. The change to use Chromium for web widgets within ... More

Discussion

comments powered by Disqus