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.
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.
Create GUI Applications with Python & Qt5 by Martin Fitzpatrick — (PyQt5 Edition) The hands-on guide to making apps with Python — Over 10,000 copies sold!
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/',
]
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 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().__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_()
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 to 30 pixels up. This is gradually returned back to 0 rotation and 0 offset by the animation timer interrupt.
:::python
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?
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.
:::python
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
).
:::python
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.
:::python
# 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.
:::python
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 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.
:::python
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 since we can only accept a single file. You could change this to iterate all the files added 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.
:::python
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 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()
.
:::python
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
.
:::python
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.
:::python
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.
:::python
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()
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 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().__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.
Challenges
You could make this even more awesome by —
- Animate 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.
- Add support for unzipping multiple files simultaneously.