Building Professional Desktop Apps in Python with PySide6
PySide6 is the official Qt for Python binding for Qt 6. It delivers a modern, full‑featured GUI toolkit with a permissive LGPL license that allows closed‑source and commercial distribution. Compared with tkinter, PySide6 offers richer widgets, powerful styling, excellent cross‑platform support, and a mature event system.
Installation and version check
pip install -U pyside6
import sys
import PySide6
print(sys.version.split()[0]) # e.g. 3.11.6
print(PySide6.__version__) # e.g. 6.x.y
Core Qt modules you’ll use most
from PySide6 import QtCore, QtGui, QtWidgets
- QtCore: core types, object model, signals/slots, timers, threading
- QtGui: fonts, colors, icons, images, events, painting
- QtWidgets: all the visual widgets and layouts
Your first window
import sys
from PySide6 import QtWidgets, QtGui
app = QtWidgets.QApplication(sys.argv)
root = QtWidgets.QWidget()
root.setWindowTitle("Magic Forest")
root.resize(600, 400)
root.move(700, 500)
# A centered label
message = QtWidgets.QLabel("Marisa Kirisame", parent=root)
font = QtGui.QFont()
font.setFamily("STCaiyun")
font.setPointSize(20)
font.setBold(True)
font.setItalic(True)
message.setFont(font)
# center using geometry information
root_geom = root.geometry()
msg_size = message.sizeHint()
message.move(
(root_geom.width() - msg_size.width()) // 2,
(root_geom.height() - msg_size.height()) // 2,
)
root.show()
sys.exit(app.exec())
Typical application flow:
# 1) create QApplication
# 2) create top-level widget(s)
# 3) add child widgets, set properties/layouts
# 4) show top-level widget
# 5) enter event loop (app.exec())
Encapsulating a window in a class
import sys
from PySide6 import QtWidgets, QtGui
class MainWindow(QtWidgets.QWidget):
def __init__(self):
super().__init__()
self.setup()
def setup(self):
self.setGeometry(400, 300, 600, 500)
self.setWindowTitle("Subterranean Animism")
title = QtWidgets.QLabel("Ancient Song of the Underworld", self)
f = QtGui.QFont("STKaiti", 25)
f.setBold(True)
title.setFont(f)
# center using sizeHint (preferred size)
w, h = self.width(), self.height()
s = title.sizeHint()
title.move((w - s.width()) // 2, (h - s.height()) // 2)
if __name__ == "__main__":
app = QtWidgets.QApplication(sys.argv)
win = MainWindow()
win.show()
sys.exit(app.exec())
QObject essentials: names, dynamic properties, and stylesheets
Every widget is a QObject. You can assign an object name and custom dynamic properties, then target them from Qt stylesheets.
from PySide6.QtCore import QObject
obj = QObject()
obj.setObjectName("summer_festival")
print(obj.objectName()) # summer_festival
obj.setProperty("author", "Sasha")
print(obj.property("author")) # Sasha
print(obj.property("missing")) # None
for p in obj.dynamicPropertyNames():
print(p.data().decode()) # property keys as strings
Application‑wide stylesheet with selectors
import sys
from PySide6.QtWidgets import QApplication, QWidget, QLabel
class Demo(QWidget):
def __init__(self):
super().__init__()
# Apply once at app level
QApplication.instance().setStyleSheet(
"""
QLabel#emphasis { font-size: 20px; color: seagreen; font-weight: 700; }
QLabel[level="warn"] { font-style: italic; }
"""
)
self.init_ui()
def init_ui(self):
self.setGeometry(400, 400, 300, 200)
self.setWindowTitle("Summer Festival")
lbl1 = QLabel("Sasha", self)
lbl1.setObjectName("emphasis")
lbl1.move(40, 50)
lbl2 = QLabel("Breathing World", self)
lbl2.setProperty("level", "warn")
lbl2.move(40, 100)
app = QApplication(sys.argv)
w = Demo()
w.show()
sys.exit(app.exec())
- Use
QLabel#nameto match an object by objectName - Use attribute selectors like
QLabel[level="warn"]to match dynamic properties - Combine selectors:
QLabel#name[level="warn"]
Parent–child relationships and object tree
Widgets form a tree. If a widget has no parent, it’s a top‑level window. Children are clipped by the parent’s bounds and destroyed with the parent.
import sys
from PySide6.QtWidgets import QApplication, QWidget, QLabel
from PySide6.QtCore import QObject
app = QApplication(sys.argv)
root = QWidget()
root.setObjectName("root_window")
c1 = QLabel("one", root); c1.setObjectName("c1")
c2 = QLabel("two", root); c2.setObjectName("c2")
c3 = QLabel("three", c2); c3.setObjectName("c3")
print(c1.parent().objectName()) # root_window
print(c3.parent().objectName()) # c2
print([ch.objectName() for ch in root.children()]) # ['c1', 'c2']
print([ch.objectName() for ch in root.findChildren(QObject)]) # ['c1','c2','c3']
Binding an existing widget to a new parent:
lbl = QLabel("reparent me")
lbl.setParent(root)
Children obey parent geometry and lifetime:
p = QWidget(); p.resize(300, 200); p.setStyleSheet("background: cyan")
q = QWidget(); q.resize(1200, 50); q.setStyleSheet("background: green")
q.setParent(p) # clipped by p
p.show()
app.exec()
Signals and slots
Qt uses signals for state changes and slots for handlers. Any callable can be a slot.
import sys
from PySide6.QtCore import QObject
from PySide6.QtWidgets import QApplication
app = QApplication(sys.argv)
obj = QObject()
obj.setObjectName("demo")
# destroyed signal
obj.destroyed.connect(lambda o=None: print("object destroyed"))
del obj
x = QObject(); x.setObjectName("x")
x.destroyed.connect(lambda o: print(f"destroyed: {o.objectName()}"))
del x
Another builtin signal:
z = QObject()
z.objectNameChanged.connect(lambda name: print(f"renamed to: {name}"))
z.setObjectName("Hey")
Disconnect and blocking:
n = QObject()
slot = lambda s: print("name:", s)
n.objectNameChanged.connect(slot)
n.setObjectName("A")
n.blockSignals(True) # connection kept, emission blocked
n.setObjectName("B")
n.blockSignals(False)
n.setObjectName("C") # prints again
n.objectNameChanged.disconnect() # remove all
n.setObjectName("D") # no output
Object deletion: deleteLater
deleteLater() posts a deferred deletion event; the object is cleaned up on the next event loop iteration. Children are also destroyed.
import sys
from PySide6.QtCore import QObject
from PySide6.QtWidgets import QApplication
app = QApplication(sys.argv)
p = QObject(); p.setObjectName("p"); p.destroyed.connect(lambda: print("p gone"))
c = QObject(p); c.setObjectName("c"); c.destroyed.connect(lambda: print("c gone"))
c.deleteLater() # queued for deletion
print(p.children()[0].objectName()) # still 'c' before loop processes events
app.exec() # now prints: c gone
Timers on QObject
Override timerEvent and start a basic timer.
import sys, time
from PySide6.QtCore import QObject
from PySide6.QtWidgets import QApplication
class T(QObject):
def timerEvent(self, ev):
print("tick", ev.timerId())
app = QApplication(sys.argv)
obj = T()
ident = obj.startTimer(500)
# simulate some delay, then stop
time.sleep(2)
obj.killTimer(ident)
app.exec()
QWidget basics: top‑level vs children
import sys
from PySide6.QtWidgets import QApplication, QWidget
app = QApplication(sys.argv)
w1 = QWidget(); w1.resize(400, 400); w1.setStyleSheet("background: cyan")
w2 = QWidget(w1); w2.resize(200, 200); w2.setStyleSheet("background: orange")
w1.show()
app.exec()
Geometry and coordinates
- Top‑level widgets use screen coordinates
- Child widgets use parent coordinates
- Origin is at the top‑left; +x to the right, +y downward
import sys
from PySide6.QtWidgets import QApplication, QWidget
app = QApplication(sys.argv)
parent = QWidget(); parent.resize(400, 300); parent.move(200, 100)
child = QWidget(parent); child.resize(100, 80); child.move(60, 70)
print(child.x(), child.y()) # 60, 70 (relative to parent)
print(child.pos()) # QPoint
print(parent.size().toTuple()) # (400, 300)
parent.show()
app.exec()
Note: move() for top‑level widgets positions the window frame. resize() sets the client area (not including the title bar).
Fixed sizes and content‑based sizing
import sys
from PySide6.QtWidgets import QApplication, QWidget, QLabel, QPushButton
app = QApplication(sys.argv)
root = QWidget(); root.setFixedSize(500, 300)
lbl = QLabel("Hello", root)
lbl.move(200, 100)
btn = QPushButton("Append", root)
btn.move(200, 150)
def grow():
lbl.setText(lbl.text() + " — PySide6")
lbl.adjustSize() # adapt to new text
btn.clicked.connect(grow)
root.show()
app.exec()
Min/max constraints
w = QWidget()
w.setMinimumSize(320, 200)
w.setMaximumSize(800, 600)
You can also constrain width or height using setMinimumWidth, setMaximumHeight, etc.
Mouse cursors
Set cursor shapes per widget.
import sys
from PySide6.QtWidgets import QApplication, QWidget, QLabel
from PySide6.QtCore import Qt
app = QApplication(sys.argv)
box = QWidget(); box.resize(400, 300)
area = QLabel("Hover me", box)
area.resize(200, 100)
area.setStyleSheet("background: lightcyan")
area.setCursor(Qt.BusyCursor)
box.show()
app.exec()
Custom cursor from an image:
import sys
from PySide6.QtWidgets import QApplication, QWidget, QLabel
from PySide6.QtGui import QPixmap, QCursor
app = QApplication(sys.argv)
root = QWidget(); root.resize(400, 300)
zone = QLabel("Custom cursor", root); zone.resize(220, 120)
zone.setStyleSheet("background: #cff")
pm = QPixmap("cursor.png").scaled(48, 48)
zone.setCursor(QCursor(pm, 0, 0)) # hotspot at (0,0)
root.show()
app.exec()
Reset/get cursor and move pointer:
cur = zone.cursor()
cur.setPos(0, 0) # screen coordinates
zone.unsetCursor()
Event system highlights
Override event handlers on any widget. Common handllers include:
- showEvent, closeEvent, resizeEvent
- enterEvent, leaveEvent, mousePressEvent, mouseReleaseEvent, mouseMoveEvent, mouseDoubleClickEvent
- keyPressEvent, keyReleaseEvent
- focusInEvent, focusOutEvent
- dragEnterEvent, dragLeaveEvent, dragMoveEvent
- paintEvent, changeEvent, contextMenuEvent
Event propagation: if a child accepts an event, it won’t bubble to the parent; call event.ignore() to continue propagation.
import sys
from PySide6 import QtWidgets, QtGui
class Panel(QtWidgets.QWidget):
def __init__(self):
super().__init__()
self.setFixedSize(300, 200)
self.label = Tile(self)
self.label.setText("Click inside me")
self.label.resize(120, 60)
self.label.setStyleSheet("background: palegreen")
def mousePressEvent(self, e: QtGui.QMouseEvent) -> None:
print("panel pressed")
class Tile(QtWidgets.QLabel):
def mousePressEvent(self, e: QtGui.QMouseEvent) -> None:
print("label pressed; forwarding to parent…")
e.ignore() # let parent handle too
app = QtWidgets.QApplication(sys.argv)
win = Panel(); win.show()
sys.exit(app.exec())
Keyboard events and modifiers:
import sys
from PySide6 import QtWidgets, QtGui, QtCore
class KeyDemo(QtWidgets.QWidget):
def keyPressEvent(self, ev: QtGui.QKeyEvent) -> None:
mods = ev.modifiers()
if (mods & QtCore.Qt.ControlModifier) and (mods & QtCore.Qt.ShiftModifier) and ev.key() == QtCore.Qt.Key_S:
print("Ctrl+Shift+S pressed")
app = QtWidgets.QApplication(sys.argv)
win = KeyDemo(); win.resize(400, 200); win.show()
sys.exit(app.exec())
A QLabel does not accept keyboard focus by default. Either grab the keyboard or set a focus policy and give it focus:
import sys
from PySide6 import QtWidgets, QtCore, QtGui
class FocusLabel(QtWidgets.QLabel):
def __init__(self, *a, **k):
super().__init__(*a, **k)
self.setText("Focus me and press Ctrl+K")
self.setStyleSheet("background: #eef")
self.setFocusPolicy(QtCore.Qt.StrongFocus)
def keyPressEvent(self, ev: QtGui.QKeyEvent) -> None:
if (ev.modifiers() & QtCore.Qt.ControlModifier) and ev.key() == QtCore.Qt.Key_K:
print("Ctrl+K in label")
app = QtWidgets.QApplication(sys.argv)
root = QtWidgets.QWidget(); root.resize(400, 200)
lbl = FocusLabel(root); lbl.resize(250, 80); lbl.setFocus() # give focus
root.show()
sys.exit(app.exec())
Z‑order (stacking) control
Control which widget appears on top when they overlap.
import sys
from PySide6.QtWidgets import QApplication, QWidget, QLabel
app = QApplication(sys.argv)
root = QWidget(); root.resize(480, 320)
bottom = QLabel("Bottom", root); bottom.resize(200, 200); bottom.move(100, 100)
bottom.setStyleSheet("background: orange")
top = QLabel("Top", root); top.resize(200, 200); top.move(100, 160)
top.setStyleSheet("background: lightblue")
# place 'top' under 'bottom'
top.stackUnder(bottom)
# alternatively: bottom.raise_(); top.lower()
root.show()
app.exec()
Top‑level window features
Window icon
import sys
from PySide6.QtWidgets import QApplication, QWidget
from PySide6.QtGui import QIcon
app = QApplication(sys.argv)
w = QWidget()
w.setWindowIcon(QIcon("app_icon.png"))
w.show()
app.exec()
Opacity
w = QWidget()
w.setWindowOpacity(0.7) # 0.0 transparent … 1.0 opaque
Window state
from PySide6.QtCore import Qt
w = QWidget()
w.setWindowState(Qt.WindowMaximized)
Window flags (frame/buttons behaviors)
from PySide6.QtCore import Qt
w = QWidget()
# borderless window
w.setWindowFlags(Qt.FramelessWindowHint)
Compose flags as needed, e.g., keep on top (Qt.WindowStaysOnTopHint), disable resize, or show only specific titlebar buttons.
Custom close button on a frameless window:
import sys
from PySide6.QtWidgets import QApplication, QWidget, QPushButton
from PySide6.QtCore import Qt
app = QApplication(sys.argv)
win = QWidget(); win.resize(300, 200)
win.setWindowFlags(Qt.FramelessWindowHint)
close_btn = QPushButton("Close", win)
close_btn.move(20, 20)
close_btn.clicked.connect(win.close)
win.show()
app.exec()
Enable/disable and visibility
btn = QtWidgets.QPushButton("Disabled")
btn.setEnabled(False)
# visibility
btn.setVisible(True) # or btn.show()
btn.setVisible(False) # or btn.hide()
print(btn.isHidden(), btn.isVisible())
Visibility depends on parent; a visible child is not shown if its parent is hidden.
"Modified" marker in window title
Place [*] in the title and toggle the marker with setWindowModified(True/False).
import sys
from PySide6 import QtWidgets
app = QtWidgets.QApplication(sys.argv)
win = QtWidgets.QWidget()
win.setWindowTitle("Document[*]")
mark = QtWidgets.QPushButton("Modify", win)
mark.clicked.connect(lambda: win.setWindowModified(True))
mark.move(20, 20)
win.show()
app.exec()
Tooltips
import sys
from PySide6.QtWidgets import QApplication, QWidget, QLabel
app = QApplication(sys.argv)
w = QWidget(); w.resize(300, 200)
w.setToolTip("This is the window tooltip")
lbl = QLabel("Hover here", w)
lbl.setToolTip("Label tooltip")
lbl.setToolTipDuration(3000) # ms
w.show()
app.exec()
Focus management
import sys
from PySide6 import QtWidgets
app = QtWidgets.QApplication(sys.argv)
win = QtWidgets.QWidget(); win.resize(300, 200)
u = QtWidgets.QLineEdit(win); u.move(40, 40)
p = QtWidgets.QLineEdit(win); p.move(40, 70)
p.setFocus() # programmatically focus second field
win.show()
app.exec()
Control when widgets can receive focus:
from PySide6.QtCore import Qt
p.setFocusPolicy(Qt.TabFocus) # only via Tab
# Qt.ClickFocus, Qt.StrongFocus, Qt.NoFocus are also available
To query the current focus widget inside event handling or timers:
fw = win.focusWidget() # returns a widget or None
win.focusNextChild() # move focus forward
win.focusPreviousChild() # move focus backward
Buttons overview (QAbstractButton family)
- QPushButton: standard push button
- QToolButton: small toolbar‑style button
- QRadioButton: mutually exclusive selection
- QCheckBox: independent toggles
- QCommandLinkButton: command link style
Updating button text on press
import sys
from PySide6 import QtWidgets, QtGui
class CounterButton(QtWidgets.QPushButton):
def __init__(self, *a, **k):
super().__init__(*a, **k)
self.setText("0")
def mousePressEvent(self, ev: QtGui.QMouseEvent) -> None:
super().mousePressEvent(ev)
try:
val = int(self.text())
except ValueError:
val = 0
self.setText(str(val + 1))
app = QtWidgets.QApplication(sys.argv)
root = QtWidgets.QWidget(); root.resize(240, 160)
btn = CounterButton("0", root); btn.setGeometry(60, 50, 60, 40)
root.show()
sys.exit(app.exec())
Button icon
import sys
from PySide6 import QtWidgets, QtGui, QtCore
app = QtWidgets.QApplication(sys.argv)
root = QtWidgets.QWidget(); root.resize(200, 140)
b = QtWidgets.QPushButton(root)
b.setGeometry(60, 40, 80, 60)
b.setIcon(QtGui.QIcon("1.png"))
b.setIconSize(QtCore.QSize(64, 64))
root.show()
app.exec()
Keyboard shortcuts and auto‑repeat
import sys
from PySide6 import QtWidgets
app = QtWidgets.QApplication(sys.argv)
root = QtWidgets.QWidget(); root.resize(240, 160)
b = QtWidgets.QPushButton("Press or Alt+G", root)
b.setGeometry(40, 40, 160, 40)
b.pressed.connect(lambda: print("pressed"))
b.setShortcut("Alt+G")
b.setAutoRepeat(True)
b.setAutoRepeatDelay(1000) # start repeating after 1s
b.setAutoRepeatInterval(2000) # repeat every 2s
root.show()
app.exec()
Pressed state via stylesheet
btn = QtWidgets.QPushButton("Styled")
btn.setStyleSheet("QPushButton:pressed { background-color: cornflowerblue; }")
Checkable and exclusive behavior
import sys
from PySide6 import QtWidgets
app = QtWidgets.QApplication(sys.argv)
root = QtWidgets.QWidget(); root.resize(300, 220)
b1 = QtWidgets.QPushButton("One", root)
b2 = QtWidgets.QPushButton("Two", root)
b3 = QtWidgets.QPushButton("Three", root)
b4 = QtWidgets.QPushButton("Four", root)
for i, b in enumerate((b1,b2,b3,b4)):
b.setGeometry(40, 30 + i*40, 120, 32)
b.setCheckable(True)
# Make first three mutually exclusive (same parent)
for b in (b1, b2, b3):
b.setAutoExclusive(True)
root.show()
app.exec()
A QCheckBox can be made exclusive in the same way, effectively acting like a radio button.
Programmatic clicks and signals
btn.click()emits a click immediatelybtn.animateClick(ms)presses and releases after the delay- Signals:
pressed,released,clicked
btn.clicked.connect(lambda: print("clicked"))
btn.animateClick(500)