Build a Desktop App with PySide6 #4: Qt Designer and UI Files — Draw the Screen, Then Load It

7 min read

In “Build a Desktop App with PySide6 #2: Widgets and Layouts — Assembling the Screen” we assembled every layout in code. For a screen with a handful of widgets that approach is fine, but as the form grows it becomes hard to picture the screen just by reading code, and even a small layout tweak means editing several places. Qt provides a dedicated tool for this problem: you draw the screen with your mouse. That tool, Qt Designer, is the subject of this post.

It runs in seven parts.

  • #1 What PySide6 is — desktop apps with Qt and Python
  • #2 Widgets and layouts — assembling the screen
  • #3 Signals and slots — the core of event handling
  • #4 Qt Designer and UI files — draw the screen, then load it ← this post
  • #5 Model/View — connecting data to lists and tables
  • #6 Threads and timers — keeping the UI responsive
  • #7 Packaging and distribution — building executables with PyInstaller

In this post we redraw the todo app screen in Designer, compare the two ways of loading the resulting .ui file into Python code, and finish with a workflow that keeps signal connections in code.

Launching Qt Designer — it is already installed #

Qt Designer needs no separate installation. It comes as a command-line tool when you install PySide6.

Launch Qt Designer
pyside6-designer

On launch, a new-form dialog opens. We are building a standalone window, so choose Main Window. If your screen only needs a single dialog, one of the Dialog templates works too.

The interface — three panels are all you need #

The Designer window looks busy at first, but only three panels matter for actual work.

  • Widget Box (left) — the list of available widgets. Drag them from here onto the form.
  • Object Inspector (top right) — the tree of widgets placed on the form. Parent-child relationships and the layout structure are visible at a glance.
  • Property Editor (bottom right) — edits the properties of the selected widget. The most important property here is objectName.

There is a reason to emphasize objectName. When we later load the .ui file from code, each widget becomes a Python attribute under exactly that objectName. If you leave defaults like pushButton and lineEdit, you cannot tell which button is which in code, so make it a habit to assign a role-revealing name like addButton or todoInput the moment you drop a widget.

Building the form — the todo app screen #

We rebuild the screen we wrote in code in #2, this time in Designer. There are three steps.

  1. Drag a Line Edit from the Widget Box to the top of the form and set its objectName to todoInput.
  2. Place a Push Button next to it, set its objectName to addButton, and set its text property to “Add”.
  3. Place a List Widget below and set its objectName to todoList.

At this point the widgets are floating at absolute coordinates on the form. Resize the window and they do not follow. We need to apply layouts.

Applying layouts and spacers #

Layouts in Designer are the same concepts as QHBoxLayout and QVBoxLayout in code, applied visually.

  1. Select todoInput and addButton together and click Lay Out Horizontally on the toolbar. The two widgets are grouped into a horizontal layout.
  2. Click an empty spot on the form to select the window itself, then click Lay Out Vertically. The horizontal group and todoList are stacked vertically, and the widgets now stretch when you resize the window.

When you need empty space between widgets, drag in a Horizontal Spacer or Vertical Spacer from the Widget Box. Spacers are invisible on screen but absorb leftover space, which lets you build arrangements like pushing a button to the far right.

When you are done, save the form as main_window.ui.

What a .ui file really is — XML #

Open the saved file in an editor and there is nothing exotic about it. It is an XML document holding the widget tree and properties as they are.

Part of main_window.ui
<widget class="QLineEdit" name="todoInput"/>
<widget class="QPushButton" name="addButton">
 <property name="text">
  <string>Add</string>
 </property>
</widget>
<widget class="QListWidget" name="todoList"/>

The key point is that this file contains only screen structure and no behavior. What happens when something is clicked is still the job of your Python code. Because screen and logic are separated at the file level, layout changes happen in Designer and behavior changes happen in code, each independently.

Loading, option 1 — generate code with pyside6-uic #

The first approach converts the .ui file into Python code that you import.

Convert the ui file to Python code
pyside6-uic main_window.ui -o ui_main_window.py

The generated ui_main_window.py contains a Ui_MainWindow class whose setupUi method reproduces the widget tree you drew in Designer. The usage pattern looks like this.

The setupUi pattern
import sys
from PySide6.QtWidgets import QApplication, QMainWindow
from ui_main_window import Ui_MainWindow


class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.ui = Ui_MainWindow()
        self.ui.setupUi(self)
        self.ui.addButton.clicked.connect(self.add_todo)

    def add_todo(self):
        text = self.ui.todoInput.text().strip()
        if text:
            self.ui.todoList.addItem(text)
            self.ui.todoInput.clear()


app = QApplication(sys.argv)
window = MainWindow()
window.show()
app.exec()

The objectName you assigned in Designer becomes an attribute, as in self.ui.addButton. The generated file is import-only, so never edit it directly. When you change the form in Designer, rerun the conversion command to refresh it.

Loading, option 2 — runtime loading with QUiLoader #

The second approach reads the .ui file directly at run time, with no conversion step.

The QUiLoader pattern
import sys
from PySide6.QtWidgets import QApplication
from PySide6.QtUiTools import QUiLoader

app = QApplication(sys.argv)
loader = QUiLoader()
window = loader.load("main_window.ui")
window.addButton.clicked.connect(
    lambda: window.todoList.addItem(window.todoInput.text())
)
window.show()
app.exec()

Edit the file, run again, and the change shows up immediately, so it takes less effort. In exchange, code alone cannot tell which widgets window carries, so you lose editor autocompletion and type checking, and because load returns a finished widget, this approach also fits poorly with the pattern of extending your window class through inheritance.

The two approaches compared #

Aspectpyside6-uic (code generation)QUiLoader (runtime loading)
Conversion stepRequired (rerun on every .ui change)None
Autocompletion and type checksWork against the generated codeNot supported
Extending via class inheritanceNatural with the setupUi patternAwkward
DistributionShip only the generated .pyShip the .ui file alongside
Best fitLarger apps, team projectsQuick experiments, small tools

The remaining parts of this series use the setupUi pattern. An environment with working autocompletion is also better for learning.

Tip
Instead of typing the conversion command by hand every time, register it as a project script. With multiple .ui files, the common approach is a one-line shell script or a Makefile target that converts them all.

Keep signal connections in code #

Designer also has an editing mode that wires signals and slots on screen. Press F4 and the view switches to one where you drag between widgets to connect signals. This series does not use it. Once connection relationships hide inside the .ui file, you can no longer find which button triggers which action through a code search. Keeping the boundary — Designer owns the screen structure, code owns behavior and connections — pays off more as the app grows.

Note
Signals and slots work exactly as covered in #3 even on a Designer-built form. Connecting signals like clicked and textChanged to methods in code with connect stays the same regardless of how the screen was built.

Wrapping up #

Three key points from this post.

  • Qt Designer is the screen-editing tool included with PySide6, and the resulting .ui file is XML holding only screen structure. The objectName becomes the name you use to reach each widget from code.
  • There are two loading approaches. The setupUi pattern via pyside6-uic wins on autocompletion and extensibility, while QUiLoader reads the file directly at the cost of type information.
  • Signal connections belong in code, not in Designer. A clear boundary between screen and behavior keeps the app manageable as it grows.

So far the todo list has pushed plain strings into QListWidget. To carry a due date and a completion flag per item, we need a structure that separates data from display. The next post, “Build a Desktop App with PySide6 #5: Model/View — Connecting Data to Lists and Tables”, covers Qt’s model/view architecture.

X