Tech Art Crash Course (Part I): Creating a Batch Renamer with Qt Designer and PyQt
To skip to the demo video, click here.
Background
From February 2024 to April 2024, I had the chance to take a technical art course through ELVTR - I had heard that the tech art role required some proficiency in Python programming, in addition to art sensibilities in the case of shaders and VFX.
Before taking the course, I was acquainted with Python, but not within the context of games. And, I had not yet tried PyQt/Qt for Python.
Batch Renamer Utility
A few weeks into the coursework (from late February to early March), I was given a task - I was to implement a batch renamer utility, firstly as a command line script, and later on as a PyQt6 application built on top of the previous script.
The assignment was meant to introduce a common pain point with game asset management - finding a way to quickly rename hundreds (or thousands) of texture exports, 3D renders and configuration files located in a repository somewhere, while retaining some flexibility in configuring said operation.
This utility would then be called upon to target files with typos, or files which needed to be moved to some other directory, or files in need of an update to a new naming convention … and so on.
The Dilemma
At the time of taking the course, the utility was expected to do at least the following per rename operation:
- Get a valid filepath to the current folder
- Get a valid filepath to a target folder
- Select files in the current folder
- Rename the selected files in place, or copy to the target folder
Optionally, the utility could do one or more of the following steps per operation:
- Filter selected files by file extension
- Find and replace one or more substrings in the selected files
- Add a prefix and/or suffix to the selected files
- Enable overwriting the selected files, if the files already exist in the target folder
Given the following test files, the output files were to be free of noise such as the version number of the file, or extraneous words such as “file”, “final”, et cetera. At the same time, each filename was to be surrounded with short, descriptive text indicating the type of game asset that the file represented:
Initial Filenames | Target |
---|---|
texture_metal_diffuse.png | T_metal_C.png |
grass_file_01.ma | M_grass.ma |
hello_world.txt | NOTE_hello_world_TEMP.txt |
For example, the filename for a diffuse map could have a “T” prefix instead of “texture”, and a “C” suffix instead of “diffuse” or “color”.
Tools and Setup
Qt Designer was introduced early on as a free tool for quickly prototyping graphical user interfaces with widgets from the Qt GUI framework (though the possibility remained open to build a GUI from scratch).
A batch file was also provided to help with translating the XML based .ui
file produced from Qt Designer to an equivalent .py
file -
the batch file made use of the pyuic.py
module included with PyQt6
, and mapped Qt widget names directly to the data attributes
of a Python class named Ui_MainWindow
when run:
python -m PyQt6.uic.pyuic -x batch_renamer.ui -o batch_renamer_ui.py
The resulting output file would be included as a dependency in another Python file, which included some starter code. The example code demonstrated how
to connect a button click event to an event handler. Or in Qt terms, connecting a clicked()
signal
to a slot:
# Our Qt Designer GUI as a Python class
from batch_renamer_ui import Ui_MainWindow
class BatchRenamerWindow(QMainWindow, Ui_MainWindow):
def __init__(self):
# UI Setup
super().__init__()
super(Ui_MainWindow).__init__()
self.setupUi(self)
# Connect button to a function
# Make sure that there is a Qt Widget named "browseBtn"
self.browseBtn.clicked.connect(self.get_filepath)
def get_filepath(self):
self.filepath = QFileDialog().getExistingDirectory()
This Python file, which contained the class definition for the BatchRenamerWindow
, would become the primary working file
over those next couple of weeks.
Implementation
Since I was unfamiliar with Python bindings for Qt beforehand, note that the following details will primarily cover discoveries made while working with PyQt.
UI Design
After prototyping in Qt Designer and converting my .ui
file to .py
, I eventually landed on the GUI shown below, which I styled with a Qt Style Sheet (credits to DevSec Studio):
Though I had a GUI (nice to look at, but with zero functionality), I still needed to handle user interaction with Qt’s edit fields, buttons, and other clickable widgets.
Requirements
After familiarizing myself with the project files, I extended the starter code and leaned on the os
module for file navigation, os.path
for pathname manipulation, and re
for regular expression matching in tandem with built-in string methods such as str.replace()
.
To fulfill the first requirement, I reused the browseBtn
in the starter code,
which I mapped to a QPushButton
to react to the
clicked()
signal. I added one line to the get_filepath()
function in order to set the display text of the corresponding QLineEdit
widget:
self.lineEditFilepath.setText(self.filepath)
Since the second requirement was similar to that of the first, I set the second filepath to the target folder with some reused code and variable names:
self.lineEditNewFolder.setText(self.new_folder)
To fulfill the third requirement - and to validate incoming filepaths - I looked at the QLineEdit
widget which held the value for the current folder. I wanted to handle the resulting value after clicking on the browseBtn
, and
the resulting value after editing the contents of the QLineEdit
widget directly. I decided to:
- Rename
get_filepath()
toget_filepath_from_filedialog()
- Connect the
QLineEdit
widget’seditingFinished()
signal to a new slot namedget_filepath_from_lineedit()
- React whenever
self.filepath
was set to some value, with a new function namedcheck_filepath()
Steps 2 and 3 are shown below:
self.lineEditFilepath.editingFinished.connect(self.get_filepath_from_lineedit())
def get_filepath_from_lineedit(self):
self.filepath = self.lineEditFilepath.text()
self.check_filepath()
The check_filepath()
function was responsible for checking if a recently set filepath
pointed to a valid directory - if so,
then the pending list of selected files (displayed via QListWidget
)
would update with the help of os.walk()
:
def check_filepath(self):
if os.path.isdir(self.filepath):
self.update_list()
else:
# Log error to console
def update_list(self):
"""
Clear listwidget
read files in filepath with os.walk
Add files as new items
"""
self.listWidget.clear()
for root, dirs, files in os.walk(self.filepath):
self.listWidget.addItems(files)
To fulfill the fourth and final requirement, I included two
QRadioButtons
which were set to exclusive
mode by default, meaning that if one radio button were to be enabled, then the other would be disabled and vice versa.
Depending on which radio button was toggled on, an internal flag named copy_files
would be set to true or false:
self.radioButtonRename.toggled.connect(self.set_copy_files)
self.radioButtonCopy.toggled.connect(self.set_copy_files)
def set_copy_files(self):
"""
Toggle between rename/move files mode and copy files mode
"""
if self.radioButtonRename.isChecked():
self.copy_files = False
elif self.radioButtonCopy.isChecked():
self.copy_files = True
Optional Features
As for the optional steps per operation, I relied on os.path.splitext()
to get the extension per filename, in order to
filter on the .png
, .ma
, and .txt
file extensions.
For the string replacement operation, I retrieved a space-delimited string from the corresponding QLineEdit
widget, and
called str.split()
to get an array of
strings to search for. I retrieved one replacement string from the other, corresponding QLineEdit
widget, and looped over
the individual array elements in order to str.replace()
with the new string:
def find_and_replace(files)
"""
If the filename contains the string to find,
then replace with the new string
"""
new_files = []
for file in files:
fname, ext = os.path.splitext(file)
for s in self.strings_to_find:
# replace with the new string
fname = fname.replace(s, self.string_to_replace)
new_file = fname + ext
new_files.append(new_file)
return new_files
For adding a prefix and/or suffix to each filename, an os.path.splitext()
followed by a few string concatenations were sufficient.
For the overwrite flag, I found that the toggled()
and isChecked()
methods could be called upon a
QCheckBox
in addition to the QRadioButton
, as
both Qt widgets inherit from the same QAbstractButton
class.
So, I repurposed the existing code around the rename/copy flag, for reuse with the overwrite flag:
self.checkBoxOverwrite.toggled.connect(self.set_overwrite)
def set_overwrite(self):
"""
Set the overwrite flag
"""
self.overwrite = self.checkBoxOverwrite.isChecked()
QoL Improvements
I had time to add some additional behavior to the batch renamer utility to make it easier to use. I gave the tool the ability to:
- Enable the “Run” button only if a valid starting filepath is set
- Clear (most)
QLineEdit
contents after each batch rename operation - Support comma-delimited strings to search for and replace
- Collapse consecutive whitepace in the strings to search for, into a single space
The consecutive whitespace issue was addressed with re.sub()
and a
regular expression for one or more whitespace characters, or \s+
, as the pattern to match.
I also had time to grant the ability to log color-coded errors and warnings, as well as successful rename operations, in the dialog itself in addition to logging to standard out.
However, one issue did arise as I worked on the logger behavior. I noticed that clicking, and then removing focus from a QLineEdit
widget (by clicking
outside of the edit field) would trigger an extra warning. I decided to address this by detecting if the QLineEdit
contents had changed between focus events, with
help from the textChanged()
signal.
I reworked the logger so that an “invalid filepath” warning would print
under the condition that the current folder had changed. I connected the corresponding QLineEdit
widget’s textChanged()
signal to a new slot
which I named update_filepath()
. Upon activation, the update_filepath()
function would update an internal boolean flag to keep track of changes:
self.lineEditFilepath.textChanged.connect(self.update_filepath)
def update_filepath(self):
"""
Check if lineEdit text for the filepath has changed
"""
if not self.filepath_changed:
self.filepath_changed = True
Demo
Below is a screen recording of the batch renamer utility: