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 zip file, so you'd press 5 times to get 5 files. Opening a zip file with 1000s of items in it soon put a stop to that idea.
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 separate 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.
Create GUI Applications with Python & Qt5 by Martin Fitzpatrick — (PyQt5 Edition) The hands-on guide to making apps with Python — Over 15,000 copies sold!
The completed .ui file was built with pyuic5 to produce an importable Python module. This, together with the standard PyQt5 modules, is 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/']
Theming the QMainWindow
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 title bar) we set the Qt.FramelessWindowHint flag.
We also enable our window to accept drag-and-drop, so we can drop zip files on it. The actual code to handle dropping files is implemented later.
class MainWindow(QMainWindow, Ui_MainWindow):
def __init__(self, *args, **kwargs):
super().__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 call raise_() on it.
self.head.raise_()
Animating the head
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 30 pixels up. This is gradually returned to 0 rotation and 0 offset by the animation timer.
def patch_mousePressEvent(self_, e):
if e.button() == Qt.MouseButton.LeftButton and self.worker is not None:
# Extract the archive.
self_.current_rotation = random.randint(-15, +15)
self_.current_y = 30
# Redraw the mainwindow
self.update()
# Perform the unzip
self.threadpool.start(self.worker)
self.worker = None # Remove the worker so it is not double-triggered.
elif e.button() == Qt.MouseButton.RightButton:
pass # Open a new zip.
You could add an x offset as well to make the head movement more erratic.
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(
int((rect.width() - prect.width()) / 2),
int(self.current_y + (rect.height() - prect.height()) / 2),
int(-(rect.width() - prect.width()) / 2),
int(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 its 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.
Create GUI Applications with Python & Qt6 by Martin Fitzpatrick — (PyQt6 Edition) The hands-on guide to making apps with Python — Over 15,000 copies sold!
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
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 on 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.
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()
We only check the first URL since we can only accept a single file. You could change this to iterate over all the files and find the first .zip instead.
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 the Window
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 button 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 could 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 process and display them as a critical error message.
def unzip_error(self, err):
exctype, value, tb = err
self.update_progress(1) # Reset the Pez bar.
dlg = QMessageBox(self)
dlg.setText(tb)
dlg.setIcon(QMessageBox.Critical)
dlg.show()
This isn't particularly user friendly, so you could extend it to display nicer customized messages for different exception types.
Unzipping in separate thread
The unzipping of files is handled in a separate thread, so it doesn't block 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; it just passes the path to zipfile.ZipFile to create a new zip object.
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. enumerate() provides a 0-based index by default, 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().__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 when the unzip completes (successfully or unsuccessfully) and is used to reset the application state. An error is emitted if unzipping fails, passing the exception and traceback as a tuple. Our run() slot emits progress as a float 0..1.
Challenges
You could make this even more awesome by —
Purchasing Power Parity
Developers in [[ country ]] get [[ discount.discount_pc ]]% OFF on all books & courses with code [[ discount.coupon_code ]]- Animating the cat head some more, so it bobbles around more amusingly when pressed.
- 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.
- Adding support for unzipping multiple files simultaneously.