Build a Desktop App with PySide6 #2: Widgets and Layouts — Assembling the Screen

5 min read

In the last post we built an app where a single button filled the window. A real app’s screen needs an input field, a list, and several buttons, each in its proper place, and the arrangement has to adjust naturally when the window is resized. This post covers the two ingredients responsible for that arrangement: widgets and layouts.

It runs in seven parts.

  • #1 What PySide6 is — desktop apps with Qt and Python
  • #2 Widgets and layouts — assembling the screen ← this post
  • #3 Signals and slots — the core of event handling
  • #4 Qt Designer and UI files — draw the screen, then load it
  • #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

By the end of this post, the todo app’s screen skeleton is complete. It does nothing yet, but the structure comes alive the moment we connect signals in the next post.

A catalog of everyday widgets #

Widgets are the parts a screen is built from. Qt ships dozens of them, but an app at the level of a work tool is mostly assembled from these seven.

WidgetPurpose
QLabelDisplays text or an image. Not editable
QLineEditSingle-line text input
QPushButtonA clickable button
QCheckBoxAn on/off checkbox
QComboBoxA dropdown selection list
QTextEditMulti-line text input and display
QSpinBoxNumeric input with up/down arrows

Add QListWidget, which shows a list of items, and you have every ingredient this post needs. You can look up each widget’s detailed options in the official docs when you need them; what matters now is knowing which parts exist.

The trap of coordinate placement, and layout managers #

The most primitive way to place widgets is by coordinates — pinning pixel positions directly, as in button.move(50, 100). This approach collapses quickly in practice. Widgets do not follow when the window is resized, and on systems with different font sizes they overlap or get clipped.

So Qt hands placement to layout managers. You simply add widgets to a layout, and every time the window size changes, the layout recalculates each widget’s position and size. There are four basic layouts.

The four basic layouts
from PySide6.QtWidgets import (
    QVBoxLayout,   # stack top to bottom
    QHBoxLayout,   # line up left to right
    QGridLayout,   # place in a grid of rows and columns
    QFormLayout,   # align label-input pairs in two columns
)

QVBoxLayout and QHBoxLayout are the staples. A calculator screen that needs a grid calls for QGridLayout, and a settings-style form with repeating “name: input” pairs fits QFormLayout.

A taste of QGridLayout and QFormLayout
# Grid placement — addWidget(widget, row, column)
grid = QGridLayout()
grid.addWidget(QPushButton("7"), 0, 0)
grid.addWidget(QPushButton("8"), 0, 1)
grid.addWidget(QPushButton("9"), 0, 2)

# Form placement — addRow(label, input widget)
form = QFormLayout()
form.addRow("Name", QLineEdit())
form.addRow("Port", QSpinBox())

Nesting — layouts inside layouts #

A real screen never ends with a single layout. Sketch the todo app’s screen and it divides like this.

Todo app screen design
┌─────────────────────────────┐
│ [ input field     ] [Add]   │  ← horizontal row (QHBoxLayout)
├─────────────────────────────┤
│                             │
│  todo list (QListWidget)    │  ← center, takes all remaining space
│                             │
├─────────────────────────────┤
│   [Delete Selected] [Clear] │  ← horizontal row (QHBoxLayout)
└─────────────────────────────┘
        ↑ stack the whole thing vertically (QVBoxLayout)

Two horizontal rows and one list, stacked in a vertical layout. Translated into code, it looks like this.

main.py — todo app screen skeleton
import sys

from PySide6.QtWidgets import (
    QApplication, QMainWindow, QWidget,
    QVBoxLayout, QHBoxLayout,
    QLineEdit, QPushButton, QListWidget,
)


class TodoWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Todo Manager")
        self.resize(400, 500)

        # Top input row — input field + add button
        self.todo_input = QLineEdit()
        self.todo_input.setPlaceholderText("Enter a todo")
        self.add_button = QPushButton("Add")
        input_row = QHBoxLayout()
        input_row.addWidget(self.todo_input)
        input_row.addWidget(self.add_button)

        # Center list
        self.todo_list = QListWidget()

        # Bottom button row — right aligned
        self.delete_button = QPushButton("Delete Selected")
        self.clear_button = QPushButton("Clear All")
        button_row = QHBoxLayout()
        button_row.addStretch()
        button_row.addWidget(self.delete_button)
        button_row.addWidget(self.clear_button)

        # Assemble — stack vertically
        layout = QVBoxLayout()
        layout.addLayout(input_row)
        layout.addWidget(self.todo_list)
        layout.addLayout(button_row)

        container = QWidget()
        container.setLayout(layout)
        self.setCentralWidget(container)


if __name__ == "__main__":
    app = QApplication(sys.argv)
    window = TodoWindow()
    window.show()
    sys.exit(app.exec())

Run it and a window matching the sketch appears, and when you stretch the window, the list area grows with it. There are three assembly rules.

  • addWidget for widgets, addLayout for layouts — when nesting a layout inside another layout, use addLayout.
  • A layout must ride on a widget to appear on screen. Only widgets can go in the center of a QMainWindow, so you load the layout onto an empty QWidget with setLayout and pass that widget to setCentralWidget.
  • Keep widgets as attributes on self. Only widgets you hold on to, like self.todo_input, can have signals connected or values read in the next post.

Margins, alignment, and leftover space #

Three tools for polishing the arrangement.

  • stretch — the addStretch() in the bottom button row is a spring that occupies empty space. Because it comes before the buttons, they are pushed to the right.
  • Margins and spacinglayout.setContentsMargins(12, 12, 12, 12) sets the layout’s outer margins, and layout.setSpacing(8) sets the gap between widgets.
  • Size ratios — give a stretch value, as in layout.addWidget(self.todo_list, stretch=1), and that widget takes priority in claiming leftover space when the window grows.
Tip
When a layout does not come out as intended, sketching the structure on paper first is the fastest route. Once you have divided the screen into horizontal rows and vertical stacks, the code becomes a matter of transcribing the drawing.

Wrap-up #

This post comes down to three points.

  • Screens are assembled from widgets as parts and layouts as placement rules; coordinate placement is not used.
  • Real screens are mostly expressed by nesting QHBoxLayout and QVBoxLayout, and a layout rides on an empty QWidget that goes onto the window via setCentralWidget.
  • Use addStretch, margins, and stretch ratios to polish the arrangement.

Right now the todo app does nothing when you click its buttons. In the next post, “Build a Desktop App with PySide6 #3: Signals and Slots — the Core of Event Handling,” we learn Qt’s core event-handling model, signals and slots, and wire real behavior into this screen.

X