LearnPyQt.dev
Comprehensive Guide for Python 3.12 & PyQt6

The 2026 Modern PyQt6 Desktop Development Masterclass

Move beyond simple scripts. In this guide, you will learn how to build a fully-featured, multithreaded Pro Flashcards Studio desktop application. We will master PyQt6 components, complex layout switching via QStackedWidget, SQLite integration, and asynchronous OpenAI Text-to-Speech using QThreads.

Part 1: The PyQt6 Paradigm

1. Why PyQt6?

Python is renowned for data science and backend web frameworks, but when you need a robust, cross-platform Desktop GUI that runs natively on Windows, Mac, and Linux, PyQt6 is the absolute gold standard.

Unlike Tkinter (which looks dated) or Kivy (which is mobile-first), PyQt6 is a set of Python bindings for the massive C++ Qt framework. It gives you access to a massive library of pre-built UI components, hardware-accelerated rendering, multimedia support, and advanced multithreading primitives.

2. Signals & Slots

The core architecture of PyQt6 revolves around Signals and Slots. A Signal is an event (e.g., a button click, a timer finishing, a network request completing). A Slot is the Python function that executes when the signal is emitted.

Python: The Signals Paradigm
from PyQt6.QtWidgets import QPushButton, QApplication, QMainWindow

def on_button_clicked():
    print("Slot executed: The user clicked the button!")

app = QApplication([])
window = QMainWindow()

# 1. Instantiate the widget
btn = QPushButton("Click Me")

# 2. Connect the Signal (clicked) to the Slot (on_button_clicked)
btn.clicked.connect(on_button_clicked)

window.setCentralWidget(btn)
window.show()
app.exec()

3. The Event Loop

Calling app.exec() starts the Qt Event Loop. The Event Loop is an infinite `while` loop running in the background, listening for mouse movements, key presses, and window repaints.

⚠️ The Golden Rule of GUI Dev: Never block the Event Loop. If you run a function that takes 5 seconds (like `time.sleep(5)` or a heavy network request) directly in a slot, the entire application will freeze and the OS will show "Application Not Responding". We solve this later using `QThread`.

Part 2: Architecture & Database

4. Robust SQLite Integration

A production desktop app needs local persistence. We will use Python's built-in sqlite3 library. However, rather than scattering SQL queries everywhere, we wrap it in a dedicated DatabaseManager class to ensure connection safety and UTF-8 handling.

flashcards.py
import sqlite3

class DatabaseManager:
    def __init__(self, db_name="flashcards.db"):
        self.conn = sqlite3.connect(db_name)
        # Using sqlite3.Row allows us to access columns by name (e.g., row['question'])
        # rather than cryptic indices (row[2]). This is a massive DX improvement!
        self.conn.row_factory = sqlite3.Row
        self.init_db()

    def execute(self, query, params=()):
        # Ensure and validate utf-8 encoding before storing strings
        clean_params = []
        for p in params:
            if isinstance(p, str):
                clean_params.devend(p.encode('utf-8', 'ignore').decode('utf-8'))
            else:
                clean_params.devend(p)
                
        # The 'with self.conn' context manager automatically commits the transaction!
        with self.conn:
            return self.conn.execute(query, tuple(clean_params))

    def fetchall(self, query, params=()): 
        return self.execute(query, params).fetchall()
        
    def fetchone(self, query, params=()): 
        return self.execute(query, params).fetchone()

# Global Singleton instantiation
db = DatabaseManager()

5. Data Modeling & Initialization

Inside init_db(), we establish our schema. We need tables for Settings, Categories, Decks, and Questions (the actual flashcards). Notice how we use PRAGMA table_info to handle lightweight schema migrations on the fly.

    def init_db(self):
        with self.conn:
            # Base Tables
            self.conn.execute("CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT)")
            self.conn.execute("CREATE TABLE IF NOT EXISTS categories (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, parent_id INTEGER REFERENCES categories(id) ON DELETE CASCADE)")
            self.conn.execute("CREATE TABLE IF NOT EXISTS decks (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, category_id INTEGER REFERENCES categories(id) ON DELETE CASCADE)")
            self.conn.execute("CREATE TABLE IF NOT EXISTS questions (id INTEGER PRIMARY KEY AUTOINCREMENT, deck_id INTEGER REFERENCES decks(id) ON DELETE CASCADE, question TEXT, answer TEXT, notes TEXT, knowledge_level INTEGER DEFAULT 5, last_studied TIMESTAMP)")
            
            # Lightweight Schema Migrations
            # If the user updates the app to a version with new features, we dynamically add the columns.
            cursor = self.conn.execute("PRAGMA table_info(decks)")
            columns = [r['name'] for r in cursor.fetchall()]
            if 'transition_sound' not in columns: 
                self.conn.execute("ALTER TABLE decks ADD COLUMN transition_sound TEXT")
            if 'icon_path' not in columns: 
                self.conn.execute("ALTER TABLE decks ADD COLUMN icon_path TEXT")

            # Seed the default root category
            c = self.conn.execute("SELECT * FROM categories WHERE name='All'").fetchone()
            if not c: 
                self.conn.execute("INSERT INTO categories (id, name, parent_id) VALUES (1, 'All', NULL)")

Part 3: Building The UI (The Flashcard Studio)

6. Main Window & Splitters

The foundation of a PyQt app is QMainWindow. To create modern side-by-side interfaces (like VSCode or Notion), we use a QSplitter. A Splitter allows the user to click and drag the boundary between widgets to resize them dynamically.

The Sidebar (QTreeWidget)

Displays our nested categories and decks. The tree tracks standard hierarchy visually.

The Content Area (QStackedWidget)

Acts like a deck of cards. It holds multiple full-screen views (Home, Editor, Study Mode) but only shows one at a time via setCurrentIndex().

class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Pro Flashcards Studio")
        self.showMaximized() 

        # We must create a central widget to hold our layouts
        self.central_widget = QWidget()
        self.setCentralWidget(self.central_widget)
        
        # Main layout is horizontal
        self.main_layout = QHBoxLayout(self.central_widget)
        self.main_layout.setContentsMargins(0, 0, 0, 0)

        # 1. Setup the left sidebar
        self.setup_sidebar()
        
        # 2. Setup the right stack (holding our different screens)
        self.setup_stack()
        
        # 3. Create the Splitter and add both halves
        self.splitter = QSplitter(Qt.Orientation.Horizontal)
        self.splitter.addWidget(self.sidebar_widget)
        self.splitter.addWidget(self.stack)
        
        # Give the right side much more default space
        self.splitter.setSizes([350, 1000])
        
        self.main_layout.addWidget(self.splitter)

7. QTableWidget & The Deck Editor

When a user clicks a Deck in the sidebar, we switch the QStackedWidget to index 1 (The Deck Editor). To show hundreds of flashcards cleanly, we use QTableWidget.

    def setup_deck_editor(self):
        layout = QVBoxLayout(self.deck_editor)
        
        # Data grid
        self.q_table = QTableWidget()
        self.q_table.setColumnCount(4)
        self.q_table.setHorizontalHeaderLabels(["Question", "Answer", "Avg Score", "Views"])
        
        # Make Question/Answer columns stretch to fill space
        self.q_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
        self.q_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
        
        # Select entire rows, not individual cells
        self.q_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
        
        # Connect double click signal to open the Card Editor
        self.q_table.itemDoubleClicked.connect(self.edit_question)
        layout.addWidget(self.q_table)

    def load_questions(self):
        self.q_table.setRowCount(0)
        qs = db.fetchall("SELECT * FROM questions WHERE deck_id = ?", (self.current_deck_id,))
        
        for row, q in enumerate(qs):
            q_dict = dict(q) # Cast sqlite3.Row to dict
            self.q_table.insertRow(row)
            self.q_table.setItem(row, 0, QTableWidgetItem(q_dict['question']))
            self.q_table.setItem(row, 1, QTableWidgetItem(q_dict['answer']))
            
            # Store the Database ID secretly in the item's UserRole!
            # This is critical so we know WHICH item to delete/update later.
            self.q_table.item(row, 0).setData(Qt.ItemDataRole.UserRole, q_dict['id'])

8. Full-Screen Card Editor & QTimer

Instead of annoying pop-up dialogs, we provide a full-screen editing experience (Stack index 4). Modern UX demands automatic saving. We achieve this effortlessly using QTimer.

class CardEditorWidget(QWidget):
    # Custom signals we can emit to tell the MainWindow to switch views
    finished = pyqtSignal()
    cancelled = pyqtSignal()

    def __init__(self, parent=None):
        super().__init__(parent)
        self.deck_id, self.q_id = None, None
        
        # The Autosave Timer
        self.autosave_timer = QTimer(self)
        self.autosave_timer.setSingleShot(True) # Only fires once per countdown
        self.autosave_timer.setInterval(1000)   # 1 second
        
        # When timer finishes counting, call perform_autosave
        self.autosave_timer.timeout.connect(self.perform_autosave)
        
        self.setup_ui()

    def on_text_changed(self):
        if not self.is_loading:
            self.save_status_lbl.setText("Typing...")
            # Reset the 1-second countdown every time they type a character!
            # This is classic "Debouncing".
            self.autosave_timer.start()

    def perform_autosave(self):
        q = self.edit_q.toPlainText().strip()
        a = self.edit_a.toPlainText().strip()
        n = self.edit_n.toPlainText().strip()
            
        if self.q_id is None:
            # New card
            cursor = db.execute("INSERT INTO questions (deck_id, question, answer, notes) VALUES (?, ?, ?, ?)", (self.deck_id, q, a, n))
            self.q_id = cursor.lastrowid
        else:
            # Update existing
            db.execute("UPDATE questions SET question=?, answer=?, notes=? WHERE id=?", (q, a, n, self.q_id))
            
        self.save_status_lbl.setText(f"Autosaved at {datetime.now().strftime('%I:%M:%S %p')}")

Part 4: Advanced Features & Multimedia

9. QThread: Non-blocking TTS

If we request OpenAI's TTS API synchronously, the app UI will freeze while waiting for the download. We must offload this to a background thread using QThread.

Crucial Gotcha: A QThread cannot directly update UI elements (like setting text on a QLabel). GUI modifications are strictly limited to the main thread. The worker must emit a pyqtSignal containing the data, which the Main Thread listens to.

from PyQt6.QtCore import QThread, pyqtSignal
from openai import OpenAI
import hashlib, os, tempfile

class TTSWorker(QThread):
    # Define signals that will carry data back to the Main GUI thread
    audio_ready = pyqtSignal(str, int)  # carries (file_path, question_id)
    error_occurred = pyqtSignal(str)

    def __init__(self, text, q_id, model, voice, speed):
        super().__init__()
        self.text, self.q_id, self.model, self.voice, self.speed = text, q_id, model, voice, speed

    def run(self):
        # This function runs completely independently of the GUI Event Loop
        try:
            # Caching strategy so we don't re-download same audio twice
            hash_str = hashlib.md5(f"{self.text}_{self.model}_{self.voice}_{self.speed}".encode('utf-8')).hexdigest()
            tmp_path = os.path.join(tempfile.gettempdir(), f"st_cache_{hash_str}.wav")
            
            if not os.path.exists(tmp_path):
                client = OpenAI(base_url="http://localhost:7788/v1", api_key="not-needed")
                response = client.audio.speech.create(
                    model=self.model, voice=self.voice, input=self.text, response_format="wav", speed=self.speed
                )
                with open(tmp_path, "wb") as f:
                    f.write(response.content)
            
            # SAFE: Emit signal to tell the GUI we are done!
            self.audio_ready.emit(tmp_path, self.q_id)
            
        except Exception as e:
            self.error_occurred.emit(str(e))

10. Study Mode, Audio & Typewriter FX

The StudyWidget is the core of the app. It uses QMediaPlayer to play the audio generated by our thread, and features a "Typewriter" animation for revealing answers incrementally using another QTimer.

from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput
from PyQt6.QtCore import QUrl

class StudyWidget(QWidget):
    def __init__(self, parent=None):
        super().__init__(parent)
        
        # Audio Pipeline Setup
        self.audio_output = QAudioOutput(self)
        self.player = QMediaPlayer(self)
        self.player.setAudioOutput(self.audio_output)
        
        # Typewriter Animation Timer
        self.tw_timer = QTimer(self)
        self.tw_timer.timeout.connect(self.tw_tick)
        self.tw_text, self.tw_idx, self.tw_target_label = "", 0, None

    def start_typewriter(self, text, label):
        self.tw_text, self.tw_idx, self.tw_target_label = text, 0, label
        label.setText("")
        # Fire a tick every 20ms
        self.tw_timer.start(20)

    def tw_tick(self):
        # On every tick, add one more character to the label
        if self.tw_idx < len(self.tw_text):
            self.tw_idx += 1
            self.tw_target_label.setText(self.tw_text[:self.tw_idx])
        else:
            self.tw_timer.stop()

    # Slot that catches the TTSWorker signal
    def on_audio_ready(self, file_path, q_id):
        if not self.current_q or self.current_q['id'] != q_id: return
        self.player.stop()
        self.player.setSource(QUrl.fromLocalFile(file_path))
        self.player.play()

11. Custom Distribution Sliders

We want the user to be able to select multiple categories to study, and then dynamically adjust the percentage of questions drawn from each category. If you slide "Math" up, "Science" and "History" must automatically slide down to compensate, maintaining a sum of 1000.

class WeightDistributionSliders(QWidget):
    def __init__(self, cat_weights, max_total=1000, parent=None):
        super().__init__(parent)
        self.max_total = max_total
        self._updating = False  # Flag to prevent infinite recursion
        
        # ... layout setup ...

    def _on_slider_changed(self, index, new_val):
        # If we are programmatically updating other sliders, ignore signals
        if self._updating: return
        
        self._updating = True
        new_val_float = float(max(0, min(new_val, self.max_total)))
        
        # Calculate sum of all OTHER sliders
        old_others_total = sum(self._values[i] for i in range(len(self._values)) if i != index)
        
        self._values[index] = new_val_float
        new_others_total = float(self.max_total - new_val_float)
        
        # Proportionally scale all other sliders so total sum remains max_total
        for i in range(len(self._values)):
            if i == index: continue
            
            if old_others_total == 0:
                self._values[i] = new_others_total / (len(self._values) - 1)
            else:
                self._values[i] = (self._values[i] / old_others_total) * new_others_total
                
        self._update_ui(active_index=index)
        self._updating = False

Review

12. Top 3 PyQt6 Gotchas

  • Garbage Collection of QThreads

    If you launch a `TTSWorker` inside a local function without saving it to a class variable (`self.worker = TTSWorker(...)`), Python's garbage collector will instantly destroy it when the function ends, even if the thread is running. Always store thread references.

  • Cross-Thread GUI Modification

    Calling `my_label.setText()` from inside the `run()` method of a QThread will cause immediate, silent crashes or segmentation faults in C++. Always emit a signal and handle the `setText()` in the main thread.

  • Late Binding in Lambda Slots

    When generating buttons in a for-loop and attaching signals: `btn.clicked.connect(lambda: self.do_thing(i))`, Python late-binds `i`. Every button will execute using the final value of the loop! Correct fix: `lambda checked, val=i: self.do_thing(val)`.